1. 初识Hertz

74

初识Hertz

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

架构设计

主体部分采用了四层分层设计:

  • 网络层扩展:原生支持基于连接与基于流的两大类网络库;
  • 协议层扩展:支持 HTTP/1.1、HTTP/2、HTTP/3、Websocket以及自定义协议;
  • 应用层扩展:支持 pprof、gzip、i18n、csrf、反向代理等常用中间件扩展。

Hertz 还提供了脚手架工具 Hz,根据接口定义(IDL)自动生成项目骨架,帮助业务聚焦核心逻辑。

1. 快速上手

  1. 安装go
    安装go,配置goproxy,过程省略。

  2. 安装hz脚手架

go install github.com/cloudwego/hertz/cmd/hz@latest
  1. 初始化一个项目
hz new -module <name>

如果是在gopath下执行,go.mod -> module path默认为当前路径相对 GOPATH 的路径。如果是在gopath外,或者想要手动指定,那么就加上 -module参数。

  1. 查看生成的代码
    目录结构:
    * biz            // 业务代码
        * handler    // api请求的处理函数
        * router     // api请求的路由规则(通过IDL生成)
    main.go
    router.go
    router_gen.go

默认生成的main.go非常简洁,里面生成并启动了一个hertz server实例,register方法用来注册所有请求的路由。

func main() {
	h := server.Default()

	register(h)
	h.Spin()
}

进一步查看register()方法所在的router_gen.go文件,里面包含了两个Register方法。
GeneratedRegister:所有通过IDL定义和生成的API接口路由默认被放置在router目录下,对应的处理函数在handler目录下;
customizedRegister:一些自定义的路由规则放在项目目录下的router.go中。

// register registers all routers.
func register(r *server.Hertz) {

	router.GeneratedRegister(r)

	customizedRegister(r)
}
  1. 在router.go中注册一个ping方法及其路由规则(默认生成的示例代码中已包含):
    router.go ->
// customizeRegister registers customize routers.
func customizedRegister(r *server.Hertz) {
	r.GET("/ping", handler.Ping)
}

handler/ping ->

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

consts包中封装了一些常量,包含一些状态码/MIME等,其中部分和http包的常量重叠。

  1. 编译运行
go build && ./demo.exe
# windows下: o build; ./demo.exe

访问http://localhost:8888/ping,可以看到pong响应。

2. 代码自动生成工具hz

上述快速上手示例中,我们使用hertz默认的代码结构生成了一个ping服务器,实现了/ping请求的路由和处理。在实际应用中,我们往往会需要处理更加复杂的请求和业务处理,为了能够规范这些业务代码的定义和构建。hertz提供了代码自动生成工具hz来根据接口定义文件(IDL)来自动生成和更新代码框架,保证整体项目架构的一致性。

  1. 在项目文件夹下创建IDL文件夹, 定义一个简单的hello.thrift
namespace go hello.world

service HelloService {
    string Hello(1: string name);
}
  1. 基于idl文件初始化项目
# 1. 注意hertz对thrift的版本有所限制
go mod edit -replace github.com/apache/thrift=github.com/apache/[email protected]

# 2. 下载完成后,基于idl文件初始化项目
hz new -idl .\IDL\hello.thrift

可以看到biz下handler/model/router文件夹中都多出了hello/world的目录结构,其中包含了基于给定idl文件生成的代码。

  1. 修改idl文件,更新代码
    在上述代码中,我们只是声明了一个方法,但是并没有指定路由、请求、响应。接下来,我们尝试实现一个简单的请求router/handler/model的完整流程,并通过hz来实现代码结构的更新。
namespace go hello.world

struct HelloReq {
    1: string Name (api.query="name")  // 在请求参数上添加 api 注解进行参数绑定
}

struct HelloResp {
    1 : string RespBody
}

service HelloService {
    HelloResp Hello(1: HelloReq request) (api.get="/hello"); // 在请求方法上添加 api 注解进行静态路由绑定
}

可以看到biz目录下生成了完整的router/handler/model代码。其中, model中是包含完整server端代码的,实现了类似rpc形式的http响应服务,可以与hz client生成的代码直接交互。在生成的handler中添加自己的处理逻辑后,go build && ./<binary_file>。请求对应接口,即可看到预期结果。

3. hz自动生成的代码结果 thriftgo为例

