Pandora.js 的 Service 机制

Pandora.js 的 Service 机制

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

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

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

Service 解决什么问题?

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

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

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

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

当然有一些基本原则:

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

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

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

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

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

Service

Service 主要分为两个部分:

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

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

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

我们先编写一个 procfile.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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 实现:

1
2
3
4
5
6
7
8
9
10
11
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()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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

1
2
3
4
$ 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,我们就可以前台启动这个应用:

1
2
3
4
$ 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 中的注册情况

1
2
$ 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)。

这时应该:

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

然后再看看 etcd 中:

1
2
$ 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 进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
...
...

/**
* 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 获得客户端地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 通过 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 软件的机会,挑战不小,当然回报也不小。

文章来源:

Author:Taobao FED
link:http://taobaofed.org/blog/2017/12/19/pandora-service/