Паттерны Go — Паттерн «Опции» — ключ к простому рефакторингу в будущем
- четверг, 4 июля 2024 г. в 00:00:03
Привет дорогие Хабряне и случайно зашедшие добрые люди! Давно хотел написать статью о паттерне опций в функциях и почему его использование настолько шикарно. В статье будет упрощенный пример из жизни, так что не судите строго. Заранее благодарен за комментарии и указания на неточности.
Вам необходимо обрабатывать датаматрицы или 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
О том, как работать с конкурентностью в вариативной функции и как ее тестрировать напишу в следующей статье.
Спасибо всем за чтение! Пользуясь случаем, поблагодарю своего друга Андрея Арькова за помощь в написании статьи и поздравлю его с Днем Рождения!