.
├── biz                                // business 层,存放业务逻辑相关流程
│   ├── handler                        // 存放 handler 文件
│   │   ├── hello                      // hello/example 对应 thrift idl 中定义的 namespace;而对于 protobuf idl,则是对应 go_package 的最后一级
│   │   │   └── example
│   │   │       └── hello_service.go   // handler 文件,用户在该文件里实现 IDL service 定义的方法,update 时会查找当前文件已有的 handler 并在尾部追加新的 handler
│   │   └── ping.go                    // 默认携带的 ping handler,用于生成代码快速调试,无其他特殊含义
│   ├── model                          // idl 内容相关的生成代码
│   │   └── hello                      // hello/example 对应 thrift idl 中定义的 namespace;而对于 protobuf idl,则是对应 go_package
│   │       └── example
│   │           └── hello.go           // thriftgo 的产物,包含 hello.thrift 定义的内容的 go 代码,update 时会重新生成
│   └── router                         // idl 中定义的路由相关生成代码
│       ├── hello                      // hello/example 对应 thrift idl 中定义的 namespace;而对于 protobuf idl,则是对应 go_package 的最后一级
│       │   └── example
│       │       ├── hello.go           // hz 为 hello.thrift 中定义的路由生成的路由注册代码;每次 update 相关 idl 会重新生成该文件
│       │       └── middleware.go      // 默认中间件函数,hz 为每一个生成的路由组都默认加了一个中间件;update 时会查找当前文件已有的 middleware 在尾部追加新的 middleware
│       └── register.go                // 调用注册每一个 idl 文件中的路由定义;当有新的 idl 加入,在更新的时候会自动插入其路由注册的调用;勿动
├── go.mod                             // go.mod 文件,如不在命令行指定,则默认使用相对于 GOPATH 的相对路径作为 module 名
├── idl                                // 用户定义的 idl,位置可任意
│   └── hello.thrift
├── main.go                            // 程序入口
├── router.go                          // 用户自定义除 idl 外的路由方法
├── router_gen.go                      // hz 生成的路由注册代码,用于调用用户自定义的路由以及 hz 生成的路由
├── .hz                                // hz 创建代码标志,无需改动
├── build.sh                           // 程序编译脚本,Windows 下默认不生成,可直接使用 go build 命令编译程序
├── script
│   └── bootstrap.sh                   // 程序运行脚本,Windows 下默认不生成,可直接运行 main.go
└── .gitignore

4. hertz + gorm

biz/dal下进行mysql的初始化:

package mysql

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

var dsn = "username:password@tcp(localhost:3306)/gorm?charset=utf8&parseTime=True&loc=Local"

var DB *gorm.DB

func Init() {
	var err error
	DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
		SkipDefaultTransaction: true,
		PrepareStmt:            true,
		Logger:                 logger.Default.LogMode(logger.Info),
	})
	if err != nil {
		panic(err)
	}
}

使用gorm_gen,基于数据库表,生成对应的模型及基本操作代码:

package main

import (
	"github.com/cloudwego/hertz-examples/bizdemo/hertz_gorm_gen/biz/dal/mysql"
	genModel "github.com/cloudwego/hertz-examples/bizdemo/hertz_gorm_gen/biz/model/orm_gen"
	"gorm.io/gen"
	// reuse your gorm db
	// init db
	_ "github.com/cloudwego/hertz-examples/bizdemo/hertz_gorm_gen/biz/dal"
)

func main() {
	g := gen.NewGenerator(gen.Config{
		OutPath: "./biz/model/dao",
		Mode:    gen.WithoutContext | gen.WithDefaultQuery | gen.WithQueryInterface, // generate mode
	})

	// gormdb, _ := gorm.Open(mysql.Open("root:@(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=True&loc=Local"))
	// reuse your gorm db
	g.UseDB(mysql.DB)

	// Generate struct `User` based on table `users`
	g.GenerateModel("users")

	// Generate basic type-safe DAO API for struct `model.User` following conventions
	g.ApplyBasic(genModel.User{}) 

	// Generate the code
	g.Execute()
}

g.ApplyBasic(genModel.User{}) 为指定数据结构生成CRUD操作,需要自己确定模型和表的一致性。可以在数据库init时:

// 自动迁移模型 (会根据模型自动新建/修改表,谨慎在开发环境中使用,不能在生产环境中使用。如果出现了类型的修改,可能会导致数据丢失。)
if err := db.AutoMigrate(&genModel.User{}); err != nil {
    panic(err)
}

另外一个方法是根据数据库中实际存在的表结构自动生成对应的crud代码:

// 生成模型文件
g.ApplyBasic(g.GenerateAllTable()...)

使用生成的代码:

package main

import (
    "fmt"
    "log"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
    "your_project/dao"
)

func main() {
    // 连接数据库
    db, err := gorm.Open(mysql.Open("username:password@tcp(127.0.0.1:3306)/dbname"), &gorm.Config{})
    if err != nil {
        log.Fatal(err)
    }

    // 创建 UserDAO 实例
    userDAO := dao.Use(db).User

    // 示例查询操作
    users, err := userDAO.Find()
    if err != nil {
        log.Fatal(err)
    }

    for _, user := range users {
        fmt.Printf("User: %v\n", user)
    }
}

5. hertz + jwt

hertz提供了非常简便的jwt集成方式,举例:

package main

import (
	"context"
	"errors"
	"github.com/cloudwego/hertz/pkg/app"
	"github.com/cloudwego/hertz/pkg/common/utils"
	"net/http"
	"time"

	"github.com/cloudwego/hertz/pkg/app/server"
	"github.com/hertz-contrib/jwt"
)

var IdentityKey = "id"

var JwtMiddleware *jwt.HertzJWTMiddleware

func main() {
	InitJwt()

	r := server.Default()
	r.POST("/login", JwtMiddleware.LoginHandler)
	r.GET("/protected", JwtMiddleware.MiddlewareFunc(), ProtectedEndpoint)

	r.Spin()
}

