SOFATracer 外掛埋點機制詳解

glmapper發表於2018-12-07

SOFATracer 是一個用於分散式系統呼叫跟蹤的元件,通過統一的 traceId 將呼叫鏈路中的各種網路呼叫情況以日誌的方式記錄下來,以達到透視化網路呼叫的目的。這些日誌可用於故障的快速發現,服務治理等。

RoadMapPR 來看,目前 SOFATracer 已經支援了豐富的元件外掛埋點。

img

目前還未支援的主要是 Dubbo、MQ 以及 Redis 等。本文將從 SOFATracer 已提供的一個外掛原始碼來分析下 SOFATracer 外掛的埋點實現。

1 SOFATracer 外掛埋點機制

SOFATracer 外掛的作用實際上就是對於不同元件進行埋點,以便於收集這些元件的鏈路資料。SOFATracer 埋點方式一般是通過 Filter、Interceptor 機制實現的。

另一個是,SOFATracer 的埋點方式並不是基於 OT-api 進行埋點的,而是基於 SOFATracer 自己的 api 進行埋點的,詳見 issue#126

1.1 Filter or Interceptor

目前已實現的外掛中,像 MVC 外掛是基於 Filter 進行埋點的,httpclient、resttemplate 等是基於Interceptor進行埋點的。在實現外掛時,要根據不同外掛的特性來選擇具體的埋點方式。

當然除了這兩種方式之外還可以通過靜態代理的方式來實現埋點。比如 sofa-tracer-datasource-plugin 外掛就是將不同的資料來源進行統一代理給 SmartDatasource,從而實現埋點的。

1.2 AbstractTracer API

SOFATracer 中所有的外掛均需要實現自己的 Tracer 例項,如 Mvc 的 SpringMvcTracer 、HttpClient的 HttpClientTracer 等,這一點與基於 Opentracing-api 介面埋點的實現有所區別。

  • 1、基於 SOFATracer api 埋點方式外掛擴充套件

img

AbstractTracer 是 SOFATracer 用於外掛擴充套件使用的一個抽象類,根據外掛型別不同,又可以分為 clientTracer 和 serverTracer,分別對應於:AbstractClientTracer 和 AbstractServerTracer,再通過 AbstractClientTracer 和 AbstractServerTracer 衍生出具體的元件 Tracer 實現。這種方式的好處在於,所有的外掛實現均由 SOFATracer 本身來管控,對於不同的元件可以輕鬆的實現差異化和定製化。缺點也源於此,每增加一個元件都需要做一些重複工作。

  • 2、基於 OpenTracing-api 埋點方式外掛擴充套件

img

這種埋點方式不基於 SOFATracer 自身提供的 API,而是基於 OpenTracing-api 介面。因為均遵循 OpenTracing-api 規範,所以元件和 Tracer 實現可以獨立分開來維護。這樣就可以對接開源的一些基於 OpenTracing-api 規範實現的元件。例如:OpenTracing API Contributions

SOFATracer 在後面將會在 4.0 版本中支援基於 OT-api 的埋點方式,對外部元件接入擴充套件提供支援。

1.3 AbstractTracer

這裡先來看下 AbstractTracer 這個抽象類中具體提供了哪些抽象方法,也就是對於 AbstractClientTracer 和 AbstractServerTracer 需要分別擴充套件哪些能力。

// 獲取client端 摘要日誌日誌名
protected abstract String getClientDigestReporterLogName();
// 獲取client端 摘要日誌滾動策略key
protected abstract String getClientDigestReporterRollingKey();
// 獲取client端 摘要日誌日誌名key
protected abstract String getClientDigestReporterLogNameKey();
// 獲取client端 摘要日誌編碼器
protected abstract SpanEncoder<SofaTracerSpan> getClientDigestEncoder();
// 建立client端 統計日誌Reporter類
protected abstract AbstractSofaTracerStatisticReporter generateClientStatReporter();
// 獲取server端 摘要日誌日誌名
protected abstract String getServerDigestReporterLogName();
// 獲取server端 摘要日誌滾動策略key
protected abstract String getServerDigestReporterRollingKey();
// 獲取server端 摘要日誌日誌名key
protected abstract String getServerDigestReporterLogNameKey();
// 獲取server端 摘要日誌編碼器
protected abstract SpanEncoder<SofaTracerSpan> getServerDigestEncoder();
// 建立server端 統計日誌Reporter類
protected abstract AbstractSofaTracerStatisticReporter generateServerStatReporter();

