使用 Go、Chi、MySQL 和 sqlx 的 REST API 第三部分

举报
Q神 发表于 2023/06/24 15:16:36 2023/06/24
【摘要】 这是之前发布的带有 Go、Chi 和 InMemory Store 的 REST API的延续。在本教程中,我将扩展服务以将数据存储在MySQL数据库中。我将使用Docker来运行 MySQL 并运行数据库迁移。项目设置我将首先复制 的内容https://github.com/kashifsoofi/blog-code-samples/tree/main/movies-api-with-go...

这是之前发布的带有 Go、Chi 和 InMemory Store 的 REST API的延续。在本教程中,我将扩展服务以将数据存储在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存储库的根目录,并且不会像这样详细。

设置数据库服务器

我将使用 docker-compose 在 docker 容器中运行 MySQL。这将允许我们添加更多我们的 REST API 依赖的服务,例如用于分布式缓存的 Redis 服务器。

让我们首先添加一个名为 的新文件docker-compose.dev-env.yml,您可以随意命名它。添加以下内容以添加电影休息 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和另一个名为migrationsdb 的文件夹。我执行了以下命令来创建迁移。

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

这将创建 4 个文件,对于每个迁移都会有一个up脚本downup将在应用迁移时执行,并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;

我通常创建一个包含所有数据库迁移和执行这些迁移的工具的容器。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来执行查询并将列映射到结构字段,反之亦然,sqlx它是一个在 go 标准database/sql库上提供一组扩展的库。

添加一个名为 的新文件mysql_movies_store.go。添加一个MySqlMoviesStore包含databaseUrl指向 的新结构体和一个指向 的指针sqlx.DB,还将帮助器方法添加connect到数据库和close连接。另请注意,我添加了一个noOpMapper方法并设置为 MapperFunc of sqlx.DB,原因是使用与结构字段名称相同的大小写。默认行为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()
}

添加数据库标签

更新文件Movie中的结构movies_store.go以添加ID字段的 db 标记,这允许 sqlx 将ID字段映射到正确的列。替代方法是使用ASin select 查询或将数据库中的列名称重命名为IDnoOpMapper所有其他字段都将使用上一节中的内容正确映射。

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并使用 执行插入查询NamedExecContext。如果返回的错误包含文本,我们正在处理error并返回。如果插入成功则返回。 创建函数看起来像DuplicateKeyErrorError 1062nil

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到连接字符串中。

按ID获取

我们使用connect辅助方法连接到数据库,然后使用GetContext方法执行选择查询,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
}

数据库配置

添加一个名为Databasein的新结构config.go并将其添加到Configurationstruct 中。

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)

测试

我不会为本教程(也许是后续教程)添加任何单元或集成测试。但是所有端点都可以使用 Postman 进行测试,按照上一篇文章中的测试计划进行测试。

您可以通过执行以下命令来启动rest api,并在docker中运行mysql

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

来源

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

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。