golang

Функциональные опции в Go

  • четверг, 12 сентября 2024 г. в 00:00:06
https://habr.com/ru/articles/842594/
Функциональные опции в Go. Комикс required? Optional!
Функциональные опции в Go. Комикс required? Optional!

Салют! Меня зовут Дима, я руковожу командой разработки ядра цифровой медицины в Республике Узбекистан. Сегодня я хочу поделиться своими знаниями о паттерне, который может значительно упростить работу, если ты пишешь на Go. Речь пойдет о функциональных опциях.

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

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

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

Функциональные опции в Go. Комикс много аргументов
Функциональные опции в Go. Комикс много аргументов

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

Этот подход не только делает код чище, но и значительно упрощает его поддержку. Добавить новую опцию? Легко! Убрать лишнюю? Тоже не проблема. В общем, штука полезная, особенно когда работаешь с большими и сложными системами.

Достаточно рекламы? Давай теперь разберемся как это работает. На собеседовании могут спросить: “Можно ли сделать аргумент функции в go опциональным?”.

Очевидным ответом будет “нет”, однако это не так. Последним аргументом функции можно передать нумерованную последовательность аргументов (variadic functions). Параметр, принимающий такие аргументы, нужно поставить последним в списке, а перед его типом — многоточие. Если при вызове функции мы не укажем variadic functions, то все сработает корректно, вот пример:

package main

func someFn(arg1 int, arg2 string, moreArgs ...bool) {
   // Какая-то важная логика...
}

func main() {
   someFn(1, "2")
   someFn(1, "2", true)
   someFn(1, "2", true, false)
}

Что это дает, помимо того что на собесе ты можем козырнуть своей внимательностью? Это дает тебе самое важное! Теперь последний аргумент стал опциональным. Более того, он может быть не один.

Давай попробуем представить что тебе предстоит написать HTTP сервер (псевдо-реализация), вот что будет если ты передашь всю конфигурацию в аргументах:

package http

import "time"

type server struct {
   Port       int
   Timeout    time.Duration
   EnableLogs bool
}

func NewServer(port int, timeout time.Duration, enableLogs bool) *server {
   return &server{
       Port:       port,
       Timeout:    timeout,
       EnableLogs: enableLogs,
   }
}
package main

import (
   "time"

   http "habr.com/server"
)

func main() {
   http.NewServer(3000, 3*time.Second, true)
}

Вроде неплохо, но давай перепишем реализацию на функциональные опции:

package http

import "time"

type server struct {
   Port       int
   Timeout    time.Duration
   EnableLogs bool
}

type serverOption func(*server)

func WithPort(port int) serverOption {
   return func(s *server) {
       s.Port = port
   }
}

func WithTimeout(timeout time.Duration) serverOption {
   return func(s *server) {
       s.Timeout = timeout
   }
}

func WithLogs(enabled bool) serverOption {
   return func(s *server) {
       s.EnableLogs = enabled
   }
}

func NewServer(opts ...serverOption) *server {
   server := &server{
       Port:       8080,
       Timeout:    60,
       EnableLogs: false,
   }

   for _, opt := range opts {
       opt(server)
   }

   return server
}

Заметил?

А?

Ну разве не круто?

Да, в пакете http кода стало больше, но как изменился вызов этой функции? Теперь нет необходимости каждый раз передавать все аргументы, для того чтобы запустить сервер. Хочешь изменить порт? Пожалуйста. Нужно включить логирование? Легко. Причем, если запустишь функцию NewServer вообще без параметров, он прекрасно запустится с дефолтными значениями. Это делает код более гибким и удобным в поддержке.

Мало кто задумывается, о том что может понадобиться добавить новый параметр конфигурации. Если не использовать функциональные опции, то придется менять конструктор, и рефакторить все места вызова. Однако ты инженер, и позаботился об этом заранее! Заложив функциональные опции, тебе нужно добавить параметр в структуру, дефолтное значение, новый метод и всё... Готово! Во всех местах, где ты уже вызывал этот метод, всё будет работать корректно!

Из примера выше ты уже наверное понял как это работает, но давай все же пройдемся по реализации. Что нужно сделать?

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

type server struct {
   Port       int
   Timeout    time.Duration
   EnableLogs bool
}

2. Теперь определи тип для опций. Обычно это функция, которая принимает указатель на объект и модифицирует его состояние:

type serverOption func(*Server)

3. Затем создай функции, которые возвращают serverOption. Каждая из них изменяет определенное поле структуры. Например:

func WithPort(port int) serverOption {
   return func(s *server) {
       s.Port = port
   }
}

func WithTimeout(timeout time.Duration) serverOption {
   return func(s *server) {
       s.Timeout = timeout
   }
}

func WithLogs(enabled bool) serverOption {
   return func(s *server) {
       s.EnableLogs = enabled
   }
}

Заметь, что каждая функция возвращает другую функцию, которая принимает указатель на структуру server и изменяет её поля. Это и есть наш основной механизм настройки.

4. Реализуй конструктор. Для этого напиши функцию, которая будет приниматьs функциональные опции. Этот конструктор сначала создаст объект с дефолтными значениями, а затем пройдется в цикле по опциями и модифицирует его.

func NewServer(opts ...serverOption) *server {
   server := &server{
       Port:       8080,
       Timeout:    60,
       EnableLogs: false,
   }

   for _, opt := range opts {
       opt(server)
   }

   return server
}

Здесь ключевой момент: мы принимаем переменное количество аргументов (функциональных опций) и применяем их к объекту server. Если ты ничего не передашь, будут использованы значения по умолчанию.

5. Теперь посмотрим, как это будет работать на практике. Допустим, тебе нужно создать сервер с кастомным портом и включенным логированием:

server := NewServer(WithPort(9090), WithLogs(true))

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

Выше я писал про гибкость. Давай попробуем добавить к нашему серверу поддержку SSL, поэтапно:

1. Добавим новое поле в структуру:

type server struct {
   Port       int
   Timeout    time.Duration
   EnableLogs bool
   WithSSL    bool
}

2. Добавим дефолтное значение (в случае bool необязательно, но для наглядности допишем)

func NewServer(opts ...serverOption) *server {
   server := &server{
       Port:       8080,
       Timeout:    60,
       EnableLogs: false,
       WithSSL:    false,
   }
   // …
   return server
}

3. Добавим новую функциональную опцию

func WithSSL(enabled bool) serverOption {
   return func(s *server) {
       s.WithSSL = enabled
   }
}

Вуаля! Теперь у твоего сервера есть поддержка SSL, и для этого тебе не нужно было менять существующий код.

Давайте подытожим:

+ Гибкость и расширяемость;
+ Чистота кода;
+ Удобство работы с параметрами по умолчанию;
+ Простота тестирования.

Но есть и минусы:
- Увеличивается сложность читаемости кода, особенно в случае большого количества опций;
- Возможные скрытые зависимости опций друг от друга;
- Необходимость дополнительного документирования каждой опции;
- Стремление к переуcложнению простых задач.

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