Skip to content
Lunfu Zhong edited this page Jan 16, 2017 · 3 revisions

[TOC]

引言

Easeagent 是一个基于 Java Agent 实现的 APM[^apm] 探针。

[^apm]: Application Performance Management

node "Host Process" <<JVM>> {
    rectangle easeagent <<java agent>> {
        component "servlet" <<Plugin>> 
        component "jdbc" <<Plugin>> 
    }
}

通过对某些类方法的字节码进行修改,easeagent 能够收集程序运行时的一些信息,包括但不限于:

  • 调用计数
  • 调用耗时
  • 调用堆栈
  • 链路跟踪
  • 更多^more

一些背景知识

Java Agent

java agent 是一种特殊的 JAR 文件, 可以被 JVM 装载并实现对 Java 程序信息采集, 其装载方式有两种:^inst

  1. 使用命令行选项 -javaagent:xxx.jar , 或者
  2. 使用 Attach API com.sun.tools.attach.VirtualMachine#loadAgent^attach.
public class JavaAgent {
  public static void premain(String agentArgs, Instrumentation inst) {
    // invoke this method by -javaagent option
  }
  
  public static void agentmain(String agentArgs, Instrumentation inst) {
    // invoke this method by attach API
  }
}

easeagent 是通过 -javaagent 选项来装载, 相反 stagemonitor 通过 ByteBuddyAgent 去调用 Attach API完成装载的 .^bba

不同装载方式下的回调时机

以一个 HelloWorld 程序为例:

// HelloWorld.java
public class HelloWorld {
  public static void main(String[] args) {
    System.out.println("Hello world!");
    System.in.read(); // wait until any key press.
  }
}

尝试用两种方法装载时,其回调的时序图如下:

box "java -javaagent:xxx.jar HelloWorld" 
    participant JVM 
    participant JavaAgent
    participant HelloWorld
end box

box "Attachment process" 
    participant VirtualMachine as VM
end box
   
JVM -> JavaAgent  : premain(...)
JVM -> HelloWorld : main(...)

VM --> JVM  : loadAgent(...)
JVM -> JavaAgent  : agentmain(...)

Instrumentation

如果有一个类 Service:

class Service {
    public void handle(...) {
        ...
    }
}

我们可以通过 Java Agent API Instrumentation^inst 修改 Service#handle 方法的字节码, 以实现它在每次被调用时都打印出调用耗时,改动后的字节码其等价的源码如下:

class Service {
    public void handle(...) {
        long begin = System.currentTimeMillis();
        try {
            ... // origin code of this method
        } finally {
            long end = System.currentTimeMillis();
            System.out.println("takes" + (end - begin) + " ms");
        }
    }
}

实际上, 我们会借助 ByteBuddy^bb 来简化这一实现过程。

ClassLoader

JVM 通过 ClassLoader 来装载运行时需要的字节码, 其中 SystemClassLoader 是所有 java 程序中用来装载启动时classpath 指定的那些类的字节码. 举个例子,还是运行 HelloWorld :

node "java HelloWorld" <<JVM>> {
    rectangle Bootstrap <<ClassLoader>> {
        [java.**.class]
        
        rectangle System <<ClassLoader>> {
            [HelloWorld.class]
        }
    }
}

HelloWorld 的字节码由 SystemClassLoader 来装载。 而 Java 标准库的字节码则是由一个特殊的 BootstrapClassLoader 来装载的。

ClassLoader 之间是 父与子 的关联关系, 当一个 ClassLoader 需要装载一个类时, 会先让其 尝试加载。成功后则父子共用, 反之 再尝试加载, 成功后 是不可见的。

现实世界里的 ClassLoader

现实中的 Java 程序中 ClassLoader 层次关系要复杂许多, 以运行在 Tomcat 中的两个 Web 应用为例:^tomcat

node "Tomcat" <<JVM>> {
    rectangle Bootstrap <<ClassLoader>> {        
        rectangle System <<ClassLoader>> {
            rectangle Common <<ClassLoader>> {
                rectangle WebApp2 <<ClassLoader>> 
                rectangle WebApp1 <<ClassLoader>>             
            }    
        }
    }
}

注意 由 WebApp1 装载的类对于 WebApp2 是不可见的, 即使它们都装载了同样的类,也是彼此独立的。

由 Java Agent 带来的类装载陷阱

The agent class will be loaded by the system class loader (see ClassLoader.getSystemClassLoader). This is the class loader which typically loads the class containing the application main method.^inst

假设我们是一个运行 Web 应用的 Tomcat 进程装载一个 Java Agent,其中 Web 应用 和 Java Agent 都分别依赖了不同版本的 Log4j v1.1v1.2。此时问题来了:

JVM 中哪一个版本的 Log4j 会被装载呢?

提示 Java Agent 不同的装载回调时机,加上各式各样的 ClassLoader 层次关系, 会导致结果千奇百怪

node "Tomcat" <<JVM>> {
    rectangle Bootstrap <<ClassLoader>> {        
        rectangle System <<ClassLoader>> {
            ["log4j-v1.2"] <<java agent>>
            rectangle Common <<ClassLoader>> {
                rectangle WebApp <<ClassLoader>> {
                    ["log4j-v1.1"] <<webapp>>               
                }                
            }    
        }
    }
}

对于这个问题理解与处理,直接决定了 easeagent 的核心设计思路。

核心设计要点

ClassLoader

上文抛出的问题已经暗示了不能使用 Java Agent 默认的 SystemClassLoader 装载其依赖的字节码, 更不能像 stagemonitor 那样与宿主程序公用 ClassLoader,那么要怎么做呢?

使用单独的 ClassLoader

node "Tomcat" <<JVM>> {
    rectangle Bootstrap <<ClassLoader>> {        
        rectangle System <<ClassLoader>> {
            rectangle JavaAgent <<ClassLoader>> {
                ["log4j-v1.2"]
            }
            rectangle Common <<ClassLoader>> {
                rectangle WebApp <<ClassLoader>> { 
                    ["log4j-v1.1"]              
                }                
            }    
        }
    }
}

完美解决了!(其实并没有😫)

当宿主程序的情况变成这样的时候:

node "App" <<JVM>> {
    rectangle Bootstrap <<ClassLoader>> {        
        rectangle System <<ClassLoader>> {
            ["log4j-v1.1"]              
            rectangle JavaAgent <<ClassLoader>> {
                ["log4j-v1.2"]
            }
         }
    }
}

由于 JavaAgentClassLoader 的准备装载 Log4j 时, 会先让其 也就是 SystemClassLoader 尝试装载, 于是装载了 v1.1 而不是我们期望中的 v1.2

实际中是借用 spring-boot-loader^sbleaseagent 所有的依赖打包到一个 JAR 文件中,并自定义独立的 ClassLoader 实现装载。

认 BootstrapClassLoader 作父

node "App" <<JVM>> {
    rectangle Bootstrap <<ClassLoader>> {        
        rectangle System <<ClassLoader>> {
            ["log4j-v1.1"]              
        }
        rectangle JavaAgent <<ClassLoader>> {
            ["log4j-v1.2"]
        }
    }
}

若将 JavaAgentClassLoader 设为 BootstrapClassLoader 后, 任何除标准库以外的依赖会因 无法装载,而只能由 JavaAgentClassLoader 完成装载,则上述问题也得到了解决。 实现方法很简单, 类似:

URL[] urls = ... // Java Agent Dependencies
ClassLoader loader = new URLClassLoader(urls, null);

BootstrapClassLoader 是不能被引用的, 所以将 URLClassLoader 设置为 null 即可。

完美解决了!(其实又并没有😫😫)

现实世界里的 Java Agent 并不会像修改 Service#handle 方法字节码那么简单,比如我们希望借助 Metrics ^metrics 记录调用状态,势必引入对相关字节码:

class Service {
    public void handle(...) {
        try {
            ... // origin code of this method
        } finally {
            MetricRegistry mr = SharedMetricRegistries.getOrCreate("easestack");
            mr.meter("Service#handler").mark();
        }
    }
}

一旦这么做了之后, 每当 Service#handle 被调用则会出现 ClassNotFoundException 提示 MetricRegistry 找不到, 因为 SystemClassLoader 并不知道去哪里找到 MetricRegistry

node "App" <<JVM>> {
    rectangle Bootstrap <<ClassLoader>> {        
        rectangle System <<ClassLoader>> {
            [Service]  
        }
        rectangle JavaAgent <<ClassLoader>> {
            [MetricRegistry]
        }
    }
}

难道要重回来路子, 使用相同的 ClassLoader 吗?

为 BootstrapClassLoader 添加查找 JAR 文件

当然不能走老路子, Instrumentation#appendToBootstrapClassLoaderSearch 提供了办法,为其额外增加查找字节码途径。

node "App" <<JVM>> {
    rectangle Bootstrap <<ClassLoader>> {        
        [MetricRegistry]
        rectangle System <<ClassLoader>> {
            [Service]  
        }
        rectangle JavaAgent <<ClassLoader>> {
            [Other]
        }
    }
}

完美解决了!(其实仍然没有😫😫😫)

我们不能一股脑儿把 JavaAgent 依赖的 JAR 全部往 BootstrapClassLoader 里加, 这会和老路子没什么区别。

Event Bus

似乎遇到进退两难的局面, 但要解决这个问题还得看细节。

要做到限制极少的外部字节码引入, 同时还要避免与其它 ClassLoader 中字节码的冲突。 解决方案是 EventBus

public class EventBus {
    public static final BlockQueue<Object> queue = ...;
}

public class MarkMeter {
  final String name;
  public MarkMeter(String name) { this.name = name;}
}

EventBusMarkMeter 注入到 BootstrapClassLoader 中,然后

class Service {
    public void handle(...) {
        try {
            ... // origin code of this method
        } finally {
            EventBus.queue.offer(new MarkMeter("Service#handle"));
        }
    }
}

而后另起一个线程拉取 EventBus 的事件对象并处理:

class EventPolling implements Runnable {
    final MetricRegistry mr = SharedMetricRegistries.getOrCreate("easestack");

    public void run() {
        while(true) {
            try {
                MarkMeter event = (MarkMeter)EventBus.queue.poll(1, TimeUnit.MILLISECOND);
                if (event == null) continue;
                mr.meter(event.name).mark();
           } catch (InterruptedException ignore) {
               return ;
           }
        }
    }
}

这样一来,我们 ClassLoader 状况就是:

node "App" <<JVM>> {
    rectangle Bootstrap <<ClassLoader>> {        
        [EventBus]
        [MarkMeter]
        rectangle System <<ClassLoader>> {
            [Service]  
        }
        rectangle JavaAgent <<ClassLoader>> {
            [MetricRegistry]
            [EventPolling]
        }
    }
}

并不完美, 但是从细节中取得了平衡。

ByteBuddy 提供了 ClassInjector.UsingInstrumentation 来简化注入代码的实现。

模块详述

整个项目代码并不多, 但划分了很多模块, 主要是为后续扩展划分清晰的职责边界, 同时也有利于灵活组装构建。

AGENT

class Main <<Java Agent>> {
    {static} + premain(String, Instrumentation)
}

class Bootstrap {
    {static} + premain(String, Instrumentation)
}

Main -> Bootstrap : load >

Main 是符合 Java Agent 规范的入口类,由它来创建认BootstrapClassLoader 为父的 LaunchURLClassLoader,并以此装载真正的 Bootstrap 完成整个启动过程, 如下:

actor JVM as J

box "BootstrapClassLoader"
    participant ServiceLoader as S
end box

box "SystemClassLoader" 
    participant Main as M <<Java Agent>>
    participant LaunchURLClassLoader as L
end box

box "LaunchURLClassLoader"
    participant Bootstrap as B 
    participant Plugin as P 
end box

J -> M : premain
create L
M -> L : new
M -> B : premain(String, Instrumentation)

B -> S : load(Plugin, LaunchURLClassLoader)
B -> P : hook

过程中用到了 ServiceLoaderMETA-INF/service/* 文件中找到并实例化所需 SPI 的实现类^spi, 这一设计是后文 BUILD 模块中实现灵活打包的核心。

CORE

核心设计要点中除 ClassLoader 外, 都在 CORE 中有所体现。

EventBus

interface Subscription {
    + register(consumer)
}

class EventBus {
    {static} + publish(event)
}

class EventDispatcher {
    + run()
}

若想订阅通过 publish 发布到 EventBus 的消息,只需要将负责处理的对象实例registerSubscription , 届时会由 EventDispatcher 将消息通过回调传递给对应的处理对象, 例如:

class Subscriber {
    @Subscription.Consume
    public void receive(YourEvent event) { ... }
}

AppendBootstrapClassLoaderSearch

class AppendBootstrapClassLoaderSearch {
    {static} + by(Instrumentation)
}

类如其名,它负责将声明的类在启动之初就添加到 BootstrapClassLoader 查找的路径上。 声明方法是为类添加 @AutoService 注解^autoservice

@AutoService(AppendBootstrapClassLoaderSearch.class)
public class NeedToLoadByBootstrap {
  ...
}

务必注意: 这样的类不能有 JDK 标准库以外的依赖,不然依旧会有 ClassNotFoundException

Plugin & Transformation

interface Plugin<Configuration> <<SPI>> {
    + hook(Configuration, Instrumentation, Subscription)
}

abstract class Transformation<Configuration> implements Plugin {
    # feature(Configuration): Features
}

interface Feature {
  type(): ElementMatcher.JunctionTypeDescription>
  transformer(): AgentBuilder.Transformer 
}

每个实现 Plugin 类都将成为 easeagent 的一部分被自动挂载,大部分时候并不需要直接实现 Plugin 而是继承之 Transformation 提供修改字节码的 Feature 即可。

@AutoService(Plugin.class)
public class Foo extends Transformation<Configuration> {
  ...
}

Configuration

前文已经多次出现了 Configuration , 可它并非是一个需要实现的 interface , 它可以是任意类, 甚至都不用以 Configuration 为命名。 不过出于编码上的易读和易维护的考虑, 建议遵循以下约定来声明你的配置类:

  1. 它最好是一个 Abstract Class
  2. 每个配置项都用一个方法表示,让它们彼此之间的关联显得一目了然;
  3. 每个方法的返回值都被视为是配置项的默认值;

最好的文档就是代码本身, 来看一个例子:

public class Foo extends Transformation<Foo.Configuration> {
  @Binding("foo")  
  static abstract class Configuration {
    int bar() {return 1;}
  }
}

对应以上代码的配置文件内容如下:

foo.bar = 1

最终,Foo.Configuration 的实例会通过回调方法 hookfeature 的参数传递供你的代码使用。

class ConfigurationDecorator {
    + newInstance(Class<Configuration>): Configuration
}

ConfigurationDecorator 借助 ByteBuddyTypesafe-Config^config 实现以生成的字节码完成配置项到方法的 绑定

METRIC

package metrics-spi <<frame>> {
    class SharedMetrics {
        {static} + singleton(): Metrics
    }

    interface Metrics <<SPI>> {
        + iterate(Consumer)
        + counter(name, tags): Counter
        + meter(name, tags): Meter
        + timer(name, tags): Timer
        + registerIfAbsent(name, Callable<Gauge>)
    }
}

package metric-stagemonitor <<frame>> {
    class MetricsX implements Metrics 
    class MetricsXModule <<Json Module>>
    class MetricsXReport <<Plugin>>    
}

package metric-standard <<frame>> {
    class StdMetrics implements Metrics
    class StdMetricsReport <<Plugin>>
}

package metric-event <<frame>> {
    class MetricEvents <<Plugin>>
}

package metric-annotation <<frame>> {
    class MetricAnnotations <<Transformation>>
}

package metric-servlet <<frame>> {
    class MetricServlet <<Transformation>>
}

package metric-jdbc <<frame>> {
    class MetricJDBC <<Transformation>>
}

SharedMetrics  <- MetricsXReport
Metrics        <- MetricsXReport
MetricsXReport -> MetricsXModule

StdMetrics     <- StdMetricsReport

SharedMetrics  <- MetricEvents
Metrics        <- MetricEvents

MetricEvents   <- MetricAnnotations
MetricEvents   <- MetricServlet
MetricEvents   <- MetricJDBC

METRIC 最为复杂,也是 easeagent 核心价值之一, 即采集各类行为的计量数据,并上报到其他处理系统。

Metrics SPI

考虑到 stagemonitormetrics.dropwizard.io 做了扩展,却未成为开源生态的主流,二者之间的差异会让我们在兼容开源生态上付出代价。为了减轻这样代价而设计的 Metrics 接口,可以让计量方面的业务逻辑不直接依赖二者的, 允许灵活切换。

Metric Events

MetricEvents 中定义了计量场景所有会用到的事件:

Metric Event
Meter Mark
Timer Update
Counter Inc
Gauge Register

它在启动后向 Subscription 订阅并处理这些事件。

Transformations

剩下的 MetricAnnotationMetricServlet 以及 MetricJDBC 均是针对特定采集场景的字节码增强实现。

TRACE

package trace-stack <<frame>> {
    class StackFrame
    class TraceStack <<Transformation>>
}

package trace-event <<frame>> {
    class TraceEvents <<Plugin>>
    class TracedRequest
}

package trace-servlet <<frame>> {
    class TraceServlet <<Transformation>>
}

StackFrame    <- TraceServlet
TracedRequest <- TraceServlet

TRACE 要简单许多,TraceServlet 增强了 HttpServlet 实现类的字节码,在每个需要跟踪的请求被处理后,发布一个包含了 StackFrame 栈的 TracedRequest 事件给 TraceEvents ,并由它传送到需要处理的系统。

关于传送机制

easeagent 借助 slf4j-api 统一了所有传送数据的调用方式, 然后通过配置不同的 LoggerAppender 实现,以传送 Kafka 或是 Gateway^log4j2

BUILD

这里没有实质功能代码,却而代之的是用来装配打包的 Maven 配置,借助 其 Profiles^profiles 机制和 maven-assembly-plugin^assemble 实现包含不同插件组合的 easeagent 版本。

Clone this wiki locally