纲要
- 用户详情查询
- 基于
GetUserInfo接口实现 - 使用模型
FindOne按主键查询
- 基于
- 用户搜索功能
- 按手机号精确查询(复用
FindOneByPhone) - 按用户名模糊查询(新增
FindByName) - 按 ID 集合批量查询(新增
FindByIds)
- 按手机号精确查询(复用
- 统一错误处理体系
- 自定义错误码与描述映射
- 基于
go-zero的errx包创建标准错误对象 - RPC 拦截器实现错误日志记录与客户端友好响应
- 在业务逻辑中替换原始错误,应用统一错误码
用户详情查询
在 user.proto 中已定义 GetUserInfo 方法,其请求参数为 uid,响应为用户模型。业务逻辑非常简单:根据 uid 调用模型层的 FindOne 方法,若用户不存在则返回"用户不存在"错误。
在 apps/user/rpc/internal/logic/getuserinfologic.go 中实现:
go
package logic
import (
"context"
"errors"
"im-system/apps/user/rpc/internal/svc"
"im-system/apps/user/rpc/user"
"im-system/common/model"
"github.com/zeromicro/go-zero/core/stores/sqlx"
"github.com/zeromicro/go-zero/core/logx"
)
type GetUserInfoLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
logx.Logger
}
func NewGetUserInfoLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserInfoLogic {
return &GetUserInfoLogic{
ctx: ctx,
svcCtx: svcCtx,
Logger: logx.WithContext(ctx),
}
}
var ErrUserNotFound = errors.New("用户不存在")
func (l *GetUserInfoLogic) GetUserInfo(in *user.GetUserInfoRequest) (*user.GetUserInfoResponse, error) {
u, err := l.svcCtx.UserModel.FindOne(l.ctx, in.Uid)
if err != nil {
if errors.Is(err, sqlx.ErrNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return &user.GetUserInfoResponse{
Uid: u.Uid,
Username: u.Username,
Phone: u.Phone,
Avatar: u.Avatar,
Gender: int64(u.Gender),
}, nil
}
用户搜索功能
搜索接口 FindUser 支持三种方式:手机号精确查询、用户名模糊查询、ID 集合批量查询。模型层需要提供对应的方法。
模型扩展
在 common/model/usermodel_ext.go 中新增方法:
go
package model
import (
"context"
"fmt"
"strings"
"github.com/zeromicro/go-zero/core/stores/sqlx"
)
// FindByName 按用户名模糊查询
func (m *defaultUserModel) FindByName(ctx context.Context, name string) ([]*User, error) {
query := fmt.Sprintf("select %s from %s where username like ? limit 20", userRows, m.table)
var resp []*User
err := m.conn.QueryRowsCtx(ctx, &resp, query, "%"+name+"%")
return resp, err
}
// FindByIds 按ID集合批量查询
func (m *defaultUserModel) FindByIds(ctx context.Context, ids []string) ([]*User, error) {
if len(ids) == 0 {
return nil, nil
}
placeholders := strings.Repeat("?,", len(ids))
placeholders = placeholders[:len(placeholders)-1]
query := fmt.Sprintf("select %s from %s where uid in (%s)", userRows, m.table, placeholders)
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
var resp []*User
err := m.conn.QueryRowsCtx(ctx, &resp, query, args...)
return resp, err
}
注意:userRows 为生成代码中定义的字段列表常量,直接使用即可。
业务逻辑实现
在 finduserlogic.go 中,根据请求参数的不同,路由到对应的查询方法。
go
package logic
import (
"context"
"im-system/apps/user/rpc/internal/svc"
"im-system/apps/user/rpc/user"
"im-system/common/model"
"github.com/zeromicro/go-zero/core/logx"
)
type FindUserLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
logx.Logger
}
func NewFindUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FindUserLogic {
return &FindUserLogic{
ctx: ctx,
svcCtx: svcCtx,
Logger: logx.WithContext(ctx),
}
}
func (l *FindUserLogic) FindUser(in *user.FindUserRequest) (*user.FindUserResponse, error) {
var users []*model.User
var err error
switch {
case in.Phone != "":
// 手机号精确查询
u, e := l.svcCtx.UserModel.FindOneByPhone(l.ctx, in.Phone)
if e == nil {
users = append(users, u)
}
err = e
case in.Username != "":
// 用户名模糊查询
users, err = l.svcCtx.UserModel.FindByName(l.ctx, in.Username)
case len(in.Ids) > 0:
// ID集合查询
users, err = l.svcCtx.UserModel.FindByIds(l.ctx, in.Ids)
default:
return nil, errors.New("搜索条件不能为空")
}
if err != nil {
return nil, err
}
// 转换为响应结构体
resp := &user.FindUserResponse{}
for _, u := range users {
resp.Users = append(resp.Users, &user.GetUserInfoResponse{
Uid: u.Uid,
Username: u.Username,
Phone: u.Phone,
Avatar: u.Avatar,
Gender: int64(u.Gender),
})
}
return resp, nil
}
统一错误处理设计
随着服务增多,业务错误若直接返回原始 error,既不利于日志追踪,也不便于客户端统一解析。我们需要构建一套标准错误体系:包含错误码、描述、日志记录,并通过 RPC 拦截器自动处理。
错误码定义
在 common/errx 包中定义错误码与描述映射。
go
package errx
const (
// 通用错误码,前三位为业务标识,后三位为具体错误
CodeServerError = 100001
CodeParamInvalid = 100002
CodeDBError = 100003
CodeUserNotFound = 100004
CodeUserExists = 100005
CodePasswordWrong = 100006
CodeTokenGenerateErr = 100007
)
var codeMsg = map[int]string{
CodeServerError: "服务器内部错误",
CodeParamInvalid: "请求参数有误",
CodeDBError: "数据库繁忙,请稍后再试",
CodeUserNotFound: "用户不存在",
CodeUserExists: "手机号已注册",
CodePasswordWrong: "密码错误",
CodeTokenGenerateErr: "令牌生成失败",
}
func GetMsg(code int) string {
if msg, ok := codeMsg[code]; ok {
return msg
}
return codeMsg[CodeServerError]
}
自定义错误对象
利用 go-zero 提供的 github.com/zeromicro/go-zero/core/errorx 包(或 errx),创建带有错误码的错误类型。在 common/errx 中继续封装。
go
package errx
import (
"github.com/zeromicro/go-zero/core/errorx"
)
// NewCodeError 创建带错误码的错误
func NewCodeError(code int) error {
return errorx.NewCodeError(code, GetMsg(code))
}
// NewCodeErrorf 创建带错误码和自定义描述的错误(描述用于日志,错误码对应信息返回给客户端)
func NewCodeErrorf(code int, format string, args ...interface{}) error {
return errorx.NewCodeErrorf(code, GetMsg(code), format, args...)
}
// 快捷错误方法
func DBError() error {
return NewCodeError(CodeDBError)
}
func UserNotFound() error {
return NewCodeError(CodeUserNotFound)
}
func UserExists() error {
return NewCodeError(CodeUserExists)
}
func PasswordWrong() error {
return NewCodeError(CodePasswordWrong)
}
func TokenGenerateErr() error {
return NewCodeError(CodeTokenGenerateErr)
}
业务逻辑中使用统一错误
以登录逻辑为例,将所有直接返回 errors.New 的地方替换为 errx 中的错误。
go
func (l *LoginLogic) Login(in *user.LoginRequest) (*user.LoginResponse, error) {
u, err := l.svcCtx.UserModel.FindOneByPhone(l.ctx, in.Phone)
if err != nil {
if errors.Is(err, sqlx.ErrNotFound) {
return nil, errx.UserNotFound() // 使用统一错误
}
return nil, errx.NewCodeErrorf(errx.CodeDBError, "根据手机号查询用户失败: %v", err)
}
if !crypt.CheckPassword(u.Password, in.Password) {
return nil, errx.PasswordWrong()
}
expire := time.Duration(l.svcCtx.Config.JWT.ExpireInSeconds) * time.Second
tokenStr, err := jwt.GenerateToken(u.Uid, []byte(l.svcCtx.Config.JWT.Secret), expire)
if err != nil {
return nil, errx.NewCodeErrorf(errx.CodeTokenGenerateErr, "生成token失败: %v", err)
}
return &user.LoginResponse{
Uid: u.Uid,
Token: tokenStr,
}, nil
}
RPC 拦截器统一处理
为将错误码信息写入 gRPC 响应的 status 中,同时将详细错误记录到日志,我们编写一个 gRPC 拦截器。
在 common/interceptor/rpc_err_interceptor.go 中:
go
package interceptor
import (
"context"
"im-system/common/errx"
"github.com/zeromicro/go-zero/core/errorx"
"github.com/zeromicro/go-zero/core/logx"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// RpcErrInterceptor 服务端统一错误拦截器
func RpcErrInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
resp, err := handler(ctx, req)
if err == nil {
return resp, nil
}
// 记录日志
logx.WithContext(ctx).Errorf("rpc err: %v", err)
// 尝试提取自定义错误码
if codeErr := errorx.CodeErrorFrom(err); codeErr != nil {
// 构造 gRPC 状态,将错误码和消息返回给客户端
return nil, status.Errorf(codes.Code(codeErr.Code), codeErr.Error())
}
// 非自定义错误,统一返回内部错误
return nil, status.Errorf(codes.Internal, errx.GetMsg(errx.CodeServerError))
}
然后在服务启动时注册拦截器,在 apps/user/rpc/user.go 或对应的 main 函数中:
go
package main
import (
"flag"
"fmt"
"im-system/apps/user/rpc/internal/config"
"im-system/apps/user/rpc/internal/server"
"im-system/apps/user/rpc/internal/svc"
"im-system/apps/user/rpc/user"
"im-system/common/interceptor"
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/zrpc"
"google.golang.org/grpc"
)
var configFile = flag.String("f", "etc/user.yaml", "the config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
s := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
user.RegisterUserServer(grpcServer, server.NewUserServer(svc.NewServiceContext(c)))
}, zrpc.WithUnaryServerInterceptor(interceptor.RpcErrInterceptor))
defer s.Stop()
fmt.Printf("Starting rpc server at %s...\n", c.ListenOn)
s.Start()
}
这一段是否有问题啊?
错误处理流程
Model Logic Interceptor Client Model Logic Interceptor Client #mermaid-svg-h8TJezq9qNmzgFl6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-h8TJezq9qNmzgFl6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-h8TJezq9qNmzgFl6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-h8TJezq9qNmzgFl6 .error-icon{fill:#552222;}#mermaid-svg-h8TJezq9qNmzgFl6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-h8TJezq9qNmzgFl6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-h8TJezq9qNmzgFl6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-h8TJezq9qNmzgFl6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-h8TJezq9qNmzgFl6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-h8TJezq9qNmzgFl6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-h8TJezq9qNmzgFl6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-h8TJezq9qNmzgFl6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-h8TJezq9qNmzgFl6 .marker.cross{stroke:#333333;}#mermaid-svg-h8TJezq9qNmzgFl6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-h8TJezq9qNmzgFl6 p{margin:0;}#mermaid-svg-h8TJezq9qNmzgFl6 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-h8TJezq9qNmzgFl6 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-h8TJezq9qNmzgFl6 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-h8TJezq9qNmzgFl6 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-h8TJezq9qNmzgFl6 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-h8TJezq9qNmzgFl6 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-h8TJezq9qNmzgFl6 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-h8TJezq9qNmzgFl6 .sequenceNumber{fill:white;}#mermaid-svg-h8TJezq9qNmzgFl6 #sequencenumber{fill:#333;}#mermaid-svg-h8TJezq9qNmzgFl6 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-h8TJezq9qNmzgFl6 .messageText{fill:#333;stroke:none;}#mermaid-svg-h8TJezq9qNmzgFl6 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-h8TJezq9qNmzgFl6 .labelText,#mermaid-svg-h8TJezq9qNmzgFl6 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-h8TJezq9qNmzgFl6 .loopText,#mermaid-svg-h8TJezq9qNmzgFl6 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-h8TJezq9qNmzgFl6 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-h8TJezq9qNmzgFl6 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-h8TJezq9qNmzgFl6 .noteText,#mermaid-svg-h8TJezq9qNmzgFl6 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-h8TJezq9qNmzgFl6 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-h8TJezq9qNmzgFl6 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-h8TJezq9qNmzgFl6 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-h8TJezq9qNmzgFl6 .actorPopupMenu{position:absolute;}#mermaid-svg-h8TJezq9qNmzgFl6 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-h8TJezq9qNmzgFl6 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-h8TJezq9qNmzgFl6 .actor-man circle,#mermaid-svg-h8TJezq9qNmzgFl6 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-h8TJezq9qNmzgFl6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} gRPC RequestDB QueryError (sqlx.ErrNotFound)Wrap with errx.UserNotFound()Return CodeErrorLog error detailsgRPC status with Code and Message
拦截器捕获所有从 handler 返回的错误,记录完整错误堆栈到日志,同时向客户端返回仅包含错误码和友好描述的 gRPC 状态,既方便运维排查,又避免敏感信息泄露。
测试验证
完成上述编码后,启动用户服务,使用 grpcurl 或单元测试验证:
- 查询存在的用户,返回正确信息。
- 查询不存在的用户,返回 gRPC 状态码为
NotFound或自定义错误码,消息为"用户不存在"。 - 搜索功能:分别测试手机号、用户名、ID 集合三种情况,确认结果正确。
- 登录错误:输入错误密码,返回"密码错误"。
总结
至此,用户服务的搜索、详情查询及统一错误处理体系已构建完成。这套错误处理模式可快速复制到社交服务、IM 服务等其他微服务中,确保整个系统的可维护性与用户体验一致性。