[Перевод] Анти-паттерны в Go Web Applications

В какой-то момент моей карьеры меня перестало радовать ПО которое я разрабатывал.

Больше всего мне нравилось работать с низкоуровневыми деталями и сложными алгоритмами. Но после перехода на пользовательские приложения эта часть работы почти исчезла. Теперь программирование казалось мне просто перемещением данных из одного места в другое с помощью уже готовых библиотек и инструментов. Знания, которые я получил раньше о программном обеспечении, уже не были такими полезными.

Откровенно говоря, большинство веб-приложений не занимаются решением сложных технических задач. Их основная цель — правильно смоделировать продукт и позволить улучшать его быстрее, чем это делают конкуренты.

Поначалу это кажется скучным, но потом приходит понимание, что достичь этой цели сложнее, чем кажется на первый взгляд. Здесь стоит совершенно иной набор задач. Даже если они и не так сложны в техническом плане, их решение оказывает огромное влияние на продукт и приносит глубокое удовлетворение.

Главный вызов стоящий перед веб приложениями заключается в том, чтобы не дать им превратиться в огромную кучу сами знаете чего (»Big Ball of Mud»). Это не только замедляет разработку, но и может уничтожить проект.

Ниже перечислены причины почему это происходит и как я научился с этим справляться.

Слабая зависимость — ключ к успеху

Частая причина, по которой приложения становятся трудно поддерживаемыми, — это сильная зависимость.

В сильно зависимых приложениях любое изменение приводит к непредвиденным последствиям. Каждая попытка рефакторинга выявляет новые проблемы. В конце концов решаете, что лучше переписать проект с нуля. В быстрорастущем продукте вы не можете позволить себе остановить всю разработку, чтобы переделать то, что уже создано. У вас нет гарантии, что в этот раз вы сделаете всё правильно.

Наоборот, приложения с небольшой взаимосвязью имеют чёткие границы. Это позволяет заменить неработающую часть, не затрагивая остальные элементы проекта. Их проще создавать и поддерживать. Но почему же они встречаются так редко?

Микросервисы обещали нам гибкость и лёгкость в обслуживании приложений, но после их популярности мы всё ещё сталкиваемся с трудностями в управлении сложными системами. Иногда ситуация даже ухудшается, и мы попадаем в ловушку распределённых монолитов, оставаясь перед теми же проблемами, что и раньше, но с дополнительными сложностями, связанными с сетевыми задержками.

bc078d692fce02abeaae3c3ce0d3b923.jpg

❌Вместо: Распределенный монолит

Микросервисы не снижают взаимосвязь, потому что не важно, сколько раз вы разделяете приложение. Важно то, как вы соединяете части.

9cc55ca9aabf70334e12bbd819bf6b1d.jpg

  • ✅ Нужно: Снижение зависимости.

    Необходимо снижать зависимость модулей. Как вы разворачиваете их (как модульный монолит или микросервис) это детали имплеминтации.

Принцип «Не повторяйся» (DRY) добавляет зависимость.

Краткие правила легко запомнить, но трудно охватить все детали всего тремя словами. Книга «Прагматический программист»(The Pragmatic Programmer) предлагает более длинную версию:

Каждая часть знаний должна иметь единое, недвусмысленное, авторитетное представление в рамках системы

Фраза «Каждая часть знаний» кажется достаточно резкой. Ответ на большинство программистских дилемм всегда «это зависит», и с DRY это не исключение.

Когда вы делаете две сущности используя общую абстракцию, вы добавляете зависимость. Если следовать принципу DRY слишком строго, вы добавляете абстракции до того, как они становятся необходимыми.

bcb785dd5e42242ed4a2dcda236f9c17.jpeg

Суть DRY в GO

По сравнению с другими современными языками, в Go нет такого набора «фичей». В нем не так много синтаксического сахара, чтобы скрыть сложность.

