与GO,CHI,MySQL和SQLX一起使用API
#api #go #mysql #休息

这是较早的REST API with Go, Chi and InMemory Store的延续。在本教程中,我将扩展服务以将数据存储在MySQL数据库中。我将使用Docker运行mySQL并运行数据库迁移。

项目设置

我将首先复制https://github.com/kashifsoofi/blog-code-samples/tree/main/movies-api-with-go-chi-and-memory-store的内容,将其放置在新文件夹movies-api-with-go-chi-and-mysql中,然后在go.mod中更新模块名称,以匹配新文件夹并在使用的源文件中更新。这通常是您的git repo的根源,并且不会像这样详细说明。

设置数据库服务器

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

让我们从添加一个名为docker-compose.dev-env.yml的新文件开始,随时随意命名它。添加以下内容以添加电影REST API的数据库实例。

version: '3.7'

services:
  movies.db:
    image: mysql:5.7
    environment:
      - MYSQL_ROOT_PASSWORD=Password123
      - MYSQL_DATABASE=moviesdb
    volumes:
      - moviesdbdata:/var/lib/mysql
    ports:
      - "3306:3306"
    healthcheck:
      test: "mysql -uroot -pPassword123 moviesdb -e 'select 1'"
      timeout: 20s
      interval: 10s
      retries: 10

volumes:
  moviesdbdata:

在docker-compose文件所在的解决方案的根部打开一个终端,并执行以下命令以启动数据库服务器。

docker-compose -f docker-compose.dev-env.yml up -d

数据库迁移

在开始使用MySQL之前,我们需要创建一个表以存储数据。我将使用出色的migrate数据库迁移工具,也可以将其导入为图书馆。

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

migrate create -ext sql -dir db/migrations -seq schema_movies_create
migrate create -ext sql -dir db/migrations -seq table_movies_create

这将创建4个文件,对于每个迁移,都会有一个up,并且一个down脚本,在应用迁移时将执行up,并且在返回更改时将执行down

  • 000001_schema_movies_create.up.sql
CREATE SCHEMA IF NOT EXISTS Movies;
  • 000001_schema_movies_create.down.sql
DROP SCHEMA IF EXISTS Movies;
  • 000002_table_movies_create.up.sql
CREATE TABLE IF NOT EXISTS Movies (
    Id          CHAR(36)        NOT NULL UNIQUE,
    Title       VARCHAR(100)    NOT NULL,
    Director    VARCHAR(100)    NOT NULL,
    ReleaseDate DATETIME        NOT NULL,
    TicketPrice DECIMAL(12, 4)  NOT NULL,
    CreatedAt   DATETIME        NOT NULL,
    UpdatedAt   DATETIME        NOT NULL,
    PRIMARY KEY (Id)
) ENGINE=INNODB;
  • 000002_table_movies_create.down.sql
DROP TABLE IF EXISTS Movies;

i通常会创建一个具有所有数据库迁移和执行这些迁移的工具的容器。运行数据库迁移的Dockerfile如下

FROM migrate/migrate

# Copy all db files
COPY ./migrations /migrations

ENTRYPOINT [ "migrate", "-path", "/migrations", "-database"]
CMD ["mysql://root:Password123@tcp(movies.db:3306)/moviesdb 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
    command: "'mysql://root:Password123@tcp(movies.db:3306)/moviesdb' up"

在项目的根部打开一个终端,其中Docker-Compose文件是位置并执行以下命令以启动数据库服务器并应用迁移以创建Movies架构和Movies表。

docker-compose -f docker-compose.dev-env.yml up -d

MySQL电影商店

我将使用sqlx执行查询和映射列到struct字段,反之亦然,sqlx是一个库,它在GO的标准database/sql库中提供了一组扩展名。

添加一个名为mysql_movies_store.go的新文件。添加一个包含databaseUrl的新结构MySqlMoviesStore和一个指向sqlx.DB的指针,还将助手方法添加到connect中,并将其添加到数据库和close连接中。另请注意,我添加了noOpMapper方法并将其设置为sqlx.DB的mapperfunc,原因是使用与结构字段名称相同的壳体。 sqlx的默认行为是将字段名称映射到较低的案例列名。

package store

import (
    "context"
    "database/sql"
    "strings"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "github.com/google/uuid"
    "github.com/jmoiron/sqlx"
)

const driverName = "mysql"

type MySqlMoviesStore struct {
    databaseUrl string
    dbx         *sqlx.DB
}

func NewMySqlMoviesStore(databaseUrl string) *MySqlMoviesStore {
    return &MySqlMoviesStore{
        databaseUrl: databaseUrl,
    }
}

func noOpMapper(s string) string { return s }

func (s *MySqlMoviesStore) 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 *MySqlMoviesStore) close() error {
    return s.dbx.Close()
}

添加DB标签

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

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

语境

我们没有在较早的movies-api-with-go-chi-and-memory-store中使用Context,因为我们正在连接到一个外部存储和软件包,我们将用于运行查询方法接受Context我们将更新我们的store.Interface以接受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中更新方法,以在调用store方法时使用r.Context()传递请求上下文。

创造

我们使用connect助手方法连接到数据库,创建一个Movie的新实例,然后使用NamedExecContext插入查询。我们正在处理error并返回DuplicateKeyError如果返回错误包含文本Error 1062。如果插入成功,我们返回nil
创建功能看起来像

func (s *MySqlMoviesStore) 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(), "Error 1062") {
            return &DuplicateKeyError{ID: createMovieParams.ID}
        }
        return err
    }

    return nil
}

得到所有

我们使用connect助手方法连接到数据库,然后使用SelectContext sqlx方法执行查询,sqlx将列将列映射到字段。如果查询成功,那么我们返回已加载的电影的切片。

func (s *MySqlMoviesStore) 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
}

如果解析DATETIME列中存在错误,请记住将parseTime=true参数添加到您的连接字符串。

GetByid

我们使用connect助手方法连接到数据库,然后使用GetContext方法执行SELECT QUERY,sqlx将将列映射到字段。如果驾驶员返回sql.ErrNoRows,则我们返回store.RecordNotFoundError。如果返回了成功的加载movie记录。

func (s *MySqlMoviesStore) 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); err != nil {
        if err != sql.ErrNoRows {
            return Movie{}, err
        }

        return Movie{}, &RecordNotFoundError{}
    }

    return movie, nil
}

更新

我们使用connect助手方法连接到数据库,然后使用NamedExecContext方法执行查询以更新现有记录。

func (s *MySqlMoviesStore) 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 *MySqlMoviesStore) 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); 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如下所示,以创建MySqlMoviesStore的新实例,我选择创建MySqlMoviesStore的实例,而不是MemoryMoviesStore,可以增强解决方案,以基于配置创建一个依赖性。

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

测试

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

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

DATABASE_URL=root:Password123@tcp(localhost:3306)/moviesdb?parseTime=true go run main.go

来源

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

参考

没有特定顺序