Spring Cloud 元件實作 (上)

Spring Cloud 元件實作 (上)

微服務已經徹底改變了我們設計和開發現代應用程式的方式。憑藉其對可擴展性、模組化和彈性的關注,微服務為工程師提供了一種強大的方法來構建堅固且適應性強的系統。在本教程中,我們將深入探討微服務的世界,探索其關鍵概念、優點和最佳實踐。無論您是一位經驗豐富的工程師想要提升技能,還是一位初學者渴望學習,本筆記將為您提供必要的知識,讓您踏上微服務之旅。讓我們一起深入並發掘微服務的潛力吧!

本筆記參考以下課程:

其他針對此門課程找到的筆記:

搭配 Bito 的 GPT-4 外掛進行學習:

課程架構圖概覽:

建立專案

請參考 Github 專案目錄:Click

根據教學影片指引建立的練習專案,但是使用 Docker 建立微服務。

服務註冊元件

由 Eureka 開始學習,其他的註冊中心都是依照此種架構類推出來的。

Eureka

請先參考以下架構圖:

  1. Eureka Server 有多個,避免單點故障
  2. Service Provider 也有多個,其會需要持續通知 Eureka 本服務還存活中。

Eureka 總共分成兩個部分:

  1. EurekaServer:提供服務註冊服務
    • 全部的微服務會在這裡進行註冊,而 EurekaServer 就會儲存這些服務作為列表,使其服務可以被觀測到
  2. EurekaClient:Client 會透過 Server 去與其他服務溝通
    • 這是一個 Java Client,用於簡化跟 EurekaServer 之間的溝通。
    • 預設 30 秒會向 EurekaServer 發送 heartbeat,如果於好幾個通知週期內沒有持續通知,EurekaServer 就會把該服務移除。

Server 與 Client 的單點建立

Server 的部分直接建立新 Project,作為一個 Springboot 服務即可。

於 Gradle 中引入:

// Eureka server
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server:4.0.1'

並於啟動類別加上 Annotation:

@SpringBootApplication
@EnableEurekaServer // Eureka server 標註
class EurekaServerApplication

至於 Client 的部分,可以於各個微服務中,再引入以下 Library:

// Eureka Client
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client:4.0.1'

這裡注意在新版的 Springcloud 中,EurekaClient 不再需要像 Server 一樣加上 Annotation,Springcloud 可以自己辨別,只要於 application.yml 中有設定好即可。

調用服務的整體流程

  1. 首先啟動 Eureka Server 這個服務註冊中心
  2. 啟動我們的微服務 ( 服務提供者、服務消費者 )
    • 啟動的微服務會到註冊中心去註冊自身服務訊息
  3. 服務消費者要去與服務提供者請求的時候,會使用『服務提供者』的名稱,去服務註冊中心取到真正的服務提供者的位置
    • 名稱就是 application.yml 中設定的名稱:
      • 數值為 spring .application.name
  4. 服務消費者得到位置後,才會去使用 http 與服務提供者請求
    • 服務消費者會暫存這個位置,預設 30 秒才會再去註冊中心確認是否有新的位置

多個 Eureka 的建立

會需要多個 Eureka Server 的原因就是需要保證服務要有『高可用性』。

多台 Eureka Server 的結構,其實是多台 Server 之間互相之間進行註冊。

這裡可以使用 docker 去建立多台 Eureka Server,但是關於每台 Server ,可以設定 host 來區別 local 上不同台的 Server。

# C:\WINDOWS\system32\drivers\etc\host
# For Localhost Testing
127.0.0.1 localhost-one
l27.0.0.1 localhost-two
127.0.0.1 localhost-three
127.0.0.1 localhost-four
  • 因為在 DS Replicas 欄位上,會顯示的是 Domain ,所以為了區別才要這樣做。

多個微服務註冊進 Eureka

有了群集 Eureka Server,再來可以將各種微服務都註冊進群集中。

詳細範例 Code 請參考:Click

開啟服務探索

可以於 Springboot 入口加入 EnableDiscoveryClient Annotation 開啟功能。

然後便可以注入 DiscoveryClient 物件進行操作,取得註冊在 Server 的各項微服務詳細資訊。

/**
 * 注意:@EnableEurekaClient 已被廢棄不使用,也不需要再使用就可以生效。
 * 使用 EnableDiscoveryClient 開啟服務探索的功能,可以透過 DiscoveryClient 獲取 Eureka Server 上已註冊服務的資訊。
 */
@SpringBootApplication
@EnableDiscoveryClient
class ProviderApplication

使用範例:開一個 API 來查詢:

@Controller
@RequestMapping("/payment")
class PaymentController(
    val paymentRepository: PaymentRepository,
    val discoveryClient: DiscoveryClient
) {

    @ResponseBody
    @GetMapping("/discovery")
    fun discovery() = ResponseJson().setStatus(SUCCESS).addData<JsonArray> {
        discoveryClient.services.forEach { serviceName ->
            this.add(JsonObject().apply {
                addProperty("service-name", serviceName)
                add("instances-list", JsonArray().apply {
                    discoveryClient.getInstances(serviceName).forEach { serviceInstance ->
                        add(JsonObject().apply {
                            addProperty("service-id",serviceInstance.serviceId)
                            addProperty("instance-id",serviceInstance.instanceId)
                            addProperty("uri",serviceInstance.uri.toString())
                        })
                    }
                })
            })
        }
    }
}

