Files
cs-note/hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/00-Unimplemented 零-Stub 模式.md
T
2026-05-24 11:42:38 +08:00

12 KiB
Raw Blame History

tags, create time
tags create time
gRPC
Go
Server
Best Practice
2026-05-15 10:00

Unimplemented 零-Stub 模式

概述

UnimplementedXxxServer 是 protoc-gen-go 为每个 gRPC service 自动生成的"空实现"结构体。通过将其嵌入自定义 Service 结构体,开发者只需编写实际需要的方法,未覆写的方法自动返回 codes.Unimplemented 错误——这是一种兼顾开发便利性与接口契约安全的 Go 语言惯用模式。

[!question] 如果没有这个机制会怎样? 假设你实现了三个 RPC method,后来 proto 新增了一个。编译器不会报错——你的新方法永远不会被调用,直到线上某个 client 发出请求后才暴露出 Bug。这就是"静默失败"陷阱。

核心思想:把运行时 Bug 提前到编译期发现。

Protobuf 生成的代码结构

当你定义一个 proto service:

service UserService {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

protoc 生成三类关键代码:

生成物 作用
UserServiceServer interface 你必须满足的方法集合
UnimplementedUserServiceServer struct 所有方法都返回 codes.Unimplemented 错误的默认实现
RegisterUserServiceServer() 函数 将你的实现注册到 server 分发表

重点看 UserServiceServer interface 的定义:

type UserServiceServer interface {
    CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error)
    GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error)
    mustEmbedUnimplementedUserServiceServer() // ← 封闭性守卫
}

mustEmbedUnimplementedUserServiceServer() 是一个无参空方法。它让 interface 成为封闭类型——任何想满足此接口的类型都必须显式嵌入 UnimplementedUserServiceServer,否则编译期直接拦截。这是 Go 社区著名的"封闭接口"(unexported method trick)设计手法,在 protobuf 场景下是有意为之。

[!note] 为什么叫 "closed set of methods"? Go 的 interface 是 implicit(隐式实现),正常情况下你可以随时"默默"实现一个新接口。但 protobuf 通过注入一个只能由生成的 struct 提供的方法,把这个隐式契约变成了显式嵌入——你必须主动写下一行 pb.UnimplementedUserServiceServer,这种"被迫直面"就是安全感的来源。

嵌入用法

嵌入后只需实现需要的业务方法:

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 不写 — UnimplementedUserServiceServer 自动返回:
// status.Error(codes.Unimplemented, "method GetUser not implemented")

Proto 新增 Method → 编译期告警

这是该模式最大的价值。先回答一个疑惑:proto 新增了 DeleteUser,重新运行 protoc 后 UnimplementedUserServiceServer 自身也会多出对应的默认实现,你的嵌入结构体照样能编译通过——那为什么说有风险前置?

原因在于两个实际场景:

场景 A:protoc 重新生成后忘记更新依赖 如果你在同一份代码库里只改了 proto 文件,却忘了对所有 micro-service 跑 protoc,那些没更新的 service 就会报编译错误:

cannot use &userService{} as UserServiceServer:
    missing method DeleteUser in receiver type userService

CI 的编译检查会直接拦住——某个服务漏跑 protoc,而不是等到线上被调用的时候才发现这个方法从未被实现。

场景 B:忘记嵌入 → 退化回普通接口 如果不 embed UnimplementedXxxServer,proto 新增 method 时编译器照样不拦你。新方法永远不会被调用,直到线上某个 client 触发了它——这就退化成了"没这个机制之前"的样子。这也是为什么"正确嵌入"是这套模式生效的前提。


[!question] 那"强制接口契约意识"怎么理解?

正确用法下,Proto 新增 method 后,UnimplementedXxxServer 也会跟着长出新的兜底实现,编译是通过的。但这正是设计者想要的效果:

  • 不强制你立刻实现(不影响正常请求)
  • 但强制你在 protoc 重新生成的那一刻"看到"变更
  • 从此时起你有两次决策机会:① 跑 protoc 时意识到契约变了;② 决定是否要把 codes.Unimplemented 替换成真实逻辑
  • 对比非类型安全语言(Java 等),新方法可以永远不被实现且永远不被发现——Go 版本至少让每次 proto 变更都变得可追踪

嵌入策略对比

不同语言的 gRPC 框架提供了类似的保底机制,但 Go 的嵌入方式有其独特优势:

维度 Go(嵌入) Java / C++(继承基类) Python(抽象基类)
语法 struct 嵌入 继承 ServiceImplBase 继承 Servicer + @abc.abstractmethod
单继承限制 无(可嵌多个类型) 有 有
方法优先级控制 嵌入顺序决定 虚函数重写 MRO 决定
扩展性 可同时混入 store、logger 等依赖 只能通过构造函数传递 同上

Go 版本的优势在于嵌入比继承更灵活——可以同时嵌入多个类型、控制方法优先级(离当前结构体越近优先匹配),并且没有单继承限制。

Streaming RPC 下的行为

上面的例子都是 unary RPC。对于 streaming RPC,behavior 完全一致:

type MessageService struct {
    pb.UnimplementedMessageServiceServer
}

// 只实现 stream-send 的场景
func (s *MessageService) StreamMessages(req *StreamRequest, ss grpc.ServerStream) error {
    for i := 0; i < req.Count; i++ {
        if err := ss.SendMsg(&StreamResponse{Data: fmt.Sprintf("msg-%d", i)}); err != nil {
            return status.Errorf(codes.Internal, "send failed: %v", err)
        }
    }
    return nil
}

// BidirectionalStreaming 不写 → 自动返回 codes.Unimplemented

关键点:

  • UnimplementedStub 对 unary、server-streaming、client-streaming、bidirectional-streaming 统一处理
  • Streaming handler 的错误返回值仍然是 error,未覆写时同样走 codes.Unimplemented
  • 如果你在 CI 中有编译检查,streaming 方法的遗漏也能被捕获

执行流程图解

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 --> AllUpdated["所有 service 同步更新<br/>Embed 自动继承新方法,编译通过"]
    GenCode --> PartialUpdate["部分 service 漏跑 protoc<br/>CI 拦截 ✅"]

    style Client fill:#E3F2FD,stroke:#1976D2
    style Handler fill:#A8E6CF,stroke:#2E7D32
    style Fallback fill:#FFB3BA,stroke:#C62828
    style AllUpdated fill:#FFF3E0,color:#000,stroke:#E65100
    style PartialUpdate fill:#FFF3E0,color:#000,stroke:#E65100

实际使用中的注意事项

[!warning] 不要手动实现 Unimplemented 方法

如果在自己的结构体中也定义了同名 method(比如不小心写了 MustEmbedUnimplementedUserServiceServer()),它会覆盖嵌入类型的空方法。虽然功能上不影响(interface 检查只看是否存在),但语义混乱,应避免。

[!warning] embed vs pointer embed 的选择

gRPC 生成的 UnimplementedXxxServer 值类型和指针类型都可以嵌入,但效果不同:

嵌入方式 *userService 是否满足接口 说明
pb.UnimplementedXxxServer(值) *userService ✅ 推荐,指针接收者可调用值方法
*pb.UnimplementedXxxServer(指针) *userService ✅ 且 userService ❌ 仅指针满足接口

最佳实践: 大多数 gRPC server 方法签名是 func (s *Service) Method(...),所以嵌入值类型的 Unimplemented 即可同时支持两种调用方式。

[!danger] 嵌入顺序决定方法优先级

Go 的结构体嵌入具有层级可达性——当多个嵌入类型都有同名方法时,编译器选择距离当前结构体最近的那个。如果不小心嵌入了错误的类型,你的实现可能永远不会被调用:

type userService struct {
    pb.UnimplementedUserServiceServer
    someOtherEmbeddedStruct // ← 如果有同名方法,这里优先!
}

养成将 Unimplemented 嵌入放在最后的习惯:

type userService struct {
    *UserStore        // 业务依赖先放前面
    logger            // 工具类
    pb.UnimplementedUserServiceServer // 永远放最后
}

Server 集成示例

下面展示 UnimplementedStub 在整个服务链路中的位置(核心逻辑已简化):

package server

import (
    "context"
    "fmt"
    "log"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    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 重新生成 → 变更暴露 → 有意识的决策点(是否把 codes.Unimplemented 替换成真实逻辑)

常见排错

[!bug] 忘记 embed → "missing method xxx" 编译错误

如果你看到类似下面的错误,首先检查是否忘写了嵌入行:

cannot use &userService{} as UserServiceServer:
    missing method CreateUser in receiver type userService

确认嵌入 pb.UnimplementedXxxServer 后即可解决。

[!bug] embed 的是指针而不是值 → userService 类型不满足接口

type userService struct {
    *pb.UnimplementedUserServiceServer // 指针嵌入
}
var _ UserServiceServer = &userService{} // ✅
var _ UserServiceServer = userService{}  // ❌ compile error

如果你的某些代码以值类型传 service,建议改用值嵌入。

[!bug] 自定义方法与嵌入类型冲突

如果你在结构体上手动定义了 mustEmbedUnimplementedUserServiceServer() 或其他与嵌入类型同名的方法,它会在方法解析时遮蔽嵌入类型的方法——虽然通常不会影响最终行为,但会造成混淆和潜在 bug。

关联笔记

  • hhs/gRPC/3. 服务端实现/08-Server 搭建与注册
  • hhs/gRPC/3. 服务端实现/09-Streaming Handler