Наконец-то организовал себя, чтобы начать изучать 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/
Добавить комментарий