前面完成了使用 Go 语言构建网站的初始化工作,有了非常灵活的基础代码结构,现在就可以在那基础上方便的添加丰富的功能了。

动态网站的构建离不开后端数据库的支持,怎样方便而高效的操作数据库一直都是开发者很关心的问题。Go 语言自带 SQL 数据库通用操作接口,开发者只需要导入特定数据库的驱动包,就可以使用通用操作接口访问数据库。

为了方便修改数据库结构或者是数据迁移,有必要添加单独的子命令来完成这个功能。虽然可以直接使用第三方现成的数据库迁移工具,但将迁移工具直接集成到项目中会更方便部署。

执行 cobra add migrate 添加一个名为 migrate 的子命令,在 migrate 子命令中集成 golang-migrate 这个包提供的功能。migrate 命令具体的实现比较简单,主要就是对 golang-migrate 中的操作进行了封装,并通过 config.yaml 为迁移工具提供配置,这里就不介绍具体的代码了。

这里使用 MySQL 作为演示项目的数据库,除了导入的数据库驱动包和 SQL 语句语法稍有不同外,其余代码对其它 SQL 数据库也是可用的。

为 migrate 命令配置数据库参数,

migration:
  dsn: "mysql://root@tcp(mysql:3306)/buhaoyong?x-migrations-table=migrations&charset=utf8mb4"
  path: ./migrations

配置中 dsn 参数规则参考 golang-migrate 针对 MySQL 数据库的文档,golang-migrate 支持从多种源获取迁移文件,这里使用本地文件,path 参数指定了 SQL 文件存放路径。

有了迁移工具,下面来准备数据库连接管理包。虽然可以直接在代码中使用 Go 语言自带了 SQL 数据库通用操作接口,但为了让代码保持简洁,也能更好的和依赖处理工具相结合,对通用操作接口做个简单的封装是有好处的。

添加 pkg/db 目录,在 db 目录中添加 db.go 来管理数据库连接相关的代码,另外添加一个 helper 目录存放一些有利于提高开发效率的函数,比如用来生成 INSERTUPDATE 语句的辅助函数。我通常会使用 sqlx 这个包来把查询数据映射到结构体上,所以在 pkg/db.go 里不是直接使用 database/sql 的连接函数,而是使用 sqlx 封装的连接函数。

|-- cmd
|   |-- migrate.go
|-- migrations
`-- pkg
    `-- db
        |-- db.go
        `-- helper
            `-- helper.go

现在可以在 controller 里试试数据库相关的操作,先通过 migrate 命令增加个表

$ ./buhaoyong --config ./config.yaml migrate create create_post_table
Using config file: config.yaml
Created migration file: ./migrations/20201102134301_create_post_table.up.sql 
Created migration file: ./migrations/20201102134301_create_post_table.down.sql 
/* ./migrations/20201102134301_create_post_table.up.sql */
create table post (
    id int auto_increment primary key,
    title varchar(200) not null,
    content text not null,
);

/* ./migrations/20201102134301_create_post_table.down.sql */
drop table post;

使用 ./buhaoyong --config ./config.yaml migrate up 命令执行 SQL 文件, *.up.sql 和 *.down.sql 文件分别对应 updown 命令的执行内容。

当需要在项目中某个包里操作数据库时,这个包就需要依赖数据库连接。这种依赖关系可以是直接的,也可以是间接的。比如现在想在 app/website/controller 包的 HelloController.SayHello 里操作数据库,就需要在 NewHelloController 函数的参数中列表增加 db.DB 参数,使得 HelloController 中能够使用数据库连接,这就属于直接依赖。当不在 HelloController 直接操作数据库,而是调用封装好的服务,由封装的服务去操作数据库,这就属于间接依赖。不管是直接依赖还是间接依赖,这类依赖关系的处理都是在 app/wire.go 里处理。

数据库连接是一个全局性的资源,可以直接放到 app/component.go 里管理。

-import "github.com/gorilla/mux"
+import (
+	"buhaoyong/pkg/db"
+	"github.com/gorilla/mux"
+)
 
 type Component struct {
 	Router *mux.Router
+	DB     *db.DB
 }
 
 func New(
 	router *mux.Router,
+	dbc *db.DB,
 ) *Component {
 	return &Component{
 		Router: router,
+		DB:     dbc,
 	}
 }

