Go语言框架三件套(ORM, RPC, Web) | 青训营笔记

300 阅读8分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天。

1 ORM框架——GORM

gorm是一个使用Go语言编写的ORM框架。它文档齐全,对开发者友好,支持主流数据库。

中文文档:GORM 指南 | GORM - The fantastic ORM library for Golang, aims to be developer friendly.

安装GORM和Mysql驱动:

go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql

1.1 模型定义

定义一个结构体与数据库中的一个表进行对应。例如

type User struct {
  ID           uint
  Name         string
  Email        *string
  Age          uint8
  Birthday     *time.Time
  MemberNumber sql.NullString
  ActivatedAt  sql.NullTime
  CreatedAt    time.Time
  UpdatedAt    time.Time
}

GORM 倾向于约定优于配置。默认情况下,GORM 使用:

  • ID 作为主键
  • 结构体名的 蛇形复数 作为表名
  • 字段名的 蛇形 作为列名
  • CreatedAtUpdatedAt 字段追踪创建、更新时间

基于此约定,GORM 预先定义了 gorm.Model 这个结构体,包含了以上4个字段。

同样的,可以为结构体中的每个字段添加 tag,详见文档。

1.2 连接数据库

GORM 官方支持的数据库类型有: MySQL, PostgreSQL, SQlite, SQL Server,以下简单介绍 MySQL 相关的。

定义 dsn 变量(data-source-name)用于打开数据库,其中包含了

  • 用户名
  • 密码
  • 连接协议
  • 数据库地址:IP+端口
  • 数据库名称
  • [字符集]
  • [parseTime]
  • [时区]
import (
  "gorm.io/driver/mysql"
  "gorm.io/gorm"
)

func main() {
  dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
  db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
}

GORM 使用 database/sql 维护连接池

sqlDB, err := db.DB()

// SetMaxIdleConns 设置空闲连接池中连接的最大数量
sqlDB.SetMaxIdleConns(10)

// SetMaxOpenConns 设置打开数据库连接的最大数量。
sqlDB.SetMaxOpenConns(100)

// SetConnMaxLifetime 设置了连接可复用的最大时间。
sqlDB.SetConnMaxLifetime(time.Hour)

1.3 CRUD

1.3.1 create 创建数据

使用 db.Create() 来插入一条记录

user := User{Name: "Jinzhu", Age: 18, Birthday: time.Now()}
result := db.Create(&user) // 通过数据的指针来创建

db.Create() 也支持批量插入记录,也可以使用 db.CreateInBatches() 实现插入数量并指定每一批的数量。

var users = []User{{name: "jinzhu_1"}, ...., {Name: "jinzhu_10000"}}
db.Create(&users)
db.CreateInBatches(users, 100)

可以添加一些钩子方法以实现在插入记录前后做检查。GORM 允许用户定义的钩子有 BeforeSave, BeforeCreate, AfterSave, AfterCreate 。创建记录时将调用这些钩子方法。

为字段添加 gorm:"default:value tag 可以指定字段的默认值

使用 clause.OnConflict ("gorm.io/gorm/clause") 来处理冲突,以下是一个不处理冲突的例子。

db.Clauses(clause.OnConflict{DoNothing: true}).Create(&user)

1.3.2 read 查询数据

查询第一条/最后一条记录(主键升序)分别用 db.First() 和 db.Last(),当没有找到记录时,会返回 ErrRecordNotFound 错误。

使用 db.Find() 查找多条记录,未找到时不会返回错误。

条件检索——按主键检索

// SELECT * FROM users WHERE id = 10; 其中参数也可以输入字符串格式
db.First(&user, 10)

// SELECT * FROM users WHERE id IN (1,2,3);
db.Find(&users, []int{1,2,3})

条件检索——非主键检索

// SELECT * FROM users WHERE name <> 'jinzhu';
db.Where("name <> ?", "jinzhu").Find(&users)

// SELECT * FROM users WHERE name IN ('jinzhu','jinzhu 2');
db.Where("name IN ?", []string{"jinzhu", "jinzhu 2"}).Find(&users)

// SELECT * FROM users WHERE name = "jinzhu" AND age = 20 ORDER BY id LIMIT 1;
db.Where(&User{Name: "jinzhu", Age: 20}).First(&user)

也可以不使用 where 语句,直接将条件作为find/first的参数

// SELECT * FROM users WHERE name = "jinzhu";
db.Find(&user, "name = ?", "jinzhu")

注意:当使用struct进行查询时,GORM将仅使用非零字段进行查询,这意味着如果字段的值为0、“”、false或其他零值,则不会使用它来构建查询条件,例如下面这个语句中的 age=0 条件会被忽略:

