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

Цепочка обязанностей или цепочка команд — это шаблон проектирования, позволяющий передавать запросы по цепочке 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 просты, но вы можете использовать в них более сложную логику и начать выстраивать цепочку:

package main

import (
	"fmt"
	"gopkg.in/yaml.v3"
	"io"
	"net/http"
)

// PipelineConfig - это представление конвейера в конфигурации.
type PipelineConfig struct {
	// Steps - это список шагов конвейера.
	Steps map[string]PipelineStep `yaml:"steps"`

	// Root - это имя первого шага в конвейере. Мы начинаем с  // его вызова, после чего он уже продолжит совершение остальных    // вызовов.
	Root string `yaml:"root"`
}

// PipelineStep - это представление шага в конфигурации.
type PipelineStep struct {
	// StepType - это тип Handler, сопоставляемого с           // конфигурацией этого шага. Список доступных типов находится в    // методе getHandlerFromType.
	StepType string `yaml:"type"`

	// Next - это следующий шаг, который нужно вызвать. Этот   // параметр обязателен.
	Next string `yaml:"next"`
}
// ------------------------------------------------------------------------------------------------------

func main() {
	// Считываем и демаршалируем конфигурацию конвейера из файла YAML.
	pipelineConfigFile := "https://gist.githubusercontent.com/thomaspoignant/2499a88c939f654c7e15295194445fd7/raw/" +
		"0c1b1f5c3ba0a0c73c121f8f002317ae87d04b7d/pipeline.yaml"
	resp, _ := http.Get(pipelineConfigFile)
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	var pipelineConfig PipelineConfig
	_ = yaml.Unmarshal(body, &pipelineConfig)

	// Создаем на основе конфигурации конвейер.
	pipeline, _ := NewPipeline(pipelineConfig)

	// Можно задействовать объект context, который будет       // доступен для использования в каждом шаге.
	context:= make([]string,0)
	pipeline.Execute(&context)

	// Проверяем выполненные всеми шагами действия.
	fmt.Println(context)
}

// ------------------------------------------------------------------------------------------------------

// NewPipeline создаст новый Pipeline, готовый к выполнению.
func NewPipeline(pipelineConfig PipelineConfig)(Pipeline, error){
	p := Pipeline{
		steps:         pipelineConfig.Steps,
		root:          pipelineConfig.Root,
	}

	// Получаем обработчиков из конфигурации.
	p.handlers = make(map[string]Handler, len(p.steps))
	for name, step := range p.steps {
		handler, _ := p.getHandlerFromType(step.StepType)
		p.handlers[name] = handler
	}

	// Инициализируем всех обработчиков.
	for name, step := range p.steps {
		err := p.handlers[name].Init(name, step, p.handlers)
		if err != nil {
			return Pipeline{}, fmt.Errorf("impossible to init the step named '%s': %v", name, err)
		}
	}
	// Проверяем существование шага root.
	if _, ok := p.handlers[p.root]; !ok {
		return Pipeline{}, fmt.Errorf("impossible to start with step \"%s\" because it does not exists", p.root)
	}
	return p, nil
}

type Pipeline struct {
	root          string
	steps         map[string]PipelineStep
	handlers      map[string]Handler
}

// getHandlerFromType сопоставляет имя типа обработчика в          // конфигурации с соответствующими обработчиками.
func (p *Pipeline) getHandlerFromType(s string) (Handler, error) {
	// Список сопоставления для обработчиков.
	handlers := map[string]Handler{
		"handlerImpl1":      &HandlerImpl1{},
		"handlerImpl2":      &HandlerImpl2{},
	}

	stepHandler, handlerExists := handlers[s]
	if !handlerExists {
		return nil, fmt.Errorf("impossible to find a matching step handler for %s", s)
	}
	return stepHandler, nil
}

// Получаем и выполняем первый шаг для запуска конвейера поиска.
func (p *Pipeline) Execute(context *[]string) error {
	return p.handlers[p.root].Execute(context)
}

// ------------------------------------------------------------------------------------------------------
// Handler определяет структуру шага.
type Handler interface {
	// Init настраивает шаг из файла конфигурации.
	Init(name string, step PipelineStep, availableHandlers map[string]Handler) error

	// Execute применяет действие текущего шага и переходи к   // следующему.
	Execute(context *[]string) error
}
// ------------------------------------------------------------------------------------------------------
type HandlerImpl1 struct {
	next Handler
}

func (e *HandlerImpl1) Init(name string, step PipelineStep, availableHandlers map[string]Handler) error {
	// Это упрощенная версия метода init. Можете убедиться, что // следующий шаг не отделен, и что обработчик доступен.
	if step.Next != "" {
		e.next = availableHandlers[step.Next]
	}
	return nil
}

func (e *HandlerImpl1) Execute(context *[]string) error{
	// Можно добавить логику до и после вызова следующего шага. 
	*context = append(*context, "HandlerImpl1: before the call")
	if e.next != nil{
		_ = e.next.Execute(context)
	}
	*context = append(*context, "HandlerImpl1: after the call")
	return nil
}
// ------------------------------------------------------------------------------------------------------
type HandlerImpl2 struct {
	next Handler
}

func (e *HandlerImpl2) Init(name string, step PipelineStep, availableHandlers map[string]Handler) error {
	if step.Next != "" { e.next = availableHandlers[step.Next] }
	return nil
}

func (e *HandlerImpl2) Execute(context *[]string) error{
	*context = append(*context, "HandlerImpl2 called")
	if e.next != nil{
		return e.next.Execute(context)
	}
	return nil
}

Заключение

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

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

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

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

Читайте нас в Telegram, VK и Яндекс.Дзен


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

Предыдущая статьяПсихологические принципы для продуктового дизайнера
Следующая статьяВ погоне за продуктивностью, или 9 полезных расширений для браузера