微服務的註冊與發現

簡介

先來回顧下總體的微服務架構html

在發佈微服務時,可鏈接 ZooKeeper 來註冊微服務,實現「服務註冊」。當瀏覽器發送請求後,可以使用 Node.js 充當 service gateway,處理瀏覽器的請求,鏈接 ZooKeeper,發現服務配置,實現服務發現。前端

實現服務註冊組件

Service Registry(服務註冊表),內部擁有一個數據結構,用於存儲已發佈服務的配置信息。本節會使用 Spring Boot 與 Zookeeper 開發一款輕量級服務註冊組件。開發以前,先要作一個簡單的設計。java

設計服務註冊表數據結構

首先在 Znode 樹狀模型下定義一個 根節點,並且這個節點是持久的。node

在根節點下再添加若干子節點,並使用服務名稱做爲這些子節點的名稱,並稱之爲 服務節點。爲了確保服務的高可用性,咱們可能會發布多個相同功能的服務,但因爲 zookeeper 不容許存在同名的服務,所以須要再服務節點下再添加一層節點。所以服務節點則是持久的。web

服務節點下的這些子節點稱爲 地址節點 。每一個地址節點都對應於一個特定的服務,咱們將服務配置存放在該節點中。服務配置中可存放服務的 IP 和端口。一旦某個服務成功註冊到 Zookeeper 中, Zookeeper 服務器就會與服務所在的客戶端進行心跳檢測,若是某個服務出現了故障,心跳檢測就會失效,客戶端將自動斷開與服務端的會話,對應的地址節點也須要從 Znode 樹狀模型中移除。所以 地址節點必須是臨時並且有順序的算法

根據上面的分析,服務註冊表數據結構模型圖以下所示spring

真實的服務註冊實例以下:
shell

由上圖可見,只有地址節點纔有數據,這些數據就是每一個服務的配置信息,即 IP 與端口,並且地址節點是臨時且順序的,根節點與服務節點都是持久的。express

下面會根據這個設計思路,實現服務註冊表的相關細節。可是在開發具體細節以前,咱們先搭建一個代碼框架。手續愛你咱們須要建立兩個項目,分別是:

  • msa-sample-api 用於存放服務 API 代碼,包含服務定義相關細節。
  • msa-framework 存放框架性代碼,包含服務註冊表細節

定義好項目後,就須要再 msa-sample-api 項目中編寫服務的業務細節,在 msa-framework 項目中完成服務註冊表的具體實現。

搭建應用程序框架

msa-sample-api 項目中搭建 Spring Boot 應用程序框架,建立一個名爲 HelloApplication 的類,該類包含一個 hello() 方法,用於處理 GET:/hello 請求。

package demo.msa.sample;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@SpringBootApplication
public class HelloApplication {
    public static void main(String[] args) {
        SpringApplication.run(HelloApplication.class, args);
    }
    
    @RequestMapping (method= RequestMethod.GET, path = "/hello")
    public String hello() {
        return "hello";
    }
}

隨後,在 application.properties 文件中添加以下配置項

server.port=8080
spring.application.name=msa-sample-api
registry.zk.servers=127.0.0.1:2181

之因此設置 spring.application.name 配置項,是由於咱們正好將其做爲服務名稱來使用。registry.zk.servers 配置項表示服務註冊表的 IP 與端口,實際上就是 Zookeeper 的鏈接字符串。若是鏈接到 Zookeeper 集羣環境,就可使用逗號來分隔多個 IP 與端口,例如: ip1:port,ip2:port,ip3:port

最後配置 maven 依賴:

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>demo.msa</groupId>
    <artifactId>msa-sample-api</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>msa-sample</name>
    <url>http://maven.apache.org</url>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.4.6.RELEASE</version>
    </parent>

    <dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <exclusions>
            <exclusion>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-logging</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-log4j2</artifactId>
    </dependency>
    <dependency>
        <groupId>demo.msa</groupId>
        <artifactId>msa-framework</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

定義服務註冊表接口

服務註冊表接口用於註冊相關服務信息,包括

  • 服務名稱
  • 服務地址包括
    • 服務所在機器的 IP
    • 服務所在機器的端口

msa-framework 項目中建立一個名爲 ServiceRegistry 的 Java 接口類,代碼以下:

package demo.msa.framework.registry;

public interface ServiceRegistry {
    
