Эта статья прежде всего будет полезна студентам и начинающим специалистам.

В отрасли разработки программного обеспечения под системным дизайном (System Design) понимают процесс проектирования архитектуры, компонентов, баз данных и связей информационной системы. Цель системного дизайна - обеспечить высокую производительность, отказоустойчивость и масштабируемость системы (способность выдерживать рост нагрузки). В этой статье я затрону базовые задачи системного дизайна - как спроектировать и разработать систему так, чтобы она обладала архитектурой, чтобы развитие и поддержка системы не приносила много страданий, чтобы доработки не ломали действующий функционал.   

Рекомендую прочитать также мою статью об архитектуре ПО https://habr.com/ru/articles/1008926/

В этой статье примеры кода будут на Go - языке программирования высокого уровня со строгой статической типизацией. Часто язык Go называют Golang для лучших поисковых результатов. Go - слишком общее слово с множеством смыслов. В своей статье я буду употреблять Golang для обозначения языка программирования Go. Синтаксис языка очень простой, я думаю логика будет понятна даже тем, кто с этим языком не знаком. Эта статья не является руководством по Golang, необходимые для понимания кода особенности языка объясняются по мере использования. Я не претендую на догматическую точность концепций языка программирования Go, многие понятия объясняю своими словами таким образом, чтобы это было понятно читателям, не знакомым с Golang.

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

Краткая постановка задачи

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

Декомпозиция

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

Архитектура бэкенда подобной системы подразумевает несколько функциональных уровней или слоев, каждый из которых делает свою работу. Минимально можно выделить такие слои:

  • Контроллеры HTTP API - отвечают за взаимодействие с клиентами системы

  • Бизнес-логика приложения (условно можно разделить на команды и запросы)

  • Компоненты для работы с БД и данными внешних систем (если потребуется) - репозитории и провайдеры данных внешних систем

Клиент

Контроллеры

Бизнес-логика (команды и запросы)

Репозитории и провайдеры

БД / Внешние системы

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

Функционал бизнес-логики можно условно разделить на команды и запросы. Запросы - это операции чтения данных, которые не изменяют состояние приложения, то есть состояние сущностей в БД приложения. Команды - это операции, изменяющие состояние сущностей в БД приложения, это операции создания, удаления и изменения данных. Команды тоже могут читать данные в процессе обработки. Команды могут возвращать состояние сущностей после внесенных изменений, но это не обязательно. 

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

Модули

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

Для этой системы можно предварительно выделить такие зоны ответственности:

  • идентификация и аутентификация пользователей

  • обработка чатов и сообщений

  • уведомления по электронной почте

  • загрузка файлов

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

Границы модулей

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

Часто бывает, что одна и та же сущность присутствует в разных модулях, например - пользователь. Это нормальная ситуация, особенно если каждый из модулей работает со своим набором атрибутов. На уровне БД эти сущности разных модулей могут отображаться на одну и ту же таблицу или на разные таблицы. Например, может быть таблица с основными атрибутами - уникальный идентификатор, ФИО, email и таблицы с дополнительными атрибутами под нужны модулей, ссылающиеся на основную таблицу.

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

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

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

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

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

Рассмотрим пример - удаление чата. При удалении в БД записи о чате нужно также удалить связанные объекты - сообщения этого чата, загруженные в чат файлы. Важно правильно определить какие действия требуют атомарности? Что произойдет, если не будут удалены связи? Скорее всего, ничего страшного не произойдет. Можно реализовать удаление записей, которые ссылаются на несуществующие чаты по расписанию. Можно организовать каскадное удаление связанных сущностей на уровне БД - это не очень хорошо, так как часть бизнес-логики перекладывается на БД, то есть на внешнюю систему, по отношению к нашей системе. Для этого примера возможно атомарное удаление данным, так как предполагается реализовать всю работу с чатами в одном модуле.

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

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

Таким образом, на этой итерации проектирования можно остановиться на таком составе модуле бэкенда системы: 

  • модуль идентификации и аутентификации пользователей

  • модуль чатов и сообщений

  • модуль уведомлений по электронной почте

  • модуль хранения файлов

Далее рассмотрим проектирование и разработку модуля идентификации и аутентификации пользователей.

Проектирование модуля идентификации и аутентификации пользователей

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

Идентификация пользователя - это связывание клиента приложения и запросов клиента с некоторой внутренней сущностью “пользователь системы” с помощью уникального идентификатора пользователя. Исторически сложилось так, что типовой идентификатор - это текстовый ник (прозвище), придуманный самим пользователем. Также часто применяется адреса электронной почты, номера телефонов или числовые идентификаторы (ИНН, СНИЛС или просто внутренний числовой идентификатор). Основное требование - этот идентификатор должен быть уникальным в рамках системы.

Аутентификация пользователя - это подтверждение того, что клиент является тем, за кого себя выдает. Такое подтверждение обычно основано на владении некоторым секретом, заданным в системе и связанным с сущностью “пользователь системы”.

Таким образом, идентификация - это ответ на вопрос “Кто ты такой?”, а аутентификация - это ответ на вопрос “Чем докажешь, что ты тот, за кого себя выдаешь?”

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

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

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

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

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

