Go 语言构建网站之操作数据库
前面完成了使用 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 目录存放一些有利于提高开发效率的函数,比如用来生成 INSERT
和 UPDATE
语句的辅助函数。我通常会使用 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 文件分别对应 up
和 down
命令的执行内容。
当需要在项目中某个包里操作数据库时,这个包就需要依赖数据库连接。这种依赖关系可以是直接的,也可以是间接的。比如现在想在 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 通常使用 `
作为关键字转义字符,而其它数据库可能使用 "
解决了数据库操作相关的问题,下一次说说怎么管理异步任务 🍻