Дневник изучения Go: запись 1

от автора

Наконец-то организовал себя, чтобы начать изучать Go. Как и полагается, решил сразу приступить к практике, дабы лучше освоиться с языком. Придумал себе «лабораторную работу», в которой планирую закреплять различные аспекты языка, не забывая при этом уже имеющийся опыт разработки на других языках, в частности — различные архитектурные принципы, включая SOLID и другие. Статью эту я пишу по ходу реализации самой идеи, озвучивая основные свои мысли и рассуждения о том, как сделать ту или иную часть работы. Так что это не статья по типу урока, где я пытаюсь научить кого-то как и что делать, а скорее просто лог моих мыслей и рассуждений для истории, чтобы было потом на что сослаться, делая работу над ошибками.

Вводная

Суть лабораторки в том, чтобы вести дневник денежных расходов при помощи консольного приложения. Функционал предварительно заключается в следующем:

  • пользователь может внести новую запись о расходе как за текущий день, так и за какой-либо день в прошлом, указав дату, сумму и комментарий

  • он также может делать выборки по датам, получив на выходе общую потраченную сумму

Формализация

Итак, по бизнес-логике у нас есть две сущности: отдельная запись о расходах (Expense) и общая сущность Diary, олицетворяющая дневник трат в целом. Expense состоит из таких полей как date, sum и comment. Diary пока ни из чего не состоит и просто олицетворяет сам дневник в целом, тем или иным образом содержа в себе набор объектов Expense, и соответственно позволяет их получить/модифицировать для различных целей. Его дальнейшие поля и методы будут видны далее. Поскольку мы говорим о последовательном списке записей, тем более упорядоченного по датам, напрашивается реализация в виде связанного списка сущностей. И в этом случае объект Diary может ссылаться всего лишь на первый элемент списка. В него также нужно добавить основные методы для манипуляции с с элементами (добавление/удаление и т.д.), но перебарщивать с наполнением этого объекта не стоит, чтобы он не брал на себя слишком многое, то есть не противоречил принципу единственной ответственности (Single responsibility — буква S в SOLID). Например, в него не стоит добавлять методы сохранения дневника в файл или чтения из него. Равно как и какие-либо другие специфические методы по анализу и сбору данных. В случае с файлом — это отдельный слой архитектуры (хранение), не связанный напрямую с бизнес-логикой. Во втором случае варианты использования дневника заранее неизвестны и могут сильно изменяться, что неминуемо приведет к постоянным изменениям в Diary, что очень нежелательно. Поэтому вся дополнительная логика будет вне этого класса.

Ближе к телу, то есть реализации

Итого имеем следующие структуры, если приземляться еще больше и говорить уже о конкретной реализации в Go:

// структура самой записи в дневнике type Expense struct {   Date time.Date   Sum float32   Comment string }  // Сам дневник type Diary struct {   Entries *list.List }

Работать со связанными списками лучше обобщенным решением, которое предоставляет, например, пакет container/list. Данные определения структур стоит вынести в отдельный пакет, который назовем expenses: создадим директорию внутри нашего проекта с двумя файлами: Expense.go и Diary.go.

Теперь поговорим о записи/чтении дневника, будь то в/из файла или других источников. Теоретически, способов сохранить дневник может быть масса: записать в файл (причем в разных форматах), загрузить напрямую на какой-нибудь веб-ресурс, или в БД записать, в конце концов, и так далее. Должны быть и соответствующие им способы загрузки дневника. От конкретных способов надо абстрагироваться, поэтому введем в наше проект интерфейс, который будет брать на себя эту абстракцию. У него будет два метода: Save(d *Diary) и Load() (*Diary). Так его и назовем: DiarySaveLoad, и поместим его во вложенный пакет expenses/io:

type DiarySaveLoad interface { 	Save(diary *expenses.Diary) 	Load() *expenses.Diary }

Эти методы не имеют никаких специфических параметров, которые бы описывали детали процесса сохранения/загрузки, потому как они могут очень сильно отличаться от одного способа сохранения/загрузки к другому (например, для файла необходимо указать путь, для веб-ресурса — URL и возможно другие параметры для установления соединения, и так далее). Эти дополнительные параметры будут определяться уже каждым конкретным объектом, реализующим приведенный выше интерфейс. Может показаться, что налицо явное нарушение принципа подстановки Лисков (Liskov substitution — буква L в SOLID), но это нарушение условное и может быть компенсировано дополнительными абстракциями. Во-первых, на этот интерфейс мы возлагаем исключительно саму операцию записи/сохранения дневника, и работа с ним будет независима от реализации в этом плане: мы всегда будем вызывать Save для сохранения и Load для загрузки. Что же касается нюансов конкретных способов, то им будет место, как уже сказал выше, в отдельных абстракциях, будь то, например, унифицированный для всех возможных параметров общий интерфейс DiarySaveLoadParameters, или же инициализация этих загрузчиков сторонними фабриками/строителями, и так далее. К этому вопросу можно будет вернуться позже. Зато мы пока как минимум не нарушили принцип разделения интерфейсов (Interface segregation — буква I в SOLID), ограничив его минимумом, общим для всех реализаций.

