На тему паттернов проектирования написаны тонны статей и издано множество книг. Однако эта тема не перестает быть актуальной, поскольку паттерны позволяют пользоваться нам уже готовыми, проверенными временем решениями, что позволяет сокращать время разработки проектов за счет улучшения качества кода и уменьшения технических долгов.
С момента появления паттернов проектирования появляются все новые примеры их эффективного использования. И это замечательно. Однако, здесь не обошлось и без ложки дегтя: каждый язык имеет свою специфику. А уж 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.
Если у уважаемых хаброжителей есть какие-либо свои мысли по шаблонам, прошу не стесняться и высказывать свои соображения по этому поводу.
Автор: adverax