При успешной аутентификации пользователя будет создаваться новая запись в таблице сессий, идентификатор сессии будет сохраняться в cookie с атрибутами HttpOnly (доступно только бэкенду, недоступно для фронта и пользователя) и Secure (передается через HTTPS, перехват невозможен). В дальнейшем при любом запросе, требующем наличия аутентифицированного пользователя, бэкенд нашего приложения будет читать куку и через запись о сессии будет находить аутентифицированного пользователя. Для успешной авторизации (предоставления доступа к защищенным обработчикам запросов) помимо идентификатора сессии необходимо совпадение “отпечатка браузера” сохраненного в сессии и вычисляемого при каждом запросе. Отпечаток клиента вычисляется как хэш от IP адреса клиента и HTTP-заголовка UserAgent (идентификатор браузера). При желании можно применить более сложный метод, но для начала достаточно и этого.

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

HTTP API модуля аутентификации включает такие операции: 

  • самостоятельная регистрация пользователя - POST /api/auth/register

  • попытка входа пользователя - POST /api/auth/login

  • аутентификация пользователя - POST /api/auth/auth

  • выход пользователя из системы (отмена сессии) - GET/POST /api/auth/logout

Первые три операции будут принимать параметры через JSON body - в теле запроса, у операции выхода из системы нет параметров.

При разработке HTTP API распространены два основных подхода. Классический подход предполагает использовать методы протокола HTTP согласно логике выполняемой операции:

  • создание нового экземпляра сущности - POST

  • удаление экземпляра сущности - DELETE

  • получение сущностей - GET

  • при изменении уже начинается путаница - чаще используется PATCH для частичного изменения атрибутов сущности, но иногда рекомендую использовать PUT

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

Лично я предпочитаю второй подход - почти всегда использовать POST и передавать параметры в теле запроса. При этом семантика запроса закладывается в путь. GET можно использовать для запросов без параметров или с 1-2 примитивными параметрами, например, получение сущности по идентификатору.

Алгоритм регистрации предполагает два параметра - обязательный email и необязательный title, email должен быть уникальным, иначе - ошибка 409. При регистрации нужно создать в БД в таблице user новую запись с title и email.

Алгоритм входа предполагает обязательный параметр email. По email находим запись в таблице user, если не нашли - ошибка 409. Формируем код подтверждения и хэш от этого кода. Хэш сохраняем в записи пользователя в таблице user, а код направляем на email пользователя.

Алгоритм аутентификации предполагает обязательные параметры email и code. По email находим запись в таблице user, если не нашли - ошибка 409. Берем хэш от code из входных параметров и сравниваем с тем, что хранится в записи пользователя. Если хэши не равны, возвращаем ошибку 409. Если хэши равны - нужно создать новую запись в таблице пользовательских сессий и записать id новой сессии в куки.

Для логаута достаточно удалить cookie с идентификатором сессии, но по-хорошему, стоит также удалить и сессию из БД. Также можно реализовать механизм периодического удаления сессий с истекшим сроком годности.

Давайте посмотрим как распределяются по слоям части алгоритмов и ответственность.

Контроллер

Команда / Запрос

Репозиторий

POST /api/auth/register

Валидация обязательных параметров login и email.

Обработка ошибок команды и формирование ответа для клиента.

Если пользователь найден по логину - вернуть ошибку.
Формирование данных для новой записи о пользователе.

Поиск пользователя по логину.

Создание новой записи о пользователе.

POST /api/auth/login

Валидация обязательного параметра login.
Отправка емейла с кодом подтверждения через провайдера электронной почты.

Обработка ошибок команды и формирование ответа для клиента.

Если пользователь не найден по логину - вернуть ошибку.

Формирование кода подтверждения и хэша от этого кода.

Поиск пользователя по логину.

Обновление записи о пользователе (обновление поля хэша).

POST /api/auth/auth 

Валидация обязательных параметров login и code.
Обработка ошибок команды и формирование ответа для клиента.

Запись сессии в cookie. 

Если пользователь не найден по логину - вернуть ошибку.
Сравнение хэша от code с хэшем в записи пользователя.
Формирование данных для новой записи о сессии пользователя. 

Поиск пользователя по логину.
Обновление записи о пользователе (обнуление поля хэша).

Создание новой записи о сессии пользователя.

Проверка сессии

Получить cookie.
Сформировать результат проверки.

По идентификатору сессии найти и вернуть атрибуты пользователя.

Поиск пользовательской сессии по идентификатору.

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

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

package models

type AuthUserAttributes struct {
	Uuid  string `json:"uuid"`
	Email string `json:"email"`
	Title string `json:"title"`
	Hash  string `json:"hash"`
}

type AuthSessionAttributes struct {
    Uuid           string `json:"uuid"`
    UserUuid       string `json:"user_uuid"`
    UserClientMeta string `json:"user_client_meta"`
    Expires        string `json:"expires"`
    CreatedAt      string `json:"created_at"`
}

Тут указан минимальный набор полей, которые я планирую использовать в модуле аутентификации. В БД в соответствующих  таблицах может хранится больше полей для нужд других модулей. В качестве уникальных идентификаторов записей я использую UUID - 128-битный глобально уникальный идентификатор. Это позволяет гарантировать уникальность идентификатора сущности при любых манипуляциях, можно свободно импортировать и экспортировать данные между хранилищами и не беспокоиться об уникальности.

