1. 初识Hertz
初识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. 快速上手
-
安装go
安装go,配置goproxy,过程省略。 -
安装hz脚手架
go install github.com/cloudwego/hertz/cmd/hz@latest
- 初始化一个项目
hz new -module <name>
如果是在gopath下执行,go.mod -> module path默认为当前路径相对 GOPATH 的路径。如果是在gopath外,或者想要手动指定,那么就加上 -module参数。
- 查看生成的代码
目录结构:
* 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)
}
- 在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包的常量重叠。
- 编译运行
go build && ./demo.exe
# windows下: o build; ./demo.exe
访问http://localhost:8888/ping,可以看到pong响应。
2. 代码自动生成工具hz
上述快速上手示例中,我们使用hertz默认的代码结构生成了一个ping服务器,实现了/ping请求的路由和处理。在实际应用中,我们往往会需要处理更加复杂的请求和业务处理,为了能够规范这些业务代码的定义和构建。hertz提供了代码自动生成工具hz来根据接口定义文件(IDL)来自动生成和更新代码框架,保证整体项目架构的一致性。
- 在项目文件夹下创建IDL文件夹, 定义一个简单的hello.thrift
namespace go hello.world
service HelloService {
string Hello(1: string name);
}
- 基于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文件生成的代码。
- 修改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 处理逻辑
-
用户登录:
- 用户通过
/login
路径发送 POST 请求,携带账户和密码。 JwtMiddleware.LoginHandler
处理登录请求,调用Authenticator
函数验证用户凭证。- 如果认证成功,
LoginResponse
函数生成 JWT 并返回给客户端。
- 用户通过
-
访问受保护资源:
- 客户端在请求头中添加
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"}}
未登录请求的处理逻辑
- 访问受保护资源:
- 客户端未提供 JWT 或提供的 JWT 无效,直接访问
/protected
路径。 JwtMiddleware.MiddlewareFunc()
中间件尝试解析并验证 JWT。- 如果 JWT 无效或缺失,
Unauthorized
函数被调用,返回 401 未授权响应。
- 客户端未提供 JWT 或提供的 JWT 无效,直接访问
同样,使用 curl
模拟请求:
# 未登录直接访问受保护资源
curl http://localhost:8080/protected
# 返回 {"code":401,"message":"Missing or invalid JWT"}
总结
通过以上两个例子,我们可以看到在 JWT 认证流程中:
-
正常请求:
- 用户登录后获取 JWT。
- 客户端在请求头中携带 JWT 访问受保护资源。
- 服务器验证 JWT 并返回相应资源。
-
未登录请求:
- 客户端未提供 JWT 或提供的 JWT 无效。
- 服务器返回 401 未授权响应。
这些处理逻辑确保了只有合法的用户才能访问受保护的资源。