Miniblog 三层简洁架构开发的最后一步便是开发 Handler 层代码。Handler 层代码的实现思路和 Biz 层、Store 层保持一致。
Handler 层代码实现位于 internal/apiserver/handler/ 目录中,因为 miniblog 同时实现了 http 和 grpc 服务器,所以有两类 Handler:HTTP Handler 和 gRPC Handler。为了提高代码的可维护性,将两类 Handler 分别保存在了 internal/apiserver/handler/grpc 目录和 internal/apiserver/handler/http 目录。
如果项目只实现了两种服务器的一种,则可以将 Handler 层代码保存在 internal/apiserver/handler/ 中。
gRPC Handler 实现
新建 internal/apiserver/handler/grpc/handler.go 文件,代码如下:
// Handler 负责处理博客模块的请求.
type Handler struct {
apiv1.UnimplementedMiniBlogServer
biz biz.IBiz
}
// NewHandler 创建一个新的 Handler 实例.
func NewHandler(biz biz.IBiz) *Handler {
return &Handler{
biz: biz,
}
}Handler 结构体类型实现了 MiniBlog gRPC 服务定义的接口。Handler 层代码通过调用 Biz 层的方法完成业务逻辑的处理。所以 Handler 结构体中包含了 biz.IBiz 类型的字段 biz。
User 资源相关接口的 Handler 处理方法实现见 internal/apiserver/handler/grpc/user.go 文件,代码内容如代码清单 9-6 所示。
代码清单 9-6 User 资源 gRPC Handler 层代码实现
package grpc
import (
"context"
apiv1 "github.com/onexstack/miniblog/pkg/api/apiserver/v1"
)
// Login 用户登录.
func (h *Handler) Login(ctx context.Context, rq *apiv1.LoginRequest) (*apiv1.LoginResponse, error) {
return h.biz.UserV1().Login(ctx, rq)
}
// ChangePassword 修改用户密码.
func (h *Handler) ChangePassword(ctx context.Context, rq *apiv1.ChangePasswordRequest) (*apiv1.ChangePasswordResponse, error) {
return h.biz.UserV1().ChangePassword(ctx, rq)
}
// CreateUser 创建新用户.
func (h *Handler) CreateUser(ctx context.Context, rq *apiv1.CreateUserRequest) (*apiv1.CreateUserResponse, error) {
return h.biz.UserV1().Create(ctx, rq)
}
...可以看到在 Handler 层没有执行任何业务逻辑,直接将请求的处理转发给了 Biz 层提供的方法。
HTTP Handler 实现
HTTP Handler 实现跟 gRPC Handler 实现类似。新建 internal/apiserver/handler/http/handler.go 文件,代码如下:
// Handler 处理博客模块的请求.
type Handler struct {
biz biz.IBiz
}
// NewHandler 创建新的 Handler 实例.
func NewHandler(biz biz.IBiz) *Handler {
return &Handler{
biz: biz,
}
}User 资源相关接口的 Handler 处理方法实现见 internal/apiserver/handler/http/user.go 文件,代码如代码清单 9-7 所示。
代码清单 9-7 User 资源 HTTP Handler 层代码实现
// Login 用户登录并返回 JWT Token.
func (h *Handler) Login(c *gin.Context) {
core.HandleJSONRequest(c, h.biz.UserV1().Login)
}
...跟 gRPC Handler 实现不同的是,因为 HTTP Handler 使用了 Gin Web 框架,所以在解析请求和返回请求时,需要使用 Gin 框架提供的各类方法。core.HandleJSONRequest 函数封装了请求参数解析、参数校验、请求返回等逻辑。函数实现代码如代码清单 9-8 所示。
代码清单 9-8 请求处理函数实现
// HandleJSONRequest 是处理 JSON 请求的快捷函数.
func HandleJSONRequest[T any, R any](c *gin.Context, handler Handler[T, R], validators ...Validator[T]) {
HandleRequest(c, c.ShouldBindJSON, handler, validators...)
}
// HandleRequest 是通用的请求处理函数.
// 负责绑定请求数据、执行验证、并调用实际的业务处理逻辑函数.
func HandleRequest[T any, R any](c *gin.Context, binder Binder, handler Handler[T, R], validators ...Validator[T]) {
var request T
// 绑定和验证请求数据
if err := ReadRequest(c, &request, binder, validators...); err != nil {
WriteResponse(c, nil, err)
return
}
// 调用实际的业务逻辑处理函数
response, err := handler(c.Request.Context(), &request)
WriteResponse(c, response, err)
}在代码清单 9-8 中,HandleJSONRequest 函数是基于 HandleRequest 封装的一个语法糖函数,用来简化调用方调用时的参数输入,提高调用效率,减小调用复杂度。语法糖函数 HandleJSONRequest、HandleQueryRequest、HandleUriRequest 调用了 HandleRequest 函数,在调用 HandleRequest 时,传入了对应的 gin.Context 类型解析请求参数的方法。
上述开发技巧是在阅读 Gin 框架源码时学到的。在程序员的职业生涯中,很多开发技巧是来自于阅读开源项目学习到的,也建议读者在学习 Go 项目开发过程中,多去阅读一些优秀开源项目的源码,例如:OneX、Gin、Kuberentes 项目的源码。
在 HandleRequest 函数中,会先调用 ReadRequest 从请求中解析出请求参数,所有的请求信息都保存在*gin.Context 类型的变量 c 中。解析完请求之后会调用传入的 Handler 方法,进行请求处理。Login 接口中的 Handler 方法是 h.biz.UserV1().Login,也就是 Biz 层的业务逻辑处理方法。
请求处理之后,会调用 WriteResponse 方法将返回参数写入 HTTP 返回体中。
提示:core 包提供了很多请求解析、请求处理函数。这些函数可以满足,不同的请求解析、请求处理场景。
请求解析:ReadRequest 函数实现
在解析 HTTP 请求参数时,所有的 API 接口均需要对请求参数进行默认值设置、参数校验。为了简化代码处理,miniblog 将参数解析、参数默认值设置、参数校验进行了标准化处理,并统一封装在了 github.com/onexstack/onexstack/pkg/core 包的 ReadRequest 函数中。ReadRequest 实现如代码清单 9-9 所示:
代码清单 9-9 ReadRequest 实现
// ReadRequest 是用于绑定和验证请求数据的通用工具函数.
// - 它负责调用绑定函数绑定请求数据.
// - 如果目标类型实现了 Default 接口,会调用其 Default 方法设置默认值.
// - 最后执行传入的验证器对数据进行校验.
func ReadRequest[T any](c *gin.Context, rq *T, binder Binder, validators ...Validator[T]) error {
// 调用绑定函数绑定请求数据
if err := binder(rq); err != nil {
return errorsx.ErrBind.WithMessage(err.Error())
}
// 如果数据结构实现了 Default 接口,则调用它的 Default 方法
if defaulter, ok := any(rq).(interface{ Default() }); ok {
defaulter.Default()
}
// 执行所有验证函数
for _, validate := range validators {
if validate == nil { // 跳过 nil 的验证器
continue
}
if err := validate(c.Request.Context(), rq); err != nil {
return err
}
}
return nil
}ReadRequest 是一个通用的、泛型化的工具函数,用于对请求数据进行参数绑定、初始化默认值以及进行请求参数校验,其功能设计清晰且非常灵活,适应多种场景。ReadRequest 函数会根据传入的绑定方法 Binder,将请求参数绑定到传入的结构体类型变量 rq 中。
ReadRequest 函数也会判断传入的 rq 结构体变量是否实现了 Default() 方法,如果实现了,则调用 rq 的 Default() 方法,用来对请求参数 rq 进行参数默认值设置操作。
ReadRequest 函数还接收一个可变长参数 validators,validators 列表中包含了 rq 结构体变量的校验方法列表。ReadRequest 函数会遍历并执行所有的校验方法,当其中一个校验方法返回校验失败错误时,ReadRequest 函数结束运行,并返回错误。
需要注意的是,在调用 *gin.Context 的 ShouldBindUri 方法时,Gin 框架会将请求中的路径参数绑定到 Go 结构体中的对应字段上,这些字段跟路径参数的映射关系,是通过 Go 结构体字段的 uri 标签来映射的。例如:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// UserRequest 定义了用于绑定 URI 参数的结构体
type UserRequest struct {
ID int `uri:"id" binding:"required"` // 路由中的 :id
Name string `uri:"name" binding:"required"` // 路由中的 :name
}
func main() {
r := gin.Default()
// 路由定义,包含两个路径参数 :id 和 :name
r.GET("/user/:id/:name", func(c *gin.Context) {
var req UserRequest
// 使用 ShouldBindUri 绑定路径参数到结构体
if err := c.ShouldBindUri(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 输出绑定成功的结果
c.JSON(http.StatusOK, gin.H{
"id": req.ID,
"name": req.Name,
})
})
r.Run() // 默认在 :8080 启动服务器
}protoc 编译器生成的 Go 结构体字段中的标签是不带 uri 标签的。Go 项目开发中,可以使用 protoc-go-inject-tag 工具来给 Protobuf 消息定义中的指定字段添加 Go 结构体标签,例如:
// DeleteUserRequest 表示删除用户请求
message DeleteUserRequest {
// userID 表示用户 ID
// @gotags: uri:"userID"
string userID = 1;
}protoc-go-inject-tag 工具的安装方法如下:
$ go install github.com/favadi/protoc-go-inject-tag@latest使用 protoc-go-inject-tag 给 Protobuf 消息体字段指定 Go 结构体标签很简单,只需要在对应的字段上添加类似 // @gotags: uri:"userID" 注释即可。@gotags: 后指定了需要添加的 Go 结构体标签,例如这里是 uri:"userID"。
在指定完 @gotags 之后,还需要执行 protoc-go-inject-tag 命令,来给已经生成的 *.pb.go 文件添加指定的标签。具体实现方法可以在 Makefile protoc 规则的命令中,添加以下命令:
protoc: # 编译 protobuf 文件.
...
@find . -name "*.pb.go" -exec protoc-go-inject-tag -input={} \;protoc 规则中新增的命令,会查找 miniblog 项目目录下所有的 *.pb.go,并使用 protoc-go-inject-tag 工具给指定的结构体字段添加指定的结构体标签。更新完 Makefile 规则后,需要重新运行以下命令来更新基于 Protobuf 文件生成的 Go 文件:
$ make protoc请求返回:WriteResponse 函数实现
在返回请求时,使用到了 github.com/onexstack/onexstack/pkg/core 包的 WriteResponse 方法,其实现如代码清单 9-10 所示。
代码清单 9-10 WriteResponse 函数实现
// ErrorResponse 定义了错误响应的结构,
// 用于 API 请求中发生错误时返回统一的格式化错误信息.
type ErrorResponse struct {
// 错误原因,标识错误类型
Reason string `json:"reason,omitempty"`
// 错误详情的描述信息
Message string `json:"message,omitempty"`
// 附带的元数据信息
Metadata map[string]string `json:"metadata,omitempty"`
}
// WriteResponse 是通用的响应函数.
// 它会根据是否发生错误,生成成功响应或标准化的错误响应.
func WriteResponse(c *gin.Context, data any, err error) {
if err != nil {
// 如果发生错误,生成错误响应
errx := errorsx.FromError(err) // 提取错误详细信息
c.JSON(errx.Code, ErrorResponse{
Reason: errx.Reason,
Message: errx.Message,
Metadata: errx.Metadata,
})
return
}
// 如果没有错误,返回成功响应
c.JSON(http.StatusOK, data)
}WriteResponse 方法会判断 err 是否是 nil,如果不是 nil,则将其解析为 errorsx.ErrorX 类型的变量 errx,并读取 errx 变量中的 Code、Reason、Message、Metadata 字段。Code 字段的值用来设置 HTTP 状态码,其他字段的值用来构建 ErrorResponse 类型对象,并编码为 JSON 格式,保存在返回体中。在接口报错时,返回结构固定的错误,可以降低客户端处理错误代码实现复杂度。
Handler 结构体实例化
Handler 中包含了 biz.IBiz 类型的实例 biz。所以在创建 Handler 之前,还需要实例化 IBiz 接口。在 internal/apiserver/server.go 文件中,新增以下代码:
package apiserver
import (
...
"github.com/onexstack/onexstack/pkg/store/where"
"gorm.io/gorm"
...
"github.com/onexstack/miniblog/internal/apiserver/biz"
"github.com/onexstack/miniblog/internal/apiserver/store"
"github.com/onexstack/miniblog/internal/pkg/contextx"
...
)
...
// Config 配置结构体,用于存储应用相关的配置.
// 不用 viper.Get,是因为这种方式能更加清晰的知道应用提供了哪些配置项.
type Config struct {
...
MySQLOptions *genericoptions.MySQLOptions
}
...
// ServerConfig 包含服务器的核心依赖和配置.
type ServerConfig struct {
cfg *Config
biz biz.IBiz
}
// NewUnionServer 根据配置创建联合服务器.
func (cfg *Config) NewUnionServer() (*UnionServer, error) {
// 注册租户解析函数,通过上下文获取用户 ID
//nolint: gocritic
where.RegisterTenant("userID", func(ctx context.Context) string {
return contextx.UserID(ctx)
})
...
return &UnionServer{srv: srv}, nil
}
// NewServerConfig 创建一个 *ServerConfig 实例.
// 进阶:这里其实可以使用依赖注入的方式,来创建 *ServerConfig.
func (cfg *Config) NewServerConfig() (*ServerConfig, error) {
// 初始化数据库连接
db, err := cfg.NewDB()
if err != nil {
return nil, err
}
store := store.NewStore(db)
return &ServerConfig{
cfg: cfg,
biz: biz.NewBiz(store),
}, nil
}
// NewDB 创建一个 *gorm.DB 实例.
func (cfg *Config) NewDB() (*gorm.DB, error) {
return cfg.MySQLOptions.NewDB()
}在 NewServerConfig 方法中,通过调用 cfg.NewDB() 创建了一个 *gorm.DB 的实例 db,再使用 db 创建了 store.IStore 的实例 store,并通过 store 创建了 biz.IBiz 的实例,最终将其保存在 ServerConfig 的 biz 字段中。
在 internal/apiserver/grpcserver.go 文件中,修改代码,在实例化 Handler 结构体类型时,传入其依赖的 biz.IBiz 类型的对象:
apiv1.RegisterMiniBlogServer(s, handler.NewHandler(c.biz))同样,修改 internal/apiserver/httpserver.go 文件,在实例化 Handler 结构体类型时,传入其依赖的 biz.IBiz 类型的对象:
handler := handler.NewHandler(c.biz)提示:运行时配置新增了 *genericoptions.MySQLOptions 类型的字段 MySQLOptions。相应的,初始化配置也需要添加 MySQLOptions 配置项。
注册 HTTP 路由
在开发完 Handler 层方法之后,就可以将这些方法作为路由函数注册到 HTTP 路由中。修改 internal/apiserver/httpserver.go 文件,添加以下代码,来注册 HTTP 路由:
import (
...
"github.com/onexstack/onexstack/pkg/core"
...
"github.com/onexstack/miniblog/internal/pkg/errno"
)
// 注册 API 路由。路由的路径和 HTTP 方法,严格遵循 REST 规范.
func (c *ServerConfig) InstallRESTAPI(engine *gin.Engine) {
...
// 创建核心业务处理器
handler := handler.NewHandler(c.biz)
...
// 注册用户登录和令牌刷新接口。这2个接口比较简单,所以没有 API 版本
engine.POST("/login", handler.Login)
engine.PUT("/refresh-token", handler.RefreshToken)
authMiddlewares := []gin.HandlerFunc{}
// 注册 v1 版本 API 路由分组
v1 := engine.Group("/v1")
{
// 用户相关路由
userv1 := v1.Group("/users")
{
// 创建用户。这里要注意:创建用户是不用进行认证和授权的
userv1.POST("", handler.CreateUser)
userv1.Use(authMiddlewares...)
userv1.PUT(":userID/change-password", handler.ChangePassword) // 修改用户密码
userv1.PUT(":userID", handler.UpdateUser) // 更新用户信息
userv1.DELETE(":userID", handler.DeleteUser) // 删除用户
userv1.GET(":userID", handler.GetUser) // 查询用户详情
userv1.GET("", handler.ListUser) // 查询用户列表.
}
// 博客相关路由
postv1 := v1.Group("/posts", authMiddlewares...)
{
postv1.POST("", handler.CreatePost) // 创建博客
postv1.PUT(":postID", handler.UpdatePost) // 更新博客
postv1.DELETE("", handler.DeletePost) // 删除博客
postv1.GET(":postID", handler.GetPost) // 查询博客详情
postv1.GET("", handler.ListPost) // 查询博客列表
}
}
}
// InstallGenericAPI 注册业务无关的路由,例如 pprof、404 处理等.
func InstallGenericAPI(engine *gin.Engine) {
...
// 注册 404 路由处理
engine.NoRoute(func(c *gin.Context) {
core.WriteResponse(c, errno.ErrPageNotFound, nil)
})
}上述代码,使用 Gin 框架提供的各类路由注册方法注册了符合 REST 规范的 HTTP 路由。Gin 框架如何注册路由,请阅读 Gin GitHub 项目仓库的 README 文件。上述代码注册的 HTTP 路由见表 9-2 所示。
表 9-2 HTTP 路由注册
至此,Handler 层代码开发完成,完整代码见 feature/s19 分支。
小结(AI 自动生成并人工审核)
本文详细介绍了 miniblog 项目 Handler 层代码的实现过程,作为三层架构的最后一层,其核心职责是接收请求并将其转发给 Biz 层处理,遵循了轻量化的设计原则。
Handler 层代码分为 HTTP Handler 和 gRPC Handler 两种类型,分别处理不同类型的请求。gRPC Handler 的实现通过直接调用 Biz 层方法来完成请求转发,而 HTTP Handler 因依赖 Gin 框架,则通过使用框架内的方法解析请求参数并返回结果,同时借助封装的通用工具函数(如 HandleJSONRequest 和 HandleRequest)简化了参数解析和校验操作,提高了代码的复用性与可读性。
Handler 层代码依赖 Biz 层的接口,示例用法展示了如何实例化相关依赖并将其注入到 Handler 中,实现了各模块间的解耦。
此外,针对 HTTP 请求参数解析、默认值设置和参数校验,项目封装了 ReadRequest 方法,用以统一处理请求绑定和校验逻辑,使代码更加规范和灵活。
在路由设计上,Handler 层严格遵循 RESTful 规范,使用 Gin 框架完成了 REST API 的路由注册,通过精确的路径和方法定义实现了清晰的资源操作逻辑,为系统的功能扩展与维护奠定了良好的基础。