Источник: 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 для управления кэшированием уже существует для другого конвейера.
Изменив конфигурацию, можно легко получить модифицированный конвейер с этапом кэширования:
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, как показано ниже. Каждый из них будет отвечать за одно действие и сможет переходить либо не переходить к следующему шагу в зависимости от необходимости:
Возможность настройки также позволяет в любое время удалять этап кэширования, не изменяя код. Например, если вы видите, что локальный кэш потребляет слишком много памяти, и хотите от него избавиться, то можете просто изменить файл конфигурации, и запросы смогут поступать напрямую в кэш Redis.
Как это реализовать
Если хотите увидеть весь код, можете перейти сразу к следующему разделу. Здесь же мы рассмотрим его по частям.
Обработка файла конфигурации
Первым шагом идет загрузка конфигурации из соответствующего файла. В этом примере я использую файл YAML, размещенный на gist.
Его формат будет следующим:
root: handler1_name
steps:
handler1_name:
type: handlerImpl1
next: handler2_name
handler2_name:
type: handlerImpl2
Далее первым шагом функции main мы выполняем демаршалинг конфигурации в структуру:
Создание конвейера
Конфигурация готова. С ней мы создадим уже сам конвейер, для чего вызовем NewPipeline из функции main:
pipeline, _ := NewPipeline(pipelineConfig)
Функция NewPipeline будет преобразовывать файл конфигурации в структуру конвейера с инициализированным Handler, готовым к использованию по запросу.
Для этого она преобразует StepType в фактический Handler и вызывает функцию init для каждого Handler с существующим Next Handler при наличии дальнейшего шага:
Сопоставление между StepType и используемым Handler происходит в функции getHandlerFromType():
Создание обработчика
Создается Handler просто и соответствует следующему интерфейсу:
Функция init используется для инициализации следующего Handler для этого шага на основе конфигурации.
Применение фактической логики происходит через вызов функции Execute. В ней принимается решение о необходимости перехода к следующему шагу. Как видите, она получает параметр context. Этот параметр определяется для каждого Handler и может быть расширен на каждом шаге. В данном примере им является *[]string, но он может быть указателем на что угодно.
Пример Handler:
Как видите, в процессе вызова функции Execute() из next Handler мы передаем контекст. При получении результата этого шага мы также можем предпринять какое-либо действие.
Выполнение конвейера
Последним шагом идет вызов функции Execute() самого конвейера:
Она получает шаг root и выполняет связанный с ним Handler. Таким образом, происходит выполнение всей цепочки, потому что каждый Handler знает, что идет следующим.
Код
Ниже приведена полная реализация. В этом примере Handlers просты, но вы можете использовать в них более сложную логику и начать выстраивать цепочку:
Заключение
В статье я показал, каким образом шаблон “цепочка ответственности” в Go может оказаться очень эффективным и стать хорошим способом разделения логики.
К тому же возможность его настройки извне кода позволяет вносить различные изменения при условии, что Handlers будут достаточно обобщенными для перемещения в другие места цепочки.
Из собственного опыта скажу, что если у вас есть один продукт, который вы можете настраивать для разных потребителей, то данная техника в этом отлично поможет. Изначально вы, конечно, потратите время на создание обработчиков, но в итоге у вас получится полноценный набор, который позволит просто выбирать правильную цепочку, ни прибегая к дополнительной разработке.
Читайте также:
Перевод статьи Thomas Poignant: Build a Configurable Chain of Responsibility in Go