// SELECT * FROM users WHERE name = "jinzhu";
db.Where(&User{Name: "jinzhu", Age: 0}).Find(&users)

要避免这一点,可以使用值类型为 interface的 map 来构建输入的条件

// SELECT * FROM users WHERE name = "jinzhu" AND age = 0;
db.Where(map[string]interface{}{"Name": "jinzhu", "Age": 0}).Find(&users)

GORM还支持了Group By和Having以及联合查询(join)等,详见文档。

1.3.3 update 更新

使用 db.save() 保存所有字段以实现更新操作:db.Save(&user)

使用 db.Update() 按条件更新单列。当使用 Model 方法,并且值中有主键值时,主键将会被用于构建条件。

// UPDATE users SET name='hello', updated_at='2013-11-17 21:34:10' WHERE active=true AND id=111;
db.Model(&User{ID:111}).Where("active = ?", true).Update("name", "hello")

使用 db.Updates() 按条件更新多列,使用方法与db.Update() 类似。

注意:跟查询类似,当使用 struct 进行更新时,GORM 只会更新非零值的字段。要避免这一点,可以使用 map 更新字段,或者使用 Select 指定要更新的字段,下面是一个用 Select 更新指定字段的例子。

// UPDATE users SET name='new_name', age=0 WHERE id=111;
db.Model(&user).Select("Name", "Age").Updates(User{Name: "new_name", Age: 0})

更新同样也支持钩子方法:BeforeSave, BeforeUpdate, AfterSave, AfterUpdate,更新记录时将调用这些方法。

返回的结果 result,可以查看更新的记录条数 result.RowsAffected 和返回的错误 result.Error

1.3.4 delete 删除

使用 db.Delete() 删除记录。可以根据主键进行删除也可以按条件删除。

// DELETE FROM users WHERE id = 10;
db.Delete(&User{}, 10)

// DELETE from emails where id = 10 AND name = "jinzhu";
db.Where("name = ?", "jinzhu").Delete(&email)

以上为物理删除方式。GORM 支持软删除,在模型结构体中添加 Deleted gorm.DeletedAt 字段启用软删除特性。拥有软删除能力的模型调用Delete时,数据库中的记录并不是被真正删除,只是GORM会将 DeletedAt 字段置为删除时间,并且不能通过正常的查询方式找到该记录。可以使用 Unscoped 查询到被软删除的记录:db.Unscoped().Find()

1.4 事务

手动操作事务:

  • db.Begin() 手动开启事务
  • db.Rollback() 手动回滚事务
  • db.Commit() 手动提交事务

也可以使用 Transaction 方式开启自动提交事务,避免用户漏写 Commit、Rollback。

为了确保数据一致性,GORM 默认会在事务里执行写入操作(创建、更新、删除),这一定程度上会降低性能。如果没有这方面的要求,可以使用 SkipDefaultTransaction 来关闭默认事务。

此外,可以使用 PrepareStmt 缓存预编译语句以提高后续调用的速度。

db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
  SkipDefaultTransaction: true,
  PrepareStmt: true,
})

2 RPC框架——Kitex

Kitex 是字节跳动内部的 Golang 微服务 RPC 框架,具有高性能、强可扩展的特点,在字节内部已广泛使用。如果对微服务性能有要求,又希望定制扩展融入自己的治理体系,Kitex 会是一个不错的选择。

中文文档:Kitex | CloudWeGo

安装:

go install github.com/cloudwego/kitex/tool/cmd/kitex@latest

检验安装是否成功:

kitex -version

注意要先将 $GOPATH/bin 加入环境变量

2.1 RPC (Remote Procedure Call)

RPC,远程过程调用,是一个分布式系统间通信的技术。最核心要解决的问题是,如何调用执行另一个机器的函数、方法,就感觉如同在本地调用一样。

假设有两台主机host A和host B,host B中有一个函数,比如add()函数,那么host A调用host B的add()的过程,就叫做RPC。

RPC示例

在整个RPC通信过程中,需要考虑的主要问题有以下两点

  • 序列化和反序列化,在请求端需要做到序列化将对象转换为二进制,在服务端需要做到反序列化将收到的二进制转化为对象。当然这边还需要涉及到一定的协议结构,这些觉得都是为了保证请求端和服务端能正确的处理发送相关调用信息;
  • 传输,针对RPC来说,需要确保通信的可靠,所以一般来说通信是建立在TCP之上的。

2.2 IDL (interface Definition language)

接口描述语言(IDL),是用来描述软件组件介面的一种计算机语言。IDL通过一种独立于编程语言的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信交流;比如,一个组件用C++写成,另一个组件用Java写成。

IDL通常用于RPC软件。在这种情况下,一般是由远程客户终端调用不同操作系统上的对象组件,并且这些对象组件可能是由不同计算机语言编写的。IDL建立起了两个不同操作系统间通信的桥梁。

——接口描述语言 - 维基百科,自由的百科全书 (wikipedia.org)

要进行 RPC,就需要知道对方的接口是什么,需要传什么参数,同时也需要知道返回值是什么样的。这时候,就需要通过 IDL 来约定双方的协议,就像在写代码的时候需要调用某个函数,我们需要知道函数签名一样。

Kitex 命令行工具可以基于 IDL 文件生成代码,默认支持 thriftproto3 两种 IDL,对应的 Kitex 支持 thriftprotobuf 两种序列化协议。 传输上 Kitex 使用扩展的 thrift 作为底层的传输协议(注:thrift 既是 IDL 格式,同时也是序列化协议和传输协议)。

Thrift IDL 语法格式:Thrift interface description language

proto3 语法格式:Language Guide(proto3)

生成代码的语法:kitex [options] IDL

生成客户端代码:kitex path_to_your_idl.thrift

生成服务端代码:kitex -module "your_module_name" -service service_name path_to_your_idl.thrift

2.3 示例:echo服务

2.3.1 编写 IDL 文件

基于 thrift proto3 的格式编写,在其中定义所需的服务。thrift IDL 如下:

echo.thrift

namespace go api

struct Request {
  1: string message
}

struct Response {
  1: string message
}

service Echo {
    Response echo(1: Request req)
}

2.3.2 生成服务端代码

有了 IDL 文件以后,通过 kitex 工具生成项目代码

kitex -module example -service example echo.thrift

其中-module 表示生成的该项目的 go module 名,-service 表明我们要生成一个服务端项目,后面紧跟的 example 为该服务的名字。最后一个参数则为该服务的 IDL 文件。

完成后的项目结构:

