天天看點

業務系統 hystrix 實際應用

作者:閃念基因

一、認識 Hystrix

Hystrix 是 Netflix 開源的一款容錯架構,包含常用的容錯方法:線程池隔離、信号量隔離、熔斷、降級回退。在高并發通路下,系統所依賴的服務的穩定性對系統的影響非常大,依賴有很多不可控的因素,比如網絡連接配接變慢,資源突然繁忙,暫時不可用,服務脫機等。我們要建構穩定、可靠的分布式系統,就必須要有這樣一套容錯方法。

業務系統 hystrix 實際應用

二、Hystrix 解決了什麼問題

複雜分布式體系結構中的應用程式有幾十個依賴項,每個依賴項都不可避免地會在某個時刻失敗。如果主機應用程式沒有與這些外部故障隔離開來,那麼它就有被這些故障摧毀的風險。 例如,對于一個依賴 30 個服務的應用程式,其中每個服務都有 99.99% 的正常運作時間,您可以期待以下内容:

99.9930 = 99.7% 正常運作時間 10 億次請求中的 0.3% = 3000000 次失敗 2 小時以上的停機時間/月,即使所有依賴項都具有良好的正常運作時間。

現實情況通常更糟。 即使所有依賴關系都表現良好,如果不對整個系統進行彈性設計,數十項服務中每項服務的 0.01% 停機時間的總影響也相當于每月可能停機數小時。 當一切正常時,請求流可能如下所示:

業務系統 hystrix 實際應用

當許多後端系統中的一個變得潛在時,它可以阻止整個使用者請求:

業務系統 hystrix 實際應用

在高流量的情況下,一個潛在的後端依賴可能會導緻所有伺服器上的所有資源在幾秒鐘内飽和。

應用程式中通過網絡或進入用戶端庫可能導緻網絡請求的每一點都是潛在故障的根源。

比故障更糟糕的是,這些應用程式還可能導緻服務之間的延遲增加,進而備份隊列、線程和其他系統資源,進而導緻系統中更多的級聯故障。

業務系統 hystrix 實際應用

當通過第三方用戶端執行網絡通路時,這些問題會加劇。第三方客戶就是一個“黑匣子”,其中實施細節被隐藏,并且可以随時更改,網絡或資源配置對于每個用戶端庫都是不同的,通常難以監視和 更改。

通過的故障包括:

網絡連接配接失敗或降級。 服務和伺服器失敗或變慢。 新的庫或服務部署會改變行為或性能特征。 用戶端庫有錯誤。

所有這些都代表需要隔離和管理的故障和延遲,以便單個故障依賴關系不能導緻整個應用程式或系統的故障。

三、Hystrix 是怎麼實作它的設計目标的?

當您使用 Hystrix 包裝每個底層依賴項時,上圖所示的體系結構如下圖所示。 每個依賴關系彼此隔離,在延遲發生時可以飽和的資源受到限制,迅速執行 fallback 的邏輯,該邏輯決定了在依賴關系中發生任何類型的故障時會做出什麼響應:

業務系統 hystrix 實際應用

四、業務場景使用 Hystrix (熔斷器元件)來進行 TOMCAT 線程池的隔離

1、線程池的隔離

1.1線程隔離

業務系統 hystrix 實際應用

依賴隔離是 Hystrix 的核心目的。依賴隔離其實就是資源隔離,把對依賴使用的資源隔離起來,統一控制和排程。那為什麼需要把資源隔離起來呢?

主要有以下幾點:

  • 合理配置設定資源,把給資源配置設定的控制權交給使用者,某一個依賴的故障不會影響到其他的依賴調用,通路資源也不受影響。
  • 可以友善的指定調用政策,比如逾時異常,熔斷處理。
  • 對依賴限制資源也是對下遊依賴起到一個保護作用,避免大量的并發請求在依賴服務有問題的時候造成依賴服務癱瘓或者更糟的雪崩效應。
  • 對依賴調用進行封裝有利于對調用的監控和分析,類似于 hystrix-dashboard 的使用。

Hystrix 提供了兩種依賴隔離方式:線程池隔離 和 信号量隔離。

