Когда мы проектируем API модулей, может возникнуть один вопрос: как будет выстраиваться логика работы с опциональными параметрами нашей структуры? Эффективное решение этой проблемы может существенно повысить удобство нашего API. В этой статье я приведу конкретный пример из моей практики в разработке CRM и как мне помог в этом вопросе паттерн Builder.
Рассмотрим один из модулей в CRM. У нас есть Счета, один из ключевых функционалов нашей системы, в котором присутствует определённая бизнес-логика. Модуль принимает различные поля - сумму, ответственного пользователя, массив товаров и прочее.
type Invoice struct {
ID string
Subject string
AssignedUser int
CreateTime time.Time
UpdatedTime time.Time
LineItems []item.LineItem
Total float64
}
func NewInvoice(id string, subject string, user int, lineItems []item.LineItem) (*Invoice, error) {
// ...
}
Разработчики начали использовать нашу функцию NewInvoice и вроде все счастливы. Но наступил момент, когда клиенты попросили добавить новые параметры в структуру модуля - описание, размер скидки, ID контрагента. Причём последнее поле должно быть обязательным. Однако, мы понимаем, что добавление новых параметров в функцию сломает обратную совместимость, изменениям подвергнутся другие модули. Т.е. изменение одного параметра в функции приведёт к вынужденной переработке других частей системы.
Одновременно с этим, нам поступила задача, что поле ID должно быть обязательным, только если Total не является пустым.
А у поля AssignedUser должна быть своя логика:
- Если пользователь не заполнен, используем пользователя по умолчанию.
- Если передано отрицательное значение, выбрасываем ошибку валидации.
- Если значение равно 0, используем случайно выбранного пользователя.
- Во всех остальных случаях заполняем пользователя, который нам передал клиент.
Как мы можем сделать дружественный API для разработчиков? Давайте посмотрим в сторону паттерна Builder.
Паттерн Builder был описан в книге Банды Четырёх и он предоставлял гибкое решение в создании объектов. Процесс создания счёта изолирован от структуры самой по себе. Он требует отдельную структуру - InvoiceBuilder, который получает методы для конфигурирования и создания Счёта.
Давайте посмотрим конкретный пример, как он поможет нам создать дружественный API, который содержит в себе все требования, включая управление связанным пользователем:
type Invoice struct {
ID *string
Subject string
AssignedUser *int
CreateTime time.Time
UpdatedTime time.Time
LineItems []item.LineItem
Total float64
}
type InvoiceBuilder struct {
Invoice
}
func NewInvoiceBuilder() *InvoiceBuilder {
return &InvoiceBuilder{
Invoice: Invoice{
CreateTime: time.Now(),
UpdatedTime : time.Now(),
},
}
}
func (b *InvoiceBuilder) WithID(id string) *InvoiceBuilder {
b.ID = &id
return b
}
func (b *InvoiceBuilder) WithAssignedUser(id int) *InvoiceBuilder {
b.ID = &id
return b
}
func (b *InvoiceBuilder) WithLineItems(items []item.LineItem) *InvoiceBuilder {
b.LineItems = &items
b.Total = float64(len(items)) * 10.0 // Assume each item costs 10.0
return b
}
func (b *InvoiceBuilder) Build() (*Invoice, error) {
if b.ID == nil && b.Total < 1 {
return nil, fmt.Errorf("ID can't be empty when amount is 0")
}
if len(b.LineItems) == 0 {
return nil, fmt.Errorf("at least one line item should be there")
}
if b.AssignedUser == nil {
b.AssignedUser = GetDefaultUser()
} else {
if *b.AssignedUser == 0 {
b.AssignedUser = RandomUser()
} else if *b.AssignedUser < 0 {
return &Invoice{}, ValidationError("Assigned user is not correct")
}
}
return &b.Invoice, nil
}
Структура InvoiceBuilder содержит в себе конфигурацию Счётов. Мы напиcали метод WithAssignedUser, для настройки связанного пользователя в счёте. Обычно подобная конфигурация методов возвращает нам сам Builder, чтобы мы могли использовать цепочку методов (например, builder.Foo("foo").Bar("bar")). Ключевым обычно является сам метод Build, который содержит в себе логику инициализации связанного пользователя и возвращает созданный счёт.
Не существует единственной возможной реализации паттерна Builder.
Например, некоторые могут предпочесть подход, при котором логика определения конечного значения пользователя находится в методе WithAssignedUser, а не в методе Build. Задача данной статьи - представить обзор паттерна Builder, а не рассматривать все возможные вариации.
Использовать этот паттерн мы можем следующим образом:
func main() {
builder := NewInvoiceBuilder()
invoice, err := builder.WithID("1").WithLineItems([]item.LineItem{...}).Build()
if err != nil {
fmt.Println("Failed to build invoice:", err)
return
}
fmt.Println(invoice)
}
Первое, что мы должны сделать - это создать InvoiceBuilder и использовать его в дальнейшем для назначения опциональных полей, таких как ID, товары, пользователя. Затем, он вызывает метод Build, который производит валидацию и проверку на ошибки. Если всё хорошо, мы возвращаем готовую структуру счёта.
Этот подход делает управление полями структуры намного удобнее. Нам не обязательно передавать ссылку на пользователя или идентификатор, эти методы принимают строку или integer. Нам также не обязательно передавать сумму, Builder рассчитает её автоматом. При этом мы также можем продолжать использовать старый метод NewInvoice, если другие разработчики захотят использовать конфигурацию по умолчанию.
Однако в этом подходе есть и определённые недостатки, в частности, возникает вопрос обработки ошибок. В других языках программирования, где выбрасывается исключение, методы Builder, такие как WithLineItems могут выбросить исключение, если значение неверное. Если мы хотим по прежнему выстраивать цепочку методов при вызове Builder, функция не может вернуть ошибку. Таким образом, нам нужно переносить вопросы валидации в метод Build. Если клиент может передавать несколько параметров, но мы хотим обрабатывать только поле ID, это делает вопрос обработки ошибок сложнее.
В статье мы рассмотрели пример применения паттерна Builder для структуры Invoice в CRM системе. Паттерн позволяет удобно работать с опциональными параметрами и создавать объекты с различными конфигурациями, делая API более понятным и удобочитаемым для разработчиков.
Преимущества использования паттерна Builder:
- Упрощение конфигурации объектов: Разделение процесса создания объектов от их структуры делает код более чистым и понятным, особенно когда объекты имеют множество опциональных параметров.
- Обеспечение обратной совместимости: Добавление новых параметров в структуру объекта не ломает обратную совместимость, так как новые параметры могут быть добавлены через новые методы в Builder, не затрагивая существующие вызовы.
- Читаемый код: Цепочка методов Builder делает код API более читаемым, позволяя легко следить за тем, какие параметры были установлены.
- Централизованная обработка ошибок: Метод Build позволяет проводить валидацию параметров и обрабатывать ошибки в одном месте, что упрощает управление ошибками.
Недостатки:
- Обработка ошибок: Перенос валидации параметров в метод Build может усложнить обработку ошибок и затруднить предоставление точной информации о причине ошибки.
- Возможное нарушение инкапсуляции: В зависимости от реализации, некоторые параметры могут быть доступны извне через Builder, что может нарушить инкапсуляцию данных.
В целом, паттерн Builder предоставляет мощный инструмент для создания дружественного и удобного API с поддержкой опциональных параметров. Он позволяет изолировать процесс создания объектов от их структуры и упрощает работу с различными конфигурациями объектов. Однако, важно с умом применять данный паттерн, учитывая особенности конкретного проекта и возможные ограничения.