複製程式碼

從 AbstractTracer 類提供的抽象方法來看,不管是 client 還是 server,在具體的 Tracer 元件實現中,都必須提供以下實現:

  • DigestReporterLogName :當前元件摘要日誌的日誌名稱
  • DigestReporterRollingKey : 當前元件摘要日誌的滾動策略
  • SpanEncoder:對摘要日誌進行編碼的編碼器實現
  • AbstractSofaTracerStatisticReporter : 統計日誌 reporter 類的實現類。

2 SpringMVC 外掛埋點分析

這裡我們以 SpringMVC 外掛為例,來分析下如何實現一個埋點外掛的。這裡是官方給出的案例工程:基於 Spring MVC 示例落地日誌

2.1 實現 Tracer 例項

SpringMvcTracer 繼承了 AbstractServerTracer 類,是對 serverTracer 的擴充套件。

PS:如何確定一個元件是client端還是server端呢?就是看當前元件是請求的發起方還是請求的接受方,如果是請求發起方則一般是client端,如果是請求接收方則是 server 端。那麼對於 MVC 來說,是請求接受方,因此這裡實現了 AbstractServerTracer 類。

public class SpringMvcTracer extends AbstractServerTracer
複製程式碼

2.1.1 建構函式與單例物件

在建構函式中,需要傳入當前 Tracer 的 traceType,SpringMvcTracer 的 traceType 為 "springmvc"。這裡也可以看到,tracer 例項是一個單例物件,對於其他外掛也是一樣的。

private volatile static SpringMvcTracer springMvcTracer = null;
/***
 * Spring MVC Tracer Singleton
 * @return singleton
 */
public static SpringMvcTracer getSpringMvcTracerSingleton() {
    if (springMvcTracer == null) {
        synchronized (SpringMvcTracer.class) {
            if (springMvcTracer == null) {
                springMvcTracer = new SpringMvcTracer();
            }
        }
    }
    return springMvcTracer;
}
private SpringMvcTracer() {
    super("springmvc");
}

複製程式碼

2.1.2 AbstractServerTracer 抽象類

在看 SpringMvcTracer 實現之前,先來看下 AbstractServerTracer。

public abstract class AbstractServerTracer extends AbstractTracer {
    // 建構函式,子類必須提供一個建構函式
    public AbstractServerTracer(String tracerType) {
        super(tracerType, false, true);
    }
    // 因為是server端,所以Client先關的提供了預設實現,返回null
    protected String getClientDigestReporterLogName() {
        return null;
    }
    protected String getClientDigestReporterRollingKey() {
        return null;
    }
    protected String getClientDigestReporterLogNameKey() {
        return null;
    }
    protected SpanEncoder<SofaTracerSpan> getClientDigestEncoder() {
        return null;
    }
    protected AbstractSofaTracerStatisticReporter generateClientStatReporter() {
        return null;
    }
}

複製程式碼

結合上面 AbstractTracer 小節中抽象方法分析,這裡在 AbstractServerTracer 中將 client 對應的抽象方法提供了預設實現,也就是說如果要繼承 AbstractServerTracer 類,那麼就必須實現 server 對應的所有抽象方法。

2.1.3 SpringMVCTracer 實現

下面是 SpringMvcTracer 部分對 server 部分抽象方法的實現。

@Override
protected String getServerDigestReporterLogName() {
    return SpringMvcLogEnum.SPRING_MVC_DIGEST.getDefaultLogName();
}

@Override
protected String getServerDigestReporterRollingKey() {
    return SpringMvcLogEnum.SPRING_MVC_DIGEST.getRollingKey();
}

