HTTP 微服务框架 Hertz 的入门实践

摘要

Hertz 是一个 Golang 微服务 HTTP 框架,由字节跳动开源,具有高易用性、高性能、高扩展性等特点。

架构

Hertz 的架构图如下所示,从上到下分为:

  • 应用层:提供前台的 Server、Client API
    • Server:参数绑定、HandlerFunc 注册、响应渲染
    • Client:DNS、服务发现
    • 通用:Context、中间件
  • 路由层:在 httprouter 的基础上做了功能改进,同时支持静态路由、参数路由,支持优先级匹配、路由回溯等,为用户提供更高的自由度
  • 协议层:支持多种网络协议,HTTP1、HTTP2、Websocket、QUIC 等,并支持自定义扩展
  • 传输层:支持底层网络库扩展,原生适配 Netpoll

在整体架构之外,Hertz 还提供了:

  • 命令行工具 hz:根据 IDL 生成 / 更新项目脚手架,提供开箱即用的能力
  • 公共组件 common:提供通用能力,包含错误处理、单元测试能力、可观测性(Log、Trace、Metrics 等)

快速上手

环境准备

可以按照快速开始 | CloudWeGo 教程,安装 Golang 环境、Hertz 框架与 hz 工具。

pingpong

新建 hertz_demo 项目,创建 main.go,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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()
// 注册路由和HandlerFunc
h.GET("/ping", func(c context.Context, ctx *app.RequestContext) {
ctx.JSON(consts.StatusOK, utils.H{"message": "pong"})
})
// 启动服务器
h.Spin()
}

运行以下命令,安装依赖并启动服务。

1
2
go mod tidy
go run .

可以观察到以下输出:

1
2
3
2024/01/18 12:22:22.235809 engine.go:668: [Debug] HERTZ: Method=GET    absolutePath=/ping                     --> handlerName=main.main.func1 (num=2 handlers)
2024/01/18 12:22:22.235951 engine.go:396: [Info] HERTZ: Using network library=netpoll
2024/01/18 12:22:22.237303 transport.go:115: [Info] HERTZ: HTTP server listening on address=[::]:8888

日志中可以看到绑定的路由 /ping 与 HandlerFunc main.main.func1(匿名函数),以及使用的网络库 netpoll,服务默认启动在 8888 端口。在另一个 shell 中进行测试,验证成功。

1
2
❯ curl http://localhost:8888/ping
{"message":"pong"}%

路由注册

点进 h.Get 函数,签名如下,允许为一个路由绑定多个 Handler(不能超过 63 个),进行链式处理,Get 的返回值同样为 IRoutes 接口,可以链式完成路由注册。

1
2
3
func (group *RouterGroup) GET(relativePath string, handlers ...app.HandlerFunc) IRoutes 
// app.HandlerFunc
type HandlerFunc func(c context.Context, ctx *RequestContext)

HandlerFunc 是一个无参函数,接收两个参数:

  • Context:标准长周期上下文,用于在 RPC Client 或者日志、Tracing 等组件间传递
  • RequestContext:短周期请求上下文,生命周期仅限于一次 HTTP 请求内

设置两种上下文的原因在于,RPC、日志中的 context 可能需要异步传递和处理,短周期的 RequestContext 可能会出现数据不一致问题。

响应渲染

Hertz 支持渲染 html、json、xml 等格式作为响应,最常用的就是 json。在上面的例子中,使用以下函数完成渲染,接受 Http 响应码和任意的对象,在 Render 函数中完成响应码的设置、Content-type 的设置,并把 json 序列化后写入 Response body。

1
2
3
4
5
6
// JSON serializes the given struct as JSON into the response body.
//
// It also sets the Content-Type as "application/json".
func (ctx *RequestContext) JSON(code int, obj interface{}) {
ctx.Render(code, render.JSONRender{Data: obj})
}

其他格式的渲染与之类似,这里就不赘述了。

参数获取

上面的例子没有提到参数如何获取,补充如下

query

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Query string parameters are parsed using the existing underlying request object.
// The request responds to url matching: /welcome?firstname=Jane&lastname=Doe&food=apple&food=fish
h.GET("/welcome", func(ctx context.Context, c *app.RequestContext) {
// 获取单个Query参数,提供默认值
firstname := c.DefaultQuery("firstname", "Guest")
// shortcut for c.Request.URL.Query().Get("lastname")
// 默认空值
lastname := c.Query("lastname")

// 迭代Query参数
// Iterate all queries and store the one with meeting the conditions in favoriteFood
var favoriteFood []string
c.QueryArgs().VisitAll(func(key, value []byte) {
if string(key) == "food" {
favoriteFood = append(favoriteFood, string(value))
}
})

c.String(consts.StatusOK, "Hello %s %s, favorite food: %s", firstname, lastname, favoriteFood)
})

form

form 有两种 content-type,需要选择对应的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// content-type : application/x-www-form-urlencoded
h.POST("/urlencoded", func(ctx context.Context, c *app.RequestContext) {
// 单个参数,返回类型 string
name := c.PostForm("name")
message := c.PostForm("message")
// 迭代所有参数
c.PostArgs().VisitAll(func(key, value []byte) {
if string(key) == "name" {
fmt.Printf("This is %s!", string(value))
}
})

c.String(consts.StatusOK, "name: %s; message: %s", name, message)
})

// content-type : multipart/form-data
h.POST("/formdata", func(ctx context.Context, c *app.RequestContext) {
// 单个参数,返回类型 []byte
id := c.FormValue("id")
name := c.FormValue("name")
message := c.FormValue("message")
// 迭代所有参数
c.String(consts.StatusOK, "id: %s; name: %s; message: %s\n", id, name, message)
})

注意 cookie 是单条获取、单个写入的,如果要写入多个 cookie,需要重复多次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
h.GET("/cookie", func(ctx context.Context, c *app.RequestContext) {
mc := "myCookie"
// get specific key
// 获取cookie值
val := c.Cookie(mc)
// 新建cookie
if val == nil {
// set a cookie
fmt.Printf("There is no cookie named: %s, and make one...\n", mc)
cookie := protocol.AcquireCookie()
// 只能设置一个key、一个value
cookie.SetKey("myCookie")
cookie.SetValue("a nice cookie!")
// 有效时间
cookie.SetExpire(time.Now().Add(3600 * time.Second))
cookie.SetPath("/")
cookie.SetHTTPOnly(true)
cookie.SetSecure(false)
// 写入响应头
c.Response.Header.SetCookie(cookie)
// 将cookie对象放回cookie池中
protocol.ReleaseCookie(cookie)
c.WriteString("A cookie is ready.")
return
}

fmt.Printf("Got a cookie: %s\nAnd eat it!", val)
// instruct upload_file to delete a cookie
// DelClientCookie instructs the upload_file to remove the given cookie.
// This doesn't work for a cookie with specific domain or path,
// you should delete it manually like:
//
// c := AcquireCookie()
// c.SetKey(mc)
// c.SetDomain("example.com")
// c.SetPath("/path")
// c.SetExpire(CookieExpireDelete)
// h.SetCookie(c)
// ReleaseCookie(c)
//
// 删除 Response 中的 cookie
c.Response.Header.DelClientCookie(mc)

// 构造新的 cookie
// construct the full struct of a cookie in response's header
respCookie := protocol.AcquireCookie()
respCookie.SetKey(mc)
c.Response.Header.Cookie(respCookie)
fmt.Printf("(The expire time of cookie is set to: %s)\n", respCookie.Expire())
protocol.ReleaseCookie(respCookie)
c.WriteString("The cookie has been eaten.")
})

参数绑定

参数绑定包含以下 API:

API 说明
ctx.BindAndValidate 利用下述的 go-tag 进行参数绑定,并在绑定成功后做一次参数校验 (如果有校验 tag 的话)
ctx.Bind BindAndValidate 但是不做参数校验
ctx.BindQuery 绑定所有 Query 参数,相当于给每一个 field 声明一个 query tag,适用于没写 tag 的场景
ctx.BindHeader 绑定所有 Header 参数,相当于给每一个 field 声明一个 header tag,适用于没写 tag 的场景
ctx.BindPath 绑定所有 Path 参数,相当于给每一个 field 声明一个 path tag,适用于没写 tag 的场景
ctx.BindForm 绑定所有 Form 参数,相当于给每一个 field 声明一个 form tag,需要 Content-Type 为:application/x-www-form-urlencoded/multipart/form-data, 适用于没写 tag 的场景
ctx.BindJSON 绑定 JSON Body,调用 json.Unmarshal() 进行反序列化,需要 Body 为 application/json 格式
ctx.BindProtobuf 绑定 Protobuf Body,调用 proto.Unmarshal() 进行反序列化,需要 Body 为 application/x-protobuf 格式
ctx.BindByContentType 根据 Content-Type 来自动选择绑定的方法,其中 GET 请求会调用 BindQuery, 带有 Body 的请求会根据 Content-Type 自动选择
ctx.Validate 进行参数校验,需要校验 tag 配合使用 (默认使用 vd tag 校验)

自定义函数校验的例子如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

package main

import (
"context"
"fmt"
"time"

"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/client"
"github.com/cloudwego/hertz/pkg/app/server"
"github.com/cloudwego/hertz/pkg/app/server/binding"
"github.com/cloudwego/hertz/pkg/protocol"
"github.com/cloudwego/hertz/pkg/protocol/consts"
)
// 使用test函数进行校验
type ValidateStruct struct {
A string `query:"a" vd:"test($)"`
}

func main() {
validateConfig := binding.NewValidateConfig()
// 注册校验函数
validateConfig.MustRegValidateFunc("test", func(args ...interface{}) error {
if len(args) != 1 {
return fmt.Errorf("the args must be one")
}
s, _ := args[0].(string)
if s == "123" {
return fmt.Errorf("the args can not be 123")
}
return nil
})
h := server.Default(server.WithHostPorts("127.0.0.1:8080"))

h.GET("customValidate", func(ctx context.Context, c *app.RequestContext) {
var req ValidateStruct
// 绑定参数
err := c.Bind(&req)
if err != nil {
fmt.Printf("error: %v\n", err)
}
// 校验
err = c.Validate(&req)
if err != nil {
fmt.Printf("error: %v\n", err)
}
})

go h.Spin()

time.Sleep(1000 * time.Millisecond)
c, _ := client.NewClient()
req := protocol.Request{}
resp := protocol.Response{}
req.SetMethod(consts.MethodGet)
req.SetRequestURI("http://127.0.0.1:8080/customValidate?a=123")
c.Do(context.Background(), &req, &resp)
}

hz

Hertz 的一大优势就在于,可以通过 hz 工具,根据 IDL 生成项目脚手架,非常便捷易用。hz 支持 Thrift 与 Protobuf 两种 IDL,在基础的 IDL 的基础上,hz 提供了注解能力满足 HTTP 框架所需的参数绑定、校验、路由注册功能。

Field 注解

注解 说明
api.raw_body 生成 “raw_body” tag,绑定 body bytes,优先级最低
api.query 生成 “query” tag,绑定 query 中参数,例如 “?query=hertz&…”
api.header 生成 “header” tag,绑定 header
api.cookie 生成 “cookie” tag,绑定 cookie
api.body 生成 “json” tag,绑定 body form 中的 "key-value",“content-type” 需为 "application/json"
api.path 生成 “path” tag,绑定路由中的参数
api.form 生成 “form” tag,绑定 body form 中的 "key-value"
“content-type” 需为 "multipart/form-data" 或 "application/x-www-form-urlencoded"
api.go_tag (protobuf) go.tag (thrift) 透传 go_tag,会生成 go_tag 里定义的内容
api.vd 生成 “vd” tag,用于参数校验
api.none 生成 “-” tag,不参与参数绑定与序列化,详情参考 api.none 注解说明

参数绑定优先级如下:

1
path > form > query > cookie > header > json > raw_body

Method 注解

注解 说明
api.get 定义 GET 方法及路由
api.post 定义 POST 方法及路由
api.put 定义 PUT 方法及路由
api.delete 定义 DELETE 方法及路由
api.patch 定义 PATCH 方法及路由
api.options 定义 OPTIONS 方法及路由
api.head 定义 HEAD 方法及路由
api.any 定义 ANY 方法及路由

绑定 HTTP 方法及路由,很直观。

案例

idl,hello.thrift 如下,使用了很多的 hz 注解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct MultiTagRequest {
1: required string QueryTag (api.query = "query"),
2: required string RawBodyTag (api.raw_body = "raw_body"),
3: required string PathTag (api.path = "path"),
4: required string FormTag (api.form = "form"),
5: required string HeaderTag (api.header = "header"),
6: required string BodyTag (api.body = "body"),
7: required string GoTag (go.tag = "json:\"go\" goTag:\"tag\""),
8: required string VdTag (api.vd = "$!='?'"),
}

struct Request {
1: required string id,
}

struct Response {
1: required string id,
}

service Hertz {
Response Method(1: MultiTagRequest request) (api.get = "/company/:path/hello"),
Response Method1(1: Request request) (api.get = "/company/department/group/user:id/name"),
Response Method2(1: Request request) (api.post = "/company/department/group/user:id/sex"),
Response Method3(1: Request request) (api.put = "/company/department/group/user:id/number"),
Response Method4(1: Request request) (api.delete = "/company/department/group/user:id/age"),
Response Method5(1: Request request) (api.options = "/school/class/student/name"),
Response Method6(1: Request request) (api.head = "/school/class/student/number"),
Response Method7(1: Request request) (api.patch = "/school/class/student/sex"),
Response Method8(1: Request request) (api.any = "/school/class/student/grade/ubjects"),
}

然后,运行 hz new -idl hello.thrfit,生成脚手架,并 go mod tidy 安装依赖,目录结构如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
❯ tree -r .                                                                                                        
.
├── script
│   └── bootstrap.sh
├── router_gen.go
├── router.go
├── main.go
├── hello.thrift
├── go.sum
├── go.mod
├── build.sh
└── biz
├── router
│   ├── register.go
│   └── hello
│   ├── middleware.go
│   └── hello.go
├── model
│   └── hello
│   └── hello.go
└── handler
├── ping.go
└── hello
└── hertz.go

下面我们逐个分析。

main.go

项目的入口文件,创建 server、注册路由并运行。如果要修改服务选项,可以在 server.Default() 中传入 options。

1
2
3
4
5
6
7
func main() {
h := server.Default()

register(h)
h.Spin()
}

router.go

自定义路由,默认会注册 /ping 用于调试。

1
2
3
4
5
6
// customizeRegister registers customize routers.
func customizedRegister(r *server.Hertz) {
r.GET("/ping", handler.Ping)

// your code ...
}

router_gen.go

main 函数调用,注册所有路由,即 idl 中定义的与用户自定义的。

1
2
3
4
5
6
7
// register registers all routers.
func register(r *server.Hertz) {

router.GeneratedRegister(r)

customizedRegister(r)
}

.hz

标识项目由 hz 工具搭建。

biz/

业务逻辑目录。

handler/

HandlerFunc 的逻辑。

hello/

对应 hello.thrifthertz.go 来源于定义的 service Hertz。一个 HandlerFunc 例子如下,帮我们完成了参数的绑定校验以及响应的渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Method .
// @router /company/:path/hello [GET]
func Method(ctx context.Context, c *app.RequestContext) {
var err error
var req hello.MultiTagRequest
err = c.BindAndValidate(&req)
if err != nil {
c.String(consts.StatusBadRequest, err.Error())
return
}

resp := new(hello.Response)

c.JSON(consts.StatusOK, resp)
}

model/

idl 中的结构体,也就是 model 层。

hello/hello.go

同样,名字对应 hello.thrift。一个 Request 代码如下,thrift tag 用于 thrift 序列化和反序列化,json tag 用于 json 序列化与反序列化,query/path/form 等 tag 用于参数绑定。这些都是 hz 工具根据 idl 与注解生成的。

1
2
3
4
5
6
7
8
9
10
type MultiTagRequest struct {
QueryTag string `thrift:"QueryTag,1,required" json:"QueryTag,required" query:"query,required"`
RawBodyTag string `thrift:"RawBodyTag,2,required" json:"RawBodyTag,required" raw_body:"raw_body,required"`
PathTag string `thrift:"PathTag,3,required" json:"PathTag,required" path:"path,required"`
FormTag string `thrift:"FormTag,4,required" form:"form,required" json:"FormTag,required"`
HeaderTag string `thrift:"HeaderTag,5,required" header:"header,required" json:"HeaderTag,required"`
BodyTag string `thrift:"BodyTag,6,required" form:"body,required" json:"body,required"`
GoTag string `thrift:"GoTag,7,required" json:"go" goTag:"tag" form:"GoTag,required" query:"GoTag,required"`
VdTag string `thrift:"VdTag,8,required" form:"VdTag,required" json:"VdTag,required" query:"VdTag,required" vd:"$!='?'"`
}

router/

路由注册逻辑。在 middlerware.go 可以为每个路由使用单独的中间件。

更新

如果 idl 发生了变更,或者新加了服务,可以通过 hz update 来对项目进行更新。

新建一个 bye.thrift,代码如下:

1
2
3
4
5
6
7
8
9
10
11
struct Request {
1: required string id,
}

struct Response {
1: required string id,
}

service ByeBye {
Response bye(1: Request req) (api.get = "/bye"),
}

输入 hz update -idl bye.thrift,可以发现 biz 目录下的目录都新增了 bye/ 文件夹,存放相关的 Handler、model。

总结

本篇博客介绍了 Hertz 框架的基本使用,通过 hz 工具构建项目并进行分析。相信读完本篇博客,读者可以基本掌握 Hertz 的用法,更深的用法(例如中间件等)可以参考官方文档。按原本的计划,我本该去读 Hertz 源码,了解背后原理。但我后来发现,作为初学者,能够掌握框架用法就已经够了,更深入的读源码并不会给日常开发带来多大帮助。而 Redis、消息队列这些中间件,则是需要深入了解原理,它们构建了后端服务的核心缓存和业务流转逻辑,稍有不慎就会危害线上服务,造成重大事故。因此,我后面会花更多时间在这些知识的学习上,框架会用即可。

参考