233 lines
8.8 KiB
Markdown
233 lines
8.8 KiB
Markdown
|
|
---
|
|||
|
|
tags: [gRPC, Go, Server, Best Practice]
|
|||
|
|
create time: 2026-05-13 14:30
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Unimplemented 零-Stub 模式
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
`UnimplementedXxxServer` 是 protoc-gen-go 为每个 gRPC service 自动生成的"空实现"结构体。通过嵌入它到自定义的 Service 结构体中,开发者只需编写实际需要的方法,其余方法自动获得 `codes.Unimplemented` 默认响应——这是一种兼顾开发便利性与接口契约安全的 Go 语言惯用模式。
|
|||
|
|
|
|||
|
|
> [!question] 如果没有这个机制会怎样?
|
|||
|
|
> 想象你实现了三个 RPC method,后来 proto 新增了一个,编译器不报错——你的新方法永远不会被调用,直到线上某个 client 发出请求后才暴露出 Bug。这就是"静默失败"陷阱。
|
|||
|
|
|
|||
|
|
## Protobuf 生成的代码结构
|
|||
|
|
|
|||
|
|
当你写一个 proto service:
|
|||
|
|
|
|||
|
|
```proto
|
|||
|
|
service UserService {
|
|||
|
|
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
|
|||
|
|
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
protoc 会生成三类关键代码:
|
|||
|
|
|
|||
|
|
| 生成物 | 作用 |
|
|||
|
|
|--------|------|
|
|||
|
|
| `UserServiceServer` interface | 你必须实现的方法集合 |
|
|||
|
|
| `UnimplementedUserServiceServer` struct | 所有方法都返回 `Unimplemented` 错误的默认实现 |
|
|||
|
|
| `RegisterUserServiceServer()` 函数 | 将你的实现注册到 server 分发表 |
|
|||
|
|
|
|||
|
|
重点看 `UserServiceServer` interface 的定义:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type UserServiceServer interface {
|
|||
|
|
CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error)
|
|||
|
|
GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error)
|
|||
|
|
mustEmbedUnimplementedUserServiceServer() // ← 封闭性守卫
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`mustEmbedUnimplementedUserServiceServer()` 是一个**无参数的空方法**。它的存在让 interface 成为封闭类型——其他任何想满足此接口的类型都必须显式嵌入 `UnimplementedUserServiceServer`,否则编译期直接拦截。这是 Go 社区著名的"封闭接口"(unexported method trick)反模式之一,但在 protobuf 场景下它是有意为之的设计。
|
|||
|
|
|
|||
|
|
## 为什么必须嵌入
|
|||
|
|
|
|||
|
|
### 1. 减少样板代码
|
|||
|
|
|
|||
|
|
嵌入后只需要实现你需要的那些方法:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type userService struct {
|
|||
|
|
pb.UnimplementedUserServiceServer // 自动获得默认 Unimplemented 响应
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *userService) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
|
|||
|
|
// 只写实际需要的业务逻辑
|
|||
|
|
if err := s.store.Create(ctx, req); err != nil {
|
|||
|
|
return nil, status.Errorf(codes.Internal, "create user failed: %v", err)
|
|||
|
|
}
|
|||
|
|
return &pb.CreateUserResponse{Id: generatedId}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUser can be omitted — UnimplementedUserServiceServer returns the default error:
|
|||
|
|
// status.Error(codes.Unimplemented, "method GetUser not implemented")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. Proto 新增 RPC Method → 编译期告警
|
|||
|
|
|
|||
|
|
这是该模式最大的价值。假设后续你在 proto 里加了一个方法:
|
|||
|
|
|
|||
|
|
```proto
|
|||
|
|
service UserService {
|
|||
|
|
rpc CreateUser(...) returns (...);
|
|||
|
|
rpc GetUser(...) returns (...);
|
|||
|
|
rpc DeleteUser(...) returns (...); // ← 新增
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
重新运行 protoc 后,`UserServiceServer` interface 会多出 `DeleteUser` 签名。**此时编译就会失败**:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
cannot use &userService{} as UserServiceServer:
|
|||
|
|
missing method DeleteUser in receiver type userService
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
从"线上偶发 Bug"变成了"提交前就发现"——把风险前置到了编译期。
|
|||
|
|
|
|||
|
|
### 3. 强制接口契约意识
|
|||
|
|
|
|||
|
|
如果不用 embed,你可以"选择性实现"——只有某些方法、其他全忽略。这会导致服务方和客户端对接口理解不一致。Zero-stub 模式强迫每次新增 method 时都做一次有意识的决策:**实现它**,还是保持 Unimplemented?
|
|||
|
|
|
|||
|
|
## 与反射型语言的对比
|
|||
|
|
|
|||
|
|
在 Java 等语言中,gRPC 通常要求你继承一个基类(如 `UserServiceImplBase`),效果类似:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
public class UserServiceImpl extends UserServiceImplBase {
|
|||
|
|
@Override
|
|||
|
|
public void createUser(..., StreamObserver observer) {
|
|||
|
|
// 只重写需要的
|
|||
|
|
}
|
|||
|
|
// getUser 自动走父类的 Unimplemented
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Go 版本的优势在于**嵌入比继承更灵活**——你可以同时嵌入多个类型、控制方法优先级(离当前结构体越近优先匹配),并且没有单继承限制。
|
|||
|
|
|
|||
|
|
## 执行流程图解
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart TD
|
|||
|
|
Client["Client RPC Call"] --> Lookup["Server 查找 dispatch table"]
|
|||
|
|
Lookup --> Found{"Method 是否被覆写?"}
|
|||
|
|
Found -->|"是"| Handler["Handler 执行业务逻辑"]
|
|||
|
|
Found -->|"否"| Fallback["UnimplementedStub<br/>codes.Unimplemented 错误"]
|
|||
|
|
|
|||
|
|
Handler --> Resp["Response to Client"]
|
|||
|
|
Fallback --> Resp
|
|||
|
|
|
|||
|
|
ProtoChanged["Proto 新增 Method"] --> GenCode["Protoc 重新生成"]
|
|||
|
|
GenCode --> CompileErr["userService 缺少新方法,编译失败 ✅"]
|
|||
|
|
|
|||
|
|
style Client fill:#E3F2FD,stroke:#1976D2
|
|||
|
|
style Handler fill:#A8E6CF,stroke:#2E7D32
|
|||
|
|
style Fallback fill:#FFB3BA,stroke:#C62828
|
|||
|
|
style CompileErr fill:#FFF3E0,color:#000,stroke:#E65100
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 实际使用中的注意事项
|
|||
|
|
|
|||
|
|
> [!warning] 不要手动实现 Unimplemented 方法
|
|||
|
|
|
|||
|
|
如果你在自己的结构体中也定义了一个同名 method(比如不小心写了 `func (s *userService) MustEmbedUnimplementedUserServiceServer()`),它会**覆盖**嵌入类型的空方法。虽然功能上不影响(interface 检查只看是否存在),但语义混乱,应避免。
|
|||
|
|
|
|||
|
|
> [!tip] 如何快速确认某个 Service 的所有方法都被覆盖了?
|
|||
|
|
|
|||
|
|
可以用 IDE 的 "implement interface" 功能列出未实现的方法,或者写一个简单的 compile test:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
var _ pb.UserServiceServer = (*userService)(nil) // 编译期检查
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
加上这一行后,任何遗漏的方法都会在这句编译报错,适合作为 CI 的一部分。
|
|||
|
|
|
|||
|
|
## 常见陷阱
|
|||
|
|
|
|||
|
|
> [!danger] 嵌入顺序决定方法优先级
|
|||
|
|
>
|
|||
|
|
> Go 的结构体嵌入具有**层级可达性**——当多个嵌入类型都有同名方法时,编译器选择距离当前结构体最近的那个。如果不小心嵌入了错误的类型,你的实现可能永远不会被调用:
|
|||
|
|
>
|
|||
|
|
> ```go
|
|||
|
|
> type userService struct {
|
|||
|
|
> pb.UnimplementedUserServiceServer
|
|||
|
|
> someOtherEmbeddedStruct // ← 如果有同名方法,这里优先!
|
|||
|
|
> }
|
|||
|
|
> ```
|
|||
|
|
>
|
|||
|
|
> 养成**将 Unimplemented 嵌入放在最后**的习惯,避免意外覆盖:
|
|||
|
|
>
|
|||
|
|
> ```go
|
|||
|
|
> type userService struct {
|
|||
|
|
> *UserStore // 业务依赖先放前面
|
|||
|
|
> someHelper // 工具类
|
|||
|
|
> pb.UnimplementedUserServiceServer // 永远放最后
|
|||
|
|
> }
|
|||
|
|
> ```
|
|||
|
|
|
|||
|
|
> [!warning] `embed` vs `pointer embed` 的选择
|
|||
|
|
>
|
|||
|
|
> gRPC 生成的 `UnimplementedXxxServer` **值类型和指针类型都可以嵌入**,但效果不同:
|
|||
|
|
>
|
|||
|
|
> | 嵌入方式 | `*userService` 是否满足接口 | 说明 |
|
|||
|
|
> |---------|---------------------------|------|
|
|||
|
|
> | `pb.UnimplementedXxxServer`(值) | `*userService` ✅ | 推荐,指针接收者可调用值方法 |
|
|||
|
|
> | `*pb.UnimplementedXxxServer`(指针) | `*userService` ✅ 且 `userService` ❌ | 仅指针满足接口 |
|
|||
|
|
>
|
|||
|
|
> **最佳实践**: 大多数 gRPC server 方法签名是 `func (s *Service) Method(...)`,所以用指针 receiver。嵌入值类型的 `Unimplemented` 即可同时支持两种调用方式。
|
|||
|
|
|
|||
|
|
## Server 集成示例
|
|||
|
|
|
|||
|
|
下面是一个完整的、可直接运行的 Server 搭建片段,展示 UnimplementedStub 在整个链路中的位置:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package server
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"log"
|
|||
|
|
|
|||
|
|
"google.golang.org/grpc"
|
|||
|
|
pb "your/proto/package"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// UserServiceImpl — 只需实现实际需要的方法
|
|||
|
|
type UserServiceImpl struct {
|
|||
|
|
store *UserStore // 业务依赖
|
|||
|
|
pb.UnimplementedUserServiceServer // 放最后
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *UserServiceImpl) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
|
|||
|
|
user := &model.User{Name: req.GetName(), Email: req.GetEmail()}
|
|||
|
|
if err := s.store.Create(ctx, user); err != nil {
|
|||
|
|
return nil, status.Errorf(codes.Internal, "create failed: %v", err)
|
|||
|
|
}
|
|||
|
|
return &pb.CreateUserResponse{Id: fmt.Sprintf("%d", user.ID)}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUser 不写 — 自动返回 codes.Unimplemented
|
|||
|
|
// DeleteUser 不写 — 同上
|
|||
|
|
|
|||
|
|
func NewUserServiceImpl(store *UserStore) *UserServiceImpl {
|
|||
|
|
return &UserServiceImpl{store: store}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Register 注册所有服务到 grpc.Server
|
|||
|
|
func Register(srv *grpc.Server, store *UserStore) {
|
|||
|
|
pb.RegisterUserServiceServer(srv, NewUserServiceImpl(store))
|
|||
|
|
log.Println("UserService registered")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
关键要点:
|
|||
|
|
- `RegisterUserServiceServer()` 内部会校验你传入的类型是否实现了 `UserServiceServer` interface
|
|||
|
|
- 由于 `UnimplementedUserServiceServer` 提供了所有方法的默认实现,你只需编写实际需要的 method
|
|||
|
|
- Proto 新增 method → protoc 重新生成 → 编译失败 → **有意识的决策点**
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册]]
|
|||
|
|
- [[hhs/gRPC/3. 服务端实现/09-Streaming Handler]]
|