@Override
protected String getServerDigestReporterLogNameKey() {
    return SpringMvcLogEnum.SPRING_MVC_DIGEST.getLogNameKey();
}

@Override
protected SpanEncoder<SofaTracerSpan> getServerDigestEncoder() {
    if (Boolean.TRUE.toString().equalsIgnoreCase(
        SofaTracerConfiguration.getProperty(SPRING_MVC_JSON_FORMAT_OUTPUT))) {
        return new SpringMvcDigestJsonEncoder();
    } else {
        return new SpringMvcDigestEncoder();
    }
}

@Override
protected AbstractSofaTracerStatisticReporter generateServerStatReporter() {
    return generateSofaMvcStatReporter();
}
複製程式碼

目前 SOFATracer 日誌名、滾動策略key等都是通過列舉類來定義的,也就是一個元件會對應這樣一個列舉類,在列舉類裡面定義這些常量。

2.2 SpringMvcLogEnum 類實現

SpringMVC 外掛中的列舉類是 SpringMvcLogEnum。

public enum SpringMvcLogEnum {

    // 摘要日誌相關
    SPRING_MVC_DIGEST("spring_mvc_digest_log_name", 
                      "spring-mvc-digest.log",
                      "spring_mvc_digest_rolling"), 
    // 統計日誌相關
    SPRING_MVC_STAT("spring_mvc_stat_log_name", 
                    "spring-mvc-stat.log", 
                    "spring_mvc_stat_rolling");
    // 省略部分程式碼....
}

複製程式碼

在 XXXLogEnum 列舉類中定義了當前元件對應的摘要日誌和統計日誌的日誌名和滾動策略,因為 SOFATracer 目前還沒有服務端的能力,鏈路資料不是直接上報給 server 的,因此 SOFATracer 提供了落到磁碟的能力。不同外掛的鏈路日誌也會通過 XXXLogEnum 指定的名稱將鏈路日誌輸出到各個元件對應的日誌目錄下。

2.3 統計日誌 Reportor 實現

SOFATracer 中統計日誌列印的實現需要各個元件自己來完成,具體就是需要實現一個AbstractSofaTracerStatisticReporter 的子類,然後實現 doReportStat 這個方法。當然對於目前的實現來說,我們也會重寫 print 方法。

2.3.1 doReportStat

@Override
public void doReportStat(SofaTracerSpan sofaTracerSpan) {
    Map<String, String> tagsWithStr = sofaTracerSpan.getTagsWithStr();
    // 構建StatMapKey物件
    StatMapKey statKey = new StatMapKey();
    // 增加 key:當前應用名
    statKey.addKey(CommonSpanTags.LOCAL_APP, tagsWithStr.get(CommonSpanTags.LOCAL_APP));
    // 增加 key:請求 url
    statKey.addKey(CommonSpanTags.REQUEST_URL, tagsWithStr.get(CommonSpanTags.REQUEST_URL));
    // 增加 key:請求方法
    statKey.addKey(CommonSpanTags.METHOD, tagsWithStr.get(CommonSpanTags.METHOD));
    // 壓測標誌
    statKey.setLoadTest(TracerUtils.isLoadTest(sofaTracerSpan));
    // 請求響應碼
    String resultCode = tagsWithStr.get(CommonSpanTags.RESULT_CODE);
    // 請求成功標識
    boolean success = (resultCode != null && resultCode.length() > 0 && this
        .isHttpOrMvcSuccess(resultCode));
    statKey.setResult(success ? "true" : "false");
    //end
    statKey.setEnd(TracerUtils.getLoadTestMark(sofaTracerSpan));
    //value the count and duration
    long duration = sofaTracerSpan.getEndTime() - sofaTracerSpan.getStartTime();
    long values[] = new long[] { 1, duration };
    // reserve
    this.addStat(statKey, values);
}
複製程式碼

這裡就是就是將統計日誌新增到日誌槽裡,等待被消費(輸出到日誌)。具體可以參考:SofaTracerStatisticReporterManager.StatReporterPrinter。

2.3.2 print

print 方法是實際將資料寫入到磁碟的方法。

@Override
public void print(StatKey statKey, long[] values) {
    if (this.isClosePrint.get()) {
        //關閉統計日誌輸出
        return;
    }
    if (!(statKey instanceof StatMapKey)) {
        return;
    }
    StatMapKey statMapKey = (StatMapKey) statKey;
    try {
        // 構建需要列印的資料串
        jsonBuffer.reset();
        jsonBuffer.appendBegin();
        jsonBuffer.append("time", Timestamp.currentTime());
        jsonBuffer.append("stat.key", this.statKeySplit(statMapKey));
        jsonBuffer.append("count", values[0]);
        jsonBuffer.append("total.cost.milliseconds", values[1]);
        jsonBuffer.append("success", statMapKey.getResult());
        //壓測
        jsonBuffer.appendEnd("load.test", statMapKey.getEnd());

        if (appender instanceof LoadTestAwareAppender) {
            ((LoadTestAwareAppender) appender).append(jsonBuffer.toString(),
                statMapKey.isLoadTest());
        } else {
            appender.append(jsonBuffer.toString());
        }
        // 這裡強制刷一次
        appender.flush();
    } catch (Throwable t) {
        SelfLog.error("統計日誌<" + statTracerName + ">輸出異常", t);
    }
}

複製程式碼

print 這個方法裡面就是將 statMapKey 中,也就是 doReportStat 中塞進來的資料轉換成 json 格式,然後刷到磁碟。需要注意的是這裡是強制 flush 了一次。如果沒有重寫 print 這個方法的話,則是在SofaTracerStatisticReporterManager.StatReporterPrinter 裡面呼叫 print 方法刷到磁碟。

2.4 資料傳播格式實現

SOFATracer 支援使用 OpenTracing 的內建格式進行上下文傳播。

public class SpringMvcHeadersCarrier implements TextMap {
    private HashMap<String, String> headers;

    public SpringMvcHeadersCarrier(HashMap<String, String> headers) {
        this.headers = headers;
    }

    @Override
    public void put(String key, String value) {
        headers.put(key, value);
    }

    @Override
    public Iterator<Map.Entry<String, String>> iterator() {
        return headers.entrySet().iterator();
    }
}
複製程式碼

2.5 自定義編碼格式實現

這個決定了摘要日誌列印的格式,和在統計日誌裡面的實現要有所區分。

public class SpringMvcDigestJsonEncoder extends AbstractDigestSpanEncoder {
    // 重寫encode,對span進行編碼處理
    @Override
    public String encode(SofaTracerSpan span) throws IOException {
        JsonStringBuilder jsonStringBuilder = new JsonStringBuilder();
        //日誌列印時間
        jsonStringBuilder.appendBegin("time", Timestamp.format(span.getEndTime()));
        appendSlot(jsonStringBuilder, span);
        return jsonStringBuilder.toString();
    }
    // 具體欄位處理
    private void appendSlot(JsonStringBuilder jsonStringBuilder, SofaTracerSpan sofaTracerSpan) {
    
        SofaTracerSpanContext context = sofaTracerSpan.getSofaTracerSpanContext();
        Map<String, String> tagWithStr = sofaTracerSpan.getTagsWithStr();
        Map<String, Number> tagWithNumber = sofaTracerSpan.getTagsWithNumber();
        //當前應用名
        jsonStringBuilder
            .append(CommonSpanTags.LOCAL_APP, tagWithStr.get(CommonSpanTags.LOCAL_APP));
        //TraceId
        jsonStringBuilder.append("traceId", context.getTraceId());
        //RpcId
        jsonStringBuilder.append("spanId", context.getSpanId());
        //請求 URL
        jsonStringBuilder.append(CommonSpanTags.REQUEST_URL,
            tagWithStr.get(CommonSpanTags.REQUEST_URL));
        //請求方法
        jsonStringBuilder.append(CommonSpanTags.METHOD, tagWithStr.get(CommonSpanTags.METHOD));
        //Http 狀態碼
        jsonStringBuilder.append(CommonSpanTags.RESULT_CODE,
            tagWithStr.get(CommonSpanTags.RESULT_CODE));
        Number requestSize = tagWithNumber.get(CommonSpanTags.REQ_SIZE);
        //Request Body 大小 單位為byte
        jsonStringBuilder.append(CommonSpanTags.REQ_SIZE,
            (requestSize == null ? 0L : requestSize.longValue()));
        Number responseSize = tagWithNumber.get(CommonSpanTags.RESP_SIZE);
        //Response Body 大小,單位為byte
        jsonStringBuilder.append(CommonSpanTags.RESP_SIZE, (responseSize == null ? 0L
            : responseSize.longValue()));
        //請求耗時(MS)
        jsonStringBuilder.append("time.cost.milliseconds",
            (sofaTracerSpan.getEndTime() - sofaTracerSpan.getStartTime()));
        jsonStringBuilder.append(CommonSpanTags.CURRENT_THREAD_NAME,
            tagWithStr.get(CommonSpanTags.CURRENT_THREAD_NAME));
        //穿透資料放在最後
        jsonStringBuilder.appendEnd("baggage", baggageSerialized(context));
    }
}
複製程式碼

從這裡其實也可以看出,統計日誌和摘要日誌的不同點。統計日誌裡面核心的資料是 span 裡面的 tags 資料,但是其主要作用是統計當前元件的次數。摘要日誌裡面除了 tags 裡面的資料之外還會包括例如 traceId 和 spanId 等資訊。

  • 統計日誌
{"time":"2018-11-28 14:42:25.127","stat.key":{"method":"GET","local.app":"SOFATracerSpringMVC","request.url":"http://localhost:8080/springmvc"},"count":3,"total.cost.milliseconds":86,"success":"true","load.test":"F"}
複製程式碼
  • 摘要日誌
{"time":"2018-11-28 14:46:08.216","local.app":"SOFATracerSpringMVC","traceId":"0a0fe91b1543387568214100259231","spanId":"0.1","request.url":"http://localhost:8080/springmvc","method":"GET","result.code":"200","req.size.bytes":-1,"resp.size.bytes":0,"time.cost.milliseconds":2,"current.thread.name":"http-nio-8080-exec-2","baggage":""}
複製程式碼

2.6 請求攔截埋點

對於基於標準 servlet 實現的元件,要實現對請求的攔截過濾,通常就是 Filter 了。sofa-tracer-springmvc-plugin 外掛埋點的實現就是基於 Filter 機制完成的。

SpringMvcSofaTracerFilter 實現了 javax.servlet.Filter 介面,因此遵循標準的 servlet 規範的容器也可以通過此外掛進行埋點。參考文件:對於標準 servlet 容器的支援( tomcat/jetty 等)

public class SpringMvcSofaTracerFilter implements Filter
複製程式碼

2.6.1 基本埋點思路

對於一個元件來說,一次處理過程一般是產生一個 span。這個span的生命週期是從接收到請求到返回響應這段過程。

但是這裡需要考慮的問題是如何與上下游鏈路關聯起來呢?在 Opentracing 規範中,可以在 Tracer 中 extract 出一個跨程式傳遞的 SpanContext 。然後通過這個 SpanContext 所攜帶的資訊將當前節點關聯到整個 tracer 鏈路中去。當然有提取(extract)就會有對應的注入(inject)。

鏈路的構建一般是 client-server-client-server 這種模式的,那這裡就很清楚了,就是會在 client 端進行注入(inject),然後再 server 端進行提取(extract),反覆進行,然後一直傳遞下去。

在拿到 SpanContext 之後,此時當前的 span 就可以關聯到這條鏈路中了,那麼剩餘的事情就是收集當前元件的一些資料。

整個過程大概分為以下幾個階段:

  • 從請求中提取 spanContext
  • 構建 span,並將當前 span 存入當前 tracer上下文中(SofaTraceContext.push(span)) 。
  • 設定一些資訊到span中
  • 返回響應
  • span結束&上報

下面逐一分析下這幾個過程。

2.6.2 從請求中提取 spanContext