Response Json:

{
    "code": 200,
    "message": "成功",
    "data": [
        {
            "service-name": "order-service",
            "instances-list": [
                {
                    "service-id": "ORDER-SERVICE",
                    "instance-id": "order_service_80",
                    "uri": "http://172.25.0.6:80"
                }
            ]
        },
        {
            "service-name": "payment-service",
            "instances-list": [
                {
                    "service-id": "PAYMENT-SERVICE",
                    "instance-id": "payment_service_8081",
                    "uri": "http://172.25.0.7:8081"
                },
                {
                    "service-id": "PAYMENT-SERVICE",
                    "instance-id": "payment_service_8080",
                    "uri": "http://172.25.0.5:8080"
                }
            ]
        }
    ]
}

自我保護機制

何謂 CAP:

  • 一致性(Consistency):使用者讀到的”總是”最新的資料
  • 可用性(Availability):使用者的請求”總是”可以獲得回應,也就是可以正常讀寫(回傳錯誤訊息並不算滿足可用性)
  • 分區容錯性(Partition tolerance):就算網路出現問題導致資料分區,整個系統仍然要可以繼續運作

服務註冊到 Eureka Server,當某服務掛了,並不會立即刪除,防止因短時間的網路問題等等問題導致砍掉其實是正常的服務。

預設 30 秒會向 EurekaServer 發送 heartbeat,如果於好幾個通知週期內沒有持續通知,EurekaServer 就會把該服務移除。

Eureka 的作法採三選二的 AP 。

這種作法是預設開啟,如果要關閉,直接在 Eureka Server 的 application.yml 中加入以下設定:

eureka:
    instance:
        enable-self-preservation: false
        eviction-interval-timer-in-ms: 2000

而各個微服務可以再設定 heartbeat 時間、過期被剔除時間:

eureka:
    instance:
        # 多久發送 heartbeat
        lease-renewal-interval-in-seconds: 30
        # 多久要設定為離線
        lease-expiration-duration-in-seconds: 90

停止更新 ??

其實最近又有新的 2.0 出來了,老 2.0 已死沒問題,但新 2.0 可以注意。

Spring 新的也是採用新 Eureka 2.0.0 的。

Zookeeper

Zookeeper 的作法採三選二的 CP。只要過期了就會直接砍掉,重新再連回來的話會視為一個新的服務註冊,ID 也都會不一樣。

Zookeeper 直接使用 Docker Image 起就可以了。

# docker-compose.yml
version: '3.8'

services:
  zookeeper-1:
    # Image 版本 3.6 對應 spring-cloud-starter-zookeeper-discovery:4.0.0
    image: zookeeper:3.6
    ports:
      - "2181:2181"
    restart: always
    networks:
      zookeeper-network:

  payment-service-1:
    build:
      context: .
      dockerfile: dockerfile-payment-zookeeper-noBuild
    image: payment-service-zookeeper:lastest
    environment:
      SERVER_PORT: 8084
      ZOOKEEPER_CONNECT_STRING: zookeeper-1:2181
    networks:
      zookeeper-network:
    depends_on:
      - zookeeper-1
networks:
  zookeeper-network:

關於要使用的 Zookeeper 版本,必須要和 SpringCloudStarterZookeeperDiscovery 裡面的版本一樣。 以 4.0.0 來說,Zookeeper 需要 3.6 版本。 這可以從 Intellij – Gradle – compileClasspath 中去找到查看。

而我們的微服務的 application.yml 針對 Zookeeper 的部分只要加上 Zookeeper 的位置參數就好:

spring:
  cloud:
    zookeeper:
      # 如果要設定成多台 Zookeeper,直接加在後面就可以了
      connect-string: ${ZOOKEEPER_CONNECT_STRING:127.0.0.1:2181}

詳細請參考範例。

要進入 Zookeeper 進行操作,首先到安裝目錄下面的 bin 資料夾中,找到 zkCli.sh 或是 zkCli.bat 執行就可以進入 CommandLine 了。

可以使用 ls / 來查看文件節點。

Consul

簡單的建立方法,跟前一小節的 Zookeeper 差不多:

version: '3.8'

services:
  consul-1:
    image: consul:1.8.0
    # -client 是表明限制何種位置能允許 request 過來,0.0.0.0 表示不限制
    command: >
      consul agent
      -server
      -bootstrap-expect=1
      -client=0.0.0.0
      -data-dir=/consul/data
      -ui
    ports:
      # 8500 是 UI 的 Port
      - "8500:8500"
      - "8600:8600/tcp"
      - "8600:8600/udp"
    networks:
      consul-network:
    # 需要指定儲存資料的位置在哪裡,否則啟動不能
    volumes:
      - consul-volumes:/consul/data

  payment-service-1:
    build:
      context: .
      dockerfile: dockerfile-payment-consul-noBuild
    image: payment-service-consul:lastest
    environment:
      SERVER_PORT: 8086
      CONSUL_HOST: consul-1
      CONSUL_PORT: 8500
      CONSUL_SERVICE_NAME: payment-service-1
    networks:
      consul-network:
    ports:
      - 8086:8086
    depends_on:
      - consul-1
networks:
  consul-network:
volumes:
  consul-volumes:

主要在 application.yml 會有以下設定:

spring:
  cloud:
    consul:
      host: ${CONSUL_HOST:localhost}
      port: ${CONSUL_PORT:8500}
      discovery:
        service-name: ${CONSUL_SERVICE_NAME:payment-cs-services-x}

三種比較

Language CAP Health Check Exposed Port Spring Cloud Supported
Eureka Java AP Supported HTTP Yes
Consul Go CP Yes HTTP/DNS Yes
Zookeeper Java CP Yes Client Yes
  • CP:一致性+分區容錯,性能不高時用,沒有高擴展性
  • AP:可用性+分區容錯,對一致性要求低的時候用

服務調用與負載均衡

這一章節提到的元件,都會集中在 Client 端的部分,該如何調用那一個 Server 會在 Client 端決定。

Ribbon

Netflix 針對 Client 端的負載均衡工具。

Project Status: On Maintenance (2023/05/12) 而且 Spring 的最後版本為 2.2.10.RELEASE

首先負載均衡分兩種:

  1. 集中:例如 Nginx 處理負載均衡
  2. Process 內:自身微服務 (Client) 做負載均衡

Ribbon 就是屬於第二種的,是可以與 Spring 的 Resttemplate 搭配使用得到負載均衡效果。

先前的範例中已經有使用過了,只要使用一個 Annotation 就能開啟了,如下:

import org.springframework.cloud.client.loadbalancer.LoadBalanced

@Configuration
class HttpConfiguration(
    val context: ApplicationContext
) {
    @Bean
    @LoadBalanced // 給予 RestTemplate 負載均衡的功能
    fun restTemplate() = RestTemplate().apply {
        messageConverters = listOf(
            context.getBean(GsonHttpMessageConverter::class.java)
        )
    }
}

先前在引入 Eureka Client 的時候,就已經有引入 Ribbon 了,所以可以不用額外加上 gradle 引用。

Resttemplate 的使用方法

Resttemplate 有兩種主要發送 Requeset 的方法:

  1. getForObject()
    • return 的是一個直接指定的返回物件
restTemplate.getForObject(url,ResponseJson::class.java)!!
  1. getForEntity()
    • 回傳一個 Entity 物件,這個物件包含了很多的功能
    • 其 getBody() 相當於直接用 getForObject()
val entity = restTemplate.getForEntity(url, ResponseJson::class.java)
val responseCode = entity.statusCode
val responseBodyJson = entity.body

預設的負載均衡規則

預設的情況是直接輪流調用,而原理是其中一個名為 IRule 的 Interface ,這個介面定義了如何做負載均衡的規則。

package com.netflix.loadbalancer;
public interface IRule {
    Server choose(Object var1);
    void setLoadBalancer(ILoadBalancer var1);
    ILoadBalancer getLoadBalancer();
}

Ribbon 自己帶有 7 種規則可用:

  1. com.netflix.loadbalancer.RoundRobinRule
    • 輪流
  2. com.netflix.loadbalancer.RandomRule
    • 隨機
  3. com.netflix.loadbalancer.RetryRule
    • 先依照 RoundRobinRule 輪流打,如果失敗會在指定時間內重打
  4. WeightedResponseTimeRule:
    • RoundRobinRule 的上位版本
    • 當回應速度越快的話,被選擇的機會越大
  5. BestAvailableRule
    • 先過濾掉處於融斷狀態的服務,選擇一個併發數量最小的服務
  6. AvailabilityFilteringRule
    • 先過濾調故障服務,再選擇併發數量最小的服務
  7. ZoneAvoidanceRule
    • 預設規則,判斷 Server 所處區域之性能、可用性,來選擇服務

替換預設規則的方法

  1. 先確定 SpringBoot 的 ComponentScan 掃描位置是哪邊
    • 看 SpringBootApplication Annotation 之下的 ComponentScan Annotation。
  2. 於該掃描位置掃不到的地方建立你要的規則 Bean。
@Configuration
public class MySelfRule {
    @Bean
    public IRule myRule() {
        // 改為隨機規則
        return new RandomRule();
    }
}

輪巡規則的做法及 Code

作法如下:

  1. 先統計所有服務的數量,使用一個計數器計算該次 Request 為第幾次。
  2. 將計數器對服務數量做餘數,該餘數就是要打的服務編號

詳細可以參考 com.netflix.loadbalancer.RoundRobinRule.choose() 方法就可以知道詳細作法。

自訂負載均衡規則 (自訂輪巡)

這一小節嘗試自行寫一個輪巡規則,並應用之。

首先把 Resttemplate 的 LoadBalanced Annotation 先註解掉。

自行創建一個類別,用途是給定一系列的服務實體列表,使用輪巡的方式,從中挑出一個此次要調用的實體。

@Component
class CustomRoundRobinRule {

    private val count = AtomicInteger(0)
    private val log = LoggerUtil.getLogger<CustomRoundRobinRule>()

    /**
     * 取得下一個 index
     */
    fun getAndIncrement(): Int {
        var nextCount: Int
        do {
            val currentCount = count.get()
            nextCount = if (currentCount > Int.MAX_VALUE) 0 else currentCount + 1
        } while (!count.compareAndSet(currentCount, nextCount))
        log.info("NextCount is $nextCount")
        return nextCount
    }

    /**
     * 自給定的服務列表中取得下一個服務實體
     */
    fun getInstance(serviceInstanceList: List<ServiceInstance>) = when {
        serviceInstanceList.isNullOrEmpty() -> null
        else -> {
            val index = getAndIncrement() % serviceInstanceList.size
            log.info("Next index = $index")
            serviceInstanceList[index]
        }
    }
}

這裡用到了 AtomicInteger 類別加上自旋鎖的用法,目的是在多執行緒之下能夠保證取到的 index 都會是不一樣的。

新增一個 API 如下:

@Controller
@RequestMapping("/order")
class OrderController(
    val restTemplate: RestTemplate,
    val eurekaClient: EurekaClient,
    val discoveryClient: DiscoveryClient,
    val customRoundRobinRule: CustomRoundRobinRule
) {
    private val log = LoggerUtil.getLogger<OrderController>()
    private val PAYMENT_SERVICE_HOST_NAME = System.getenv("PAYMENT_SERVICE_HOST_NAME") ?: "PAYMENT-SERVICE"
    private val CREATE_PAYMENT_URL = "/payment/create"
    private val GET_PAYMENT_URL = "/payment/get"
    
    @ResponseBody
    @GetMapping("/getRoundRobin/{id}")
    fun getOrderByCustomRobinRule(
        @PathVariable("id") id: Long
    ): ResponseJson {

        // 取得 Payment 服務的服務實體列表
        val instanceServiceList = discoveryClient.getInstances(PAYMENT_SERVICE_HOST_NAME)
        val hostname = when {
            instanceServiceList.isNullOrEmpty() -> PAYMENT_SERVICE_HOST_NAME
            else -> {
                val instance = customRoundRobinRule.getInstance(instanceServiceList)
                "${instance?.host}:${instance?.port}"
            }
        }

        val url = "http://$hostname$GET_PAYMENT_URL/$id"
        log.info { "Get Url = $url" }

        val entity = restTemplate.getForEntity(url, ResponseJson::class.java)
        val responseCode = entity.statusCode
        val responseBodyJson = entity.body

        return responseBodyJson
    }
}

流程主要是先從 discoveryClient 得到所有的可用服務,在根據自訂的 index 取值規則去決定要打那一個 API。

OpenFeign

Feign是一個聲明式的 WebService Clinet 端,使用方式非常的簡單,就是在已經定義好的介面上加上 Annotation 即可使用,Feign 也可以與 Eureka 和 Ribbon組合使用達成負載均衡效果。

而 Spring Cloud 的 OpenFeign 是在原有的 Feign 基礎上支援 SpringMVC 的 Annotation,如 RequentMapping 等等。

詳細可以參考官方文件:Spring Cloud OpenFeign

Spring 的 OpenFeign 可以看做與 Mybatis 的 Mapper Interface 差不多,一樣都是建立 Interface 後加上需要的 Annotation,剩下交給 Spring 動態實作。

整體的設定風格就跟平常寫 SpringMVC 差不多:

  1. 首先要於啟動類別額外加上 EnableFeignClients Annotation
/**
 * 使用 OpenFeign 需要多這個 EnableFeignClients Annotation
 */
@EnableFeignClients
@SpringBootApplication
class ConsumerApplication
  1. 建立一個 Interface,對應要調用的微服務
/**
 * 這裡將 Annotation 中的微服務名稱抽出來到 application 中設定。
 */
@Component
@FeignClient("\${feign-client.payment-service-host-name}")
interface PaymentFeignClient {

    @GetMapping("/payment/get/{id}")
    fun getPayment(
        @PathVariable("id") id: Long
    ): ResponseJson
}
  1. Controller 或是其他物件要調用微服務時只要使用該方法即可。
    • 不用再像最初的那樣使用 Resttemplate 了。
@Controller
@RequestMapping("/order")
class OrderController(
    val paymentFeignClient: PaymentFeignClient
) {

    private val log = LoggerUtil.getLogger<OrderController>()

    @ResponseBody
    @GetMapping("/get/{id}")
    fun getOrder(
        @PathVariable("id") id: Long
    ): ResponseJson {
        log.info("Get payment $id")
        return paymentFeignClient.getPayment(id)
    }
}

Timeout 設定

由於每個微服務是獨立的,使用 OpenFeign 看起來只是在調用方法,但是實際上需要考慮調用時間的問題。

我們可以自訂 Feign 的 Builder 來達到 timeout 設定效果:

@Configuration
class OpenFeignConfig {
    @Bean
    fun feignBuilder(): Feign.Builder = Feign.builder().apply {
        options(
            // 設定 Timeout 時間
            Request.Options(
                1, TimeUnit.MILLISECONDS, 1, TimeUnit.MILLISECONDS, true
            )
        )
        retryer(Retryer.NEVER_RETRY)
    }
}

Log 級別設定

使用 OpenFeign 後,微服務調用的方法內部會由 Spring 完成,如果需要知道調用內部的詳細 log,需要另外獨立設定:

  1. 建立 Feign 的 log 級別物件
  2. 在 application.yml 中加入你的微服務 interface 級別設定
@Configuration
class OpenFeignConfig {

    /**
     * 設定 OpenFeign 相關的 log 級別。
     * 另外需搭配 application.yml 中的設定要印出的 FeignClient 是哪一個。
     */
    @Bean
    fun feignLoggerLevel() = Logger.Level.FULL
}
logging:
    level:
        # 自訂級別
        com.enix.consumer.feignClient.PaymentFeignClient: debug

服務降級、熔斷、限流:Hystrix

首先介紹 Hystrix,雖然已經停止更新,但其功能強大且多,後續的衍生 Library 都由此而來,故可先行了解。

可以參考其 Github:Netflix – Hysrix

Hystrix is no longer in active development, and is currently in maintenance mode.

  • 由於 Springboot 3 已經移除 Hystrix,故以下的說明範例全部都沒有實際走過一次。
  • 真的要用可以參考以下教學文章:Spring Boot – Hystrix

當微服務越來越多,彼此之間互相調用的動作也越多,導致彼此之間都有很強的的相依性,這時候如果其中一個服務炸了,將有可能發生其他服務跟著死了,故有必要在發生預期外問題時,給予整個系統一個執行備案,避免系統全面崩潰。

標題的三個功能,概念分別如下:

  1. Fallback (降級):服務發生問題時,於最後提供解決方案,避免直接爆炸
    • 會發生降級的原因有 Timeout、熔斷觸發、Exception、Thread Pool 滿了等等。
  2. Break (熔斷):已無法承受更多 Request,直接拒絕回復
    • 可以再分成三塊:降級、熔斷、回復
  3. Flow Limit (限流):Request 帶多,提供排隊功能,避免 Server 掛了。

高併發下的狀況與解決方法

當針對較慢回應的 API 發大量的 Request ,會使其他理應回應快速的 API 也連帶受到影響被拖慢回應速度,因為 Tomcat 的 Thread 已經被佔據完了。

影片中使用 Jmeter 展示,此略。

針對這種情況,解決方式有以下要點:

  1. 如果服務提供者有 timeout 的狀況會出現,不要一直持續要等待其回復,需要有降級的措施。
    • timeout 不要等
  2. 如果服務提供者掛了,也不能一直等待,需要有降級的措施。
    • 掛了需要有最終的備案措施。
  3. 如果服務提供者其實沒有大問題,只是服務調用者需要更高的回應時間要求,這時候可以令服務調用者自己降級。

使用時需要加上 Annotation 開啟 Hystrix 功能:

@EnableHystrix
// @EnableCircuitBreaker
@EnableEurekaClient
@SpringBootApplication
public class PaymentHystrixMain8001{
    //...
}

降級的設定 Fallback

降級的設定在服務提供、調用者皆可設定。 提供者可以限制自身提供對外的服務要多少時間才算正常,調用者也可以去限制自身的等待時間,這樣就會有兩層的限制。

Fallback 一般是放在 Client 中。

由於 Hystrix 的屬性設定都是修改在 Annotation 中的數值,這在 spring-boot-devtools 的熱更新中不太有作用,建議直接重啟。

@HystrixCommand(
        // 超過時限要走的方法
        fallbackMethod = "paymentInfo_TimeOutHandler",
        commandProperties = {
            // 設定時間
            @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "5000")
        })
public String paymentInfo_TimeOut(Integer id)
}

// 超過限制時間,走這一條方法回復
public String paymentInfo_TimeOutHandler(Integer id) {
    //...
}

如果每個方法都設定一個 fallbackMethod 會有太多的方法出現,故也可以設定一個 Global 的 fallback 方法:

/**
 * 單獨於 Controller 上加上 DefaultProperties Annotation
 * 則只要 HystrixCommand 沒有額外設定,fallback 就會走 default 設定的方法
 */
@DefaultProperties(defaultFallback = "payment_Global_FallbackMethod")
@RestController
class OrderHystrixController {

    @HystrixCommand
    @GetMapping("/consumer/payment/hystrix/timeout/{id}")
    fun paymentInfo_TimeOut(
        @PathVariable("id") id: Int
    ) = "Normal"

    fun payment_Global_FallbackMethod() = "Global Fallback method."
            
}

另外如果搭配 OpenFeign 也可以做 fallback 統一設定:

  1. 可以在 OpenFeignClient 上的 Annotation 增加一個 fallback 屬性
    • fallback 屬性填寫下面的實作類別名稱.class
  2. 實作一個 Class 繼承這個 client interface,其每個方法內容都是 fallback 時要做的內容
/**
 * 原 OpenFeign 的 Interface
 */
@Component
@FeignClient(
    value = "CLOUD-PROVIDER-HYSTRIX-PAYMENT",
    // 寫上自訂實作的 Fallback class
    fallback = PaymentFallbackService::class
)
interface PaymentHystrixService {
    @GetMapping("/payment/hystrix/ok/{id}")
    fun paymentInfo_OK(@PathVariable("id") id: Int): String
}

/**
 * 當 Feign 的方法出現問題時,就會轉而調用這個實作 Class 的方法回復
 */
@Component
class PaymentFallbackService : PaymentHystrixService {
    override fun paymentInfo_OK(id: Int) = "Fallback content."
}

熔斷的設定:break

熔斷就是保險絲的概念,根據 Martin Fowler 的文章所述,可以有以下的階段:

  1. Closed 狀態:正常情況
  2. Open 狀態:當流量過大、大量失敗,則進入此狀態,服務一律回復不可用。
  3. Half Open 狀態:當經過時間到下一個統計區間時,會先容許小部分的 Request,當持續成功時才會再慢慢回復為 Closed 狀態。
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand
import com.netflix.hystrix.contrib.javanica.annotation.HystrixProperty

@HystrixCommand(
    // 當失敗時會轉而調用的方法。
    fallbackMethod = "paymentCircuitBreaker_fallback",
    commandProperties = [HystrixProperty(
        // 是否開啟熔斷功能,開了下面這些屬性才能用
        name = "circuitBreaker.enabled",
        value = "true"
    ), HystrixProperty(
        // 設定多少時間作為一個區段
        // 也可以視為當開啟熔斷後,多久時間能從 Open 轉去 Half Open (跳到下一個時間區段),預設 5000 毫秒 = 5 秒
        name = "circuitBreaker.sleepWindowInMilliseconds",
        value = "10000"
    ), HystrixProperty(
        // 在一個時間區段內,Request 數到達多少以上才能啟動熔斷,預設 20 次
        // 只要時間內 Request 不足此數量,就算全失敗也不會開啟熔斷。
        name = "circuitBreaker.requestVolumeThreshold",
        value = "10"
    ), HystrixProperty(
        // 當錯誤率達到多少 % 則啟動熔斷,預設 50 %
        name = "circuitBreaker.errorThresholdPercentage",
        value = "60"
    )]
)
fun paymentCircuitBreaker(
    @PathVariable("id") id: Int
): String
  • HystrixCommand 還有很多的 Property 可以設定,其餘設定請參照官方文件。

限流:Flow Limit

課程中略過。

Hystrix 詳細流程圖

可以參考官方 Github 的說明:How it Works

大致分成九個步驟:

  1. Construct a HystrixCommand or HystrixObservableCommand Object
    • 選擇用 Annotation 或者是自訂物件的方式設定,前面的小節用的都是 Annotation
  2. Execute the Command
  3. Is the Response Cached?
    • 如果 Response 有暫存,就會直接回覆,不會往後走
  4. Is the Circuit Open?
  5. Is the Thread Pool/Queue/Semaphore Full?
    • 也就是之前設定的一個時間區段內要有多少個 Request,一個 Request = 一個 Thread
  6. HystrixObservableCommand.construct() or HystrixCommand.run()
  7. Calculate Circuit Health
    • 執行的結果會通知 Hystrix 進行統計
  8. Get the Fallback
  9. Return the Successful Response

Hystrix UI Dashboard

直接再建立一個專案,引入 Library,加入 Annotation 就完成了,很簡單快速。

implementation 'org.springframework.cloud:spring-cloud-starter-netflix-hystrix-dashboard:2.2.10.RELEASE'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
@EnableHystrixDashboard
@SpringBootApplication
class HystrixDashboard9001

另外在要被監控的服務上,還要多一個 Bean 的設定:

@Bean
fun getServlet(): ServletRegistrationBean<*> {
    val streamServlet = HystrixMetricsStreamServlet()
    val registrationBean: ServletRegistrationBean<*> = ServletRegistrationBean<Any>(streamServlet)
    registrationBean.setLoadOnStartup(1)
    registrationBean.addUrlMappings("/hystrix.stream")
    registrationBean.setName("HystrixMetricsStreamServlet")
    return registrationBean
}

而 Dashboard 啟動後,直接在介面上,輸入你要監控的服務 URL,這並不是在 Dashboard 的 Code 上進行設定的。

Hystrix 改換 resilience4j

根據訊息:

In SpringOne 2019, Spring announced that Hystrix Dashboard will be removed from Spring Cloud 3.1 version which makes it officially dead.

不同於上一節沒有實際跑過 Code,這一節將改用 resilience4j 的做實際操作。