В Golang ключевое слово package задает пространство имен, в рамках которого определяются идентификаторы типов, переменных, функций. Большая буква в начале идентификатора задает видимость во вне, то есть экспортируемые идентификаторы. Поля структуры могут дополняться тэгами, в данном случае используется тэг json, задающий имя поля при преобразованиях в JSON-объекты. 

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

package AuthApp

import (
	"chat_vault/internal/modules/auth/models"
	"chat_vault/internal/tools"
)

type IAuthUserLoginRepo interface {
	FindUserByEmail(email string) (*models.AuthUserAttributes, error)
	UpdateUserHash(userUuid string, hash string, updatedAt string) error
}

func CommandUserLogin(
	userRepo IAuthUserLoginRepo,
	email string,
) (
	user *models.AuthUserAttributes,
	code string,
	err error,
) {
	user, err = userRepo.FindUserByEmail(email)

	if err != nil {
		return nil, "", err
	}

	code = tools.CreateAuthCode()
	hash := tools.MakeAuthHash(code)
	updatedAt := tools.NowToIsoString()

	err = userRepo.UpdateUserHash(user.Uuid, hash, updatedAt)

	if err != nil {
		return nil, "", err
	}

	user.Hash = ""

	return user, code, nil
}

Импортируются пакеты, экспортируется функция CommandUserLogin с двумя параметрами - интерфейсом репозитория и строкой login. В Golang функция может возвращать множество результатов и они могут быть поименованы, их можно использовать как объявленные переменные и при выходе из функции можно не указывать их значения - будут взяты текущие значения. В этом примере я все же явно указываю значения при выходе, а имена возвращаемых значений служат для лучшей читаемости кода. В Golang принято возвращать ошибки как один из результатов функции, механизм исключений для ошибок логики не используется.

Вначале с помощью репозитория ищем пользователя по логину. Ожидаем, что если поиск пользователя прошел без ошибок, то пользователь существует и найден. Тогда можно сгенерировать код подтверждения, хеш от него сохраняется в запись пользователя. Запись пользователя меняется, поэтому данная операция является командой, а не запросом. Возвращаем пользователя, код и ошибку. В контроллере могут понадобиться uuid и email пользователя, значение хэша в возвращаемом значении очищаем, так как оно не понадобится в контроллере и содержит неактуальное значение от предыдущей попытки входа. Можно было бы вернуть только email пользователя, но пока что на всякий случай будем возвращать всю структуру атрибутов пользователя. 

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

Также в корне лежит файл go.mod, в котором хранится конфигурация зависимостей проекта. Директива module задает уникальное имя проекта - в нашем случае это chat_vault. Если это программа, а не библиотека для публичного переиспользования, то уникальность имени не важна. Директива require позволяет перечислить библиотеки, которые используются в проекте и их версии. Первоначально библиотеки скачиваются и подключаются к проекту (добавляются в go.mod) командой go get <имя_библиотеки>. Если нужно на другом компьютере скачать зависимости, то достаточно запустить команду go build в корневом каталоге проекта и зависимости будут скачаны в каталог компилятора Go и затем будет выполнена компиляция и сборка. Компилятор Go создает один самодостаточный исполняемый файл, концепция подключаемых библиотек, расположенных в отдельных файлах не используется.

В каталоге ./data находятся файлы данных БД SQLite.

В каталоге ./internal/db находятся файлы пакета database - используемые модулями проекта общие функции подключения к БД.

В каталоге ./internal/tools находятся файлы пакета tools -используемые модулями проекта общие вспомогательные функции. Некоторые из них использовались в CommandUserLogin.

В каталоге ./internal/modules находятся файлы модулей проекта.

В каталоге ./internal/modules/auth содержатся подкаталоги для пакетов модуля идентификации и аутентификации пользователей.

В каталоге ./internal/modules/auth/app содержатся файлы команд и запросов пакета AuthApp - бизнес-логики модуля идентификации и аутентификации пользователей.

В каталоге ./internal/modules/auth/models содержатся файлы пакета models, где определены структуры пользователя и сессии, используемые в модуле идентификации и аутентификации пользователей

В каталоге ./internal/modules/chat содержатся подкаталоги для пакетов модуля чатов и сообщений.

Пространство имен - package - может состоять из нескольких файлов в одном каталоге, важно чтобы в каталоге были файлы только одного пространства имен (пакета, package). Для доступа к элементам используется синтаксис с точкой - models.AuthUserAttributes - структура атрибутов пользователей из пакета models. Когда пакет импортируется в файле другого пакета, то будут доступны только идентификаторы, начинающиеся с заглавной буквы. Идентификаторы, начинающиеся с прописной (малой) буквы доступны только в файлах пакета, в котором они объявлены.

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

package AuthApp

import (
	AuthErrors "chat_vault/internal/modules/auth/errors"
	"chat_vault/internal/modules/auth/models"
	"errors"
)

type IAuthUserRegisterRepo interface {
	FindUserByEmail(email string) (*models.AuthUserAttributes, error)
	CreateUser(email string, title string) (*models.AuthUserAttributes, error)
	UpdateUserHash(userUuid string, hash string, updatedAt string) error
}