.
|-- build.sh
|-- echo.thrift
|-- handler.go
|-- kitex_gen
|   `-- api
|       |-- echo
|       |   |-- client.go
|       |   |-- echo.go
|       |   |-- invoker.go
|       |   `-- server.go
|       |-- echo.go
|       `-- k-echo.go
|-- main.go
`-- script
    |-- bootstrap.sh
    `-- settings.py

2.3.3 编写服务逻辑

需要编写的服务端逻辑都在 handler.go 这个文件中。在Echo函数中编写具体的服务逻辑

package main

import (
  "context"
  "example/kitex_gen/api"
)

// EchoImpl implements the last service interface defined in the IDL.
type EchoImpl struct{}

// Echo implements the EchoImpl interface.
func (s *EchoImpl) Echo(ctx context.Context, req *api.Request) (resp *api.Response, err error) {
  // TODO: Your code here...
  return &api.Response{Message: req.Message}, nil
  return
}

2.3.4 编译运行服务端

sh build.sh
sh output/bootstrap.sh

执行第一个命令后,会生成一个 output 目录,里面含有编译产物。

执行第二个命令后,Echo 服务就开始运行了。

2.3.5 编写客户端

编写一个客户端用于调用刚刚运行起来的服务端。

首先,同样地创建一个目录用于存放我们的客户端代码并进入:

mkdir client
cd client

创建一个 main.go 文件,编写客户端代码

import "example/kitex_gen/api/echo"
import "github.com/cloudwego/kitex/client"
...
c, err := echo.NewClient("example", client.WithHostPorts("0.0.0.0:8888"))
if err != nil {
  log.Fatal(err)
}

echo.NewClient 用于创建 client,其第一个参数为调用的服务名,第二个参数为 options,用于传入参数, 此处的 client.WithHostPorts 用于指定服务端的地址。

然后编写发起调用的代码:

import "example/kitex_gen/api"
...
req := &api.Request{Message: "my request"}
resp, err := c.Echo(context.Background(), req, callopt.WithRPCTimeout(3*time.Second))
if err != nil {
  log.Fatal(err)
}
log.Println(resp)

上述代码首先创建了一个请求 req , 然后通过 c.Echo 发起了调用。第一个参数为 context.Context,通常用其传递信息或者控制本次调用的一些行为。第二个参数为本次调用的请求。第三个参数为本次调用的 options ,Kitex 提供了一种 callopt 机制,顾名思义——调用参数 ,有别于创建 client 时传入的参数,这里传入的参数仅对此次生效。 此处的 callopt.WithRPCTimeout 用于指定此次调用的超时(通常不需要指定,此处仅作演示之用)。

2.3.6 发起调用

编写完的客户端后,编译运行客户端以发起调用

go run main.go

不出意外可以看到类似如下输出:

2021/05/20 16:51:35 Response({Message:my request})

3 Web框架——Hertz

Hertz 是一个 Golang 微服务 HTTP 框架,在设计之初参考了其他开源框架 fasthttpginecho 的优势, 并结合字节跳动内部的需求,使其具有高易用性、高性能、高扩展性等特点,目前在字节跳动内部已广泛使用。

中文文档:Hertz | CloudWeGo

安装

go install github.com/cloudwego/hertz/cmd/hz@latest

检验安装是否成功:

hz -version

示例:简易ping-pong服务器

首先创建代码目录并进入,然后用 hz 工具生成代码,用 go mod 整理和拉取依赖

mkdir hertz_demo
cd hertz_demo
hz new -module hertz_demo
go mod tidy

这样就生成了简易服务器所需的代码,然后就可以直接编译并启动服务器

go build -o hertz_demo && ./hertz_demo

成功启动将看到以下信息:

2023/01/17 15:51:01.452091 engine.go:617: [Debug] HERTZ: Method=GET    absolutePath=/ping                     --> handlerName=main/biz/handler.Ping (num=2 handlers)
2023/01/17 15:51:01.452211 engine.go:389: [Info] HERTZ: Using network library=netpoll
2023/01/17 15:51:01.453351 transport.go:110: [Info] HERTZ: HTTP server listening on address=[::]:8888

使用curl进行测试

curl http://127.0.0.1:8888/ping

可以得到以下输出

{"message":"pong"}

3.1 基本使用

package main

import (
    "context"

    "github.com/cloudwego/hertz/pkg/app"
    "github.com/cloudwego/hertz/pkg/app/server"
    "github.com/cloudwego/hertz/pkg/common/utils"
    "github.com/cloudwego/hertz/pkg/protocol/consts"
)

func main() {
    h := server.Default(server.WithHostPorts("127.0.0.1:8080"))

    h.GET("/ping", func(c context.Context, ctx *app.RequestContext) {
            ctx.JSON(consts.StatusOK, utils.H{"message": "pong"})
    })

    h.Spin()
}

使用 Hertz 监听8080端口,注册了一个GET方法的路由函数,对于访问/ping后缀的请求返回一个包含消息"pong"的响应。

3.2 路由

3.2.1 路由注册

Hertz 提供了 GETPOSTPUTDELETEANY 等方法用于注册路由。3.1 节中给出了GET方法注册路由的示例。

详见:路由 | CloudWeGo

3.2.2 路由组

Hertz 提供了路由组( Group )的能力,用于支持路由分组的功能。

下面这个示例中,注册了两个路由组v1和v2。

h := server.Default(server.WithHostPorts("127.0.0.1:8080"))
v1 := h.Group("/v1")
v1.GET("/get", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "get")
})
v1.POST("/post", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "post")
})
v2 := h.Group("/v2")
v2.PUT("/put", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "put")
})
v2.DELETE("/delete", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "delete")
})
h.Spin()

3.2.3 参数路由

Hertz 支持丰富的路由类型用于实现复杂的功能,包括静态路由、参数路由、通配路由。

路由的优先级:静态路由 > 命名路由 > 通配路由。

静态路由的使用前面的例子中已经展示了。

Hertz 支持使用 :name 这样的命名参数设置路由,并且命名参数只匹配单个路径段。

例如:设置了一个 /user/:name 路由,匹配情况可能为:

路径是否匹配
/user/gordon匹配
/user/you匹配
/user/gordon/profile匹配
/user/ 或 /user不匹配

使用 RequestContext.Param 方法可以获取路由中携带的参数,例如下面这个例子的 version 参数。

h := server.Default(server.WithHostPorts("127.0.0.1:8080"))
// This handler will match: "/hertz/version", but will not match : "/hertz/" or "/hertz"
h.GET("/hertz/:version", func(ctx context.Context, c *app.RequestContext) {
    version := c.Param("version")
    c.String(consts.StatusOK, "Hello %s", version)
})
h.Spin()

3.2.4 通配路由

Hertz 支持使用 *path 这样的通配参数设置路由,并且通配参数会匹配所有内容。

如果我们设置/src/*path路由,匹配情况可能为:

路径是否匹配
/src/匹配
/src/somefile.go匹配
/src/subdir/somefile.go不匹配

同样可以通过RequestContext.Param 方法可以获取路由中携带的参数,例如下面这个例子的 action 参数。

h := server.Default(server.WithHostPorts("127.0.0.1:8080"))
// However, this one will match "/hertz/v1/" and "/hertz/v2/send"
h.GET("/hertz/:version/*action", func(ctx context.Context, c *app.RequestContext) {
    version := c.Param("version")
    action := c.Param("action")
    message := version + " is " + action
    c.String(consts.StatusOK, message)
})
h.Spin()

3.3 参数绑定和校验

Hertz 提供了 Bind, Validate, BIndAndValidate 方法用于进行参数绑定和校验。

// 参数绑定需要配合特定的go tag使用
type Test struct {
    A string `query:"a" vd:"$!='Hertz'"`
}

func main() {
	r := server.New()
    r.GET("/hello", func(c context.Context, ctx *app.RequestContext) {

        // BindAndValidate
        var req Test
        err := ctx.BindAndValidate(&req)
        ...

	    // Bind
        req = Test{}
        err = ctx.Bind(&req)
        ...

        // Validate,需要使用 "vd" tag
        err = ctx.Validate(&req)
        ...
    })
...
}

具体支持的 tag 和书写格式详见 绑定与校验 | CloudWeGo

3.4 中间件

Hertz 中间件简单分为两大类:服务端中间件和客户端中间件

3.4.1 服务端中间件

Hertz 服务端中间件是 HTTP 请求-响应周期中的一个函数,提供了一种方便的机制来检查和过滤进入应用程序的 HTTP 请求, 例如记录每个请求或者启用CORS。

中间件可以在请求更深入地传递到业务逻辑之前或之后执行:

  • 中间件可以在请求到达业务逻辑之前执行,比如执行身份认证和权限认证。
  • 中间件也可以在执行过业务逻辑之后执行,比如记录响应时间和从异常中恢复。

以下是一个在请求到达业务逻辑之前执行中间件的实现方式

func MyMiddleware() app.HandlerFunc {
  return func(ctx context.Context, c *app.RequestContext) {
    // pre-handle
    // ...
    c.Next(ctx)
  }
}

其中 Next 方法用于执行下一个中间件。如果要终止中间件的调用链,可以使用以下方法:

  • Abort():终止后续调用
  • AbortWithMsg(msg string, statusCode int):终止后续调用,并设置 response中body,和状态码
  • AbortWithStatus(code int):终止后续调用,并设置状态码

3.4.2 客户端中间件

客户端中间件可以在请求发出之前或获取响应之后执行:

  • 中间件可以在请求发出之前执行,比如统一为请求添加签名或其他字段。
  • 中间件也可以在收到响应之后执行,比如统一修改响应结果适配业务逻辑。

客户端中间件实现和服务端中间件不同。Client 侧无法拿到中间件 index 实现递增,因此 Client 中间件采用提前构建嵌套函数的形式实现,在实现一个中间件时,可以参考下面的代码。

func MyMiddleware(next client.Endpoint) client.Endpoint {
  return func(ctx context.Context, req *protocol.Request, resp *protocol.Response) (err error) {
    // pre-handle
    // ...
    err = next(ctx, req, resp)
    if err != nil {
      return
    }
    // post-handle
    // ...
  }
}

3.5 代码生成工具 hz

Hertz 也提供了 hz 这个代码生成工具,来根据 IDL 文件生成对应的基础服务代码。下面介绍使用流程。

3.5.1 编写 IDL 文件

以 thrift IDL 为例

hello.thrift

namespace go hello.example

struct HelloReq {
    1: string Name (api.query="name"); // 添加 api 注解为方便进行参数绑定
}

struct HelloResp {
    1: string RespBody;
}

service HelloService {
    HelloResp HelloMethod(1: HelloReq request) (api.get="/hello");
}

3.5.2 创建项目、整理和拉取依赖

hz new -idl hello.thrift
go mod tidy

执行后会在当前目录下生成 Hertz 项目的脚手架。

3.5.3 修改 handler,实现具体逻辑

func HelloMethod(ctx context.Context, c *app.RequestContext) {
        var err error
        var req example.HelloReq
        err = c.BindAndValidate(&req)
        if err != nil {
                c.String(400, err.Error())
                return
        }

        resp := new(example.HelloResp)

        // 你可以修改整个函数的逻辑,而不仅仅局限于当前模板
        resp.RespBody = "hello," + req.Name // 添加的逻辑

        c.JSON(200, resp)
}

然后就可以编译和运行项目。