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

+1
0
+1
0
+1
0
+1
0
+1
0

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *