[Программирование, Проектирование и рефакторинг, Go] Дневник изучения Go: запись 1

Автор Сообщение
news_bot ®

Стаж: 6 лет 7 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
14-Сен-2020 15:35

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

Похожие новости: Теги для поиска: #_programmirovanie (Программирование), #_proektirovanie_i_refaktoring (Проектирование и рефакторинг), #_go, #_solid, #_design_patterns, #_go, #_golang, #_learning, #_personalnyj_blog (персональный блог), #_programmirovanie (
Программирование
)
, #_proektirovanie_i_refaktoring (
Проектирование и рефакторинг
)
, #_go
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 28-Сен 11:15
Часовой пояс: UTC + 5