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