如下圖,線程池隔離,Hystrix 可以為每一個依賴建立一個線程池,使之和其他依賴的使用資源隔離,同時限制他們的并發通路和阻塞擴張。

每個依賴可以根據權重配置設定資源(這裡主要是線程),每一部分的依賴出現了問題,也不會影響其他依賴的使用資源。

業務系統 hystrix 實際應用

1.2線程池隔離

如果簡單的使用異步線程來實作依賴調用會有如下問題:

1.2.1 線程的建立和銷毀;

1.2.2 線程上下文空間的切換,使用者态和核心态的切換帶來的性能損耗。

使用線程池的方式可以解決第一種問題,但是第二個問題計算開銷是不能避免的。

Netflix在使用過程中詳細評估了使用異步線程和同步線程帶來的性能差異,結果表明在 99% 的情況下,異步線程帶來的幾毫秒延遲的完全可以接受的。

業務系統 hystrix 實際應用

1.3線程池隔離的優缺點

優點:

  • 一個依賴可以給予一個線程池,這個依賴的異常不會影響其他的依賴。
  • 使用線程可以完全隔離第三方代碼,請求線程可以快速放回。
  • 當一個失敗的依賴再次變成可用時,線程池将清理,并立即恢複可用,而不是一個長時間的恢複。
  • 可以完全模拟異步調用,友善異步程式設計。
  • 使用線程池,可以有效的進行實時監控、統計和封裝。

缺點:

  • 使用線程池的缺點主要是增加了計算的開銷。每一個依賴調用都會涉及到隊列,排程,上下文切換,而這些操作都有可能在不同的線程中執行。

2、整合 hystrix 元件

整體流程

業務系統 hystrix 實際應用

2.1 POM依賴

<dependency>
            <groupId>de.ahus1.prometheus.hystrix</groupId>
            <artifactId>prometheus-hystrix</artifactId>
            <version>4.1.0</version>
        </dependency>
        <dependency>
            <groupId>com.netflix.hystrix</groupId>
            <artifactId>hystrix-core</artifactId>
            <version>1.5.18</version>
        </dependency>
        <dependency>
            <groupId>com.netflix.hystrix</groupId>
            <artifactId>hystrix-metrics-event-stream</artifactId>
            <version>1.5.18</version>
        </dependency>
        <dependency>
            <groupId>com.netflix.hystrix</groupId>
            <artifactId>hystrix-javanica</artifactId>
            <version>1.5.18</version>
        </dependency>
           

2.2 Hystrix 生效

2.2.1 HystrixCommonRequestAspect

import com.netflix.hystrix.HystrixCommandGroupKey;
import com.netflix.hystrix.HystrixThreadPoolKey;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
 
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
 
 
@Slf4j
@Aspect
@Order(1)
@Component
public class HystrixCommonRequestAspect {
    @Around(value = "(within(@org.springframework.stereotype.Controller *) || within(@org.springframework.web.bind.annotation.RestController *)) && @annotation(requestMapping)")
    public Object requestMappingAround(ProceedingJoinPoint joinPoint, RequestMapping requestMapping) throws Throwable {
        return handleRequest(joinPoint, requestMapping);
    }
 
    @Around(value = "(within(@org.springframework.stereotype.Controller *) || within(@org.springframework.web.bind.annotation.RestController *)) && @annotation(getMapping)")
    public Object getMappingAround(ProceedingJoinPoint joinPoint, GetMapping getMapping) throws Throwable {
        return handleRequest(joinPoint, getMapping);
    }
 
    @Around(value = "(within(@org.springframework.stereotype.Controller *) || within(@org.springframework.web.bind.annotation.RestController *)) && @annotation(postMapping)")
    public Object postMappingAround(ProceedingJoinPoint joinPoint, PostMapping postMapping) throws Throwable {
        return handleRequest(joinPoint, postMapping);
    }
 
    @Around(value = "(within(@org.springframework.stereotype.Controller *) || within(@org.springframework.web.bind.annotation.RestController *)) && @annotation(putMapping)")
    public Object putMappingAround(ProceedingJoinPoint joinPoint, PutMapping putMapping) throws Throwable {
        return handleRequest(joinPoint, putMapping);
    }
 