func CommandUserRegister(
	userRepo IAuthUserRegisterRepo,
	email string,
	title string,
) (
	user *models.AuthUserAttributes,
	code string,
	err error,
) {
	foundUser, err := userRepo.FindUserByEmail(email)

	// логин найден, нельзя регистрировать еще одного с таким логином
	if foundUser != nil {
		return nil, "", AuthErrors.AuthErrorUserEmailIsNotUnique
	}

	// логин не найден, другая ошибка
	if err != nil && !errors.Is(err, AuthErrors.AuthErrorUserEmailNotFound) {
		return nil, "", err
	}

	// логин не найден, ошибок нет, создаем нового пользователя
	_, err = userRepo.CreateUser(email, title)

	// ошибка создания
	if err != nil {
		return nil, "", err
	}

	// получим код
	user, code, err = CommandUserLogin(userRepo, email)

	return user, code, err
}

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

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

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

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

В Golang реализована довольно специфичная концепция методов типов, вместе с типами-структурами это похоже на частично реализованное объектно-ориентированное программирование. В Golang нет классов. ООП реализуется с помощью типов-структур и их методов, конструкторы отсутствуют. Существует договоренность рядом с типом структуры класть функцию вида NewTypeName которая по смыслу является фабрикой инициированных переменных типа TypeName. Не буду на этом подробно останавливаться. Если у функции задан параметр определенного типа перед именем функции, например (r *AuthUserRepo), то это метод типа AuthUserRepo.

package repo

import (
	AuthErrors "chat_vault/internal/modules/auth/errors"
	"chat_vault/internal/modules/auth/models"
  
	"github.com/cvilsmeier/sqinn-go/v2"
	"github.com/google/uuid"
)

type AuthUserRepo struct {
	db *sqinn.Sqinn
}

func NewAuthUserRepo(db *sqinn.Sqinn) *AuthUserRepo {
	return &AuthUserRepo{
		db: db,
	}
}

func (r *AuthUserRepo) Trancate() error {
	err := r.db.ExecParams(
		"DELETE FROM user_acc;",
		1,
		0,
		[]sqinn.Value{},
	)

	return err
}

func (r *AuthUserRepo) FindUserByEmail(email string) (*models.AuthUserAttributes, error) {
	rows, err := r.db.QueryRows(
		"SELECT uuid, email, title, hash FROM user_acc WHERE email = ?;",
		[]sqinn.Value{sqinn.StringValue(email)},
		[]byte{sqinn.ValString, sqinn.ValString, sqinn.ValString, sqinn.ValString},
	)

	if err != nil {
		return nil, err
	}

	if len(rows) > 0 {
		return &models.AuthUserAttributes{
			Uuid:  rows[0][0].String,
			Email: rows[0][1].String,
			Title: rows[0][2].String,
			Hash:  rows[0][3].String,
		}, nil
	} else {
		return nil, AuthErrors.AuthErrorUserEmailNotFound
	}
}

func (r *AuthUserRepo) CreateUser(email string, title string) (*models.AuthUserAttributes, error) {
	user := models.AuthUserAttributes{
		Uuid:  uuid.New().String(),
		Email: email,
		Title: title,
		Hash:  "",
	}

	err := r.db.ExecParams(
		"INSERT INTO user_acc (uuid, email, title, hash) VALUES (?, ?, ?, ?);",
		1,
		4,
		[]sqinn.Value{
			sqinn.StringValue(user.Uuid),
			sqinn.StringValue(user.Email),
			sqinn.StringValue(user.Title),
			sqinn.StringValue(user.Hash),
		},
	)

	if err != nil {
		return nil, err
	}

	return &user, nil
}

func (r *AuthUserRepo) UpdateUserHash(userUuid string, hash string, updatedAt string) error {
	err := r.db.ExecParams(
		"UPDATE user_acc SET hash = ?, updated_at = ? WHERE uuid = ?;",
		1,
		3,
		[]sqinn.Value{
			sqinn.StringValue(hash),
			sqinn.StringValue(updatedAt),
			sqinn.StringValue(userUuid),
		},
	)

	if err != nil {
		return err
	}

	return nil
}

Репозиторий реализован с помощью пакета github.com/cvilsmeier/sqinn-go/v2, который не требует компиляции движка SQLite. Используется отдельное скомпилированное приложение, своего рода локальный сервер БД, которое подтягивается при установке пакета в каталог пакетов Go. Это решение проще других вариантов с другими пакетами для работы с SQLite. Для небольших и ненагруженных проектов применимо, нормально работает в среде Linux и macOS, на Windows я не проверял.

Ничего особенного тут нет, простые SQL-запросы. Trancate пригодится для тестов. В диалекте SQLite нет команды TRANCATE, поэтому для очистки таблицы применяется DELETE FROM без WHERE.

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

package database

import (
	"log"

	"github.com/cvilsmeier/sqinn-go/v2"
)