在 app/wire.go 中提供数据库连接 provider

@@ -5,6 +5,7 @@ package app
 import (
 	"buhaoyong/app/api"
 	"buhaoyong/app/website"
+	"buhaoyong/pkg/db"
 	"fmt"
 
 	"github.com/google/wire"
@@ -12,10 +13,15 @@ import (
 	"github.com/spf13/viper"
 )
 
+var (
+	dbSet = wire.NewSet(db.New, dbConfigProvider)
+)
+
 func SetupComponent() (*Component, error) {
 	wire.Build(
 		New,
 		mux.NewRouter,
+		dbSet,
 	)
 
 	return nil, nil
@@ -67,3 +73,16 @@ func websiteConfigProvider() (*website.Config, error) {
 	}
 	return &c, nil
 }
+
+func dbConfigProvider() (*db.Config, error) {
+	var c db.Config
+	key := "database"
+	if !viper.IsSet(key) {
+		return nil, fmt.Errorf("missing %s config", key)
+	}
+
+	if err := viper.UnmarshalKey(key, &c); err != nil {
+		return nil, fmt.Errorf("can not decode database config: %w", err)
+	}
+	return &c, nil
+}

对应的在 config.yaml 中添加数据库需要的配置信息

database:
  driver: "mysql"
  dsn: "root:@tcp(mysql:3306)/test?charset=utf8mb4"

假设 app/website/controller 包中 HelloController 依赖数据库连接,要将数据库连接传递到 HelloController 就需要通过 app/website.New 函数先把依赖传入 app/website 包,然后在 app/website.setupRoutes 函数中把依赖通过 app/website/controller.NewHelloController 传入 HelloController。

配置好依赖关系后,记得使用 wire 命令重新生成依赖处理代码。依赖搞定后,在 HelloController 中操作数据库就很方便了

func (c *helloControllerImpl) SayHello(w http.ResponseWriter, r *http.Request) {
+   c.db.ExecContext(r.Context(), `insert into post (title, content) values(?, ?)`, "标题", "内容")

	c.HTML(w, http.StatusOK, "<h1>Hello</h1>")
}

我几乎不会直接在 controller 里操作数据库,因为相同的数据库操作可能会在多个 controller 里使用,比如查询文章的操作,可能会用在 website 也可能会用在 api ,还可能会用在后台管理。所以大多数时候我都把数据库相关的操作封装到单独的包里,我把这类包称为服务。

添加一个 pkg/service/post 目录,在这里提供对 post 数据的所有操作。

package post

import (
	"buhaoyong/pkg/db"
	"buhaoyong/pkg/db/helper"
	"buhaoyong/pkg/service/post/model"
	"context"
	"database/sql"
	"fmt"
)

type Repository interface {
	Create(ctx context.Context, m *model.Post) (*model.Post, error)
	Update(ctx context.Context, m *model.Post) error
	FindByID(ctx context.Context, id int32) (*model.Post, error)
	Delete(ctx context.Context, id int32) error
}

type repositoryImpl struct {
	db *db.DB
}

func New(dbc *db.DB) Repository {
	return &repositoryImpl{db: dbc}
}

func (r *repositoryImpl) Create(ctx context.Context, m *model.Post) (*model.Post, error) {
	columns, placeholders, values, err := helper.BuildForInsert(m)
	if err != nil {
		return nil, err
	}

	result, err := r.db.ExecContext(ctx, fmt.Sprintf(`insert into post (%s) values(%s)`, columns, placeholders), values...)
	if err != nil {
		return nil, err
	}

	id, err := result.LastInsertId()
	if err != nil {
		return nil, err
	}

	return r.FindByID(ctx, int32(id))
}

func (r *repositoryImpl) Update(ctx context.Context, m *model.Post) error {
	columns, values, err := helper.BuildForUpdate(m)
	if err != nil {
		return err
	}

	values = append(values, m.ID)
	_, err = r.db.ExecContext(ctx, fmt.Sprintf(`update post set %s where id=?`, columns), values...)
	return err
}

func (r *repositoryImpl) FindByID(ctx context.Context, id int32) (*model.Post, error) {
	var m model.Post
	if err := r.db.QueryRowxContext(ctx, `select id,title,content from post where id=?`, id).StructScan(&m); err != nil {
		if err == sql.ErrNoRows {
			return nil, nil
		}

		return nil, err
	}

	return &m, nil
}

func (r *repositoryImpl) Delete(ctx context.Context, id int32) error {
	_, err := r.db.ExecContext(ctx, `delete from post where id=?`, id)
	return err
}

有了 pkg/service/post 以后,在 controller 里就不用直接依赖数据库连接了,转而依赖 pkg/service/post.Repository 接口。在 app/wire.go 里配置依赖关系,增加 pkg/service/post.Repository 的 provider

func SetupWebsite(c *Component) (website.Repository, error) {
 	wire.Build(
 		website.New,
 		websiteConfigProvider,
+		postServiceProvider,
 		wire.FieldsOf(&c, "Router"),
 	)

...
+func postServiceProvider(c *Component) (post.Repository, error) {
+	wire.Build(
+		post.New,
+		wire.FieldsOf(&c, "DB"),
+	)
+
+   return nil, nil
+}

修改依赖关系后 HelloController 操作 post 数据的方式是通过 pkg/service/post.Repository 接口完成的,HelloController 就不再关心操作 post 数据的具体细节,只需要通过 post 服务提供的接口操作即可。

package controller

import (
	"buhaoyong/pkg/service/post"
	"buhaoyong/pkg/service/post/model"
	"fmt"
	"net/http"
)

type HelloController interface {
	SayHello(w http.ResponseWriter, r *http.Request)
}

type helloControllerImpl struct {
	*Controller

	postService post.Repository
}

func NewHelloController(baseController *Controller, postService post.Repository) HelloController {
	return &helloControllerImpl{Controller: baseController, postService: postService}
}

func (c *helloControllerImpl) SayHello(w http.ResponseWriter, r *http.Request) {
	_, err := c.postService.Create(r.Context(), &model.Post{Title: "标题", Content: "内容"})
	if err != nil {
		fmt.Println(fmt.Errorf("create post error: %w", err))
		c.HTML(w, http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
		return
	}

	c.HTML(w, http.StatusOK, "<h1>Hello</h1>")
}

在 pkg/service/post 的 Create 和 Update 函数中分别使用了 helper.BuildForInsert 和 helper.BuildForUpdate 函数,它们主要是根据 pkg/service/post/model.Post 的 tag 设置创建对应的 SQL 操作语句片段。

// pkg/service/post/model/post.go

package model

type Post struct {
	ID      int32  `db:"id,unsafe"`
	Title   string `db:"title"`
	Content string `db:"content"`
}

db 标签用于设置 Post 结构体中字段在数据库表中的字段名,标签的 unsafe 选项指的是该字段值不能被外部调用设置。这里用在 ID 字段上是指即使给 Post.ID 赋值了,BuildForInsert/BuildForUpdate 函数也不会生成 ID 字段相关的语句,因为前面创建表的时候将 id 字段定义为使用数据库自增的,也就不需要外部调用来设置 ID 字段的值。同时 unsafe 还能起到一定安全控制,比如前端网页提供一个表单用来修改用户昵称,昵称和 UserID 放在同一个表中,程序直接把表单数据映射到 User 结构体,这时候如果没有 unsafe 来控制,UserID 可能就会被前端恶意提交的值修改。

在查询操作中,sqlx 的 StructScan 函数可以很方便的将 SELECT 数据中的字段数据映射到对应的结构体中,免去了手动一个字段一个字段映射的麻烦。

借助 pkg/db/helper 和 sqlx 可以很大程度的提高数据库操作效率和安全性,它们的实现足够简单,可以根据项目需求轻松的扩展。

⚠️ pkg/db/helper 是我这里只是针对 MySQL 数据库写的,如果使用其它数据库需要根据数据库语法规则来修改。比如 MySQL 通常使用 ` 作为关键字转义字符,而其它数据库可能使用 "

解决了数据库操作相关的问题,下一次说说怎么管理异步任务 🍻