Привет дорогие Хабряне и случайно зашедшие добрые люди! Давно хотел написать статью о паттерне опций в функциях и почему его использование настолько шикарно. В статье будет упрощенный пример из жизни, так что не судите строго. Заранее благодарен за комментарии и указания на неточности.
Задача из жизни
Вам необходимо обрабатывать датаматрицы или qr коды с товаров на складе. Сотрудники склада сканируют их, ваша программа эти данные получает. Далее идет ваша бизнес-логика...
Я намеренно упрощу реализацию, чтобы вам не было скучно
У нас есть отсканированный товар:
type ScannedItem struct {
Datamatrix string //сами данные датаматрицы , которые расшифрованы
Length int //кол-во символов в расшифрованной строке датаматрицы
GS1 bool // Относится ли датаматрица к формату GS1
Valid bool // Правильная датаматрица или нет
ErrorReason []error // Ошибки в датаматрице при сканировании
}
*** Про формат GS1 можете прочитать здесь. Просто будем считать, что датаматрица либо к нему относится, либо нет.
Далее мы хотим просканировать датаматрицу и получить из нее данные, которые мы вставим в структуру ScannedItem.
Напишем такую функцию:
func (item *ScannedItem) Scan(dm string, gs1 bool)error {
if len(dm) == 0 {
return errors.New("пустая датаматрица")
}
l := len(dm)
/*Хотя ,разумеется, желательно использовать utf8.RuneCountInString(dm),
но для простоты оставим просто длинну символов и все они в ASCII */
if gs1 == true {
item.GS1 = true
item.Datamatrix = dm
item.Length = l
} else {
item.GS1 = false
item.Datamatrix = dm[:30]
item.Length = 31
}
}
Эта функция проверяет получает 2 параметра строку , которая зашимфрована в датаматрице и статус является ли данная датаматрица формата GS1. Далее функция обрезает строку в 31 знак от полученной датаматрицы, если она не относится к формату GS1.
Все вроде просто:
Простая структура данных
-
Простая функция которая делает что-то одно
Но наступает суровая реальность и надо добавлять возможности / опции в нашу функцию.
Уверен, что вы сталкиваетесь с подобной задачей постоянно. Есть работающая функция и теперь вам надо ее либо расширять, либо писать еще одну , либо выносить новый метод в интерфейс и наверняка есть еще много способов усложнить себе жизнь.
Плохой подход к решению
Нам теперь понадобилось проверять соотвествуют ли первые 3 знака в датаматрице строке "010" , и если нет выбрасывать ошибку.
Давайте напишем проверочную функцию
func (item *ScannedItem) CheckErrorDM() error {
if item.Datamatrix[:3] != "010"/* // Будем считать, что 010 в начале строки
датаматрицы - это канон */
return errors.New("датаматрица имеет неверный формат")
}
return nil
}
Т.е. теперь у нас есть функция, которая проверяет датаматрицу на "вшивость" и если что-то не так выплевывает ошибку. Вроде все понятно, но в этом случае , если мы (ради наглядности) хотим вставить эту проверку в нашу функцию Scan() - мы волей не волей должны вставить туда и ошибку при выводе результата, а ведь в процессе проверок у нашей датаматрицы может измениться множество полей и быть несколько ошибок, которые нам следует добавить в поле ErrorReason.
Соотвествено мы вставляем обработку ошибки в нашу функцию Scan():
func (item *ScannedItem) Scan(dm string, gs1 bool) error {
l := utf8.RuneCountInString(dm)
if len(dm) == 0 {
return errors.New("пустая строка")
}
if gs1 == true {
item.GS1 = true
item.Datamatrix = dm
item.Length = l
} else {
item.Valid = true
item.ErrorReason = append(item.ErrorReason, errors.New("датаматрица не соотносится с GS1 форматом"))
}
if item.CheckErrorDM() != nil {
return item.CheckErrorDM()
}
return nil
}
И теперь мы уже имеем ужас зависимостей в коде, который у нас уже был написан до этого.
Убежден, что вы, дорогие читатели, можете предложить с дюжину методов , как улучшить ситуацию с кодом. Однако мы рассматриваем самый неприглядный вариант. Так что потерпите!
Сейчас мы усложнили функцию и Scan() и добавили работы ребятам, которые будут переписывать код, где она фигурирует. Теперь у нас есть новая ошибка, которую функция может выплюнуть. Нам предстоит с этим жить.
Но что же будет, когда нам придется добавлять все новые и новые проверки в функцию Scan() ?
Как жить с тем, что у этой функции может появиться "заглушка" (значения параметров по умолчанию)?
Давайте рассмотрим паттерн "Опции":
Этот паттерн великолепен тем, что сразу решает задачу "заглушки" и упрощает жизнь тем, кто будет добавлять новую бизнес логику в функцию Scan()
Начнем с того, что мы превратим нашу обычную функцию Scan() в вариативную ScanWithOptions:
func (item *ScannedItem) ScanWithOptions(opts ...ScanFunc)error{
///Здесь будет наша логика
return nil
}
Наша функция будет принимать произвольное количество аргументов на вход (можно и без параметров ее запустить). Однако мы видим, что все эти аргументы имеют тип ScanFunc.
Давайте зададим новый тип ScanFunc, который будет отвечать условиям нашей основной функции.
type ScanFunc func(item *ScannedItem)
Как мы видим тип ScanFunc - это функция, которая что-то делает с нашим struct'ом ScannedItem.
Теперь мы можем начать создавать функции, которые будут указывать нашей материнской функции Scan, как себя вести.
Зададим стандартное поведение для Scan() в виде функции возвращающей начальный struct ScannedItem - это и будет заглушка, например, когда мы не можем знать, что пришло:
func (item *ScannedItem) DefaultScan() ScannedItem {
err := errors.New("использована заглушка")
return ScannedItem{"010456789123456789123456789123456789", 36, false, false, append(item.ErrorReason, err)}
}
Возможно это не идеальный пример, но он позволяет нам получить то, что мы ожидаем в нашей функции ScanWithOptions(), т.е. некоторое значение по умолчанию:
func (item *ScannedItem) ScanWithOptions(opts ...ScanFunc)error{
defaultScanItem := item.DefaultScan() /* Теперь мы точно знаем, что мы будем подставлять
значение , возвращаемое из DefaultScan()*/
return nil
}
Давайте теперь перейдем к самому интересному, а именно к самим опциям, с которыми мы можем запускать нашу вариативную функцию ScanWithOptions(). Мы действительно можем теперь назначить ей множество опцицональных проверок, которые мы обязаны были в нее включить в код. Теперь же это опционально !
Начнем с проверки на наличие 010 в начале строки датаматрицы (ScannedItem.Datamatrix)
func (item *ScannedItem) CheckValidWithReason() ScanFunc {
if item.Datamatrix[:3] != "010" {
return func(item *ScannedItem) () {
item.Valid = false
item.ErrorReason = append(item.ErrorReason,errors.New("датаматрица имеет неверный формат"))
}
} else {
return func(item *ScannedItem) () {
item.Valid = true
}
}
}
Как мы видим наша вспомогаетельная функция CheckErrorWithReason() работает с объектом (struct'ом) ScannedItem и возвращает тип ScanFunc. Этот тип мы сами задали , и поэтому следуем своим же условиям :
Если условие сработало , мы возвращаем безымянную функцию, которая работает с начальным объектом ScannedItem и преобразует его поля по нашему усмотрению. В данном случае передает или не передает ошибку в поле item.ErrorReason + еще раз переназначает item.Valid на false или на true
Давайте посмотрим, как мы можем запустить функцию ScanWithOptions()
c новой проверкой:
func (item *ScannedItem) CheckGS1Option() ScanFunc {
if item.GS1 == true {
return func(item *ScannedItem) {
item.Length = len(item.Datamatrix)
item.Valid = true
}
} else {
return func(item *ScannedItem) {
item.GS1 = false
item.Valid = true
item.ErrorReason = append(item.ErrorReason, errors.New("датаматрица не соотносится с GS1 форматом"))
}
}
}
Эта проверка была зашита в теле функции Scan, ныне же мы выносим ее в отдельную необязательную(опциональную) функцию - она будет проверять наличие значения true в поле GS1 и отрезать 31 символ от датаматрицы :
func (item *ScannedItem) CropDatamatrix() ScanFunc {
return func(item *ScannedItem) {
if len(item.Datamatrix) > 31 && item.GS1 == false{
item.Datamatrix = item.Datamatrix[:31]
item.Length = 31
}
}
}
Мы опять возвращаем ScanFunc, поскольку именно этот тип и никакой другой мы не должны использовать , как аргумент вариативной функции ScanWithOptions().
У нас появилось некоторое количество опциональных аргументов, которые мы можем использовать в материнской функции ScanWithOptions(). Давайте посмотрим , как мы будем их обрабатывать в теле функции:
func (item *ScannedItem) ScanWithOptions(opts ...ScanFunc) error {
if len(opts) == 0 {
a := item.DefaultScan()
*item = a
return nil
} // Используем заглушку при отсутствии параметров ,будем считать, что это так
// нас просили сделать
for _, fn := range opts {
fn(item)
} //Пробегаем по нашим опциям и применяем их на struct ScannedItem
if len(item.ErrorReason) > 0 {
return item.ErrorReason[len(item.ErrorReason)-1]
} //выбрасываем последнюю полученную ошибку хотя, можем и проигнорировать ее , посмотрев ,
//что нас хранится в слайсе ErrorReason
return nil
}
Теперь нам осталось инициализировать нашу функцию и применить произвольное количество проверок или манипуляций с данными. Все они являются опциональными:
func main() {
a := &ScannedItem{"01045678912345678912345678912345678988888888888888888",
len("12345678912345678912345678912345678988888888888888888"),
false,
false,
nil}
a.ScanWithOptions(
a.CheckValidWithReason(),
// a.CropDatamatrixOption(),
a.CheckGS1Option(),
)
fmt.Printf("%+v:", a)
}
type ScannedItem struct {
Datamatrix string
Length int
GS1 bool
Valid bool
ErrorReason []error
}
type ScanFunc func(item *ScannedItem)
func (item *ScannedItem) ScanWithOptions(opts ...ScanFunc) error {
if len(opts) == 0 {
a := item.DefaultScan()
*item = a
return nil
}
for _, fn := range opts {
fn(item)
}
if len(item.ErrorReason) > 0 {
return item.ErrorReason[len(item.ErrorReason)-1]
}
return nil
}
func (item *ScannedItem) CheckGS1Option() ScanFunc {
if item.GS1 == true {
return func(item *ScannedItem) {
item.Length = len(item.Datamatrix)
item.Valid = true
}
} else {
return func(item *ScannedItem) {
item.GS1 = false
item.Valid = true
item.ErrorReason = append(item.ErrorReason, errors.New("датаматрица не соотносится с GS1 форматом"))
}
}
}
func (item *ScannedItem) DefaultScan() ScannedItem {
err := errors.New("использована заглушка")
return ScannedItem{"010456789123456789123456789123456789", 36, false, false, append(item.ErrorReason, err)}
}
Распечатаем результат выполнения без опций :
&{Datamatrix:010456789123456789123456789123456789 Length:36 GS1:false
Valid:false ErrorReason:[использована заглушка]}:
a если поставим все опции получится :
&{Datamatrix:01045678912345678912345678912345678988888888888888888 Length:53
GS1:true Valid:true ErrorReason:[]}
Мы видим, что функция получилась короткая, емкая и гибкая. Самое важное, что новые проверки (функции) теперь можно спокойно разрабатывать, не боясь, что мы получим заивисимости в теле основной функции.
Теперь , когда понадобится что-то улучшить - можно просто дописать новую опцию!
Но как же это тестировать ?
Тестирование паттерна Опций
Мы понимаем, что нам потребуются аргументы для тест случаев. При этом мы понимаем, что на выходе нам понадобится функция, которая возвращает ссылку на struct ScannedItems, поэтому создадим эту функцию:
func HelperFunc() *ScannedItem {
a := &ScannedItem{
Datamatrix: "01045678912345678912345678912345678988888888888888888",
Length: len("01045678912345678912345678912345678988888888888888888") + 1,
GS1: false,
Valid: false,
ErrorReason: nil,
}
return a
}
Таких функций или ссылок на объекты мы можем создать сколько хотим, но нам эта функция нужна для того, чтобы получить тип ScanFunc и дальше его использовать для теста.
Вот как мы получаем аргументы для тест-функции типа ScanFunc:
type args struct {
opts []ScanFunc
} //Аргументы для тест-функции
var a, b, c args
a.opts = append(a.opts, HelperFunc().CropDatamatrixOption(), HelperFunc().CheckGS1Option(), HelperFunc().CheckValidWithReason())
b.opts = append(b.opts, HelperFunc().CheckGS1Option(), HelperFunc().CheckValidWithReason())
c.opts = append(c.opts, HelperFunc().CheckValidWithReason())
/* Проверочные случаи тест-функции на базе вспомогательной функции HelperFunc(),
которая выдает ссылку на ScannedItem*/
Соберем все в тестовую функцию:
func TestScannedItem_ScanWithOptions(t *testing.T) {
type fields struct {
Datamatrix string
Length int
GS1 bool
Valid bool
ErrorReason []error
}
type args struct {
opts []ScanFunc
}
var a, b, c args
a.opts = append(a.opts, HelperFunc().CropDatamatrixOption(), HelperFunc().CheckGS1Option(), HelperFunc().CheckValidWithReason())
b.opts = append(b.opts, HelperFunc().CheckGS1Option(), HelperFunc().CheckValidWithReason())
c.opts = append(c.opts, HelperFunc().CheckValidWithReason())
tests := []struct {
name string
fields fields
args args
wantErr bool
}{{
name: "Test variadic A",
fields: fields{
Datamatrix: HelperFunc().Datamatrix,
Length: HelperFunc().Length,
GS1: false,
Valid: true,
ErrorReason: nil,
},
args: args{opts: a.opts},
wantErr: true,
},
{
name: "Test variadic B",
fields: fields{
Datamatrix: HelperFunc().Datamatrix,
Length: HelperFunc().Length,
GS1: false,
Valid: false,
ErrorReason: nil,
},
args: args{opts: b.opts},
wantErr: false,
},
{
name: "Test variadic C",
fields: fields{
Datamatrix: HelperFunc().Datamatrix,
Length: HelperFunc().Length,
GS1: false,
Valid: false,
ErrorReason: nil,
},
args: args{opts: c.opts},
wantErr: false,
}, // TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
item := &ScannedItem{
Datamatrix: tt.fields.Datamatrix,
Length: tt.fields.Length,
GS1: tt.fields.GS1,
Valid: tt.fields.Valid,
ErrorReason: tt.fields.ErrorReason,
}
if err := item.ScanWithOptions(tt.args.opts...); (err != nil) != tt.wantErr {
t.Errorf("ScanWithOptions() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
Мы можем проверять и разные опции(ScanFunc) и разные входные данные (ScannedItem).
Вот результаты
=== RUN TestScannedItem_ScanWithOptions
=== RUN TestScannedItem_ScanWithOptions/Test_variadic_A
=== RUN TestScannedItem_ScanWithOptions/Test_variadic_B
main_test.go:322: ScanWithOptions() error = датаматрица не соотносится с GS1 форматом, wantErr false
=== RUN TestScannedItem_ScanWithOptions/Test_variadic_C
--- FAIL: TestScannedItem_ScanWithOptions (0.00s)
--- PASS: TestScannedItem_ScanWithOptions/Test_variadic_A (0.00s)
--- FAIL: TestScannedItem_ScanWithOptions/Test_variadic_B (0.00s)
--- PASS: TestScannedItem_ScanWithOptions/Test_variadic_C (0.00s)
FAIL
О том, как работать с конкурентностью в вариативной функции и как ее тестрировать напишу в следующей статье.
Спасибо всем за чтение! Пользуясь случаем, поблагодарю своего друга Андрея Арькова за помощь в написании статьи и поздравлю его с Днем Рождения!
Комментарии (7)
pin2t
03.07.2024 13:24+1Выглядит как сложное решение непонятно какой даже проблемы. Чем это лучше последовательного вызова Check-функций? Их все равно надо написать и передать в Scan
a.Scan() a.CheckValidWithReason() // a.CropDatamatrixOption() a.CheckGS1Option()
Я ещё понимаю если бы было
a, err := ScanWithOptions(CheckValidWithReason(), CheckGS1Option())
обычно так это и используют
lelikPtz
Паттерн крутой, но так он меняет cостояние объекта, применяя его в методах структуры стреляете себе в ногу, например необходимо сбрасывать значения полей при повторных вызовах.
Senshi26 Автор
Есть 2 мнения на этот счет. Однако мой подход приведен для наглядности примера с паттерном.
Можно сделать функцию, которая будет сбрасывать значение или возвращать null по умолчанию.
Не вижу почему это выстрел себе в ногу. Если возможно, буду благодрен за пример
lelikPtz
В том и проблема что можно написать любую функцию, которая будет менять состояние ScannedItem, и будет очень сложно контролировать все возможные варианты, например так:
и тогда в зависимости от порядка опций в вызове ScannedItem айтем с одними и теме же Datamatrix может быть и валидным и не валидным
lelikPtz
Option pattern удобно и более правильно использовать для установки параметров структуры отличных от дефолтных по умолчанию, например так:
В вашем случае, если в методе Scan надо научиться запускать кастомные валидаторы, то можно в структуру добавить validators []ScanFunc, задавать их список в конструкторе с так же с помощью opts, а в методе Scan вызывать
Senshi26 Автор
Спасибо за пример - асбсолютно согласен!
Можно будет даже дополнить статью им.
qeeveex
Согласен с @lelikPtz. Опции лучше использовать только в конструкторах!
Для смены состояний предлагаю использовать явные сеттеры.