REST API с использованием Go, Chi, SQL Server и sqlx
В этом статье я расширю сервис для хранения данных в базе данных Microsoft SQL Server. Для этого примера я буду использовать образы Microsoft SQL Server – Ubuntu. Я буду использовать Docker для запуска SQL Server и использовать его же для запуска миграции базы данных.
Настройка сервера базы данных
Я буду использовать docker-compose для запуска SQL Server в контейнере docker. Это позволит нам добавить больше сервисов, от которых зависит наш rest api, например, сервер redis для распределенного кэширования.
Мы будем использовать пользовательский образ для нашего экземпляра SQL Server. Причина в том, что контейнер SQL Server не имеет встроенной функциональности для создания пользовательской базы данных приложения, которую MySQL и Postgres предоставляют с помощью переменных окружения. У нас есть setup-db.sql, который мы скопируем в наш пользовательский образ и выполним как часть проверки работоспособности в конфигурации docker-compose.
Начнем с добавления Dockerfile.db в папку db.
FROM mcr.microsoft.com/mssql/server:2022-latest
WORKDIR /scripts
COPY setup-db.sql /scripts/setup-db.sql
ENTRYPOINT [ "/opt/mssql/bin/sqlservr" ]
Вот файл setup-db.sql, также расположенный в папке db, я создаю только базу данных, но это хорошее место для добавления пользователя приложения, роли и установки разрешений.
IF NOT EXISTS(SELECT * FROM sys.databases WHERE name = 'Movies')
BEGIN
CREATE DATABASE Movies
SELECT 'READY'
END
Давайте добавим новый файл docker-compose.dev-env.yml, не стесняйтесь назвать его так, как вам нравится. Добавьте следующее содержимое для добавления экземпляра базы данных для фильмов rest api.
version: '3.7'
services:
movies.db:
image: movies.db
build:
context: ./db/
dockerfile: Dockerfile.db
environment:
- ACCEPT_EULA=Y
- MSSQL_SA_PASSWORD=Password123
- MSSQL_PID=Express
volumes:
- moviesdbdata:/var/opt/mssql
ports:
- "1433:1433"
healthcheck:
test: '/opt/mssql-tools/bin/sqlcmd -U sa -P Password123 -i /scripts/setup-db.sql | grep -q "READY"'
timeout: 20s
interval: 10s
retries: 10
volumes:
moviesdbdata:
Откройте терминал в корне решения, где находится файл docker-compose, и выполните следующую команду для запуска сервера базы данных.
docker-compose -f docker-compose.dev-env.yml up -d
Миграции баз данных
Прежде чем мы сможем начать использовать SQL Server, нам необходимо создать таблицу для хранения наших данных. Я буду использовать отличный инструмент миграции баз данных migrate, его также можно импортировать как библиотеку.
Для миграций я создал папку migrations в папке db. Я выполнил следующие команды для создания миграций.
migrate create -ext sql -dir db/migrations -seq table_movies_create
Это создаст 2 файла, для каждой миграции будет скрипт up и down, up будет выполняться при применении миграции, а down – при откате изменений.
000001_table_movies_create.up.sql
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='Movies' and xtype='U')
BEGIN
CREATE TABLE Movies (
Id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
Title VARCHAR(100) NOT NULL,
Director VARCHAR(100) NOT NULL,
ReleaseDate DateTimeOffset NOT NULL,
TicketPrice DECIMAL(12, 4) NOT NULL,
CreatedAt DateTimeOffset NOT NULL,
UpdatedAt DateTimeOffset NOT NULL
)
END
000001_table_movies_create.down.sql
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='Movies' and xtype='U')
BEGIN
DROP TABLE Movies
END
Обычно я создаю контейнер, содержащий все миграции базы данных и инструмент для выполнения этих миграций. Dockerfile.migrations для выполнения миграций базы данных выглядит следующим образом
FROM migrate/migrate
# Copy all db files
COPY ./migrations /migrations
ENTRYPOINT [ "migrate", "-path", "/migrations", "-database"]
CMD ["sqlserver://sa:Password123@movies.db:1433/Movies up"]
Добавьте следующее в файл docker-compose.dev-env.yml, чтобы добавить контейнер миграций и запускать миграции при запуске. Пожалуйста, помните, что если вы добавите новые миграции, вам придется удалить контейнер и образ movies.db.migrations, чтобы добавить новые файлы миграций в образ.
movies.db.migrations:
depends_on:
movies.db:
condition: service_healthy
image: movies.db.migrations
build:
context: ./db/
dockerfile: Dockerfile.migrations
command: "sqlserver://sa:Password123@movies.db:1433/Movies up"
Откройте терминал в корне проекта, где находится файл docker-compose, и выполните следующую команду для запуска сервера базы данных и применения миграций для создания схемы Movies и таблицы Movies.
docker-compose -f docker-compose.dev-env.yml up -d
Хранилище фильмов SQL Server
Я буду использовать sqlx для выполнения запросов и сопоставления колонок с полями struct и наоборот. sqlx – это библиотека, которая предоставляет набор расширений для стандартной библиотеки базы данных/sql go.
Добавьте новый файл с именем sqlserver_movies_store.go в папку store. Добавьте новый struct SqlServerMoviesStore, содержащий databaseUrl и указатель на sqlx.DB, также добавьте вспомогательные методы для подключения к базе данных и закрытия соединения. Также обратите внимание, что я добавил метод noOpMapper и установил как MapperFunc sqlx.DB, причина этого в том, чтобы использовать тот же регистр, что и имя поля struct. По умолчанию sqlx использует имена полей в нижнем регистре имен столбцов.
package sqlserver
import (
"context"
"github.com/jmoiron/sqlx"
_ "github.com/microsoft/go-mssqldb"
)
const driverName = "sqlserver"
type SqlServerMoviesStore struct {
databaseUrl string
dbx *sqlx.DB
}
func NewSqlServerMoviesStore(databaseUrl string) *SqlServerMoviesStore {
return &SqlServerMoviesStore{
databaseUrl: databaseUrl,
}
}
func noOpMapper(s string) string { return s }
func (s *SqlServerMoviesStore) connect(ctx context.Context) error {
dbx, err := sqlx.ConnectContext(ctx, driverName, s.databaseUrl)
if err != nil {
return err
}
dbx.MapperFunc(noOpMapper)
s.dbx = dbx
return nil
}
func (s *SqlServerMoviesStore) close() error {
return s.dbx.Close()
}
Добавить тег db
Обновите Movie struct в файле movies_store.go, чтобы добавить тег db для поля ID, это позволит sqlx сопоставить поле ID с нужным столбцом. Альтернативой этому является использование AS в запросах select или обновление имени столбца в таблице базы данных как ID. Все остальные поля будут правильно сопоставлены с помощью noOpMapper из приведенного выше раздела.
type Movie struct {
ID uuid.UUID `db:"Id"`
...
}
Контекст
Мы не использовали Context в предыдущем примере movies-api-with-go-chi-and-memory-store, теперь, когда мы подключаемся к внешнему хранилищу и пакет, который мы собираемся использовать для выполнения запросов, поддерживает методы, принимающие Context, мы обновим наш store.Interface, чтобы он принимал Context и использовал его при выполнении запросов. store.Interface будет обновлен следующим образом
type Interface interface {
GetAll(ctx context.Context) ([]Movie, error)
GetByID(ctx context.Context, id uuid.UUID) (Movie, error)
Create(ctx context.Context, createMovieParams CreateMovieParams) error
Update(ctx context.Context, id uuid.UUID, updateMovieParams UpdateMovieParams) error
Delete(ctx context.Context, id uuid.UUID) error
}
Нам также потребуется обновить методы MemoryMoviesStore, чтобы они принимали Context, удовлетворяющий store.Interface, и обновить методы в movies_handler, чтобы передавать контекст запроса с помощью r.Context() при вызове методов store.
Создать
Мы подключаемся к базе данных с помощью вспомогательного метода connect, создаем новый экземпляр Movie и выполняем запрос insert с помощью NamedExecContext. Мы обрабатываем ошибку и возвращаем DuplicateIdError, если возвращаемая ошибка содержит текст Cannot insert duplicate key. Если вставка прошла успешно, то мы возвращаем nil.
Функция Create выглядит следующим образом
func (s *SqlServerMoviesStore) Create(ctx context.Context, createMovieParams CreateMovieParams) error {
err := s.connect(ctx)
if err != nil {
return err
}
defer s.close()
movie := Movie{
ID: createMovieParams.ID,
Title: createMovieParams.Title,
Director: createMovieParams.Director,
ReleaseDate: createMovieParams.ReleaseDate,
TicketPrice: createMovieParams.TicketPrice,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if _, err := s.dbx.NamedExecContext(
ctx,
`INSERT INTO Movies
(Id, Title, Director, ReleaseDate, TicketPrice, CreatedAt, UpdatedAt)
VALUES
(:Id, :Title, :Director, :ReleaseDate, :TicketPrice, :CreatedAt, :UpdatedAt)`,
movie); err != nil {
if strings.Contains(err.Error(), "Cannot insert duplicate key") {
return &DuplicateKeyError{ID: createMovieParams.ID}
}
return err
}
return nil
}
GetAll
Мы подключаемся к базе данных с помощью вспомогательного метода connect, затем используем метод SelectContext sqlx для выполнения запроса, sqlx сопоставляет столбцы с полями. Если запрос выполнен успешно, то мы возвращаем фрагмент загруженных фильмов.
func (s *SqlServerMoviesStore) GetAll(ctx context.Context) ([]Movie, error) {
err := s.connect(ctx)
if err != nil {
return nil, err
}
defer s.close()
var movies []Movie
if err := s.dbx.SelectContext(
ctx,
&movies,
`SELECT
Id, Title, Director, ReleaseDate, TicketPrice, CreatedAt, UpdatedAt
FROM Movies`); err != nil {
return nil, err
}
return movies, nil
}
GetByID
Мы подключаемся к базе данных с помощью вспомогательного метода connect, затем используем метод GetContext для выполнения запроса select, sqlx сопоставляет столбцы с полями. Если драйвер возвращает sql.ErrNoRows, то мы возвращаем store.RecordNotFoundError. В случае успеха возвращается загруженная запись фильма.
Обратите внимание на sql.Named query paramter, он нужен драйверу sql server для передачи именованных параметров.
func (s *SqlServerMoviesStore) GetByID(ctx context.Context, id uuid.UUID) (Movie, error) {
err := s.connect(ctx)
if err != nil {
return Movie{}, err
}
defer s.close()
var movie Movie
if err := s.dbx.GetContext(
ctx,
&movie,
`SELECT
Id, Title, Director, ReleaseDate, TicketPrice, CreatedAt, UpdatedAt
FROM Movies
WHERE Id = @id`,
sql.Named("id", id)); err != nil {
if err != sql.ErrNoRows {
return Movie{}, err
}
return Movie{}, &RecordNotFoundError{}
}
return movie, nil
}
Обновление
Мы подключаемся к базе данных с помощью вспомогательного метода connect, затем используем метод NamedExecContext для выполнения запроса на обновление существующей записи.
func (s *SqlServerMoviesStore) Update(ctx context.Context, id uuid.UUID, updateMovieParams UpdateMovieParams) error {
err := s.connect(ctx)
if err != nil {
return err
}
defer s.close()
movie := Movie{
ID: id,
Title: updateMovieParams.Title,
Director: updateMovieParams.Director,
ReleaseDate: updateMovieParams.ReleaseDate,
TicketPrice: updateMovieParams.TicketPrice,
UpdatedAt: time.Now().UTC(),
}
if _, err := s.dbx.NamedExecContext(
ctx,
`UPDATE Movies
SET Title = :Title, Director = :Director, ReleaseDate = :ReleaseDate, TicketPrice = :TicketPrice, UpdatedAt = :UpdatedAt
WHERE Id = :Id`,
movie); err != nil {
return err
}
return nil
}
Удалить
Мы подключаемся к базе данных с помощью вспомогательного метода connect, затем выполняем запрос на удаление существующей записи с помощью ExecContext.
func (s *SqlServerMoviesStore) Delete(ctx context.Context, id uuid.UUID) error {
err := s.connect(ctx)
if err != nil {
return err
}
defer s.close()
if _, err := s.dbx.ExecContext(
ctx,
`DELETE FROM Movies
WHERE id = @id`, sql.Named("id", id)); err != nil {
return err
}
return nil
}
Конфигурация базы данных
Добавьте новую структуру с именем Database в config.go и добавьте ее в структуру Configuration.
type Configuration struct {
HTTPServer
Database
}
...
type Database struct {
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
LogLevel string `envconfig:"DATABASE_LOG_LEVEL" default:"warn"`
MaxOpenConnections int `envconfig:"DATABASE_MAX_OPEN_CONNECTIONS" default:"10"`
}
Dependency Injection
Обновите main.go следующим образом для создания нового экземпляра SqlServerMoviesStore, я выбрал создание экземпляра SqlServerMoviesStore вместо MemoryMoviesStore, решение может быть улучшено для создания любой из зависимостей на основе конфигурации.
// store := store.NewMemoryMoviesStore()
store := store.NewSqlServerMoviesStore(cfg.DatabaseURL)
Test
Я не добавляю никаких модульных или интеграционных тестов для этого руководства, возможно, это будет сделано в следующем руководстве. Но все конечные точки можно протестировать либо с помощью Postman, либо следуя плану тестирования из предыдущей статьи.
Вы можете запустить rest api с SQL Server, запущенным в docker, выполнив следующее
DATABASE_URL=sqlserver://sa:Password123@localhost:1433/Movies go run main.go