Цели данного задания:
- Практика использования библиотек для работы с http:
labstack/echo
иgo-resty/resty
- Проектирование и использование NoSQL key/value хранилища с использованием
boltdb/bolt
- Создание CLI приложения с использованием
spf13/cobra
- Использование Unix Domain Socket как альтернативы TCP в качестве транспорта для http коммуникации в рамках одной машины
Приложение будет состоять из 2ух частей:
server
- http сервер, который будет обрабатывать приходящие запросы и хранить данные вboltdb/bolt
cli
- cli приложение, написанное с использованиемspf13/cobra
, которое будет отправлять запросы на сервер используяgo-resty/resty
Коммуникация между клиентом и сервером будет осуществляться с помощью Unix Domain Socket
Для хранения информации мы будем использовать встраиваемую NoSQL базу данных boltdb/bolt
.
Наше приложение должно хранить информацию о пользователях и их ролях и предоставлять следующие операции:
- Получение списка пользователей (всех, по роли или по email)
- Добавление пользователя
- Модификацию пользователя
- Удаление пользователя
Для хранения данных мы будем использовать 2 bucket'а:
users
- хранит информацию о пользователях. Ключом будет являться email пользователя, а значением - структураUser
roles
- хранит информацию о ролях во вложенных бакетах для каждой роли. Ключом будет название роли, а значением - бакет с email'ами пользователей, которые имеют данную роль
Для того чтобы менять роли пользователям следует использовать тип Set
и его методы Difference
и Equals
Тестирование приложения будет осуществляться с помощью cli
приложения.
Это все на вас. Придумайте какие-нибудь тесты, меняйте юзеров и их роли, проверяйте доступность пользователей, через различные get запросы
Для того чтобы познакомиться с генерацией Swagger спецификации и использованием Swagger UI, мы добавим в приложение такую возможность.
- Добавить 2 флага: -addr и -listener-type
- -addr - адрес, который будет использоваться при создании листенера
- -listener-type - тип листенера, который будет использоваться при создании листенера (tcp или unix)
- Без использования флагов приложение не должно изменить свое поведение
- Написать аннотации к методам, которые будут использоваться для генерации Swagger спецификации
- Описывайте все методы в
handler.go
. Описание состоит из шапки и из коментариев которые относятся к каждому хендлеру в отдельности - Ссылка на формат: https://github.com/swaggo/swag#declarative-comments-format
- Описывайте все методы в
- Установить swag:
go install github.com/swaggo/swag/cmd/swag@latest
- В директории
server
сгенерировать документацию:swag init --parseDependency
- Заимпортировать в main сегенерированную документацию
import _ "github.com/cloudmachinery/apps/http-userroles/server/docs"
- Добавить эндпоинт для получения документации:
e.GET("/swagger/*", echoSwagger.WrapHandler)
(для этого нужно установитьechoSwagger
пакет:go get -u github.com/swaggo/echo-swagger
) - Запустить приложение на tcp листенере, открыть его в браузере по адресу
http://localhost:8080/swagger/index.html
(порт может отличаться) и проверить что документация отображается корректно
- Router - путь до эндпоинта и его метод:
// @Router /api/users/{email} [delete]
- Param - параметр эндпоинта:
- Если параметр пути:
// @Param email path string true "User email"
- Если параметр body:
// @Param user body contracts.User true "User"
- Если параметр пути:
- Success - какой ответ ждать при успешном выполнении
// @Success 200 {array} contracts.User
- Failure - какой ответ ждать при ошибке
// @Failure 400 {object} echo.HTTPError
- Укажите по аннотации для каждого кода ошибки
- Запустите сервер
- Откройте в браузере страницу документации
- Проверьте что все эндпоинты описаны корректно
- Вызовите каждый эндпоинт и проверьте что ответы соответствуют описанию
Для того чтобы познакомиться с библиотекой jacks/pgx
, моделированием баз данных и получить практический опыт работы с интерфейсами мы попытаемся сделать поддержку PostgreSQL хранилища в нашем приложении.
- Добавьте неиспользующийся параметр
_ context.Context
во все методы Store, включаяClose
. По соглашениям контекст является первым параметром в методах, которые его используют - В хендлерах передавайте контекст реквеста через
c.Request().Context()
в методы Store - Проверьте что код компилируется и приложение работает как и раньше
- Создадим в сервере новый пакет
store
добавив папкуstore
в папкуserver
- В этой папке создадим следующие файлы:
store.go
- содержит интерфейсStore
в котором будет методClose(context.Context) error
и еще 6 функций связанных для получения данных. Также этот файл должен содержать все ошибки связанные с хранилищами (у нас их 2)set.go
- сюда мы просто перетащим наш сетboltdb.go
- сюда мы перетащим нашу реализацию хранилища на основеBoltDB
. Назовем ееBoltDBStore
. Для того чтобы убедиться в реализации интерфейса добавим строкуvar _ Store = (*BoltDBStore)(nil)
перед объявлением структурыroles.go
- сюда мы перетащим нашу функциюadjustRoles
, которую мы также хотим использовать вPostgresStore
. Ее придется немного изменить чтобы она не зависила отBoltDB
postgres.go
- здесь мы реализуем нашу новую реализацию хранилища на основеPostgreSQL
. Назовем ееPostgresStore
. Для того чтобы убедиться в реализации интерфейса добавим строкуvar _ Store = (*PostgresStore)(nil)
перед объявлением структуры
- Создайте структуру
PostgresStore
в файлеpostgres.go
с единственным полемdb *pgx.Conn
- это наш доступ к базе данных - Создайте конструктор
NewPostgresStore
который будет приниматьcontext.Context
и строку подключения к базе данных и возвращать*PostgresStore
иerror
- В конструкторе создайте подключение к базе данных и сохраните его в поле
db
структуры, а потом выполните инициализацию схемы баз данных (2 таблицы и 2 индекса) - Из конструктора мы должны возвращать хранилище с полностью проинициализированной базой
- Добавьте в
main.go
флаг-con-string
с описаниемConnection string to store
- Если значение в нем начинается на
boltdb://
, то берите остаток строки как путь к файлу и создавайте хранилище на основеBoltDB
- Если значение в нем начинается на
postgres://
, то передавайте всю строку как строку подключения вNewPostgresStore
и создавайте хранилище на основеPostgreSQL
. Также передайте в функцию контекст, который истечет через 5 секунд для того, чтобы ограничить время на подключение и иницаилазацию базы данных - Если значение в нем начинается не на
boltdb://
и не наpostgres://
, то выведите ошибкуunknown connection string. Only boltdb://* and postgres://* are supported
- Проведите все эти манипуляции в отдельной функции
func getStore(connString string) (store.Store, error)
в файлеmain.go
- Создайте
Makefile
с таргетамиstop-db
,run-db
аналогичными тем, что мы делали в заданииsql-querier
. Также добавье таргетrun-server
SERVER_ADDR ?= ":8080"
DB_HOST ?= localhost
run-server:
cd server && go run . -listener-type tcp -addr $(SERVER_ADDR) -conn-string "postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable"
- Джоиним таблицы и возвращаем юзеров с ролями
- Используйте контекст при передаче в функцию
Query
- Для того чтобы переиспользовать код сканирования юзеров в модели создадим функцию
func scanUsers(rows pgx.Rows) ([]*contracts.User, error)
- Используйте мапу для того, чтобы собирать ряды относящиеся к одному юзеру в один объект
contracts.User
- Помните, что несмотря на то, что поле
role
являетсяNOT NULL
оно может бытьNULL
в результатеLEFT JOIN
. Это лишь значит, что у нас есть юзер без ролей. Для того чтобы сканировать такие данные используйте тип*string
для роли и проводите проверку наnil
после сканирования - После сбора все юзеров в мап возвращаем их в виде среза дополнительно отсортировав по
email
- Джоиним таблицы и возвращаем юзера с ролями (по аналогии с
GetUsers
). Используем фильтрацию по email. - Учтите, что вам нужно использовать
Query
, неQueryRow
, так как даже один юзер может быть представлен несолькими строками после джоина - Если вы не получили ни одной строки, верните
ErrUserNotFound
- Выделите функцию сканированя рядов в одного юзера
func scanUser(rows pgx.Rows) (*contracts.User, error)
- Функция CreateUser должна менять данные в двух таблицах сразу
users
иroles
. Для этого используйте транзакцию. Лучше всего создать обертку для работы транзакцийinTransaction
- Попытка вставки пары
email
,full_name
может завершиться ошибкой в случае если такойemail
уже существует. Используйте пакетpgerrcode
для того чтобы отловить нарушение уникальности и вернутьErrUserAlreadyExists
if err != nil {
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == pgerrcode.UniqueViolation {
return ErrUserAlreadyExists
}
return fmt.Errorf("insert user: %w", err)
}
- После вставки юзера в таблицу
users
вставьте роли используя отрефакторенную версиюadjustRoles
- Функция
UpdateUser
опять использует транзакции - Для того, чтобы переиспользовать логику
GetUser
вUpdateUser
вам нужно будет создать вспомогательный интерфейс, который объединитpgx.Conn
иpgx.Tx
type queryable interface {
Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, optionsAndArgs ...interface{}) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, optionsAndArgs ...interface{}) pgx.Row
}
- Теперь создайте функцию
func getUser(ctx context.Context, q queryable, email string) (*contracts.User, error)
и перенесите в ее код изGetUser
и вызовите ее изGetUser
. ТеперьgetUser
универсальная и может работать как в транзакции, так и вне ее - Теперь используйте
getUser
вUpdateUser
. Учитите, что если getUser вернетErrUserNotFound
, то вы должны не изменять эту ошибку возвращая ее изUpdateUser
- После того как вы получили все данные о юзере мы можем приступить к его обновлению. Используйте
UPDATE users SET ...
запрос иadjustRoles
в рамках одной транзкации
- Выполните
DELETE FROM ...
запрос и проверьте кол-во затронутых рядов. Если их 0 - верните ошибкуErrUserNotFound
- В этой функции можно не менять вручную роли, так как они удалятся благодаря
ON DELETE CASCADE
внешнего ключа
- Запустите
make run-db
- Запустите
make run-server
- Выполните создание юзеров с 0, 1 и более ролями через SwaggerUI. Выполняйте создание юзеров с уже существующими email. Проверяйте корректность ошибок и статус кодов
- Изменяйте существующих и несуществующих юзеров. Изменяйте им роли
- Проверяйте, что все юзеры обновляются и достаются через
GetUsers
,GetUsersByRole
иGetUser
- Удаляйте юзеров и проверяйте, что они удаляются из базы. Пытайтесь удалить несуществующих юзеров
Для того чтобы поработать с HTTPS мы создадим пару server.key
и server.cert
и будем использовать их в качестве приватного ключа и сервификата для TLS
openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt -days 365
- Перенесите данные получеаемые из флагов в структуру
config
- Добавьте дополнительные поля
KeyPath
,CertPath
,TLSAddr
в структуруconfig
- Добавьте соответствующие флаги. При наличии установленного
TLSAddr
оба пути к приватному ключу и сертификату должны быть указаны - Переработайте функцию
main
так, чтобы при наличииTLSAddr
сервер запускался с TLS (echo.Echo.StartTLS
), а при его отсутствии как и раньше с помощью сконфигурированного листенера и функцииecho.Echo.Start
. Лучше это сделать в конце main, после регистрации путей.
- Запустите скрипт
make run-https-server
и убедитесь что вы можете обращаться к серверу через HTTPS (https://localhost:8443/swagger/index.html
). Игнорируйте сообщение браузера о том, что сертификат не доверенный
SERVER_TLS_ADDR ?= ":8443"
run-https-server:
cd server && go run . -tls-addr $(SERVER_TLS_ADDR) -cert "server.crt" -key "server.key" -conn-string "postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable"
Для того чтобы попрактиковаться с контейнеризацией мы создадим Dockerfile и соберем образы сервера и cli-утилиты, а потом запустим их вместе с базой данных в одной сети и проверим работу. База данных должна будет иметь примонтированную директорию для хранения данных, чтобы при перезапуске контейнера базы данных данные не терялись
- Добавьте нужные флаги для того, чтобы утилита могла отправлять запросы к серверу не только используя
unix
сокет, но иHTTP
. - Проверьте, что утилита работает как и раньше, но теперь может использовать
HTTP
вместоunix
сокета
Мы будем использовать очень нетипичную сборку образа. Это связано с тем, что server и cli-утилита находятся в разных пакетах одного модуля.
- Создайте Dockerfile в директории http-userroles.
- Добавьте в его первый этап, в котором будут собираться оба проекта (server и cli). Назовите его builder и используйте на основу образ golang:1.20-alpine. Разделите этап загрузки библиотек и сборки проектов на разные части для улучшения кеширования слоев
- Добавьте 2 остальных этапа сборки с названиями cli и server. Используйте за основу образ alpine:3.14. Эти этапы должны копировать бинарники из предыдущего этапа.
- В
ENTRYPOINTS
server
образа можете захардкодить все нужные для запуска флаги
- В
- Соберите образы с помощью следующих скриптов:
docker build -t http-userroles-cli:f4 --target cli .
docker build -t http-userroles-server:f4 --target server .
- Создайте сеть
userroles
- Запустите 3 контейнера: userroles-db, userroles-server и userroles-cli в этой сети
- Проверьте, что все контейнеры запустились и работают. Если нет - проверьте логи и исправьте ошибки
- Зайтите в контейнер
userroles-cli
и проверьте, что вы можете использовать команды
- Создайте
volume
с именемuserroles-db-data
- Перезапустите контейнер
userroles-db
с примонтированнымvolume
в/var/lib/postgresql/data
- Проверьте, что данные не теряются при пересоздании контейнера