Pandora.js 的 Service 机制
作者:九十 发布于:2017-12-20 08:19:29

这篇依然是介绍 Pandora.js 的系列文章之一
项目地址:https://github.com/midwayjs/pandora 欢迎社会各界前来 Star ~


本章主要介绍我们的 Service 机制,是 Pandora.js 对于进程编排的一种高级机制。


Q:为什么要有这种机制?
A:因为进程是昂贵的,我们需要有一种机制可以管理进程内的启停。


Service 解决什么问题?


我们希望,Service 能做一些应用流程之外的事情,比如:


  1. 基础的中间件管理(比如 etcd)
  1. 需要标准上下线流程的服务,比如 RPC Provider
  1. 需要和应用隔离的一些服务,启动停止时同步文件等等


通过一定的规范体系,把常用的,需要内聚的程序逻辑放到一起,我们就把这些逻辑称为 "Service"。


如果程序本身的逻辑之外,我们还需要考虑和 Pandora.js 整体,进程编排逻辑,应用的启动生命周期相关联,我们还考虑了其他方面的东西。


当然有一些基本原则:


  • 接口简单易用,便于实现
  • 把启动流程尽量统一化


除了上面两条之外,还有一些其他的原则,简述如下:


  1. 异步启动: 无法让进程异步的启动,如果应用启动需要几秒,没办法知道什么时候才算启动好了。
  • 我们之前的做法是定时轮询 HTTP 接口是否暴露,相当地 Tricky。
  1. 异步停止: 关闭全靠 kill (也许还要 -9),RPC 、Web 服务也不好做平滑下线。
  • 我们经常因为这个问题,在发布新版时收到一些接口调用超时的错误报警。
  1. 进程是昂贵的: 一个进程一个入口文件不能结构复用宝贵的进程。
  • 当然直接在入口文件里直接 require,或者像 egg 的 plugin 机制都能解决这个问题。不过依然不够通用,分层也不够清晰。


最终确定了,Service 主要提供了如下的能力:


  1. 标准的 async start() 接口
  1. 标准的 async stop() 接口
  1. 结构化的日志管理、配置能力
  1. 进程内的启动顺序(依赖关系)管理


Service


Service 主要分为两个部分:


  1. procfile.js 中的链式定义语法
  1. 实现 Service 的接口约束


接下来通过一个自发现的 RPC Provider 来介绍


下面的例子在:https://github.com/midwayjs/pandora-example/tree/master/rpc


我们先编写一个 procfile.js


module.exports = function(pandora) {

  /**
   * Part 1 : 基础 Service
   * Etcd 是所有进程都要有的基础 Service
   * 使用 weak-all 分配到全部启动了的进程
   */
  pandora

    // 定义 Service 的名字叫 etcd
    .service('etcd', './services/Etcd')

    // weak-all 表示这个 service 不激活任何进程
    // 但是会分配到激活了的进程中去,比如下面被 tryRpc 激活的了的 rpc 进程
    .process('weak-all')

    // 配置 etcd 的地址
    .config({
      host: 'http://localhost:2379'
    });

  /**
   * Part 2 : RPC 进程
   */
  pandora

  // 定义一个进程专门发布 RPC
    .process('rpc')
    .scale(1);

  // 向 rpc 进程注入一个 叫 tryRpc 的 RPC Provider
  pandora
    .service('tryRpc', './services/TryRpc')
    .process('rpc')
    .config({
      port: 5222
    });
};


我们先把最基础的中间件 etcd services/Etcd.js 实现:


const NodeEtcd = require('node-etcd');

/**
 * 简单继承 NodeEtcd 即可
 */
module.exports = class Etcd extends NodeEtcd {
  constructor(ctx) {
    // 从 ctx 中获得配置,传给父类构造
    super(ctx.config.host);
  }
};


然后我们再来编写 RPC Provider 的实现 services/TryRpc.js


主要关注里面的 async start()async stop()


const {promisify} = require('util');
const jayson = require('jayson/promise');
const uuid = require('uuid');

/**
 * 实现一个服务自发现的 Provider
 */
class TryRpc {

