SOFATracer 是一个用于分布式系统调用跟踪的组件,通过统一的 traceId
将调用链路中的各种网络调用情况以日志的方式记录下来,以达到透视化网络调用的目的。这些日志可用于故障的快速发现,服务治理等。
从RoadMap 和 PR 来看,目前 SOFATracer 已经支持了丰富的组件插件埋点。
目前还未支持的主要是 Dubbo、MQ 以及 Redis 等。本文将从 SOFATracer 已提供的一个插件源码来分析下 SOFATracer 插件的埋点实现。
SOFATracer 插件的作用实际上就是对于不同组件进行埋点,以便于收集这些组件的链路数据。SOFATracer 埋点方式一般是通过 Filter、Interceptor 机制实现的。
另一个是,SOFATracer 的埋点方式并不是基于 OT-api 进行埋点的,而是基于 SOFATracer 自己的 api 进行埋点的,详见 issue#126 。
目前已实现的插件中,像 MVC 插件是基于 Filter 进行埋点的,httpclient、resttemplate 等是基于Interceptor进行埋点的。在实现插件时,要根据不同插件的特性来选择具体的埋点方式。
当然除了这两种方式之外还可以通过静态代理的方式来实现埋点。比如 sofa-tracer-datasource-plugin 插件就是将不同的数据源进行统一代理给 SmartDatasource,从而实现埋点的。
SOFATracer 中所有的插件均需要实现自己的 Tracer 实例,如 Mvc 的 SpringMvcTracer 、HttpClient的 HttpClientTracer 等,这一点与基于 Opentracing-api 接口埋点的实现有所区别。
AbstractTracer 是 SOFATracer 用于插件扩展使用的一个抽象类,根据插件类型不同,又可以分为 clientTracer 和 serverTracer,分别对应于:AbstractClientTracer 和 AbstractServerTracer,再通过 AbstractClientTracer 和 AbstractServerTracer 衍生出具体的组件 Tracer 实现。这种方式的好处在于,所有的插件实现均由 SOFATracer 本身来管控,对于不同的组件可以轻松的实现差异化和定制化。缺点也源于此,每增加一个组件都需要做一些重复工作。
这种埋点方式不基于 SOFATracer 自身提供的 API,而是基于 OpenTracing-api 接口。因为均遵循 OpenTracing-api 规范,所以组件和 Tracer 实现可以独立分开来维护。这样就可以对接开源的一些基于 OpenTracing-api 规范实现的组件。例如: OpenTracing API Contributions 。
SOFATracer 在后面将会在 4.0 版本中支持基于 OT-api 的埋点方式,对外部组件接入扩展提供支持。
这里先来看下 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 组件实现中,都必须提供以下实现:
这里我们以 SpringMVC 插件为例,来分析下如何实现一个埋点插件的。这里是官方给出的案例工程:基于 Spring MVC 示例落地日志 。
SpringMvcTracer 继承了 AbstractServerTracer 类,是对 serverTracer 的扩展。
PS:如何确定一个组件是client端还是server端呢?就是看当前组件是请求的发起方还是请求的接受方,如果是请求发起方则一般是client端,如果是请求接收方则是 server 端。那么对于 MVC 来说,是请求接受方,因此这里实现了 AbstractServerTracer 类。
public class SpringMvcTracer extends AbstractServerTracer 复制代码
在构造函数中,需要传入当前 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"); } 复制代码
在看 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 对应的所有抽象方法。
下面是 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等都是通过枚举类来定义的,也就是一个组件会对应这样一个枚举类,在枚举类里面定义这些常量。
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 指定的名称将链路日志输出到各个组件对应的日志目录下。
SOFATracer 中统计日志打印的实现需要各个组件自己来完成,具体就是需要实现一个AbstractSofaTracerStatisticReporter 的子类,然后实现 doReportStat 这个方法。当然对于目前的实现来说,我们也会重写 print 方法。
@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。
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 方法刷到磁盘。
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(); } } 复制代码
这个决定了摘要日志打印的格式,和在统计日志里面的实现要有所区分。
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":""} 复制代码
对于基于标准 servlet 实现的组件,要实现对请求的拦截过滤,通常就是 Filter 了。sofa-tracer-springmvc-plugin 插件埋点的实现就是基于 Filter 机制完成的。
SpringMvcSofaTracerFilter 实现了 javax.servlet.Filter 接口,因此遵循标准的 servlet 规范的容器也可以通过此插件进行埋点。参考文档: 对于标准 servlet 容器的支持( tomcat/jetty 等) 。
public class SpringMvcSofaTracerFilter implements Filter 复制代码
对于一个组件来说,一次处理过程一般是产生一个 span。这个span的生命周期是从接收到请求到返回响应这段过程。
但是这里需要考虑的问题是如何与上下游链路关联起来呢?在 Opentracing 规范中,可以在 Tracer 中 extract 出一个跨进程传递的 SpanContext 。然后通过这个 SpanContext 所携带的信息将当前节点关联到整个 tracer 链路中去。当然有提取(extract)就会有对应的注入(inject)。
链路的构建一般是 client-server-client-server 这种模式的,那这里就很清楚了,就是会在 client 端进行注入(inject),然后再 server 端进行提取(extract),反复进行,然后一直传递下去。
在拿到 SpanContext 之后,此时当前的 span 就可以关联到这条链路中了,那么剩余的事情就是收集当前组件的一些数据。
整个过程大概分为以下几个阶段:
下面逐一分析下这几个过程。
这里的提取用到了上面我们提到的#数据传播格式实现#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; } 复制代码
serverReceive 这个方法是在 AbstractTracer 类中提供了实现,子类不需要关注这个。在 SOFATracer 中将请求大致分为以下几个过程:
无论是哪个插件,在请求处理周期内都可以从上述几个阶段中找到对应的处理方法。因此,SOFATracer 对这几个阶段处理进行了封装。这四个阶段实际上会产生两个 span,第一个 span 的起点是 cs,到 cr 结束;第二个 span是从 sr 开始,到 ss 结束。也就是说当执行 clientSend 和 serverReceive 时会返回一个 span 对象。来看下MVC中的实现:
红色框内对应的服务端接受请求,也就是 sr 阶段,产生了一个 span 。红色框下面的这段代码是为当前这个 span 设置一些基本的信息,包括当前应用的应用名、当前请求的url、当前请求的请求方法以及请求大小。
在 filter 链执行结束之后,在 finally 块中又补充了当前请求响应结果的一些信息到 span 中去。然后调用serverSend 结束当前 span。这里关于 serverSend 里面的逻辑就不展开说了,不过能够想到的是这里肯定是调用span.finish 这个方法( opentracing 规范中,span.finish 的执行标志着一个 span 的结束),当前也会包括对于数据上报的一些逻辑处理等。
在第2节中以 SpringMVC 插件为例,分析了下 SOFATracer 插件埋点实现的一些细节。那么本节则从整体思路上来总结下如何编写一个 SOFATracer 的插件。
当然最重要的还是对于要实现插件的理解,要明确我们需要收集哪些数据。
本文先介绍了SOFATracer的埋点方式与标准OT-api 埋点方式的区别,然后对 SOFATracer 中 SpringMVC 插件的埋点实现进行了分析。希望通过本文能够让更多的同学理解埋点实现这样一个过程以及需要关注的一些点。如果有兴趣或者有什么实际的需求,欢迎来讨论。