在使用gin框架开发web应用时,需要我们自己手动完成请求到结构体的反序列化,以及发送响应,如下:
func Handler(ctx *gin.Context) {
user := new(User)
if err := ctx.ShouldBind(&user); err != nil {
...
}
...
resp := serivce()
...
ctx.Json(http.StatusOk, resp)
}
显然,这些工作都是多余的,和业务无关的,每个handler都需要我们自己处理,非常的麻烦
为了解决这个问题,我们可以使用反射的方式来字段完成请求数据到结构体的映射;对于响应,则定义一个统一的结构体,并且让handler返回这个结构体,如下:
type Response struct {
R RespValue
Status int
}
type RespValue struct {
Data interface{} `json:"data"`
Codee int `json:"code"`
Messagee string `json:"message"`
}
func NewResponse(status, code int, message string, data interface{}) *Response {
...
}
func Handler(ctx *gin.Context, user *User) *Response {
...
resp = service()
...
return NewResponse(http.StatusOk, 0, "success", resp)
}
在注册路由时,则需要使用反射来对我们的handler进行适配,使用反射机制创建请求参数,然后将数据反序列化为对应的结构体,然后调用我们定义的handler,并且获取到返回值,调用ctx.Json来发送
这种方式方便了我们的开发,但是使用反射会对程序带来一定的性能损失(但是在这里只是简单的使用,性能损失很少),并且使用反射容易出现错误
最近在使用了bilibili的kratos框架后,给了我一些灵感,我们完全可以使用proto来定义http的路由,然后生成反序列化的结构代码,并且可以使用proto来定义返回错误码等。
因此借鉴了kratos的设计,我实现了一个小工具用来加速我的web开发
github:https://github.com/mangohow/mangokit
mangokit是一个web项目的管理工具,它的功能如下:
proto定义文件如下:
hello.proto
syntax = "proto3";
package hello.v1;
import "google/api/annotations.proto";
option go_package = "api/helloworld/v1;v1";
// 定义service
service Hello {
rpc SayHello (HelloRequest) returns (HelloReply) {
option (google.api.http) = {
get: "/hello/:name"
};
}
}
message HelloRequest {
string name = 1;
}
message HelloResponse {
string message = 2;
}
然后使用mangokit命令根据proto生成gin框架对应的路由处理器:
mangokit generate proto api
生成的文件如下:
hello.pb.go
hello_http_gin.pb.go
其中hello.pb.go
是protoc --go-out生成的,而hello_http_gin.pb.go
是我们自己写的proto插件protoc-gen-go-gin
生成的
hello_http_gin.pb.go
的代码如下:
// Code generated by protoc-gen-go-gin. DO NOT EDIT.
// versions:
// - protoc-gen-go-gin v1.0.0
// - protoc v3.20.1
// source: helloworld/v1/proto/hello.proto
package v1
import (
"context"
"net/http"
"github.com/mangohow/mangokit/serialize"
"github.com/mangohow/mangokit/transport/httpwrapper"
)
type HelloHTTPService interface {
SayHello(context.Context, *HelloRequest) (*HelloReply, error)
}
func RegisterHelloHTTPService(server *httpwrapper.Server, svc HelloHTTPService) {
server.GET("/hello/:name", _Hello_SayHello_HTTP_Handler(svc))
}
func _Hello_SayHello_HTTP_Handler(svc HelloHTTPService) httpwrapper.HandlerFunc {
return func(ctx *httpwrapper.Context) error {
in := new(HelloRequest)
if err := ctx.BindRequest(in); err != nil {
return err
}
value := context.WithValue(context.Background(), "gin-ctx", ctx)
reply, err := svc.SayHello(value, in)
if err != nil {
return err
}
ctx.JSON(http.StatusOK, serialize.Response{Data: reply})
return nil
}
}
在上面生成的go代码中,包含一个接口的定义,其中包含了我们定义的handler方法
并且提供了RegisterHelloHTTPService
函数来注册路由,注册的路由为_Hello_SayHello_HTTP_Handler
函数,在这个函数中有反序列化的代码,以及响应代码
因此我们只需要实现HelloHTTPService
中的方法,并且调用RegisterHelloHTTPService
来注册路由即可,大大的减少了我们的工作量。
这有点类似于grpc的方式。
有时候只使用http的状态码是不够的,比如200表示请求成功,但是虽然请求成功了,还可能出现其它问题。
比如一个登录的接口,用户登录时可能出现以下的情况:1、用户不存在 2、密码错误 3、用户被封禁了
因此,我们需要定义相关的一些响应码来处理这些情况
proto定义文件如下:
errors.proto
syntax = "proto3";
package errors.v1;
import "errors/errors.proto";
option go_package = "api/errors/v1;v1";
enum ErrorReason {
// 设置缺省错误码
option (errors.default_code) = 500;
Success = 0 [(errors.code) = 200];
// 为某个枚举单独设置错误码
UserNotFound = 1 [(errors.code) = 200];
UserPasswordIncorrect = 2 [(errors.code) = 200];
UserBanned = 3 [(errors.code) = 200];
}
在上面的proto文件中,我们使用enum来定义响应码,其中包括int
类型的响应码,以及返回的http状态码(errors.code)
然后使用mangokit来生成go代码:
mangokit generate proto api
生成的文件如下:
errors.pb.go
errors_errors.pb.go
其中errors.pb.go
是protoc --go_out生成的,而errors_errors.pb.go
同样也是自己编写的proto插件protoc-gen-go-error
生成的
errors_errors.pb.go
中的代码如下:
// Code generated by protoc-gen-go-error. DO NOT EDIT.
// versions:
// - protoc-gen-go-error v1.0.0
// - protoc v3.20.1
// source: errors/v1/proto/errors.proto
package v1
import (
"fmt"
"github.com/mangohow/mangokit/errors"
)
func ErrorSuccess(format string, args ...interface{}) errors.Error {
return errors.New(int32(ErrorReason_Success), 200, ErrorReason_Success.String(), fmt.Sprintf(format, args...))
}
// 为某个枚举单独设置错误码
func ErrorUserNotFound(format string, args ...interface{}) errors.Error {
return errors.New(int32(ErrorReason_UserNotFound), 200, ErrorReason_UserNotFound.String(), fmt.Sprintf(format, args...))
}
func ErrorUserPasswordIncorrect(format string, args ...interface{}) errors.Error {
return errors.New(int32(ErrorReason_UserPasswordIncorrect), 200, ErrorReason_UserPasswordIncorrect.String(), fmt.Sprintf(format, args...))
}
func ErrorUserBanned(format string, args ...interface{}) errors.Error {
return errors.New(int32(ErrorReason_UserBanned), 200, ErrorReason_UserBanned.String(), fmt.Sprintf(format, args...))
}
然后我们就可以调用这些函数来生成具体的响应码,减少我们的代码工作量
wire是谷歌开源的一款依赖注入工具,相比于其它的反射式的依赖注入方式,wire采用代码生成的方式来完成依赖注入,代码运行效率更高
代码如下:
//go:generate wire
//go:build wireinject
// +build wireinject
package main
import (
"github.com/google/wire"
"mangokit_test/internal/conf"
"mangokit_test/internal/dao"
"mangokit_test/internal/server"
"mangokit_test/internal/service"
"github.com/mangohow/mangokit/transport/httpwrapper"
"github.com/sirupsen/logrus"
)
func newApp(serverConf *conf.Server, dataConf *conf.Data, logger *logrus.Logger) (*httpwrapper.Server, func(), error) {
panic(wire.Build(dao.ProviderSet, service.ProviderSet, server.NewHttpServer))
}
根据上面的代码,wire即可自动生成依赖创建的代码:
// Code generated by Wire. DO NOT EDIT.
//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package main
import (
"mangokit_test/internal/conf"
"mangokit_test/internal/dao"
"mangokit_test/internal/server"
"mangokit_test/internal/service"
"github.com/mangohow/mangokit/transport/httpwrapper"
"github.com/sirupsen/logrus"
)
// Injectors from wire.go:
func newApp(serverConf *conf.Server, dataConf *conf.Data, logger *logrus.Logger) (*httpwrapper.Server, func(), error) {
db, cleanup, err := dao.NewFakeMysqlClient(dataConf)
if err != nil {
return nil, nil, err
}
greeterDao := dao.NewGreeterDao(db)
greeterService := service.NewGreeterService(greeterDao, logger)
httpwrapperServer := server.NewHttpServer(serverConf, logger, greeterService)
return httpwrapperServer, func() {
cleanup()
}, nil
}
同样的mangokit中也添加了相应的指令来生成wire依赖注入代码
mangokit generate wire
mangokit主要包含三个组件:
protoc-gen-go-gin
protoc-gen-go-error
mangokit
protoc-gen-go-gin
用于根据proto文件中定义的service来生成gin框架的路由代码
protoc-gen-go-error
用于根据proto文件中定义的enum来生成相应的响应错误码
mangokit中则设置了多种指令用于管理项目,比如:
在使用protoc时可以指定其它的插件用于生成代码,比如:
--go_out
则会调用protoc-gen-go插件来生成go的代码--go-grpc_out
则会调用protoc-gen-go-grpc插件来生成grpc的代码同样的,我们可以使用go来实现一个类似的插件,从而根据proto文件来生成gin框架的代码以及响应码代码
工作原理:
在使用 protoc --go-gin_out
时,protoc
会解析proto
文件,然后生成抽象语法树
,并且它会使用protobuf
将语法树
序列化为二进制序列
,然后使用标准输入将二进制序列传入我们的插件中,然后再使用protobuf
进行反序列化
,然后我们在自己的程序中就可以根据提供的信息来生成go代码,比如:proto
中定义的message、service、enum
等
开发proto插件我们可以使用google.golang.org/protobuf/compiler/protogen
库
我们可以参考kratos的代码来实现自己的代码:
https://github.com/go-kratos/kratos/tree/main/cmd/protoc-gen-go-errors
首先看main函数:
protogen.Options.Run来运行我们的程序
在传入的匿名函数中,我们会接收到protogen.Plugin参数,该参数中有proto文件中定义的各种结构的详细信息
然后我们可以遍历每个proto文件来生成相应的代码,在generateFile中生成代码
package main
import (
"flag"
"fmt"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/types/pluginpb"
)
var (
showVersion = flag.Bool("version", false, "print the version and exit")
)
func main() {
flag.Parse()
if *showVersion {
fmt.Printf("protoc-gen-go-gin %v\n", version)
return
}
protogen.Options{
ParamFunc: flag.CommandLine.Set,
}.Run(func(plugin *protogen.Plugin) error {
plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
for _, f := range plugin.Files {
if !f.Generate {
continue
}
generateFile(plugin, f)
}
return nil
})
}
在protogen.File中保存了一个proto文件中定义的各种结构解析后的信息:
详细代码参考:https://github.com/mangohow/mangokit
代码编写好之后编译为二进制程序,在使用protoc时指定插件名称,我们的插件一定要以protoc-gen
开头,在指定插件名称时指定protoc-gen
后面的部分拼接上_out
即可;比如protoc-gen-go-error
,在使用时为:protoc --go-error_out=. hello.proto
mangokit使用cobra命令行工具开发,包含以下功能:
首先使用mangokit来创建一个项目,项目目录为mangokit-test,go mod名称为mangokit_test
mangokit create mangokit-test mangokit_test
然后执行cd mangokit-test && go mod tidy
来下载依赖
项目目录结构如下:
$ tree
.
|-- api
| |-- errors
| | `-- v1
| | |-- errors.pb.go
| | |-- errors_errors.pb.go
| | `-- proto
| | `-- errors.proto
| `-- helloworld
| `-- v1
| |-- greeter.pb.go
| |-- greeter_http_gin.pb.go
| `-- proto
| `-- greeter.proto
|-- cmd
| `-- server
| |-- main.go
| |-- wire.go
| `-- wire_gen.go
|-- configs
| `-- application.yaml
|-- internal
| |-- conf
| | |-- conf.pb.go
| | `-- conf.proto
| |-- dao
| | |-- dao.go
| | |-- data.go
| | `-- userdao.go
| |-- middleware
| |-- model
| | `-- user.go
| |-- server
| | `-- http.go
| `-- service
| |-- helloservice.go
| `-- service.go
|-- pkg
|-- test
|-- third_party
|-- go.mod
|-- go.sum
|-- makefile
|-- Dockerfile
|-- openapi.yaml
32 directories, 52 files
api:
api目录用来放置proto文件以及根据proto文件生成的go代码,通常将.proto文件放在proto文件夹下,而生成的代码放在它的上一级目录,这样看起来更清晰一些cmd:
cmd目录存放了wire注入代码和main文件configs:
configs目录用来放置程序的配置文件internal:
internal用来存放本项目依赖的代码,不会暴露给其它的项目,其中包括middleware(中间件)
、model(数据库结构体模型
)、dao(数据库访问对象
)、conf(配置信息代码)
、server(服务初始化代码)
、service(service的具体实现代码)
pkg:
用来存放一些共用代码test:
存放测试代码third_party:
其中包含一些使用到的proto的扩展文件在创建项目时默认会从github拉取一个预制的项目结构,如果遇到网络问题导致无法拉取,则可以使用-r
命令来指定其它的仓库,比如使用gitee:
mangokit create -r https://gitee.com/mangohow/mangokit-template mangokit-test mangokit_test
可以使用下面的命令来添加新的proto文件
# 添加http api
mangokit add api api/helloworld/v1/proto hello.proto
然后就会在api/helloworld/v1/proto目录下生成一个hello.proto文件
syntax = "proto3";
package hello.v1;
import "google/api/annotations.proto";
option go_package = "api/helloworld/v1;v1";
service Hello {
}
使用下面的命令来添加error proto
mangokit add error api/errors/v1/proto errorReason.proto
同样的,在api/errors/v1/proto目录下生成了errorReason.proto文件
syntax = "proto3";
package errorReason.v1;
import "errors/errors.proto";
option go_package = "api/errors/v1;v1";
enum ErrorReason {
option (errors.default_code) = 500;
Placeholder = 0 [(errors.code) = 0];
}
除了添加proto文件,还可以添加预制的makefile和Dockerfile
根据proto生成代码
# 根据api目录下的proto文件生成go代码
mangokit generate proto api
根据wire依赖注入生成代码:
mangokit generate wire
生成openapi文档
mangokit generate openapi
生成上面所有的三个项目
mangokit generate all