  /**
   * @param ctx 构造时会传递一个上下文对象,这个具体可以参考:
   *   http://www.midwayjs.org/pandora/api-reference/pandora/classes/servicecontextaccessor.html
   */
  constructor(ctx) {

    // 生成个 UUID 作这个 Provider 的标识好了
    this.uuid = uuid.v4();

    // 标准的 Logger 对象
    //   http://www.midwayjs.org/pandora/api-reference/pandora/classes/servicecontextaccessor.html#logger
    this.logger = ctx.logger;

    // 从 config 里拿 RPC 监听的地址
    this.port = ctx.config.port;
    this.host = ctx.config.host || '127.0.0.1';

    // 从依赖里获得 etcd
    //   http://www.midwayjs.org/pandora/api-reference/pandora/classes/servicecontextaccessor.html#getdependency
    this.etcd = ctx.getDependency('etcd');

    // 得到自己在 etcd 上的 key
    this.etcdKey = '/JSONRPC/' + this.uuid;

    // 得到自己在 etcd 上的 Value
    this.etcdValue = JSON.stringify({
      uuid: this.uuid,
      hostname: this.host,
      port: this.port
    });

    // 通过 jayson 创建一个 RPC 服务
    this.server = jayson.server(this.getRpcMethods());

    // 我们通过 jayson 的 http 界面暴露服务
    this.http = this.server.http();
  }

  /**
   * 获得 RPC 中暴露的方法
   */
  getRpcMethods() {
    return {
      async add(args) {
        return args[0] + args[1];
      },
      async mul(args) {
        return args[0] * args[1];
      }
    };
  }

  /**
   * 标准的启动接口
   */
  async start() {
    // 将我们的 RPC 的 HTTP 界面进行监听
    await promisify(this.http.listen).call(this.http, this.port, this.host);

    // 在 etcd 中暴露,并且定时心跳
    await this.startHeartbeat();

    // 启动完成
    this.logger.info('Service JSON RPC Listens On http://' + this.host + ':' + this.port);
  }

  /**
   * 标准的停止接口
   */
  async stop() {
    // 清除心跳定时器
    if(this.timer) {
      clearInterval(this.timer);
    }

    // 把自己从 etcd 上删除,等于下线
    await promisify(this.etcd.del).call(this.etcd, this.etcdKey);

    // TODO: 等待所有的 RPC 调用都结束,否则会出现客户端调用超时

    // 已经从 etcd 上下线,并且所有存量 RPC 调用也已经完成
    // 关闭 HTTP 监听
    await promisify(this.http.close).call(this.http);

    // 下线完成
    this.logger.info('Service JSON RPC Stopped');
  }

  /**
   * 开始向 etcd 注册,并开始心跳
   */
  async startHeartbeat() {
    const interval = 30;
    const value = this.etcdValue;
    const once = () => {
      return promisify(this.etcd.set).call(this.etcd, this.etcdKey, value, {ttl: interval * 2})
        .catch(this.logger.error.bind(this.logger));
    };
    await once();
    this.timer = setInterval(once, interval * 1000);
  }
}

// 标记依赖,在 TC39 Stage 2 中可以使用 static dependencies 代替
TryRpc.dependencies = ['etcd'];
module.exports = TryRpc;


本地启动一个试试看