Пока на уме только сохранение дневника в файл, решил сразу написать конкретную реализацию для этого: FileSystemDiarySaveLoad. Конкретный формат файла сейчас не имеет особого значения, поэтому код пишу “на коленке”, чтобы по-скорее получить возможность сохранить/прочитать дневник трат в файл:

package io  import ( 	"expenses/expenses" 	"fmt" 	"os" )  type FileSystemDiarySaveLoad struct { 	Path string }  func (f FileSystemDiarySaveLoad) Save(d *expenses.Diary) { 	file, err := os.Create(f.Path) 	if err != nil { 		panic(err) 	}  	for e := d.Entries.Front(); e != nil; e = e.Next() { 		buf := fmt.Sprintln(e.Value.(expenses.Expense).Date.Format(time.RFC822)) 		buf += fmt.Sprintln(e.Value.(expenses.Expense).Sum) 		buf += fmt.Sprintln(e.Value.(expenses.Expense).Comment) 		if e.Next() != nil { 			buf += "\n" 		}  		_, err := file.WriteString(buf) 		if err != nil { 			panic(err) 		} 	} 	err = file.Close() }

Ну и симметричный метод загрузки из файла:

func (f FileSystemDiarySaveLoad) Load() *expenses.Diary { 	file, err := os.Open(f.Path) 	if err != nil { 		panic(err) 	}  	scanner := bufio.NewScanner(file) 	entries := new(list.List) 	var entry *expenses.Expense 	for scanner.Scan() { 		entry = new(expenses.Expense) 		entry.Date, err = time.Parse(time.RFC822, scanner.Text()) 		if err != nil { 			panic(err) 		} 		scanner.Scan() 		buf, err2 := strconv.ParseFloat(scanner.Text(), 32) 		if err2 != nil { 			panic(err2) 		} 		entry.Sum = float32(buf) 		scanner.Scan() 		entry.Comment = scanner.Text() 		entries.PushBack(*entry) 		entry = nil 		scanner.Scan() // empty line 	}  	d := new(expenses.Diary) 	d.Entries = entries  	return d }

Можно проверить работоспособность этого кода “на глаз”, вручную попытавшись сохранить/прочитать файл. Но думаю, будет лучше сразу написать отдельный тест для этого, который будет выглядеть следующим образом внутри файла expenses/io/FileSystemDiarySaveLoad_test.go:

package io  import ( 	"container/list" 	"expenses/expenses" 	"math/rand" 	"testing" 	"time" )  func TestConsistentSaveLoad(t *testing.T) {   path := "./test.diary"   d := getSampleDiary() 	saver := new(FileSystemDiarySaveLoad) 	saver.Path = path 	saver.Save(d)  	loader := new(FileSystemDiarySaveLoad) 	loader.Path = path 	d2 := loader.Load()  	var e, e2 *list.Element 	var i int  	for e, e2, i = d.Entries.Front(), d2.Entries.Front(), 0; e != nil && e2 != nil; e, e2, i = e.Next(), e2.Next(), i+1 { 		_e := e.Value.(expenses.Expense) 		_e2 := e2.Value.(expenses.Expense)  		if _e.Date != _e2.Date { 			t.Errorf("Data mismatch for entry %d for the 'Date' field: expected %s, got %s", i, _e.Date.String(), _e2.Date.String()) 		}     // аналогично проверяются остальные поля в Expense ... 	}  	if e == nil && e2 != nil { 		t.Error("Loaded diary is longer than initial") 	} else if e != nil && e2 == nil { 		t.Error("Loaded diary is shorter than initial") 	} }  func getSampleDiary() *expenses.Diary { 	testList := new(list.List)  	var expense expenses.Expense  	expense = expenses.Expense{ 		Date:    time.Now(), 		Sum:     rand.Float32() * 100, 		Comment: "First expense", 	} 	testList.PushBack(expense)    // аналогично добавляются еще записи   // ...  	d := new(expenses.Diary) 	d.Entries = testList  	return d }

Здесь мы создаем тестовый дневник со слегка рандомными данными, сохраняем его в файл, тут же читаем отдельным лоадером и сверяем идентичность полученных данных. В данном случае мы тестируем черный ящик, не вдаваясь в детали самого формата файла и вообще способа сохранения/загрузки: нам важно сохранить дневник, а потом загрузить его, получив исходные данные. Запускаем тест командой go test expenses/expenses/io -v

И видим сплошные FAIL с такими вот ошибками:

Data mismatch for entry 0 for the 'Date' field: expected 2020-09-14 04:16:20.1929829 +0300 MSK m=+0.003904501, got 2020-09-14 04:16:00 +0300 MSK

Причина тому: не полностью идентичная дата в записях. Создавая записи в коде, мы в качестве даты присваиваем time.Now, и эта дата включает в себя данные вплоть до долей секунды. Также можно заметить и другое отличие: в загрузчике/сохраняторе используется формат даты RFC822, который даже секунды не пишет, что скорее всего нам уже критичнее, чем отсутствие миллисекунд. И тут возникает двоякая ситуация. С одной стороны, объект, непосредственно сохраняющий запись, не вправе решать, какие данные существенны (в данном случае доли секунды), а какие нет. То есть он в идеале должен сохранить объект абсолютно точно. Или по крайней мере он должен быть кастомизируемым, если потребуется уточнить некоторые детали сохранения. Выражаясь в терминологии SOLID, он должен быть открытым для расширения, но закрытым для изменения (Open-closed principle — буква O в SOLID). В данном случае можно было бы указывать ему извне, какой формат использовать для записи даты. С другой стороны, если нам доли секунды не нужны с точки зрения бизнес-логики, то нужно избавляться на них уже на стадии создания объекта. Получается, логику создания экземпляров, очевидно, нужно вынести в какое-то единое место, чтобы она не дублировалась везде, где создаются экземпляры Expense. Для таких целей как правило используются конструкторы классов, но поскольку в Go конструкторов в явном виде не существует, просто напишем для этого отдельную функцию внутри пакета expenses:

func Create(date time.Time, sum float32, comment string) Expense { 	return Expense{Date: date.Truncate(time.Second), Sum: sum, Comment: comment} }

И нужно внести соответствующие изменения во все места, где создаются экземпляры Expense (звучит уже очень неприятно :D), а именно: метод Load в FileSystemDiarySaveLoad, а также в самом тесте (метод getSampleDiary). Эти изменения простые и приводить листинг смысла нет. И раз уж зашла речь о формате даты, то можно заодно также и вынести формат даты как отдельное поле у загрузчика, предусмотрев значение по умолчанию в виде, например, time.RFC3339Nano как максимально детализированный. Хотя справедливости ради стоит отметить, что только указание этого формата проблему бы не решило, поскольку даже он не записывает дату абсолютно полностью, и тест бы снова провалился.

Теперь тест отрабатывает отлично. На этом пока все на сегодня 🙂 Хотя стоит отметить, что с реализацией сохранения/загрузки в файл, а также с тестом на это, я однозначно поспешил. Уж больно хотелось получить сохраняемый в файл дневник 🙂 Проблема в том, что код в упомянутых выше частях проекта сейчас работает напрямую с внутренним связным списком объекта Diary, и это не есть хорошо. Скорее даже это очень плохо. Непосредственная реализация набора записей дневника (в данном случае связный список при помощи пакета container/list) — исключительно внутренняя «кухня» Diary, и внешнему миру совершенно необязательно об этом что-либо знать. Ему (миру) нужно взаимодействовать непосредственно с Diary, который, в свою очередь, должен предоставить интерфейс для соответствующих манипуляций. Но это уже будет тема и предмет рефакторинга для следующей части.

Заключение

Несмотря на заголовок, который говорит об изучении Go, запись получилась больше об архитектуре, нежели о каких-то тонкостях самого Go. Что, впрочем, не отменяет полезности проделанной работы для меня самого: лишний раз убедиться, что основные архитектурные принципы применимы независимо от языка. А также проверить себя на то, что в состоянии их применять в совершенно новом для себя языке. Ну а насколько примененные решения окажутся грамотными и полезными для дальнейшей разработки, покажет время 🙂

P.S. Репозиторий с проектом находится по адресу https://github.com/Amegatron/golab-expenses. Ветка master будет содержать самую последнюю версию работы. Метками (тэгами) буду отмечать последний коммит, сделанный в соответствии с каждой статьей. Например, последний коммит в соответствии с данной статьёй (запись 1) будет помечен тэгом stage_01.

ссылка на оригинал статьи https://habr.com/ru/post/518966/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *