这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天
本堂课重点内容:
- 了解 Gorm/Kitex/Hertz 是什么
- 熟悉 Gorm/Kitex/Hertz 的基础用法
- 通过实战案例分析将三个框架的使用串联了起来
1.课程介绍
1.1 Gorm
Gorm 是一个已经迭代了 10年+ 的功能强大的 ORM 框架,在字节内部被广泛使用并且拥有非常丰富的开源扩展。
1.2 Kitex
Kitex 是字节内部的 Golang 微服务 RPC 框架,具有高性能、强可扩展的主要特点,支持多协议并且拥有丰富的开源扩展。
1.3 Hertz
2.三件套使用
在实际开发过程中,我们也是先去写的是一些 DB 的基础操作,再去写一些 RPC 调用,最后再去用写一些 API 服务,去做一些 BFF,做一些接口的聚合吐给前端或者客户端
2.1 Gorm 的基础使用
2.1.1 Gorm 的约定(默认)
- Gorm 使用名为 ID 的字段作为主键
- 使用结构体的蛇形复数作为表名
- 字段名的蛇形作为列名
- 使用 CreatedAt、UpdatedAt 字段作为创建、更新时间
2.1.2 Gorm 支持的数据库
GORM 目前支持 MySQL、SQLServer、PostgreSQL、SQLite。
什么是 DSN ? github.com/go-sql-driv…
GORM 通过驱动来连接数据库,如果需要连接其它类型的数据库,可以复用/自行开发驱动。
2.1.3 Gorm 创建数据
首先我们来定义了一个结构体还是 product 结构体,但是按照我们的约定,我们没有给它去实现 table name 接口,它就会使用蛇形复数去来作为这样一个表名。接下来是一个ID,我们使用 ID 就说明按照约定它,这个 ID 就会作为它的主键。我们同时使用这样一个 go tag,然后定义 GrOM : primary key,定义了这个字段就是主键。
code 我们使用 GORM 的结构体 GORM 的tag,定义了一个column, column 是code,大家其实就能看出来 column 的作用就是去定义这样一个列名,就说明列名,它就是一个 code price。大家也可以看到,我们可以通过列名去修改,去让它保证跟它的字段名是不一致的。虽然我们结构体的字段名是price,但是我们通过定义 GORM tag 可以去使用column,可以给它定义成 user ID,这样我们就可以保证数据是我们想要的字段,当我们有这种特殊需求的时候,我们就可以通过这种方式。
接下来还是去连接数据库,这里头还是去打开一个 MySQL 的数据库链接。这个地方我们通过使用 create 定义一个结构体,去创建一条数据。这个地方我们需要注意,我们因为是 GORM,是一个链式调用
创建一条数据,我们使用的是 create,这里头地方传递的是一个结构体。如果我们使用 DB.create 去创建一条数据,我们需要注意,因为 GORM 是一个链式调用,因为它是链式调用,所以它会返回一个 GORM 对象。但是所以这个地方我们就需要把使用 GORM 对象去获取 error,所以这个地方有一个 res = DB.create。如果想要获取当前操作的一些 error,或者是返回插入数据的主键获取 error,我们就要用链式调用返回的对象去调它 error,就可以去获取到这次创建数据是否有 error 返回。如果还有一点,当我们去创建一条数据的时候,大家注意我没有传主键,这个时候可能我们会设置一些自增主键,我们的 gorm 也是会把主键回填回去的,只要我们设置了这样一个主键的字段,所以我们这个地方打印一下主键就可以打印出来的。
创建多条数据的时候,我们需要去传递一个 list 结构体,我们定义这样的一个 list 之后,我们通过 DB.create 去批量创建数据,也是通过链式调用返回的对象 res 去获取 error,同样 GORM 也会帮我们把主键的 ID,当然有一个前提条件,我们记得我们去定义 ID 字段时,要加上标签 primary key 。
如何使用 Upsert? 使用 clause.OnConflict 处理数据冲突
在我们在开发的场景中可能会遇到两个比较常见的 case 。
第一种就是更新数据创建的时候,如果遇到一些唯一索引冲突,我们该如何处理?这里头我们会去使用 upset。 GORM 也提供了这样一个 upset 的支持。我们需要去使用 clause on conflict 这个子句去处理数据冲突。它的用法其实是非常简单的,这里头我们就以如果我们使用唯一索引的场景,并且如果出出现了冲突,我们以不处理冲突,只是把数据插进去为例
定义了一个结构体。我们使用 clauses 去传递这样的一个子句 clause.onConflict。这里头写的是 DoNothing 是 true。这个语义就告诉我们,当遇到冲突的时候,我们就什么都不做,只是把数据插进去,再调点 create 这个地方。
注意一下,像对我们去调一些 .update,.create,.delete 的时候,这些都是 finish API。前面那些 API 都是一些组合的API,就是用来拼接 SQL 的。当我们去调像这种动词的 API 的时候,create,delete,update,update,这些 API 的时候,它就是真正的去要执行 SQL 了。如果我们在 .create 后面再加一个点 where 的条件,它是不生效的,因为 .create 的时候 SQL 已经执行了
如何使用默认值? 通过使用 default 标签为字段定义默认值
第二个遇到的场景比较多,就是默认值。这个场景如果是我们要去使用一些,设置一些默认值。在 GORM ,我们可以通过 GORM 提供的一个 default 标签来为字段定义默认值。这里头包括我们可以给 name 去定义这样的一个默认值,给 age 也去定义一个默认值。当有默认值需求的时候,我们就可以通过使用 default 标签来实现这部分
2.1.4 Gorm 查询数据
First 的使用踩坑
- 使用 First 时,需要注意查询不到数据会返回 ErrRecordNotFound。
- 使用 Find 查询多条数据,查询不到数据不会返回错误。
使用结构体作为查询条件
当使用结构作为条件查询时,GORM 只会查询非零值字段。这意味着如果您的字段值为 0、‘’、false 或其他 零值该字段不会被用于构建查询条件,
所以当我们有 0 值需求时,我们就需要使用 Map 来构建查询条件。或者还有一种方式,我们可以去使用 GORM 提供的一个 select API,它也是类似于一个 where 的API,只不过它是用来挑选字段。像我们拼 SQL 的时候,它相当于这个地方是 select *,如果我们使用 select API,它就是指定字段了。用这种方式我们也可以去规避 0 值
查询数据这里头主要的就是介绍 GORM 提供的 first 的 API,还有它的 find API,以及如何去跟条件查询去联动。
首先也是去通过点 open 去打开一个数据库的链接。接下来我们可以去使用 first 的方法,它是默认去查一条数据,获取第一条数据,按照主键的升序。但是要注意的地方就是 first 如果查询不到数据的时候,它就会返回一个 ErrRecordNotFound。他去返回这样的一个错误的时候,如果我们第一次去使用,我们不知道特性,这样很容易去造成一个线上的问题。所以我们在日常开发的时候,我们更多的是去使用 find,因为 find 是查询一组数据,如果查询不到它返回的是一个空数组,这样它不会返回 error
查询多条数据的时候,我们去定义一个这样的 user 的结构体,首先也是 DB.where 传进来一个 where 的查询条件, age 是大于10。通过调用 .find 触发查询。调用 .find 把定义好的结构体传递进来,这样我们生成的这样一个 where age 大于 10 的语句。因为它是链式调用,所以我们要想要获取它的 error 以及它的影响条数,我们需要去把它返回对象保存起来。我们通过 result.RowsAffected 就能够返回找到的记录数。这里相当于是 len(users)。我们想去获取这次查询有没有 error,通过 result.error 就返回了error。
接下来可以来看一下一些像复杂的查询,比如 in 查询,那就是 db.where name in,通过这个问号传递一个数组,然后 .find(&users),这样实现了一个 in 查询
like 查询也是类似的
我们可以把一个 where 拆成两个 where 条件去做。 GORM 也是会帮我们用 and 去拼接的。同时我们的 GORM where 它也可以支持传递结构体和传递结构体和map。
注意,递结构体去查询,因为结构体的 0 值的问题,所以它也不会把 0 值的条件加进去,只会去加非 0 值。它传递 map 也是它会去解决 0 值的问题。在日常开发过程中,我们大部分使用比较多的还是通过这种 .where 加 find 或者加 update 加 delete 这种方式去做一个 CRUD。
2.1.5 Gorm 更新数据
使用 Struct 更新时,只会更新非零值,如果需要更新零值可以使用 Map 更新或使用 Select 选择字段。
更新单个列。 DB.model 这个地方我们传递 model 就代表的意思是,我们可以去设置一个表名。因为按照我们的约定,如果 user 结构体,它实现了 table name 接口,我们就直接选 table name 接口返回的表名。如果没实现,就选它的蛇形复数。再去使用 .where 去设置一个查询条件。 因为 update 它只能是更新单个列,它并没有接口,并没有位置能让我去传递表名。所以当使用 update 更新单个列 API 接口的时候,我们需要去使用 .model 或者 .team 去设置表名,否则它会报一个没有表名的错误。
更新多个列的时候,我们也去使用 .model 去设置一个 user 结构体。再去调用 .updates 传递一个 user 的结构体。使用 struct 来更新属性,只会去更新非 0 值。当使用 update 的时候,如果我们没有查询 where 查询条件的时候,像我们 updates,它就没有 where 查询条件,但是这个时候我们 gorm 还会去我们 model 里面传递的结构体去找他主键的 ID,如果主键的 ID 是有值,他就会用主键的 ID 的值去做这样一个更新。
使用 map 也是类似的,我们这个地方也是 .model 去传递一下,再去调用 .update,但是这个地方我们传递的是一个 map,它是用来去规避 0 值更新这个问题的。这个地方突然之间想到,在我们去使用结构体的时候,如果我们在调用 update,使用 users 结构体的时候,因为我们看到了 model 也传了一个 user 结构体, updates 也传了一个 user 结构体。这个时候我们就不需要去再调用点 model 了。 gorm 本身它是会有一些兜底的逻辑处理,它会去我们传递结构体里面,也会去再看一遍有没有 table name,实没实现,有没有表明。如果没有,就会拿它的复数去做。
更新选定字段。我们之前说的 GORM 提供了一个 select API,这个地方我们传进去,进来一个字符串,用来去选定我们要更新的字段。这个地方我们再去传一个 map,这个时候 map 里面虽然有多个键值对,但是我们只会更新 name,因为我们这个地方已经用 select 去选定 name
比如我们去要使用 GORM 去做一些表达式更新,比如像这种 price 等于 price 乘 2 + 100,这样我们该去怎么做? GORM 提供了一个 GORM 表达式这样的一个用法,这个也是比较常见的,但是可能在文档里头是有提及的,大家可以去也可以记一下。通过这样的一个方式去做这样一个表达式更新
2.1.6 Gorm 删除数据
物理删除:
软删除:
我们在结构体里面要去额外定义一个 deleted 的字段,它还需要使用 GORM.DeletedAt 这个字段。如果我们想使用 GORM 的软删,我们就需要去使用这个字段类型,这样我们的结构体就被赋予了软删的能力。
这样软删的时候它原来是 delete 它,这个时候就变成 update,只不过是它就把 delete_at 设置成是删除时间,它在删除的时候它已经变成了update。如果当我们结构体里头有个软删字段,同时 delete_at 这个字段有值的时候,我们会发现它在查询的时候也会去忽略软删的字段,因此我们不需要去做额外的操作,只要去正常的查询。但因为我们结构体名有软删的字段,所以它会帮我们默认加上 delete_at。
GORM 提供了 gorm.DeletedAt 用于帮助用户实现软删
拥有软删除能力的 Model 调用 Deleted 时,记录不会被从数据库中真正删除。但 GORM 会将 DeletedAt 置为当前时间,并且你不能再通过正常的查询方法找到该记录。
可以使用 Unscoped 可以查询到被软删的数据
2.1.7 Gorm 事务
Gorm 提供了 Begin、Commit、Rollback 方法用于使用事务
有一些数据一致性相关操作的时候,如果对一致性要求比较强,记得一定要去使用事务
Gorm 提供了 Tansaction 方法用于自动提交事务,避免用户漏写 Commit、Rollbcak。
2.1.8 GORM Hook
GORM 提供了 CURD 的 Hook 能力。
Hook 是在创建、查询、更新、删除等操作之前、之后自动调用的函数。
如果任何 Hook 返回错误,GORM 将停止后续的操作并回滚事务。
2.1.9 GORM 性能提高
对于写操作(创建、更新、删除),为了确保数据的完整性,GORM 会将它们封装在事务内运行。但这会降低性能,你可以使用 SkipDefaultTransaction 关闭默认事务。
使用 PrepareStmt 缓存预编译语句可以提高后续调用的速度,本机测试提高大约 35% 左右。
2.1.10 GORM 生态
GORM 拥有非常丰富的扩展生态,以下列举一部分常用扩展。
关于更多的 GORM 用法可以查看 Gorm 的文档( gorm.cn)。
2.2 Kitex 的基础使用
2.2.1 安装 Kitex 代码生成工具
Kitex 目前对 Windows的支持不完善,如果本地开发环境是 Windows 建议使用虚拟机或 WSL2。
安装代码生成工具
go install github.com/cloudwego/kitex/tool/cmd/kitex@latest
go install github.com/cloudwego/thriftgo@latest
文档:www.cloudwego.io/zh/docs/kit…
2.2.2 定义 IDL
使用 IDL 定义服务与接口
如果我们要进行 RPC,就需要知道对方的接口是什么,需要传什么参数,同时也需要知道返回值是什么样的。这时候,就需要通过 IDL 来约定双方的协议,就像
在写代码的时候需要调用某个函数,我们需要知道函数签名一样。
Thrift:thrift.apache.org/docs/idl
Proto3 :developers.google.com/protocol-bu…
2.2.3 Kitex生成代码
使用 kitex -module example -service example echo.thrift 命令生成代码
- build.sh : 构建脚本
- kitex_gen : IDL 内容相关的生成代码主要是基础的 Server/Client 代码。
- main.go : 程序入口
- handler.go : 用户在该文件里实现 IDL
- service : 定义的方法
2.2.4 Kitex 基本使用
服务默认监听 8888 端口
2.2.5 Kitex Client 发起请求
创建 Client :
始化 client 就用 NewClient 传递一下目标服务名。目标服务名的作用其实在我们常规使用其实是没有什么用的,但是在我们如果去使用服务发现,服务发现与注册的时候,这个地方会作为服务名用来去过滤服务的 client
发起请求 :
2.2.6 Kitex 服务注册与发现
目前 Kitex 的服务注册与发现已经对接了主流了服务注册与发现中心,如 ETCD,Nacos 等
文档: www.cloudwego.io/zh/docs/kit…
2.2.7 Kitex 生态
Kitx 拥有非常丰富的扩展生态,以下列举一部分常用扩展。
文档:www.cloudwego.io/zh/docs/kit…
2.3 Hertz 的基础使用
使用 Hertz 实现,服务监听 8080 端口并注册了一个 GET 方法的路由函数。
我们可以通过使用 server 提供了一系列的option,比如去设置它监听的端口以及超时都是可以。
除了提供一个 server.default,还提供了一个 server.new。我们该怎么去使用这个地方呢。 server.default 它默认会集成一个 recover 中间件,server.new 没有集成,就看我们要不要使用 recover 中间件了,或者有一些自定义 recover 中间件的格式的需求的话,也可以使用 server.new。
调用 h.Spin。它的意思就是要开启自旋了,也就是在我们服务没停止之前,它服务就会夯在这一行。 h.Spin(),也就是他在后面的代码都不会去执行了。
Hertz 支持了不同的hook,比如有开始hook,还有一些结束的 shutdown 的hook,在服务结束之后的hook,还有建立链接的hook。这些 hook 的能力可以帮我们去满足,可以去看一下文档。
也是在 Hertz 的文档里面。大家可能还有一个疑惑,像 gin 或者像 fasthttp、Faber,它们都是一个上下文,Hertz 是两个上下文,为什么分成两个上下文?这个是在内部的实践中,也是深度踩坑,发现我们要合成一个上下文,它是会有有一些坑的大家可以看一下赫兹的文章。所以 Hertz 经过实践才会给它分成两个上下文,一个专注于传递元信息,一个专注于去做请求的处理。
文档:www.cloudwego.io/zh/docs/her…
2.3.1 Hertz 路由
Hertz 提供了 GET、POST、PUT、DELETE、ANY 等方法用于注册路由
如果我们想去自定义,应该是去使用 handle,Hertz 也提供一个 handle 方法。有些同学可能有些需求,就是我不想使用标准的 HTTP method,可能比如要自定义一个其他的 HTTP method,以去使用 Hertz 提供的 handle 方法,然后去注册自定义的 method
Hertz 提供了路由组(Group)的能力,用于支持路由分组的功能
Hertz 提供了参数路由和通配路由,路由的优先级为:静态路由>命名路由>通配路由(可能有一些路由注册重复,我们可能访问的时候,访问同一个请求会命中 3 条路由,这个时候它根据优先级的选择一个符合条件的路由)
文档:www.cloudwego.io/zh/docs/her…
2.3.2 Hertz 参数绑定
Hertz 提供了 Bind、Validate、BindAndValidate 函数用于进行参数绑定和校验
不同于其他框架 ,Hertz 是通过 go tag 这种方式来确定你这个参数该绑在哪里
比如你这个参数是 query 参数,就 go tag 定义一个 query 的tag,参数名就叫query。
如是 URL 参数,就定义一个 path tag。其他同理
绑定参数需要传递的是指针。如果传递的是指针的指针,会导致也会导致 binding 失败。
文档:www.cloudwego.io/zh/docs/her…
2.3.3 Hertz 中间件
Hertz 的中间件主要分为客户端中间件与服务端中间件,如下展示一个服务端中间件,
我们一般什么场景下会去用中间件。当我们有一些通用的一些逻辑,比如去打日志,计算接口的耗时,还有一些元信息的设置和传递。这些地方我们会去需要去使用中间件
如何终止中间件调用链的执行
- c.Abort
- c.AbortWithMsg
- c.AbortWIthStats
文档:www.cloudwego.io/zh/docs/her…
2.3.4 Hertz Client
Hertz 提供了 HTTP Client 用于帮助用户发送 HTTP 请求
DST 这个地方它有什么作用?在我们去做一些高性能开发的时候,我们希望有些结构体是复用的。这个地方我们可以手动给他传入一段字节数组,让他去把数据放到字节数组里面。这样就不需要我们去频繁地去申请内存,去创建数组。
2.3.5 Hertz代码生成工具
Hertz 提供了代码生成工具 Hz,通过定义 IDL(inteface description language)文件即可生成对应的基础服务代码.
binding 如何跟 IDL 去结合?其实这个地方它做了一个示例,通过 IDL 的 api.query 注解去给设置一个 name,这样生成代码的时候,我们的结构体就会有 tag
怎么样去定义路由? IDL 描述的语义是 API.get,它是一个 get 的路由,然后路由的 path 是 /hello
文档:www.cloudwego.io/zh/docs/her…
2.3.6 Hertz 性能
- 网络库 Netpoll
字节内部的一个开源的网络库 Netpoll,它的性能它在小包场景是优于标准库的,Hertz 它是能够支持我们去选择网络库的使用。可以看一下 Hertz 的文档,Hertz 文档有一节网络库,告诉大家什么场景下使用什么样的标准库是最优的。
需要注意一点,就是当我们去使用我们的 server 去做 TLS server 的时候,就是使用 HTTPS,可能这个时候 Netpoll 是不支持 TLS 的,所以我们这个地方需要使用 Hertz 的一个配置,把它切换到标准库,这样就 OK 了
- Json 编解码 Sonic
Hertz 默认使用了 Sonic,作为一个高性能的 JSON 编解码.
- 使用 sync.Pool 复用对象协议层数据解析优化
Hertz 是在底层使用了 sunny 的pull,是复用对象。在一些协议层数据解析的地方,也做了一些 fast path 去加速数据的解析
2.3.7 Hertz 生态
Hertz拥有非常丰富的扩展生态,以下列举一部分常用扩展。
3.实战案例介绍
3.1 项目介绍
笔记项目是一个使用 Hertz、Kitex、Gorm 搭建出来的具备一定业务逻辑的后端 API 项目。
3.2 项目功能介绍
3.3 项目调用关系
大家看到了 API 服务是不会直接跟 MySQL 打交道的。 user 服务跟 note 服务去数据库调用。
这样的好处就是有一些服务,比如我们后面再去加后台管理系统的时候,我们再可以再起一个 demo admin,这样也是可以去复用一部分的接口。
3.4 IDL 介绍
3.5 项目技术栈介绍
3.6 关键代码讲解
3.6.1 Hertz 关键代码讲解
绑定结构体
参数校验
JWT
3.6.2 Kitex Client 关键代码讲解
初始化 etcd
注入常用的中间件
3.6.3 Kitex Server 关键代码讲解
3.6.4 Gorm 关键代码讲解
个人总结
重难点:
- 熟悉 Gorm/Kitex/Hertz 的使用(多看文档、源码,多用)
- 如何将三个框架的使用串联了起来