Найти в Дзене
Nuances of programming

Создаем настраиваемую цепочку обязанностей в Go

Источник: Nuances of Programming Цепочка обязанностей или цепочка команд — это шаблон проектирования, позволяющий передавать запросы по цепочке Handlers. Каждый Handler решает, нужно ли обработать и расширить запрос или же передать его следующему Handler. Это позволяет добиться хорошей изоляции между каждым шагом и избежать наличия бизнес-логики посреди технической логики. Это также дает возможность скорректировать порядок цепочки, если вдруг потребуется изменить поведение приложения. Звучит интересно, но зачем нам нужна настраиваемость? Причина лежит в возможности быстрого изменения поведения приложения без развертывания кода. Предположим, что у нас в сервисе есть набор Handlers: Как видите, здесь простой конвейер, где мы связываем Handlers для обработки запроса. Его можно легко представить подобной конфигурацией: root: step1
steps:
step1:
type: handlerImpl1
next: step2 step2:
type: handlerImpl2
next: step3 step3:
type: handlerImpl3 Но, предположим, что мы находи
Оглавление

Источник: Nuances of Programming

Цепочка обязанностей или цепочка команд — это шаблон проектирования, позволяющий передавать запросы по цепочке Handlers. Каждый Handler решает, нужно ли обработать и расширить запрос или же передать его следующему Handler.

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

Звучит интересно, но зачем нам нужна настраиваемость?

Причина лежит в возможности быстрого изменения поведения приложения без развертывания кода.

Предположим, что у нас в сервисе есть набор Handlers:

Начальный конвейер.
Начальный конвейер.

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

root: step1
steps:
step1:
type: handlerImpl1
next: step2

step2:
type: handlerImpl2
next: step3

step3:
type: handlerImpl3

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

Изменив конфигурацию, можно легко получить модифицированный конвейер с этапом кэширования:

-3

root: step1
steps:
step1:
type: handlerImpl1
next:
step2

step2:
type: handlerImpl2
next:
step4

step3:
type: handlerImpl3

step4:
type: RewriteHandler
next:
step3

Реальный пример использования

Представим, что создаем поисковый API. Базово он просто получает запрос поиска и отвечает на него.

Этот API находится на вершине Elasticsearch, поэтому по факту мы вызываем ES напрямую из API (очень просто). Спустя какое-то время вы видите, что некоторые наиболее популярные поисковые запросы происходят слишком часто, в связи с чем решаете добавить перед вызовом ES кэш Redis. Давайте также предположим, что вы хотите дополнительно повысить скорость и создать локальный кэш в приложении, чтобы отвечать на эти популярные запросы еще быстрее.

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

-4

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

Как это реализовать

Если хотите увидеть весь код, можете перейти сразу к следующему разделу. Здесь же мы рассмотрим его по частям.

Обработка файла конфигурации

Первым шагом идет загрузка конфигурации из соответствующего файла. В этом примере я использую файл YAML, размещенный на gist.

Его формат будет следующим:

root: handler1_name
steps:
handler1_name:
type: handlerImpl1
next: handler2_name

handler2_name:
type: handlerImpl2

Открыть весь gist
Открыть весь gist

Далее первым шагом функции main мы выполняем демаршалинг конфигурации в структуру:

Открыть весь gist
Открыть весь gist

Создание конвейера

Конфигурация готова. С ней мы создадим уже сам конвейер, для чего вызовем NewPipeline из функции main:

pipeline, _ := NewPipeline(pipelineConfig)

Функция NewPipeline будет преобразовывать файл конфигурации в структуру конвейера с инициализированным Handler, готовым к использованию по запросу.

Для этого она преобразует StepType в фактический Handler и вызывает функцию init для каждого Handler с существующим Next Handler при наличии дальнейшего шага:

Открыть весь gist
Открыть весь gist

Сопоставление между StepType и используемым Handler происходит в функции getHandlerFromType():

Открыть весь gist
Открыть весь gist

Создание обработчика

Создается Handler просто и соответствует следующему интерфейсу:

Открыть весь gist
Открыть весь gist

Функция init используется для инициализации следующего Handler для этого шага на основе конфигурации.

Применение фактической логики происходит через вызов функции Execute. В ней принимается решение о необходимости перехода к следующему шагу. Как видите, она получает параметр context. Этот параметр определяется для каждого Handler и может быть расширен на каждом шаге. В данном примере им является *[]string, но он может быть указателем на что угодно.

Пример Handler:

Открыть весь gist
Открыть весь gist

Как видите, в процессе вызова функции Execute() из next Handler мы передаем контекст. При получении результата этого шага мы также можем предпринять какое-либо действие.

Выполнение конвейера

Последним шагом идет вызов функции Execute() самого конвейера:

Открыть весь gist
Открыть весь gist

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

Код

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

Заключение

В статье я показал, каким образом шаблон “цепочка ответственности” в Go может оказаться очень эффективным и стать хорошим способом разделения логики.

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

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

Читайте также:

Читайте нас в Telegram, VK

Перевод статьи Thomas Poignant: Build a Configurable Chain of Responsibility in Go