首先我们启动本地的 etcd (当然你得先安装 etcd, Mac 的话直接 brew install etcd


$ etcd # 启动 etcd
2017-12-19 14:09:20.292891 I | etcdmain: etcd Version: 3.2.11
2017-12-19 14:09:20.293013 I | etcdmain: Git SHA: GitNotFound
...


将这三个文件放到同一目录下(目录叫 tryPandora),然后运行 pandora dev,我们就可以前台启动这个应用:


$ pandora dev # 本地前台启动
2017-12-19 14:25:16,619 INFO 42977 [serviceName: tryRpc, processName: rpc] Service JSON RPC Listens On http://127.0.0.1:5222
2017-12-19 14:25:16,621 INFO 42975 Process [name = rpc, pid = 42977] Started successfully!
Application start successful.


我们看到 Service JSON RPC Listens On http://127.0.0.1:5222,RPC Provider 已经成功监听。而 Application start successful. 的提示永远在其之后。


然后我们查看 etcd 中的注册情况


$ curl http://127.0.0.1:2379/v2/keys/JSONRPC
{"action":"get","node":{"key":"/JSONRPC","dir":true,"nodes":[{"key":"/JSONRPC/5a32f5ab-f423-4b3c-b661-4a12e8ece5b2","value":"{\"uuid\":\"5a32f5ab-f423-4b3c-b661-4a12e8ece5b2\",\"hostname\":\"127.0.0.1\",\"port\":5222}","expiration":"2017-12-19T06:29:46.648936363Z","ttl":59,"modifiedIndex":11,"createdIndex":11}],"modifiedIndex":4,"createdIndex":4}}


看上去已经发布到 etcd 中了。


停止应用看看


我们按 Ctrl + C 停止应用,我们可以看到同样有 Service JSON RPC Stopped 的输出,表示 RPC 已经成功取消监听了,而进程的退出的提示永远在其之后(比如 Process [name = rpc, pid = 13357] Exit with code 0 and signal null)。


这时应该:


  1. 所有的存量 RPC 已经结束
  1. 已从 etcd 中下线


然后再看看 etcd 中:


$ curl http://127.0.0.1:2379/v2/keys/JSONRPC
{"action":"get","node":{"key":"/JSONRPC","dir":true,"modifiedIndex":4,"createdIndex":4}}


看上去也已经从 etcd 中下线。


实现个然并卵的消费端


我们实现一个 HTTP Server,作为一个 RPC 的消费端,通过 etcd 发现 RPC 服务并进行调用。


浏览器 -> HTTP Server -> etcd -> RPC Provider


那我们在 procfile.js 中加入一个 web 进程:


...
  ...
  ...
  
  /**
   * Part 3 : 测试用的 Web 进程
   * 一个测试进程,本不应该存在的,为了方便例子演示
   */
  // 定义一个进程专门发布 Web 服务
  pandora
    .process('web')
    .scale(1);

  // 向 rpc 进程注入一个 叫 tryWeb 的 Web 实现 Service
  pandora
    .service('tryWeb', './services/TryWeb')
    .process('web')
    .config({
      port: 5555
    });
  
  ...
  ...
  ...


然后我们的消费端是一个 HTTP 服务 services/TryWeb.js


太长了就不直接贴代码了,https://github.com/midwayjs/pandora-example/blob/master/rpc/services/TryWeb.js


里面的核心通过是 etcd 获得客户端地址:


/**
   * 通过 etcd 获得 RPC 客户端
   */
  async getRpcClient() {

    // etcd 中获得全部可用 nodes
    const etcdRes = await promisify(this.etcd.get).call(this.etcd, '/JSONRPC/', {recursive: true});
    const nodes = etcdRes.node.nodes;
    if(!nodes || !nodes.length) {
      throw new Error('Cannot found provider');
    }

    // 随机取一个(虽然我们例子中,怎么样都只有一个)
    const randomInt = getRandomInt(0, nodes.length - 1);
    const pickedNode = nodes[randomInt];
    const node = JSON.parse(pickedNode.value);
    this.logger.info('total got nodes: ' + nodes.length);
    this.logger.info('use node: ' + pickedNode.value);

    // 创建 client
    const client = jayson.client.http({
      hostname: node.hostname,
      port: node.port
    });

    return client;
  }


然后再用浏览器访问:


http://127.0.0.1:5555/?method=add&params=[1,5]
{"jsonrpc":"2.0","id":"30a7767a-1173-41f3-be22-94376d9409f1","result":6}


这是一个简单的消费端例子,虽然是在一个应用里通过 etcd 服务发现了自己,感觉没什么用处。


未完待续


这样 Pandora.js 的 Service 模型就介绍完毕了,下一篇介绍进程间通信。进程模型介绍完之后开始介绍业务度量的能力,比如 Metrics、全链路 Trace 等,大家敬请期待。


最后,不要忘了给点个 Star 喔~


https://github.com/midwayjs/pandora/


最后的最后,我们招人。我们有超过一半的淘宝前台访问在 Node.js 上,也有做开源 Node.js 软件的机会,挑战不小,当然回报也不小。