本篇文章基于我的开源 slot 工程。
本文基于 jdk8 编写

埋点系统对比

一般的,埋点系统可分为两大类:侵入式埋点和无侵入式埋点。

侵入式埋点

一般通过 SDK 来提供埋点能力。

侵入式埋点优点

  1. 埋点粒度可以随意控制,想在哪埋点就在哪埋点

  2. 使用简单,开发人员根据需要在指定的业务代码处调用 API 即可

侵入式埋点缺点

  1. 需要对业务代码进行改造,可能会需要重新进行回归测试和重新发版

  2. 对业务人员不友好,所有的埋点操作都需要通过研发人员进行操作,对于业务的响应有滞后

无侵入埋点

一般通过 agent 来实现。

无侵入埋点优点

  1. 无需对原有的业务代码进行改造

  2. 对业务人员较友好,业务人员可以配置需要埋点的内容

无侵入埋点缺点

  1. 埋点粒度无法控制,只能依托于埋点 agent 的实现粒度。

  2. 埋点的时效性只能依托于埋点 agent 实现,如果是通过 javaagent 来实现,那么需要重启应用来刷新埋点配置,如果是 attach 来实现则无需重启应用。

javaagent

在这片文章中对于 javaagent 只做简单介绍,无痕埋点的思路是本篇文章的重点。

如果读者想深入了解 javaagent 可自行搜索相关资料。

jvm 允许我们通过 javaagent 在程序启动的过程中对加载的 class 进行修改,这给我们的无痕埋点提供了入口,javaagent是slot 实现无痕埋点的基石

javaagent 主要包含三个部分,分别是:

  • Manifest
  • Agent Class
  • ClassFileTransformer

Manifest

jvm 要求 javaagent 的 jar 包中必须含有 Manifest 说明文件。

结构

我们将 Manifest 定义的属性分成了三组:基础、能力和特殊情况。

                                       ┌─── Premain-Class
┌─── Basic ─────┤
│ └─── Agent-Class

│ ┌─── Can-Redefine-Classes
│ │
Manifest Attributes ───┼─── Ability ───┼─── Can-Retransform-Classes
│ │
│ └─── Can-Set-Native-Method-Prefix

│ ┌─── Boot-Class-Path
└─── Special ───┤
└─── Launcher-Agent-Class

主要参数说明:

  • Premain-Class: When an agent is specified at JVM launch time this attribute specifies the agent class. That is, the class containing the premain method. When an agent is specified at JVM launch time this attribute is required. If the attribute is not present the JVM will abort. Note: this is a class name, not a file name or path.
  • Agent-Class: If an implementation supports a mechanism to start agents sometime after the VM has started then this attribute specifies the agent class. That is, the class containing the agentmain method. This attribute is required, if it is not present the agent will not be started. Note: this is a class name, not a file name or path.
  • Can-Redefine-Classes: Boolean (true or false, case irrelevant). Is the ability to redefine classes needed by this agent. Values other than true are considered false. This attribute is optional, the default is false.
  • Can-Retransform-Classes: Boolean (true or false, case irrelevant). Is the ability to retransform classes needed by this agent. Values other than true are considered false. This attribute is optional, the default is false.
  • Can-Set-Native-Method-Prefix: Boolean (true or false, case irrelevant). Is the ability to set native method prefix needed by this agent. Values other than true are considered false. This attribute is optional, the default is false.
  • Boot-Class-Path: A list of paths to be searched by the bootstrap class loader. Paths represent directories or libraries (commonly referred to as JAR or zip libraries on many platforms). These paths are searched by the bootstrap class loader after the platform specific mechanisms of locating a class have failed. Paths are searched in the order listed. Paths in the list are separated by one or more spaces. A path takes the syntax of the path component of a hierarchical URI. The path is absolute if it begins with a slash character (/), otherwise it is relative. A relative path is resolved against the absolute path of the agent JAR file. Malformed and non-existent paths are ignored. When an agent is started sometime after the VM has started then paths that do not represent a JAR file are ignored. This attribute is optional.
  • Launcher-Agent-Class: If an implementation supports a mechanism to start an application as an executable JAR then the main manifest may include this attribute to specify the class name of an agent to start before the application main method is invoked.
  • 此文件最后必须要有行样换行

slot 工程的 Manifest 文件如下所示:

