Салют! Меня зовут Дима, я руковожу командой разработки ядра цифровой медицины в Республике Узбекистан. Сегодня я хочу поделиться своими знаниями о паттерне, который может значительно упростить работу, если ты пишешь на Go. Речь пойдет о функциональных опциях.
Поначалу это может показаться немного сложным, но на самом деле идея и реализация очень просты. Поверь, как только ты разберешься, твой код станет немного гибче и проще.
К сожалению в Go нет богатого функционала управления “обязательными” и “необязательными” аргументами функции, также нет приятного способа присваивания дефолтных значений этим аргументам, однако есть небольшой лайфхак, об этом сегодня и пойдет речь.
Представь что у тебя есть функция, у которой много параметров для конфигурации. Как обычно пишутся такие функции? Ты создаешь структуру, пихаешь в неё все имеющиеся параметры, затем пишешь для неё конструктор, в который также суешь все эти параметры в виде аргументов. Теперь при вызове функции приходится каждый раз прописывать одни и те же аргументы для конфигурации. Это может быстро превратиться в кошмар, особенно если этих параметров становится 10, 15 или даже 20. Запутаться или ошибиться в таком случае очень легко, так как функция превращается в свалку аргументов.
Функциональные опции - это паттерн, который поможет тебе навести порядок в хаосе аргументов. Он позволяет передавать параметры по мере необходимости. В теле конструктора задаются дефолтные значения, а затем с помощью переданных функций они модифицируются.
Этот подход не только делает код чище, но и значительно упрощает его поддержку. Добавить новую опцию? Легко! Убрать лишнюю? Тоже не проблема. В общем, штука полезная, особенно когда работаешь с большими и сложными системами.
Достаточно рекламы? Давай теперь разберемся как это работает. На собеседовании могут спросить: “Можно ли сделать аргумент функции в 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. Спасибо Батыру Ширматову за мемасы ;-)
Комментарии (40)
anayks
11.09.2024 16:14+2... или паттерн "строитель" и даже ничего придумывать не нужно
qeeveex
11.09.2024 16:14Строитель нарушает SRP.
Так же, по остальным принципам, внутреннее состояние из вне можно определять только при создании объекта, т.е. через конструктор.
mayorovp
11.09.2024 16:14И в чём же заключается это нарушение?
qeeveex
11.09.2024 16:14Отвечает за создание и конфигурацию объекта.
Если бизнес-логика создания объектов изменяется, вам придётся менять сам строитель, что увеличивает количество причин для изменения этого класса.
mayorovp
11.09.2024 16:14+1Это одна задача, а не две, потому что объект нельзя создать несконфигурированным.
И какую ещё причину изменения билдера вы видите, помимо изменения, э-э-э, бизнес-логики создания объекта?
a-tk
11.09.2024 16:14Билдер может формировать промежуточное внутреннее представление, из которого потом целиком собирается объект. Совсем не обязательно чтобы задачей билдера было изменение собираемого объекта.
Но в таком случае было бы неплохо иметь интерфейс билдера во fluent стиле.
Kahelman
11.09.2024 16:14+6Вот только как потом понять какие параметры в конструктор передать можно?
И почему -бы просто не создать структуру serverOptions и передать ее в конструктор?
Вам ее так и так создавать надо, но в «традиционном варианте» есть описание структуры с дефолтными полями и комментариями.Надо посмотреть какие параметры есть - прочитали описание.
Попытались добавить параметр которого нет - IDE/ компилятор вас тормознёт.
В итоге все- равно придётся параметры из конфиг файла читать - для этого же специально flags завезли.
Чтобы быстро и просто структуры из конфигуратор инициализировать.
Sly_tom_cat
11.09.2024 16:14+1flags - это для параметров командной строки.
В контейнерной теме принято через переменные окружения параметры задавать и там как ни крути получится `var := cmp.Or(os.Getenv(EnvName), EnvDefault)`
Но статья ни разу не про это.
А вот структуру передать в конструктор - добавляет нюансов с тем, что в этой структуре считать не инициализированными значениями (т.е. где применять дефолты). Самое замечательное это bool - там дефолт и одно из двух значений..... остается только *bool использовать, что бы значением nul указывать на то, что нужно проставить дефолт. Но, если так со всеми полями поступить то, код превратится в изрядную портянку однотипных проверок на nul.Finesse
11.09.2024 16:14+3добавляет нюансов с тем, что в этой структуре считать не инициализированными значениями (т.е. где применять дефолты)
Можно сделать для структуры опций конструктор, который будет устанавливать дефолтные значения, а прикладной код будет менять те, которые ему интересны:
// Библиотека 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 })
Sly_tom_cat
11.09.2024 16:14А в чем тут выигрыш по сравнению с функциональными опциями? По моему: те же яйца, вид сбоку.
qeeveex
11.09.2024 16:14Такой подход тоже имеет право на существование. Используйте наиболее подходящий.
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.
Sly_tom_cat
11.09.2024 16:14+4Только возникает некоторое количество "НО"...
Если у меня больше одного параметра типа int?
mrobespierre
11.09.2024 16:14+1В приличном месте такое использование any никогда ревью не пройдёт. Посмотрите как надо: https://cs.opensource.google/go/go/+/refs/tags/go1.23.1:src/encoding/json/stream.go;l=289
И да, в вашем примере лапши будет гораздо больше, ведь вам же надо перебирать все возможные типы для всех возможных опций. В Go используя any вы теряете информацию о типе, но не получаете ничего. Если тип переменной известен - никогда не передавайте её как any, это просто не имеет смысла.
Sly_tom_cat
11.09.2024 16:14Спасибо за статью, сам натыкался на эти опции в библиотеках. Первое - начинаешь лазить по исходникам и искать как нужную опцию передать. Доки иногда не помогают т.к. в коде скудно с комментариями (док_стрингами точнее). Связь между опциями - тоже досталяет.... В результате мне как пользователю 2-й и 3-й минус перевешивают все плюсы. Уже пару раз они меня подталкивали поискать альтернативу библиотеке где используется этот паттерн.
olivera507224
11.09.2024 16:14+1В статье пропущен один важный, на мой взгляд, момент, однозначно относящийся к плюсам. Все эти опции, принимаемые конструктором - по сути просто функции, манипулирующие целевым объектом. При желании пользователь может передать в конструктор собственную функцию подходящей сигнатуры и самостоятельно в ней задать необходимые значения, при условии, конечно, что поля объекта публичные (хотя, в целом, и приватные поля можно поменять, пройдя путём знатного пердолинга).
Finesse
11.09.2024 16:14пройдя путём знатного пердолинга
Или комбинируя вызовы родных функций конфигурации
olivera507224
11.09.2024 16:14Это в случае, если они есть. А если очень нужно задать значение приватного поля, но нет соответствующего сеттера - пердолинга не избежать.
Fardeadok
11.09.2024 16:14+3Статья была как изменить одно значение в бооольшом конфиге и нам предложили передавать в конструктор миллион вызовов функций. Если параметров сотня то желаю автору удачи
Вот еще тривиальные способы:
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
bogolt
11.09.2024 16:14+4Решение несуществующей проблемы через создание кучи глобальных функций, причем с названиями которые делают это неюзабельным для более чем одного типа данных ( ну потому что если двум типам нужен будет
WithTimeout
придется уже давать функциям не столь красивые лаконичные имена.Finesse
11.09.2024 16:14Почему глобальных. Код же делится на пакеты, можно даже сделать отдельный пакет для функций конфигурации сервера.
bogolt
11.09.2024 16:14+1Можно, но слишком много движений и лишнего кода. С таким же успехом можно ничего не делать, кода будет меньше, код будет проще поддерживать.
mayorovp
11.09.2024 16:14А разве из отдельного пакета вы получите доступ к приватным свойствам сервера, ради модификации которых всё и затевалось?
Finesse
11.09.2024 16:14Можно сделать их публичными. Затевалось всё не ради скрытых свойств сервера, а ради упрощения работы с большим количеством аргументов.
Можно положить код сервера в тот же пакет.
a-tk
А тулинг от такого как быстро суёт голову в песок?
siberianlaika
С тулингом тут нет проблем. Вообще паттерн functional options старая штука,в go в коде разных проектов встречается.
Desprit
А в чем же здесь проблема для тулинга?
server := NewServer(WithPort(9090), WithLogs(true))
здесьWithLogs
это ведь просто функция, тулинг прекрасно справляется. Или вы не про language server и всякие линтеры?mayorovp
Проблема в том, чтобы выдать программисту в нужном месте контекстную подсказку со списком возможных опций.
Desprit
Ну это не проблема, функциональные опции в go называются одинаково, просто начинаете писать With и сразу все возможные варианты можете видеть.
a-tk
А если мы пытаемся сконфигурировать несколько сущностей?
a-tk
Проблема в том, чтобы тулинг позволил угадать, что ещё туда передать можно.
ghostiam
Скрытый текст
Подобный подход с опциями используется в GRPC и Goland хорошо справляется(всё из списка на скрине это валидные опции кроме tabnine, это AI), особенно если использовать комбинацию
Shift+Ctrl+Space
, чтобы автодополнение учитывало тип.