這裡的提取用到了上面我們提到的#資料傳播格式實現#SpringMvcHeadersCarrier 這個類。上面分析到,因為mvc 做作為 server 端存在的,所以在 server 端就是從請求中 extract 出 SpanContext。

public SofaTracerSpanContext getSpanContextFromRequest(HttpServletRequest request) {
    HashMap<String, String> headers = new HashMap<String, String>();
    // 獲取請求頭資訊 
    Enumeration headerNames = request.getHeaderNames();
    while (headerNames.hasMoreElements()) {
        String key = (String) headerNames.nextElement();
        String value = request.getHeader(key);
        headers.put(key, value);
    }
    // 拿到 SofaTracer 例項物件
    SofaTracer tracer = springMvcTracer.getSofaTracer();
    // 解析出 SofaTracerSpanContext(SpanContext的實現類)
    SofaTracerSpanContext spanContext = (SofaTracerSpanContext) tracer.extract(
        ExtendFormat.Builtin.B3_HTTP_HEADERS, new SpringMvcHeadersCarrier(headers));
    spanContext.setSpanId(spanContext.nextChildContextId());
    return spanContext;
}
複製程式碼

2.6.3 獲取 span & 資料獲取

serverReceive 這個方法是在 AbstractTracer 類中提供了實現,子類不需要關注這個。在 SOFATracer 中將請求大致分為以下幾個過程:

  • 客戶端傳送請求 clientSend cs
  • 服務端接受請求 serverReceive sr
  • 服務端返回結果 serverSend ss
  • 客戶端接受結果 clientReceive cr

無論是哪個外掛,在請求處理週期內都可以從上述幾個階段中找到對應的處理方法。因此,SOFATracer 對這幾個階段處理進行了封裝。這四個階段實際上會產生兩個 span,第一個 span 的起點是 cs,到 cr 結束;第二個 span是從 sr 開始,到 ss 結束。也就是說當執行 clientSend 和 serverReceive 時會返回一個 span 物件。來看下MVC中的實現:

img

紅色框內對應的服務端接受請求,也就是 sr 階段,產生了一個 span 。紅色框下面的這段程式碼是為當前這個 span 設定一些基本的資訊,包括當前應用的應用名、當前請求的url、當前請求的請求方法以及請求大小。

2.6.4 返回響應與結束 span

在 filter 鏈執行結束之後,在 finally 塊中又補充了當前請求響應結果的一些資訊到 span 中去。然後呼叫serverSend 結束當前 span。這裡關於 serverSend 裡面的邏輯就不展開說了,不過能夠想到的是這裡肯定是呼叫span.finish 這個方法( opentracing 規範中,span.finish 的執行標誌著一個 span 的結束),當前也會包括對於資料上報的一些邏輯處理等。

img

3 思路總結與外掛編寫流程

在第2節中以 SpringMVC 外掛為例,分析了下 SOFATracer 外掛埋點實現的一些細節。那麼本節則從整體思路上來總結下如何編寫一個 SOFATracer 的外掛。

  • 1、確定所要實現的外掛,然後確定以哪種方式來埋點
  • 2、實現當前外掛的 Tracer 例項,這裡需要明確當前外掛是以 client 存在還是以 server 存在。
  • 3、實現一個列舉類,用來描述當前元件的日誌名稱和滾動策略 key 值等
  • 4、實現外掛摘要日誌的 encoder ,實現當前元件的定製化輸出
  • 5、實現外掛的統計日誌 Reporter 實現類,通過繼承 AbstractSofaTracerStatisticReporter 類並重寫doReportStat。
  • 6、定義當前外掛的傳播格式

當然最重要的還是對於要實現外掛的理解,要明確我們需要收集哪些資料。

小結

本文先介紹了SOFATracer的埋點方式與標準OT-api 埋點方式的區別,然後對 SOFATracer 中 SpringMVC 外掛的埋點實現進行了分析。希望通過本文能夠讓更多的同學理解埋點實現這樣一個過程以及需要關注的一些點。如果有興趣或者有什麼實際的需求,歡迎來討論。

相關文章