Мы привыкли что краткость — сестра таланта, по этому многословность go воспринимается нами с трудом. Похоже, у нас выработался инстинкт поиска «более лаконичных» способов написания кода.

Лучший пример — обработка ошибок. Если у вас есть опыт написания кода на Go, этот фрагмент покажется вам естественным:

if err != nil {
    return err
}

Новичкам повторять эти три строчки снова и снова кажется нарушением правила DRY. Они часто ищут способ избежать этого шаблона, но добром это никогда не заканчивается.

В конце концов, все признают, что именно так работает Go. Это заставляет вас повторяться, но это не то дублирование, которого DRY советует вам избегать.

Единая Модель Увеличивает Зависимость

В Go есть одна фишка, которая обеспечивает сильную зависимость и заставляет вас думать, что вы следуете DRY. Она использует несколько тегов в одной структуре. Это кажется хорошей идеей, потому что мы часто используем схожие модели для разных целей.

Вот популярный способ сохранить модель User.

type User struct {
	ID           int        `json:"id" gorm:"autoIncrement primaryKey"`
	FirstName    string     `json:"first_name" validate:"required_without=LastName"`
	LastName     string     `json:"last_name" validate:"required_without=FirstName"`
	DisplayName  string     `json:"display_name"`
	Email        string     `json:"email,omitempty" gorm:"-"`
	Emails       []Email    `json:"emails" validate:"required,dive" gorm:"constraint:OnDelete:CASCADE"`
	PasswordHash string     `json:"-"`
	LastIP       string     `json:"-"`
	CreatedAt    *time.Time `json:"-"`
	UpdatedAt    *time.Time `json:"-"`
}

type Email struct {
	ID      int    `json:"-" gorm:"primaryKey"`
	Address string `json:"address" validate:"required,email" gorm:"size:256;uniqueIndex"`
	Primary bool   `json:"primary"`
	UserID  int    `json:"-"`
}

Такой подход требует нескольких строк кода и позволяет поддерживать только одну структуру.

Однако поддержание работоспособности в рамках одной модели требуется хитрости. API не должен предоставлять доступ к некоторым полям, поэтому они скрыты с помощью json:»-». Только одна конечная точка API использует поле Email, поэтому ORM пропускает его, а тег omitempty скрывает его от обычных ответов в формате JSON

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

Когда вы хотите обновить что-либо в структуре, вы понятия не имеете, что еще может измениться. Вы можете нарушить контракт API, изменив схему базы данных, или повредить сохраненные данные при обновлении правил проверки.

Чем сложнее модель, тем с большим количеством проблем вы сталкиваетесь.

Например, тег json означает JSON, а не HTTP. Что происходит, когда вы вводите события, которые также преобразуются в JSON, но в формате, немного отличающемся от формата ответа API? Вы будете продолжать добавлять хаки, чтобы заставить это работать.

В конечном счете, ваша команда избегает каких-либо изменений в структуре, потому что не знает, что может сломаться после того, как вы к этому прикоснетесь.

c5aa4892e9ea618660245684ebe51124.png

❌ Вместо: The Single Model

Не назначайте одной модели более одной ответственности.

Не используйте более одного тега для каждого поля структуры.

Дублирование уменьшает зависимость

Самый простой способ уменьшить зависимость — использовать отдельные модели.

Мы извлекаем часть, относящуюся к API, в виде HTTP-моделей:

type CreateUserRequest struct {
	FirstName string `json:"first_name" validate:"required_without=LastName"`
	LastName  string `json:"last_name" validate:"required_without=FirstName"`
	Email     string `json:"email" validate:"required,email"`
}

type UpdateUserRequest struct {
	FirstName *string `json:"first_name" validate:"required_without=LastName"`
	LastName  *string `json:"last_name" validate:"required_without=FirstName"`
}

type UserResponse struct {
	ID          int             `json:"id"`
	FirstName   string          `json:"first_name"`
	LastName    string          `json:"last_name"`
	DisplayName string          `json:"display_name"`
	Emails      []EmailResponse `json:"emails"`
}

