當前位置: 妍妍網 > 碼農

分布式鏈路追蹤:TraceIdFilter + MDC + Skywalking

2024-06-09碼農


架構師(JiaGouX)

我們都是架構師!
架構未來,你來不來?


  • 痛點

  • 解決

  • MDC

  • 原理

  • Logback 占位符

  • 痛點

    查線上日誌時,同一個 Pod 內多執行緒日誌交錯,很難追蹤每個請求對應的日誌資訊。

    日誌收集工具將多個 Pod 的日誌收集到同一個資料庫中後,情況就更加混亂不堪了。

    解決

    TraceId + MDC

    MDC:

    https://logback.qos.ch/manual/mdc.html

  • 前端每次請求時,添加 X-App-Trace-Id 請求頭,X-App-Trace-Id 值的生成方式可以選擇【時間戳 + UUID】,保證 traceId 的唯一性。

  • 後端在 TraceIdFilter 中取出 X-App-Trace-Id 的值:String traceId = httpServletRequest.getHeader(TRACE_ID_HEADER_KEY)。如果請求沒有攜帶 X-App-Trace-Id 請求頭,後端服務可以使用 UUID 或者 Snowflake 演算法生成一個 traceId。

  • 將 traceId 塞到 slf4j MDC 中:MDC.put(MDC_TRACE_ID_KEY, traceId),在 logback pattern 中使用 %X{traceId} 占位符打印 traceId。

  • publicvoiddoFilter(ServletRequest request, ServletResponse response, FilterChain chain)
    throws IOException, ServletException {
    HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    String traceId = httpServletRequest.getHeader(TRACE_ID_HEADER_KEY);
    if (StrUtil.isBlank(traceId)) {
    traceId =UUID.randomUUID().toString();
    }
    MDC.put(MDC_TRACE_ID_KEY, traceId);
    try {
    chain.doFilter(request, response);
    } finally {
    MDC.remove(MDC_TRACE_ID_KEY);
    }
    }
    <?xml version="1.0" encoding="UTF-8"?>
    <configuration debug="false">
    <property name="pattern" value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] %-5level [%thread] %logger %line [%X{traceId}] [%tid] - %msg%n"/>

    整合 Feign

    發起服務間呼叫時,需要將 MDC 中的 traceId 傳遞到被呼叫服務。我們計畫中統一使用 Feign Client,實作服務間的 HTTP 遠端呼叫,在 Feign RequestInterceptor 中,取出 MDC 中的 traceId,塞到請求頭中:requestTemplate.header(TRACE_ID_HEADER_KEY, MDC.get(MDC_TRACE_ID_KEY));

    @Override
    publicvoidapply(RequestTemplate template) {
    template.header(TRACE_ID_HEADER_KEY, MDC.get(MDC_TRACE_ID_KEY));
    }

    多執行緒適配

    Please note that MDC as implemented by logback- classic assumes that values are placed into the MDC with moderate frequency. Also note that a child thread does not automatically inherit a copy of the mapped diagnostic context of its parent.

    在子執行緒執行任務前,將父執行緒的 MDC 內容設定到子執行緒的 MDC 中;在子執行緒任務執行完成後,清除子執行緒 MDC 中的內容。

    適配 JDK ThreadPoolExecutor:

    public classMdcAwareThreadPoolExecutorextendsThreadPoolExecutor {
    @Override
    publicvoidexecute(Runnable command) {
    Map<String, String> parentThreadContextMap =MDC.getCopyOfContextMap();
    super.execute(MdcTaskUtils.adaptMdcRunnable(command, parentThreadContextMap));
    }
    }

    適配 Spring TaskDecorator:


    @Component
    public classMdcAwareTaskDecoratorimplementsTaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
    Map<String, String> parentThreadContextMap =MDC.getCopyOfContextMap();
    return MdcTaskUtils.adaptMdcRunnable(runnable, parentThreadContextMap);
    }
    }

    MdcTaskUtils#adaptMdcRunnable():采用裝飾者模式,裝飾原生的 Runnable runnable 物件,在原生 Runnable 物件執行前,將父執行緒的 MDC 設定到子執行緒中,在原生 Runnable 物件執行結束後,清除子執行緒 MDC 中的內容。

    @Slf4j
    public abstract classMdcTaskUtils {
    publicstatic Runnable adaptMdcRunnable(Runnable runnable, Map<String, String> parentThreadContextMap) {
    return () -> {
    log.debug("parentThreadContextMap: {}, currentThreadContextMap: {}", parentThreadContextMap,
    MDC.getCopyOfContextMap());
    if (MapUtils.isEmpty(parentThreadContextMap) ||!parentThreadContextMap.containsKey(MDC_TRACE_ID_KEY)) {
    log.debug("can not find a parentThreadContextMap, maybe task is fired using async or schedule task.");
    MDC.put(MDC_TRACE_ID_KEY, UUID.randomUUID().toString());
    } else {
    MDC.put(MDC_TRACE_ID_KEY, parentThreadContextMap.get(MDC_TRACE_ID_KEY));
    }
    try {
    runnable.run();
    } finally {
    MDC.remove(MDC_TRACE_ID_KEY);
    }
    };
    }
    }

    整合 Skywalking

    Skywalking 官方提供了對 logback 1.x 版本的適配:apm-toolkit-logback-1.x,可以在 logback 中打印 skywalking traceId,可以將 X-App-Trace-Id 和 skywalking traceId 結合起來,方便介面業務和效能問題的排查。

    layout 具體實作類選擇 TraceIdPatternLogbackLayout。在 logback pattern 中使用 %tid 打印 skywalking traceId。

    <?xml version="1.0" encoding="UTF-8"?>
    <configuration debug="false">
    <property name="pattern" value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] %-5level [%thread] %logger %line [%X{traceId}] [%tid] - %msg%n"/>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
    <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">
    <pattern>${pattern}</pattern>
    </layout>
    </encoder>
    </appender>

    TraceIdPatternLogbackLayout 類初始化時,添加了兩個 PatternConverter:

  • tid:使用 LogbackPatternConverter,將 %tid 占位符轉換為 skywalking traceId。

  • sw_ctx:使用 LogbackSkyWalkingContextPatternConverter,將 %sw_ctx 占位符轉換為 skywalking context。

  • public classTraceIdPatternLogbackLayoutextendsPatternLayout {
    publicTraceIdPatternLogbackLayout() {
    }
    static {
    defaultConverterMap.put("tid", LogbackPatternConverter. class.getName());
    defaultConverterMap.put("sw_ctx", LogbackSkyWalkingContextPatternConverter. class.getName());
    }
    }

    LogbackPatternConverter#convert() 方法寫死了返回 "TID: N/A",這是怎麽回事呢?

    public classLogbackPatternConverterextends classicConverter {
    publicLogbackPatternConverter() {
    }
    publicStringconvert(ILoggingEvent iLoggingEvent) {
    return"TID: N/A";
    }
    }

    啟動 Java 套用時,指定 java agent 啟動參數 -javaagent:-javaagent:/opt/tools/skywalking-agent.jar。skywalking agent 會代理 LogbackPatternConverter 類,重寫 convert() 方法的邏輯。


    package org.apache.skywalking.apm.toolkit.log.logback.v1.x;
    import ch.qos.logback. classic.pattern. classicConverter;
    import ch.qos.logback. classic.spi.ILoggingEvent;
    import java.lang.reflect.Method;
    import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.EnhancedInstance;
    import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstMethodsInter;
    import org.apache.skywalking.apm.toolkit.log.logback.v1.x.LogbackPatternConverter$auxiliary$pJ6Zrqzi;
    public classLogbackPatternConverter
    extends classicConverter
    implementsEnhancedInstance {
    private volatile Object _$Enhanced classField_ws;
    publicstatic volatile /* synthetic */ InstMethodsInter delegate$mo3but1;
    privatestatic final /* synthetic */ Method cachedValue$oeLgRjrq$u5j8qu3;
    publicStringconvert(ILoggingEvent iLoggingEvent) {
    return (String)delegate$mo3but1.intercept(this, newObject[]{iLoggingEvent}, newLogbackPatternConverter$auxiliary$pJ6Zrqzi(this, iLoggingEvent), cachedValue$oeLgRjrq$u5j8qu3);
    }
    private/* synthetic */Stringconvert$original$T8InTdln(ILoggingEvent iLoggingEvent) {
    /*34*/return"TID: N/A";
    }
    @Override
    publicvoidsetSkyWalkingDynamicField(Object object) {
    this._$Enhanced classField_ws= object;
    }
    @Override
    publicObjectgetSkyWalkingDynamicField() {
    returnthis._$Enhanced classField_ws;
    }
    static {
    classLoader.getSystem classLoader().load class("org.apache.skywalking.apm.dependencies.net.bytebuddy.dynamic.Nexus").getMethod("initialize", class. class, Integer.TYPE).invoke(null, LogbackPatternConverter. class, -1942176692);
    cachedValue$oeLgRjrq$u5j8qu3 = LogbackPatternConverter. class.getMethod("convert", ILoggingEvent. class);
    }
    final /* synthetic */Stringconvert$original$T8InTdln$accessor$oeLgRjrq(ILoggingEvent iLoggingEvent) {
    returnthis.convert$original$T8InTdln(iLoggingEvent);
    }
    }






    MDC 原理

    MDC 在 slf4j-api jar 包中,MDC 是 slf4j 的規範,對 MDC 的所有操作都會落到 MDCAdapter 介面的方法上。


    public classMDC {
    static final StringNULL_MDCA_URL="http://www.slf4j.org/codes.html#null_MDCA";
    static final StringNO_STATIC_MDC_BINDER_URL="http://www.slf4j.org/codes.html#no_static_mdc_binder";
    static MDCAdapter mdcAdapter;
    publicstaticvoidput(String key, String val) throws IllegalArgumentException {
    if (key ==null) {
    thrownewIllegalArgumentException("key parameter cannot be null");
    }
    if (mdcAdapter ==null) {
    thrownewIllegalStateException("MDCAdapter cannot be null. See also "+NULL_MDCA_URL);
    }
    mdcAdapter.put(key, val);
    }
    }

    MDCAdapter 是 slf4j 提供的 MDC 介面卡介面,也就是 MDC 的規範。任何日誌框架想要使用 MDC 功能,需要遵守 MDCAdapter 介面介面規範,實作介面中的方法。

    // This interface abstracts the service offered by various MDC implementations.
    publicinterfaceMDCAdapter {
    publicvoidput(String key, String val);
    publicStringget(String key);
    }

    Logback 日誌框架提供了對 MDCAdapter 的適配:LogbackMDCAdapter,底層采用 ThreadLocal 實作。


    public classLogbackMDCAdapterimplementsMDCAdapter {
    // The internal map is copied so as
    // We wish to avoid unnecessarily copying of the map. To ensure
    // efficient/timely copying, we have a variable keeping track of the last
    // operation. A copy is necessary on 'put' or 'remove' but only if the last
    // operation was a 'get'. Get operations never necessitate a copy nor
    // successive 'put/remove' operations, only a get followed by a 'put/remove'
    // requires copying the map.
    // See http://jira.qos.ch/browse/LOGBACK-620 for the original discussion.
    // We no longer use CopyOnInheritThreadLocal in order to solve LB classIC-183
    // Initially the contents of the thread local in parent and child threads
    // reference the same map. However, as soon as a thread invokes the put()
    // method, the maps diverge as they should.
    final ThreadLocal<Map<String, String>> copyOnThreadLocal =newThreadLocal<Map<String, String>>();
    privatestatic final int WRITE_OPERATION=1;
    privatestatic final int MAP_COPY_OPERATION=2;
    // keeps track of the last operation performed
    final ThreadLocal<Integer> lastOperation =newThreadLocal<Integer>();

    publicvoidput(String key, String val) throws IllegalArgumentException {
    if (key ==null) {
    thrownewIllegalArgumentException("key cannot be null");
    }
    Map<String, String> oldMap = copyOnThreadLocal.get();
    Integer lastOp =getAndSetLastOperation(WRITE_OPERATION);
    if (wasLastOpReadOrNull(lastOp) || oldMap ==null) {
    Map<String, String> newMap =duplicateAndInsertNewMap(oldMap);
    newMap.put(key, val);
    } else {
    oldMap.put(key, val);
    }
    }
    publicStringget(String key) {
    final Map<String, String> map = copyOnThreadLocal.get();
    if ((map !=null) && (key !=null)) {
    return map.get(key);
    } else {
    returnnull;
    }
    }
    }







    Logback 占位符

    PatternLayout 類初始化時,設定了 logback 常用占位符對應的 Converter。


    public classPatternLayoutextendsPatternLayoutBase<ILoggingEvent> {
    publicstatic final Map<String, String>DEFAULT_CONVERTER_MAP=newHashMap<String, String>();
    publicstatic final Map<String, String>CONVERTER_ class_TO_KEY_MAP=newHashMap<String, String>();
    /**
    * @deprecated replaced by DEFAULT_CONVERTER_MAP
    */

    publicstatic final Map<String, String> defaultConverterMap =DEFAULT_CONVERTER_MAP;
    publicstatic final StringHEADER_PREFIX="#logback. classic pattern: ";
    static {
    DEFAULT_CONVERTER_MAP.putAll(Parser.DEFAULT_COMPOSITE_CONVERTER_MAP);
    DEFAULT_CONVERTER_MAP.put("d", DateConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("date", DateConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(DateConverter. class.getName(), "date");
    DEFAULT_CONVERTER_MAP.put("r", RelativeTimeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("relative", RelativeTimeConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(RelativeTimeConverter. class.getName(), "relative");
    DEFAULT_CONVERTER_MAP.put("level", LevelConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("le", LevelConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("p", LevelConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(LevelConverter. class.getName(), "level");

    DEFAULT_CONVERTER_MAP.put("t", ThreadConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("thread", ThreadConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(ThreadConverter. class.getName(), "thread");
    DEFAULT_CONVERTER_MAP.put("lo", LoggerConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("logger", LoggerConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("c", LoggerConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(LoggerConverter. class.getName(), "logger");
    DEFAULT_CONVERTER_MAP.put("m", MessageConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("msg", MessageConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("message", MessageConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(MessageConverter. class.getName(), "message");
    DEFAULT_CONVERTER_MAP.put("C", classOfCallerConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put(" class", classOfCallerConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put( classOfCallerConverter. class.getName(), " class");
    DEFAULT_CONVERTER_MAP.put("M", MethodOfCallerConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("method", MethodOfCallerConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(MethodOfCallerConverter. class.getName(), "method");
    DEFAULT_CONVERTER_MAP.put("L", LineOfCallerConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("line", LineOfCallerConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(LineOfCallerConverter. class.getName(), "line");
    DEFAULT_CONVERTER_MAP.put("F", FileOfCallerConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("file", FileOfCallerConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(FileOfCallerConverter. class.getName(), "file");
    DEFAULT_CONVERTER_MAP.put("X", MDCConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("mdc", MDCConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("ex", ThrowableProxyConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("exception", ThrowableProxyConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("rEx", RootCauseFirstThrowableProxyConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("rootException", RootCauseFirstThrowableProxyConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("throwable", ThrowableProxyConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("xEx", ExtendedThrowableProxyConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("xException", ExtendedThrowableProxyConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("xThrowable", ExtendedThrowableProxyConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("nopex", NopThrowableInformationConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("nopexception", NopThrowableInformationConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("cn", ContextNameConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("contextName", ContextNameConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(ContextNameConverter. class.getName(), "contextName");
    DEFAULT_CONVERTER_MAP.put("caller", CallerDataConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(CallerDataConverter. class.getName(), "caller");
    DEFAULT_CONVERTER_MAP.put("marker", MarkerConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(MarkerConverter. class.getName(), "marker");
    DEFAULT_CONVERTER_MAP.put("property", PropertyConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("n", LineSeparatorConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("black", BlackCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("red", RedCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("green", GreenCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("yellow", YellowCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("blue", BlueCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("magenta", MagentaCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("cyan", CyanCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("white", WhiteCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("gray", GrayCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("boldRed", BoldRedCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("boldGreen", BoldGreenCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("boldYellow", BoldYellowCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("boldBlue", BoldBlueCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("boldMagenta", BoldMagentaCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("boldCyan", BoldCyanCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("boldWhite", BoldWhiteCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("highlight", HighlightingCompositeConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("lsn", LocalSequenceNumberConverter. class.getName());
    CONVERTER_ class_TO_KEY_MAP.put(LocalSequenceNumberConverter. class.getName(), "lsn");
    DEFAULT_CONVERTER_MAP.put("prefix", PrefixCompositeConverter. class.getName());
    }
    }
























    ThreadConverter:將 %thread 占位符轉換為 logger 執行緒。

    public classThreadConverterextends classicConverter {
    publicStringconvert(ILoggingEvent event) {
    return event.getThreadName();
    }
    )

    在 PatternLayout 中,預設添加了對 MDC 的支持,可以將 %X{key} 或者 %mdc{key} 占位符轉換為 MDC.get(key)。

    DEFAULT_CONVERTER_MAP.put("X", MDCConverter. class.getName());
    DEFAULT_CONVERTER_MAP.put("mdc", MDCConverter. class.getName());

    MDCConverter 為何可以將 %X{traceId} 或者 %mdc{traceId} 占位符轉換為 MDC.get("traceId") ?

    在程式啟動時,ch.qos.logback.core.pattern.parser.Compiler#compile() 會解析使用者配置的 pattern 運算式,得到 pattern 中需要動態解析的占位符,比如 %d{yyyy-MM-dd HH:mm:ss.SSS},%X{traceId}。將這些動態占位符傳遞給 DynamicConverter#optionList 欄位(MDCConverter 本質就是 DynamicConverter),optionList 欄位包含程式要處理的占位符名稱,比如 traceId。

    程式啟動時,logback 進行 Convert 初始化,會呼叫 MDCConverter#start() 方法,將成員變量 private String key 的值設定為 traceId(MDCConverter#getFirstOption() 返回使用者配置的 traceId)。

    在 MDCConverter#convert() 方法中,將 traceId 占位符轉換為 MDC.get(key):String value = mdcPropertyMap.get(key)。


    public classMDCConverterextends classicConverter {
    privateString key;
    privateString defaultValue ="";
    @Override
    publicvoidstart() {
    String[] keyInfo =extractDefaultReplacement(getFirstOption());
    key = keyInfo[0];
    if (keyInfo[1] !=null) {
    defaultValue = keyInfo[1];
    }
    super.start();
    }
    @Override
    publicStringconvert(ILoggingEvent event) {
    Map<String, String> mdcPropertyMap = event.getMDCPropertyMap();
    if (mdcPropertyMap ==null) {
    return defaultValue;
    }
    if (key ==null) {
    returnoutputMDCForAllKeys(mdcPropertyMap);
    } else {
    String value = mdcPropertyMap.get(key);
    if (value !=null) {
    return value;
    } else {
    return defaultValue;
    }
    }
    }
    }




    ILoggingEvent 介面的實作類 LoggingEvent 中,對 MDCAdapter 做了適配:


    public classLoggingEventimplementsILoggingEvent {
    publicMap<String, String>getMDCPropertyMap() {
    // populate mdcPropertyMap if null
    if (mdcPropertyMap ==null) {
    MDCAdapter mdc =MDC.getMDCAdapter();
    if (mdc instanceofLogbackMDCAdapter)
    mdcPropertyMap = ((LogbackMDCAdapter) mdc).getPropertyMap();
    else
    mdcPropertyMap = mdc.getCopyOfContextMap();
    }
    // mdcPropertyMap still null, use emptyMap()
    if (mdcPropertyMap ==null)
    mdcPropertyMap = Collections.emptyMap();
    return mdcPropertyMap;
    }
    }

    如喜歡本文,請點選右上角,把文章分享到朋友圈
    如有想了解學習的技術點,請留言給若飛安排分享

    因公眾號更改推播規則,請點「在看」並加「星標」 第一時間獲取精彩技術分享

    ·END·

    相關閱讀:

    作者:OnebyWang

    來源:https://juejin.cn/post/7278498472860581925

    版權申明:內容來源網路,僅供學習研究,版權歸原創者所有。如有侵權煩請告知,我們會立即刪除並表示歉意。謝謝!

    架構師

    我們都是架構師!

    關註 架構師(JiaGouX),添加「星標」

    獲取每天技術幹貨,一起成為牛逼架構師

    技術群請 加若飛: 1321113940 進架構師群

    投稿、合作、版權等信箱: [email protected]