Go 开发三件套(Web/RPC/ORM) | 青训营笔记

172 阅读10分钟

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

Web/ RPC / ORM


课程目标: 学习并掌握Hertz/Kitex/Gorm基本用法

Gorm

Gorm 是一个迭代了 10年 + 的功能强大的 ORM 框架,有丰富的开源拓展。

 // 定义 gorm model
 type Product struct {
     Code string
     Price uint
 }
 
 // 为 model 定义表名
 func (p product) TableName() string {
     return "product"
 }
 
 func main() {
     // 链接 MySQL 数据库
     db, err = gorm.Open(
         mysql.Open("user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"),
         &gorm.Config{})
     if err != nil {
         panic("failed to connect database")
     }
     
     // 创建一条数据(如何创建多条?
     db.Create(&Product{Code : "D42", Price : 100})
 }

303530\sim 35 行中,更新方法使用了 Model 方法,而在 8108\sim 10 行中,我们设置了 Model 方法的表名,这样就可以知道我们更新的数据在哪一个表了。除了用这种方法,还可以使用 Table 方法设置表名 db.Table()

Gorm 默认约定

  1. Grom 默认 ID 字段作为主键
  2. 当没有定义表名方法的时候,Grom 使用结构体的蛇形负数作为表名(即小写+下划线+复数)
  3. 字段名的蛇形作为列名
  4. 使用 CreatedAtUpdatedAt 字段作为创建、更新时间

gorm 支持的数据库

Grom 目前支持 Mysql, SQLServer, PostgreSQL, SQLite

Grom 创建数据

package main

import (
    "fmt"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type Product struct {
    Id 		uint 	`grom:"primarykey"`		// 定义主键
    Code	string	`grom:"column: code"`	// 定义列名
    Price 	uint	`grom:"column: user_id"`// 定义不一样的列名
}

func main() {
    db, err := gorm.Open(mysql.Open("username:password@tcp(localhost:9910)/database?charseet=utf8"),
                         &grom.Config{})
    if err != nil : "failed to connect database"
    
    // 创建一条数据
    p := &Product{Code: "D42"}
    res := db.Create(p)
    fmt.Println(res.Error) // 获取错误信息
    fmt.Println(p.ID) // 返回插入数据的主键
    
    // 创建多条数据
    products := []*Product{{Code: "D41"}, {Code: "D42"}, {Code: "D43"}}
    res = db.Create(products)
    fmt.Println(res.Error)
    for _, p := range products {
        fmt.Println(p.ID)
    }
}

问题一:当创建数据的时候出现唯一索引冲突

使用 Upsertclause.OnConflict 处理数据冲突

// 以不处理冲突为例,创建一条数据
p := &Product{Code: " D42", ID: 1}
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&p)

问题二:设置默认值

通过使用 default 标签定义默认值

type User struct {
    ID		int64
    Name 	string	`gorm:"default:galeone"`
    Age		int64	`gorm:"default:18"`
}

Grom 查询数据

Grom 查询数据大体有两种方式,一种是通过 First 一种是通过 Find

u := &User{}

// 查询单条数据(按主键升序方式)
db.First(u)

// 查询多条数据使用Find
users := make([] *User, 0)
result := db.Where("age > 10").Find(&users) // select * from users where age > 10
fmt.Println(result.RowsAffected) // 返回记录数, 相当于 `len(users)`
fmt.Println(result.Error) //  返回错误信息

// 条件查询
// select * from users where name in('jinzhu', 'jinzhu 2');
db.Where("name IN ?", []string{"jinzhu", "jinzhu2"}).Find(&users)

// select * from users where name like '%jin%';
db.Where("name LIKE ?", "%jin%").Find(&users)

// select * from users where name = 'jinzhu' and age >= 22;
db.Where("name = ? And age >= ?", "jinzhu", "22").Find(&users)

// 一个小坑
// select * form users where name = "jinzhu";
db.Where(&User{name: "jinzhu", Age: 0}).Find(&users)

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

注意:

  1. 使用 First 方法的时候,需要注意当查询不到数据的时候,会返回 ErrRecordNotFound
  2. 使用 Find 方法的时候,查询多条数据,查询不到数据不会返回错误,而是返回一个空列表
  3. 2424 行代码告诉我们,使用 Where 方法的时候,如果出现 0值字段(或者 false),就不会构建到 SQL 语句中。想要查询0值字段,需要往里面传一个 map 结构体。或者使用 select 的 api

Grom 更新数据

// 更新单条数据
// UPDATE users SET name = "hello", update_at='2023-1-20 12:26:23' WHERE age > 18;
db.Model(&User{ID: 111}).Where("age > ?", 18).Upate("name", "hello")

// 更新多条数据
// UPDATE users SET name = "hello", age = 18 WHERE id = 111;
db.Model(&User{ID: 111}).Updates(User{Name: "hello", Age: 18})

// 根据 map 更新
// UPDATE users SET name="hello", age=18, actived=false WHERE id = 111;
db.Model(&User{ID: 111}).Updates(map[string] interface{}{"name": "hello", "age": 18, "actived": false})

// 只更新选定字段
// update users set name = "hello" where id = 111;
db.Model(&User{ID : 111}).Select("name").Updates(map[string] interface{}{"name" :"hello", 
                                                                         "age":18, "actived": false})

// SQL 表达式更新
// update "prodects" set "price" = price * 2 + 100, where "id = 3"
db.Model(&User{ID: 111}).Update("age", gorm.Expr("age * ? + ?", 2, 100))

注意: 使用 Struct 更新的时候,只会更新非零值,如果需要更新零值,需要使用 Map 或者 select

Grom 删除数据

物理删除

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

db.Delete(&User{}, []int{1, 2, 3}) // delete from users where id in (1, 2, 3);

// delete from users where name like "%jinzhu%";
db.Where("name LIKE ?", "%jinzhu%").Delete(User{}) 
db.Delete(User{}, "email like ?", "%jinzhu%")

软删除

Grom 提供了 grom.DeletedAt 用于帮助用户实现软删除。

拥有软删除能力的 Model 调用 Delete 方法的时候,记录不会从数据库中真正删除,但是 Grom 会将 DeletedAt 设置为当前时间,并且你不能通过正常的查询找到该记录。

可以通过 Unscoped 查询到被软删除的数据阿

...
type User struct {
    ID		int64
    Name 	string `grom:"default:galcone"`
    Age 	string `gorm:"defalut:18"`
    Deleted	grom.DeletedAt
}
...
func main() {
    ...
    // 删除一条数据
    u := User{ID: 111}
    db.Delete(&u) // update users set deleted_at='现在的时间' where id = 111
    
    // 删除多条数据
    db.Where("age = ?", 20).Delete(&User{})
    users := make([]*User, 0)
    
    // 查询被软删除的数据
    db.Unscopeed.Where().Find()
}

Grom 事务

Gorm 提供了 BeginCommitRollback 方法用于使用事务。

tx := db.Begin() // 这里开启一个事务
// 当事务开始时,要执行一些 db 操作的时候,应该使用 'tx' 而不是 'db'

if err = tx.Create(&User{Name: "name"}).Error; err != nil {
    tx.Rollback()
    // 遇到错误回滚事务
    return 
}

tx.Commit() // 提交事务

Gorm 提供了 Tansaction 方法用于自动提交事务,避免用户漏写 CommitRollback

if err = db.Transaction(func(tx * gorm.DB) error {
    if err = tx.Create(&User{Name: "name"}).Error; err != {
        return err
    }
    return nil
}); err != nil {
    return 
}
  • Tansaction 返回 err 的时候会自动 Rollback

  • Tansaction 返回 nil 的时候会自动 Commit

Gorm 性能提高

对于写操作(创建, 更新, 删除), 为了确保数据的完整性,GROM 会将他们封装在事务内运行。这样会造成性能的降低,可以使用 SkipDefaultTransaction 关闭默认事务

使用 Preparestmt 缓存预编译语句可以提高后续的调用的速度

db, err := gorm.Open(mysql.Open("username:password@tcp(localhost:9910)/database?charset=utf8"),
                     &gorm.Config{
                         SkipDefaultTransaction: true,
                         PrepareStmt:			true},
                    )

if err != nil {
    panic("failed to connect database")
}

官方文档

Grom 生态

Grom 代码生成工具github.com/go-gorm/gen
Grom 分片库方案github.com/go-gorm/sha…
Grom 手动索引github.com/go-gorm/hin…
Grom 乐观锁github.com/go-gorm/opt…
Grom 读写分离github.com/go-gorm/dbr…
Grom OpenTelemetry 拓展gothub.com/go-gorm/ope…

Kitex

kitex 是字节内部的 Golang 微服务 RPC 框架,有着高性能,强可拓展性的特点,支持多协议。

Kitex 目前对 Windows 支持不完善,如果本地开发环境是 Windows 建议使用虚拟机或者 WSL2

流程:

  1. 安装 wsl2 : 最好安装在其他盘(非C盘)
  2. 安装 Ubuntu
  3. wsl2 上安装 go 语言环境,安装 docker 环境
  4. 每次打开终端 source /etc/profile 就可以进入 go 可编辑环境

想要进入其他盘: cd /mnt/d或者e或者c

安装代码生成工具

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

定义IDL

如果我们要进行 RPC , 就需要知道对方的接口是什么,需要传什么参数,同时也需要知道返回值长什么样子。这时候,就需要通过 IDL 来约定双方的协议。

namespace go api

struct Request {
	1: string message
}

stuct Response {
	1: string message
}

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

Kitex 生成代码

server 服务端

使用以下代码生成代码

kitex -module example -service example echo.thrift

下图中 client 文件是运行起来后被另外一个端口生成的 client 端,初次生成代码是不存在的。

image-20230123180446490.png
  • 服务默认监听 88888888

Client 端

创建 Clinet

c, err := echo.NewClient("example", client.WithHostPorts("0.0.0.0:8888")) // 初始化Client
if err != nil {
    log.Fatal(err)
}

发起请求

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)

Kitex 服务注册与发现

Kitex 的服务注册与发现已经对接了主流的服务注册与发现中心,如 ETCDNacos

注册服务

type HelloImpl struct {}

fun (h *HellpImpl) Echo(ctx context.Context, req *api.Request) (resp *api Response, err error) {
    resp = &api.Response {
        Message: req.Message,
    }
    return 
}

func main() {
    r, err := etcd.NewEtcdRegistry([]string{"127.0.0.1:2379"})
    if err != nil {
        log.Fatal(err)
    }
    
    server := hello.NewServer(new(HelloImpl), server.WithRegistry(r), server.WithServerBasicInfo(&rpcinfo.EndpointBasicInfo {
        ServerName: "Hello",
    }))
    
    err = server.Run()
    if err != nil {
        log.Fatal(err)
    }
}

1111 行指定了 ETCD 的集群地址

161816 \sim 18 行初始化 server ,先新建一个 server 对象,将 server 的实现( helloImpl )注入,通过 WithReqistry 传递 ETCD 的对象,使用 WithServerBasicInfo 设置服务名 ServerName,使用 WithRegistryInfo 设置其他属性。

这样我们的 server 信息就注册到 ETCD 中了,接下来就是要让 client 能在 ETCD 中获取

发现服务

func main() {
    r, err := etcd.NewEtcdResolver([] string{"127.0.0.1:2379"})
    if err != nil {
        log.Fatal(err)
    }
    
    client := hello.MustNewClient("Hello", client.WithResolver(r))
    for {
        ctx, cancel :=context.WithTimeout(context.Background(), time.Second * 3)
        resp, err := client.Echo(ctx, &api.Request{Message: "Hello"})
        cancel()
        if err != nil {
            log.Fatal(err)
        }
        log.Println(resp)
        time.Sleep(time.Second)
    }
}

第二行使用 NewEtcdResolver 传递 ETCD 对象的地址,就可以初始化发现对象 r 。这里一定要把 error 处理好。

使用 MustNewClient 传递并发现服务名,达到服务过滤的效果。再将发现的对象 r 传递进来。然后就可以初始化 client

Kitex 生态

XDS 扩展 : 做多泳道的工具,多环境治理,流量路由

opentelemetry 拓展 : 可监控,可链路追踪,且Gron, kitex, hertz 都有这个扩展插件

ETCD服务注册与发现拓展 Nacos服务注册与发现拓展 polaris

Zookeeper 服务注册与发现拓展 丰富的示例代码与业务demo

Hertz

Hertz 是字节内部的 HTTP 框架。

Hertz的基本使用

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")) // 会自带一个 recover 中间件
    h.GET("/ping", func(c context.Context, ctx *app.RequestContext)) {
        ctx.JSON(consts.StatusOK, utils.H{"ping": "pong"})
    })
    h.Spin()
}

1212 行使用 server.Default 会自带一个 recover 中间件,如果不需要或者想自定义中间件的话,可以使用 server.New

131513\sim 15 行注册一个 GET 路由,地址是 \ping,传递一个 function

1616 行调用 Spin(),开启自旋,服务没有停止之前,他就会停留在这,不执行后面的代码。

Hertz 路由

Hertz 提供了 GET/POST/PUT/DELETE/ANY 等方法注册路由

h.GET("/get", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "get")
})

h.POST("/post", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "post")
})

h.PUT("/put", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "put")
})

h.DELETE("/delete", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "delete")
})

h.PATCH("/patch", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "patch")
})

h.HEAD("/head", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "head")
})

h.OPTIONS("/options", func(ctx context.Context, c *app.RequestContext) {
    c.String(consts.StatusOK, "options")
})

分组功能

v1 := h.Group("/v1")
{
    v1.Post("/login", loginEndpoint) // 路由为 /v1/login
    v1.Post("/submit", submitEndpoint)
}

v2 := h.Group("/v2")
{
    v2.POST("/login", loginEndpoint)
    v2.POST("/submit", submitEndpoint)
}

参数路由

h.GET("/hertz/:version", func(ctx context.Context, c *app.RequestContext) {
    version := c.Param("version")
    c.String(consts.StatusOK, "hello %s", version)
})

可以匹配到 /hertz/version 但不能匹配到 /hertz

通配路由

h.POST("/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)
})

可以匹配到 /hertz/v1//hertz/v2/send

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

Hertz 参数绑定

Hertz 提供了 BindValidateBindAndValidate 函数用于进行参数绑定和校验

type Args struct {
    Query		string 		`query:"query"`
    QuerySlice 	[]string	`query: "q"`
    Path		string		`path: "path"`
    Header		string 		`header: "header"`
    Form 		string 		`form: "form"`
    Vd			int 		`query: "vd"` vd: "$ == 0 || $ == 1"
}

func main() {
    h := server.Default(server.WithHostPorts("127.0.0.1:8080"))
    
    h.Post("v:path/bind", func(ctx context.Context, c *app.RequestContext) {
        var arg Args  //  声明结构体
        err := c.BindAndValidate(&arg) // 回写传递指针
        if err != nil {
            panic(err)
        }
        fmt.Println(arg)
    })
    
    h.Spin()
}

Hertz 中间件

Hertz 中间件主要分为客户端中间件和服务端中间件,当有一些通用的逻辑(比如打印日志)的时候就可以使用中间件(参考洋葱模型)

func MyMiddleware() app.HandlerFunc {
    return func(ctx context.Context, c *app.RequestContext) {
        fmt.Println("pre-handle")
        c.Next(ctx) // 向下执行,调用下一个中间件(handler)
        fmt.Println("post-handle")
    }
}

func main() {
    h := server.Default(server.WithHostPorts("127.0.0.1:8080")) // h注册为全局环境
    h.Use(MyMiddleware()) // 全局注册中间件,每个路由调用前都会执行中间件方法(也可以注册在路由组上)
}

1111 行全局注册中间件后,每一个路由执行前都会打印 pre-handle ,执行后都会打印 post-handle

Hertz Client

Hertz 提供里 HTTP Client 用于帮助用户发送 HTTP 请求

c, err := client.NewClient()
if err != nil {
    return 
}

// send http get request
status, body, err := c.Get(context.Background(), nil, "http://www.example.com") // 状态码,主体,错误
fmt.Println("status = %v body = %v\n" status, string(body))

// send http post request
var postArgs protocol.Args
postArgs.Set("arg", "a") // Set post args
sstatus, body, err = c.Post(context.Background(), nil, "http://www.example.com", &postArgs)
fmt.Printf("status = %v body = %v\n", status, string(body))

Hertz 代码生成工具

Hertz 提供了代码生成工具 Hz,通过 IDL(inteface description language) 文件即可生成对应的基础服务代码

namespace go hello.example

struct HelloReq {
	1: string Name (api.query = "name")
}

struct HelloResp {
	1: string RespBody;
}

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

44 行代码中 api.query 就是 Hertz 参数绑定中,通过名字绑定的参数

1212 行代码中 api.get 定义了 Hertz 路由中的 get 方法,路径为 /hello

Hertz 性能
  1. 网络库 Netpoll
  2. Josn 编解码 Sonic
  3. 使用 sync.Pool 复用对象协议层数据解析优化
Hertz 生态
拓展插件网址
opentelemetry 拓展github.com/hertz-contr…
国际化拓展github.com/hertz-contr…
反向代理拓展github.com/hertz-contr…
JWT 拓展github.com/hertz-contr…
Websocket 拓展github.com/hertz-contr…
示例代码与业务 Demogithub.com/cloudwego/h…
HTTP2 拓展github.com/hertz-contr…