微服务从代码到k8s部署应有尽有系列(十、错误处理)
我们用一个系列来讲解从需求到上线、从代码到 k8s 部署、从日志到监控等各个方面的微服务完整实践。
整个项目使用了 go-zero 开发的微服务,基本包含了 go-zero 以及相关 go-zero 作者开发的一些中间件,所用到的技术栈基本是 go-zero 项目组的自研组件,基本是 go-zero 全家桶了。
实战项目地址:github.com/Mikaelemmmm/go-zero-loo...
1、概述#
我们在平时开发时候,程序在出错时,希望可以通过错误日志能快速定位问题(那么传递进来的参数、包括堆栈信息肯定就要都要打印到日志),但同时又想返回给前端用户比较友善、能看得懂的错误提示,那这两点如果只通过一个 fmt.Error、errors.new 等返回一个错误信息肯定是无法做到的,除非在返回前端错误提示的地方同时在记录 log,这样的话日志满天飞,代码难看不说,日志到时候也会很难看。
那么我们想一下,如果有一个统一的地方记录日志,同时在业务代码中只需要一个 return err 就能将返回给前端的错误提示信息、日志记录相信信息分开提示跟记录,如果按照这个思路实现,那简直不要太爽,是的 go-zero-looklook 就是这么处理的,接下来我们看下。
2、rpc 错误处理#
按照正常情况下,go-zero 的 rpc 服务是基于 grpc 的,默认返回的错误是 grpc 的 status.Error 没法给我们自定义的错误合并,并且也不适合我们自定义的错误,它的错误码、错误类型都是定义死在 grpc 包中的,ok ,如果我们在 rpc 中能用自定义错误返回,然后在拦截器统一返回时候转成 grpc 的 status.Error , 那么我们 rpc 的 err 跟 api 的 err 是不是可以统一管理我们自己的错误了呢?
我们看一下 grpc 的 status.Error 的 code 里面是什么
package codes // import "google.golang.org/grpc/codes"
import (
"fmt"
"strconv"
)
// A Code is an unsigned 32-bit error code as defined in the gRPC spec.
type Code uint32
.......
grpc 的 err 对应的错误码其实就是一个 uint32 , 我们自己定义错误用 uint32 然后在 rpc 的全局拦截器返回时候转成 grpc 的 err,就可以了
所以我们自己定义全局错误码在 app/common/xerr
errCode.go
package xerr
// 成功返回
const OK uint32 = 200
// 前3位代表业务,后三位代表具体功能
// 全局错误码
const SERVER_COMMON_ERROR uint32 = 100001
const REUQES_PARAM_ERROR uint32 = 100002
const TOKEN_EXPIRE_ERROR uint32 = 100003
const TOKEN_GENERATE_ERROR uint32 = 100004
const DB_ERROR uint32 = 100005
// 用户模块
errMsg.go
package xerr
var message map[uint32]string
func init() {
message = make(map[uint32]string)
message[OK] = "SUCCESS"
message[SERVER_COMMON_ERROR] = "服务器开小差啦,稍后再来试一试"
message[REUQES_PARAM_ERROR] = "参数错误"
message[TOKEN_EXPIRE_ERROR] = "token失效,请重新登陆"
message[TOKEN_GENERATE_ERROR] = "生成token失败"
message[DB_ERROR] = "数据库繁忙,请稍后再试"
}
func MapErrMsg(errcode uint32) string {
if msg, ok := message[errcode]; ok {
return msg
} else {
return "服务器开小差啦,稍后再来试一试"
}
}
func IsCodeErr(errcode uint32) bool {
if _, ok := message[errcode]; ok {
return true
} else {
return false
}
}
errors.go
package xerr
import "fmt"
// 常用通用固定错误
type CodeError struct {
errCode uint32
errMsg string
}
// 返回给前端的错误码
func (e *CodeError) GetErrCode() uint32 {
return e.errCode
}
// 返回给前端显示端错误信息
func (e *CodeError) GetErrMsg() string {
return e.errMsg
}
func (e *CodeError) Error() string {
return fmt.Sprintf("ErrCode:%d,ErrMsg:%s", e.errCode, e.errMsg)
}
func NewErrCodeMsg(errCode uint32, errMsg string) *CodeError {
return &CodeError{errCode: errCode, errMsg: errMsg}
}
func NewErrCode(errCode uint32) *CodeError {
return &CodeError{errCode: errCode, errMsg: MapErrMsg(errCode)}
}
func NewErrMsg(errMsg string) *CodeError {
return &CodeError{errCode: SERVER_COMMON_ERROR, errMsg: errMsg}
}
比如我们在用户注册时候的 rpc 代码
package logic
import (
"context"
"looklook/app/identity/cmd/rpc/identity"
"looklook/app/usercenter/cmd/rpc/internal/svc"
"looklook/app/usercenter/cmd/rpc/usercenter"
"looklook/app/usercenter/model"
"looklook/common/xerr"
"github.com/pkg/errors"
"github.com/tal-tech/go-zero/core/logx"
"github.com/tal-tech/go-zero/core/stores/sqlx"
)
var ErrUserAlreadyRegisterError = xerr.NewErrMsg("该用户已被注册")
type RegisterLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
logx.Logger
}
func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic {
return &RegisterLogic{
ctx: ctx,
svcCtx: svcCtx,
Logger: logx.WithContext(ctx),
}
}
func (l *RegisterLogic) Register(in *usercenter.RegisterReq) (*usercenter.RegisterResp, error) {
user, err := l.svcCtx.UserModel.FindOneByMobile(in.Mobile)
if err != nil && err != model.ErrNotFound {
return nil, errors.Wrapf(xerr.ErrDBError, "mobile:%s,err:%v", in.Mobile, err)
}
if user != nil {
return nil, errors.Wrapf(ErrUserAlreadyRegisterError, "用户已经存在 mobile:%s,err:%v", in.Mobile, err)
}
var userId int64
if err := l.svcCtx.UserModel.Trans(func(session sqlx.Session) error {
user := new(model.User)
user.Mobile = in.Mobile
user.Nickname = in.Nickname
insertResult, err := l.svcCtx.UserModel.Insert(session, user)
if err != nil {
return errors.Wrapf(xerr.ErrDBError, "err:%v,user:%+v", err, user)
}
lastId, err := insertResult.LastInsertId()
if err != nil {
return errors.Wrapf(xerr.ErrDBError, "insertResult.LastInsertId err:%v,user:%+v", err, user)
}
userId = lastId
userAuth := new(model.UserAuth)
userAuth.UserId = lastId
userAuth.AuthKey = in.AuthKey
userAuth.AuthType = in.AuthType
if _, err := l.svcCtx.UserAuthModel.Insert(session, userAuth); err != nil {
return errors.Wrapf(xerr.ErrDBError, "err:%v,userAuth:%v", err, userAuth)
}
return nil
}); err != nil {
return nil, err
}
// 2、生成token.
resp, err := l.svcCtx.IdentityRpc.GenerateToken(l.ctx, &identity.GenerateTokenReq{
UserId: userId,
})
if err != nil {
return nil, errors.Wrapf(ErrGenerateTokenError, "IdentityRpc.GenerateToken userId : %d , err:%+v", userId, err)
}
return &usercenter.RegisterResp{
AccessToken: resp.AccessToken,
AccessExpire: resp.AccessExpire,
RefreshAfter: resp.RefreshAfter,
}, nil
}
errors.Wrapf(ErrUserAlreadyRegisterError, "用户已经存在 mobile:%s,err:%v", in.Mobile, err)
这里我们使用 go 默认的 errors 的包的 errors.Wrapf ( 如果这里不明白就去查一下 go 的 errors 包下的 Wrap、 Wrapf 等)
第一个参数, ErrUserAlreadyRegisterError 定义在上方 就是使用 xerr.NewErrMsg (“该用户已被注册”) , 返回给前端友好的提示,要记住这里用的是我们 xerr 包下的方法
第二个参数,就是记录在服务器日志,可以写详细一点都没关系只会记录在服务器不会被返回给前端
那我们来看看为什么第一个参数就能是返回给前端的,第二个参数就是记录日志的
⚠️【注】我们在 rpc 的启动文件 main 方法中,加了 grpc 的全局拦截器,这个很重要 ,如果不加这个没办法实现
package main
......
func main() {
........
//rpc log,grpc的全局拦截器
s.AddUnaryInterceptors(rpcserver.LoggerInterceptor)
.......
}
我们看看 rpcserver.LoggerInterceptor 的具体实现
import (
...
"github.com/pkg/errors"
)
func LoggerInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
resp, err = handler(ctx, req)
if err != nil {
causeErr := errors.Cause(err) // err类型
if e, ok := causeErr.(*xerr.CodeError); ok { //自定义错误类型
logx.WithContext(ctx).Errorf("【RPC-SRV-ERR】 %+v", err)
//转成grpc err
err = status.Error(codes.Code(e.GetErrCode()), e.GetErrMsg())
} else {
logx.WithContext(ctx).Errorf("【RPC-SRV-ERR】 %+v", err)
}
}
return resp, err
}
当有请求进入到 rpc 服务时候,先进入拦截器然后就是执行 handler 方法,如果你想在进入之前处理某些事情就可以写在 handler 方法之前,那我们想处理的是返回结果如果有错误的情况,所以我们在 handler 下方使用了 github.com/pkg/errors 这个包,这个包处理错误是 go 中经常用到的这不是官方的 errors 包,但是设计的很好,go 官方的 Wrap、Wrapf 等就是借鉴了这个包的思路。
因为我们 grpc 内部业务在返回错误时候
1)如果是我们自己业务错误,我们会统一用 xerr 生成错误,这样就可以拿到我们定义的错误信息,因为前面我们自己错误也是用的 uint32,所以在这里统一转成 grpc 错误 err = status.Error (codes.Code (e.GetErrCode ()), e.GetErrMsg ()),那这里获取到的,e.GetErrCode () 就是我们定义的 code,e.GetErrMsg () 就是我们之前定义返回的错误第二个参数
2)但是还有一种情况是 rpc 服务异常了底部抛出来的错误,本身就是 grpc 错误了,那这种的我们直接就记录异常就好了
3、api 错误#
当我们 api 在 logic 中调用 rpc 的 Register 时候,rpc 返回了上面第 2 步的错误信息 代码如下
......
func (l *RegisterLogic) Register(req types.RegisterReq) (*types.RegisterResp, error) {
registerResp, err := l.svcCtx.UsercenterRpc.Register(l.ctx, &usercenter.RegisterReq{
Mobile: req.Mobile,
Nickname: req.Nickname,
AuthKey: req.Mobile,
AuthType: model.UserAuthTypeSystem,
})
if err != nil {
return nil, errors.Wrapf(err, "req: %+v", req)
}
var resp types.RegisterResp
_ = copier.Copy(&resp, registerResp)
return &resp, nil
}
这里同样是使用标准包的 errors.Wrapf , 也就是说所有我们业务中返回错误都适用标准包的 errors,但是内部参数要使用我们 xerr 定义的错误
这里有 2 个注意点
1)api 服务想把 rpc 返回给前端友好的错误提示信息,我们想直接返回给前端不做任何处理(比如 rpc 已经返回了 “用户已存在”,api 不想做什么处理,就想把这个错误信息直接返回给前端)
针对这种情况,直接就像上图这种写就可以了,将 rpc 调用处的 err 直接作为 errors.Wrapf 第一个参数扔出去,但是第二个参数最好记录一下自己需要的详细日志方便后续在 api log 里查看
2)api 服务不管 rpc 返回的是什么错误信息,我就想自己再重新定义给前端返回错误信息(比如 rpc 已经返回了 “用户已存在”,api 想调用 rpc 时只要有错误我就返回给前端 “用户注册失败”)
针对这种情况,如下这样写即可(当然你可以将 xerr.NewErrMsg (“用户注册失败”) 放到代码上方使用一个变量,这里放变量也可以)
func (l *RegisterLogic) Register(req types.RegisterReq) (*types.RegisterResp, error) {
.......
if err != nil {
return nil, errors.Wrapf(xerr.NewErrMsg("用户注册失败"), "req: %+v,rpc err:%+v", req,err)
}
.....
}
接下来我们看最终返回给前端怎么处理的,我们接着看 app/usercenter/cmd/api/internal/handler/user/registerHandler.go
func RegisterHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.RegisterReq
if err := httpx.Parse(r, &req); err != nil {
httpx.Error(w, err)
return
}
l := user.NewRegisterLogic(r.Context(), ctx)
resp, err := l.Register(req)
result.HttpResult(r, w, resp, err)
}
}
这里可以看到,go-zero-looklook 生成的 handler 代码 有 2 个地方跟默认官方的 goctl 生成的代码不一样,就是在处理错误处理的时候,这里替换成我们自己的错误处理了,在 common/result/httpResult.go
【注】有人会说,每次使用 goctl 都要过来手动改,那不是要麻烦死了,这里我们使用 go-zero 给我们提供的 template 模版功能(还不知道这个的就要去官方文档学习一下了),修改一下 handler 生成模版即可,整个项目的模版文件放在 deploy/goctl 下,这里 hanlder 修改的模版在 deploy/goctl/1.2.3-cli/api/handler.tpl
ParamErrorResult 很简单,专门处理参数错误的
// http 参数错误返回
func ParamErrorResult(r *http.Request, w http.ResponseWriter, err error) {
errMsg := fmt.Sprintf("%s ,%s", xerr.MapErrMsg(xerr.REUQES_PARAM_ERROR), err.Error())
httpx.WriteJson(w, http.StatusBadRequest, Error(xerr.REUQES_PARAM_ERROR, errMsg))
}
我们主要来看 HttpResult , 业务返回的错误处理的
// http返回
func HttpResult(r *http.Request, w http.ResponseWriter, resp interface{}, err error) {
if err == nil {
// 成功返回
r := Success(resp)
httpx.WriteJson(w, http.StatusOK, r)
} else {
// 错误返回
errcode := xerr.SERVER_COMMON_ERROR
errmsg := "服务器开小差啦,稍后再来试一试"
causeErr := errors.Cause(err) // err类型
if e, ok := causeErr.(*xerr.CodeError); ok {
// 自定义错误类型
// 自定义CodeError
errcode = e.GetErrCode()
errmsg = e.GetErrMsg()
} else {
if gstatus, ok := status.FromError(causeErr); ok {
// grpc err错误
grpcCode := uint32(gstatus.Code())
if xerr.IsCodeErr(grpcCode) {
// 区分自定义错误跟系统底层、db等错误,底层、db错误不能返回给前端
errcode = grpcCode
errmsg = gstatus.Message()
}
}
}
logx.WithContext(r.Context()).Errorf("【API-ERR】 : %+v ", err)
httpx.WriteJson(w, http.StatusBadRequest, Error(errcode, errmsg))
}
}
err : 要记录的日志错误
errcode : 返回给前端的错误码
errmsg :返回给前端的友好的错误提示信息
成功直接返回,如果遇到错误了,也是使用 github.com/pkg/errors 这个包来判断错误,是不是我们自己定义的错误(api 中定义的错误直接使用我们自己定义的 xerr),还是 grpc 错误(rpc 业务抛出来的),如果是 grpc 错误在通过 uint32 转成我们自己错误码,根据错误码再去我们自己定义错误信息中找到定义的错误信息返回给前端,如果是 api 错误直接返回给前端我们自己定义的错误信息,都找不到那就返回默认错误 “服务器开小差了” ,
4、结尾#
到这里错误处理已经消息描述清楚了,接下来我们要看打印了服务端的错误日志,我们该如何收集查看,就涉及到日志收集系统。
项目地址#
欢迎使用 go-zero
并 star 支持我们!
微信交流群#
关注『微服务实践』公众号并点击 交流群 获取社区群二维码。
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: