Node.js:记一次使用gRPC重构http服务的实践

3,461 阅读3分钟

背景

最近在业务开发过程中,发现同时维护多个服务间的业务通信,存在较高的开发成本。在窥视了部分同僚们的代码后,发现他们普通使用了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并将其中声明的servicecontroller一一对应起来。

  // 初始化方法
    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作为子项目进行维护。

参考资料