Manifest-Version: 1.0
Premain-Class: zenv.slot.SlotAgentBootstrap
Power-By: zhengwei AKA zenv
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Class-Path: conf/ lib/slot-repackage-disruptor-1.0.jar lib/slot-repack
age-logger-1.0.jar
Build-Jdk-Spec: 1.8
Created-By: Maven Jar Plugin 3.2.0

如何生成

maven 插件

可以通过 maven-jar-plugin 插件来帮助我们生成 Manifest 文件,相关配置如下:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<excludes>
<exclude>resources/**</exclude>
</excludes>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
<manifestEntries>
<Power-By>zhengwei AKA zenv</Power-By>
<Manifest-Version>1.0</Manifest-Version>
<Premain-Class>zenv.slot.SlotAgentBootstrap</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
<Class-Path>conf/</Class-Path>
</manifestEntries>
</archive>
</configuration>
</plugin>

其中 manifestEntries 块就是 Manifest 的内容。在 maven 构建 jar 包的时候就会自动生成 Manifest 文件。

自己创建

可以在 resource 文件夹下手动创建一个名为 MANIFEST.MF 的文件,文件的最后一行必须为空行,内容形如:

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: <your_agent_class>

Agent Class

与以往的普通程序不同,javaagent的入口必须premain 函数,并且对函数签名也有要求,javaagent 仅接受两个签名的函数,分别为:

// 第一种,优先加载
public static void premain(String slotConfFilePath, Instrumentation inst)

// 第二种
public static void premain(String slotConfFilePath)

jvm 优先加载含有 Instrumentation 签名的方法,如果没有第一种方法则会加载第二种方法。我们可以对 Instrumentation 进行操作以达到我们操作 class 文件的目的, Instrumentation 定义如下:

public interface Instrumentation {
//增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

//在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
void addTransformer(ClassFileTransformer transformer);

boolean removeTransformer(ClassFileTransformer transformer);

boolean isRetransformClassesSupported();

//在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

boolean isRedefineClassesSupported();

void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException;

boolean isModifiableClass(Class<?> theClass);

@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();

@SuppressWarnings("rawtypes")
Class[] getInitiatedClasses(ClassLoader loader);

long getObjectSize(Object objectToSize);

void appendToBootstrapClassLoaderSearch(JarFile jarfile);

void appendToSystemClassLoaderSearch(JarFile jarfile);

boolean isNativeMethodPrefixSupported();

void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix);
}

我们可以操作 premain 方法中的 Instrumentation 对象来添加相关的操作。我们主要会用到 addTransformer 来添加转换类的实现,具体的为 SlotTransformer

ClassFileTransformer

在 Instrumentation 接口中,定义了添加和移除 ClassFileTransformer 的方法:

public interface Instrumentation {
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

boolean removeTransformer(ClassFileTransformer transformer);
}

在 ClassFileTransformer 接口中,定义了 transform 抽象方法:

public interface ClassFileTransformer {
byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException;

}

当我们想对 Class 进行 bytecode instrumentation 时,就要实现 ClassFileTransformer 接口,并重写它的 transform 方法。

使用

javaagent 主要以 jar 包的形式提供服务,并在使用 java 命令时以 -javaagent:jar_path[=option] 来启动服务,示例如下:

java -javaagent:./target/TheAgent.jar -jar Demo.jar

埋点思路

如何确定埋点范围

通过外部配置文件来指定。我们支持读取特定格式的 properties 文件解析出哪些方法需要进行埋点,properties 文件的路径通过 -javaagent:/your/properties/file/path 传递给 premainString 参数。读取 properties 文件的方法如下所示:

/**
* 初始化埋点配置文件
* <p>
* 本来初始化配置文件的操作在 {@link InitValve} 中进行初始化,但现在需要对埋点日志的文件名按服务名进行划分,需要先获取到本次埋点
* 的服务名,所以提前到 {@link SlotAgentBootstrap} 中进行
*
* @param filePath 埋点配置文件路径
* @return 埋点配置文件 properties
*/
private static Properties init(String filePath) {
AnsiLog.info("加载的配置文件路径为:{}", filePath);
Properties prop = null;
try {
prop = PropertiesUtils.loadExternalProp(filePath);
} catch (IOException e) {
AnsiLog.info("配置文件不存在,程序退出...");
System.exit(1);
}
// 获取服务名称已初始化埋点日志名称
final String serviceName = prop.getProperty(SLOT_SERVICE);
AnsiLog.debug(serviceName);
// 初始化日志
SlotLogUtils.initLogger(serviceName);
return prop;
}