func InitJwt() {
	var err error
	JwtMiddleware, err = jwt.New(&jwt.HertzJWTMiddleware{
		Realm:         "test zone",
		Key:           []byte("secret key"),
		Timeout:       time.Hour,
		MaxRefresh:    time.Hour,
		TokenLookup:   "header: Authorization, query: token, cookie: jwt",
		TokenHeadName: "Bearer",
		LoginResponse: func(ctx context.Context, c *app.RequestContext, code int, token string, expire time.Time) {
			c.JSON(http.StatusOK, utils.H{
				"code":    code,
				"token":   token,
				"expire":  expire.Format(time.RFC3339),
				"message": "success",
			})
		},
		Authenticator: func(ctx context.Context, c *app.RequestContext) (interface{}, error) {
			var loginStruct struct {
				Account  string `form:"account" json:"account" query:"account" vd:"(len($) > 0 && len($) < 30); msg:'Illegal format'"`
				Password string `form:"password" json:"password" query:"password" vd:"(len($) > 0 && len($) < 30); msg:'Illegal format'"`
			}
			if err := c.BindAndValidate(&loginStruct); err != nil {
				return nil, err
			}
			// 假设 CheckUser 是一个函数,用于验证用户凭证
			users, err := CheckUser(loginStruct.Account, loginStruct.Password)
			if err != nil {
				return nil, err
			}
			if len(users) == 0 {
				return nil, errors.New("user not found or wrong password")
			}
			return users[0], nil
		},
		IdentityKey: IdentityKey,
		IdentityHandler: func(ctx context.Context, c *app.RequestContext) interface{} {
			claims := jwt.ExtractClaims(ctx, c)
			return &User{
				UserName: claims[IdentityKey].(string),
			}
		},
		PayloadFunc: func(data interface{}) jwt.MapClaims {
			if v, ok := data.(*User); ok {
				return jwt.MapClaims{
					IdentityKey: v.UserName,
				}
			}
			return jwt.MapClaims{}
		},
		Unauthorized: func(ctx context.Context, c *app.RequestContext, code int, message string) {
			c.JSON(http.StatusUnauthorized, utils.H{
				"code":    code,
				"message": message,
			})
		},
	})
	if err != nil {
		panic(err)
	}
}

func ProtectedEndpoint(ctx context.Context, c *app.RequestContext) {
	claims := jwt.ExtractClaims(ctx, c)
	user, _ := c.Get("user")
	c.JSON(http.StatusOK, utils.H{
		"user":   user,
		"claims": claims,
	})
}

type User struct {
	UserName string
}

func CheckUser(account, password string) ([]User, error) {
	// 这是一个模拟函数,实际中你应该查询数据库或者其他存储来验证用户
	if account == "admin" && password == "password" {
		return []User{{UserName: "admin"}}, nil
	}
	return nil, nil
}

正常请求的 JWT 处理逻辑

  1. 用户登录

    • 用户通过 /login 路径发送 POST 请求,携带账户和密码。
    • JwtMiddleware.LoginHandler 处理登录请求,调用 Authenticator 函数验证用户凭证。
    • 如果认证成功,LoginResponse 函数生成 JWT 并返回给客户端。
  2. 访问受保护资源

    • 客户端在请求头中添加 Authorization: Bearer <token>,访问 /protected 路径。
    • JwtMiddleware.MiddlewareFunc() 中间件解析并验证 JWT。
    • 如果 JWT 有效,IdentityHandler 提取用户身份信息,并将其存储在请求上下文中。
    • ProtectedEndpoint 函数处理请求,通过 c.Get("user") 获取用户信息,并返回给客户端。

假设用户使用 curl 命令来模拟请求:

# 登录请求
curl -X POST http://localhost:8080/login -d '{"account":"admin","password":"password"}'
# 假设返回 {"code":200,"token":"<token>","expire":"...","message":"success"}

# 访问受保护资源
curl -H "Authorization: Bearer <token>" http://localhost:8080/protected
# 假设返回 {"user":{"UserName":"admin"},"claims":{"id":"admin"}}

未登录请求的处理逻辑

  1. 访问受保护资源
    • 客户端未提供 JWT 或提供的 JWT 无效,直接访问 /protected 路径。
    • JwtMiddleware.MiddlewareFunc() 中间件尝试解析并验证 JWT。
    • 如果 JWT 无效或缺失,Unauthorized 函数被调用,返回 401 未授权响应。

同样,使用 curl 模拟请求:

# 未登录直接访问受保护资源
curl http://localhost:8080/protected
# 返回 {"code":401,"message":"Missing or invalid JWT"}

总结

通过以上两个例子,我们可以看到在 JWT 认证流程中:

  • 正常请求

    • 用户登录后获取 JWT。
    • 客户端在请求头中携带 JWT 访问受保护资源。
    • 服务器验证 JWT 并返回相应资源。
  • 未登录请求

    • 客户端未提供 JWT 或提供的 JWT 无效。
    • 服务器返回 401 未授权响应。

这些处理逻辑确保了只有合法的用户才能访问受保护的资源。

参考文献

字节开源go框架