type EmailResponse struct {
	Address string `json:"address"`
	Primary bool   `json:"primary"`
}

И часть, связанная с базой данных, в виде моделей хранения:

type UserDBModel struct {
	ID           int            `gorm:"column:id;primaryKey"`
	FirstName    string         `gorm:"column:first_name"`
	LastName     string         `gorm:"column:last_name"`
	Emails       []EmailDBModel `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
	PasswordHash string         `gorm:"column:password_hash"`
	LastIP       string         `gorm:"column:last_ip"`
	CreatedAt    *time.Time     `gorm:"column:created_at"`
	UpdatedAt    *time.Time     `gorm:"column:updated_at"`
}

type EmailDBModel struct {
	ID      int    `gorm:"column:id;primaryKey"`
	Address string `gorm:"column:address;size:256;uniqueIndex"`
	Primary bool   `gorm:"column:primary"`
	UserID  int    `gorm:"column:user_id"`
}

Сначала казалось, что мы будем везде использовать одну и ту же User модель. Теперь ясно, что мы слишком рано отказались от дублирования. API и структуры хранения данных похожи, но все же достаточно различаются, и заслуживают отдельные модели.

В веб-приложениях представления, которые возвращает API (модели чтения), отличаются от того, что вы храните в базе данных (модели записи).

Слой БД не должен ничего знать о моделях HTTP, поэтому нам нужно преобразовать структуры.

81426a877f661eb7fc77b4d1e8b55149.png

func userResponseFromDBModel(u UserDBModel) UserResponse {
	var emails []EmailResponse
	for _, e := range u.Emails {
		emails = append(emails, emailResponseFromDBModel(e))
	}
	
	return UserResponse{
		ID:          u.ID,
		FirstName:   u.FirstName,
		LastName:    u.LastName,
		DisplayName: displayName(u.FirstName, u.LastName),
		Emails:      emails,
	}
}

func emailResponseFromDBModel(e EmailDBModel) EmailResponse {
	return EmailResponse{
		Address: e.Address,
		Primary: e.Primary,
	}
}

func userDBModelFromCreateRequest(r CreateUserRequest) UserDBModel {
	return UserDBModel{
		FirstName: r.FirstName,
		LastName:  r.LastName,
		Emails: []EmailDBModel{
			{
				Address: r.Email,
			},
		},
	}
}

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

Возникает желание создать универсальное решение для отображения структур данных, например, с помощью маршалинга или использования reflect. Однако стоит этому желанию противостоять. Создание шаблона требует меньше времени и усилий, чем устранение проблем, связанных с отображением данных в крайних случаях. Простые функции понятны всем в вашей команде. Со временем даже вам будет сложно разобраться в сложных механизмах преобразования данных.

✅  Нужно: Одна модель — одна ответственность.

Постарайтесь снизить уровень зависимости, применяя отдельные модели. Напишите простые и понятные функции для преобразования между моделями.

Если опасаетесь, что код будет часто повторяться, подумайте о самом плохом сценарии развития событий. Даже если в результате останется несколько структур, которые не будут меняться по мере роста приложения, вы сможете объединить их в одину. В отличие от тесно связанного кода, исправление повторяющегося кода не составит труда.

Генерация шаблонного кода

Если вас пугает перспектива вручную писать весь этот код, есть более простой и идиоматический способ — используйте библиотеки, которые сгенерируют код за вас.

Вы можете генерировать такие вещи, как:

  • HTTP модели и маршруты из определения OpenAPI (oapi-codegen и другие библиотеки).

  • Модели базы данных и связанный с ними код из SQL схемы (sqlboiler и другие ORM).

  • gRPC модели из файлов ProtoBuf.

Сгенерированный код предоставляет вам строгие типы, поэтому вам больше не нужно передавать interface{} в общие функции. Вы сохраняете проверку на этапе компиляции и не нуждаетесь в написании кода вручную.

Вот как выглядят сгенерированные модели.

// PostUserRequest defines model for PostUserRequest.
type PostUserRequest struct {
	
	// E-mail
	Email string `json:"email"`
	
	// First name
	FirstName string `json:"first_name"`
	
	// Last name
	LastName string `json:"last_name"`
}

// UserResponse defines model for UserResponse.
type UserResponse struct {
	DisplayName string          `json:"display_name"`
	Emails      []EmailResponse `json:"emails"`
	FirstName   string          `json:"first_name"`
	Id          int             `json:"id"`
	LastName    string          `json:"last_name"`
}
type User struct {
	ID           int64       `boil:"id" json:"id" toml:"id" yaml:"id"`
	FirstName    string      `boil:"first_name" json:"first_name" toml:"first_name" yaml:"first_name"`
	LastName     string      `boil:"last_name" json:"last_name" toml:"last_name" yaml:"last_name"`
	PasswordHash null.String `boil:"password_hash" json:"password_hash,omitempty" toml:"password_hash" yaml:"password_hash,omitempty"`
	LastIP       null.String `boil:"last_ip" json:"last_ip,omitempty" toml:"last_ip" yaml:"last_ip,omitempty"`
	CreatedAt    null.Time   `boil:"created_at" json:"created_at,omitempty" toml:"created_at" yaml:"created_at,omitempty"`
	UpdatedAt    null.Time   `boil:"updated_at" json:"updated_at,omitempty" toml:"updated_at" yaml:"updated_at,omitempty"`
	
	R *userR `boil:"-" json:"-" toml:"-" yaml:"-"`
	L userL  `boil:"-" json:"-" toml:"-" yaml:"-"`
}

Иногда вам может понадобиться написать инструмент для генерации кода. Это не так сложно, и результатом будет обычный Go-код, который каждый сможет прочитать и понять. Альтернатива — рефлексия, которую сложно понять и отладить. Конечно, сначала подумайте, стоит ли это усилий. В большинстве случаев написание кода вручную будет достаточно быстрым.

✅  Нужно: Генерация повторяющихся частей

Сгенерированный код предоставляет вам строгие типы и проверку на этапе компиляции. Выбирайте его вместо рефлексии.

Не злоупотребляйте библиотеками

Используйте сгенерированный код только для того, для чего он предназначен. Вы хотите избежать написания шаблонного кода вручную, но все же следует сохранять несколько специализированных моделей. Не попадайтесь в ловушку антипаттерна единой модели.

Легко попасть в эту ловушку, когда вы хотите следовать принципу DRY.

Например, проекты sqlc и sqlboiler генерируют код из SQL-запросов. sqlc позволяет добавлять теги JSON к сгенерированным моделям и даже позволяет выбирать между camelCase и snake_case. sqlboiler добавляет теги json, toml и yaml ко всем моделям по умолчанию. Очевидно, что люди используют эти модели не только для хранения.

Просматривая вопросы по sqlc, я нашел разработчиков, которые просят еще больше гибкости, например, переименовывать сгенерированные поля или пропускать некоторые поля JSON полностью. Кто-то даже упоминает, что им нужно скрыть чувствительные поля в REST API.

Все это побуждает поддерживать единую модель для множества обязанностей. Это позволяет вам писать меньше кода, но всегда учитывайте, стоит ли такая связь усилий.

Также остерегайтесь магии, скрытой в тегах структур. Например, рассмотрим модель разрешений, поддерживаемую gorm:

type User struct {
Name string gorm:"<-:create" // разрешить чтение и создание
Name string gorm:"<-:update" // разрешить чтение и обновление
Name string gorm:"<-" // разрешить чтение и запись (создание и обновление)
Name string gorm:"<-:false" // разрешить чтение, запретить запись
Name string gorm:"->" // только чтение (запрещает запись, если не настроено иначе)
Name string gorm:"->;<-:create" // разрешить чтение и создание
Name string gorm:"->:false;<-:create" // только создание (запрещено чтение из БД)
Name string gorm:"-" // игнорировать это поле при записи и чтении с использованием структуры
}

Вы также можете использовать довольно сложные сравнения с использованием библиотеки валидаторов, например, ссылаясь на другие поля:

type User {
FirstName string validate:"required_without=LastName"
LastName string validate:"required_without=FirstName"
}

Это экономит вам немного времени на написание кода, но вы отказываетесь от проверок на этапе компиляции. Легко сделать ошибку в теге структуры, и это риск, особенно для чувствительных областей, таких как валидация и разрешения. Это также запутывает тех, кто не знаком с архаичной синтаксисом библиотеки.

Я не хочу критиковать упомянутые библиотеки. У всех них есть свои применения, но эти примеры показывают, как мы стремимся довести принцип DRY до крайностей, чтобы не писать больше кода.

❌ Вместо: Использование магии для экономии времени на написание кода

Не злоупотребляйте библиотеками, чтобы избежать многословия.

Избегайте неявных имен тегов

Большинство библиотек не требуют обязательного наличия тегов и используют имена полей по умолчанию.

При рефакторинге проекта кто-то может переименовать поле, не зная, что он редактирует ответ API или модель базы данных. Если теги отсутствуют, это может нарушить ваш API-контракт или даже повредить ваше хранилище.

Всегда заполняйте все теги. Даже если вам нужно дважды набрать одно и то же имя, это не противоречит принципу DRY.

❌ Вместо: Пропуск тегов структуры

Не пропускайте теги структуры, если библиотека их использует.

type Email struct {
ID int gorm:"primaryKey"
Address string gorm:"size:256;uniqueIndex"
Primary bool
UserID int
}

✅  Нужно: Явные теги структуры

Всегда заполняйте теги структуры, даже если имена полей совпадают.

type Email struct {
ID int gorm:"column:id;primaryKey"
Address string gorm:"column:address;size:256;uniqueIndex"
Primary bool gorm:"column:primary"
UserID int gorm:"column:user_id"
}

Отделите логику от деталей реализации

Разделение API от хранилища и использование сгенерированных моделей — хороший старт. Но мы все еще оставляем валидацию в HTTP-обработчиках.

type createRequest struct {
	Email     string `validate:"required,email"`
	FirstName string `validate:"required_without=LastName"`
	LastName  string `validate:"required_without=FirstName"`
}

validate := validator.New()
err = validate.Struct(createRequest(postUserRequest))
if err != nil {
	log.Println(err)
	w.WriteHeader(http.StatusBadRequest)
	return
}

Валидация — это только одна часть бизнес-логики, присутствующая в HTTP-обработчиках. Часто будет больше:

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

  • проверка разрешений,

  • скрытие полей в зависимости от роли,

  • расчет цены,

  • принятие действий в зависимости от нескольких факторов.

Смешивание логики с деталями реализации (например, держание их в HTTP-обработчиках) — это быстрый способ доставить MVP. Но это также вводит наихудший вид технического долга. Это причина, по которой вы попадаете в ловушку поставщика и почему вы продолжаете добавлять хаки для поддержки новых функций.

❌ Вместо: Смешивание логики и деталей

Не смешивайте бизнес-логику вашего приложения с деталями реализации.

Бизнес-логика заслуживает собственного слоя. Изменение реализации (движок базы данных, библиотека HTTP, инфраструктура, Pub/Sub и т.д.) должно быть возможным без каких-либо изменений в логических частях.

Вы делаете это разделение не потому, что ожидаете сменить базу данных. Это случается редко. Но разделение ответственности делает ваш код легким для понимания и модификации. Вы знаете, что именно меняете, и что не будет побочных эффектов. Труднее добавить баги в самые важные части.

Чтобы отделить уровень приложения, нам нужно добавить дополнительные модели и маппинги.

aa9cf9e3168ee88f5269c1bab1e8905f.png

type User struct {
	id        int
	firstName string
	lastName  string
	emails    []Email
}

func NewUser(firstName string, lastName string, emailAddress string) (User, error) {
	if firstName == "" && lastName == "" {
		return User{}, ErrNameRequired
	}

	email, err := NewEmail(emailAddress, true)
	if err != nil {
		return User{}, err
	}

	return User{
		firstName: firstName,
		lastName:  lastName,
		emails:    []Email{email},
	}, nil
}

type Email struct {
	address string
	primary bool
}

func NewEmail(address string, primary bool) (Email, error) {
	if address == "" {
		return Email{}, ErrEmailRequired
	}

	// A naive validation to make the example short, but you get the idea
	if !strings.Contains(address, "@") {
		return Email{}, ErrInvalidEmail
	}

	return Email{
		address: address,
		primary: primary,
	}, nil
}

Это код, с которым я хотел бы работать, когда мне нужно обновить бизнес-логику. Он скучный, очевидный, и я точно знаю, что изменится.

Мы делаем то же самое, когда добавляем другой API, например gRPC, или внешнюю систему, такую как Pub/Sub. Каждая часть использует отдельные модели, и мы используем те, которые находятся на уровне приложения, чтобы маппить их.

Поскольку модели приложения содержат все валидации и другие бизнес-правила, не имеет значения, используем ли мы их из HTTP или gRPC API. API — это просто точка входа в приложение.

baf332a24480bc8e92a5ddb110a1e233.jpeg

✅  Нужно: Уровень приложения

Посвятите отдельный слой самому важному коду вашего продукта.

Приведенные выше примеры кода взяты из одного и того же репозитория и реализуют классический домен «пользователи». Все примеры предоставляют один и тот же API и проходят один и тот же набор тестов.

Вот как они сравниваются:

Тесно связанный

Слабо связанный

Слабо связанный сгенерированный

Слабо связанный слой приложения

Зависимость

Сильная

Средняя

Средняя

Шаблонный код

Ручной

Ручной

Сгенерированный

Строк кода

292

345

298

Сгенерированный код

0

0

2154

Стандартная структура проекта на Go

Если вы видели репозиторий, вы могли быть удивлены, что в каждом примере есть только один пакет.

В Go нет официальной структуры директорий. Вы можете найти множество репозиториев «пример микросервиса» или «шаблон REST», которые предлагают, как разделить пакеты. Они обычно имеют хорошо продуманную структуру директорий. Некоторые даже упоминают, что они следуют «Чистой архитектуре» или «Гексагональной архитектуре».

Первое, что я проверяю, это как пример хранит модели. Чаще всего он использует структуры с JSON и тегами базы данных вместе.

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

Иронично, что обсуждение «Стандартной структуры проекта на Go» продолжается в сообществе, в то время как антипаттерн одной модели широко распространен. Если типы связывают ваше приложение, никакая структура директорий не изменит этого.

Когда вы смотрите на примеры структур, помните, что они могут быть разработаны для другого типа приложения. Ни один подход не работает одинаково хорошо для открытого инфраструктурного инструмента, бэкенда веб-приложения и стандартной библиотеки.

Проблема с иерархией пакетов аналогична разделению микросервисов. Важная часть не в том, как вы их разделяете, а в том, как они соединены.

Когда вы сосредотачиваетесь на слабой связи, структура становится очевидной. Вы отделяете детали реализации от бизнес-логики. Вы группируете вещи, которые ссылаются друг на друга, и держите отдельно то, что не связано.

В примерах, которые я подготовил, я мог бы легко переместить код, связанный с HTTP и базой данных, в отдельные пакеты. Это сделало бы пространство имен менее загроможденным. Уже нет связи между моделями, поэтому это просто деталь.

❌ Вместо: Перемудривание со структурой директорий

Не начинайте проект с разделения директорий. Как бы вы это ни делали, это всего лишь соглашение.

Мало вероятно, что вы сделаете это правильно, не написав ни одной строки кода.

✅  Нужно: Слабо связанный код

Смысл не в структуре директорий, а в том, как пакеты и структуры ссылаются друг на друга.

Сохраняйте простоту

Предположим, вы хотите создать пользователя с полем ID. Самый простой подход может выглядеть так:

type User struct {
	ID string `validate:"required,len=32"`
}

func (u User) Validate() error {
	return validate.Struct(u)
}

Это рабочий код. Однако вы не можете сказать, корректна ли структура в любой момент. Вы полагаетесь на что-то, что вызывает валидацию и правильно обрабатывает ошибку.

Другой подход следует старому доброму принципу инкапсуляции.

type User struct {
	id UserID
}

type UserID struct {
	id string
}

func NewUserID(id string) (UserID, error) {
	if id == "" {
		return UserID{}, ErrEmptyID
	}
	
	if len(id) != 32 {
		return UserID{}, ErrInvalidLength
	}

	return UserID{
		id: id,
	}, nil
}

func (u UserID) String() string {
	return u.id
}

Этот фрагмент более явный и многословный. Если вы создаете новый UserID и не получаете ошибку, вы уверены, что он действителен. В противном случае вы можете легко сопоставить ошибку с соответствующим ответом, специфичным для вашего API.

Какой бы подход вы ни выбрали, вам нужно смоделировать основную сложность идентификатора пользователя. С чисто реализационной точки зрения, хранение идентификатора в строке — это самое простое решение.

Go должен быть простым, но это не значит, что вы должны использовать только примитивные типы. Для сложных поведений используйте код, который отражает, как работает продукт. В противном случае вы закончите с упрощенной моделью.

❌  Вместо: Чрезмерное упрощение

Не моделируйте сложное поведение с помощью тривиального кода.

✅  Нужно: Пишите очевидный код

Будьте явными, даже если это приведет к многословности.

Используйте инкапсуляцию, чтобы ваши структуры всегда находились в допустимом состоянии.

Примечание

Возможно создать пустую структуру вне пакета, даже если все поля неэкспортируемые. Это единственное, что вам нужно проверить при приеме UserID в качестве параметра.

Вы можете либо использовать if id == UserID{}, либо написать специальный метод IsZero(), который это сделает.

Начинайте со схемы базы данных

Давайте рассмотрим возможность добавления команд, которые пользователи создают и к которым присоединяются.

Следуя реляционному подходу, мы бы добавили таблицу teams и еще одну, которая связывает её с пользователями. Назовем её membership.

59d861f0d0a2d959d3585e75bfc3d388.jpeg

Мы уже поддерживаем UserStorage, поэтому естественно добавить еще две структуры: TeamStorage и MembershipStorage. Они предоставляют методы CRUD для каждой таблицы.

Пример добавления новой команды может выглядеть так:

func CreateTeam(teamName string, ownerID int) error {
    teamID, err := teamStorage.Create(teamName)
    if (err != nil) {
        return err
    }
    
    return membershipStorage.Create(teamID, ownerID, MemberRoleOwner)
}

Этот подход имеет одну проблему: мы не создаем команду и запись о членстве в рамках одной транзакции. Мы можем оказаться с командой без назначенного владельца, если что-то пойдет не так.

Первое решение, которое приходит на ум, это передача объекта транзакции между методами.

func CreateTeam(teamName string, ownerID int) error {
    tx, err := db.Begin()
    if (err != nil) {
        return err
    }
    
    defer func() {
        if (err == nil) {
            err = tx.Commit()
        } else {
            rollbackErr := tx.Rollback()
            if (rollbackErr != nil) {
                log.Error("Rollback failed:", err)
            }
        }
    }()
    
    teamID, err := teamStorage.Create(tx, teamName)
    if (err != nil) {
        return err
    }
    
    return membershipStorage.Create(tx, teamID, ownerID, MemberRoleOwner)
}

Вот упражнение: рассмотрите, как бы мы смоделировали это в документной базе данных. Например, мы могли бы хранить всех членов внутри документа Team.

bfaf11e18178295aecae2a325a0b4082.jpeg

В этом сценарии добавление членов выполняется в TeamStorage. Нам не потребуется отдельный MembershipStorage. Разве не странно, что смена базы данных изменяет наше представление о моделях?

Теперь понятно, что мы раскрыли детали реализации, введя концепцию «членства». Говоря «создать новое членство», мы только запутаем наших коллег из отдела продаж или обслуживания клиентов. Это серьезный сигнал тревоги, когда вы начинаете говорить на другом языке, чем остальная часть компании.

❌  Вместо: Начало со схемы базы данных

Не основывайте свои модели на схеме базы данных. Вы в конечном итоге раскроете детали реализации.

TeamStorage хранит команды, но это не про таблицу teams в SQL. Это про концепцию команды в нашем продукте.

Когда вы начинаете моделировать с домена, вы видите реальные поведения вместо методов CRUD. Вы также замечаете границы транзакций.

Каждый понимает, что создание команды требует владельца, и мы можем предоставить метод для этого. Метод выполняет все запросы в рамках одной транзакции.

teamStorage.Create(teamName, ownerID, MemberRoleOwner)

Аналогично, мы могли бы оставить метод для присоединения к команде.

teamStorage.JoinTeam(teamID, memberID, MemberRoleGuest)

Таблица membership все еще существует, но это деталь реализации, скрытая в TeamStorage.

✅ Нужно: Начните с домена

Ваши методы хранения должны следовать поведению продукта. Не раскрывайте транзакции вне их.

Ваше веб-приложение не CRUD

Учебники часто представляют «простые CRUD», поэтому кажется, что они являются строительным блоком любого веб-приложения. Это миф. Если вашему продукту нужен только CRUD, вы тратите время и деньги, создавая его с нуля.

Фреймворки и инструменты без кода упрощают создание CRUD, но мы все равно платим разработчикам за создание пользовательского программного обеспечения. Даже GitHub Copilot не знает, как работает ваш продукт, кроме шаблонного кода.

Это особые правила и странные детали делают ваше приложение уникальным. Это не логика, которую вы добавляете поверх четырех операций CRUD. Это ядро продукта, который вы продаете.

На стадии MVP заманчиво начать с CRUD, чтобы быстро создать рабочую версию. Но это как использовать таблицу вместо специализированного программного обеспечения. Вы получите аналогичные результаты сначала, но каждая новая функция потребует больше обходных решений.

❌  Вместо: Начало с CRUD

Не проектируйте свое приложение вокруг идеи четырех операций CRUD.

✅  Нужно: Поймите свой домен

Потратьте время на понимание работы вашего продукта и смоделируйте это в коде.

Многие из описанных мной тактик связаны с известными паттернами:

Некоторые из них близки к Domain-Driven Design:

  • Value Objects (структуры всегда в допустимом состоянии).

  • Aggregate и Repository (сохранение объектов домена транзакционно независимо от количества таблиц базы данных).

  • Ubiquitous Language (использование языка, понятного всем).

Эти паттерны кажутся связанными в основном с корпоративными приложениями. Но большинство из них касаются простых основных идей, таких как «Нужно» из этой статьи. Они применимы и в веб-приложениях, которые часто имеют дело со сложным бизнес-поведением.

Вам не нужно читать тяжелые книги или копировать, как все работает в других языках, чтобы следовать этим паттернам. Можно писать идиоматичный Go-код вместе с проверенными на практике техниками. Если хотите узнать больше о них, ознакомьтесь с нашей бесплатной электронной книгой.

Habrahabr.ru прочитано 4067 раз