异常处理

方法自身没有抛出异常

我们将目标方法代码块使用统一的 try-cache 进行处理,不论原方法是否抛出异常,我们都假定其会抛出异常,进行异常捕获并抛出。

在一开始实现过程中并没有对所有方法都进行异常捕获,但是在 spring boot 程序测试中发现 spring boot 存在全局异常捕获机制导致埋点流程被打断,因为进行了异常抛出所以代码的后续被 spring boot 接管了,而不会沿着原本的业务进行下去,那么埋点逻辑处理的就不完整,埋点数据收集的不完整,所以需要全局捕获异常,强制代码块走到埋点逻辑的结束。

例如原方法如下所示:

public void hello() {
System.out.println("hello world");
}

经过全局异常处理之后变为:

public void hello() throws Throwable {
// 埋点系统插入代码
try {
System.out.println("hello world");
} catch (Throwable t) {// 埋点系统插入代码
// 埋点系统插入代码
throw t;
}// 埋点系统插入代码
}

方法自身抛出异常/处理异常

如果方法自身抛出了异常或者对异常进行了处理,那么我们会在源代码的 catch 代码块中记录异常类型、异常信息和异常堆栈,以供后续分析。例如:

public void calc() {
try {
int c = 1 / 0;
} catch (Exception e) {
throw new e;
}
}

经过全局处理之后

public void calc() {
// 埋点系统插入代码
String var1 = null;
// 埋点系统插入代码
String var2 = null;
// 埋点系统插入代码
StackTraceElement[] var3 = null;
try {
int c = 1 / 0;
} catch (Exception e) {
// 埋点系统插入代码
var1 = e.getClass.getName();
// 埋点系统插入代码
var2 = e.getMessage();
// 埋点系统插入代码
var3 = e.getStackTrace();
throw new e;
}
}

方法耗时计算、方法开始时间

我们在方法代码块的最开始插入获取当前系统时间的时间戳和 LocalDateTime 来确定方法的开始时机,在方法的结尾、每个 catch 代码块中插入获取方法的结束时间代码并使用代码开始的时间戳减去方法结束的时间戳以获取方法的执行时间。

埋点数据的处理

为了埋点给系统带来最低的性能影响,埋点系统采用 disruptor 消息中间件来处理埋点产生的埋点数据得到及时有效的处理。源码在这

刚开始埋点系统采用的是 Java 自带的阻塞队列,发现存在性能问题和 OOM 问题,后期调研到 disruptor 组件,性能有了质的提升。在百万数据量下使用的内存和处理速度都有提升。

字节码插桩

slot 使用 ASM 字节码操作工具来对 Java 源码进行修改。JDK 自带的字节码操作框架无需额外引入框架,降低了 javaagent jar 包的体积。

ASM 是较为低级别的 API,使用和学习都存在一定的门槛,需要对 ClassFile 的结构有较深的理解和认识。

slot 是如何使用 ASM 进行字节码插桩的,可以参见这里

埋点思路与 javaagent 的结合

以上所有的思路全都依赖 ClassFileTransformer 中的 transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) 方法来实现。特别的,slot 中的实现为 SlotTransformer

我们主要会用到 loader、className 和 classfileBuffer 参数。

  1. loader 是当前被加载到 class 的类加载器,这个参数非常有用,我们需要使用该加载器来加载修改字节码之后的类,否则可能会出现 ClassNotFoundException

    特别的 spring boot 的 jar 包被称为 fat_jar,它内部的类加载机制由 spring 来控制,我们需要使用 spring 的类加载器来对修改后的类进行加载,否则会报 ClassNotFoundException

  2. className 用来与埋点配置文件中的配置项进行匹配,用来确定哪些类需要进行埋点,如果不似乎我们需要埋点的类那么可以直接返回 null,jvm 会自动加载原本的 class

  3. classfileBuffer 存放了当前加载的 class 的字节码信息,我们使用 ASM 组件来操作字节码,插入我们需要的埋点字节码

jvm 保证在 transform 的过程如果出现异常的话最终加载的是未经修改的 class。

展望

  1. 对于跨进程和跨机器的集群服务支持埋点服务
  2. 可以更换为 attach api 来热更新埋点配置
  3. 目前只能通过配置文件的方式来指定埋点的范围,可以考虑使用 jar 包探测,探测目标工程为 spring boot、netty、grpc或其他工程来确定埋点范围

Reference

  1. Java Agent系列一:基础篇