Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

Sentinel與控制檯通信原理

基礎篇中我們學習瞭如何爲項目整合Sentinel,並搭建了Sentinel的可視化控制檯,介紹及演示了各種Sentinel所支持的規則配置方式。本文則對Sentinel進行更進一步的介紹。

首先我們來了解控制檯是如何獲取到微服務的監控信息的:

微服務集成Sentinel需要添加spring-cloud-starter-alibaba-sentinel依賴,該依賴中包含了sentinel-transport-simple-http模塊。集成了該模塊後,微服務就會通過配置文件中所配置的連接地址,將自身註冊到Sentinel控制檯上,並通過心跳機制告知存活狀態,由此可知Sentinel是實現了一套服務發現機制的。

如下圖:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

通過該機制,從Sentinel控制檯的機器列表中就可以查看到Sentinel客戶端(即微服務)的通信地址及端口號:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

如此一來,Sentinel控制檯就可以實現與微服務通信了,當需要獲取微服務的監控信息時,Sentinel控制檯會定時調用微服務所暴露出來的監控API,這樣就可以實現實時獲取微服務的監控信息。

另外一個問題就是使用控制檯配置規則時,控制檯是如何將規則發送到各個微服務的呢?同理,想要將配置的規則推送給微服務,只需要調用微服務上接收推送規則的API即可。

我們可以通過訪問http://{微服務註冊的ip地址}:8720/api接口查看微服務暴露給Sentinel控制檯調用的API,如下:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

相關源碼:

  • 註冊/心跳機制:com.alibaba.csp.sentinel.transport.heartbeat.SimpleHttpHeartbeatSender
  • 通信API:com.alibaba.csp.sentinel.command.CommandHandler的實現類

Sentinel API的使用

本小節簡單介紹一下在代碼中如何使用Sentinel API,Sentinel主要有以下三個API:

  • SphU:添加需要讓sentinel監控、保護的資源
  • Tracer:對業務異常進行統計(非 BlockException 異常)
  • ContextUtil:上下文工具類,通常用於標識調用來源

示例代碼如下:

@GetMapping("/test-sentinel-api")
public String testSentinelAPI(@RequestParam(required = false) String a) {
    String resourceName = "test-sentinel-api";
    // 這裏不使用try-with-resources是因爲Tracer.trace會統計不上異常
    Entry entry = null;
    try {
        // 定義一個sentinel保護的資源,名稱爲test-sentinel-api
        entry = SphU.entry(resourceName);
        // 標識對test-sentinel-api調用來源爲test-origin(用於流控規則中“針對來源”的配置)
        ContextUtil.enter(resourceName, "test-origin");
        // 模擬執行被保護的業務邏輯耗時
        Thread.sleep(100);
        return a;
    } catch (BlockException e) {
        // 如果被保護的資源被限流或者降級了,就會拋出BlockException
        log.warn("資源被限流或降級了", e);
        return "資源被限流或降級了";
    } catch (InterruptedException e) {
        // 對業務異常進行統計
        Tracer.trace(e);
        return "發生InterruptedException";
    } finally {
        if (entry != null) {
            entry.exit();
        }

        ContextUtil.exit();
    }
}

對幾個可能有疑惑的點說明一下:

  • 資源名:可任意填寫,只要是唯一的即可,通常使用接口名
  • ContextUtil.enter:在該例子中,用於標識對test-sentinel-api的調用來源均爲test-origin。例如使用postman或其他請求方式調用了該資源,其來源都會被標識爲test-origin
  • Tracer.trace:降級規則中可以針對異常比例或異常數的閾值進行降級,而Sentinel只會對BlockException及其子類進行統計,其他異常不在統計範圍,所以需要使用Tracer.trace手動統計。1.3.1 版本開始支持自動統計,將在下一小節進行介紹

相關官方文檔:


@SentinelResource註解

經過上一小節的代碼示例,可以看到這些Sentinel API的使用方式並不是很優雅,有點類似於使用I/O流API的感覺,顯得代碼比較臃腫。好在Sentinel在1.3.1 版本開始支持@SentinelResource註解,該註解可以讓我們避免去寫這種臃腫不美觀的代碼。但即便如此,也還是有必要去學習Sentinel API的使用方式,因爲其底層還是得通過這些API來實現。

