NodeJs服务注册与服务发现实现

5,353 阅读3分钟

前言

由于作者才刚开始学习NodeJs,水平实在有限,本文更像是一篇学习笔记,适合同刚开始学习NodeJs的朋友阅读。

服务治理

如果你的团队正在探索微服务的搭建,那么你们可能就在寻找一种机制,这个机制让每个服务能动态的创建地址,同时调用方要能感知到这些服务地址的动态变化。服务注册与服务发现就是这其中一种机制,大概的流程为:

屏幕快照 2019-01-07 下午10.09.12.png

其中:

  • 注册中心:zookeeper
  • 服务提供者:NodeJs应用服务
  • 服务消费者:NodeJs API Gateway

ZooKeeper服务注册中心

ZooKeeper的身份是管理者,它是一个分布式数据一致性的解决方案,分布式任务可以基于它实现数据的发布与订阅、负载均衡、命名服务、分布式协调与通知、集群管理、领导选举、分布式锁、分布式队列等。本文并不会对所有的方面都展开讲解,因为作者也还没涉及到。我们的目标是利用ZooKeeper来实现一个服务的注册中心,如果你感兴趣,可以自己去研究看看,后面我研究了会再来分享的。

1、利用树状模型构建服务地址存储数据结构

zk内部有一个树状的内存模型,类似于文件系统,有若干目录,每个目录又可以有若干文件夹、文件,如下图:

屏幕快照 2019-01-07 下午10.37.56.png

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还可以转换协议,整合数据、认证、限速等逻辑。

屏幕快照 2019-01-08 上午12.26.26.png

例如前端有个用户获取的请求,应该这样写:

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变化;比如在选择服务的时候可以做负载均衡。