go-zero官网
go-zero详细文档
本系列为作者跟着Mikaelemmmm的b站教学视频学习时做的笔记
api文件
语法和go类似,api文法官网
syntax语法声明:syntax="v1"
import语法块
1
2
3
4
5
6
7
|
import "foo.api"
import "foo/bar.api"
import(
"bar.api"
"foo/bar/foo.api"
)
|
info语法块
1
2
3
4
5
|
info(
foo: "foo value"
bar: "bar value"
desc: "long long long long long long text"
)
|
type语法块
- 保留了golang内置数据类bool,int,int8,int16,int32,int64,uint,uint8,uint16,uint32,uint64,uintptr,float32,float64,complex64,complex128,string,byte,rune,
- 兼容golang struct风格声明
- 保留golang关键字
tag表和tag修饰符:xxx:“yyy,zzz”,xxx位tag,yyy为值,zzz为tag修饰符
- json,path,form,header
- optional,options,default,range
service语法块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
//atServer
@server(
//声明当前service下所有路由需要jwt鉴权,且会自动生成包含jwt逻辑的代码
jwt: Auth
//声明当前service或者路由文件分组
group: user
//添加路由分组
prefix : userapi/v1
//声明当前service需要开启中间件
middleware: AuthMiddleware
)
//对外提供的api接口,service name必须一致,包括import的文件
service user-api {
//doc
@doc "获取用户信息"
//handler
@handler userInfo
get /users/info (UserInfoReq) returns (UserInfoResq)
@doc "修改用户信息"
@handler userUpdate
get /users/update (UserUpdateReq) returns (UserUpdateResq)
}
|
构建命令
1
|
goctl api go -api user.api -dir ../ -style goZero
|
开发规范
main文件
main文件的名称和api文件名称一致
查看该文件main函数上面有一个-f的flag配置项。默认使用etc里面的yaml配置文件,在启动服务的时候使用-f可以使用指定的配置文件
1
|
var configFile = flag.String("f", "etc/user-api.yaml", "the config file")
|
载入配置文件
1
2
|
var c config.Config
conf.MustLoad(*configFile, &c)//可以添加选项conf.UseEnv(),这样可以在yaml文件里面使用环境变量${xxx}
|
etc/.yaml配置文件,默认生成配置有
1
2
3
|
Name: user-api
Host: 0.0.0.0
Port: 8888
|
生成一个服务对象
1
2
|
server := rest.MustNewServer(c.RestConf)
defer server.Stop()//执行清理操作,比如关闭logx
|
内部执行SetUp(),配置logx,Prometheus(服务监控),Telemetry(jeager,链路追踪),matrics
另外这里可以给出选项来配置server,可以自己写一个选项函数
1
|
RunOption func(*Server)
|
预定义返回选项的函数,添加时可以调用,这些函数和RunOption在一个文件下
- func WithUnauthorizedCallback(callback handler.UnauthorizedCallback) RunOption:添加jwt不通过的逻辑
- func WithNotFoundHandler(handler http.Handler) RunOption:没有匹配的handler时的handler
将依赖配置到ctx
1
|
ctx := svc.NewServiceContext(c)
|
internal/svc/serviceContext.go的ServiceContext对象,逻辑里面的svcCtx就是这个对象
注册路由
1
|
handler.RegisterHandlers(server, ctx)
|
internal/handler/routes.go里面是详细路由配置
启动服务
源码
Server
1
2
3
4
5
|
Server struct {
ngin *engine
//httpx路由
router httpx.Router
}
|
engine
1
2
3
4
5
6
|
type engine struct {
...
//结构体路由
routes []featuredRoutes
...
}
|
server.Start()
1
2
3
|
func (s *Server) Start() {
handleError(s.ngin.start(s.router))
}
|
s.ngin.start(s.router)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
func (ng *engine) start(router httpx.Router) error {
//绑定结构体路由到httpx路由
if err := ng.bindRoutes(router); err != nil {
return err
}
if len(ng.conf.CertFile) == 0 && len(ng.conf.KeyFile) == 0 {
return internal.StartHttp(ng.conf.Host, ng.conf.Port, router, ng.withTimeout())
}
//启动服务,里面调用ListenAndServe
return internal.StartHttps(ng.conf.Host, ng.conf.Port, ng.conf.CertFile,
ng.conf.KeyFile, router, func(svr *http.Server) {
if ng.tlsConfig != nil {
svr.TLSConfig = ng.tlsConfig
}
}, ng.withTimeout())
}
|
ng.bindRoutes(router)
1
2
3
4
5
6
7
8
9
10
11
12
|
func (ng *engine) bindRoutes(router httpx.Router) error {
metrics := ng.createMetrics()
//绑定每组结构体路由到httpx路由
for _, fr := range ng.routes {
if err := ng.bindFeaturedRoutes(router, fr, metrics); err != nil {
return err
}
}
return nil
}
|
ng.bindFeaturedRoutes(router, fr, metrics)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
func (ng *engine) bindFeaturedRoutes(router httpx.Router, fr featuredRoutes, metrics *stat.Metrics) error {
verifier, err := ng.signatureVerifier(fr.signature)
if err != nil {
return err
}
//每组结构体路由的每个结构体路由绑定httpx路由
for _, route := range fr.routes {
if err := ng.bindRoute(fr, router, metrics, route, verifier); err != nil {
return err
}
}
return nil
}
|
ng.bindRoute(fr, router, metrics, route, verifier)
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
|
func (ng *engine) bindRoute(fr featuredRoutes, router httpx.Router, metrics *stat.Metrics,
route Route, verifier func(chain.Chain) chain.Chain) error {
chn := ng.chain
if chn == nil {
//绑定中间件链,之前是alice包,现在这个应该是自己封装的,执行路由处理之前先执行下面这些中间件
chn = chain.New(
handler.TracingHandler(ng.conf.Name, route.Path),
ng.getLogHandler(),
handler.PrometheusHandler(route.Path),
handler.MaxConns(ng.conf.MaxConns),
handler.BreakerHandler(route.Method, route.Path, metrics),
handler.SheddingHandler(ng.getShedder(fr.priority), metrics),
handler.TimeoutHandler(ng.checkedTimeout(fr.timeout)),
handler.RecoverHandler,
handler.MetricHandler(metrics),
handler.MaxBytesHandler(ng.checkedMaxBytes(fr.maxBytes)),
handler.GunzipHandler,
)
}
chn = ng.appendAuthHandler(fr, chn, verifier)
for _, middleware := range ng.middlewares {
chn = chn.Append(convertMiddleware(middleware))
}
handle := chn.ThenFunc(route.Handler)
return router.Handle(route.Method, route.Path, handle)
}
|
etc目录
etc/yaml文件,和internal/config/config.go对应
internal目录
internal/config
config文件,和etc/yaml文件对应
注意这里不能用匿名字段
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
|
package config
import (
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/rest"
"github.com/zeromicro/go-zero/zrpc"
)
type Config struct {
rest.RestConf
//即使名字一样也不能用匿名字段,可定义匿名结构体
DB struct {
DataSource string
}
Cache cache.CacheConf
Log logx.LogConf
UserRpcConf zrpc.RpcClientConf
//变量名和类型名一样
Auth Auth
}
type Auth struct {
AccessSecret string
AccessExpire string
}
|
internal/handler目录
该目录除了routes.go文件,其他的文件都按api中定义的@server.group分组
routes.go
注册路由的逻辑,和api里面定义的server对应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
//一次调用对应api中的一个@server
server.AddRoutes(
[]rest.Route{
//一个路由对应一个@server下的一个@handler
{
Method: http.MethodPost,
Path: "/user/register",
Handler: user.RegisterHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/user/login",
Handler: user.LoginHandler(serverCtx),
},
},
//选项和@server的配置有关
rest.WithPrefix("/usercenter/v1"),
)
...
}
|
AddRoutes的逻辑是
1
2
3
4
5
6
7
8
9
10
11
12
|
func (s *Server) AddRoutes(rs []Route, opts ...RouteOption) {
//对应上面的一组路由
r := featuredRoutes{
routes: rs,
}
//执行传入选项函数对这组路由
for _, opt := range opts {
opt(&r)
}
//将一组路由添加到引擎engine
s.ngin.addRoutes(r)
}
|
s.ngin.addRoutes(r)
1
2
3
4
|
func (ng *engine) addRoutes(r featuredRoutes) {
//将一组路由添加到引擎engine
ng.routes = append(ng.routes, r)
}
|
Handler函数
相当于java的controller,返回http.HandlerFunc的函数,这里会传入svc里面的那个结构体命名为ctc
1
|
func datacenterHandler(ctx *svc.ServiceContext) http.HandlerFunc
|
首先进行参数解析
1
2
3
4
5
|
var req types.UserInfoReq
if err := httpx.Parse(r, &req); err != nil {
httpx.Error(w, err)
return
}
|
然后生成对应的logic类型实例,调用logic的方法,这里同时传递了ctx,所以ctx的依赖在logic里也能够使用
1
2
|
l := user.NewUserInfoLogic(r.Context(), svcCtx)
resp, err := l.UserInfo(req)
|
internal/logic目录
写真正的业务逻辑,封装为一个结构体,它具有默认的logx,ctx以及svcCtx
业务逻辑封装成结构体的方法,结构体还有一个工厂函数,可以在其他逻辑里面通过工厂函数新建并执行逻辑函数
1
2
3
4
5
6
7
|
type UserInfoLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func (l *UserInfoLogic) UserInfo(req *types.UserInfoReq) (resp *types.UserInfoResp, err error)
|
logic函数传入的req和返回的resp分别对应type里面的类型,可以直接使用req.xxx来访问传入的数据
另一个返回值是标准库errors里的error对象,成功返回nil,出错返回err实例。
internal/middleware目录
存放api文件定义的middleware
另外internal/handler目录里面的routes.go是这样使用middleware的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
rest.WithMiddlewares(
//这里的参数是用的serverCtx结构体的TestMiddleware字段,所以需要把TestMiddleware添加到svc的serverCtx结构体中
[]rest.Middleware{serverCtx.TestMiddleware},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/user/info",
Handler: user.UserInfoHandler(serverCtx),
},
...
}
),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/userapi/v1"),
)
...
}
|
middleware需要导入到svcCtx,导入过程如下
先修改svcCtx结构体
1
2
3
4
|
type ServiceContext struct {
...
TestMiddleware rest.Middleware
}
|
再修改New函数
1
2
3
4
5
6
|
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
...
TestMiddleware: middleware.NewTestMiddleware().Handle,
}
}
|
接下来就可以修改middleware的内部逻辑了
1
2
3
4
5
6
7
|
func (m *TestMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Println("come in testMiddleware before")
next(w, r)
fmt.Println("come in testMiddleware end")
}
}
|
全局中间件
全局中间件通常放在app/common/middleware目录中,比如jwt全局中间件
一个中间件其实就是一个接收并返回http.HandleFunc对象的函数,这里将其封装到一个结构体的方法,这样能向里面传参
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
type GlobalMiddleware struct{
可加字段
}
func NewGlobalMiddleware(/*可加参数*/) *GlobalMiddleware {
return &GlobalMiddleware{/*补全字段*/}
}
func (m *GlobalMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
//可使用CommonJwtAuthMiddleware对象m的参数
fmt.Println("global before")
next(w, r)
fmt.Println("global end")
}
}
|
main中使用如下方式添加全局中间件
1
2
3
4
5
|
func main() {
...
server.Use(middleware.NewCommonJwtAuthMiddleware().Handle)
...
}
|
全局中间件比局部中间件更早执行
整体源码分析
整体源码分析
zero里面有很多基于golang …语法糖的选项模式
读取config.yaml
如果在config.yaml中使用环境变量则在main中给该函数配置选项conf.UseEnv()
1
|
conf.MustLoad(*configFile, &c, conf.UseEnv())
|
这样就可以在yaml文件里面使用${envName}格式的变量来绑定环境变量
根据配置文件生成server对象
1
|
server := rest.MustNewServer(c.RestConf)
|
Setup():日志,Prometheus,链路追踪,metrics
api调用rpc
配置
rpc配置
rpc的yaml文件需要配置直连,Etcd或者K8s,选择的方式要和api对应。这里展示Etcd,Etcd使用Key来查询服务
1
2
3
4
|
Etcd:
Hosts:
- 0.0.0.0:2379
Key: user.rpc
|
api配置
internal/config/config.go中config结构体添加zrpc.RpcClientConf字段
1
2
3
4
|
type Config struct {
...
UserRpcConf zrpc.RpcClientConf
}
|
etc/.yaml文件添加配置,下面的代码展示的是ETCD的配置方式,除了这个方式还有直连EndPoint和K8s。
Etcd使用Key来查询服务
1
2
3
4
5
|
UserRpcConf:
Etcd:
Hosts:
- 0.0.0.0:2379
Key: user.rpc
|
svc添加zrpc客户端
1
2
3
4
|
type ServiceContext struct {
...
UserRpcClient usercenter.Usercenter
}
|
根据配置c创建rpc客户端,rpc客户端的go文件在rpc那边,如果不是本地开发,需要拷贝过来
1
2
3
4
5
6
|
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
...
UserRpcClient: usercenter.NewUsercenter(zrpc.MustNewClient(c.UserRpcConf)),
}
}
|
调用
zrpc的Client可以直接调用service,传入2个参数:ctx和pb生成的Req结构体对象指针。返回的结果为pb定义的Resp结构体对象指针。所以go-zero的rpc调用非常方便
1
2
3
4
5
6
|
userResp, err := l.svcCtx.UserRpcClient.GetUserInfo(l.ctx, &pb.GetUserInfoReq{
Id: req.UserId,
})
if err != nil {
return nil, err
}
|
官方支持的api与rpc对接的三种方式
直连,Etcd和K8s
除了这三种在go-zero team里面的zero-contrib里面还有consul,nacos,polaris等注册中心的连接方式
Etcd
rpc的配置
rpc的yaml文件配置
1
2
3
4
|
Etcd:
Hosts:
- 0.0.0.0:2379
Key: user.rpc
|
具体的Etcd配置可以看config.go里面的RpcServerConf.Etcd相关的字段
api的配置
api的yaml文件配置需要与rpc中的Key对应才能实现服务发现。
1
2
3
4
5
|
UserRpcConf:
Etcd:
Hosts:
- 0.0.0.0:2379
Key: user.rpc
|
修改etc/config.go
1
2
3
4
|
type Config struct {
...
UserRpcConf zrpc.RpcClientConf
}
|
api调用rpc时是需要rpc生成的客户端目录里面的go文件
1
2
3
4
|
type ServiceContext struct {
...
UserRpcClient usercenter.Usercenter
}
|
详见调用
rpc注册到etcd后可以使用如下命令查看etcd注册的服务
1
|
etcdctl get --prefix ""
|
直连
直接配置api
1
2
3
|
UserRpcConf:
Endpoints:
- 0.0.0.0:8080
|
EndPoints是一个数组,也有负载均衡。go-zero都是使用P2C算法来进行负载均衡的。
K8s
rpc不需要配置,但是需要写k8s的yaml文件,并将rpc装载到k8s pod中
api需要配置yaml文件,将rpc设置为Target(k8s连接),值为k8s集群地址,比如下面这个例子,k8s://表示k8s协议
1
2
|
UserRpcConf:
Target: k8s://go-zero-looklook/basic-rpc-svc:9001
|
部署过程
使用goctl生成rpc和api的dockerfile
1
|
goctl docker -go user.go
|
生成rpc、api镜像同时推送到镜像仓库
生成镜像需要将build上下文必须是具有gomod文件的目录,即上下文必须是项目根目录,goctl有标记rpc或api文件相对根目录的相对路径,所以不用担心将整个项目构建
1
|
go build -t xxxx:yyy -f path/to/Dockerfile .
|
另外还有一种简单的方法
1
|
goctl docker -go .\service\user\rpc\user.go
|
其余内容见bilibili和looklook文档
api参数校验集成第三方库
默认go-zero的参数校验不够用时,可以使用第三方validator库
给结构体添加tag
1
2
3
4
5
6
|
type User struct {
FirstName string `validate:"required"`
LastName string `validate:"required"`
Age uint8 `validate:"gte=0,lte=130"`
Email string `validate:"required,email"`
}
|
生成validator对象
直接调用validator.Struct(&xxx)返回err,err会记录出错的地方,详见文档
1
2
|
err:=v.Struct(&user)
err:=v.StructCtx(r.Context,&user)
|
为了防止被覆盖,校验的tag加到api文件
可以修改模板来复用validator校验逻辑,避免每次生成都得修改文件
jwt鉴权
官方文档
api文件设置jwt:xxx
routes.go中引入jwt的代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
|
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.TestMiddleware},
[]rest.Route{
...
}...,
),
//需要在config.go里面添加Auth字段
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/userapi/v1"),
)
}
|
所以,yaml配置文件配置xxx的相关配置
1
2
3
|
Auth:
AccessSecret: $AccessSecret
AccessExpire: $AccessExpire
|
还有修改etc/config.go
1
2
3
4
5
6
7
|
type Config struct {
...
Auth struct {
AccessSecret string
AccessExpire string
}
}
|
后续通过jwt鉴权的请求携带的jwt信息可以在其他地方通过ctx访问
1
2
3
4
|
func (l *SearchLogic) Search(req types.SearchReq) (*types.SearchReply, error) {
logx.Infof("userId: %v",l.ctx.Value("userId"))// 这里的key和生成jwt token时传入的key一致
return &types.SearchReply{}, nil
}
|
定义jwt验证不通过的响应
1
2
3
|
server := rest.MustNewServer(c.RestConf, rest.WithUnauthorizedCallback(func(w http.ResponseWriter, r *http.Request, err error) {
w.WriteHeader(200)
}))
|
详见looklook
api的兼容性
api的兼容性
定义或修改API的时候一定要考虑向前兼容:
- 增加新的API接口协议
- 请求参数添加字段,需要保证新老客户端对该字段的处理方式不同
- 响应结果添加字段,该字段信息只会在新版本客户端中展示
如下几种情况是向前不兼容的:
- 删除或重命名服务、字段、方法
- 修改字段类型
- 修改现有请求的可见行为,客户端通常依赖于API行为和语义,即使这样的行为没有被明确支持或记录。因此,在大多数情况下,修改API数据的行为或语义将被消费者视为是破坏性的
- 给资源消息添加 读取/写入 字段
http.Request.Body只能读取一次
http.Request.Body是一个ioutil.NopCloser类型,只能读取一次,如果要读取多次,得再把body给塞回去:
1
2
3
4
5
6
7
8
9
10
11
|
body, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 将请求体存储到缓冲区中
buf := bytes.NewBuffer(body)
// 将缓冲区的内容写回http.Request.Body
r.Body = ioutil.NopCloser(buf)
|