Функциональные опции в 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ложнению простых задач.

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

P.S. Спасибо Батыру Ширматову за мемасы ;-)

Комментарии (27)


  1. a-tk
    11.09.2024 16:14
    +2

    А тулинг от такого как быстро суёт голову в песок?


    1. siberianlaika
      11.09.2024 16:14
      +1

      С тулингом тут нет проблем. Вообще паттерн functional options старая штука,в go в коде разных проектов встречается.


    1. Desprit
      11.09.2024 16:14
      +2

      А в чем же здесь проблема для тулинга? server := NewServer(WithPort(9090), WithLogs(true)) здесь WithLogs это ведь просто функция, тулинг прекрасно справляется. Или вы не про language server и всякие линтеры?


      1. mayorovp
        11.09.2024 16:14
        +1

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


      1. a-tk
        11.09.2024 16:14

        Проблема в том, чтобы тулинг позволил угадать, что ещё туда передать можно.


  1. anayks
    11.09.2024 16:14
    +1

    ... или паттерн "строитель" и даже ничего придумывать не нужно


    1. qeeveex
      11.09.2024 16:14

      Строитель нарушает SRP.

      Так же, по остальным принципам, внутреннее состояние из вне можно определять только при создании объекта, т.е. через конструктор.


      1. mayorovp
        11.09.2024 16:14

        И в чём же заключается это нарушение?


        1. qeeveex
          11.09.2024 16:14

          Отвечает за создание и конфигурацию объекта.

          Если бизнес-логика создания объектов изменяется, вам придётся менять сам строитель, что увеличивает количество причин для изменения этого класса.


          1. mayorovp
            11.09.2024 16:14

            Это одна задача, а не две, потому что объект нельзя создать несконфигурированным.

            И какую ещё причину изменения билдера вы видите, помимо изменения, э-э-э, бизнес-логики создания объекта?


      1. a-tk
        11.09.2024 16:14

        Билдер может формировать промежуточное внутреннее представление, из которого потом целиком собирается объект. Совсем не обязательно чтобы задачей билдера было изменение собираемого объекта.

        Но в таком случае было бы неплохо иметь интерфейс билдера во fluent стиле.


  1. Kahelman
    11.09.2024 16:14
    +3

    Вот только как потом понять какие параметры в конструктор передать можно?

    И почему -бы просто не создать структуру serverOptions и передать ее в конструктор?
    Вам ее так и так создавать надо, но в «традиционном варианте» есть описание структуры с дефолтными полями и комментариями.

    Надо посмотреть какие параметры есть - прочитали описание.

    Попытались добавить параметр которого нет - IDE/ компилятор вас тормознёт.

    В итоге все- равно придётся параметры из конфиг файла читать - для этого же специально flags завезли.

    Чтобы быстро и просто структуры из конфигуратор инициализировать.


    1. Sly_tom_cat
      11.09.2024 16:14
      +1

      flags - это для параметров командной строки.
      В контейнерной теме принято через переменные окружения параметры задавать и там как ни крути получится `var := cmp.Or(os.Getenv(EnvName), EnvDefault)`

      Но статья ни разу не про это.

      А вот структуру передать в конструктор - добавляет нюансов с тем, что в этой структуре считать не инициализированными значениями (т.е. где применять дефолты). Самое замечательное это bool - там дефолт и одно из двух значений..... остается только *bool использовать, что бы значением nul указывать на то, что нужно проставить дефолт. Но, если так со всеми полями поступить то, код превратится в изрядную портянку однотипных проверок на nul.


      1. Finesse
        11.09.2024 16:14
        +1

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

        Можно сделать для структуры опций конструктор, который будет устанавливать дефолтные значения, а прикладной код будет менять те, которые ему интересны:

        // Библиотека
        
        type serverOptions struct {
            Port       int
            Timeout    time.Duration
            EnableLogs bool
        }
        
        func NewServerOptions() *serverOptions {
            return &serverOptions{
                Port:       8080,
                Timeout:    60,
                EnableLogs: false,
            }
        }
        
        func NewServer(opts *serverOptions) *server {
            // ...
        }
        
        // Прикладной код
        
        serverOptions := NewServerOptions()
        serverOptions.Port = 9090
        serverOptions.EnableLogs = true
        server := NewServer(serverOptions)

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

        // Библиотека
        
        type ServerOptions struct {
            Port       int
            Timeout    time.Duration
            EnableLogs bool
        }
        
        type serverConfig func(*ServerOptions)
        
        func NewServer(configure serverConfig) *server {
            options := &ServerOptions{
                Port:       8080,
                Timeout:    60,
                EnableLogs: false,
            }
            configure(options)
        
            return createServer(options)
        }
        
        func createServer(options *ServerOptions) *server {
            // ...
        }
        
        // Прикладной код
        
        server := NewServer(func (options *ServerOptions) {
            options.Port = 9090
            options.EnableLogs = true
        })


    1. qeeveex
      11.09.2024 16:14

      Такой подход тоже имеет право на существование. Используйте наиболее подходящий.


  1. gohrytt
    11.09.2024 16:14

    А можно ещё проще

    type Port int
    
    func NewHTTPServer(options ...any) *HTTPServer {
      server := new(HTTPServer)
    
      for _, option := range options {
        switch typed := option.(type) {
          case int:
            server.port = typed
        }
      }
    }
    

    Лапши меньше, переносов контекстов меньше, функциональность 1 в 1.


    1. Sly_tom_cat
      11.09.2024 16:14
      +2

      Только возникает некоторое количество "НО"...
      Если у меня больше одного параметра типа int?


    1. Desprit
      11.09.2024 16:14
      +2

      Эм, и как понять какие именно опции он принимает?


      1. a-tk
        11.09.2024 16:14

        Очевидно, почитать документацию... Ой


    1. mrobespierre
      11.09.2024 16:14

      В приличном месте такое использование any никогда ревью не пройдёт. Посмотрите как надо: https://cs.opensource.google/go/go/+/refs/tags/go1.23.1:src/encoding/json/stream.go;l=289
      И да, в вашем примере лапши будет гораздо больше, ведь вам же надо перебирать все возможные типы для всех возможных опций. В Go используя any вы теряете информацию о типе, но не получаете ничего. Если тип переменной известен - никогда не передавайте её как any, это просто не имеет смысла.


  1. Sly_tom_cat
    11.09.2024 16:14

    Спасибо за статью, сам натыкался на эти опции в библиотеках. Первое - начинаешь лазить по исходникам и искать как нужную опцию передать. Доки иногда не помогают т.к. в коде скудно с комментариями (док_стрингами точнее). Связь между опциями - тоже досталяет.... В результате мне как пользователю 2-й и 3-й минус перевешивают все плюсы. Уже пару раз они меня подталкивали поискать альтернативу библиотеке где используется этот паттерн.


  1. olivera507224
    11.09.2024 16:14
    +1

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


    1. Finesse
      11.09.2024 16:14

      пройдя путём знатного пердолинга

      Или комбинируя вызовы родных функций конфигурации


      1. olivera507224
        11.09.2024 16:14

        Это в случае, если они есть. А если очень нужно задать значение приватного поля, но нет соответствующего сеттера - пердолинга не избежать.


  1. Fardeadok
    11.09.2024 16:14
    +1

    Статья была как изменить одно значение в бооольшом конфиге и нам предложили передавать в конструктор миллион вызовов функций. Если параметров сотня то желаю автору удачи

    Вот еще тривиальные способы:

    server.Port(777).Timeout(30)

    Server.set("port", 777)

    server.Set("port",777).Set('"timeout",30).Set()

    SetPort(server, port,777)

    И даже так: cfg := server.cfg()

    cfg.Port = 777


    1. Finesse
      11.09.2024 16:14

      Чем ваш пример принципиально лучше? Так же как в статье, у вас каждый не дефолтный параметр требует 1 вызов функции или 1 присвоение.


      1. mayorovp
        11.09.2024 16:14
        +2

        Наличием контекстных подсказок он лучше (первый и последний варианты)