SOFATracer 插件埋点机制详解

综合编程 2018-12-07 阅读原文

SOFATracer 是一个用于分布式系统调用跟踪的组件,通过统一的 traceId 将调用链路中的各种网络调用情况以日志的方式记录下来,以达到透视化网络调用的目的。这些日志可用于故障的快速发现,服务治理等。

从RoadMap 和 PR 来看,目前 SOFATracer 已经支持了丰富的组件插件埋点。

目前还未支持的主要是 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 埋点方式插件扩展

AbstractTracer 是 SOFATracer 用于插件扩展使用的一个抽象类,根据插件类型不同,又可以分为 clientTracer 和 serverTracer,分别对应于:AbstractClientTracer 和 AbstractServerTracer,再通过 AbstractClientTracer 和 AbstractServerTracer 衍生出具体的组件 Tracer 实现。这种方式的好处在于,所有的插件实现均由 SOFATracer 本身来管控,对于不同的组件可以轻松的实现差异化和定制化。缺点也源于此,每增加一个组件都需要做一些重复工作。

  • 2、基于 OpenTracing-api 埋点方式插件扩展

这种埋点方式不基于 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中的实现:

红色框内对应的服务端接受请求,也就是 sr 阶段,产生了一个 span 。红色框下面的这段代码是为当前这个 span 设置一些基本的信息,包括当前应用的应用名、当前请求的url、当前请求的请求方法以及请求大小。

2.6.4 返回响应与结束 span

在 filter 链执行结束之后,在 finally 块中又补充了当前请求响应结果的一些信息到 span 中去。然后调用serverSend 结束当前 span。这里关于 serverSend 里面的逻辑就不展开说了,不过能够想到的是这里肯定是调用span.finish 这个方法( opentracing 规范中,span.finish 的执行标志着一个 span 的结束),当前也会包括对于数据上报的一些逻辑处理等。

3 思路总结与插件编写流程

在第2节中以 SpringMVC 插件为例,分析了下 SOFATracer 插件埋点实现的一些细节。那么本节则从整体思路上来总结下如何编写一个 SOFATracer 的插件。

  • 1、确定所要实现的插件,然后确定以哪种方式来埋点
  • 2、实现当前插件的 Tracer 实例,这里需要明确当前插件是以 client 存在还是以 server 存在。
  • 3、实现一个枚举类,用来描述当前组件的日志名称和滚动策略 key 值等
  • 4、实现插件摘要日志的 encoder ,实现当前组件的定制化输出
  • 5、实现插件的统计日志 Reporter 实现类,通过继承 AbstractSofaTracerStatisticReporter 类并重写doReportStat。
  • 6、定义当前插件的传播格式

当然最重要的还是对于要实现插件的理解,要明确我们需要收集哪些数据。

小结

本文先介绍了SOFATracer的埋点方式与标准OT-api 埋点方式的区别,然后对 SOFATracer 中 SpringMVC 插件的埋点实现进行了分析。希望通过本文能够让更多的同学理解埋点实现这样一个过程以及需要关注的一些点。如果有兴趣或者有什么实际的需求,欢迎来讨论。

稀土掘金

责编内容by:稀土掘金阅读原文】。感谢您的支持!

您可能感兴趣的

Spring MVC Hibernate java.lang&peri... I am trying to create a Spring MVC program with Hibernate. Here is the code. welcome-servlet.xml ...
Read HTTP Request Header in Spring MVC In this Spring MVC tutorial, you will learn how to read HTTP Request Header in the Rest Controller class of your Applica...
Spring MVC with Thymeleaf and Tomcat 8 UTF-8 encod... I have some problem about encoding. According to this referance I have to do all of this and I did. But my encoding pro...
Spring MVC 的 Java Config ( 非 XML ) 配置方式... 索引: 开源Spring解决方案--lm.solution 参看代码 GitHub: solution/pom.xml web/pom.xml web.xm...
(Spring MVC / Jackson) M... @GetMapping("item") public @ResponseBody String get(@ModelAttribute Item item) Item has the attributes ...