1. 程式人生 > >微服務註冊與發現

微服務註冊與發現

目錄

簡介

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

在釋出微服務時,可連線 ZooKeeper 來註冊微服務,實現“服務註冊”。當瀏覽器傳送請求後,可使用 Node.js 充當 service gateway,處理瀏覽器的請求,連線 ZooKeeper,發現服務配置,實現服務發現。

實現服務註冊元件

Service Registry(服務登錄檔),內部擁有一個數據結構,用於儲存已釋出服務的配置資訊。本節會使用 Spring Boot 與 Zookeeper 開發一款輕量級服務註冊元件。開發之前,先要做一個簡單的設計。

設計服務登錄檔資料結構

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

在根節點下再新增若干子節點,並使用服務名稱作為這些子節點的名稱,並稱之為 服務節點。為了確保服務的高可用性,我們可能會發布多個相同功能的服務,但由於 zookeeper 不允許存在同名的服務,因此需要再服務節點下再新增一層節點。因此服務節點則是持久的。

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

根據上面的分析,服務登錄檔資料結構模型圖如下所示

真實的服務註冊例項如下:

由上圖可見,只有地址節點才有資料,這些資料就是每個服務的配置資訊,即 IP 與埠,而且地址節點是臨時且順序的,根節點與服務節點都是持久的。

下面會根據這個設計思路,實現服務登錄檔的相關細節。但是在開發具體細節之前,我們先搭建一個程式碼框架。手續愛你我們需要建立兩個專案,分別是:

  • 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請求路由到可用的服務介面上。

參考

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