學習一個註解除了需要知道它能幹什麼之外,還得了解其支持的屬性作用,下表總結了@SentinelResource註解的屬性:

屬性 作用 是否必須
value 資源名稱
entryType entry類型,標記流量的方向,取值IN/OUT,默認是OUT
blockHandler 處理BlockException的函數名稱
blockHandlerClass 存放blockHandler的類。對應的處理函數必須static修飾,否則無法解析,其他要求:同blockHandler
fallback 用於在拋出異常的時候提供fallback處理邏輯。fallback函數可以針對所有類型的異常(除了exceptionsToIgnore 裏面排除掉的異常類型)進行處理
fallbackClass【1.6支持】 存放fallback的類。對應的處理函數必須static修飾,否則無法解析,其他要求:同fallback
defaultFallback【1.6支持】 用於通用的 fallback 邏輯。默認fallback函數可以針對所有類型的異常(除了exceptionsToIgnore 裏面排除掉的異常類型)進行處理。若同時配置了 fallback 和 defaultFallback,以fallback爲準
exceptionsToIgnore【1.6支持】 指定排除掉哪些異常。排除的異常不會計入異常統計,也不會進入fallback邏輯,而是原樣拋出
exceptionsToTrace 需要trace的異常 Throwable

blockHandler,處理BlockException函數的要求:

  1. 必須是public
  2. 返回類型與原方法一致
  3. 參數類型需要和原方法相匹配,並在最後加BlockException類型的參數
  4. 默認需和原方法在同一個類中。若希望使用其他類的函數,可配置 blockHandlerClass ,並指定blockHandlerClass裏面的方法

fallback函數要求:

  1. 返回類型與原方法一致
  2. 參數類型需要和原方法相匹配,Sentinel 1.6開始,也可在方法最後加Throwable類型的參數
  3. 默認需和原方法在同一個類中。若希望使用其他類的函數,可配置 fallbackClass ,並指定fallbackClass裏面的方法

defaultFallback函數要求:

  1. 返回類型與原方法一致
  2. 方法參數列表爲空,或者有一個Throwable類型的參數
  3. 默認需要和原方法在同一個類中。若希望使用其他類的函數,可配置 fallbackClass ,並指定 fallbackClass 裏面的方法

現在我們已經對@SentinelResource註解有了一個比較全面的瞭解,接下來使用@SentinelResource註解重構之前的代碼,直觀地瞭解下該註解帶來了哪些便利,重構後的代碼如下:

@GetMapping("/test-sentinel-resource")
@SentinelResource(
        value = "test-sentinel-resource",
        blockHandler = "blockHandlerFunc",
        fallback = "fallbackFunc"
)
public String testSentinelResource(@RequestParam(required = false) String a)
        throws InterruptedException {
    // 模擬執行被保護的業務邏輯耗時
    Thread.sleep(100);

    return a;
}

/**
 * 處理BlockException的函數(處理限流)
 */
public String blockHandlerFunc(String a, BlockException e) {
    // 如果被保護的資源被限流或者降級了,就會拋出BlockException
    log.warn("資源被限流或降級了.", e);
    return "資源被限流或降級了";
}

/**
 * 1.6 之前處理降級
 * 1.6 開始可以針對所有類型的異常(除了 exceptionsToIgnore 裏面排除掉的異常類型)進行處理
 */
public String fallbackFunc(String a) {
    return "發生異常了";
}

注:@SentinelResource註解目前不支持標識調用來源

Tips:

1.6.0 之前的版本 fallback 函數只針對降級異常(DegradeException)進行處理,不能針對業務異常進行處理

blockHandlerfallback 都進行了配置,則被限流降級而拋出 BlockException 時只會進入
blockHandler 處理邏輯。若未配置 blockHandlerfallbackdefaultFallback,則被限流降級時會將 BlockException 直接拋出

從 1.3.1 版本開始,註解方式定義資源支持自動統計業務異常,無需手動調用 Tracer.trace(ex) 來記錄業務異常。Sentinel 1.3.1 以前的版本需要自行調用 Tracer.trace(ex) 來記錄業務異常

@SentinelResource註解相關源碼:

  • com.alibaba.csp.sentinel.annotation.aspectj.AbstractSentinelAspectSupport
  • com.alibaba.csp.sentinel.annotation.aspectj.SentinelResourceAspect

相關官方文檔:


RestTemplate整合Sentinel

如果有了解過Hystrix的話,應該就會知道Hystrix除了可以對當前服務的接口進行容錯,還可以對服務提供者(被調用方)的接口進行容錯。到目前爲止,我們只介紹了在Sentinel控制檯對當前服務的接口添加相關規則進行容錯,但還沒有介紹如何對服務提供者的接口進行容錯。

實際上有了前面的鋪墊,現在想要實現對服務提供者的接口進行容錯就很簡單了,我們都知道在Spring Cloud體系中可以通過RestTemplate或Feign實現微服務之間的通信。所以只需要在RestTemplate或Feign上做文章就可以了,本小節先以RestTemplate爲例,介紹如何整合Sentinel實現對服務提供者的接口進行容錯。

很簡單,只需要用到一個註解,在配置RestTemplate的方法上添加@SentinelRestTemplate註解即可,代碼如下:

package com.zj.node.contentcenter.configuration;

import org.springframework.cloud.alibaba.sentinel.annotation.SentinelRestTemplate;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class BeanConfig {

    @Bean
    @LoadBalanced
    @SentinelRestTemplate
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

注:@SentinelRestTemplate註解包含blockHandler、blockHandlerClass、fallback、fallbackClass屬性,這些屬性的使用方式與@SentinelResource註解一致,所以我們可以利用這些屬性,在觸發限流、降級時定製自己的異常處理邏輯

然後我們再來寫段測試代碼,用於調用服務提供者的接口,代碼如下:

package com.zj.node.contentcenter.controller.content;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

@Slf4j
@RestController
@RequiredArgsConstructor
public class TestController {

    private final RestTemplate restTemplate;

    @GetMapping("/test-rest-template-sentinel/{userId}")
    public UserDTO test(@PathVariable("userId") Integer userId) {
        // 調用user-center服務的接口(此時user-center即爲服務提供者)
        return restTemplate.getForObject(
                "http://user-center/users/{userId}", UserDTO.class, userId);
    }
}

編寫完以上代碼重啓項目並可以正常訪問該測試接口後,此時在Sentinel控制檯的簇點鏈路中,就可以看到服務提供者(user-center)的接口已經註冊到這裏來了,現在只需要對其添加相關規則就可以實現容錯:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

若我們在開發期間,不希望Sentinel對服務提供者的接口進行容錯,可以通過以下配置進行開關:

# 用於開啓或關閉@SentinelRestTemplate註解
resttemplate:
  sentinel:
    enabled: true

Sentinel實現與RestTemplate整合的相關源碼:

  • org.springframework.cloud.alibaba.sentinel.custom.SentinelBeanPostProcessor

Feign整合Sentinel

上一小節介紹RestTemplate整合Sentinel時已經做了相關鋪墊,這裏就不廢話了直接上例子。首先在配置文件中添加如下配置:

feign:
  sentinel:
    # 開啓Sentinel對Feign的支持
    enabled: true

定義一個FeignClient接口:

package com.zj.node.contentcenter.feignclient;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(name = "user-center")
public interface UserCenterFeignClient {

    @GetMapping("/users/{id}")
    UserDTO findById(@PathVariable Integer id);
}

同樣的來寫段測試代碼,用於調用服務提供者的接口,代碼如下:

package com.zj.node.contentcenter.controller.content;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import com.zj.node.contentcenter.feignclient.UserCenterFeignClient;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class TestFeignController {

    private final UserCenterFeignClient feignClient;

    @GetMapping("/test-feign/{id}")
    public UserDTO test(@PathVariable Integer id) {
        // 調用user-center服務的接口(此時user-center即爲服務提供者)
        return feignClient.findById(id);
    }
}

編寫完以上代碼重啓項目並可以正常訪問該測試接口後,此時在Sentinel控制檯的簇點鏈路中,就可以看到服務提供者(user-center)的接口已經註冊到這裏來了,行爲與RestTemplate整合Sentinel是一樣的:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]


默認當限流、降級發生時,Sentinel的處理是直接拋出異常。如果需要自定義限流、降級發生時的異常處理邏輯,而不是直接拋出異常該如何做?@FeignClient註解中有一個fallback屬性,用於指定當遠程調用失敗時使用哪個類去處理。所以在這個例子中,我們首先需要定義一個類,並實現UserCenterFeignClient接口,代碼如下:

package com.zj.node.contentcenter.feignclient.fallback;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import com.zj.node.contentcenter.feignclient.UserCenterFeignClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class UserCenterFeignClientFallback implements UserCenterFeignClient {

    @Override
    public UserDTO findById(Integer id) {
        // 自定義限流、降級發生時的處理邏輯
        log.warn("遠程調用被限流/降級了");
        return UserDTO.builder().
                wxNickname("Default").
                build();
    }
}

然後在UserCenterFeignClient接口的@FeignClient註解上指定fallback屬性,如下:

@FeignClient(name = "user-center", fallback = UserCenterFeignClientFallback.class)
public interface UserCenterFeignClient {
    ...

接下來做一個簡單的測試,看看當遠程調用失敗時是否調用了fallback屬性所指定實現類裏的方法。爲服務提供者的接口添加一條流控規則,如下圖:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

使用postman頻繁發生請求,當QPS超過1時,返回結果如下:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

可以看到,返回了代碼中定義的默認值。由此可證當限流、降級或其他原因導致遠程調用失敗時,就會調用UserCenterFeignClientFallback類裏所實現的方法。


但是又有另外一個問題,這種方式無法獲取到異常對象,並且控制檯不會輸出任何相關的異常信息,若業務需要打印異常日誌或針對異常進行相關處理的話該怎麼辦呢?此時就得用到@FeignClient註解中的另一個屬性:fallbackFactory,同樣需要定義一個類,只不過實現的接口不一樣。代碼如下:

package com.zj.node.contentcenter.feignclient.fallbackfactory;

import com.zj.node.contentcenter.domain.dto.user.UserDTO;
import com.zj.node.contentcenter.feignclient.UserCenterFeignClient;
import feign.hystrix.FallbackFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class UserCenterFeignClientFallbackFactory implements FallbackFactory<UserCenterFeignClient> {

    @Override
    public UserCenterFeignClient create(Throwable cause) {

        return new UserCenterFeignClient() {
            @Override
            public UserDTO findById(Integer id) {
                // 自定義限流、降級發生時的處理邏輯
                log.warn("遠程調用被限流/降級了", cause);
                return UserDTO.builder().
                        wxNickname("Default").
                        build();
            }
        };
    }
}

在UserCenterFeignClient接口的@FeignClient註解上指定fallbackFactory屬性,如下:

@FeignClient(name = "user-center", fallbackFactory = UserCenterFeignClientFallbackFactory.class)
public interface UserCenterFeignClient {
    ...

需要注意的是,fallback與fallbackFactory只能二選一,不能同時使用。

重複之前的測試,此時控制檯就可以輸出相關異常信息了:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

Sentinel實現與Feign整合的相關源碼:

  • org.springframework.cloud.alibaba.sentinel.feign.SentinelFeign

Sentinel使用姿勢總結

Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]


擴展 - 錯誤信息優化

Sentinel默認在當前服務觸發限流或降級時僅返回簡單的異常信息,如下:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

並且限流和降級返回的異常信息是一樣的,導致無法根據異常信息區分是觸發了限流還是降級。

所以我們需要對錯誤信息進行相應優化,以便可以細緻區分觸發的是什麼規則。Sentinel提供了一個UrlBlockHandler接口,實現該接口即可自定義異常處理邏輯。具體如下示例:

package com.zj.node.contentcenter.sentinel;

import com.alibaba.csp.sentinel.adapter.servlet.callback.UrlBlockHandler;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.authority.AuthorityException;
import com.alibaba.csp.sentinel.slots.block.degrade.DegradeException;
import com.alibaba.csp.sentinel.slots.block.flow.FlowException;
import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowException;
import com.alibaba.csp.sentinel.slots.system.SystemBlockException;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.codehaus.jackson.map.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 自定義流控異常處理
 *
 * @author 01
 * @date 2019-08-02
 **/
@Slf4j
@Component
public class MyUrlBlockHandler implements UrlBlockHandler {

    @Override
    public void blocked(HttpServletRequest request, HttpServletResponse response, BlockException e) throws IOException {
        MyResponse errorResponse = null;
        // 不同的異常返回不同的提示語
        if (e instanceof FlowException) {
            errorResponse = MyResponse.builder()
                    .status(100).msg("接口限流了")
                    .build();
        } else if (e instanceof DegradeException) {
            errorResponse = MyResponse.builder()
                    .status(101).msg("服務降級了")
                    .build();
        } else if (e instanceof ParamFlowException) {
            errorResponse = MyResponse.builder()
                    .status(102).msg("熱點參數限流了")
                    .build();
        } else if (e instanceof SystemBlockException) {
            errorResponse = MyResponse.builder()
                    .status(103).msg("觸發系統保護規則")
                    .build();
        } else if (e instanceof AuthorityException) {
            errorResponse = MyResponse.builder()
                    .status(104).msg("授權規則不通過")
                    .build();
        }

        response.setStatus(500);
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        new ObjectMapper().writeValue(response.getWriter(), errorResponse);
    }
}

/**
 * 簡單的響應結構體
 */
@Data
@Builder
class MyResponse {
    private Integer status;
    private String msg;
}

此時再觸發流控規則就可以響應代碼中自定義的提示信息了:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]


擴展 - 實現區分來源

當配置流控規則或授權規則時,若需要針對調用來源進行限流,得先實現來源的區分,Sentinel提供了RequestOriginParser接口來處理來源。只要Sentinel保護的接口資源被訪問,Sentinel就會調用RequestOriginParser的實現類去解析訪問來源。

寫代碼:首先,服務消費者需要具備有一個來源標識,這裏假定爲服務消費者在調用接口的時候都會傳遞一個origin的header參數標識來源。具體如下示例:

package com.zj.node.contentcenter.sentinel;

import com.alibaba.csp.sentinel.adapter.servlet.callback.RequestOriginParser;
import com.alibaba.nacos.client.utils.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

/**
 * 實現區分來源
 *
 * @author 01
 * @date 2019-08-02
 **/
@Slf4j
@Component
public class MyRequestOriginParser implements RequestOriginParser {

    @Override
    public String parseOrigin(HttpServletRequest request) {
        // 從header中獲取名爲 origin 的參數並返回
        String origin = request.getHeader("origin");
        if (StringUtils.isBlank(origin)) {
            // 如果獲取不到,則拋異常
            String err = "origin param must not be blank!";
            log.error("parse origin failed: {}", err);
            throw new IllegalArgumentException(err);
        }

        return origin;
    }
}

編寫完以上代碼並重啓項目後,此時header中不包含origin參數就會報錯了:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]


擴展 - RESTful URL支持

瞭解過RESTful URL的都知道這類URL路徑可以動態變化,而Sentinel默認是無法識別這種變化的,所以每個路徑都會被當成一個資源,如下圖:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

這顯然是有問題的,好在Sentinel提供了UrlCleaner接口解決這個問題。實現該接口可以讓我們對來源url進行編輯並返回,這樣就可以將RESTful URL裏動態的路徑轉換爲佔位符之類的字符串。具體實現代碼如下:

package com.zj.node.contentcenter.sentinel;

import com.alibaba.csp.sentinel.adapter.servlet.callback.UrlCleaner;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.stereotype.Component;

import java.util.Arrays;

/**
 * RESTful URL支持
 *
 * @author 01
 * @date 2019-08-02
 **/
@Slf4j
@Component
public class MyUrlCleaner implements UrlCleaner {

    @Override
    public String clean(String originUrl) {
        String[] split = originUrl.split("/");

        // 將數字轉換爲特定的佔位標識符
        return Arrays.stream(split)
                .map(s -> NumberUtils.isNumber(s) ? "{number}" : s)
                .reduce((a, b) -> a + "/" + b)
                .orElse("");
    }
}

此時該RESTful接口就不會像之前那樣一個數字就註冊一個資源了:
Spring Cloud Alibaba之服務容錯組件 - Sentinel [代碼篇]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章