Олег Марков
Опции компонента в Go - паттерн component-options
Введение
Опции компонента (часто называют component-options или functional options) — это подход к конфигурированию структур и сервисов, который помогает избавляться от конструкторов с длинными списками параметров, множества перегруженных функций и "магических" значений по умолчанию, разбросанных по коду.
Смотрите, мы хотим создать компонент (например, HTTP‑клиент, репозиторий, сервис), у которого:
- есть обязательные параметры (например, адрес сервера или подключение к БД),
- есть множество необязательных настройки (таймауты, логгер, кэш, ретраи, флаги и т.д.),
- набор этих настроек растет со временем.
Если для каждой новой настройки добавлять аргумент в конструктор, он быстро превращается в огромный список параметров, в том числе однотипных. Паттерн component-options решает эту проблему с помощью опций, которые передаются в конструктор в виде гибкого списка.
В этой статье вы увидите, как:
- устроен паттерн component-options в Go,
- проектировать интерфейс опций,
- безопасно обрабатывать значения по умолчанию,
- добавлять новые опции без ломающих изменений,
- использовать этот подход для тестирования.
Все примеры будут на Go, но сама идея применима и в других языках.
Базовая идея паттерна component-options
Почему обычный конструктор быстро становится неудобным
Представьте простой HTTP‑клиент:
- база URL
- таймаут
- логгер
- количество ретраев
- флаг включения/выключения метрик
- пользовательский HTTP‑транспорт
Наивный конструктор может выглядеть так:
// Такой конструктор быстро становится неудобным,
// особенно если часть аргументов необязательные.
func NewClient(
baseURL string,
timeout time.Duration,
retries int,
logger *log.Logger,
metricsEnabled bool,
transport http.RoundTripper,
) *Client {
return &Client{
baseURL: baseURL,
timeout: timeout,
retries: retries,
logger: logger,
metricsEnabled: metricsEnabled,
transport: transport,
}
}
Проблемы такого подхода:
- Появляются "пакеты" из аргументов одного типа:
- несколько bool
- несколько time.Duration
- несколько int
Легко перепутать порядок.
- Неудобно вызывать, если вас интересуют только 1–2 настройки.
- Любая новая опция требует менять все вызовы конструктора.
- Невозможно различить обязательные и необязательные параметры.
Здесь и помогает идея component-options.
Суть подхода в двух словах
Компонент получает:
- обязательные параметры явно (например, через аргументы конструктора),
- необязательные — через список опций.
Опция — это функция, которая меняет конфигурацию компонента. Обычно это:
- либо функция, принимающая указатель на конфиг/структуру,
- либо значение, реализующее интерфейс с методом Apply.
Чаще всего в Go используют функциональный вариант.
Сигнатура в общем виде:
// Option описывает "функцию-опцию",
// которая настраивает конфигурацию ClientConfig.
type Option func(*ClientConfig)
Конструктор:
- создает конфигурацию с безопасными значениями по умолчанию,
- последовательно применяет все опции,
- создает компонент на основе итоговой конфигурации.
Такой подход:
- позволяет добавлять новые опции без изменений в вызывающем коде,
- делает вызовы конструктора читаемыми,
- улучшает тестируемость.
Теперь давайте пошагово разберем типичную реализацию.
Базовая реализация component-options
Шаг 1. Вводим структуру конфигурации
Вместо того чтобы хранить настройки прямо в компоненте, выносите их в отдельную структуру. Это удобно как логически, так и для тестов.
// ClientConfig хранит все настройки HTTP-клиента.
type ClientConfig struct {
baseURL string
timeout time.Duration
retries int
logger *log.Logger
metricsEnabled bool
transport http.RoundTripper
}
Здесь вы собираете все возможные настройки в одном месте.
Шаг 2. Определяем тип Option
Смотрите, сейчас создадим тип функции, которая будет "применять" изменения к конфигурации.
// Option - функция, которая изменяет конфигурацию клиента.
type Option func(*ClientConfig)
Конструктор будет принимать набор таких функций и вызывать их одну за другой.
Шаг 3. Реализуем конструктор с опциями
Давайте сделаем конструктор, который:
- имеет обязательный параметр baseURL,
- принимает срез опций,
- задает значения по умолчанию,
- применяет опции.
// Client - сам компонент, с которым вы будете работать.
type Client struct {
cfg ClientConfig
httpClient *http.Client
}
// NewClient создает HTTP-клиент с опциями.
func NewClient(baseURL string, opts ...Option) *Client {
// Создаем конфигурацию с разумными значениями по умолчанию.
cfg := ClientConfig{
baseURL: baseURL,
timeout: 5 * time.Second, // дефолтный таймаут
retries: 3, // дефолтное число ретраев
logger: log.Default(), // стандартный логгер
metricsEnabled: true, // метрики включены по умолчанию
transport: http.DefaultTransport,
}
// Применяем опции по очереди.
for _, opt := range opts {
// Каждая опция модифицирует cfg.
opt(&cfg)
}
// Создаем http.Client с учётом настроек.
httpClient := &http.Client{
Timeout: cfg.timeout,
Transport: cfg.transport,
}
return &Client{
cfg: cfg,
httpClient: httpClient,
}
}
Обратите внимание:
- baseURL — обязательный параметр,
- все необязательные — внутри cfg и изменяются только через опции,
- вызов NewClient можно делать с любым числом опций, вплоть до нуля.
Шаг 4. Создаем конкретные опции
Теперь создадим несколько вспомогательных функций, которые возвращают Option. Они и являются вашим публичным API для настройки компонента.
// WithTimeout задает таймаут HTTP-запросов.
func WithTimeout(d time.Duration) Option {
return func(cfg *ClientConfig) {
// Здесь вы можете добавить валидацию аргументов.
if d <= 0 {
// Если таймаут невалидный, оставляем значение по умолчанию.
return
}
cfg.timeout = d
}
}
// WithRetries задает количество повторных попыток.
func WithRetries(n int) Option {
return func(cfg *ClientConfig) {
if n < 0 {
return
}
cfg.retries = n
}
}
// WithLogger позволяет передать свой логгер.
func WithLogger(l *log.Logger) Option {
return func(cfg *ClientConfig) {
if l == nil {
return
}
cfg.logger = l
}
}
// WithMetrics выключает/включает метрики.
func WithMetrics(enabled bool) Option {
return func(cfg *ClientConfig) {
cfg.metricsEnabled = enabled
}
}
// WithTransport задает свой HTTP-транспорт.
func WithTransport(t http.RoundTripper) Option {
return func(cfg *ClientConfig) {
if t == nil {
return
}
cfg.transport = t
}
}
Теперь вы увидите, как это выглядит в использовании.
Пример использования компонента с опциями
func main() {
// Создаем логгер.
logger := log.New(os.Stdout, "client: ", log.LstdFlags)
// Создаем клиент, передавая только нужные нам опции.
client := NewClient(
"https://api.example.com",
WithTimeout(10*time.Second), // переопределяем таймаут
WithRetries(5), // увеличиваем число ретраев
WithLogger(logger), // добавляем свой логгер
WithMetrics(false), // выключаем метрики
)
// Дальше используем client как обычный компонент.
_ = client
}
Преимущества такого API:
- легко читается по именам опций,
- легко дополняется новыми опциями,
- порядок опций неважен.
Теперь давайте подробнее рассмотрим проектирование опций и слабые места, о которых стоит помнить.
Проектирование интерфейса опций
Обязательные vs необязательные параметры
Хорошее правило:
обязательные параметры — всегда явные аргументы конструктора; опции — только для необязательных.
Например:
// dbURL и credentials - обязательные,
// все остальное - опции.
func NewUserRepository(
dbURL string,
credentials Credentials,
opts ...Option,
) (*UserRepository, error) {
// ...
}
Почему лучше так:
- при чтении кода сразу видно, что обязательно для работы компонента,
- опции можно не трогать, если вам достаточно дефолтов,
- не нужно придумывать "обязательные опции", без которых код упадет.
Если вы чувствуете, что какая-то опция обязательна, скорее всего это не опция, а обычный аргумент конструктора.
Именование опций
Имена опций — это ваша документация. Старайтесь:
- использовать префикс With или похожий: WithTimeout, WithLogger, WithBufferSize,
- избегать слишком общих имен вроде Set, Opt, Enable,
передавать смысл в названии:
- WithMaxConnections
- WithInsecureTLS
- WithCacheTTL
На практике With остаётся де-факто стандартом в Go‑сообществе для component-options.
Валидация аргументов внутри опций
Смотрите, вы можете валидировать данные:
- либо в конструкторе после применения всех опций,
- либо в самих опциях.
Часто удобно делать базовую защиту прямо в опциях, чтобы не ломать дефолтную конфигурацию:
func WithMaxConnections(n int) Option {
return func(cfg *ClientConfig) {
if n <= 0 {
// Игнорируем невалидное значение,
// оставляя дефолт из конструктора.
return
}
cfg.maxConnections = n
}
}
Если есть сложная валидация, её логичнее делать в конструкторе и возвращать ошибку:
func NewClient(baseURL string, opts ...Option) (*Client, error) {
cfg := defaultConfig(baseURL)
for _, opt := range opts {
opt(&cfg)
}
// Проверяем итоговую конфигурацию.
if cfg.timeout < time.Second {
return nil, fmt.Errorf("timeout too small")
}
// Создаем компонент...
// ...
return &Client{cfg: cfg}, nil
}
Так вызывающий код может обработать ошибки конфигурации.
Варианты реализации опций: функции vs объекты
Мы уже смотрели функциональный вариант:
type Option func(*ClientConfig)
Иногда вам нужны опции, которые могут возвращать ошибку при применении. Тогда можно использовать интерфейс:
// OptionV2 - опция, которая может вернуть ошибку.
type OptionV2 interface {
Apply(*ClientConfig) error
}
Пример:
type insecureTLSOption struct{}
func (o insecureTLSOption) Apply(cfg *ClientConfig) error {
// Настраиваем TLS с InsecureSkipVerify...
// Если что-то идет не так - возвращаем ошибку.
return nil
}
func WithInsecureTLS() OptionV2 {
return insecureTLSOption{}
}
Конструктор в этом случае будет:
func NewClientV2(baseURL string, opts ...OptionV2) (*Client, error) {
cfg := defaultConfig(baseURL)
for _, opt := range opts {
if err := opt.Apply(&cfg); err != nil {
return nil, err
}
}
// ...
return &Client{cfg: cfg}, nil
}
Такой подход используется реже, но он полезен, когда:
- настройка требует внешних ресурсов,
- при конфигурировании реально могут возникать ошибки.
Если ошибок быть не должно, проще и привычнее использовать функции-опции.
Управление значениями по умолчанию
Где задавать значения по умолчанию
Лучшее место — конструктор (или функция defaultConfig). Именно там вы задаете:
- таймауты по умолчанию,
- флаги включения,
- стандартные реализации интерфейсов.
Пример выделения логики дефолтов:
func defaultConfig(baseURL string) ClientConfig {
return ClientConfig{
baseURL: baseURL,
timeout: 5 * time.Second,
retries: 3,
logger: log.Default(),
metricsEnabled: true,
transport: http.DefaultTransport,
}
}
Смотрите, я выношу эту функцию отдельно, чтобы:
- использовать её и в продакшене, и в тестах,
- явно видеть все дефолтные значения в одном месте.
Когда стоит делать "обязательные опции"
Иногда встречается подход, когда вообще нет обязательных параметров, а вместо них создают обязательные опции:
func WithBaseURL(url string) Option { /* ... */ }
И в конструкторе проверяют, была ли такая опция применена. Это ухудшает читаемость, потому что:
- по сигнатуре конструктора не видно, что baseURL обязателен,
- любой вызов без этой опции может привести к ошибке во время выполнения.
Поэтому лучше:
- использовать явные аргументы для действительно обязательных параметров,
- опции оставлять для всего, без чего компонент может работать по умолчанию.
Component-options и расширяемость API
Добавление новых опций без ломающих изменений
Одно из ключевых преимуществ подхода — возможность расширять API компонента без изменения его сигнатуры. Представим, что вы решили добавить:
- включение трейсинга,
- пользовательский middleware.
Вы просто добавляете опции:
func WithTracing(tracer Tracer) Option {
return func(cfg *ClientConfig) {
cfg.tracer = tracer
}
}
func WithMiddleware(mw Middleware) Option {
return func(cfg *ClientConfig) {
cfg.middleware = append(cfg.middleware, mw)
}
}
Все существующие вызовы конструктора продолжают работать, потому что:
- они не обязаны знать о новых возможностях,
- дефолтное поведение остается тем же.
Групповые опции (композиция опций)
Интересный прием — писать опции, которые внутри вызывают другие опции. Смотрите, как это выглядит:
// WithProductionDefaults включает набор типичных опций для продакшена.
func WithProductionDefaults() Option {
return func(cfg *ClientConfig) {
// Здесь мы просто вызываем другие опции.
WithTimeout(30 * time.Second)(cfg)
WithRetries(5)(cfg)
WithMetrics(true)(cfg)
}
}
Использование:
client := NewClient(
"https://api.example.com",
WithProductionDefaults(), // применяем несколько настроек сразу
WithLogger(logger), // переопределяем логгер
)
Так вы можете создавать "преднастроенные профили" без усложнения конструктора.
Тестируемость и component-options
Как опции упрощают тестирование
Когда вы строите компонент через опции, становится легче:
- подменять зависимости (логгер, HTTP‑клиент, кэш),
- создавать разные сценарии конфигурации,
- переиспользовать готовые опции в тестах.
Давайте разберемся на примере.
Пример: внедрение тестового HTTP‑транспорта
Допустим, вы хотите протестировать, что клиент корректно обрабатывает ошибки сервера. Вы можете написать фейковый транспорт:
// fakeTransport - тестовая реализация http.RoundTripper.
type fakeTransport struct {
// Здесь мы определяем, что должен вернуть RoundTrip.
resp *http.Response
err error
}
func (t *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Возвращаем заранее подготовленный ответ или ошибку.
return t.resp, t.err
}
И использовать его через опцию WithTransport:
func TestClient_ServerError(t *testing.T) {
// Готовим фейковый ответ сервера с кодом 500.
resp := &http.Response{
StatusCode: 500,
Body: io.NopCloser(strings.NewReader("internal error")),
}
// Создаем фейковый транспорт.
tr := &fakeTransport{
resp: resp,
err: nil,
}
// Создаем клиент, подставляя фейковый транспорт через опцию.
client := NewClient(
"https://api.example.com",
WithTransport(tr), // здесь мы подставляем наш fakeTransport
)
// Дальше вызываем метод клиента и проверяем поведение.
_ = client
}
Обратите внимание, как опция WithTransport превращает "внедрение зависимости" в очень простой и явный механизм.
Тестовые опции как отдельный пакет
Иногда удобно вынести дополнительные "тестовые" опции в отдельный файл/пакет, чтобы:
- не засорять продакшен‑код,
- не публиковать эти опции как часть публичного API библиотеки.
Например:
- production: public options (WithTimeout, WithLogger, ...)
- internal/testoptions: WithTestTransport, WithNoRetries и т.д.
Это особенно полезно для библиотек, где вы хотите держать внешний API максимально аккуратным.
Типичные ошибки и подводные камни
Неявные обязательные опции
Ошибка: компонент не работает без определенной опции, но это не видно по сигнатуре конструктора.
Как это выглядит:
// Где-то внутри NewClient предполагается,
// что обязательно должна быть вызвана WithAuth().
client := NewClient("https://api.example.com", WithTimeout(10*time.Second))
// В рантайме всё ломается из-за отсутствия аутентификации.
Как избежать:
- все действительно обязательные параметры передавайте явно,
- опции используйте как дополнение, а не как замену конструктора,
- если без чего‑то нельзя — либо аргумент конструктора, либо дефолт.
Хрупкие опции, зависящие от порядка
Иногда люди пишут опции, которые ожидают, что их вызовут в определённом порядке. Например:
func WithTLSConfig(cfg *tls.Config) Option {
return func(c *ClientConfig) {
// какая-то логика, зависящая от уже установленных полей
}
}
Если эта логика подразумевает, что другая опция уже сработала, то вы получаете зависимость от порядка применения опций. Лучше так не делать.
Как минимизировать риск:
- старайтесь, чтобы каждая опция была "самодостаточной",
- если нужно, чтобы что‑то происходило после всех опций — делайте это в конструкторе.
Слишком "умные" опции
Еще один типичный анти‑паттерн — опции, которые делают слишком много:
- выполняют сетевые запросы,
- открывают файлы,
- запускают горутины.
Лучше:
- в опциях задавать только параметры конфигурации,
- а "тяжелую" работу выполнять в конструкторе после применения всех опций.
Так вы упрощаете:
- предсказуемость поведения,
- тестируемость,
- обработку ошибок.
Расширенный пример компонента с опциями
Теперь давайте посмотрим на чуть более "настоящий" пример — сервис работы с пользователями, у которого:
- есть обязательное соединение с базой,
- есть опциональный кэш,
- есть логгер,
- есть метрики,
- есть лимитер запросов.
Структура компонента и конфигурации
type UserService struct {
cfg Config
db *sql.DB
cache Cache // интерфейс кэша
limiter RateLimiter // интерфейс лимитера
metrics Metrics // интерфейс метрик
logger *log.Logger
}
type Config struct {
cacheEnabled bool
cacheTTL time.Duration
rateLimit int
metricsEnabled bool
}
Тип опции и дефолтная конфигурация
type Option func(*Config)
func defaultConfig() Config {
return Config{
cacheEnabled: false, // по умолчанию кэш выключен
cacheTTL: 5 * time.Minute, // TTL по умолчанию
rateLimit: 100, // запросов в минуту
metricsEnabled: true,
}
}
Конструктор
func NewUserService(db *sql.DB, opts ...Option) *UserService {
// Обязательная зависимость - db.
cfg := defaultConfig()
for _, opt := range opts {
opt(&cfg)
}
s := &UserService{
cfg: cfg,
db: db,
logger: log.Default(),
}
// Настраиваем зависимости по конфигу.
if cfg.cacheEnabled {
// Здесь мы могли бы создать реальный кэш.
s.cache = NewInMemoryCache(cfg.cacheTTL)
}
if cfg.rateLimit > 0 {
s.limiter = NewTokenBucketLimiter(cfg.rateLimit)
}
if cfg.metricsEnabled {
s.metrics = NewPrometheusMetrics()
}
return s
}
Здесь я упрощаю реализацию зависимостей, но идея понятна: конфигурация управляет тем, какие конкретные реализации будут созданы.
Опции
Теперь добавим несколько опций.
// WithCache включает кэш с заданным TTL.
func WithCache(ttl time.Duration) Option {
return func(cfg *Config) {
if ttl <= 0 {
return
}
cfg.cacheEnabled = true
cfg.cacheTTL = ttl
}
}
// WithNoCache явно отключает кэш.
func WithNoCache() Option {
return func(cfg *Config) {
cfg.cacheEnabled = false
}
}
// WithRateLimit задает лимит запросов в минуту.
func WithRateLimit(limit int) Option {
return func(cfg *Config) {
if limit <= 0 {
return
}
cfg.rateLimit = limit
}
}
// WithNoMetrics отключает метрики.
func WithNoMetrics() Option {
return func(cfg *Config) {
cfg.metricsEnabled = false
}
}
Использование сервиса
func main() {
db, err := sql.Open("postgres", "postgres://...")
if err != nil {
log.Fatal(err)
}
// Создаем сервис с кэшем и лимитом запросов.
svc := NewUserService(
db,
WithCache(10*time.Minute), // включаем кэш
WithRateLimit(500), // увеличиваем лимит
WithNoMetrics(), // метрики отключаем
)
_ = svc
}
Как видите, мы можем читать вызов конструктора как "конфигурацию" компонента, а не как список неочевидных аргументов.
Заключение
Паттерн component-options (опции компонента) в Go позволяет:
- избавиться от конструкторов с длинными списками аргументов,
- чётко разделить обязательные и необязательные параметры,
- аккуратно управлять значениями по умолчанию,
- расширять API компонента без ломающих изменений,
- облегчить тестирование и внедрение зависимостей.
Базовая идея проста:
- заводим структуру конфигурации,
- создаем тип Option как функцию, меняющую конфигурацию,
- в конструкторе инициализируем дефолты и применяем все опции.
Важно:
- обязательные параметры передавать явно, а не прятать в "обязательные опции",
- не делать опции хрупкими (зависящими от порядка),
- держать тяжелую логику в конструкторе, а не в опциях.
Если вы будете последовательно применять этот подход в проектах на Go, компоненты станут проще в использовании, тестировании и расширении, а API — более выразительным и устойчивым к изменениям.
Частозадаваемые технические вопросы по теме и ответы
Как передавать контекст (context.Context) через опции компонента
Часто возникает желание сделать опцию WithContext и хранить context.Context внутри компонента. Это обычно плохая идея, потому что контекст должен жить на уровне запроса, а не компонента. Лучше:
- в конструкторе не использовать context.Context,
- во всех методах компонента принимать контекст как первый аргумент:
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error) {
// Здесь вы используете ctx для отмены и дедлайнов.
}
Так вы избегаете "застывшего" контекста и проблем с утечками.
Как сделать опцию, которая зависит от других опций
Если опция логически зависит от других настроек, лучше не полагаться на порядок их применения. Вместо этого:
- сохраняйте "сырые" значения в конфигурации,
- после применения всех опций делайте вычисления в конструкторе.
Пример:
type Config struct {
addr string
port int
endpoint string // итоговое соединение addr:port
}
func NewServer(opts ...Option) *Server {
cfg := defaultConfig()
for _, opt := range opts {
opt(&cfg)
}
// Здесь уже после всех опций собираем endpoint.
cfg.endpoint = fmt.Sprintf("%s:%d", cfg.addr, cfg.port)
// ...
}
Так вы избегаете неожиданных эффектов от порядка.
Можно ли делать опции потокобезопасными
Сами опции обычно вызываются до начала работы компонента, в одном потоке. Поэтому потокобезопасности нужно добиваться не в опциях, а в реализуемом компоненте:
- защищайте изменяемое состояние мьютексами внутри компонента,
- не меняйте конфигурацию после создания компонента,
- относитесь к Config как к неизменяемой после конструктора.
Если же вы всё-таки хотите переиспользовать одну и ту же Config, не делайте этого: создавайте отдельную конфигурацию для каждого экземпляра компонента.
Как логировать применение опций для отладки
Иногда нужно понять, какие именно опции были применены. Есть два варианта:
- Добавить логирование внутрь опций:
func WithTimeout(d time.Duration) Option {
return func(cfg *ClientConfig) {
log.Printf("apply option WithTimeout(%s)", d)
cfg.timeout = d
}
}
- Оборачивать опции в обертку‑декоратор:
func LogOption(name string, opt Option) Option {
return func(cfg *ClientConfig) {
log.Printf("apply option %s", name)
opt(cfg)
}
}
И использовать:
NewClient("...", LogOption("WithTimeout(5s)", WithTimeout(5*time.Second)))
Как делать опции для вложенных структур конфигурации
Если конфигурация становится большой, её разбивают на вложенные структуры:
type HTTPConfig struct {
Timeout time.Duration
}
type TLSConfig struct {
Insecure bool
}
type ClientConfig struct {
HTTP HTTPConfig
TLS TLSConfig
}
Опции в этом случае просто "проваливаются" внутрь:
func WithHTTPTimeout(d time.Duration) Option {
return func(cfg *ClientConfig) {
cfg.HTTP.Timeout = d
}
}
func WithInsecureTLS() Option {
return func(cfg *ClientConfig) {
cfg.TLS.Insecure = true
}
}
Так вы держите конфигурацию структурированной и при этом продолжаете пользоваться удобными опциями.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев