Знакомые незнакомцы или еще раз об использовании паттернов проектирования

    На тему паттернов проектирования написаны тонны статей и издано множество книг. Однако эта тема не перестает быть актуальной, поскольку паттерны позволяют пользоваться нам уже готовыми, проверенными временем решениями, что позволяет сокращать время разработки проектов за счет улучшения качества кода и уменьшения технических долгов.


    С момента появления паттернов проектирования появляются все новые примеры их эффективного использования. И это замечательно. Однако, здесь не обошлось и без ложки дегтя: каждый язык имеет свою специфику. А уж golang — и подавно (в нем нет даже классической модели ООП). Поэтому и возникают вариации шаблонов, применительно к отдельно взятым языкам программирования. В этой статье хотелось бы затронуть тему паттернов проектироавния применительно к golang.


    Decorator


    Шаблон «Декоратор» позволяет подключать к объекту дополнительное поведение (статически или динамически), не влияя на поведение других объектов того же класса. Шаблон часто используется для соблюдения принципа единственной обязанности (Single Responsibility Principle), поскольку позволяет разделить функциональность между классами для решения конкретных задач.

    Всем известный паттерн ДЕКОРАТОР широко используется во многих языках программирования. Так, в golang, на его основе строятся все middleware. Например, профилирование запросов может выглядеть следующим образом:


    func ProfileMiddleware(next http.Handler) http.Handler {
        started := time.Now()
        next.ServeHTTP()
        elapsed := time.Now().Sub(started)
        fmt.Printf("HTTP: elapsed time %d", elapsed)
    }

    В данном случае, интерфейс декоратора — единственная функция. Как правило, к этому и нужно стремиться. Однако, иногда может быть полезен декоратор и с более широким интерфейсом. Рассмотрим к примеру доступ к базе данных (пакет database/sql). Предположим, что нам нужно сделать все то же профилирование запросов к базе данных. В этом случае, нам необходимо:


    • Вместо прямого взаимодействия с базой данных через указатель, нам нужно перейти к взаимодействию через интерфейс (отделить поведение от реализации).
    • Создать обертку для каждого метода, выполняющего SQL запрос к базе данных.

    В результате мы получим декоратор, позволяющий профилировать все запросы к базе данных. Достоинства такого подхода неоспоримы:


    • Сохраняется чистота кода основного компонента доступа к базе данных.
    • Каждый декоратор реализует единственное требование. За счет этого достигается его простота реализации.
    • За счет композиции декораторов мы получаем расширяемую модель, легко адаптирующуюся к нашим потребностям.
    • Получаем нулевой оверхед производительности в продакшен режиме за счет простого отключения профилировщика.

    Так, например, можно реализовать следующие виды декораторов:


    • Heartbeat. Пинговка базы данных для сохранения alive подключения к ней.
    • Profiler. Вывод как тела запроса, так и времени его выполнения.
    • Sniffer. Сбор метрик базы данных.
    • Clone. Клонирование оригинальной базы данных для отладочных целей.

    Как правило, при реализации rich декораторов, не требуется реализация всех методов: достаточно делегировать не реализуемые методы внутреннему объекту.


    Предположим, что нам необходимо реализовать продвинутый логгер для отслеживания DML запросов к базе данных (для отслеживания запросов INSERT/UPDATE/DELETE). В этом случае нам не требуется реализовывать весь интерфейс базы данных — достаточно перекрыть только метод Exec.


    type MyDatabase interface{
        Query(...) (sql.Rows, error)
        QueryRow(...) error
        Exec(query string, args ...interface) error
        Ping() error
    }
    
    type MyExecutor struct {
        MyDatabase
    }
    
    func (e *MyExecutor) Exec(query string, args ...interface) error {
        ...
    }

    Таким образом, мы видим, что создание даже rich декоратора на языке golang не представляет особых сложностей.


    Template method


    Шаблонный метод (англ. Template method) — поведенческий шаблон проектирования, определяющий основу алгоритма и позволяющий наследникам переопределять некоторые шаги алгоритма, не изменяя его структуру в целом.

    Язык golang поддерживает парадигму ООП, поэтому данный шаблон не может быть реализован в чистом виде. Однако, ничто не мешает нам импровизировать используя подходящие функции конструкторы.


    Предположим, нам необходимо определить шаблонный метод со следующей сигнатурой:


    func Method(s string) error

    При декларации нам достаточно использовать поле функционального типа. Для удобства работы с ним, мы можем использовать функцию-обертку, дополняющую вызов недостающим параметром, а для создания конкретного экземпляра — соответствующую функцию-конструктор.


    
    type MyStruct struct {
        MethodImpl func (me *MyStruct, s string) error
    }
    
    // Wrapper for template method
    func (ms *MyStruct) Method(s string) error {
        return ms.MethodImpl(ms, s)
    }
    
    // First constructor 
    func NewStruct1() *MyStruct {
        return &MyStruct{
            MethodImpl: func(me *MyStruct, s string) error {
                // Implementation 1
                ...
            },
        }
    }
    
    // Second constructor
    func NewStruct2() *MyStruct {
        return &MyStruct{
            MethodImpl: func(me *MyStruct, s string) error {
                // Implementation 2
                ...
            },
        }
    }
    
    func main() {
        // Create object instance
        o := NewStruct2()
        // Call the template method
        err := o.Method("hello")
        ...
    }

    Как видно из примера, семантика использования паттерна почти не отличается от классического ООП.


    Adapter


    Шаблон проектирования «Адаптер» позволяет использовать интерфейс существующего класса как другой интерфейс. Этот шаблон часто применяется для обеспечения работы одних классов с другими без изменения их исходного кода.

    Вообще, в качестве адаптеров могут служить, как отдельные функции, так и целые интерфейсы. Если с интерфейсами все более менее понятно и предсказуемо, то с точки зрения отдельных функций есть свои тонкости.


    Предположим, мы пишем некоторый сервис, который имеет некоторое внутреннее API:


    type MyService interface {
        Create(ctx context.Context, order int) (id int, err error)
    }

    Если же нам требуется предоставить публичное API с другим интерфейсом (скажем для работы с gRPC), то мы можем просто использовать функции-адаптеры, которые занимаются конверсией интерфейса. Для этой цели очень удобно использовать замыкания.


    type Endpoint func(ctx context.Context, request interface{}) (interface{}, error)
    
    type CreateRequest struct {
        Order int
    }
    
    type CreateResponse struct {
        ID int,
        Err error
    }
    
    func makeCreateEndpoint(s MyService) Endpoint {
       return func(ctx context.Context, request interface{}) (interface{}, error) {
          // Decode request 
          req := request.(CreateRequest)
          // Call service method
          id, err := s.Create(ctx, req.Order)
          // Encode response
          return CreateResponse{ID: id, Err: err}, nil
       }
    }

    Действие функции makeCreateEndpoint заключается в трех стандартных шагах:


    • декодирование значения
    • вызов метода из внутреннего API, реализуемого сервиса
    • кодирование значения

    По этому принципу построены все endpoints в пакета gokit.


    Visitor


    Шаблон «Посетитель» — это способ отделения алгоритма от структуры объекта, в которой он оперирует. Результат отделения — возможность добавлять новые операции в существующие структуры объектов без их модифицирования. Это один из способов соблюдения принципа открытости/закрытости (open/closed principle).

    Рассмотрим всем известный шаблон посетителя на примере геометрических фигур.


    type Geometry interface {
        Visit(GeometryVisitor) (interface{}, error)
    }
    
    type GeometryVisitor interface {
        VisitPoint(p *Point) (interface{}, error)
        VisitLine(l *Line) (interface{}, error)
        VisitCircle(c *Circle) (interface{}, error)
    }
    
    type Point struct{
        X, Y float32
    }
    
    func (point *Point) Visit(v GeometryVisitor) (interface{}, error) {
        return v.VisitPoint(point)
    }
    
    type Line struct{
        X1, Y1 float32
        X2, Y2 float32
    }
    
    func (line *Line) Visit(v GeometryVisitor) (interface{}, error) {
        return v.VisitLine(line)
    }
    
    type Circle struct{
        X, Y, R float32
    }
    
    func (circle *Circle) Visit(v GeometryVisitor) (interface{}, error) {
        return v.VisitCircle(circle)
    }

    Предположим, что мы хотим написать стратегию расчета дистанции от заданной точки до указанной фигуры.


    type DistanceStrategy struct {
        X, Y float32
    }
    
    func (s *DistanceStrategy) VisitPoint(p *Point) (interface{}, error) {
        // Evaluate distance from point(X, Y) to point p
    }
    
    func (s *DistanceStrategy) VisitLine(l *Line) (interface{}, error) {
        // Evaluate distance from point(X, Y) to line l 
    }
    
    func (s *DistanceStrategy) VisitCircle(c *Circle) (interface{}, error) {
        // Evaluate distance from point(X, Y) to circle c   
    }
    
    func main() {
        s := &DistanceStrategy{X: 1, Y: 2}
        p := &Point{X: 3, Y: 4}
        res, err := p.Visit(s)
        if err != nil {
            panic(err)
        }
        fmt.Printf("Distance is %g", res.(float32))
    }

    Аналогично мы можем реализовать и другие, необходимые нам стратегии:


    • Протяженность по объекта по вертикали
    • Протяженность объекта по горизонтали
    • Построение минимального охватывающего квадрата (MBR)
    • Другие, необходимые нам примитивы.

    Причем, определенные ранее фигуры (Point, Line, Circle...) ничего не знают об этих стратегиях. Единственные их знания ограничиваются интерфейсом GeometryVisitor. Это позволяет изолировать их в отдельный пакет.


    В свое время, работая над картографическим проектом, у меня стояла задача написать функцию определения расстояния между двумя произвольными географическими объектами. Решения были самые разные, однако все они были недостаточно эффективными и элегантным. Рассматривая как-то паттерн Visitor, я обратил внимание на то, что он служит для селекции целевого метода и чем-то напоминает отдельный шаг рекурсии, который, как известно, упрощает задачу. Это натолкнуло меня на мысль использования Double Visitor. Представьте мое удивление, когда я обнаружил, что подобный подход вообще не упоминается на просторах Интернета.


    type geometryStrategy struct{
        G Geometry
    }
    
    func (s *geometryStrategy) VisitPoint(p *Point) (interface{}, error) {
        return s.G.Visit(&pointStrategy{Point: p}) 
    }
    
    func (d *geometryStrategy) VisitLine(l *Line) (interface{}, error) {
        return s.G.Visit(&lineStrategy{Line: l})    
    }
    
    func (d *geometryStrategy) VisitCircle(c *Circle) (interface{}, error) {
        return s.G.Visit(&circleStrategy{Circle: c})        
    }
    
    type pointStrategy struct{
        *Point
    }
    
    func (point *pointStrategy) Visit(p *Point) (interface{}, error) {
        // Evaluate distance between point and p
    }
    
    func (point *pointStrategy) Visit(l *Line) (interface{}, error) {
        // Evaluate distance between point and l
    }
    
    func (point *pointStrategy) Visit(c *Circle) (interface{}, error) {
        // Evaluate distance between point and c
    }
    
    type lineStrategy struct {
        *Line
    }
    
    func (line *lineStrategy) Visit(p *Point) (interface{}, error) {
        // Evaluate distance between line and p
    }
    
    func (line *lineStrategy) Visit(l *Line) (interface{}, error) {
        // Evaluate distance between line and l
    }
    
    func (line *lineStrategy) Visit(c *Circle) (interface{}, error) {
        // Evaluate distance between line and c
    }
    
    type circleStrategy struct {
        *Circle
    }
    
    func (circle *circleStrategy) Visit(p *Point) (interface{}, error) {
        // Evaluate distance between circle and p
    }
    
    func (circle *circleStrategy) Visit(l *Line) (interface{}, error) {
        // Evaluate distance between circle and l
    }
    
    func (circle *circleStrategy) Visit(c *Circle) (interface{}, error) {
        // Evaluate distance between circle and c
    }
    
    func Distance(a, b Geometry) (float32, error) {
        return a.Visit(&geometryStrategy{G: b})
    }

    Таким образом, мы построили двухуровневый селективный механизм, который в результате своей работы вызовет соответствующий метод расчета дистанции между двумя примитивами. Нам остается только написать эти методы и цель достигнута. Вот так элегантно недетирминированная задача может быть сведена к ряду элементарных функций.


    Заключение


    Несмотря на то, что в golang отсутствует классическое ООП, в языке вырабатывается собственный диалект паттернов, играющих на сильных сторонах языка. Эти шаблоны проходят стандартный путь от отрицания до всеобщего признания и со временем становятся best practics.


    Если у уважаемых хаброжителей есть какие-либо свои мысли по шаблонам, прошу не стесняться и высказывать свои соображения по этому поводу.

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 7

      0
      Действительно, между декоратором и прокси получается не всегда четкая граница. Однако можно выделить следующие признаки присущие декоратору:
      * Декоратор полностью повторяет интерфейс декорируемого объекта.
      * Декоратор не обладает полнотой знаний о декорируемом объекте.
      * При создании декоратора, ему всегда передается декорируемый объект (прокси всю работу выполняет сам).

      Данный вопрос поднимался и в других местах.
        0
        Декоратор полностью повторяет интерфейс декорируемого объекта.
        Я не очень понял, что есть интерфейс в таком вот контексте, поясните, пожалуйста.

        Да и весь пример с интерфейсом MyDatabase не понятен, честно сказать, в отличие от декоратора HTTP-обработчика. Как именно происходит логирование в этом случае?
          +1
          Структура MyExecutor ВКЛЮЧАЕТ в себя rich интерфейс MyDatabase, а следовательно, она реализует этот интерфейс. У нас же остается возможность переопределить часть (или все) методы этого интерфейса. Здесь мы перекрываем метод Exec, и заворачиваем в него вызов базового интерфейса. В результате, мы расширяем поведение прототипа, дополняя его профилированием/логгированием запроса.
            0
            Век живи. Не знал про такую композицию интерфейсов. Держите плюс.
        0
        Предположим, что нам нужно сделать все то же профилирование запросов к базе данных. В этом случае, нам необходимо:

        Вместо прямого взаимодействия с базой данных через указатель, нам нужно перейти к взаимодействию через интерфейс (отделить поведение от реализации).
        Создать обертку для каждого метода, выполняющего SQL запрос к базе данных.


        Но разве это не является прокси, а не декоратором?
          0
          да и в первом случае скорее цепочка обязанностей
            0
            Википедия говорит следующее: в шаблоне «Цепочка обязанностей» сообщения в системе обрабатываются по схеме «обработай сам либо перешли другому», то есть одни сообщения обрабатываются на том уровне, где они получены, а другие пересылаются объектам иного уровня.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое