第15讲:Tomcat 插件原理精析,看 SkyWalking 如何增强这只 Cat(上)

通过前面几课时的学习,我们已经了解了 SkyWalking Agent 中最底层的 apm-agent-core 模块的核心实现,相信同学们已经了解下面几个知识点:

  • SkyWalking Agent 的整体架构、启动流程。
  • 插件埋点的基本原理,其中深入讲解了对静态方法、构造方法以及实例方法的拦截和增强,并结合 mysql-8.x 插件进行了串讲。
  • Trace 基本概念在 SkyWalking 中的落地,其中讲解了 Trace ID、Span、TraceSegment、TracingContext 等核心组件的实现,并结合 demo-webapp 进行了分析。
  • 核心 BootService 实现的深入剖析,其中包括了网络连接的封装和管理、服务以及服务实例的注册流程、定期心跳、EndpointName、NetworkAddress 定期同步、Context 生成与管理、客户端采样的功能、Trace 的收集与发送。
  • DataCarrier 核心原理的深入剖析。

其中最重要的是,理解 SkyWalking Agent 中,Trace 的相关组件是如何系统工作的,数据流向是什么样儿的。在接下来的几课时中,我们将从 apm-sdk-plugin 模块中选取几个比较有代表性的插件进行剖析,使你能够了解这些 SkyWalking Agent 插件是如何与 apm-agent-core 模块配合工作的。

本课时重点要介绍的是 tomcat-7.x-8.x-plugin 插件,如果你想要看懂 Tomcat 插件的原理,需要对 Tomcat 本身的结构有一些了解。

Tomcat 架构基础

Tomcat 的核心架构如下图所示,最顶层的 Server 代表整个 Tomcat 服务器,它可以包含多个 Service:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_tomcat

每个 Service 都会包含两个部分:Connector 和 Container,其中 Connector 用于处理连接相关的事情,并提供 Socket 与 Request 和 Response 相关的转化。Container 用于封装和管理Servlet,以及具体处理 Request 请求的业务。

一个 Service 可以有多个 Connector 连接器,这主要是因为一个服务可以支持多种网络协议,如下图所示的 HTTP、HTTPS 等,当然也可以在不同端口支持相同的协议。我们在工作中写 Spring MVC Controller 时用到的 HttpRequest 和 HttpResponse 对象就是由 Connector 创建的,这些 HTTP 请求的后续处理,则是由 Container 来负责的。

SkyWalking 提供的 tomcat-7.x-8.x-plugin 插件与 Tomcat Connector 组件没有任何关系,这里不再深入剖析 Connector 的原理,只要知道其功能是处理 Socket 网络连接与 Reques 和 Response 之间的转换即可。

Container 是容器的父接口,所有子容器都必须实现这个接口。Tomcat 中有四个子容器组件,分别是:Engine、Host、Context、Wrapper,这四个组件之间不是平行关系,而是父子关系。Engine 包含 Host,Host 包含 Context,Context 包含 Wrapper。下面是四个 Container 的核心功能。

  • Engine:用于管理多个站点,一个 Service 最多只能有一个 Engine。
  • Host:代表一个站点,也可以叫虚拟主机,通过在 server.xml 配置文件就可以添加 Host,一个 Host 下可以运行多个 Context,但是在实践中,单 JVM 的处理能力有限,一般一个 Tomcat 实例只会配置一个 Host,也只会配置一个 Context。
  • Context:代表一个应用程序,对应你在日常开发的一个 Web 应用。Context 最重要的功能就是管理它里面的 Servlet 实例,并为 Request 匹配正确的 Servlet。Servlet 实例在 Context 中是以 Wrapper 出现的。
  • Wrapper:一个 Wrapper 负责管理一个 Servlet,包括 Servlet 的装载、初始化、执行以及资源回收。Wrapper 是最底层的容器,它没有子容器了。

下面这张图大致展示了从 Connector 开始接收请求,经过 Engine、Host、Context、Wrapper,最终到 Servlet 的流程,这里需要关注的是拿到 Request 请求对象之后的处理:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务_02

Container 中真正处理请求的是 Valve,一组 Valve 组成一个 Pipeline,这是典型的责任链模式。责任链模式是指在一个请求处理的过程中会有很多处理器依次对请求进行处理,每个处理器只负责处理自己相应的部分,当对应的部分被处理完成之后,会将请求交给下一个处理器继续处理,直至请求完全处理完成。

以现实生活中汽车组装为例,整个责任链就像是汽车的生产线,责任链上的每个处理器则对应每个组装车间,每个组装车间只组装汽车的一部分,如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务_03

在每个 Container 的 Pipeline 中,我们可以增加任意多个 Valve,处理请求的 Tomcat 线程会依次执行这些 Valve,并最终完成请求的处理。在上图中我们可以看到,每个 Pipeline 都有一个特定的 Valve(即图中的 StandEngineValve、StandHostValve、StandContextValve、StandWrapperValve),而且这些 Valve 是在 Pipeline 中最后一个执行,这种 Valve 叫作BaseValve。我们可以在 Tomcat 的 server.xml 文件中自定义 Pipeline 中的 Valve,但上述四个 BaseValve 是不可删除的。这些 BaseValve 会负责调用子容器的 Pipeline,将请求传给子容器,以保证处理逻辑能继续向下执行。Valve 接口与四个标准 Valve 实现的继承关系如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务模块不能通过线程获取id_04

tomcat-7.x-8.x-plugin 插件

Tomcat 一般作为服务入口接收 HTTP 或 HTTPS 请求,tomcat-7.x-8.x-plugin 插件要做的事情也比较明确:

  1. 在请求进入 Web 项目之前进行拦截。
  2. 检测当前请求是否处于一个 Trace 之中,也就是检测当前请求是否携带了 ContextCarrier。如果携带了 ContextCarrier,则在创建 TracingContext 时恢复上下文信息,保持实现 Trace 跨进程传递;如果没携带 ContextCarrier,则会开启一个全新的 TracingContext。
  3. 创建(或 restart) EntrySpan。
  4. 记录一些额外的信息,例如,请求相关的 Tags 信息(请求的 URL、Method 信息等),记录当前组件的类型(即 Tomcat)等。

通过对 Tomcat 结构的分析,以及对 tomcat-7.x-8.x-plugin 插件的功能定位分析,相信你已经发现,tomcat-7.x-8.x-plugin 插件在 StandardHostValve 处拦截请求是合适的。Valve 接口中定义的 invoke(Request request, Response response) 方法是每个 Valve 的核心逻辑,例如,根据请求信息进行过滤、修改请求的特殊字段、打印 access log 等,正如前文介绍的,那些特殊功能的 Valve 实现是可插拔的,而标准 Valve 实现不可删除,这里 StandardHostValve 实现的 invoke() 方法只负责选择合适的 Context 继续处理请求,下面是其核心实现:

public final void invoke(Request request, Response response){
    // 根据请求选择Context
    Context context = request.getContext(); 
    // 获取Context中第一个Valve,并调用其invoke()方法
    context.getPipeline().getFirst().invoke(request, response);
    Throwable t = (Throwable) request
         .getAttribute(RequestDispatcher.ERROR_EXCEPTION);
    if (response.isErrorReportRequired()) {
       if (t != null) { // 出现异常的话,会调用throwable()方法处理
           throwable(request, response, t);
        }
    }
}

因此,tomcat-7.x-8.x-plugin 插件拦截 StandardHostValve 的 invoke() 方法即可满足之前的需求。

tomcat-7.x-8.x-plugin 插件在 SkyWalking 项目中的位置如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_tomcat_05

在前文介绍 SkyWalking Agent 启动流程时提到,SkyWalking Agent 启动时会扫描 agent 目录下的全部插件 jar 包,并根据每个插件 jar 包中的 skywalking-plugin.def 配置文件加载指定的 AbstractClassEnhancePluginDefine 实现。tomcat-7.x-8.x-plugin 插件的 skywalking-plugin.def 配置文件如下:

tomcat-7.x/8.x=org.apache.skywalking.apm.plugin.tomcat78x.define
.TomcatInstrumentation

tomcat-7.x/8.x=org.apache.skywalking.apm.plugin.tomcat78x.define
.ApplicationDispatcherInstrumentation

这两个类都继承了 ClassInstanceMethodsEnhancePluginDefine 抽象类,同时间接继承了 ClassEnhancePluginDefine 类,如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_java_06

这里先简单回顾一下 ClassEnhancePluginDefine 这个类,ClassEnhancePluginDefine 抽象类使用了模板方法模式:只定义了增强 static 静态方法、构造方法、实例方法(以及增加字段)的流程,具体增强哪些方法则由子类实现,也就是说,ClassEnhancePluginDefine 的子类需要实现下面三个方法:

  • getStaticMethodsInterceptPoints()方法:用于获取 static 静态方法增强点,也就是说,指定了增强哪些类的哪些 static 静态方法。
  • getConstructorsInterceptPoints()方法:用于获取构造方法增强点,也就是说,指定增强哪些类的哪些构造方法。
  • getInstanceMethodsInterceptPoints()方法:用于获取实例方法增强点,也就是说,指定增强哪些类的哪些实例方法。

ClassInstanceMethodsEnhancePluginDefine 只实现了 getStaticMethodsInterceptPoints() 方法,且具体实现为空实现,也就是说,它的所有子类都不会增强 static 静态方法,只会增强构造方法或实例方法,例如下面即将要介绍的 TomcatInstrumentation 实现。

而 ClassStaticMethodsEnhancePluginDefine 则正好相反,它实现了 getConstructorsInterceptPoints() 和 getInstanceMethodsInterceptPoints() 两个方法,并且这两个方法都是空实现,也就是说,它的所有子类都不会增强构造方法和实例方法,只会增强 static 静态方法,例如后面我们将要介绍的 apm-toolkit-activation 工具箱中的 TraceContextActivation 实现。

你可以回顾一下 AbstractMysqlInstrumentation 这个类,它同时实现了上述三个方法(且三个方法都是空实现),然后由子类根据具体情况进行覆盖。在实践中你可以比较 AbstractMysqlInstrumentation 与 ClassInstanceMethodsEnhancePluginDefine、ClassStaticMethodsEnhancePluginDefine 的设计方式,根据实际情况进行折中选择。


第16讲:Tomcat 插件原理精析,看 SkyWalking 如何增强这只 Cat(下)

TomcatInstrumentation

回顾完 ClassEnhancePluginDefine 抽象类的相关设计,我们回到 tomcat-7.x-8.x-plugin 插件中继续分析 TomcatInstrumentation 这个插件类,重点关注四个问题:拦截哪个类、拦截哪个方法、由谁进行增强、具体增强逻辑。

先来看 enhanceClass()方法,它返回的 ClassMatch 匹配了拦截的类名:

protected ClassMatch enhanceClass() { // 拦截Tomcat的StandardHostValve类
    return byName("org.apache.catalina.core.StandardHostValve");
}

TomcatInstrumentation.getConstructorsInterceptPoints() 方法返回为 null,
不会拦截 StandardHostValve 的构造方法。getInstanceMethodsInterceptPoints() 返回了两个实例方法增强点(InstanceMethodsInterceptPoint 对象),其中一个是拦截 invoke() 方法,相关实现如下:

new InstanceMethodsInterceptPoint() {
    public ElementMatcher<MethodDescription> getMethodsMatcher() {
        return named("invoke"); // 拦截名为invoke的方法
    }

public String getMethodsInterceptor() {
    return "org.apache.skywalking.apm.plugin.tomcat78x
         .TomcatInvokeInterceptor"; // 拦截后的增强逻辑
}

public boolean isOverrideArgs() {
    return false; // 不修改invoke()方法的参数
}

}

TomcatInvokeInterceptor 实现了 InstanceMethodsAroundInterceptor 接口,定义了具体的增强逻辑,你可以回顾一下 InstMethodsInter 实现类,它会在目标方法前后调用 InstanceMethodsAroundInterceptor 实现的 beforeMethod() 方法、handleMethodException
() 方法以及 afterMethod() 方法。

下面是关于 TomcatInvokeInterceptor.beforeMethod() 方法三种场景的考虑:

  1. 当 Tomcat 作为用户请求接入层的场景时,如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务_07

此时请求没有与任何 Trace 关联,也就不会携带 ContextCarrier 请求头,beforeMethod() 方法中会创建全新的 TracingContext 以及 EntrySpan。

  1. tomcat-7.x-8.x-plugin 插件被嵌套在其他插件之后的场景,如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_dubbo_08

此时请求在经过其他插件的时候,已经创建了关联的 TracingContext 以及 EntrySpan,beforeMethod() 方法无需创建 TracingContext,只需重新调用 EntrySpan 的 start() 方法即可。

  1. Tomcat 作为下游系统被其他系统调用的场景,如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_tomcat_09

此时请求已经在上游系统中关联了 Trace,在跨进程 HTTP 调用时就会携带 ContextCarrier 请求头,在 TomcatInstrumentation 的 beforeMethod() 方法中进行反序列化,并填充到全新的 TracingContext 中,还会新建 EntrySpan 并调用其 start() 方法。

TomcatInvokeInterceptor.beforeMethod() 方法同时支持了上述三种场景,它首先会尝试从 HttpServletRequest 请求头中查找 ContextCarrier 请求头,如果存在则进行反序列化操作。然后,查找(或创建)请求关联的 TracingContext 以及 EntrySpan。最后会记录 Tags 信息以及Component 信息。具体代码实现如下:

public void beforeMethod(EnhancedInstance objInst, Method method, 
      Object[] allArguments, Class<?>[] argumentsTypes, 
           MethodInterceptResult result) throws Throwable {
    // invoke()方法的第一个参数就是HttpServletRequest对象
    HttpServletRequest request = (HttpServletRequest)allArguments[0];
    // 创建一个空的ContextCarrier对象
    ContextCarrier contextCarrier = new ContextCarrier();
    // 从Http请求头中反序列化ContextCarrier
    CarrierItem next = contextCarrier.items();
    while (next.hasNext()) {
        next = next.next();
        next.setHeadValue(request.getHeader(next.getHeadKey()));
    }
    // 获取当前线程绑定的TracingContext,如果未绑定则会创建新TracingContext并
    // 绑定,同时还会创建EntrySpan,如果已存在EntrySpan,则再次调用其start()方
    // 法 。这里的第一个参数是operationName(即EndpointName),Tomcat的场景下
    // 就是请求的 URI。
    AbstractSpan span = ContextManager.createEntrySpan(
        request.getRequestURI(), contextCarrier);
    // 为EntrySpan添加Tags,记录请求的URL以及Method信息
    Tags.URL.set(span, request.getRequestURL().toString());
    Tags.HTTP.METHOD.set(span, request.getMethod());
    span.setComponent(ComponentsDefine.TOMCAT); // 设置component字段
    SpanLayer.asHttp(span); // 设置layer字段
}

在前面的课时中我们已经详细介绍了 ContextManager、TracingContext 以及 EntrySpan 的实现原理,这里不再展开,你可以回顾第 11 课时和第 13 课时中的相关内容。

再探 ContextCarrier

在 TomcatInvokeInterceptor 反序列化 ContextCarrier 的逻辑中,没有看到 deserialize() 方法的调用,而是看到 CarrierItem 这个类。在 SkyWalking 的 3.x 版本和 6.x 版本中,CarrierContext 的序列化格式略有区别(V1 版本和 V2 版本),我们可以通过 CarrierItem 同时兼容两个版本的格式。CarrierItem 的继承关系如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_dubbo_10

先来看序列化过程,ContextCarrier.items() 方法会根据 ACTIVE_V2_HEADER 配置以及 ACTIVE_V1_HEADER 配置决定当前 Agent 支持哪个版本的格式(也可以同时支持),下图展示了在同时支持 V1、V2 两个版本序列化格式时,ContextCarrier.items() 方法创建的 CarrierItem 链表:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务_11

在 CarrierItem 中有 headKey 和 headValue 两个核心字段,其中 headKey 由 agent.namespace 和版本标记两部分构成,headValue 则是 ContextCarrier 按照相应版本格式序列化后得到的字符串。下面是 SW6CarrierItem 的构造方法:

public SW6CarrierItem(ContextCarrier carrier, CarrierItem next) {
super(HEADER\_NAME, // headKey
// 按照V2版本序列化得到headValue
carrier.serialize(ContextCarrier.HeaderVersion.v2),
next); // 下一个CarrierItem节点
this.carrier = carrier; // 记录关联的ContextCarrier对象
}

上图中的 CarrierItemHead 只是链表的头节点,不携带任何有效信息。

通过 CarrierContext.item() 方法拿到 CarrierItem 链表之后,CarrierItemHead 就可以将其中每个 CarrierItem 作为附件信息添加到跨进程调用的请求中,例如,添加到 HTTP 请求头中,其中 headKey 作为 HttpHeader 的 Key,headValue 作为 HttpHeader 的 Value。

在处理 HTTP 请求的服务端,例如本课时分析的 tomcat-7.x-8.x-plugin 插件中,会根据当前 Agent 支持的版本,从相应 HttpHeader 中,按照拿到的 ContextCarrier 字符串,反序列化填充 ContextCarrier 对象,所以才会有 TomcatInvokeInterceptor.beforeMethod() 方法中的这段代码片段:

// 创建空的ContextCarrier对象
ContextCarrier contextCarrier = new ContextCarrier();
// 创建CarrierItem链表,因为ContextCarrier对象是空的,所以链表也是空的
CarrierItem next = contextCarrier.items();
while (next.hasNext()) {
next = next.next();
// 拿到HttpHeader的Value,即对应版本的ContextCarrier序列化字符串
next.setHeadValue(request.getHeader(next.getHeadKey()));
}

在 SW6CarrierItem.setHeaderValue() 方法中会调用 ContextCarrier.deserialize() 方法,并按照 V2 版本的格式对 ContextCarrier 字符串进行解析,同时填充 ContextCarrier 对象的相应字段。SW3CarrierItem.setHeaderValue() 方法的实现与上述过程类似。

到此,TracingContext 的跨进程传播流程已经梳理完成了,相信你对此处的逻辑也已经有了清晰的认知。

请求经过 beforeMethod() 方法处理之后,会继续调用 StandardHostValve.invoke() 这个目标方法。 在 invoke() 方法返回之后,继续执行 TomcatInvokeInterceptor.afterMethod() 的后置处理,请求会调用当前 stopSpan() 关闭当前 Span(即前面创建的 EntrySpan),同时会根据 HTTP 响应码在 Span 中标记该请求是否发生异常,记录相关 Tags 信息等,具体实现如下:

public Object afterMethod(EnhancedInstance objInst, Method method,
Object[] allArguments, Class<?>[] argumentsTypes,
Object ret) throws Throwable {
// invoke()方法的第二个参数是 HttpServletResponse
HttpServletResponse response =
(HttpServletResponse)allArguments[1];
// 获取当前Span,因为TracingContext是栈的形式管理Span,当前Span即为
// beforeMethod()方法中创建的EntrySpan
AbstractSpan span = ContextManager.activeSpan();
if (response.getStatus() >= 400) {
// 如果响应码是4xx或是5xx,则表示Http响应异常,标记当前Span的
// errorOccurred字段,并记录一个Key为status\_code的Tag
span.errorOccurred();
Tags.STATUS\_CODE.set(span,
Integer.toString(response.getStatus()));
}
// 关闭当前EntrySpan,如果EntrySpan完全关闭,则整个Span栈为空,
// 所在的TraceSegment也将随之关闭,这些逻辑在前面已经详细介绍过了
ContextManager.stopSpan();
// 从RuntimeContext中清理FORWARD\_REQUEST\_FLAG信息,其含义后面再说
ContextManager.getRuntimeContext().remove(
Constants.FORWARD\_REQUEST\_FLAG);
return ret;
}

最后,在 StandardHostValve.invoke() 方法处理请求抛出异常时,TomcatInvokeInterceptor.handleMethodException() 方法会在当前 Span 中记录 Log 信息,并通过 Span 的 errorOccurred 字段标记该请求处理异常。

ApplicationDispatcherInstrumentation

如果你了解 Java Web 编程,就会知道 Servlet 中有 forward(直接请求转发) 和 redirect(间接请求转发) 两种跳转方式。

redirect 跳转,也叫重定向,它一般用于避免用户的非正常访问,例如,在用户没有登录的情况下访问后台资源,Servlet 可以将该 HTTP 请求重定向到登录页面,让用户进行登录操作。在Servlet 中,redirect 会通过调用 response 对象的 sendRedirect() 方法,告诉浏览器重定向,访问指定的 URL,示例代码如下:

public void doGet(HttpServletRequest request,
HttpServletResponse response){
response.sendRedirect("跳转到的目标URL");
}

下图展示了 redirect 跳转的流程:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_tomcat_12

注意,redirect 跳转可以跳转到任意 URL,Servlet 1 和 Servlet 2 不一定要在一个 Webapp 中。

在 Tomcat 的代码实现中,我们可以看到 org.apache.catalina.connector.Response 这个类对 sendRedirect() 方法的实现,它会将响应状态码设置成 302(或307) ,并设置 Location 这个 Header 指明跳转的目标地址,相关实现片段如下:

public void sendRedirect(String location, int status) {
try {
String locationUri = ...; // 获取 redirectUrl
setStatus(status); // 状态码设置为302或是307
setHeader("Location", locationUri);
if (getContext().getSendRedirectBody()) { // 返回ResponseBody
...
}
} catch (IllegalArgumentException e) {
setStatus("404");
}
setSuspended(true); // Cause the response to be finished
}

forward 跳转是 Webapp 内部的跳转,对用户来说是无感知的,跳转期间不会返回响应,用户浏览器的 URL 地址栏也不会发生变化。注意,forward 跳转无法跨越多个 Webapp。forward 跳转的具体流程如下所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务模块不能通过线程获取id_13

实际的 forward 跳转代码如下所示:

public void doGet(HttpServletRequest request ,
HttpServletResponse response){
// 获取请求转发器对象,该转发器的指向通过getRequestDisPatcher()的参数设置
RequestDispatcher requestDispatcher =
request.getRequestDispatcher("Servler2的地址");
// 调用forward()方法,转发请求
requestDispatcher.forward(request,response);
}

RequestDispatcher 是 Java Servlet 规范中规定的一个接口,在 Tomcat 的代码中,ApplicationDispatcher 实现了 RequestDispatcher 接口。在 forward() 方法实现中,会根据指定的目标创建一个新的 Request 请求并交给 Context 进行处理,具体实现逻辑较长,如果你感兴趣的话可以去翻看一下具体的实现逻辑。

在 tomcat-7.x-8.x-plugin 插件的 skywalking-plugin.def 配置文件中定义的 ApplicationDispatcherInstrumentation 类,负责拦截 Tomcat 中 ApplicationDispatcher 的全部构造方法以及其 forward()方法,具体的增强逻辑位于 ForwardInterceptor 中。 首先来看 ForwardInterceptor 对构造方法的增强,onConstruct() 方法会将跳转的目标地址记录到增强字段(_$EnhancedClassField_ws)中:

public void onConstruct(EnhancedInstance objInst,
Object[] allArguments) {
// ApplicationDispatcher构造方法的第二个参数为跳转的目标地址,下图所示
objInst.setSkyWalkingDynamicField(allArguments[1]);
}

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务_14

ForwardInterceptor 对 forward() 方法的增强比较简单,会在 beforeMethod() 方法中将跳转 URL 地址作为 Log 记录到当前 Span 中,同时会在 RuntimeContext 中记录 forward 跳转标记:

public void beforeMethod(EnhancedInstance objInst, Method method,
Object[] allArguments, Class<?>[] argumentsTypes,
MethodInterceptResult result) throws Throwable {
AbstractSpan abstractTracingSpan =
ContextManager.activeSpan();
Map\<String, String> eventMap = new HashMap\<String, String>();
eventMap.put("forward-url",
objInst.getSkyWalkingDynamicField() == null ? "" :
String.valueOf(objInst.getSkyWalkingDynamicField()));
// 通过Log的方式记录将跳转URL
abstractTracingSpan.log(System.currentTimeMillis(), eventMap);
ContextManager.getRuntimeContext() // 记录forward标记哦
.put(Constants.FORWARD\_REQUEST\_FLAG, true);
}
总结

本课时第 1 部分介绍了 Tomcat 的整体架构,帮助你梳理了 Tomcat 处理请求的逻辑。Tomcat 在接收到用户请求时,首先由 Connector 将请求转换成 Request 对象,然后调用容器的 Pipeline 来处理该 Request 对象。Pipeline 由多个自定义 Valve 与标准 Valve 构成,Pipeline 首先会调用自定义 Valve 处理请求,最后标准 Valve 调用子容器,这是典型的责任链模式。整个调用流程如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务模块不能通过线程获取id_15

当请求经过所有的 Pipeline-Valve 的处理之后,Tomcat 会将返回的结果交给 Connector,Connector 会通过底层的 Socket 连接将响应结果返回给用户。

理清 Tomcat 架构之后,本课时的第 2 部分深入介绍了 tomcat-7.x-8.x-plugin 插件对 StandardHostValve 中 invoke() 方法的增强,同时还深入讲解了 ContextCarrier 同时支持多个序列化版本的实现原理。最后介绍了 forward 跳转、redirect 跳转的原理,以及 tomcat-7.x-8.x-plugin 插件对 forward 跳转的处理。


第17讲:Dubbo 插件核心剖析,Trace 是这样跨服务传播的

今天我们进入 Dubbo 插件核心剖析的学习。

Dubbo 架构剖析

Dubbo 是 Alibaba 开源的分布式服务框架,在前面的课时中,我们搭建的 demo-webapp 示例就是通过 Dubbo 实现远程调用 demo-provider 项目中 HelloService 服务的。通过前面 demo 示例的演示,你可能已经大概了解 Dubbo 的架构,如下图所示:

这里简单说明一下上图中各个步骤与 Demo 示例之间的关系:

  1. demo-provider 项目所在的 Container 容器启动,初始化其中的服务。demo-provider 启动之后,作为服务的提供方(Dubbo Provider),Dubbo 框架会将其暴露的服务地址注册到注册中心(Registry,即示例中的 Zookeeper)。
  2. demo-webapp 启动之后,作为服务的消费者(Dubbo Consumer),可以在注册中心处订阅关注的服务地址。
  3. 注册中心在收到订阅之后,会将 Dubbo Provider 的地址列表发送给 Dubbo Consumer,同时与 Dubbo Consumer 维持长连接。如果后续 Dubbo Provider 的地址列表发生变化,注册中心会实时将变更后的地址推送给 Dubbo Consumer。
  4. 在 Dubbo Consumer 从注册中心拿到 Dubbo Provider 的地址列表之后,会根据一定的负载均衡方式,从地址列表中选择一个 Dubbo Provider,与其建立网络连接,并发起 RPC 请求,调用其暴露的服务。
  5. 在 Dubbo Consumer 和 Dubbo Provider 运行的过程中,我们可以将调用时长、调用次数等监控信息定时发送到监控中心(Monitor)处进行统计,从而实现监控服务状态的能力。Monitor 在上述架构中不是必须存在的。

了解了 Dubbo 框架顶层的运行逻辑之后,我们进一步深入了解一下 Dubbo 框架架构。Dubbo 最大的特点是按照分层的方式来进行架构的,这种方式可以使各个层之间的耦合降到最低。从服务模型的角度来看,Dubbo 采用的是一种非常简单的模型,要么是提供方提供服务,要么是消费者消费服务,基于这一点可以抽象出服务提供方(Provider)和服务消费方(Consumer)两个角色。如下图所示,图左侧蓝色部分为 Dubbo Consumer 相关接口和实现类,右边绿色部分为 Dubbo Provider 相关的接口和实现类, 位于中轴线上的为双方都用到的接口:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务_16

下面我将结合 Dubbo 官方文档,分别介绍一下 Dubbo 框架这 10 层的核心功能。

  • 服务接口层(Service):它与实际业务逻辑相关,根据 Provider 和 Consumer 的具体业务设计相应的接口和实现。其中接口对应 demo 示例中的 HelloService 接口,Implement 实现则对应 demo 示例中 DefaultHelloService 这个实现类。
  • 配置层(Config):用来对外配置接口,以 ServiceConfig 和 ReferenceConfig 为中心,可以直接创建配置类,也可以通过 Spring 解析配置生成配置类。在 demo-webapp 中使用的@Reference 注解(注入 HelloService 接口实现),就是依赖 ReferenceConfig 实现的;在 demo-provider 中通过 application.yml 配置文件暴露的接口,就是依赖 ServiceConfig 实现的。
  • 服务代理层(Proxy):它是服务接口代理,这一层会生成服务的客户端 Stub 和服务器端Skeleton。Stub 和 Skeleton 可以帮助我们屏蔽下层网络相关的操作细节,这样上层就可以像调用本地方法一样,进行远程调用了。
  • 服务注册层(Registry):用于封装服务地址的注册与发现,以服务 URL 为中心,扩展接口为 RegistryFactory、Registry 和 RegistryService。
  • 集群层(Cluster):它主要用在 Consumer 这一侧,集群层可以封装多个负载均衡,并桥接注册中心,以 Invoker 为中心,扩展接口为 Cluster、Directory、Router 和 LoadBalance。将多个服务提供方组合为一个服务提供方,这样,就可以对 Consumer 透明,Consumer 会感觉自己只与一个 Provider 进行交互。
  • 监控层(Monitor):用于统计 RPC 调用次数和调用时间。Dubbo 收发请求时,都会经过 Monitor 这一层,所以 Monitor 是 SkyWalking Dubbo 插件要关注的重点。
  • 远程调用层(Protocol):这一层是对 RPC 调用的封装,封装了远程调用使用的底层协议,例如 Dubbo 协议、HTTP 协议、Thrift 协议、RMI 协议等。在 RPC 层面上,Protocol 层是核心层,只要有 Protocol + Invoker + Exporter 就可以完成非透明的 RPC 调用。
  • 信息交换层(Exchange):这是一种封装请求-响应模式,用来完成同步与异步之间的转换。
  • 网络传输层(Transport):它可以将底层的网路库(例如,netty、mina 等)抽象为统一接口。
  • 数据序列化层(Serialize):包含可复用的一些工具,扩展接口为 Serialization、ObjectInput、ObjectOutput 和 ThreadPool。

了解了 Dubbo 10 层架构中每一层的核心功能之后,我们通过一次请求将 Dubbo 这 10 个层次串联起来,如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务模块不能通过线程获取id_17

图中底部的蓝色部分是 Consumer,上层绿色部分是 Provider。请求通过 Consumer 一侧的 Proxy 代理发出,在 Invoker 处会有 Cluster、Registry 两层参与进来,我们可以根据 Provider 地址列表以及负载均衡算法选择一个 Provider 进行调用。调用之后会经过 Filter,Dubbo 中的 Filter 可以做很多事情,例如,限流(limit)、监控(monitor),甚至可以直接创建 Mock 响应,返回给上层的 Consumer 服务。最后 Invoker 会选择合适的协议和序列化方式,通过 Client(封装了 Netty 等网络库)将请求发送出去。

在 Provider 侧接收到请求时,会通过底层的 Server(同样是依赖 Netty 等网络库实现)完成请求的接收,其中包括请求的反序列化、分配处理线程等操作。之后,在 Exporter 处选择合适的协议进行解析,经过 Filter 过滤之后交给 Invoker ,最终到达业务逻辑实现(Implement)。

Dubbo Filter

很多框架和组件中都有与 Filter 类似概念,例如,Java Servlet 编程中的 Filter,还有上一课时介绍的 Tomcat 中的 Valve,都是与 Filter 类似的概念。在上个课时介绍 Dubbo 请求的处理流程时,我们在 Dubbo 中也看到了 Filter 的概念,Dubbo 官方针对 Filter 做了很多的原生支持,常见的有打印访问日志(AccessLogFilter)、限流(ActiveLimitFilter、ExecuteLimitFilter、TpsLimitFilter)、监控功能(MonitorFilter)、异常处理(ExceptionFilter)等,它们都是通过 Dubbo Filter 来实现的。Filter 也是 Dubbo 用来实现功能扩展的重要机制,我们可以通过添加自定义 Filter 来增强或改变 Dubbo 的行为。

这里简单看一下 Dubbo 中与 Filter 相关的核心逻辑。首先,构建 Dubbo Filter 链表的入口是在 ProtocolFilterWrapper.buildInvokerChain() 方法处,它将加载到的 Dubbo Filter 实例串成一个 Filter 链表:

private static <T> Invoker<T> buildInvokerChain(final Invoker<T> 
        invoker, String key, String group) {
    Invoker<T> last = invoker;  // 最开始的last是指向invoker参数
    // 通过SPI方式加载Filter
    List<Filter> filters = ExtensionLoader
           .getExtensionLoader(Filter.class)
             .getActivateExtension(invoker.getUrl(), key, group);
    // 遍历filters集合,将Filter封装成Invoker并串联成一个Filter链表
    for (int i = filters.size() - 1; i >= 0; i--) {
        final Filter filter = filters.get(i);
        final Invoker<T> next = last;
        last = new Invoker<T>() {
            @Override
            public Result invoke(Invocation invocation) {
                // 执行当前Filter的逻辑,在Filter中会调用下一个
                // Invoker.invoke()方法,触发下一个 Filter
                return filter.invoke(next, invocation);
            }
            // 其他方法的实现都委托给了invoker参数(略)
        };
    }
    return last;
}

buildInvokeChain() 方法的调用点如下图所示,其中传入的 Invoker 对象分别对应 Consumer 和 Provider:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_java_18

在 getActivateExtension() 方法中,不是直接使用 SPI 方式加载 Filter 实现,中间还会有其他的过程,比如:

  • 根据 Filter 上注解标注的 group 值确定它是工作在 Consumer 端还是 Provider 端。
  • 根据用户配置开启或关闭某些特定的 Filter。
  • 结合 Filter 默认优先级以及用户配置的优先级进行排序。

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_java_19

getActivateExtension() 方法的代码非常长,但是逻辑并不复杂,如果你感兴趣可以翻看一下具体的代码实现。

在众多 Dubbo Filter 中,我们这里重点关注 MonitorFilter 的实现,它里面的 invoke() 方法中会记录并发线程数、请求耗时以及请求结果:

public Result invoke(Invoker<?> invoker, Invocation invocation) {
    RpcContext context = RpcContext.getContext(); 
    String remoteHost = context.getRemoteHost();
    long start = System.currentTimeMillis(); // 记录请求的起始时间
    getConcurrent(invoker, invocation).incrementAndGet();//增加当前并发数
    try {
        Result result = invoker.invoke(invocation); // 执行后续Filter
        // 收集监控信息
        collect(invoker, invocation, result, remoteHost, 
            start, false);
        return result;
    } catch (RpcException e) {
        collect(invoker, invocation, null, remoteHost, start, true);
        throw e;
    } finally { // 减少当前并发数
        getConcurrent(invoker, invocation).decrementAndGet(); 
    }
}

collect() 方法会将上述监控信息整理成 URL 并缓存起来,具体实现如下:

private void collect(Invoker<?> invoker, Invocation invocation, 
        Result result, String remoteHost, long start, boolean error) {
    URL monitorUrl = invoker.getUrl()
        .getUrlParameter(Constants.MONITOR_KEY);
    Monitor monitor = monitorFactory.getMonitor(monitorUrl);
    // 将请求的耗时时长、当前并发线程数以及请求结果等信息拼接到URL中
    URL statisticsURL = createStatisticsUrl(invoker, invocation, 
        result, remoteHost, start, error);
    monitor.collect(statisticsURL); // 在DubboMonitor中缓存该URL
}

DubboMonitor.collect() 方法会从 URL 中提取监控信息,并将其缓存到底层的 Map(statisticsMap 字段) 中。在进行缓存之前,该方法会对于相同 URL 的监控数据进行合并。另外,DubboMonitor 还会启动一个定时任务,定时发送 statisticsMap 字段中缓存的监控数据。在发送监控数据的时候,也会将监控数据整理成 URL 地址进行发送,这里不再展开。

SkyWalking Dubbo 插件

Dubbo MonitorFilter 的相关内容介绍完之后,我们开始进行对 Skywalking Dubbo 插件的分析。在 apm-dubbo-2.7.x-plugin 插件中,skywalking-plugin.def 定义的类是 DubboInstrumentation,它继承了 ClassInstanceMethodsEnhancePluginDefine 抽象类,拦截的是 MonitorFilter.invoke() 方法。具体的增强逻辑定义在 DubboInterceptor 中,其中的 beforeMethod() 方法会判断当前处于 Consumer 端还是 Provider 端:

  • 如果处于 Consumer 端,则会将当前 TracingContext 上下文序列化成 ContextCarrier 字符串,并填充到 RpcContext 中。RpcContext 中携带的信息会在之后随 Dubbo 请求一起发送出去,相应的,还会创建 ExitSpan。
  • 如果处于 Provider 端,则会从请求中反序列化 ContextCarrier 字符串,并填充当前 TracingContext 上下文。相应的,创建 EntrySpan。

DubboInterceptor.beforeMethod() 方法的具体实现如下:

public void beforeMethod(EnhancedInstance objInst, Method method,
       Object[] allArguments, Class<?>[] argumentsTypes, 
            MethodInterceptResult result) throws Throwable {
    Invoker invoker = (Invoker)allArguments[0]; // invoke()方法的两个参数
    Invocation invocation = (Invocation)allArguments[1];
    // RpcConterxt是Dubbo用来记录请求上下文信息的对象
    RpcContext rpcContext = RpcContext.getContext(); 
    // 检测当前服务是Consumer端还是Provider端
    boolean isConsumer = rpcContext.isConsumerSide(); 
    URL requestURL = invoker.getUrl();
    AbstractSpan span;
    final String host = requestURL.getHost();
    final int port = requestURL.getPort();
    if (isConsumer) { // 检测是否为 Consumer
        final ContextCarrier contextCarrier = new ContextCarrier();
        // 如果当前是Consumer侧,则需要创建ExitSpan对象,其中EndpointName是
        // 由请求URL地址、服务名以及方法名拼接而成的
        span = ContextManager.createExitSpan(
            generateOperationName(requestURL, invocation), 
               contextCarrier, host + ":" + port);
        // 创建CarrierItem链表,其中会根据当前Agent支持的版本号对
        // ContextCarrier进行序列化,该过程在前文已经详细介绍过了
        CarrierItem next = contextCarrier.items(); 
        while (next.hasNext()) {
            next = next.next();
            // 将ContextCarrier字符串填充到RpcContext中,后续会随Dubbo请求一
            // 起发出
            rpcContext.getAttachments().put(next.getHeadKey(), 
                 next.getHeadValue());
        }
    } else { // 如果当前是Provider侧,则尝试从
        ContextCarrier contextCarrier = new ContextCarrier();
        CarrierItem next = contextCarrier.items();// 创建CarrierItem链表
        while (next.hasNext()) {
            next = next.next();
            // 从RpcContext中获取ContextCarrier字符串反序列化,并填充当前上
            // 面创建的空白ContextCarrier对象
            next.setHeadValue(rpcContext
                  .getAttachment(next.getHeadKey()));
        }
        // 创建 EntrySpan,这个过程在前面分析Tomcat插件的时候,详细分析过了
        span = ContextManager.createEntrySpan(generateOperationName(
            requestURL, invocation), contextCarrier);
    }
    // 设置Tags
    Tags.URL.set(span, generateRequestURL(requestURL, invocation)); 
    span.setComponent(ComponentsDefine.DUBBO);// 设置 component
    SpanLayer.asRPCFramework(span); // 设置 SpanLayer
}

DubboInterceptor.afterMethod() 方法的实现就比较简单了,它会检查请求结果是否有异常,如果有异常,则通过 Log 将异常的堆栈信息记录到当前 Span 中,并在当前 Span 设置异常标志(即 errorOccurred 字段设置为 true),handleMethodException() 方法也是如此处理异常的,afterMethod() 方法最后会调用 ContextManager.stopSpan() 方法关闭当前 Span(也就是 beforeMethod() 方法中创建的 EntrySpan 或 ExitSpan)。

下图展示了 Dubbo 插件的整个处理逻辑:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_java_20

总结

本课时结合了 demo 示例,介绍了 Dubbo 框架远程调用的基本运行原理,并进一步介绍了 Dubbo 框架的 10 层结构。之后,重点介绍了 Dubbo 中 Filter 的工作原理以及 MonitorFilter 的相关实现。最后,结合上述基础知识分析了 SkyWalking Dubbo 插件的核心原理及实现。


第18讲:带你揭开 toolkit-activation 工具箱的秘密

在前面两课时中,我们详细介绍了 tomcat-7.x-8.x-plugin 插件以及 dubbo-2.7.x-plugin 插件的核心实现。但是在有些场景中,不仅需要通过插件收集开源组件的 Trace 数据,还需要收集某些关键业务逻辑的 Trace 数据。我们可以通过开发新插件的方式来实现该需求,但是成本是非常高的,尤其是当业务代码发生重构时(例如,方法名或是类名改变了),插件也需要随之修改、发布 jar 包,非常麻烦。

toolkit-trace 插件

SkyWalking 为了解决上述问题,提供了一个 @Trace 注解,我们只要将该注解添加到需要监控的业务方法之上,即可收集到该方法相关的 Trace 数据。

下面我们先通过 demo-webapp 介绍 @Trace 注解的使用和效果。首先,我们定义一个 Service 类—— DemoService:

@Service // Spring的@Service注解
public class DemoService {
    // 添加@Trace注解,使用该注解需要引入apm-toolkit-trace依赖,
    // 在搭建demo-webapp项目时已经介绍过了,pom文件不再展示
    @Trace(operationName = "default-trace-method")
    public void traceMethod() throws Exception {
        Thread.sleep(1000);
        ActiveSpan.tag("trace-method", 
             String.valueOf(System.currentTimeMillis()));
        ActiveSpan.info("traceMethod info Message");
        System.out.println(TraceContext.traceId()); // 打印Trace ID
    }
}

然后在 HelloWorldController 中注入 DemoService,并在 "/hello/{words}" 接口中调用 traceMethod() 方法:

@RestController
@RequestMapping("/")
public class HelloWorldController {
    @Autowired
    private DemoService demoService;

<span >@GetMapping</span>(<span >"/hello/{words}"</span>)
<span ><span >public</span> String <span >hello</span><span >(@PathVariable(<span >"words"</span>)</span> String words) </span>{
    ... 
    demoService.traceMethod();
    ... <span >// 省略其他方法</span>
}

}

接下来访问 "localhost:8000/hello/xxx" 这个地址等待片刻之后,即可在 SkyWalking Rocketbot 界面中看到相应的 Span 数据,如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务_21

点击该 Span,可以看到具体的 Tag 信息以及 Log 信息,如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_dubbo_22

深入工具类原理

了解了 @Trace 注解的使用之后,我们来分析其底层实现。首先我们跳转到 SkyWalking 项目的 apm-toolkit-trace 模块,如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务模块不能通过线程获取id_23

该模块就有前面使用到的 @Trace 注解以及 ActiveSpan、TraceContext 工具类,打开这两个工具类会发现,全部是空实现,那添加 Tag、获取 Trace ID 等操作是如何完成的呢?我在前面介绍 SkyWalking 源码各个模块功能时提到,apm-application-toolkit 模块类似于暴露 API 定义,对应的处理逻辑在 apm-sniffer/apm-toolkit-activation 模块中实现。

在 apm-toolkit-trace-activation 模块的 skywalking-plugin.def 文件中定义了四个 ClassEnhancePluginDefine 实现类:

  • ActiveSpanActivation
  • TraceAnnotationActivation
  • TraceContextActivation
  • CallableOrRunnableActivation

这四个 ClassEnhancePluginDefine 实现类的继承关系如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_dubbo_24

TraceAnnotationActivation 会拦截所有被 @Trace 注解标记的方法所在的类,在 TraceAnnotationActivation 覆盖的 enhanceClass() 方法中可以看到相关实现:

static ClassMatch byMethodAnnotationMatch(String[] annotations){
    return new MethodAnnotationMatch(annotations); 
}

MethodAnnotationMatch 在判断一个类是否符合条件时,会遍历类中的全部方法,只要发现一个被 @Trace 注解标记的方法,则该类符合拦截条件。

从 getInstanceMethodsInterceptPoints() 方法中可以看到,@Trace 注解的相关增强逻辑定义在 TraceAnnotationMethodInterceptor 中,其 beforeMethod() 方法会调用 ContextManager.createLocalSpan() 方法创建 LocalSpan(注意,EndpointName 优先从注解配置中获取)。在 afterMethod() 方法中会关闭该 LocalSpan,在 handleMethodException() 方法会将异常堆栈作为 Log 记录在该 LocalSpan 中。

TraceContextActivation 拦截的是 TraceContext.traceId() 这个 static 静态方法,具体增强逻辑在 TraceContextInterceptor 中,其 afterMethod() 方法会调用 ContextManager.getGlobalTraceId() 方法获取当前线程绑定的 Trace ID 并替换 TraceContext.traceId() 方法返回的空字符串。

ActiveSpanActivation 会拦截 ActiveSpan 类中 static 静态方法并交给不同的 Interceptor 进行增强,具体的 static 静态方法与 Interceptor 之间的映射关系如下:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_tomcat_25

这里以 tag() 方法为例,在 ActiveSpanTagInterceptor 的 beforeMethod() 方法中,会获取 activeSpanStack 栈顶的 Span 对象,并调用其 tag() 方法记录 Tag 信息。其他的 ActiveSpan*Interceptor 会通过 Span.log() 方法记录 Log,这里不再展开。

跨线程传播

前面的课时已经详细介绍了 Trace 信息跨进程传播的实现原理,这里我们简单看一下跨线程传播的场景。这里我们在 HelloWorldService 中启动一个线程池,并改造 DemoService 的调用方式:

@RestController
@RequestMapping("/")
public class HelloWorldController {
    // 启动一个单线程的线程池
    private ExecutorService executorService = 
            Executors.newSingleThreadScheduledExecutor();

<span >@Autowired</span>
<span >private</span> DemoService demoService;

<span >@GetMapping</span>(<span >"/hello/{words}"</span>)
<span ><span >public</span> String <span >hello</span><span >(@PathVariable(<span >"words"</span>)</span> String words)</span>{
    ... <span >// 省略其他调用</span>
    executorService.submit( <span >// 省略try/catch代码块</span>
        <span >// 使用RunnableWrapper对Runnable进行包装,实现Trace跨线程传播</span>
        RunnableWrapper.of(() -> demoService.traceMethod())
    );
    ...
}

}

此时再访问 "http://localhost:8000/hello/xxx" 地址,稍等片刻之后,会在 SkyWalking Rocketbot 上看到下图这种分叉的 Trace,其中下面那条 Trace 分支就是通过跨线程传播过去的:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务_26

除了通过 RunnableWrapper 包装 Runnable 之外,我们可以通过 CallableWrapper 包装 Callable 实现 Trace 的跨线程传播。下图展示了 Trace 信息跨线程传播的核心原理:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务模块不能通过线程获取id_27

下面来看 RunnableWrapper 和 CallableWrapper 的实现原理。toolkit-trace-activation 中的 CallableOrRunnableActivation 会拦截被 @TraceCrossThread 注解标记的类(RunnableWrapper 和 CallableWrapper 都标注了 @TraceCrossThread 注解)。

目标类的构造方法会由 CallableOrRunnableConstructInterceptor 进行增强,其中会调用 capture() 方法将当前 TracingContext 的核心信息填充到 ContextSnapshot 中,并记录到_$EnhancedClassField_ws 字段中:

public void onConstruct(EnhancedInstance objInst, 
       Object[] allArguments) {
    if (ContextManager.isActive()) {
        objInst.setSkyWalkingDynamicField(ContextManager.capture());
    }
}

此时的 Runnable(或 Callable)对象就携带了当前线程关联的 Trace 信息。

目标类的 run() 方法或是 callable() 方法由 CallableOrRunnableInvokeInterceptor 进行增强,其 before() 方法会创建 LocalSpan,在上面的 demo-webapp 示例中,线程池中的工作线程没有关联的 TracingContext 也会新创建,之后从增强的 _$EnhancedClassField_ws 字段中获取 ContextSnapshot 对象,将上游线程的数据恢复到新建的 TracingContext 中,具体实现如下:

public void beforeMethod(EnhancedInstance objInst, Method method,
        Object[] allArguments, Class<?>[] argumentsTypes,
            MethodInterceptResult result) throws Throwable {
    // 该调用中会先创建TracingContext,然后创建LocalSpan
    ContextManager.createLocalSpan("Thread/" + 
         objInst.getClass().getName() + "/" + method.getName());
    ContextSnapshot cachedObjects = 
        (ContextSnapshot)objInst.getSkyWalkingDynamicField();
    if (cachedObjects != null) { // 恢复Trace信息
        ContextManager.continued(cachedObjects);
    }
}

在 afterMethod() 中会关闭前置增强逻辑中创建的 LocalSpan,同时,为了防止内存泄漏,会清空 _$EnhancedClassField_ws 字段。

Trace ID 与日志

在实际定位问题的时候,我们可能需要将某个用户的某个请求的 Trace 监控以及相关的日志结合起来进行分析,毕竟 Trace 携带 Log 有限,不会携带请求整个生命周期中全部的日志。为了方便将 Trace 和日志进行关联,一般会在日志开头的固定位置打印 Trace ID,
application-toolkit 工具箱目前支持 logback、log4j-1.x、log4j-2.x 三个日志框架,下面以 logback 为例演示并分析原理。

日志集成 Trace ID

这里依然通过 demo-webapp 模块为例,介绍如何在日志文件中自动输出 Trace ID。首先我们引入 apm-toolkit-logback-1.x 这个依赖,如下所示:

<dependency>
    <groupId>org.apache.skywalking</groupId>
    <artifactId>apm-toolkit-logback-1.x</artifactId>
    <version>6.2.0</version>
</dependency>

接下来在 resource 目录下添加 logback.xml 配置文件,指定日志的输出格式:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_dubbo_28

该配置文件有两个地方需要注意,一个是使用的 layout 为 TraceIdPatternLogbackLayout,该类位于 apm-toolkit-logback-1.x.jar 这个依赖包中,另一个在 pattern 配置中添加了 [%tid] 占位符。

在 HelloWorldController 中的 "/hello/world" 接口中,我们添加一条日志输出:

private static final Logger LOGGER = 
       LoggerFactory.getLogger(HelloWorldController.class);

@GetMapping(“/hello/{words}”)
public String hello(@PathVariable(“words”) String words)
LOGGER.info(“this is an info log,{}”, words);
… // 省略其他代码
}

最后重启 demo-webapp 项目,访问 "localhost:8000/hello/xxx" 这个地址,就可以在控制台看到如下输出:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_java_29

Logback 核心概念

Logback 日志框架分为三个模块:logback-core、logback-classic 和 logback-access:

  • core 模块是整个 logback 的核心基础。
  • classic 模块是在 core 模块上的扩展,classic 模块实现了 SLF4J API。
  • access 模块主要用于与 Servlet 容器进行集成,实现记录 access-log 的功能。

Logback 日志框架中有三个核心类:Logger、Appender 和 Layout。Logger 主要用来接收要输出的日志内容。每个 Logger 实例都有名字,而 Logger 的继承关系与其名称的层级关系保持一致。例如,现在有 3 个 Logger 实例 L1、L2、L3,L1 的名字为 "com",L2 的名字为 "com.xxx",L3 的名字为 "com.xxx.Main",那么三者的继承关系如下图所示:

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_tomcat_30

其中,名为 "ROOT" 的 Logger 实例是顶层 Logger,它是所有其他 Logger 实例的祖先。

