golang

Паттерны Go — Паттерн «Опции» — ключ к простому рефакторингу в будущем

  • четверг, 4 июля 2024 г. в 00:00:03
https://habr.com/ru/articles/826412/

Привет дорогие Хабряне и случайно зашедшие добрые люди! Давно хотел написать статью о паттерне опций в функциях и почему его использование настолько шикарно. В статье будет упрощенный пример из жизни, так что не судите строго. Заранее благодарен за комментарии и указания на неточности.

Задача из жизни

Вам необходимо обрабатывать датаматрицы или 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

О том, как работать с конкурентностью в вариативной функции и как ее тестрировать напишу в следующей статье.

Спасибо всем за чтение! Пользуясь случаем, поблагодарю своего друга Андрея Арькова за помощь в написании статьи и поздравлю его с Днем Рождения!