背景
最近在业务开发过程中,发现同时维护多个服务间的业务通信,存在较高的开发成本。在窥视了部分同僚们的代码后,发现他们普通使用了rpc
去构建服务,所以我决定尝试使用gRPC
重构服务。这里介绍一些项目的基本情况:
- 同时维护多个独立的服务。
- 服务分别部署在多个机器上。
- 服务间存在业务上的通信(
http
)。 - 项目目录
│ ├── controller
│ ├── service
│ ├── route
├── app.ts
问题分析
每当我尝试添加新的服务时,都需要通过路由确保字段类型和名称完全匹配,还需要编写API
文档进行约束,然后还需要对数据格式的输入输出编写额外的代码。最后,当接受来自API
的响应时,服务将再次反向执行整个过程。这整个过程就显得:开发缓慢。
需求分析
- 在尽量不破坏原有项目结构以及业务代码的前提下进行重构。
- 编写一个
API
可以同时约束多个服务的调用。 - 不需要为输入输出编写额外的代码。
实现过程
这里以server
端为例。首先,使用gRPC
替换服务程序入口。
import service from 'controller';
// 加载proto文件
const grpcPkg = protoLoader.loadSync(file, loader);
function main() {
const server = new grpc.Server();
// 遍历grpcPkg,加载对应的service method
bindServices(server, service, grpcPkg);
server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure());
server.start();
}
根据rpc
的基本原理,我们需要通过编写.proto
文件来描述服务暴露的api
:
syntax = "proto3";
package test;
service Api {
rpc index (Query) returns (Response) {};
rpc show (Param) returns (Response) {};
rpc update (Body) returns (Response) {};
rpc create (Body) returns (Response) {};
rpc del (Param) returns (Response) {};
}
message Response {
int32 code = 1;
string data = 2;
}
这里我让文件中service
声明的方法与http
服务的controller
保持一致
class Api {
public index() {}
public show() {}
public update() {}
public create() {}
public del() {}
}
在bindService
中,我们可以遍历加载的grpcPkg
并将其中声明的service
和controller
一一对应起来。
// 初始化方法
for (const definition of getServiceNames(grpcPkg)) {
const service = (service as any)[definition.name.toLowerCase()];
!!service &&
service.addService(
// proto file中解析出的service
definition.service.service,
// 包装对应service
await createService(service)
);
}
为了能够确保每个controller
中的方法能够准确的和grpcPkg
中加载的方法想对应,并且能够在所有controller
里通用,同时规避同时方法的冲突,我们可以使用reflect-metadata
收集controller
中的方法,尽可能不侵入原有业务。
// decorator.ts
export const ROUTE_MAPPING_METADATA = 'MODULE_ROUTE_MAPPING';
export const ROUTE_METADATA = 'MODULE_ROUTE';
export const ROUTE_METADATA_PARAMS = 'MODULE_ROUTE_PARAMS';
export function Handler(params: any = {}) {
return (target: any, methodName: string, descriptor: PropertyDescriptor) => {
// 收集方法名
const methods = Reflect.getMetadata(ROUTE_MAPPING_METADATA, target) || new Set<string>();
methods.add(methodName);
// 收集方法引用
Reflect.defineMetadata(ROUTE_METADATA, descriptor.value, target, methodName);
// 收集方法额外参数
Reflect.defineMetadata(ROUTE_MAPPING_METADATA, methods, target);
Reflect.defineMetadata(
ROUTE_METADATA_PARAMS,
params,
target,
methodName
);
return descriptor;
};
}
// controller.ts
class Api {
// rpc method
@Handler()
public index() {}
// http method
public show() {}
}
然后在addService
时,我们就可以获取grpcPkg
中方法对应的method
,并对其就是包装。达到统一输入输出的目的。
function createService(controller: any) {
const handlers: any = {};
// 获取当前controller的方法集
const methods: Set<string> = Reflect.getMetadata(ROUTE_MAPPING_METADATA, controller);
for (const method of Array.from(methods)) {
const handler = Reflect.getMetadata(ROUTE_METADATA, controller, method);
if (!handler) continue;
// 包装method
const enhancer = createServiceMethod(
handler
);
handlers[method] = enhancer;
}
// 返回方法集
return handlers;
}
最后是proto
文件的维护问题,因为多个服务可能要共同使用同一个proto
文件。这里我们可以使用git subtree
,抽离出proto作为子项目进行维护。