    @Around(value = "(within(@org.springframework.stereotype.Controller *) || within(@org.springframework.web.bind.annotation.RestController *)) && @annotation(deleteMapping)")
    public Object putMappingAround(ProceedingJoinPoint joinPoint, DeleteMapping deleteMapping) throws Throwable {
        return handleRequest(joinPoint, deleteMapping);
    }
 
    @Around(value = "(within(@org.springframework.stereotype.Controller *) || within(@org.springframework.web.bind.annotation.RestController *)) && @annotation(patchMapping)")
    public Object putMappingAround(ProceedingJoinPoint joinPoint, PatchMapping patchMapping) throws Throwable {
        return handleRequest(joinPoint, patchMapping);
    }
 
    private Object handleRequest(ProceedingJoinPoint joinPoint, Annotation mapping) throws Throwable {
        if(hasHystrixCommand(joinPoint)){
            if(log.isDebugEnabled()){
                log.debug("目前請求有自定義的command,使用自定義的command");
            }
            return joinPoint.proceed();
        }else{
            if(log.isDebugEnabled()){
                log.debug("目前請求沒有自定義的command,使用預設的command");
            }
            HttpProceedCommand proceedCommand = new HttpProceedCommand();
            proceedCommand.setJoinPoint(joinPoint);
            return proceedCommand.execute();
        }
    }
 
 
 
    public static class HttpProceedCommand extends com.netflix.hystrix.HystrixCommand{
 
        private ProceedingJoinPoint joinPoint;
 
        public ProceedingJoinPoint getJoinPoint() {
            return joinPoint;
        }
 
        public HttpProceedCommand(){
            super(HystrixCommandGroupKey.Factory.asKey("HttpProceedCommand"),HystrixThreadPoolKey.Factory.asKey("HttpProceedCommandThreadPool"));
        }
 
 
        public void setJoinPoint(ProceedingJoinPoint joinPoint) {
            this.joinPoint = joinPoint;
        }
 
        @Override
        protected Object run() throws Exception {
            try {
                return joinPoint.proceed();
            } catch (Throwable e) {
                throw new RuntimeException(e);
            }
        }
    }
 
 
    private boolean hasHystrixCommand(ProceedingJoinPoint joinPoint){
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        HystrixCommand hystrixCommand = method.getAnnotation(HystrixCommand.class);
        return hystrixCommand != null;
    }
 
 
 
 
}
           

2.2.2 HystrixCommand 注解

@Configuration
public class HystrixConfig {
 
    @Resource
    private CollectorRegistry registry;
 
    /**
     * 用來攔截處理 HystrixCommand 注解
     * @return
     */
    @Bean
    public HystrixCommandAspect hystrixCommandAspect() {
        HystrixPlugins.getInstance().registerCommandExecutionHook(new MyHystrixHook());
        HystrixPrometheusMetricsPublisher.builder().withRegistry(registry).buildAndRegister();
        return new HystrixCommandAspect();
    }
 
    /**
     * 用來向監控中心 Dashboard 發送 stream 資訊
     * @return
     */
    @Bean
    public ServletRegistrationBean hystrixMetricsStreamServlet() {
        ServletRegistrationBean registration = new ServletRegistrationBean(new HystrixMetricsStreamServlet());
        registration.addUrlMappings("/hystrix.stream");
        return registration;
    }
}
           

2.2.3 HystrixCommand 注解

參數:

commandKey : 代表了一類 command,一般來說,代表了底層的依賴服務的一個接口

threadPoolKey : 代表使用的線程池 KEY,相同的 threadPoolKey 會使用同一個線程池

ignoreExceptions : 調用服務時,除了 HystrixBadRequestException 之外,其他 @HystrixCommand 修飾的函數抛出的異常均會被Hystrix 認為指令執行失敗而觸發服務降級的處理邏輯 (調用 fallbackMethod 指定的回調函數),是以當需要在指令執行中抛出不觸發降級的異常時來使用它,通過這個參數指定,哪些異常抛出時不觸發降級(不去調用 fallbackMethod ),而是将異常向上抛出。

**fallbackMethod **: 降級使用的方法,需要在同一個類中

@PostMapping("/test")
@HystrixCommand(commandKey = "testCommandKey",
        threadPoolKey = "testThreadPool",
        ignoreExceptions = {RuntimeException.class},
        fallbackMethod = "testHystrixFail")
public String test() {
    System.out.println("test");
    return "測試";
}
           

降級方法

public String testHystrixFail() {
    return "進入降級方法";
}
           

對重要資料進行緩存

/**
 * 首頁的查詢緩存,緩存 48 個小時;做降級政策使用
 * 時間小于 24h 時,進行更新緩存
 */
public static <T> void setUpHystrixCache(T t, String key) {
    Long ttl = jedisClientUtil.ttl(key);
    Long day = 169200L;
    if (ttl <= day) {
       jedisClientUtil.set(key, 60 * 60 * 24 * 2, JSONObject.toJSONString(t));
    }
}
           

線程池配置如下

業務系統 hystrix 實際應用

3、線程池的規劃

根據實際情況而定

查詢活躍線程數方法

top -H -i -b -d 1 -n2 -p 程序号 | awk -v RS= 'END{print $0}' | awk '$1 ~ /[0-9]+/{print $12}' | sed -E 's/[0-9]+/n/g' | sort | uniq -c 
           

arthas

thread --state RUNNABLE
           

4、踩坑

4.1 因為使用的是線程池的模式,請求會在一個新的線程池中拿到線程執行代碼,而不是使用 tomcat 的線程所有會存在一個 ThreadLocal 變量擷取不到的情況,例如 TraceId

解決方案

/**
 * Hystrix 使用線程池的模式會得不到父線程的線程上下文 列如 TraceId 該類就是為了解決可以得到父線程的 ThreadLocal 的變量
 */
public class MyHystrixHook extends HystrixCommandExecutionHook {
 
    private HystrixRequestVariableDefault<String> traceIdVariable = new HystrixRequestVariableDefault<>();
 
    @Override
    public <T> void onStart(HystrixInvokable<T> commandInstance) {
        HystrixRequestContext.initializeContext();
        traceIdVariable.set(TraceIdUtil.getCurrentTraceId());
    }
 
    @Override
    public <T> Exception onError(HystrixInvokable<T> commandInstance, HystrixRuntimeException.FailureType failureType, Exception e) {
        HystrixRequestContext.getContextForCurrentThread().shutdown();
        return super.onError(commandInstance, failureType, e);
    }
 
    @Override
    public <T> void onSuccess(HystrixInvokable<T> commandInstance) {
        HystrixRequestContext.getContextForCurrentThread().shutdown();
        super.onSuccess(commandInstance);
    }
 
    @Override
    public <T> void onExecutionStart(HystrixInvokable<T> commandInstance) {
        TraceIdUtil.initTraceId(traceIdVariable.get());
    }
 
    @Override
    public <T> void onFallbackStart(HystrixInvokable<T> commandInstance) {
        TraceIdUtil.initTraceId(traceIdVariable.get());
    }
}
           

然後在合适的位置注冊:

HystrixPlugins.getInstance().registerCommandExecutionHook(new MyHystrixHook());
           

4.2 通過注解的方式可以設定降級方法的 **ignoreExceptions 參數,**攔截器的方式無法設定

解決方案

public static class HttpProceedCommand extends com.netflix.hystrix.HystrixCommand{
 
       private ProceedingJoinPoint joinPoint;
 
       public ProceedingJoinPoint getJoinPoint() {
           return joinPoint;
       }
 
       public HttpProceedCommand(){
           super(HystrixCommandGroupKey.Factory.asKey("HttpProceedCommand"),HystrixThreadPoolKey.Factory.asKey("HttpProceedCommandThreadPool"));
       }
 
 
       public void setJoinPoint(ProceedingJoinPoint joinPoint) {
           this.joinPoint = joinPoint;
       }
 
       @Override
       protected Object run() throws Exception {
           try {
               return joinPoint.proceed();
           } catch (Throwable e) {
               if (e instanceof RuntimeException) {
                   throw (Exception) e;
               } else {
                   throw new HystrixBadRequestException(e.getMessage(), e);
               }
           }
       }
   }
           

在合适的地方抛出 HystrixBadRequestException

HystrixBadRequestException 用提供的參數或狀态表示錯誤而不是執行失敗的異常。與 HystrixCommand 抛出的所有其他異常不同,這不會觸發回退,不會計算故障名額,是以不會觸發斷路器。

4.3 HystrixRuntimeException: Command fallback execution rejected

執行錯誤了,本應該去執行 fallback 方法,可是卻被 reject 了,為什麼呢?

這種情況下,一般來說是 command 已經熔斷了,所有請求都進入 fallback 導緻的,因為 fallback 預設是有個并發最大處理的限制,fallback.isolation.semaphore.maxConcurrentRequests,預設是10,這個方法及時很簡單,處理很快,可是QPS如果很高,還是很容易達到10這個門檻值,導緻後面的被拒絕。

解決方法也很簡單:

  • fallback 盡可能的簡單,不要有耗時操作,如果用一個 http 接口來作為另一個 http 接口的降級處理,那你必須考慮這個 http 是不是也會失敗;
  • 可以适當增大 fallback.isolation.semaphore.maxConcurrentRequests

4.4 時間較長的接口是否應該進行中斷

根據實際場景來判斷 可通過

hystrix.command.[command].execution.isolation.thread.interruptOnTimeout = false

來配置關閉

5、注意問題 需要注意本身就是耗時的請求

下載下傳請求

上傳請求

6、Hystrix配置

command 和 pool 和 collapser 的配置參數

HystrixCommandProperties 指令執行相關配置:

hystrix.command.[commandkey].execution.isolation.strategy 隔離政策THREAD或SEMAPHORE 預設HystrixCommands使用THREAD方式 HystrixObservableCommands使用SEMAPHORE
  hystrix.command.[commandkey].execution.timeout.enabled 是否開啟逾時設定,預設true。
  hystrix.command.[commandkey].execution.isolation.thread.timeoutInMilliseconds 預設逾時時間 預設1000ms
  hystrix.command.[commandkey].execution.isolation.thread.interruptOnTimeout是否打開逾時線程中斷 預設值true
  hystrix.command.[commandkey].execution.isolation.thread.interruptOnFutureCancel 當隔離政策為THREAD時,當執行線程執行逾時時,是否進行中斷處理,即Future#cancel(true)處理,預設為false。
  hystrix.command.[commandkey].execution.isolation.semaphore.maxConcurrentRequests 信号量最大并發度 預設值10該參數當使用ExecutionIsolationStrategy.SEMAPHORE政策時才有效。如果達到最大并發請求數,請求會被拒絕。理論上選擇semaphore size的原則和選擇thread size一緻,但選用semaphore時每次執行的單元要比較小且執行速度快(ms級别),否則的話應該用thread。 
  hystrix.command.[commandkey].fallback.isolation.semaphore.maxConcurrentRequests fallback方法的信号量配置,配置getFallback方法并發請求的信号量,如果請求超過了并發信号量限制,則不再嘗試調用getFallback方法,而是快速失敗,預設信号量為10。
  hystrix.command.[commandkey].fallback.enabled 是否啟用降級處理,如果啟用了,則在逾時或異常時調用getFallback進行降級處理,預設開啟。


  hystrix.command.[commandkey].circuitBreaker.enabled 是否開啟熔斷機制,預設為true。
  hystrix.command.[commandkey].circuitBreaker.forceOpen 強制開啟熔斷,預設為false。
  hystrix.command.[commandkey].circuitBreaker.forceClosed 強制關閉熔斷,預設為false。
  hystrix.command.[commandkey].circuitBreaker.sleepWindowInMilliseconds  熔斷視窗時間,預設為5s。
  hystrix.command.[commandkey].circuitBreaker.requestVolumeThreshold 當在配置時間視窗内達到此數量後的失敗,進行短路。預設20個
  hystrix.command.[commandkey].circuitBreaker.errorThresholdPercentage 出錯百分比門檻值,當達到此門檻值後,開始短路。預設50%


  hystrix.command.[commandkey].metrics.rollingStats.timeInMilliseconds   設定統計滾動視窗的長度,以毫秒為機關。用于監控和熔斷器 預設10s
  hystrix.command.[commandkey].metrics.rollingStats.numBuckets  設定統計視窗的桶數量 預設10
  hystrix.command.[commandkey].metrics.rollingPercentile.enabled 設定執行時間是否被跟蹤,并且計算各個百分比,50%,90%等的時間 預設true
  hystrix.command.[commandkey].metrics.rollingPercentile.timeInMilliseconds 設定執行時間在滾動視窗中保留時間,用來計算百分比 預設60000ms
  hystrix.command.[commandkey].metrics.rollingPercentile.numBuckets 設定rollingPercentile視窗的桶數量 預設6。
  hystrix.command.[commandkey].metrics.rollingPercentile.bucketSize 此屬性設定每個桶儲存的執行時間的最大值 預設100。如果bucket size=100,window=10s,若這10s裡有500次執行,隻有最後100次執行會被統計到bucket裡去。增加該值會增加記憶體開銷以及排序的開銷。
  hystrix.command.[commandkey].metrics.healthSnapshot.intervalInMilliseconds 記錄health 快照(用來統計成功和錯誤綠)的間隔,預設500ms


  hystrix.command.[commandkey].requestCache.enabled 設定是否緩存請求,request-scope内緩存 預設值true
  hystrix.command.[commandkey].requestLog.enabled 設定HystrixCommand執行和事件是否列印到HystrixRequestLog中 預設值true
  hystrix.command.[commandkey].threadPoolKeyOverride   指令的線程池key,決定該指令使用哪個線程池。
           

HystrixThreadPoolProperties線程池相關配置:

hystrix.threadpool.[threadkey].coreSize 線程池核心線程數 預設值10;
  hystrix.threadpool.[threadkey].maximumSize  線程池最大線程數 預設值10; 
  hystrix.threadpool.[threadkey].allowMaximumSizeToDivergeFromCoreSize   當線程數大于核心線程數時,是否需要回收。與keepAliveTimeMinutes配合使用。
  hystrix.threadpool.[threadkey].keepAliveTimeMinutes  當實際線程數超過核心線程數時,線程存活時間 預設值1min
  hystrix.threadpool.[threadkey].maxQueueSize  最大等待隊列數 預設不開啟使用SynchronousQueue 不可動态調整
  hystrix.threadpool.[threadkey].queueSizeRejectionThreshold   允許在隊列中的等待的任務數量 預設值5
  hystrix.threadpool.[threadkey].metrics.rollingStats.timeInMilliseconds 設定統計滾動視窗的長度,以毫秒為機關 預設值10000。
  hystrix.threadpool.[threadkey].metrics.rollingStats.numBuckets 設定統計視窗的桶數量 預設10
           

HystrixCollapserProperties批處理相關配置:

hystrix.collapser.[collapserKey].maxRequestsInBatch 單次批處理的最大請求數,達到該數量觸發批處理,預設Integer.MAX_VALUE
  hystrix.collapser.[collapserKey].timerDelayInMilliseconds  觸發批處理的延遲,也可以為建立批處理的時間+該值,預設值10
  hystrix.collapser.[collapserKey].requestCache.enabled 預設值true
  hystrix.collapser.[collapserKey].metrics.rollingStats.timeInMilliseconds  預設值10000
  hystrix.collapser.[collapserKey].metrics.rollingStats.numBuckets 預設值10
  hystrix.collapser.[collapserKey].metrics.rollingPercentile.enabled 預設值true
  hystrix.collapser.[collapserKey].metrics.rollingPercentile.timeInMilliseconds 預設值60000
  hystrix.collapser.[collapserKey].metrics.rollingPercentile.numBuckets 預設值6
  hystrix.collapser.[collapserKey].metrics.rollingPercentile.bucketSize 預設值100
           

五、總結

以上文檔是借鑒網際網路經驗和項目接入經驗總結而來,相關配置僅供參考,具體配置請以實際情況而定。

參考 : https://github.com/Netflix/Hystrix/wiki#what

作者:落日

來源:微信公衆号:政采雲技術

出處:https://mp.weixin.qq.com/s/2l9DhdFxkeYjXAQj5Kk-0Q

繼續閱讀