    /**
     * 註冊服務信息
     * @param serviceName 服務名稱
     * @param serviceAddress 服務地址
     */
    void register(String serviceName, String serviceAddress);

}

下面來實現 ServiceRegistry 接口,它會經過 ZooKeeper 客戶端建立響應的 ZNode 節點,從而實現服務註冊。

使用 ZooKeeper 實現服務註冊

msa-framework 中建立一個 ServiceRegistry 的實現類 ServiceRegistryImpl 。同時還須要實現 ZooKeeper 的 Watch 接口,便於監控 SyncConnected事件,以鏈接 ZooKeeper 客戶端。

package demo.msa.framework.registry;

import java.util.concurrent.CountDownLatch;

import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.ZooKeeper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ServiceRegistryImpl implements ServiceRegistry, Watcher {
    
    private static final String REGISTRY_PATH = "/registry";
    
    private static final int SESSION_TIMEOUT = 5000;

    private static final Logger logger = LoggerFactory.getLogger(ServiceRegistryImpl.class);
    
    private static CountDownLatch latch = new CountDownLatch(1);
    
    private ZooKeeper zk;
    
    public ServiceRegistryImpl() {
        // TODO Auto-generated constructor stub
    }
    
    public ServiceRegistryImpl(String zkServers) {
        try {
            // 建立 zookeeper
            zk = new ZooKeeper(zkServers, SESSION_TIMEOUT, this);
            latch.await();
            logger.debug("connect to zookeeper");
        } catch (Exception ex) {
            logger.error("create zk client fail", ex);
        }
    }
    
    @Override
    public void register(String serviceName, String serviceAddress) {
        try {
            // 建立根節點(持久節點)
            if (zk.exists(REGISTRY_PATH, false) == null) {
                zk.create(REGISTRY_PATH, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
                logger.debug("create registry node: {}", REGISTRY_PATH);
            }
            
            // 建立服務節點 (持久節點)
            String servicePath = REGISTRY_PATH + "/" + serviceName;
            if (zk.exists(servicePath, false) == null) {
                zk.create(servicePath, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
                
                logger.debug("create registry node: {}", REGISTRY_PATH);
            }
            
            // 建立地址節點  (臨時有序節點)
            String addresspath = servicePath + "/address-";
            String addressNode = zk.create(addresspath, serviceAddress.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            logger.debug("create address node: {} => {}", addressNode, serviceAddress);
            if (zk.exists(REGISTRY_PATH, false) == null) {
                zk.create(REGISTRY_PATH, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
                logger.debug("create registry node: {}", REGISTRY_PATH);
            }
            
            String servicePath = REGISTRY_PATH + "/" + serviceName;
            if (zk.exists(servicePath, false) == null) {
                zk.create(servicePath, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
                
                logger.debug("create registry node: {}", REGISTRY_PATH);
            }
            
            String addresspath = servicePath + "/address-";
            String addressNode = zk.create(addresspath, serviceAddress.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            logger.debug("create address node: {} => {}", addressNode, serviceAddress);
        } catch(Exception ex) {
            logger.error("create node fail", ex);
        }
        
    }

    @Override
    public void process(WatchedEvent event) {
        if (event.getState() == Event.KeeperState.SyncConnected) {
            latch.countDown();
        }
    }
}

使用 ZooKeeper 的客戶端 API, 很容易建立 ZNode 節點,只是在調用節點以前有必要調用 exists() 方法,判斷將要建立的的節點是否已經存在。須要注意, 根節點和服務節點都是持久節點 ,只有地址節點是臨時有序節點。而且有必要在建立節點完成後輸出一些調試信息,來獲知節點是否建立成功了。

咱們的指望是,當 HelloApplication 程序啓動時,框架會將其服務器 IP 與端口註冊到服務註冊表中。實際上,在 ZooKeeper 的 ZNode 樹狀模型上將建立 /registry/msa-sample-api/address-0000000000 節點,該節點所包含的數據爲 127.0.0.1:8080msa-framework 項目則封裝了這些服務註冊行爲,這些行爲對應用端徹底透明,對 ServiceRegistry 接口而言,則須要在框架中調用 register()方法,並傳入 serviceName 參數(/registry/msa-sample-api/address-0000000000)與 serviceAddress 參數(127.0.0.1:8080)。

接下來要作的就是經過編寫 Spring 的 @configuration 配置類來建立 ServiceRegistry 對象,並調用 register() 方法。具體代碼以下:

package demo.msa.sample.config;

import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.UnknownHostException;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import demo.msa.framework.registry.ServiceRegistry;
import demo.msa.framework.registry.ServiceRegistryImpl;

@Configuration
public class RegistryConfig {
    @Value("${registry.zk.servers}")
    private String servers;
    
    @Value("${server.port}")
    private int serverPort;
    
    @Value("${spring.application.name}")
    private String serviceName;
    
    
    @Bean
    public ServiceRegistry serviceRegistry() {
        ServiceRegistry serverRegistry = new ServiceRegistryImpl(servers);
        String serviceAdress = getServiceAddress();
        serverRegistry.register(serviceName, serviceAdress);
        return serverRegistry;
    }
    
    private String getServiceAddress() {
        InetAddress localHost = null;
        try {
          localHost = Inet4Address.getLocalHost();
        } catch (UnknownHostException e) {
        }
        String ip = localHost.getHostAddress();
        
        return ip + ":" + serverPort;
    }

}

其中,getServiceAddress 方法用來獲取服務運行的本機地址和端口。

此時,服務註冊組件已經基本開發完畢,此時可啓動 msa-sample-api 應用程序,並經過命令客戶端來觀察 ZooKeeper 的 ZNode 節點信息。經過下面命令鏈接到 ZooKeeper 服務器,並觀察註冊表中的數據結構:

$ bin/zkCli.sh

服務註冊表數據結構以下所示:

[zk: localhost:2181(CONNECTED) 4] ls /registry/msa-sample-api
[address-0000000001]
[zk: localhost:2181(CONNECTED) 5] get /registry/msa-sample-api/address-0000000001
127.0.0.1:8080
cZxid = 0x79
ctime = Sun Jan 06 18:22:18 CST 2019
mZxid = 0x79
mtime = Sun Jan 06 18:22:18 CST 2019
pZxid = 0x79
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x16817f3391b002c
dataLength = 16
numChildren = 0

服務註冊模式

服務註冊 (Service Registry) 是一種微服務架構的核心模式,咱們能夠在微服務網站上了解它的詳細內容。

Service Registry 模式: https://microservices.io/patterns/service-registry.html

有兩種服務註冊模式

除了 ZooKeeper,還有一些其餘的開源服務註冊組件,好比 Eureka, Etcd, Consul 等。

實現服務發現組件

服務發現組件在微服務架構中由 Service Gateway(服務網關)提供支持,前端發送的 HTTP 請求首先會進入服務網關,此時服務網關將從服務註冊表中獲取當前可用服務對應的服務配置,隨後將經過 反向代理技術 調用具體的服務。像這樣獲取可用服務配置的過程稱爲 服務發現。服務發現是整個微服務架構中的 核心組件,該組件不只須要 高性能,還要支持 高併發,還需具有 高可用

當咱們啓動多個 msa-sample-api 服務(調整爲不一樣的端口)時,會在服務註冊表中註冊以下信息:

/registry/msa-sample-api/address-0000000000 => 127.0.0.1:8080
/registry/msa-sample-api/address-0000000001 => 127.0.0.1:8081
/registry/msa-sample-api/address-0000000002 => 127.0.0.1:8082

以上結構表示同一個 msa-sample-api 服務節點包含 3 個地址節點,每一個地址節點都包含一組服務配置(IP 和端口)。咱們的目標是,經過服務節點的名稱來獲取其中某個地址節點所對應的服務配置。最簡單的作法是隨機獲取一個地址節點,固然能夠根據 輪詢 或者 哈希 算法來獲取地址節點。

所以,要實現以上過程,咱們必須得知服務節點的名稱是什麼,也就是服務名稱是什麼,能夠經過服務名稱來獲取服務配置,那麼,如何獲取服務名稱呢?

當服務網關接收 HTTP 請求時,咱們可以很輕鬆的獲取請求的相關信息,最容易獲取服務名稱的地方就是請求頭,咱們不妨 添加一個名爲 Service-Name 的自定義請求頭,用它來定義服務名稱,隨後可在服務網關中獲取該服務名稱,並在服務註冊表中根據服務名稱來獲取對應的服務配置。

搭建應用程序框架

咱們再建立一個項目,名爲 msa-service-gateway ,它至關於整個微服務架構中的前端部分,其中包括一個服務發現框架。至於測試請求,可使用 firefox 插件 RESTClient 來完成。

項目msa-service-gateway 包含兩個文件

  • app.js :服務網關應用程序,經過 Node.js 來實現
  • package.json 用於存放 Node.js 的基本信息,以及所依賴的 NPM 模塊。

首先在 package.json 文件中添加代碼

{
  "name": "msa-service-gateway",
  "version": "1.0.0",
  "dependencies": {
  }
}

實現服務發現

實現服務發現,須要安裝 3 個模塊,分別是

  • express : web Server 應用框架
  • node-zookeeper-client: node.js zooKeeper 客戶端
  • http-proxy : 代理模塊

使用下面命令來依次安裝它們

npm install express -save
npm install node-zookeeper-client -save
npm install http-proxy -save

app.js 的代碼以下所示

var express = require('express')
var zookeeper = require('node-zookeeper-client')
var httpProxy = require('http-proxy')

var REGISTRY_ROOT = '/registry';

var CONNECTION_STRING = '127.0.0.1:2181';
var PORT = 1234;

// 鏈接 zookeeper
var zk = zookeeper.createClient(CONNECTION_STRING);
zk.connect();

// 建立代理服務器對象並監聽錯誤事件
var proxy = httpProxy.createProxyServer()
proxy.on('error', function(err, req, res) {
  res.end();
})

var app = express();
// 攔截全部請求
app.all('*', function (req, res) {
  // 處理圖標請求
  if (req.path == '/favicon.ico') {
    res.end();
    return;
  }

  // 獲取服務名稱
  var serviceName = req.get('Service-Name');
  console.log('serviceName: %s', serviceName);
  if (!serviceName) {
    console.log('Service-Name request header is not exist');
    res.end();
    return
  }

  // 獲取服務路徑
  var servicePath = REGISTRY_ROOT + '/' + serviceName;
  console.log('serviceName: %s', servicePath)

  // 獲取服務路徑下的地址節點
  zk.getChildren(servicePath, function (error, addressNodes) {
    if (error) {
      console.log(error.stack);
      res.end();
      return;
    }

    var size = addressNodes.length;
    if (size == 0) {
      console.log('address node is not exist');
      res.end();
      return;
    }

    // 生成地址路徑
    var addressPath = servicePath + '/';
    if (size === 1) {
      // 若是隻有一個地址,則獲取該地址
      addressPath += addressNodes[0];
    } else {
      // 若存在多個地址,則隨機獲取一個地址
      addressPath += addressNodes[parseInt(Math.random()*size)]
    }

    console.log('addressPath: %s', addressPath)

    zk.getData(addressPath, function(error, serviceAddress) {
      if (error) {
        console.log(error.stack);
        res.end();
        return;
      }

      console.log('serviceAddress: %s', serviceAddress)

      if (!serviceAddress) {
        console.log('service address is not exist')
        res.end()
        return
      }

      proxy.web(req, res, {
        target: 'http://' + serviceAddress
      });
    })
})
});

app.listen(PORT, function() {
  console.log('server is running at %d', PORT)
})

使用下面命令啓動 web server:

$ node app.js

此時,使用 firefox 插件 RESTClient 向地址 http://localhost:1234/hello 發送請求,記得要配置 HTTP 頭字段 Service-Name=msa-sample-api 。能夠獲取到結果 hello

在 Node.js 控制檯能夠看到以下輸出結果。

$ node app.js
server is running at 1234
serviceName: msa-sample-api
serviceName: /registry/msa-sample-api
addressPath: /registry/msa-sample-api/address-0000000001
serviceAddress: 127.0.0.1:8080

服務發現優化方案

服務發現組件雖然基本可用,但實際上代碼中還存在着大量的不足,須要咱們不斷優化(這部份內容後續完善)。

  1. 鏈接 ZooKeeper 集羣環境

  2. 對服務發現的目標地址進行緩存

  3. 使服務網關具有高可用性

服務發現模式

服務發現 servicer discovery 是一種微服務架構的核心模式,它通常與服務註冊模式共同使用。

服務發現模式分爲兩種:

  • 客戶端發現 client side discovery
    • 是指服務發現機制在客戶端中實現
  • 服務端發現 server side discovery
    • 服務發現機制經過一個路由中間件來實現
    • 當前實現的就是服務端發現模式

Ribbon 是一款基於 Java 的 HTTP 客戶端附件,它能夠查詢 Eureka,將 HTTP請求路由到可用的服務接口上。

參考

  • 《架構探險—輕量級微服務架構》