每个 Logger 实例都有对应的 Level 级别,如果未明确指定 Logger 实例的 Level 级别,则默认沿用上层 Logger 实例的 Level 级别,常用的 Level 级别以及 Level 优先级如下 :

TRACE < DEBUG < INFO < WARN < ERROR

在调用 Logger 实例记录日志时会产生对应的日志记录请求,每个日志记录请求也有一个 Level 属性。只有日志记录请求的 Level 属性值大于或等于相应的 Logger 实例的 Level 级别时,该日志记录请求才是有效的。例如,有一个 Logger 实例的 Level 级别为 INFO,调用它的 error() 方法产生的日志记录请求的 Level 级别为 ERROR,ERROR > INFO,所以该日志记录可以正常输出;如果调用其 debug() 方法,则产生的日志记录请求 Level 级别为 DEBUG,DEBUG < INFO,则该日志记录无法正常输出。

另外,当一个 Logger 实例的 Level 级别为 OFF 时,任何在该 Logger 实例上产生的日志记录请求都是无效的;当一个 Logger 实例的 Level 级别为 ALL 时,任何在该 Logger 实例上产生的日志记录请求都是有效的。

在使用 Logback 时,我们都是通过下面的方式获取 Logger 实例:

private static final Logger LOGGER =
      LoggerFactory.getLogger(HelloWorldController.class);

这个过程底层会查找 LoggerContext 维护的缓存(loggerCache,Map<String, Logger> 类型,其中 Key 是 Logger 实例的名字,Value 为相应 Logger 实例)。如果 loggerCache 中存在相应 Logger 实例,会直接返回;否则会创建相应的 Logger 实例并返回,同时也会将该新建的 Logger 实例缓存到 loggerCache 中,也就是说,同名的 Logger 实例全局只有一个实例。另外,在新建 Logger 实例时,会同时把 loggerCache 中不存在的父 Logger 实例都创建好。

Appender 是对日志输出目的地的抽象,在示例中使用的 ConsoleAppender 会将日志打印到控制台,实践中常用的 FileAppender、RollingFileAppender 等会将日志输出到 log 文件中,还有 Appender 可以将日志输出到 MySQL 等持久化存储中,这里不再一一列举 。

一个 Logger 实例上可以绑定多个 Appender 实例,当在 Logger 实例上产生有效的日志记录请求时,日志记录请求会被发送到所有绑定的 Appender 实例上,然后由 Appender 实例进行输出。另外,Logger 实例上绑定的 Appender 实例还可以继承自上层 Logger 实例的 Appender 绑定。

在老版本的 Logback 中, Appender 会通过 Layout 将日志事件转换成字符串,然后输出到 java.io.Writer 中,实现控制日志输出格式的目的。在新版本的 Logback 中,Appender 不再直接使用 Layout,而是使用 Encoder 实现日志事件到字节数组的转换。Encoder 同时会将转换后的字节数组输出到 Appender 维护的 Outputstream 中。

最常用的 Encoder 实现是 PatternLayoutEncoder,继承关系如下图所示。

微服务模块不能通过线程获取id 微服务可以不用tomcat吗_微服务模块不能通过线程获取id_31

从 LayoutWrapperEncoder 中 encode() 方法的实现就可以看出,上述 Encoder 底层还是依赖 Layout 确定日志的格式:

public byte[] encode(E event) {
    String txt = layout.doLayout(event); // 依赖Layout将日志事件转换字符串
    return convertToBytes(txt); // 将字符串转换成字节数组
}

PatternLayoutEncoder 底层就是直接依赖 PatternLayout 确定日志格式的。当然,我们可以使用 LayoutWrappingEncoder 并指定其他自定义的 Layout ,实现自定义格式的日志。

那 PatternLayout 是如何根据指定的日志输出格式呢?在示例中, 标签下会配置 标签,其中指定了日志的格式。在 PatternLayoutBase 初始化的时候,会解析 字符串,并创建相应的 Converter,其中每个占位符对应一个 Converter,相关代码片段如下:

public void start() {
    // 解析pattern字符串
    Parser<E> p = new Parser<E>(pattern);
    Node t = p.parse();
    // 根据解析后的pattern创建Converter链表
    this.head = p.compile(t, getEffectiveConverterMap());
    ... ... // 省略其他代码
}

Logback 自带的 Converter 实现都在 PatternLayout.defaultConverterMap 集合之中,先来展示了部分 Converter 的功能:

static {
    // DateConverter处理pattern字符串中的"%d"或是"%date"占位符
    defaultConverterMap.put("d", DateConverter.class.getName());
    defaultConverterMap.put("date", DateConverter.class.getName());
    // ThreadConverter处理pattern字符串中的"%t"或是"%thread"占位符
    defaultConverterMap.put("t", ThreadConverter.class.getName());
    defaultConverterMap.put("thread", 
         ThreadConverter.class.getName());
    // MessageConverter处理"%m"、"%msg"、"message"占位符
    defaultConverterMap.put("m", MessageConverter.class.getName());
    defaultConverterMap.put("msg", MessageConverter.class.getName());
    defaultConverterMap.put("message", 
         MessageConverter.class.getName());
    // 省略其他占位符对应的Converter
}

Converter 的核心是 convert() 方法,它负责从日志事件中提取相关信息填充占位符,例如, DateConverter.convert() 方法的实现就是获取日志时间来填充 %d(或 %date)占位符:

public String convert(ILoggingEvent le) {
    long timestamp = le.getTimeStamp(); // 获取日志事件
    return cachingDateFormatter.format(timestamp); // 格式化
}
MessageConverter 就是获取日志格式化信息来填充 %m、%msg 或 %message 占位符:
public String convert(ILoggingEvent event) {
    return event.getFormattedMessage();
}
toolkit-logback-1.x

了解了 Logback 日志框架的核心概念之后,我们回到 demo-webapp 中的 logback.xml 配置文件,这里使用的 Encoder 实现是 LayoutWrappingEncoder,其中指定的 Layout 实现为 SkyWalking 提供的自定义 Layout 实现 —— TraceIdPatternLogbackLayout,它继承了 PatternLayout 并向 defaultConverterMap 中注册了 %tid 占位符对应的 Converter,具体代码如下:

public class TraceIdPatternLogbackLayout extends PatternLayout {
    static {
        defaultConverterMap.put("tid", 
             LogbackPatternConverter.class.getName());
    }
}

LogbackPatternConverter 中的 convert() 方法实现直接返回了 "TID: N/A"。

下面我们跳转到 apm-toolkit-logback-1.x-activation 模块,其 skywalking-plugin.def 文件中指定的 LogbackPatternConverterActivation 会拦截 LogbackPatternConverter 的 convert() 方法,并由 PrintTraceIdInterceptor 进行增强。PrintTraceIdInterceptor.afterMethod() 方法实现中会用当前的 Trace ID 替换 "TID: N/A"返回值:

public Object afterMethod(EnhancedInstance objInst, Method method, 
    Object[] allArguments, Class<?>[] argumentsTypes, Object ret) {
    return "TID:" + ContextManager.getGlobalTraceId(); // 获取 Trace ID
}
总结

本课时重点介绍了 SkyWalking 中 application-toolkit 工具箱的核心原理。首先介绍了 toolkit-trace 模块中 @Trace 注解、 TraceContext 以及 ActiveSpan 工具类的使用方式,然后深入介绍了它们的核心实现。接下来,通过示例介绍了 SkyWalking 与 Logback 日志框架集成方式,深入分析了 Logback 日志框架的核心概念,最后深入介绍了 toolkit-trace-activation 模块的核心原理。