func CreateSQLiteDatabaseConnection(filename string) *sqinn.Sqinn {
	log.Println("> createSQLiteDatabaseConnection, filename:", filename)

	sq := sqinn.MustLaunch(sqinn.Options{
		// Db: ":memory:", // use a transient in-memory database
		Db: filename,
	})

	initTable := `
	CREATE TABLE IF NOT EXISTS user_acc (
      uuid TEXT UNIQUE NOT NULL,
      email TEXT UNIQUE NOT NULL,
      title TEXT,
      hash TEXT,
      extra TEXT,
      created_at TEXT,
      updated_at TEXT
    );`

	err := sq.ExecSql(initTable)
	log.Println("> createSQLiteDatabaseConnection, create user_acc err:", err)

	initTable = `  
	  CREATE TABLE IF NOT EXISTS user_session (
        uuid TEXT UNIQUE NOT NULL,
        user_acc_uuid TEXT NOT NULL,
        user_client_meta TEXT NOT NULL,
        expires TEXT NOT NULL,
        created_at TEXT
      );`

	err = sq.ExecSql(initTable)
	log.Println("> createSQLiteDatabaseConnection, create user_session err:", err)

	return sq
}

Теперь можно создать тест, с помощью которого будем тестировать разработанные бизнес-логику и репозиторий. Тесты в Go помечаются именем файла, оканчивающимся на _test.go. Файлы тестов можно размещать где угодно - в отдельных каталогах и пакетах или непосредственно в тех пакетах, чей функционал они тестируют, мне больше нравится размещать тесты рядом с тестируемым кодом. Запустить все тесты проекта (всех вложенных пакетов) можно командой:

go test ./...

Вот простой пример теста. При подключении к БД вместо имени файла задается строка ":memory:", что означает размещение БД в оперативной памяти. Затем определяются две вложенные функции, тестирующие бизнес-логику регистрации и логина. Затем эти функции запускаются, между их вызовами делается очистка таблицы через метод репозитория Trancate. В Golang вложенные функции можно определить только в таком синтаксисе - через присваивание функции переменной.

package AuthApp

import (
	database "chat_vault/internal/db"
	AuthErrors "chat_vault/internal/modules/auth/errors"
	"chat_vault/internal/modules/auth/repo"
	"testing"
)

func TestApp(t *testing.T) {
	db := database.CreateSQLiteDatabaseConnection(":memory:")

	userRepo := repo.NewAuthUserRepo(db)

	testCommandUserRegister := func(t *testing.T) {
		user1, code, err := CommandUserRegister(userRepo, "user1@site.ru", "user1")

		if err != nil {
			t.Error(err)
		}

		if len(code) != 6 {
			t.Error("Неправильная длина code", code)
		}

		if len(user1.Uuid) != 36 {
			t.Error("Неправильная длина uuid", user1.Uuid)
		}

		_, code, err = CommandUserRegister(userRepo, "user1@site.ru", "user1")

		if err != AuthErrors.AuthErrorUserEmailIsNotUnique {
			t.Error("Непредвиденная ошибка:", err)
		}

		if len(code) != 0 {
			t.Error("Непредвиденное значение code:", code)
		}
	}

	testCommandUserLogin := func(t *testing.T) {
		user1title := "user1"
		user1email := "user1@site.ru"

		user1, code, err := CommandUserLogin(userRepo, user1email)

		if err != AuthErrors.AuthErrorUserEmailNotFound {
			t.Error("Непредвиденная ошибка:", err)
		}

		user1, code, err = CommandUserRegister(userRepo, user1email, user1title)

		if err != nil {
			t.Error("Непредвиденная ошибка:", err)
		}

		if len(code) != 6 {
			t.Error("Неправильная длина code", code)
		}

		if user1.Email != user1email {
			t.Log(user1)
			t.Error("Неправильный user1 email", user1.Email)
		}

		user1, code, err = CommandUserLogin(userRepo, user1email)

		if err != nil {
			t.Error("Непредвиденная ошибка:", err)
		}
	}

	testCommandUserRegister(t)

	userRepo.Trancate()

	testCommandUserLogin(t)
}

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

package AuthApp

import (
	"chat_vault/internal/modules/auth/models"
)

type IAuthSessionQueryUserRepo interface {
	FindUserBySessionUuid(uuid string, meta string) (*models.AuthUserAttributes, error)
}

func QueryUserBySession(
	sessionRepo IAuthSessionQueryUserRepo,
	sessionUuid string,
	meta string,
) (
	user *models.AuthUserAttributes,
	err error,
) {
	user, err = sessionRepo.FindUserBySessionUuid(sessionUuid, meta)

	return user, err
}

type IAuthSessionLogoutRepo interface {
	DeleteSessionByUuid(uuid string, meta string) error
}

func CommandUserLogout(
	sessionRepo IAuthSessionLogoutRepo,
	sessionUuid string,
	meta string,
) (
	done bool,
	err error,
) {
	err = sessionRepo.DeleteSessionByUuid(sessionUuid, meta)

	if err != nil {
		return false, err
	}

	return true, err
}

Теперь рассмотрим как может выглядеть слой контроллеров в пакете AuthController. В моем примере контроллер представлен структурой, которая инкапсулирует в себе функционал слоя контроллеров модуля аутентификации пользователей. Пакет состоит из нескольких файлов - основной файл с определением типа структуры AuthHttpController и основными методами, а также из отдельных файлов с реализацией методов userRegister, userLogin, userAuth, userLogout, userInfo.

