279 lines
12 KiB
Markdown
279 lines
12 KiB
Markdown
---
|
||
tags: [gRPC, Go, Server, Service]
|
||
create time: 2026-05-13 23:25
|
||
---
|
||
|
||
# Service Registration — 服务注册详解
|
||
|
||
## 概述
|
||
|
||
Service Registration 是把你的业务实现"告诉"gRPC server 的过程。你调用一次 `RegisterXxxServer()`,proto 编译器生成的元数据(`ServiceDesc`)就会被写入 server 内部的 dispatch table——此后每个到达的请求都能被正确路由到你的 handler。
|
||
|
||
本文聚焦三个核心问题:
|
||
1. Proto 到底生成了什么?ServiceDesc 在其中扮演什么角色?
|
||
2. 怎么把多个 service 注册到一个 server?底层路由怎么走?
|
||
3. 注册函数内部原理是什么?为什么签名是 `ServiceRegistrar` 而不是 `*grpc.Server`?
|
||
|
||
> [!tip] 前置知识
|
||
> 阅读本文建议先了解 [[hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成]](proto 生成机制)和 [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/00-Unimplemented 零-Stub 模式]](Unimplemented 基类的作用)。
|
||
|
||
## Proto 生成的三类关键符号
|
||
|
||
看一段最普通的 proto 定义:
|
||
|
||
```proto
|
||
service UserService {
|
||
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
|
||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||
}
|
||
```
|
||
|
||
运行 protoc 后,与你接下来要写的**业务代码**直接相关的有三类符号:
|
||
|
||
| 生成物 | 类型 | 你在业务代码中的用法 |
|
||
|--------|------|---------------------|
|
||
| `UserServiceServer` | interface | **嵌入 Unimplemented + 实现方法**来创建实现体 |
|
||
| `UnimplementedUserServiceServer` | struct | 嵌入到你自己的 struct 中获取默认行为 |
|
||
| `RegisterUserServiceServer()` | 函数 | 在 server 启动时调用,完成注册 |
|
||
| `UserService_ServiceDesc` | `grpc.ServiceDesc` | 自动注入到 Register 函数中,**你不需要直接操作它** |
|
||
|
||
### 生成的 Interface —— 封闭接口技巧
|
||
|
||
```go
|
||
type UserServiceServer interface {
|
||
CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error)
|
||
GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error)
|
||
mustEmbedUnimplementedUserServiceServer() // ← 未导出
|
||
}
|
||
```
|
||
|
||
`mustEmbedUnimplementedUserServiceServer()` 是**未导出方法**(首字母小写),这就是 Go 社区经典的"封闭接口"技巧——其他任何想满足此接口的结构体必须显式嵌入 `UnimplementedUserServiceServer`,否则编译失败。
|
||
|
||
> [!question] 为什么这么设计?
|
||
> Proto 文件随时可能新增 RPC 方法。如果没有这个强制嵌入规则,开发者可能在升级 proto 后忘记补上新方法,导致线上服务静默返回错误。封闭接口把"漏实现"变成编译错误,把风险挡在开发阶段。
|
||
|
||
详细的零-Stub 模式机制参见 [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/00-Unimplemented 零-Stub 模式]]。
|
||
|
||
### ServiceDesc —— 注册的真正核心
|
||
|
||
`ServiceDesc`(Service Descriptor)是所有注册逻辑的**元数据枢纽**。它描述了 service 的名称、所有方法的签名、处理函数等完整信息。Register 函数的本质就是把 ServiceDesc 塞进 server 的 internal map:
|
||
|
||
```go
|
||
var UserService_ServiceDesc = grpc.ServiceDesc{
|
||
ServiceName: "user.v1.UserService", // 全限定名,用于路由匹配
|
||
HandlerType: (*UserServiceServer)(nil), // 接口类型(仅用于类型检查)
|
||
Methods: []grpc.MethodDesc{ // Unary / Streaming 方法列表
|
||
{
|
||
MethodName: "CreateUser",
|
||
Handler: _UserCreateUser_Handler, // 实际的分发函数
|
||
},
|
||
{
|
||
MethodName: "GetUser",
|
||
Handler: _UserGetUser_Handler,
|
||
},
|
||
},
|
||
Streams: []grpc.StreamDesc{}, // 流式方法(本例没有)
|
||
Metadata: "user/v1/user.proto",
|
||
}
|
||
```
|
||
|
||
关键点:
|
||
- **`ServiceName`** 决定了请求的路由前缀——客户端调用的完整方法名就是 `user.v1.UserService/CreateUser`
|
||
- **`Methods`** 里列出了所有注册的服务方法,每个方法对应一个 `_XXX_Handler` 分发函数
|
||
- **`HandlerType`** 是个 nil 指针,纯粹用于编译期类型检查——确保传给 Register 的对象确实实现了该 interface
|
||
|
||
## 注册函数的内部原理
|
||
|
||
很多人以为 `RegisterXxxServer(s, srv)` 只是简单地把对象存起来。实际上它做了两件事:
|
||
|
||
```go
|
||
// 简化后的源码逻辑
|
||
func RegisterUserServiceServer(s grpc.ServiceRegistrar, srv UserServiceServer) {
|
||
s.RegisterService(&UserService_ServiceDesc, srv)
|
||
}
|
||
```
|
||
|
||
一行代码——把 ServiceDesc 和你的实现一起交给 server。但真正的逻辑在 `s.RegisterService()` 里面:
|
||
|
||
1. **校验**:检查 method name 是否已被注册过(重复注册会导致 panic)
|
||
2. **封装**:用你的 `srv` 实例包装 `_XXX_Handler` 分发函数,绑定上下文
|
||
3. **存储**:写入 server 内部的 `map[string]*method` dispatch table
|
||
|
||
### 为什么签名是 ServiceRegistrar 而不是 *grpc.Server?
|
||
|
||
仔细看生成代码的函数签名:
|
||
|
||
```go
|
||
func RegisterUserServiceServer(s grpc.ServiceRegistrar, srv UserServiceServer)
|
||
```
|
||
|
||
参数类型是 `grpc.ServiceRegistrar`(一个 interface),不是 `*grpc.Server`(一个 concrete type):
|
||
|
||
```go
|
||
type ServiceRegistrar interface {
|
||
RegisterService(desc *ServiceDesc, svc any)
|
||
}
|
||
```
|
||
|
||
这看似多余,实际上有两个好处:
|
||
|
||
- **可测试性**:单元测试中可以 mock `ServiceRegistrar`,无需启动真实的 server 进程
|
||
- **扩展性**:任何实现了 `RegisterService` 的结构体都可以接受注册——比如自定义 wrapper、多端口路由器等未来场景
|
||
|
||
> [!important] 理解层次
|
||
> `*grpc.Server` 实现了 `ServiceRegistrar` + 更多方法(如 `Serve`, `Stop`, `GracefulStop`)。注册只需要 "注册服务的能力",不关心 "如何监听网络"——这是典型的单一职责设计。
|
||
|
||
## 服务端实现的标准模式
|
||
|
||
结合 proto 生成的符号,标准的业务实现模板如下:
|
||
|
||
```go
|
||
// userService 是你的业务实现
|
||
type userService struct {
|
||
pb.UnimplementedUserServiceServer // ① 嵌入基类 → 零值安全
|
||
|
||
userStore UserStore // ② 依赖注入 → 数据库、缓存等
|
||
logger *zap.Logger // ③ 基础设施
|
||
}
|
||
|
||
// NewUserService 构造器:集中管理依赖
|
||
func NewUserService(store UserStore, logger *zap.Logger) *userService {
|
||
return &userService{
|
||
userStore: store,
|
||
logger: logger,
|
||
}
|
||
}
|
||
|
||
// ④ 只实现你需要暴露的方法——其余由 Unimplemented 提供默认拒绝响应
|
||
func (s *userService) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
|
||
user := &ModelUser{
|
||
Name: req.GetName(),
|
||
...
|
||
}
|
||
id, err := s.userStore.Create(ctx, user)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "create user failed: %v", err)
|
||
}
|
||
return &pb.CreateUserResponse{Id: id}, nil
|
||
}
|
||
```
|
||
|
||
四个要点:
|
||
|
||
| 步骤 | 目的 | 说明 |
|
||
|------|------|------|
|
||
| ① 嵌入 Unimplemented | 防止 proto 升级后漏实现新方法 | 详见 [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/00-Unimplemented 零-Stub 模式]] |
|
||
| ② 注入依赖 | 解耦业务逻辑与基础设施 | 通过构造器传入,而非全局变量 |
|
||
| ③ 注入日志 | 统一追踪体系 | 使用 zap 或 slog 均可 |
|
||
| ④ 选择性实现 | 灵活控制暴露面 | 只能整体注册 service,不能逐个方法注册 |
|
||
|
||
> [!warning] 常见反模式
|
||
> - ❌ 在 struct 中直接用全局变量拿 DB 连接 → 不可测试、线程不安全
|
||
> - ❌ 手动实现所有方法而不嵌入 Unimplemented → proto 升级时漏掉新方法
|
||
> - ❌ 在一个 service 里塞过多职责 → 考虑拆成独立的 proto service
|
||
|
||
## 单 Server 多 Service
|
||
|
||
单个 gRPC server 天然支持多个 service registration,互不影响:
|
||
|
||
```go
|
||
s := grpc.NewServer(opts...)
|
||
|
||
// 全部挂到同一个 server
|
||
pb.RegisterUserServiceServer(s, NewUserService(store))
|
||
pb.RegisterOrderServiceServer(s, NewOrderService(store))
|
||
pb.RegisterPaymentServiceServer(s, NewPaymentService(store))
|
||
|
||
s.Serve(lis) // 一个端口对外暴露三个 service
|
||
```
|
||
|
||
每个 service 完全独立:有自己的方法名空间、自己的 handler、自己的错误处理。它们共享同一个 listener 和连接池。
|
||
|
||
### 路由链路——从请求到你的 handler
|
||
|
||
一个完整的 RPC 请求到达 server 后的流转路径:
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client
|
||
participant H as HTTP/2 Layer
|
||
participant D as Dispatch Table
|
||
participant M as Method Handler
|
||
participant B as Your Business Logic
|
||
|
||
C->>H: gRPC request<br/>"/user.v1.UserService/CreateUser"
|
||
H->>D: 提取 method path 作为 key
|
||
D->>D: 查找 dispatch table
|
||
alt 找到匹配 entry
|
||
D->>M: _UserCreateUser_Handler(req)
|
||
M->>B: 调用 svc.(*userService).CreateUser(ctx, req)
|
||
B->>B: 执行业务逻辑 (DB / 缓存 / ...)
|
||
B-->>M: response + error
|
||
M-->>H: protobuf-encoded response
|
||
H-->>C: gRPC response
|
||
else 未找到匹配
|
||
D-->>H: UNIMPLEMENTED status
|
||
H-->>C: error response
|
||
end
|
||
```
|
||
|
||
路由 key 是完整的方法名 `/package.ServiceName/MethodName`,来源是 HTTP/2 的 header。server 内部维护了一个 `map[string]*MethodInfo` 结构的 dispatch table,key 就是 method name,value 就是你注册时绑定好的 handler。
|
||
|
||
### 什么时候需要多 Server?
|
||
|
||
虽然可以一个 server 挂所有 service,但有些场景你会**有意分开**:
|
||
|
||
| 场景 | 做法 | 原因 |
|
||
|------|------|------|
|
||
| 微服务拆分 | 每个服务独立进程 | 独立部署、独立扩缩容 |
|
||
| 内外网隔离 | 内网 server 只挂核心 service | 安全隔离,网关聚合 |
|
||
| 不同协议 | HTTP + gRPC | 不同 listener |
|
||
|
||
见 [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/05-Multi-Port 多端口暴露]]。
|
||
|
||
## Service 的全生命周期
|
||
|
||
从 proto 定义到线上运行的完整链路:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["定义 .proto<br/>service Xxx { ... }"] --> B["protoc 生成代码<br/>_grpc.pb.go"]
|
||
B --> C["实现 XxxServer interface<br/>嵌入 Unimplemented"]
|
||
C --> D["构造器注入依赖<br/>NewXxxService(...)"]
|
||
D --> E["RegisterXxxServer(s, impl)"]
|
||
E --> F["s.Serve(lis)<br/>阻塞等待请求"]
|
||
F --> G["Client RPC 请求到达"]
|
||
G --> H["dispatch table 路由"]
|
||
H --> I["执行你的业务逻辑"]
|
||
|
||
style A fill:#E3F2FD
|
||
style B fill:#FFF4CC
|
||
style E fill:#A8E6CF
|
||
style I fill:#FFD43B,color:#000
|
||
```
|
||
|
||
## 常见问题
|
||
|
||
> [!question] 我可以只注册部分方法吗?
|
||
> 不行。注册的是整个 service(整个 interface),不是单个方法。如果你不想暴露某个方法,要么从 proto 里拆出去单独一个 service,要么在方法内部返回 permission denied。
|
||
|
||
> [!question] 同一个 service 能注册多次吗?
|
||
> 不能。对同一个 method name 调用两次 `RegisterXxxServer()` 会导致 server 启动时 panic。如果你在测试代码中反复创建了 server 实例,记得每次用 `grpc.NewServer()` 创建新的——不要复用旧实例。
|
||
|
||
> [!warning] ServiceName 必须唯一
|
||
> 如果你的 proto 文件中有两个 service 起了相同的 `ServiceName`(全限定名),第二个注册时会 panic。命名时养成习惯:始终带上 package 前缀(如 `user.v1.UserService`)。
|
||
|
||
> [!tip] 注册顺序不重要
|
||
> 无论你先注册 UserService 还是 OrderService,server 都会正确路由。因为 dispatch table 是按 method name 查找的,与注册先后无关。
|
||
|
||
> [!note] 生产环境:反射开关
|
||
> 开发时可以开启 reflection([[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/04-Reflection 开发调试利器]])方便用 grpcurl 调试,生产环境务必关闭——它会暴露完整的 API 结构给外部。
|
||
|
||
## 关联笔记
|
||
|
||
- [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/00-Unimplemented 零-Stub 模式]]
|
||
- [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/01-Hello World 最小可运行 Server]]
|
||
- [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/04-Reflection 开发调试利器]]
|
||
- [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/05-Multi-Port 多端口暴露]]
|
||
- [[hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成]]
|