定义 service:一元调用与生成的服务端/客户端骨架
message 定义数据,service 定义方法。一元调用(unary RPC)是最常用的形态:一次请求、一次响应,像调用本地函数,只是中间隔着网络。本章从一份能跑通的 account.proto 出发,把「定义 → 生成 → 服务端实现 → 客户端调用」这条链路走完;流式调用留给第 5 章。
定义 service
syntax = "proto3";
package im.account.v1;
option go_package = "example.com/im/gen/account/v1;accountv1";
service AccountService {
rpc Login (LoginReq) returns (LoginResp);
}
message LoginReq {
int64 user_id = 1;
string token = 2;
string device_id = 3;
}
message LoginResp {
string session_id = 1;
int64 uid = 2;
string nickname = 3;
}
- 方法名用大驼峰动词短语(
Login、GetProfile);请求响应消息按方法名Req、方法名Resp命名,一个方法独占一对消息(要加GetProfile就再补一对GetProfileReq/GetProfileResp),别让多个方法复用同一个 Req,否则日后各自加字段会互相牵制。 - 一元调用写成
rpc 方法名 (请求) returns (响应);,返回类型前加stream才是流式(第 5 章);版本写在 package 里(im.account.v1),加方法通常兼容,删方法要配reserved(第 15 章)。
生成出来的骨架长什么样
跑一次 buf generate(见第 3 章)后,account_grpc.pb.go 里会多出这几样东西:
| 生成物 | 作用 |
|---|---|
AccountServiceClient 接口 | 客户端可调用的方法集合 |
NewAccountServiceClient(cc grpc.ClientConnInterface) | 由连接构造客户端实例 |
AccountServiceServer 接口 | 服务端必须实现的接口,含未导出方法 mustEmbedUnimplementedAccountServiceServer() |
UnimplementedAccountServiceServer 结构体 | 所有方法的默认实现,统一返回 codes.Unimplemented |
RegisterAccountServiceServer(s grpc.ServiceRegistrar, srv AccountServiceServer) | 把实现注册进 server |
其中 AccountServiceServer 接口的方法签名是 Login(context.Context, *LoginReq) (*LoginResp, error) 这种形态(参数一律指针),并且带一个未导出方法 mustEmbedUnimplementedAccountServiceServer()——它是个前向兼容阀门:结构体里嵌入 UnimplementedAccountServiceServer 就自动满足它。以后 proto 加了方法,老实现照样编译通过,新方法统一返回 Unimplemented 状态码,而不是让调用方收到含糊的 Unknown,所以每个服务实现都必须嵌它。
服务端实现
// import 省略:context、codes、status、accountv1 "example.com/im/gen/account/v1"
// 只贴方法:Service 结构体嵌入 accountv1.UnimplementedAccountServiceServer,并持有 sessions SessionStore
func (s *Service) Login(ctx context.Context, req *accountv1.LoginReq) (*accountv1.LoginResp, error) {
if req.GetToken() == "" {
return nil, status.Error(codes.InvalidArgument, "token 不能为空")
}
sess, err := s.sessions.Verify(ctx, req.GetUserId(), req.GetToken(), req.GetDeviceId())
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "会话校验失败: %v", err)
}
return &accountv1.LoginResp{SessionId: sess.ID, Uid: sess.UID, Nickname: sess.Nickname}, nil
}
- 错误统一用
status.Error(codes.X, msg)返回:裸errors.New到客户端会退化成codes.Unknown,没法分类处理(第 6 章)。读字段统一用req.GetXxx(),即使req为 nil 也不会 panic。 - 方法内部照常传
ctx:继续向别的服务发起调用时要沿用同一个 ctx,deadline 与取消才会级联(第 7 章)。context 是生成方法的第一参数,不要自己往 message 里塞 ctx 字段。
起一个服务端
// import 省略:context、log、net、os、os/signal、syscall、grpc、accountv1、internal/account
func main() {
lis, err := net.Listen("tcp", ":9000")
if err != nil {
log.Fatalf("listen: %v", err)
}
srv := grpc.NewServer()
accountv1.RegisterAccountServiceServer(srv, account.NewService(store))
// 收到 SIGTERM 后优雅停止;生产上再加超时兜底,超时未停完就 srv.Stop()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
srv.GracefulStop()
}()
if err := srv.Serve(lis); err != nil {
log.Fatalf("serve: %v", err)
}
}
客户端调用
func Login(ctx context.Context, token string) (*accountv1.LoginResp, error) {
conn, err := grpc.NewClient("127.0.0.1:9000",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("new client: %w", err)
}
defer conn.Close()
client := accountv1.NewAccountServiceClient(conn)
resp, err := client.Login(ctx, &accountv1.LoginReq{UserId: 10086, Token: token})
if err != nil {
return nil, fmt.Errorf("login: %w", err)
}
return resp, nil
}
grpc.NewClient 是惰性的:调用它不建连接,首次 RPC 才真正连接;老代码里的 grpc.Dial、DialContext 已不推荐,连接管理以官方文档为准(第 11 章)。conn 代表一条可复用的长连接,进程内建一次全局复用——上面片段里的 defer conn.Close() 只是示例,别在每次请求里新建连接。生成的方法签名固定为 (ctx, *Req, ...grpc.CallOption),本次调用的超时由调用方用 context.WithTimeout 控制(第 7 章)。
常见坑
| 坑 | 现象 | 改法 |
|---|---|---|
忘了嵌 UnimplementedAccountServiceServer | 编译报 mustEmbedUnimplemented... | 结构体首行嵌入它,别去用 Unsafe 接口 |
| 方法签名抄错(漏 ctx、参数写成值类型) | 编译报「does not implement」 | 严格照生成接口抄签名,参数一律 *Req |
| 直接返回裸 error | 客户端只看到 Unknown | 统一 status.Error(codes.X, msg) |
每次调用都 grpc.NewClient | 连接数暴涨、握手开销大 | 启动时建一次,全局复用一个 conn |
小结:service 里一行 rpc 换回两侧的强类型骨架——服务端嵌入 Unimplemented 结构体实现接口再注册,客户端用 grpc.NewClient 建一条复用连接去调生成的方法,错误一律用 status.Error 带上状态码。