Go Patterns – The Options Pattern is the Key to Easy Refactoring in the Future

I will intentionally simplify the implementation so that you don't get bored.

We have a scanned product:

type ScannedItem struct {
	Datamatrix string //сами данные датаматрицы , которые расшифрованы
	Length     int   //кол-во символов в расшифрованной строке датаматрицы
	GS1        bool // Относится ли датаматрица к формату GS1
    Valid      bool // Правильная датаматрица или нет
    ErrorReason        []error // Ошибки в датаматрице при сканировании
}

*** You can read about the GS1 format Here. Let's just assume that the data matrix either relates to it or not.

Next we want to scan the data matrix and get data from it, which we will insert into the ScannedItem structure.

Let's write the following function:

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

	}

}

This function checks the string that is encrypted in the data matrix and the status of whether the data matrix is ​​of the GS1 format. Then the function cuts the string of 31 characters from the received data matrix if it does not belong to the GS1 format.
Everything seems simple:

  • Simple data structure

  • A simple function that does one thing.

    But the harsh reality sets in and we need to add features/options to our function.

I'm sure you face this problem all the time. There is a working function and now you need to either extend it, or write another one, or bring a new method to the interface, and there are probably many more ways to complicate your life.

Bad approach to the solution

Now we need to check whether the first 3 characters in the data matrix correspond to the string “010”, and if not, throw an error.

Let's write a test function

func (item *ScannedItem) CheckErrorDM() error {

	if item.Datamatrix[:3] != "010"/* // Будем считать, что 010 в начале  строки
датаматрицы - это канон */
		return errors.New("датаматрица имеет неверный формат")

	}
	return nil

}

That is, now we have a function that checks the data matrix for “lice” and if something is wrong, it spits out an error. It seems to be clear, but in this case, if we (for the sake of clarity) want to insert this check into our Scan() function, we willy-nilly have to insert an error there when outputting the result, but during the checks, our data matrix can change many fields and there can be several errors that we should add to the ErrorReason field.

Accordingly, we insert error handling into our Scan() function:

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
}

And now we already have a horror of dependencies in the code that we had already written before.

I am sure that you, dear readers, can suggest a dozen methods to improve the situation with the code. However, we are considering the most unsightly option. So be patient!

Now we've made the Scan() function more complicated and added work for the guys who will rewrite the code where it appears. Now we have a new error that the function can spit out. We'll have to live with it.

But what happens when we have to add more and more checks to the Scan() function?

How to live with the fact that this function may have a “stub” (default parameter values)?

Let's look at the “Options” pattern:

This pattern is great because it immediately solves the “stub” problem and simplifies life for those who will add new business logic to the Scan() function.

Let's start by turning our regular Scan() function into variable ScanWithOptions:

func (item *ScannedItem) ScanWithOptions(opts ...ScanFunc)error{
	
	///Здесь будет наша логика
	
  return nil
}

Our function will accept an arbitrary number of arguments as input (it can be run without parameters). However, we see that all these arguments are of the ScanFunc type.

Let's define a new ScanFunc type that will meet the conditions of our main function.

type ScanFunc func(item *ScannedItem)

As we can see, the ScanFunc type is a function that does something with our ScannedItem struct.
Now we can start creating functions that will tell our parent Scan function how to behave.

Let's set the standard behavior for Scan() as a function returning the initial struct ScannedItem – this will be the loading, for example, when we cannot know what arrived:

func (item *ScannedItem) DefaultScan() ScannedItem {

	err := errors.New("использована заглушка")

	return ScannedItem{"010456789123456789123456789123456789", 36, false, false, append(item.ErrorReason, err)}

}

This may not be a perfect example, but it allows us to get what we expect in our ScanWithOptions() function, i.e. some default value:

func (item *ScannedItem) ScanWithOptions(opts ...ScanFunc)error{
	
	
	defaultScanItem := item.DefaultScan() /* Теперь мы точно знаем, что мы будем подставлять
 значение , возвращаемое из DefaultScan()*/
return nil
}

Let's now move on to the most interesting part, namely the options themselves, with which we can run our ScanWithOptions() variadic function. We can now actually assign it a multitude of optional checks that we were obliged to include in the code. Now it's optional!

Let's start by checking for 010 at the beginning of the datamatrix line (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
		}

	}

}

As we can see, our helper function CheckErrorWithReason() works with the ScannedItem object (struct). and returns the ScanFunc type. We set this type ourselves, and therefore we follow our own conditions:

If the condition is met, we return an unnamed function that operates on the initial ScannedItem object and transforms its fields as we see fit. In this case, it passes or does not pass the error to the item.ErrorReason field + once again reassigns item.Valid to false or true

Let's see how we can run the ScanWithOptions() function

with a new check:

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 форматом"))

		}
	}

}

This check was embedded in the body of the Scan function, but now we are moving it into a separate optional function – it will check for the presence of the true value in the GS1 field and cut off 31 characters from the data matrix:

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
		}

	}
}

We return ScanFunc again, since this is the type and no other that we should use as an argument to the ScanWithOptions() variadic function.

We now have a number of optional arguments that we can use in the parent function ScanWithOptions(). Let's see how we handle them in the function body:

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
}

Now all we have to do is initialize our function and apply any number of checks or data manipulations. All of them are optional:

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)}

}


Let's print the result of execution without options:

&{Datamatrix:010456789123456789123456789123456789 Length:36 GS1:false 
Valid:false ErrorReason:[использована заглушка]}:

and if we set all the options we get:

&{Datamatrix:01045678912345678912345678912345678988888888888888888 Length:53 
GS1:true Valid:true ErrorReason:[]}

We see that the function is short, concise and flexible. The most important thing is, that new checks (functions) can now be safely developed without fear that we will get dependencies in the body of the main function.

Now, when you need to improve something, you can simply add a new option!

But how to test this?

Testing the Options Pattern

We understand that we will need arguments for test cases. At the same time, we understand that at the output we will need a function that returns a reference to struct ScannedItems, so we will create this function:

func HelperFunc() *ScannedItem {
	a := &ScannedItem{
		Datamatrix:  "01045678912345678912345678912345678988888888888888888",
		Length:      len("01045678912345678912345678912345678988888888888888888") + 1,
		GS1:         false,
		Valid:       false,
		ErrorReason: nil,
	}
	return a

}

We can create as many of these functions or references to objects as we want, but we need this function in order to get the ScanFunc type and then use it for the test.

This is how we get arguments for a test function of type 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*/

Let's put everything together in a test function:

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)
			}
		})
	}
}

We can check both different options (ScanFunc) and different input data (ScannedItem).

Here are the results

=== 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

I will write about how to work with concurrency in a variable function and how to test it in the next article.

Thank you all for reading! I would like to take this opportunity to thank my friend Andrey Arkov for helping me write this article and wish him a happy birthday!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *