Node.js Eggjs和RPC框架的结合实践方案 使用egg-sofa-rpc模块搭建服务端和客户端

前言

本文将简单介绍RPC,以及通过Eggjs框架搭建RPC客户端和服务端。
过程遇到的问题记录在这:Node.js Eggjs使用RPC模块 egg-sofa-rpc 踩坑记录

RPC介绍

RPC ( Remote Procedure Call ) 即 远程过程调用,就是像调用本地的函数一样去调用远程的函数。简单讲,就是本地调用的逻辑处理的过程放在的远程的机器上,而不是本地服务代理来处理。

也就是说两台服务器A,B,一个应用部署在A服务器上,想要调用B服务器上应用提供的函数/方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用的数据。

HTTP和RPC的对比

在HTTP和RPC的选择上,可能有些人是迷惑的,主要是因为,有些RPC框架配置复杂,如果走HTTP也能完成同样的功能,那么为什么要选择RPC,而不是更容易上手的HTTP来实现了。

以下是阐述HTTP和RPC的异同。

  • 传输协议
    • RPC,可以基于TCP协议,也可以基于HTTP协议
    • HTTP,基于HTTP协议
  • 传输效率
    • RPC,使用自定义的TCP协议,可以让请求报文体积更小,或者使用HTTP2协议,也可以很好的减少报文的体积,提高传输效率
    • HTTP,如果是基于HTTP1.1的协议,请求中会包含很多无用的内容,如果是基于HTTP2.0,那么简单的封装以下是可以作为一个RPC来使用的,这时标准RPC框架更多的是服务治理
  • 性能消耗,主要在于序列化和反序列化的耗时
    • RPC,可以基于thrift实现高效的二进制传输
    • HTTP,大部分是通过json来实现的,字节大小和序列化耗时都比thrift要更消耗性能
  • 负载均衡
    • RPC,基本都自带了负载均衡策略
    • HTTP,需要配置Nginx,HAProxy来实现
  • 服务治理(下游服务新增,重启,下线时如何不影响上游调用者)
    • RPC,能做到自动通知,不影响上游
    • HTTP,需要事先通知,修改Nginx/HAProxy配置

总结:

RPC主要用于公司内部的服务调用,性能消耗低,传输效率高,服务治理方便。HTTP主要用于对外的异构环境,浏览器接口调用,APP接口调用,第三方接口调用等。

HTTP接口是在接口不多、系统与系统交互较少的情况下,解决信息孤岛初期常使用的一种通信手段;优点就是简单、直接、开发方便。利用现成的HTTP协议进行传输。但是如果是一个大型的网站,内部子系统较多、接口非常多的情况下,RPC框架的好处就显示出来了,首先就是长链接,不必每次通信都要像HTTP一样去3次握手什么的,减少了网络开销;其次就是RPC框架一般都有注册中心,有丰富的监控管理;发布、下线接口、动态扩展等,对调用方来说是无感知、统一化的操作。第三个来说就是安全性。最后就是最近流行的服务化架构、服务化治理,RPC框架是一个强力的支撑

准备工作

本文以windows为例,其他操作系统的安装、使用方法请自行 google。

安装 nodejs

要求版本 >= 8.0.0

  • 下载安装包:https://nodejs.org/zh-cn/
  • 执行安装

安装 zookeeper

下载安装包:http://zookeeper.apache.org/releases.html

如果是最新版本则下载带有bin的包,不带bin是源码,需要自行编译

启动 zookeeper 服务

  • 复制 conf/zoo_sample.cfgconf/zoo.cfg

  • 在bin目录打开cmd,执行命令运行服务

    • Linux:bin/zkServer.sh start
    • Windows: bin/zkServer.cmd

全局安装 egg-init

$ npm i egg-init -g

初始化工程

  • 通过 egg-init 初始化项目脚手架,选择 simple 模板,接下来根据实际情况填写必要信息
$ egg-init

? Please select a boilerplate type (Use arrow keys)
  ──────────────
❯ simple - Simple egg app boilerplate
  ts - Simple egg && typescript app boilerplate
  empty - Empty egg app boilerplate
  plugin - egg plugin boilerplate
  framework - egg framework boilerplate
  • 进入生成好的项目目录,并安装依赖
$ cd /rpc-demo
$ npm i
  • 安装 sofa-node框架,egg-sofa-rpc 插件和 egg-rpc-generator 工具
$ npm i sofa-node --save
$ npm i egg-sofa-rpc --save
$ npm i egg-rpc-generator --save-dev

配置RPC

配置

  • 配置 package.json 的 scripts 节点,增加一个命令 rpc 如下

    {
      "scripts": {
        "rpc": "egg-rpc-generator -p protobuf"
      }
    }
    

    在使用egg-rpc-generator生成代理文件时,会同时进行ProtoRPCPlugin, jsdoc2jar, Jar2ProxyPlugin这三种模式,其中会编译生成jar包,在目前的实际应用中,需要用的是egg之间调用,不涉及跨平台,那也就没必要生成相关jar包,解决方式是添加参数-p

  • 配置 package.json 的 egg 节点,增加 "framework": "sofa-node"

    {
      "egg": {
        "framework": "sofa-node",
        "declarations": true
      },
    }
    
  • 配置 config/plugin.js 开启 egg-sofa-rpc 插件

    // config/plugin.js
    
    'use strict';
    
    module.exports = {
      sofaRpc: {
        enable: true,
        package: 'egg-sofa-rpc',
      },
    };
    
  • 默认的服务发现依赖于 zookeeper,所以需要配置一个 zk 的地址。在 config/config.{env}.js 中配置 rpc 如下:

    'use strict';
    
    module.exports = appInfo => {
      const config = exports = {};
    
      config.rpc = {
        registry: {
          address: '127.0.0.1:2181', // zk 地址指向本地 2181 端口
        },
      };
    
      return config;
    };
    

定义接口文件

这个proto文件是作为通信转换,在客户端或服务端都需要。

在 egg 项目根目录下创建 proto 目录,创建 ProtoService.proto 文件

.
├── app
│   ├── controller
│   │   └── home.js
│   └── router.js
├── config
│   ├── config.default.js
│   └── plugin.js
├── package.json
└── proto
    └── ProtoService.proto

protobuf 有自己的接口定义语言,详细可以参考官方文档。

# ProtoService.proto 
syntax = "proto3";

package com.nodejs.rpc;
option java_multiple_files = false; // 可选
option java_outer_classname = "ProtoServiceModels"; // 可选

service ProtoService {
    rpc echoObj (EchoRequest) returns (EchoResponse) {}
}

message EchoRequest {
    string name = 1;
    Group group = 2;
}

message EchoResponse {
    int32 code = 1;
    string message = 2;
}

enum Group {
    A = 0;
    B = 1;
}

上面这个 ProtoService.proto 文件定义了一个服务:com.nodejs.rpc.ProtoService,它有一个叫 echoObj 的方法,入口参数类型是 EchoRequest,返回值类型是 EchoResponse

RPC服务端

配置 RPC 服务端的参数

通过 config/config.{env}.js 配置 RPC 服务端 server 的参数

// config/config.default.js
'use strict';

module.exports = appInfo => {
  const config = exports = {};

  config.rpc = {
    registry: {
      address: '127.0.0.1:2181', // zk 地址指向本地 2181 端口
    },
    server: {
      namespace: 'com.nodejs.rpc',
    },
  };

  return config;
};

其中最主要的配置就是 namespace,其他配置都可以缺省:

  • namespace(必选): 接口的命名空间,所有的暴露的接口默认都在该命名空间下
  • selfPublish(可选): 是否每个 worker 进程独立暴露服务。nodejs 多进程模式下,如果多个进程共享一个端口,在 RPC 这种场景可能造成负载不均,所以 selfPublish 默认为 true,代表每个进程独立监听端口和发布服务
  • port(可选): 服务监听的端口(注意:在 selfPublish=true 时,监听的端口是基于这个配置生成的)
  • maxIdleTime(可选): 客户端连接如果在该配置时长内没有任何流量,则主动断开连接
  • responseTimeout(可选): 服务端建议的超时时长,具体的超时还是以客户端配置为准
  • codecType(可选): 推荐的序列化方式,默认为 protobuf

实现接口逻辑

app/rpc 目录下创建 ProtoService.js 文件,用于实现接口逻辑

'use strict';

exports.echoObj = async function(req) {
  return {
    code: 200,
    message: 'hello ' + req.name + ', you are in ' + req.group,
  };
};

发布服务

运行命令 npm run dev

测试 RPC 接口

在单元测试中,我们可以通过 app.rpcRequest 接口来方便的测试我们自己暴露的 RPC 服务,例如:

'use strict';

const { app, assert } = require('egg-mock/bootstrap');

describe('test/app/rpc/ProtoService.test.js', () => {
  it('should assert', () => {
    const pkg = require('../../../package.json');
    assert(app.config.keys.startsWith(pkg.name));

    // const ctx = app.mockContext({});
    // yield ctx.service.xx();
  });

  it('should invoke ProtoService', done => {
    app.rpcRequest('com.nodejs.rpc.ProtoService')
      .invoke('echoObj')
      .send({ name: 'test', group: 'A' })
      .expect({ code: 200, message: 'hello test, you are in 0' }, done);
  });
});

详细 app.rpcRequest 的 api 可以参考:单元测试 RPC 服务的方法

执行单元测试需要先配置要调用的接口

RPC客户端

配置 RPC 客户端的参数

${app_root}/config/config.${env}.js 做一些全局性的配置

// ${app_root}config/config.${env}.js
exports.rpc = {
  client: {
    responseTimeout: 3000,
  },
};
  • responseTimeout(可选): RPC 的超时时长,默认为 3 秒

配置要调用的接口

RPC 客户端还有一个重要的配置文件是:${app_root}/config/proxy.js,你需要把你调用的服务配置到里面,然后通过 egg-rpc-generator 工具帮你生成本地调用代码。

让我们看一个最简单的配置,它的基本含义是:我需要调用 sofarpc 应用暴露的 com.nodejs.rpc.ProtoService 这个服务。

'use strict';

module.exports = {
  services: [{
    appName: 'sofarpc',
    api: {
      ProtoService: 'com.nodejs.rpc.ProtoService',
    },
  }],
};
  • appName(必选): 服务提供方的应用名,如果没有可以任意起一个
  • api(必选): 接口列表,是一个 key-value 键值对,key 是生成的 proxy 文件名,value 是接口名(如果要更精细的配置也可以是一个对象)

详细的配置可以参考 RPC 代理(Proxy)配置

生成本地调用代理

在根目录下运行 npm run rpc,生成调用的 proxy 文件

$ npm run rpc

> [email protected] rpc /egg-rpc-demo
> egg-rpc-generator -p protobuf

[EggRpcGenerator] framework: /egg-rpc-demo/node_modules/sofa-node, baseDir: /egg-rpc-demo
[ProtoRPCPlugin] found "com.nodejs.rpc.ProtoService" in proto file
[ProtoRPCPlugin] save all proto info into "/egg-rpc-demo/run/proto.json"

运行成功以后,会发现生成了两个文件

  • app/proxy/ProtoService.js - 调用服务的代理文件
  • run/proto.json - 从 .proto 文件中导出的接口信息,是一个 json 格式文件
.
├── app
│   ├── controller
│   │   └── home.js
│   ├── proxy
│   │   └── ProtoService.js
│   └── router.js
├── config
│   ├── config.default.js
│   ├── plugin.js
│   └── proxy.js
├── package.json
├── proto
│   └── ProtoService.proto
└── run
    └── proto.json

生成的 app/proxy/ProtoService.js 文件内容如下(注意:不要手动去改这个文件):

// Don't modified this file, it's auto created by egg-rpc-generator

'use strict';

const path = require('path');

/* eslint-disable */
/* istanbul ignore next */
module.exports = app => {
  const consumer = app.rpcClient.createConsumer({
    interfaceName: 'com.nodejs.rpc.ProtoService',
    targetAppName: 'sofarpc',
    version: '1.0',
    group: 'SOFA',
    proxyName: 'ProtoService',
  });

  if (!consumer) {
    // `app.config['sofarpc.rpc.service.enable'] = false` will disable this consumer
    return;
  }

  app.beforeStart(async() => {
    await consumer.ready();
  });

  class ProtoService extends app.Proxy {
    constructor(ctx) {
      super(ctx, consumer);
    }

    async echoObj(req) {
      return await consumer.invoke('echoObj', [ req ], { 
        ctx: this.ctx,
      });
    }
  }

  return ProtoService;
};
/* eslint-enable */

调用服务

上面定义的这个 ProtoService 这个类,会挂载在 app.proxyClasses 上。

通过 ctx.proxy.proxyName (注意这里是小驼峰)来访问生成的 proxy 代码,proxyName 就是上面 proxy.js 配置的 api 键值对中的 key。例如:上面配置的 ProtoService,但是需要特别注意的是 proxyName 会自动转成小驼峰形式,所以就是 ctx.proxy.protoService

下面我们在 home controller 调用 ProtoService 的 echoObj 方法

// app/controller/home.js
'use strict';

const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    const { ctx } = this;
    const res = await ctx.proxy.protoService.echoObj({
      name: 'gxcsoccer',
	  group: 'A',
    });
    ctx.body = res;
  }
}

module.exports = HomeController;

和调用本地方法体验一模一样。

相关资源链接

  • egg-sofa-rpc 插件
    https://github.com/eggjs/egg-sofa-rpc

  • egg-rpc-generator 工具
    https://github.com/eggjs/egg-rpc-generator

  • zookeeper
    http://zookeeper.apache.org/

你可能感兴趣的:(node)