詳細的官方文件:Resilience4j is a fault tolerance library for Java™

還有 Spring 做的整合: Spring Cloud Circuit Breaker Spring 提供的部分可以使用 Annotation 的方式設定,不用像 Resilience4j 原本的方式做複雜的物件 Bean 建立設定,但 Github 上的教學一樣大量用物件設定的方式,之後可以再深入。

  • Resilience4j 2 requires Java 17.

Resilience4j 其實分成了很多的模組,可以直接全加或者是分開引入,不過這邊一樣直接用 Spring 整合好的即可。

// Resilience4J non-reactive-version
implementation 'org.springframework.cloud:spring-cloud-circuitbreaker-resilience4j:3.0.2'
  • 這個版本號獨立於 Cloud 所以不能省略。

Resilience4j 的設定概念基本上就跟 Hystrix 一樣,熔斷器關閉/半開/打開等等狀態都相同。

斷路器都可以在 application.xml 中設定,可以有預設設定、個別的自訂斷路器設定,完全可以根據需求分開設定:

resilience4j:
  circuitbreaker:
    configs:
      default:
        failure-rate-threshold: 50
    instances:
      order-breaker-1:
        failure-rate-threshold: 50
        wait-duration-in-open-state: 5s
      order-breaker-2:
        # 故障率限制,超過則啟動,預設 50
        failure-rate-threshold: 20
        # 預設 100,單位要看下面 Type 是什麼,Type 預設是時間
        sliding-window-size: 5
        sliding-window-type: TIME_BASED
        # 熔斷器關閉時,要多少次調用才能進入半開狀態,預設 100
        minimum-number-of-calls: 5
        # 熔斷器打開之後,多久會進入半開狀態,預設 60 秒
        wait-duration-in-open-state: 30s
        # 緩衝區,預設 100
        event-consumer-buffer-size: 5
        # 半開狀態時,最大調用次數,預設 10
        permitted-number-of-calls-in-half-open-state: 2
        record-exceptions:
          - java.lang.Exception
  • 以上的設定是透過 Bito 問出來的

在來就可以在需要斷路器的方法上加上 Annotation 就可以使用我們自訂的斷路器了:

@Controller
@RequestMapping("/order")
class OrderController(
    val paymentFeignClient: PaymentFeignClient,
) {
    private val log = LoggerUtil.getLogger<OrderController>()

    @ResponseBody
    @CircuitBreaker(
        name = "order-breaker-2",
        fallbackMethod = "fallback"
    )
    @GetMapping("/breaker/{milliseconds}")
    fun getBreakerOrder(
        @PathVariable("milliseconds") milliseconds: Long
    ): ResponseJson {
        log.info("Start to sleep ${milliseconds} milliseconds")
        Thread.sleep(milliseconds)
        val paymentResponseJson = paymentFeignClient.getPayment(1)
        return ResponseJson().setStatus(CodeMessage.SUCCESS).addData<JsonObject> {
            addProperty("message", "Success to sleep ${milliseconds} milliseconds.")
            if (paymentResponseJson.code == 200)
                add("payment", paymentResponseJson.data)
            else
                addProperty("payment", paymentResponseJson.message)
        }
    }
    
    /**
     * 這裡似乎要有 throwable 的參數在才可以正常被調用,沒有會炸
     */
    fun fallback(
        throwable: Throwable
    ) = ResponseJson().setStatus(CodeMessage.FAILURE).addData<JsonObject> {
        addProperty("message", "Fallback method has been invoked. ${throwable.message}")
    }
}
  • 這裡 Feign 跟 Resilience4j 獨立開來運作的,似乎也可以整合在一起,但這裡先不深入。
  • CircuitBreaker Annotation 用的是 Recilience4j 的,指定在 application.yml 設定的名稱就可以運作

這裡如果故意令其造成錯誤,可以成功得到斷路器開啟,Request 被封鎖的訊息:

{
    "code": 998,
    "message": "失敗",
    "data":
    {
        "message": "Fallback method has been invoked. CircuitBreaker \u0027order-breaker-2\u0027 is OPEN and does not permit further calls"
    }
}
  • 可以嘗試更改設定來玩玩。

Gateway

雖然 Netflix 有 Zuul 2 可用,但這裡只說明 Spring 的 Gateway,可以視為 Zuul 的替換品。

而 Spring 並沒有針對 Zuul 2 有做整合,所以如果 Zuul 要整合 Spring 就只能用版本 1,但是版本 1ˇ 又沒有一些 2 有推出的一些新特性。

Spring 的 Gateway 有以下好處:

  1. Spring 整合
  2. 使用 non blocking api
    • Webflux (Netty) => Project Reactor
    • Spring Cloud Gateway is built on Spring Boot 2.x, Spring WebFlux, and Project Reactor.
  3. 支援 Websocket

下圖可以看到 Gateway 的位置:

  1. Nginx 的負載均衡之後
  2. 所有微服務入口前

運作流程

Spring Cloud Gateway 有三個核心概念:

  1. Route:路由
    • 最基本的模組,由一系列的 Predicate、Filter 組成,都通過則導至該位置
  2. Predicate:斷言
    • 跟 Java 8 的 Predicate 差不多意思
    • 判斷是否可以進入到該 URI 位置
  3. Filter:過濾
    • 這指的是 Spring 中的 GatewayFilter 物件,可以在進入該 URI 位置前與後進行各種操作。

參考官方流程圖:

整個 Gateway 的重點就是:『轉發+過濾器過濾』

使用方法

首先加入 library:

// Gateway
implementation "org.springframework.cloud:spring-cloud-starter-gateway:${springCloudGatewayVersion}"
  • 由於 Spring 的 Gateway 是採 Reactive 的,這邊需要注意 library 不要引入 Spring Web 的部分。

轉發的設定可以直接寫在 application.yml 中就能生效:

spring:
  # 由於 Spring 的 Gateway 是採 Reactive 的,與 MVC 相衝,不特別指定這個設定會出錯。
  main:
    web-application-type: reactive
  application:
    name: gateway-service
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
      routes:
        - id: payment_route
          # lb 是 Spring 提供用來做 Loadbalance 用的
          uri: lb://${PAYMENT_SERVICE_HOST_NAME:PAYMENT-SERVICE}
          predicates:
            - Path=/payment/get/**
        - id: order_route
          uri: lb://${ORDER_SERVICE_HOST_NAME:ORDER-SERVICE}
          # Spring 內建提供的 GatewayFilter 可以於此設定
#          filters:
            # AddRequestHeaderGatewayFilter 會於通過驗證的 Request 上加上一個 Header
#            - AddRequestParameter=X-Request-Id, 1024
          predicates:
            - Path=/order/get/**
            # 指定時間之後才能存取,否則 404,注意這個字串格式是 ZoneDateTime.toString() 來的。
#            - After=2020-06-17T12:53:40.325+08:00[Asia/Shanghai]
            # 指定時間之前才能存取,否則 404
#            - Before=2020-06-17T11:53:40.325+08:00[Asia/Shanghai]
            # 指定時間內才能存取,否則 404
#            - Between=2020-06-17T11:53:40.325+08:00[Asia/Shanghai],2020-06-17T12:53:40.325+08:00[Asia/Shanghai]
            # 限定 Cookie 中帶有 key, value 者才能存取
#            - Cookie=username,angenin
            # Request 需要有 headerName, [正規表達式] 者才能存取,\d+ 表示要整數
#            - Header=X-Request-Id, \d+
            # Host 限制
#            - Host=**.angenin.com
            # 方法限制
#            - Method=GET
            # url 中,必須要有 key, [正規表達式] 者才能存取,\d+ 表示要整數
#            - Query=username, \d+
  • 服務發現功能打開後,才能使用 lb 開頭的負載均衡功能,搭配服務發現,這是 Spring 提供的。

每個 route 都有以下項目:

  1. id:這個只要唯一就可以了
  2. predicate:各種判定規則,詳細要參考 Spring 的文件說明:Route Predicate Factories
    • spring 提供了大約 12 種設定可以做
  3. filter:可以做各種過濾功能,詳細一樣可以看官方文件:GatewayFilter Factories
    • Spring 一樣提供了超多的內建 Filter 可用,大概 30 種以上

物件設定

除了以上使用 application.yml 設定外,也可以透過建立物件進行設定:

Spring 於 Route 的部分有提供 kotlin 的寫法,寫起來跟 application.yml 差不多:

@Configuration
class RouteConifg {

    /**
     * 直接使用物件的方式進行 Route 設定。
     */
    @Bean
    fun googleRouteLocator1(
        routeLocatorBuilder: RouteLocatorBuilder
    ) = routeLocatorBuilder.routes {
        route("googleHomePage") {
            path("/google")
            uri("https://www.google.co.jp/")
        }
    }
}
  • 如果是 Java 的寫法會有點亂。

再來 Filter 的部分如果需要自訂,不使用官方提供的,也可以有以下的寫法:

@Component
class GlobalGatewayFilter : GlobalFilter, Ordered {

    private val log = LoggerUtil.getLogger<GlobalGatewayFilter>()

    /**
     * 注意這已經是 Webflux 的寫法了
     * 這裡不深究這個部分,僅需知道可以透過物件去自訂 Filter
     */
    override fun filter(
        exchange: ServerWebExchange,
        chain: GatewayFilterChain
    ): Mono<Void> {
        val request = exchange.request
        val response = exchange.response
        if (!request.queryParams.containsKey("user")) {
            log.info { "Unknown user." }
            response.setStatusCode(HttpStatus.NOT_ACCEPTABLE)
            return response.setComplete()
        }

        // 放行
        return chain.filter(exchange)
    }

    /**
     * Filter 優先順位設定,越小越前面
     * 可以設定最小 -2147483648 到最大 2147483648
     */
    override fun getOrder() = 0

}
  • 自訂需要繼承兩個類別:GlobalFilter 與 Ordered
  • 這裡包含了 webflux 的寫法

發佈留言