package AuthController

import (
	"doc_vault/internal/auth/models"
	"encoding/json"
	"log"
	"net/http"
	"time"

	"github.com/go-playground/validator"
)

type IAuthUserRepo interface {
	FindUserByEmail(email string) (*models.AuthUserAttributes, error)
	CreateUser(login string, email string) (*models.AuthUserAttributes, error)
	UpdateUserHash(userUuid string, hash string, updatedAt string) error
}

type IAuthSessionRepo interface {
	CreateSession(
		userUuid string,
		browserId string,
		expires string,
		createdAt string,
	) (*models.AuthSessionAttributes, error)
	DeleteSessionByUuid(uuid string, meta string) error
	FindUserBySessionUuid(uuid string, meta string) (*models.AuthUserAttributes, error)
	FindSessionsAll() ([]models.AuthSessionAttributes, error)
}

type IEmailMessageSender interface {
	Send(email string, text string) error
}

type AuthHttpController struct {
	validate    *validator.Validate
	userRepo    IAuthUserRepo
	sessionRepo IAuthSessionRepo
	emailSender IEmailMessageSender
	server      *http.Server
	handler     http.Handler
}

func NewAuthController(
	userRepo IAuthUserRepo,
	sessionRepo IAuthSessionRepo,
	emailSender IEmailMessageSender,
) *AuthHttpController {
	return &AuthHttpController{
		validate:    validator.New(),
		userRepo:    userRepo,
		sessionRepo: sessionRepo,
		emailSender: emailSender,
		handler:     nil,
		server:      nil,
	}
}

func (this *AuthHttpController) loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()

		// Код ДО выполнения основного обработчика
		// log.Printf("Начало запроса: %s %s", r.Method, r.URL.Path)

		next.ServeHTTP(w, r) // Передача управления следующему обработчику

		// Код ПОСЛЕ выполнения основного обработчика
		log.Printf("%s %s : %v", r.Method, r.URL.Path, time.Since(start))
	})
}

func (this *AuthHttpController) RegisterRoutes() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /api/auth/__/sessions", func(w http.ResponseWriter, r *http.Request) {
		items, err := this.sessionRepo.FindSessionsAll()

		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(items)
	})

	// проверяет сессию в куках
	mux.HandleFunc("GET /api/auth/info", this.userInfo)

	mux.HandleFunc("POST /api/auth/register", this.userRegister)
	mux.HandleFunc("POST /api/auth/login", this.userLogin)
	mux.HandleFunc("POST /api/auth/auth", this.userAuth)
  
	mux.HandleFunc("POST /api/auth/logout", this.userLogout)
	mux.HandleFunc("GET /api/auth/logout", this.userLogout)

	result := this.loggingMiddleware(this.getSessionUserMiddleware(mux))

	this.handler = result

	return result
}

func (this *AuthHttpController) RunServer() {
	if this.handler == nil {
		this.handler = this.RegisterRoutes()
	}

	this.server = &http.Server{
		Addr:         ":8888",
		Handler:      this.handler,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  120 * time.Second,
	}

	log.Println("Server is running on http://localhost:8888")

	log.Fatal(this.server.ListenAndServe())
}

Тут используется стандартный пакет net/http, в нем есть все необходимое для реализации веб-сервера. Функция-конструктор контроллера принимает два параметра - репозитории пользователей и сессий.

Обрабатываемые маршруты прописаны в методе RegisterRoutes структуры (объекта) AuthHttpController. Обработчики маршрутов можно определять прямо по месту как анонимные функции, пример - обработчик "GET /api/auth/__/sessions". Эта конструкция - получение списка сессий для целей отладки - является примером плохой архитектуры. Вызовы методов репозиториев прямо в контроллере - такое можно делать только временно, в процессе отладки. В релизном коде такого не должно быть. Обработчики, определенные в контроллере в месте использования делают структуру кода хуже читаемой, такое допустимо, если в контроллере всего 1-3 небольших обработчика.

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

В структуре контроллера присутствует валидатор из пакета github.com/go-playground/validator, пример про валидацию будет далее.

Строка

result := this.loggingMiddleware(this.getSessionUserMiddleware(mux))

Навешивает 2 мидлвари на все обрабатываемые этим контроллером маршруты.

getSessionUserMiddleware - ищет cookie с идентификатором сессии и если сессия присутствует, то проверяет длину значения сессии (мы ожидаем что там будет UUID из 36 символов) и если все хорошо, то пытается найти в БД пользователя, связанного с этой сессией через функцию-запрос AuthApp.QueryUserBySession слоя бизнес-логики.

loggingMiddleware - пишет в лог время обработки.

Вот код getSessionUserMiddleware и parseSessionUserContext.

package AuthController

import (
	AuthApp "chat_vault/internal/modules/auth/app"
	"chat_vault/internal/modules/auth/config"
	"chat_vault/internal/tools"
	"context"
	"log"
	"net/http"
)

/*
Проверка cookie сессии в middleware, сохранение в контексте запроса
*/

type authContextKey string

const authContextKeyName authContextKey = "authDataStruct"

