打开网易新闻 查看精彩图片

一个中型Angular项目里,gRPC拦截器的平均维护成本是每增加1个功能,就要新建2个文件、写3处注册、测4种顺序组合。这不是我编的——是nx-grpc-kit作者在重构6个@Injectable()拦截器时,用git diff数出来的。

6个文件、6个装饰器、6次"顺序 matters but nothing enforces it"的俄罗斯轮盘赌。

这就是Angular生态里被默认接受的"正常"。直到有人把provideHttpClient(withInterceptors(...))的设计语言,翻译到了gRPC层。

旧架构:每个拦截器都是一个小型创业公司

旧架构:每个拦截器都是一个小型创业公司

来看一个标准的AuthInterceptor长什么样:

export class AuthInterceptor implements GrpcInterceptor {
constructor(private authService: AuthService) {}
// ...
const token = this.authService.getToken();
request.metadata.set('Authorization', `Bearer ${token}`);
}

模板代码、依赖注入、测试mock——一个拦截器就是一个小型创业公司,有完整的组织架构。6个拦截器就是6个创业公司,各自融资、各自招人、各自写PPT。

注册环节更隐蔽地消耗认知资源:

{ provide: GRPC_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: GRPC_INTERCEPTORS, useClass: MetadataInterceptor, multi: true },
{ provide: GRPC_INTERCEPTORS, useClass: RetryInterceptor, multi: true },
{ provide: GRPC_INTERCEPTORS, useClass: DeadlineInterceptor, multi: true },
{ provide: GRPC_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
{ provide: GRPC_INTERCEPTORS, useClass: ErrorMappingInterceptor, multi: true }

6行代码,顺序敏感,零类型约束。把RetryInterceptor和AuthInterceptor调换位置,就会出现"重试请求带着已过期token"的幽灵bug。这种错误不会在编译期暴露,也不会在单测里尖叫——它只会在生产环境的凌晨3点,以用户投诉的形式抵达。

作者的原话:「Swap two and you get auth tokens on retried requests that should not have them, or logging that misses error-mapped responses.」

换句话说,这是一份没有保险绳的高空作业。

新架构:工厂函数的组合拳

新架构:工厂函数的组合拳

nx-grpc-kit的解法是把"类"碾平成"函数",把"注册表"压缩成"调用链"。

export const appConfig: ApplicationConfig = {
providers: [
provideGrpcClient(
withAuth(() => authStore.getToken()),
withMetadata({
'x-org-id': authStore.selectedOrgId(),
'x-trace-id': crypto.randomUUID(),
}),
withRetry({ maxRetries: 3, initialDelayMs: 200 }),
withDeadline({ timeoutMs: 5000 }),
withLogging({ enabled: !environment.production }),
withErrorMapping(err => {
if (err.statusCode === GrpcStatusCode.UNAUTHENTICATED) {
return new Error('Session expired — please log in again');
}
return err;
})
)
]
}

6个拦截器,1个provider调用,执行顺序就是阅读顺序。

如果你熟悉Angular 15+的provideHttpClient(withInterceptors(...)),这个API设计会让你瞬间找到肌肉记忆——这不是巧合,是刻意对齐。作者把HTTP客户端的成功范式,移植到了gRPC场景。

每个工厂函数都是纯函数或闭包,没有类装饰器的仪式负担。withAuth接收一个token工厂,支持同步字符串或异步Promise;withMetadata接受键值对,直接映射到gRPC元数据;withRetry、withDeadline、withLogging、withErrorMapping各自携带配置对象,类型推断完整。

测试也从"mock整个DI容器"降级为"传几个stub函数"。

为什么是工厂函数,不是更炫的东西

为什么是工厂函数,不是更炫的东西

这个方案没有发明新范式。它只是拒绝了Angular早期设计中一个被过度使用的抽象层:@Injectable()类。

拦截器的本质是"对请求/响应的变换函数"。TypeScript的类在这里提供的封装性,远小于它带来的文件膨胀和心智负担。工厂函数的组合(compose)比类的继承(extend)更贴合拦截器的管道语义——数据从左进、从右出,中间每个环节都是纯变换。

作者提到的一个细节很有意思:旧方案中,每个拦截器的构造函数都在拉取自己的依赖(authService、logger、configService),这些依赖的初始化顺序和拦截器执行顺序完全解耦,形成隐式的时序依赖网。新方案里,token工厂和配置对象在调用点显式传入,依赖关系摊开在代码表面。

这不是"更函数式"的审美偏好,是故障定位时的信息密度差异。

nx-grpc-kit目前开源在GitHub,npm包名就叫nx-grpc-kit。作者没有给出benchmark数据,但给出了一个更有说服力的指标:重构后,gRPC相关的文件数量从18个(6拦截器×3文件:实现、测试、mock)降到1个app.config.ts中的1个provider调用。

代码行数的减少是副产品。真正的收益是拦截器管道的可视化——现在打开一个文件,200毫秒内就能看到完整的请求生命周期。以前需要跳6个文件,在IDE的标签页里做拼图游戏。

这个设计选择也暴露了Angular生态的一个张力:框架团队(Google)主推的范式(类+装饰器+DI),与社区在实际复杂度面前自发演化的范式(函数组合+显式配置),正在分道扬镳。provideHttpClient是框架团队的回调,nx-grpc-kit是社区侧的前进。