В предыдущей статье мы обсуждали паттерн Builder. Там мы заметили такой недостаток, что мы не можем производить валидацию при назначении параметров. В результате мы лишаемся возможности выстраивать цепочку методов. Давайте рассмотрим другой более интересный паттерн - Functional options. Существуют различные имплементации с небольшими отличиями, но основная идея заключается в следующем:
- Создаётся неэкспортируемая структура, содержашая конфигурацию: options.
- Каждая опция - это функция, которая возвращает один и тот же тип: type Option func(options *options) error. Например, функция WithEmail, которая принимает строку адреса электронной почты в качестве аргумента и возвращает тип Option, который представляет из себя функцию с инструкцией о том, как обновлять структуру.
Ниже я приведу конкретный пример из CRM системы, которая позволяет создать новый контакт:
package main
import (
"errors"
"fmt"
"net/mail"
"strings"
)
type Contact struct {
ID string
Name string
Phone string
Email string
Company *CompanyInfo
Address string
Notes string
}
type CompanyInfo struct {
Name string
Position string
}
type ContactOptions struct {
Phone string
Email string
Company *CompanyInfo
Address string
Notes string
}
type ContactOption func(*ContactOptions) error
func WithPhone(phone string) ContactOption {
return func(options *ContactOptions) error {
if len(phone) < 10 {
return errors.New("Phone number must be at least 10 digits")
}
options.Phone = phone
return nil
}
}
func WithEmail(email string) ContactOption {
return func(options *ContactOptions) error {
_, err := mail.ParseAddress(email)
if err != nil {
return errors.New("Invalid email address")
}
options.Email = email
return nil
}
}
func WithCompany(name string, position string) ContactOption {
return func(options *ContactOptions) error {
if strings.TrimSpace(name) == "" || strings.TrimSpace(position) == "" {
return errors.New("Both company name and position must be provided")
}
options.Company = &CompanyInfo{
Name: name,
Position: position,
}
return nil
}
}
func WithAddress(address string) ContactOption {
return func(options *ContactOptions) error {
options.Address = address
return nil
}
}
func WithNotes(notes string) ContactOption {
return func(options *ContactOptions) error {
options.Notes = notes
return nil
}
}
func CreateContact(id string, name string, opts ...ContactOption) (*Contact, error) {
options := ContactOptions{}
for _, opt := range opts {
err := opt(&options)
if err != nil {
return nil, err
}
}
contact := &Contact{
ID: id,
Name: name,
Phone: options.Phone,
Email: options.Email,
Company: options.Company,
Address: options.Address,
Notes: options.Notes,
}
return contact, nil
}
func main() {
contact, err := CreateContact("1", "John Doe", WithPhone("1234567890"), WithEmail("john.doe@gmail.com"), WithCompany("ABC Corp", "Manager"), WithAddress("123 Main St"), WithNotes("Important client"))
if err != nil {
fmt.Println("Failed to create contact:", err)
return
}
fmt.Println(contact)
}
Здесь мы видим множество методов, наподобие Builder, которые наполняют нашу структуру необходимыми данными. Например, WithNotes, WithAddress, WithCompany - все они возвращают замыкание. Т.е. анонимную функцию, которая связывает переменные вне своей области видимости. В нашем случае это заметки, компания, адрес. Функция соответствует типу Option и содержит в себе логику валидации данных. Для каждого поля в этом случае нам нужно написать свою функцию, которая по стандартам начинается с префикса With. И эта функция должна содержать в себе ту же самую логику: валидация данных при необходимости и обновление структуры.
Давайте посмотрим на функцию CreateContact. Мы передаём опции как переменные аргументы. Следовательно, для изменения структуры, мы должны произвести итерацию над этими опциями.
Всё начинается с того, что мы создаём пустую структуру
options := ContactOptions{}
Затем, мы производим итерацию по каждому аргументу Option, выполняем её как функцию, эта функция, в свою очередь, производит изменение структуры.
for _, opt := range opts {
err := opt(&options)
if err != nil {
return nil, err
}
}
Как только мы собрали структуру, мы можем имплементировать окончательную логику создания контакта.
contact := &Contact{
ID: id,
Name: name,
Phone: options.Phone,
Email: options.Email,
Company: options.Company,
Address: options.Address,
Notes: options.Notes,
}
Потому что CreateContact принимает опции в качестве аргумента, разработчики могут теперь очень удобно использовать наше API, передавая различные опциональные параметры, при этом идентафикатор и имя у нас будут обязательны:
contact, err := CreateContact("1", "John Doe", WithPhone("1234567890"), WithEmail("john.doe@gmail.com"), WithCompany("ABC Corp", "Manager"), WithAddress("123 Main St"), WithNotes("Important client"))
При этом мы по-прежнему можем создать контакта без дополнительных данных, передав только идентификатор и имя.
Этот паттерн является functional options. Он обеспечивает удобный и дружественный к API способ работы с опциями. Хотя паттерн builder может быть приемлемым вариантом, он имеет некоторые незначительные недостатки, которые делают паттерн functional options идиоматическим способом решения этой проблемы в Go. Отметим также, что этот паттерн используется в различных библиотеках Go, таких как gRPC.