前言
由于作者才刚开始学习NodeJs,水平实在有限,本文更像是一篇学习笔记,适合同刚开始学习NodeJs的朋友阅读。
服务治理
如果你的团队正在探索微服务的搭建,那么你们可能就在寻找一种机制,这个机制让每个服务能动态的创建地址,同时调用方要能感知到这些服务地址的动态变化。服务注册与服务发现就是这其中一种机制,大概的流程为:
其中:
- 注册中心:zookeeper
- 服务提供者:NodeJs应用服务
- 服务消费者:NodeJs API Gateway
ZooKeeper服务注册中心
ZooKeeper的身份是管理者,它是一个分布式数据一致性的解决方案,分布式任务可以基于它实现数据的发布与订阅、负载均衡、命名服务、分布式协调与通知、集群管理、领导选举、分布式锁、分布式队列等。本文并不会对所有的方面都展开讲解,因为作者也还没涉及到。我们的目标是利用ZooKeeper来实现一个服务的注册中心,如果你感兴趣,可以自己去研究看看,后面我研究了会再来分享的。
1、利用树状模型构建服务地址存储数据结构
zk内部有一个树状的内存模型,类似于文件系统,有若干目录,每个目录又可以有若干文件夹、文件,如下图:
zk有4种节点(会话指客户端连接zk的长连接):
- 持久节点:当会话结束时,节点不会被删除
- 持久顺序节点:当会话结束时,节点不会删除,节点名自带增数后缀
- 临时节点:当会话结束时,节点会被删除
- 临时顺序节点:当会话结束时,节点会被删除,节点名自带增数后缀
只有持久节点才能有子节点。
现在一般是集群部署应用,所以我们来看下集群部署下的服务地址状况。例如,当你拥有应用A,应用A部署在2台机器上,机器IP分别为:127.0.0.1和127.0.0.2,应用服务端口6666,应用A就有这么两个服务地址:127.0.0.1:6666、127.0.0.2:6666
我们指定一个节点来作为所有服务地址的根节点(类似命名空间),所以该节点应该为一个持久节点。我们有n个应用,每个应用下有n台机器,所以应用节点也拥有子节点,也应该是持久节点。每台机器在启动应用服务的时候要向zk注册一个地址,在服务下线的时候要删除zk中的地址,所以使用临时节点特点正好符合这个行为,同时可以使用顺序节点自动帮我们管理节点名称。
因为我们都是使用node操作,所以使用zk的node客户端node-zookeeper-client。
2、app启动前连接zk
本来我是用eggjs插件写的,这里将框架的东西剔除,其他提取出来,这样就不和框架挂钩了。
const { createClient, ACL, CreateMode } = require('node-zookeeper-client');
const zkClient = createClient('127.0.0.1:2181');
const promisify = require('util').promisify;
zkClient.connect();
zkClient.once('connected', () => {
registerService();
});
// 让zkClient支持promise
const proto = Object.getPrototypeOf(zkClient);
Object.keys(proto).forEach(fnName => {
const fn = proto[fnName];
if (proto.hasOwnProperty(fnName) && typeof fn === 'function') {
zkClient[`${fnName}Async`] = promisify(fn).bind(zkClient);
}
});
// host和port应该和部署系统结合分配
// serviceName要求唯一
const { serviceName, host, port } = config;
async function registerService() {
try {
// 创建根节点,持久节点
const rootNode = await zkClient.existsAsync('/services');
if (rootNode == null) {
await zkClient.createAsync('/services', null, ACL.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
// 创建服务节点,持久节点
const servicePath = `/services/${serviceName}`;
const serviceNode = await zkClient.existsAsync(servicePath);
if (serviceNode == null) {
await zkClient.createAsync(servicePath, null, ACL.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
// 创建地址节点,临时顺序节点,这样name就不需要我们去维护了,递增
const addressPath = `${servicePath}/address-`;
const serviceAddress = `${host}:${port}`;
const addressNode = await zkClient.createAsync(addressPath, Buffer.from(serviceAddress), ACL.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
} catch (error) {
throw new Error(error);
}
}
上面的代码其实很简单,就是连接zk后先判断根节点是不是创建,如果没有就创建(第一个应用),然后判断应用节点是否创建,没有就创建(集群第一台机器),最后就是创建机器节点,这里使用临时顺序节点,省去了我们维护唯一name的麻烦,让其递增,注意,host和port作为存储内容,这个需要app部署的时候部署系统提供(如果是使用自动部署系统的话),然后地址转为Buffer存起来。
这样其实服务注册就完成了。
NodeJs API Gateway 网关服务
上面已经在服务启动的时候都注册到zk中了,当前端调用接口访问服务的时候,我们需要知道服务的地址,这就是服务发现过程。
API Gateway如它的字面意思来看,是API的入口,用来路由请求。其实,不单单是路由请求,API Gateway还可以转换协议,整合数据、认证、限速等逻辑。
例如前端有个用户获取的请求,应该这样写:
fetch('/api/user/get', {
method: 'POST',
body: { id: 1 },
headers: {
// header的方式指定service
'servive-name': 'user'
}
})
API Gateway本质也是是个服务,使用Eggjs编写,我们的服务发现封装成一个中间件,所以这里只展示中间件的内容,其他的自己看egg的文档。
const proxy = require('koa-proxies');
module.exports = (options, app) => {
return async (ctx, next) => {
const serviceName = ctx.request.headers['servive-name'];
if (!serviceName) {
ctx.throw(404, 'no service found.');
}
const servicePath = `/services/${serviceName}`;
const addressNodes = await app.zookeeper.getChildrenAsync(servicePath);
const size = addressNodes.length;
if (size === 0) {
ctx.throw(404, 'no service found.');
}
let addressPath = `${servicePath}/`;
if (size === 1) {
addressPath += addressNodes[0];
} else {
// 这里你可以做负载均衡
addressPath += addressNodes[parseInt(Math.random() * size)];
}
const serviceAddress = await app.zookeeper.getDataAsync(addressPath);
if (!serviceAddress) {
ctx.throw(404, 'no service found.');
}
await proxy('/', {
target: `http://${serviceAddress}/`,
})(ctx, next);
};
};
上面的中间件中根据headers中的service-name去获取到该应用下所有的服务地址,然后根据某个策略选择一个服务,使用代理转发到对应的服务。
总结
以上很简陋地实现了,虽然可以使用,还有很多细节要处理,比如API Gateway中对于已经拿到的服务地址可以缓存起来,然后订阅zk变化;比如在选择服务的时候可以做负载均衡。