type AuthDataStruct struct {
	browserId   string
	sessionUuid string
	userUuid    string
	userEmail   string
}

// Вычисляет ИД клиентского браузера
func GetBrowserId(r *http.Request) string {
	host := tools.GetRemoteHost(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"))
	meta := tools.MakeBrowserId(r.UserAgent(), host)

	log.Println("  > UserAgent:", r.UserAgent())
	log.Println("  > host:", host)
	log.Println("  > meta:", meta)

	return meta
}

// Извлечение инфы из контекста
func (this *AuthHttpController) parseSessionUserContext(
	r *http.Request,
) (
	browserId string,
	sessionUuid string,
	userUuid string,
	userEmail string,
) {
	browserId = ""
	sessionUuid = ""
	userUuid = ""
	userEmail = ""

	authData, ok := r.Context().Value(authContextKeyName).(AuthDataStruct)

	if ok {
		browserId = authData.browserId
		sessionUuid = authData.sessionUuid
		userUuid = authData.userUuid
		userEmail = authData.userEmail
	}

	return
}

// Проверка cookie сессии в middleware, сохранение в контексте запроса
func (this *AuthHttpController) getSessionUserMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		browserId := GetBrowserId(r)

		session := ""
		userUuid := ""
		userEmail := ""

		cookie, err := r.Cookie("session")

		if err == nil {
			session = cookie.Value

			if len(session) == config.UUID_LENGTH {
				user, _ := AuthApp.QueryUserBySession(this.sessionRepo, session, browserId)

				if user != nil {
					userUuid = user.Uuid
					userEmail = user.Email
				}
			}
		}

		ctx := context.WithValue(
			r.Context(),
			authContextKeyName,
			AuthDataStruct{
				browserId:   browserId,
				sessionUuid: session,
				userUuid:    userUuid,
				userEmail:   userEmail,
			},
		)

		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Пример обработчика с проверкой сессии.

package AuthController

import (
	AuthErrors "chat_vault/internal/modules/auth/errors"
	"encoding/json"
	"log"
	"net/http"
)

func (this *AuthHttpController) userInfo(w http.ResponseWriter, r *http.Request) {
	meta, session, userUuid, userEmail := this.parseSessionUserContext(r)

	log.Println("  > meta:", meta)
	log.Println("  > session:", session)

	if session == "" {
		http.Error(w, AuthErrors.AuthErrorSessionNotFound.Error(), http.StatusUnauthorized)
		return
	}

	log.Println("  > user.email:", userEmail)

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(UserInfoResponse{
		Uuid:  userUuid,
		Email: userEmail,
	})
}

Пример обработчика с валидацией входных параметров.

package AuthController

import (
	AuthApp "doc_vault/internal/auth/app"
	"encoding/json"
	"net/http"
)

type UserRegisterParams struct {
	Title string `json:"title"`
	Email string `json:"email" validate:"required,email"`
}

type UserRegisterResponse struct {
	Title string `json:"title"`
	Email string `json:"email"`
	Uuid  string `json:"uuid"`
	Code  string `json:"code"` // на время отладки
}

func (this *AuthHttpController) userRegister(w http.ResponseWriter, r *http.Request) {
	var params UserRegisterParams
	var response UserRegisterResponse

	err := json.NewDecoder(r.Body).Decode(&params)

	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	err = this.validate.Struct(params)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	user, code, err := AuthApp.CommandUserRegister(this.userRepo, params.Email, params.Title)

	if err != nil {
		http.Error(w, err.Error(), http.StatusConflict)
		return
	}

	// отправить код
	text := "Ваш проверочный код: " + code
	err = this.emailSender.Send(user.Email, text)

	if err != nil {
		http.Error(w, err.Error(), http.StatusConflict)
		return
	}

	response = UserRegisterResponse{
		Code:  code, // на время отладки
		Title: params.Title,
		Email: params.Email,
		Uuid:  user.Uuid,
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

Как работает валидатор из пакета github.com/go-playground/validator?

Для начала нужно определить структуру входных параметров - UserRegisterParams, в которой для полей задается теги json и validate. Тег json используется для декодирования из json-строки в структуру. В данном примере правила валидации с помощью тега validate задают обязательное поле Email, которое валидируется как адрес электронной почты за счет тега validate:"required,email". Title - необязательное поле и оно не валидируется.

В методе контроллера userRegister вначале параметры декодируются из тела запроса в структуру params, которая затем валидируется с помощью this.validate.Struct(params). В случае неудачной валидации работа контроллера прекращается и клиенту возвращается ошибка 400 Bad Request. Такая же ошибка возвращается, если не удалось декодировать параметры из тела запроса в структуру.

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

package AuthController

import (
	AuthApp "chat_vault/internal/modules/auth/app"
	"chat_vault/internal/modules/auth/config"
	"encoding/json"
	"log"
	"net/http"
	"time"
)

func (this *AuthHttpController) userAuth(w http.ResponseWriter, r *http.Request) {
	var params UserAuthParams
	var response UserAuthResponse

	err := json.NewDecoder(r.Body).Decode(&params)

	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	err = this.validate.Struct(params)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	browserId, session, userUuid, userEmail := this.parseSessionUserContext(r)

	// если есть сессия и найден пользователь,
	// то не идем дальше и не затираем сeссию

	if userUuid != "" {
		// не было нового запроса на логин с момента успешной аутентификации
		log.Println("VALID SESSION ALREADY EXISTS!")
		log.Println("  > session:", session)
		log.Println("  > user.email:", userEmail)

		response = UserAuthResponse{
			Email:         userEmail,
			Authenticated: true,
			Session:       session, // на время отладки
			Meta:          browserId,    // на время отладки
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(response)

		return
	}

	authenticated, session, err := AuthApp.CommandUserAuth(
		this.userRepo,
		this.sessionRepo,
		params.Email,
		params.Code,
		browserId,
	)

	if err != nil {
		http.Error(w, err.Error(), http.StatusConflict)
		return
	}

	expires := time.Now().Add(
		time.Duration(config.SESSION_VALIDITY_PERIOD_DAYS*24) * time.Hour,
	)

	cookie := http.Cookie{
		Name:     "session",
		Value:    session,
		Path:     "/", // Available across the whole site
		Expires:  expires,
		HttpOnly: true,                    // Mitigates XSS attacks (JS can't read it)
		Secure:   false,                   // Only sent over HTTPS - dev env -> false
		SameSite: http.SameSiteStrictMode, // Mitigates CSRF attacks
	}

	http.SetCookie(w, &cookie)

	response = UserAuthResponse{
		Email:         params.Email,
		Authenticated: authenticated,
		Session:       session, // на время отладки
		Meta:          browserId,    // на время отладки
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

Методы userRegister и userLogin не требуют наличия сессии в куках, userAuth тоже не требует наличия сессии в куках, но он обрабатывает ситуацию, когда сессия есть. Метод контроллера parseSessionUserContext извлекает данные из контекста запроса.

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

Приведу пример кода основного файла проекта, с помощью которого можно запустить приложение с нашим модулем аутентификации.

package main

import (
	database "chat_vault/internal/db"
	AuthController "chat_vault/internal/modules/auth/controller"
	"chat_vault/internal/modules/auth/repo"
	"fmt"
)

type ConsoleSender struct{}

func (r *ConsoleSender) Send(email string, text string) error {
	fmt.Println(">>>> @ to: ", email, " >> ", text)
	return nil
}

const SQLITE_DATABASE_FILE = "./data/chat.vault.db"

func main() {
	db := database.CreateSQLiteDatabaseConnection(SQLITE_DATABASE_FILE)

	userRepo := repo.NewAuthUserRepo(db)
	sessionRepo := repo.NewAuthUserSessionRepo(db)

	sender := &ConsoleSender{}

	app := AuthController.NewAuthController(userRepo, sessionRepo, sender)
	app.RunServer()
}

Путь к файлу БД в этом примере задан прямо в коде. В продакшн среде принято брать такие параметры из переменных среды. Если мы захотим использовать другую БД, то можно будет реализовать для другой БД репозитории, соответствующие интерфейсам и передать их в AuthController.NewAuthController.

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

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

Изменение схемы БД продукта в стадии эксплуатации

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

Традиционным решением этой задачи является концепция миграций БД. Каждое изменение схемы БД представляется атомарной операцией - миграцией и при развертывании продукта запускается цепочка миграций, которая приводит БД в состояние, согласованное с кодом приложения. Миграции должны выполняться строго в заданной последовательности. Если в одной миграции произойдет сбой, последующие миграции нельзя выполнять.

Описанные ваше операции создания таблиц - это первая миграция, далее мы можем реализовать миграции, которые добавляют новые таблицы или изменяют существующие таблицы (добавляют, удаляют или изменяют поля, записывают данные в таблицы и т.д.). При развертывании новой версии продукта нужно запустить все миграции, которые не запускались ранее, чтобы довести состояние схемы БД до нужной версии. Для этого нужно иметь механизмы контроля над миграциями, как минимум - нужна специальная отдельная таблица в БД и управляющий скрипт. Для контроля над миграциями используются специальные программные пакеты. Если используются ORM, то часто они имеют поддержку механизмов миграций БД.

Легкой альтернативой миграциям можно считать другой подход - изменять БД так, чтобы всегда работала обратная совместимость. То есть, если запустить старую версию приложения с БД от новой версии приложения, старая версия приложения должна работать без ошибок и не ломать целостность БД. Чтобы этого добиться достаточно придерживаться нескольких простых правил - не удалять и не переименовывать поля в БД, а только расширять структуру БД - добавлять поля и таблицы. Например, если нам понадобится добавить новые атрибуты сущности пользователя, то в функцию инициализации соединения с БД нам нужно добавить выполнение SQL запросов, добавляющих нужные поля в таблицу. Это почти то же самое, что миграции, только проще и с ограничениями. Такой подход применим в несложных продуктах, обновляемых нечасто. Чтобы не добавлять сложности в мой демо проект, я пока что буду использовать этот подход. При переходе на серверную БД можно будет добавить функционал миграций.

Заключение

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

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

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

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

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

Полный код проекта можно посмотреть тут https://gitverse.ru/igor_sheludko/chat_vault_v1.

Комментарии (1)


  1. Annikangl
    04.07.2026 19:11

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