与GO,CHI,SQL Server和SQLX进行REST API
#sql #api #go #休息

这是较早的REST API with Go, Chi and InMemory Store的延续。在本教程中,我将扩展服务以将数据存储在Microsoft SQL Server数据库中。我将在此样本中使用Microsoft SQL Server - Ubuntu based images。我将使用Docker运行SQL Server并使用相同的操作数据库迁移。

设置数据库服务器

我将使用Docker-Compose在Docker容器中运行SQL Server。这将使我们添加更多的服务,例如,我们的REST API在例如REDIS服务器用于分布式缓存。

我们将为我们的SQL Server实例使用自定义图像。这样做的原因是,SQL Server容器没有内置的功能来创建MySQL和Postgres使用环境变量提供的自定义应用程序数据库。我们有一个setup-db.sql,我们将在我们的自定义图像中复制并执行为docker-compose配置中的健康检查的一部分。

让我们从在db文件夹下添加Dockerfile.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数据库迁移工具,也可以将其导入为图书馆。

对于迁移,我在db文件夹下创建了一个文件夹migrations。我执行了以下命令创建迁移的命令。

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

i通常会创建一个具有所有数据库迁移和执行这些迁移的工具的容器。运行数据库迁移的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是一个库,它在GO的标准database/sql库中提供了一组扩展名。

store文件夹下添加一个名为sqlserver_movies_store.go的新文件。添加一个包含databaseUrl的新结构SqlServerMoviesStore和一个指向sqlx.DB的指针,还将辅助方法添加到connect中,也将辅助方法添加到数据库和close连接。另请注意,我添加了noOpMapper方法并将其设置为sqlx.DB的mapperfunc,原因是使用与结构字段名称相同的外壳。 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标签

movies_store.go文件中更新Movie struct以添加ID字段的DB标签,这允许SQLX映射ID字段以纠正列。替代方案是在选择查询中使用AS或在数据库表中更新列名称为ID。所有其他字段将通过上面的noOpMapper正确映射。

type Movie struct {
    ID          uuid.UUID `db:"Id"`
    ...
}

语境

我们没有在较早的样本movies-api-with-go-chi-and-memory-store中使用Context,因为我们正在连接到一个外部存储和软件包,我们将使用用于运行Context的查询方法,我们将更新我们的store.Interface以接受Context并使用Context并使用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的新实例,然后使用NamedExecContext插入查询。我们正在处理error并返回DuplicateIdError如果返回错误包含文本Cannot insert duplicate key。如果插入成功,我们返回nil
创建功能看起来像

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
}

得到所有

我们使用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 QUERY,sqlx将将列映射到字段。如果驾驶员返回sql.ErrNoRows,则我们返回store.RecordNotFoundError。如果返回了成功的加载movie记录。
请注意sql.Named查询参数,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
}

数据库配置

config.go中添加一个名为Database的新结构

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"`
}

依赖注入

更新main.go如下所示,以创建SqlServerMoviesStore的新实例,我选择创建SqlServerMoviesStore的实例,而不是MemoryMoviesStore,可以增强解决方案以基于配置创建一个依赖性。

// store := store.NewMemoryMoviesStore()
store := store.NewSqlServerMoviesStore(cfg.DatabaseURL)

测试

我没有为本教程添加任何单元或集成测试,也许是以下教程。但是所有终点都可以使用邮递员进行测试,以通过以下来自previous article的测试计划进行测试。

您可以通过执行以下
在Docker中运行SQL Server启动REST API

DATABASE_URL=sqlserver://sa:Password123@localhost:1433/Movies go run main.go

来源

演示应用程序的源代码托管在blog-code-samples存储库的GitHub上。

参考

没有特定顺序