В этом домашнем задании вам предстоит реализовать интеграцию с базой данных в рамках сервиса library. Для простоты понимания описание этого ДЗ сделано в императивном, а не декларативном стиле
Ниже описана одна из возможных реализаций схемы базы данных. Вы можете сделать свою, объяснив выбор в комментариях PR
Сперва вам необходимо написать миграции к вашей базе данных.
Создайте директорию db/migrations с вашими миграциями, а также db/migrations/migrate.go для их применения
Создайте таблицу author
-- +goose Up
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE author
(
id ...,
name ...,
created_at ...,
updated_at ...
);
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION update_author_timestamp() RETURNS TRIGGER AS
$$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
CREATE OR REPLACE TRIGGER trigger_update_author_timestamp
BEFORE UPDATE
ON ...
FOR EACH ROW
EXECUTE FUNCTION update_author_timestamp();
-- +goose Down
DROP TABLE ...;Отдельной миграцией создайте индекс на имя автора
-- +goose Up
CREATE INDEX ...;
-- +goose Down
DROP INDEX ...;Создайте таблицу book
-- +goose Up
CREATE TABLE book
(
id ...,
name ...,
created_at ...,
updated_at ...
);
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION update_book_timestamp() RETURNS TRIGGER AS
$$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
CREATE OR REPLACE TRIGGER trigger_update_book_timestamp
BEFORE UPDATE
ON ...
FOR EACH ROW
EXECUTE FUNCTION update_book_timestamp();
-- +goose Down
DROP TABLE ...;Отдельной миграцией создайте индекс на имя книги
-- +goose Up
CREATE INDEX ...;
-- +goose Down
DROP INDEX ...;Создайте таблицу author_book
-- +goose Up
CREATE TABLE author_book
(
author_id ...,
book_id ...,
PRIMARY KEY (.. .)
);
-- +goose Down
DROP TABLE author_book;- Добавьте
foreign keyдля author_id и book_id. - Поддержите каскадное удаление
ON DELETE CASCADE, в случае удаления автора или книги в этой таблице не должны остаться неконсистентные записи - Добавьте композитный
PRIMARY KEY, состоящий изauthor_idиbook_id
Композитный PRIMARY KEY по умолчанию добавляет индекс на свои части, однако
его эффективность для каждого атрибута разная.
Отдельной миграцией добавьте индекс для book_id
В файле db/migrations/migrate.go напишите код, который будет накатывать миграции.
Используйте библиотеки ниже, а также //go:embed migrations/*.sql для загрузки
миграций - пример
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/project/library/config"Попробуйте поднять базу данных и проверить, что ваши миграции корректно накатываются
docker volumes
docker volume ls // если нужно удалить старый volume
docker volume rm ... // если нужно удалить старый volume
docker-compose up -d
docker ps -a // посмотреть контейнеры
docker stop / docker rm - для остановки и удаления контейнера
2025/03/06 15:03:14 OK 001_create_author_table.sql (5.89ms)
2025/03/06 15:03:14 OK 002_create_author_name_index.sql (8.83ms)
2025/03/06 15:03:14 OK 003_create_book_table.sql (9.78ms)
2025/03/06 15:03:14 OK 004_create_book_name_index.sql (2.51ms)
2025/03/06 15:03:14 OK 005_create_author_book_table.sql (3.28ms)
2025/03/06 15:03:14 OK 006_create_author_book_book_id_index.sql (2.99ms)
2025/03/06 15:03:14 goose: successfully migrated database to version: 6
Поддержите в вашем конфиге параметры для подключения к базе данных
type (
Config struct {
GRPC
PG
}
GRPC struct {
Port string `env:"GRPC_PORT"`
GatewayPort string `env:"GRPC_GATEWAY_PORT"`
}
PG struct {
URL string
Host string `env:"POSTGRES_HOST"`
Port string `env:"POSTGRES_PORT"`
DB string `env:"POSTGRES_DB"`
User string `env:"POSTGRES_USER"`
Password string `env:"POSTGRES_PASSWORD"`
MaxConn string `env:"POSTGRES_MAX_CONN"`
}
)Пример URL:
postgres://user:password@host:port/dbname?sslmode=disable&pool_max_conns=10
Добавьте новую реализацию репозитория вашего сервиса, используя поднятую базу данных. Не забывайте про консистентность и атомарность операций. Пример:
func (r *PostgresRepository) CreateBook(ctx context.Context, book entity.Book) (entity.Book, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
return entity.Book{}, err
}
defer tx.Rollback(ctx)
const queryBook = `INSERT INTO book (name) VALUES ($1) RETURNING id, created_at, updated_at`
err = tx.QueryRow(ctx, queryBook, book.Name).Scan(&book.ID, &book.CreatedAt, &book.UpdatedAt)
if err != nil {
return entity.Book{}, err
}
const queryAuthorBooks = `INSERT INTO author_book (author_id, book_id) VALUES ($1, $2)`
for _, authorID := range book.AuthorIDs {
_, err := tx.Exec(ctx, queryAuthorBooks, authorID, book.ID)
if err != nil {
return entity.Book{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return entity.Book{}, err
}
return book, nil
}- Старайтесь обойтись одним запросом там, где это возможно
- ID автора и книги должны генерироваться на уровне базы через
DEFAULT uuid_generate_v4()
Добавьте в API для Book поля created_at и updated_at
import "google/protobuf/timestamp.proto";
message Book {
...
google.protobuf.Timestamp created_at = ...;
google.protobuf.Timestamp updated_at = ...;
}С этой части начинается ДЗ outbox. Ветка с решением должна иметь название outbox. Важно, чтобы в PR не было diff'a старого ДЗ.
Вы можете добиться этого, сделав rebase на main после проверки предыдущего ДЗ
Реализуйте паттерн outbox, который обсуждался на лекции
Создайте таблицу outbox
CREATE TYPE outbox_status as ENUM ('CREATED', 'IN_PROGRESS', 'SUCCESS');
CREATE TABLE outbox
(
idempotency_key TEXT PRIMARY KEY,
data JSONB NOT NULL,
status outbox_status NOT NULL,
kind INT NOT NULL,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);Поддержите транзакции на уровне доменной логики
type Transactor interface {
WithTx(ctx context.Context, function func(ctx context.Context) error) error
}
func extractTx(ctx context.Context) (pgx.Tx, error) {}
func injectTx(ctx context.Context, pool *pgxpool.Pool) (context.Context, error, pgx.Tx) {}Например:
func (l *libraryImpl) RegisterBook(ctx context.Context, name string, authorIDs []string) (*library.AddBookResponse, error) {
var book entity.Book
err := l.transactor.WithTx(ctx, func(ctx context.Context) error {
book, txErr = l.booksRepository.CreateBook(ctx, entity.Book{
Name: name,
AuthorIDs: authorIDs,
})
...
l.outboxRepository.SendMessage(ctx, idempotencyKey, repository.OutboxKindBook, serialized)
})
...
}Поддержите конфиг для Outbox
type Outbox struct {
Enabled bool `env:"OUTBOX_ENABLED"`
Workers int `env:"OUTBOX_WORKERS"`
BatchSize int `env:"OUTBOX_BATCH_SIZE"`
WaitTimeMS time.Duration `env:"OUTBOX_WAIT_TIME_MS"`
InProgressTTLMS time.Duration `env:"OUTBOX_IN_PROGRESS_TTL_MS"`
AuthorSendURL string `env:"OUTBOX_AUTHOR_SEND_URL"`
BookSendURL string `env:"OUTBOX_BOOK_SEND_URL"`
}При создании книги или автора вам необходимо асинхронно отправить POST запрос c AuthorID или BookID на OUTBOX_AUTHOR_SEND_URL
или OUTBOX_BOOK_SEND_URL, соответственно.
Для удобства выполнения и проверки дз вводится ряд правил, унифицирующих используемые технологии
- Структура проекта go-clean-template и этот шаблон
- Для генерации кода авторские Makefile и easyp.yaml
- Для логирования zap
- Для валидации protoc-gen-validate
- Для поддержики REST-to-gRPC API gRPC gateway
- Для миграций goose
- pgx как драйвер для postgres
-
Код тестов можно посмотреть в файле integration_test.go
-
Важно, чтобы ваш сервис умел корректно обрабатывать SIGINT и SIGTERM, иначе тесты могут работать некорректно
-
В Makefile реализованы метки build и generate, без них CI не будет работать
В рамках вашего сервиса вы должны реализовать конфиг, который будет работать с переменными окружения
Необходимо сгенерировать моки и написать свои тесты, степень покрытия будет проверяться в CI
Вам необходимо своими словами написать README.md в ./docs к своему сервису library
- Пример реализации
- Не забывайте про логирование
- Не забывайте про консистентность в базе данных
- Используйте тесты чтобы осознать недосказанности
- Не нужно добавлять старую in-memory реализацию репозитория
Поскольку количество попыток сдачи ограничено, вы можете написать дополнительные комментарии в PR. Если ваше
обоснование будет достаточно разумным, это может быть учтено при выставлении баллов. Например,
-
описать, почему вы написали именно такие интерфейсы
-
описать, почему вы сделали именно такую валидацию
-
описать, почему вы сделали именно такую схему в базе данных
-
Открыть pull request из ветки задания в ветку
mainвашего репозитория. -
В описании PR заполнить количество часов, которые вы потратили на это задание.
-
Отправить заявку на ревью в соответствующей форме.
-
Время дедлайна фиксируется отправкой формы.
-
Изменять файлы в ветке main без PR запрещено.
-
Изменять файл CI workflow запрещено.
Для удобств локальной разработки сделан Makefile. Имеются следующие команды:
Запустить полный цикл (линтер, тесты):
make all
Запустить только тесты:
make test
Запустить линтер:
make lint
Подтянуть новые тесты:
make update
При разработке на Windows рекомендуется использовать WSL, чтобы
была возможность пользоваться вспомогательными скриптами.