写点什么

深入 OpenTelemetry 源代码:Java 探针的实现和二次开发

  • 2023-02-28
    北京
  • 本文字数:17739 字

    阅读完需:约 58 分钟

深入OpenTelemetry源代码:Java探针的实现和二次开发

前言


定位:读者需要对 Java JVM、Java Agent 有一定了解,之前写过"Java 自动化探针技术的核心原理和实践"的文章。OpenTelemetry 源代码的核心实现正好基于它的知识。如果你对 Java Instrumentation、Byte Buddy 不太熟悉,建议你先看看这篇文章。

 

本文主要分三个部分:

  • OpenTelemetry Java 探针项目结构、编译部署的介绍;

  • OpenTelemetry Agent 启动原理和源代码具体的实现;

  • OpenTelemetry Java 上进行二次开发组件的完整实例和运行效果。

 

如果喜欢文章的内容和疑问,欢迎分享,公众号下留言。

 

文章涉及技术概念:

 

JVMTI、Java Agent、Class Loader、Bootstrap ClassLoader、 Java Instrumentation、Byte Buddy、Java Byte-Code、ServiceLoader、SPI

 

重温 Java Agent

JVMTI


JVM 在设计之初,就考虑到了虚拟机状态的监控、程序 Debug、线程和内存分析等功能。


JVMTI :Java Virtual Machine Tool Interface。它底层基于 C、C++实现。通过它可以探查 JVM 内部的一些运行状态,甚至控制 JVM 应用程序的执行。Sun 公司出了 Java Agent,一个用 Java 实现 JVMTI 的流行方案。

Java Agent 技术由来


Java Agent 直译为 Java 代理,中文圈也流行另外一个称呼 Java 探针 Probe 技术。


它在 JDK1.5 引入,是一种可以动态修改 Java 字节码的技术。Java 类编译后形成字节码被 JVM 执行,在 JVM 在执行这些字节码之前获取这些字节码的信息,通过字节码转换器对字节码进行修改,以此来完成一些额外的功能。


Java Agent 是一个不能独立运行 jar 包,它通过依附于目标程序 JVM 进程工作。


//这是一种 Java Agent启动模式,通过参数和目标进程一起启动java -javaagent:myagent.jar=mode=test Test
复制代码

Agent 启动方式

Premain Agent 模式


Java 程序运行前:在Main方法执行之前,通过一个叫 premain方法来执行。


启动时需要在目标程序的启动参数中添加 -javaagent参数,Java Agent 内部通过注册 ClassFileTransformer ,这个转化器在 Java 程序 Main方法前加了一层拦截器。在类加载之前,完成对字节码修改。



Premain 完整工作流程图

JVM Attach Agent 模式


另一种是程序运行中修改,需通过 JVM 中 Attach 技术实现。

Agentmain 工作原理


和 Premain 模式相似,主要区别在进行字节码增强前,拦截入口不同。一个叫Premain,一个叫Agentmain 。 运行时加载,当前 JVM 进程已经启动了。这时借助另一个 JVM 进程通信,调用 Attach API 再把 Agent 启动起来。后面的字节码修改和重加载的过程那就是一样的。



编写一个演示 Attach 通信的 JVM 程序,用于启动 目标 JVM 程序的 Agent。


public class AttachJVM {    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {        // 获取运行中的JVM列表        List<VirtualMachineDescriptor> vmList = VirtualMachine.list();        // 我们编写探针的Jar包路径        String agentJar = "/Users/jiangzhiwei/eclipse-workspace/agentdemo/target/javaagent-demo-0.0.1-SNAPSHOT-jar-with-dependencies.jar";        for (VirtualMachineDescriptor vmd : vmList) {            // 找到测试的JVM            System.out.println("vmd name: "+vmd.displayName());            if (vmd.displayName().endsWith("AgentAttachTest")) {                // attach到目标ID的JVM上                VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());                // agent指定jar包到已经attach的JVM上                virtualMachine.loadAgent(agentJar);                virtualMachine.detach();}}}}  
复制代码

Java Agent 的价值


Java Agent 成熟的技术架构,有着对字节码通用的重写能力。它应用场景是非常广泛。


  • IDE 的调试功能,例如 Eclipse、IntelliJ IDEA;

  • 热部署功能,例如 JRebel、XRebel、spring-loaded;

  • 各种线上诊断工具,例如 Btrace、Greys,国内阿里的 Arthas;

  • 各种性能分析工具,例如 Visual VM、JConsole 等;

  • 全链路性能检测工具,例如 OpenTelemetry、Skywalking、Pinpoint 等。


接下来,我们用案例实现性能检测工具的 Java Agent 探针。

OpenTelemetry 如何使用 Java Agent


我们打开 OpenTelemetry Java 探针源文件 Jar 包,同理,从

MANIFEST.MF 文件中,找到对应 Premain 和 Attach 启动 Agent 的实现类。



Opentelemetry Java 项目


OpenTelemetry 目前 repositories 项目已经有 60 个了。大体分为几类。


  • 社区、文档内项目,比如communityopentelemetry.io

  • 各语言内探针项目,目前 Java、Go、Python、JS、Rust、Php 包括 eBPF;

  • OpenTelemetry Collector 相关项目,比如 opentelemetry-collector

opentelemetry-operator


OpenTelemetry 会把非官方贡献项目有一个类似XX-Contrib 第三方库,各种语言探针和 Collector 项目都会有。一方面,考虑是这些库有特定适用范围。另一方面,给官方项目一些必要的补充。

Java 探针核心项目opentelemetry-java-instrumentation源代码地址。


https://github.com/open-telemetry/opentelemetry-java-instrumentation


鉴于项目名太长,暂且下面统称 OJI ,OJI 至少 Java 8+ 版本支持。

OJI 相关项目 SDK 和 Contrib


OpenTelemetry Java SDK 源代码地址:


https://github.com/open-telemetry/opentelemetry-java


OJI 实现非侵入式的 Java 探针,SDK 项目基于 OJI 上提供了manual手动创建链路的能力,这种能力通过 OpenTelemetry API、SDK 方式实现的。

 

OpenTelemetry Java Contrib 源代码地址:


https://github.com/open-telemetry/opentelemetry-java-contrib


Contrib 给 Java 探针贡献了额外第三方库,目前 Contrib 主要提供一些库。


AWS ResourcesAWS X-Ray SDK SupportAWS X-Ray PropagatorConsistent samplingJFR StreamingJMX Metric GathererOpenTelemetry Maven ExtensionRuntime AttachSamplers
复制代码


通过 OpenTelemetry Client 官方架构图,我们总结下 OJI、SDK、Contrib 项目的关系。



OJI 目录结构


我们把源项目通过 Gradle 方式导入 IDE 工具后,看到的 OJI 项目结构。



OJI 需要关注的几个项目模块:


  • docs帮助介绍文档

  • benchmark-overhead压力测试工具

  • custom-checks源代码格式规范检查

  • javaagentjavaagent 核心实现

  • javaagent-bootstrapBootstrap classloader 核心实现 

  • instrumentation这里开发自己组件的地方 

  • muzzle涉及安全字节码增强和类加载的控制

OJI 如何部署编译、运行探针

初次编译


OJI 使用Gradle来进行依赖管理,配置用的是 Kotlin DSL 脚本文件。


Groovy DSL 脚本文件使用.gradle 文件扩展名

Kotlin DSL 脚本文件使用.gradle.kts 文件扩展名


OJI 开发需要 JDK 9+ 的版本,查看本地 IDE JDK 版本是否符合要求。建议 IDE 用IntelliJ IDEAEclipse可能一堆环境问题。

 

导入项目后,探针生成是通过javaagent模块的 Tasks 来编排完成。



我们执行gradle assemble进行打包任务。初次打包需要花费很长时间,OJI 拉取很多依赖。我建议你最好开代理,大致执行半个小时。


如果没有代理,可能遇到依赖拉取失败问题,我看网上有人编译超过一个多小时,他给的方案在build.gradle.kts文件添加其他的仓库地址,比如阿里云仓库。这个仅供参考。


allprojects {  repositories {    maven {      setUrl("https://maven.aliyun.com/nexus/content/groups/public/")    }    mavenCentral()    mavenLocal()    jcenter {      setUrl("https://jcenter.bintray.com/")    }    google()  }}
复制代码

编译打包


经过等待,最后显示如下类似信息,说明编译成功。



从上面看到,编译会打包探针成一个 Jar 包,存放在javaagent/build/libs/


下面可以来动手二次编写 OpenTelemetry 探针。

运行探针


我拉了 OJI 最新版本 1.22.0 重写了一个探针:


opentelemetry-javaagent-1.22.0-jiangzhiwei.jar


通过 java agent 方式启动探针,运行如下:


java -Dotel.traces.exporter=jaeger -javaagent:/path/opentelemetry-javaagent-1.22.0-jiangzhiwei.jar  -jar app.jar 
复制代码

探针的核心实现

OJI 底层设计原理


OJI 基于 Javaagent 技术启动它的探针。我们通过 Gradle 编译生成的 Jar 包中的 MANIFEST.MF文件中定义的 Agent 的启动类 OpenTelemetryAgent


Manifest-Version: 1.0Main-Class: io.opentelemetry.javaagent.OpenTelemetryAgentPremain-Class: io.opentelemetry.javaagent.OpenTelemetryAgentAgent-Class: io.opentelemetry.javaagent.OpenTelemetryAgentCan-Redefine-Classes: trueCan-Retransform-Classes: true
复制代码

MANIFEST.MF 这个文件源代码不存在的,它是用 Gradle 脚本文件 javaagent/build.gradle.kts 操作 manifest生成的。


manifest {  attributes(jar.get().manifest.attributes)  attributes(    "Main-Class" to "io.opentelemetry.javaagent.OpenTelemetryAgent",    "Agent-Class" to "io.opentelemetry.javaagent.OpenTelemetryAgent",    "Premain-Class" to "io.opentelemetry.javaagent.OpenTelemetryAgent",    "Can-Redefine-Classes" to true,    "Can-Retransform-Classes" to true)}
复制代码


OpenTelemetryAgent 是整个探针入口,它通过startAgent方法实现 Javaagent 的 premainagentmain


public final class OpenTelemetryAgent {  public static void premain(String agentArgs, Instrumentation inst) {    startAgent(inst, true);  }  public static void agentmain(String agentArgs, Instrumentation inst) {    startAgent(inst, false);  }  private static void startAgent(Instrumentation inst, boolean fromPremain) {    .......      File javaagentFile = installBootstrapJar(inst);      InstrumentationHolder.setInstrumentation(inst);      JavaagentFileHolder.setJavaagentFile(javaagentFile);      AgentInitializer.initialize(inst, javaagentFile, fromPremain);    .......   }
复制代码

OJI Agent 启动过程


startAgent 按顺序做的一些操作。


  1. Classloader 先去加载 Bootstrap 下的类。

  2. 初始化我们 Instrument , 这里面包含我们开发第三方组件探针服务。

  3. 通过 AgentInitializer.initialize 去调用 ClassLoader 加载 AgentStarterImpl 类,它是启动 Agent 的核心实现。


public class AgentStarterImpl implements AgentStarter {  public void start() {    extensionClassLoader = createExtensionClassLoader(getClass().getClassLoader(), javaagentFile);    .......       loggingCustomizer.init();      AgentInstaller.installBytebuddyAgent(instrumentation, extensionClassLoader);      WeakConcurrentMapCleaner.start();    .......}}
复制代码


从源代码可以看到:


1、AgentStarterImpl 用到 Bytebuddy做字节码增强技术。

2、AgentStarterImpl 加载两个类库,分布是 instrumentationextensionClassLoader

到这,我们需要系统回顾下 Java Classloader 技术。了解下 OpenTelemetry Javaagent 类加载设计机制、structurehierarchy 的概念。最后着重讲讲 OpenTelemetry 基于 Classloader 加载我们开发组件服务核心实现以及开发我们自己的 Instrument 的过程。

OJI Agent structure

Java ClassLoader


Java 类加载器。虚拟机把描述类的数据从 class 字节码文件加载到内存,并对数据进行检验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。


JVM 中内置了几个重要的 ClassLoader:


  • Bootstrap ClassLoader 根加载器,负责加载 JVM 运行时核心类,这些类位于 $JAVA_HOME/lib/rt.jar 文件中,我们常用内置库 java.xxx.* 都在里面,比如 java.util.、java.io.、java.nio.、java.lang. 等等。这个 ClassLoader 比较特殊,它是由 C 代码实现。

  • Extension ClassLoader 扩展类加载器,负责加载 JVM 扩展类,比如 swing 系列、内置的 js 引擎、xml 解析器等等,这些库名通常以 javax 开头,它们的 jar 包位于 $JAVA_HOME/lib/ext/*.jar 中,有很多 jar 包。

  • System ClassLoader系统类加载器,Extension ClassLoader 的子类,用于加载应用级别的类到 JVM,即加载 classpath 目录下的 Java 类,通过ClassLoader.getSystemClassLoader()可以获得。


这块需要重点说一下。网上还有另外一个概念App ClassLoader ,其实它们是一回事, 具体参考 Jdk9 中的 ClassLoader 的文档。



  • Customer ClassLoader自定义的 ClassLoader 指开发者根据具体需求编写的类加载器,可以实现定制化加载。



URLClassLoader


位于网络上静态文件服务器提供的 jar 包和 class 文件,JDK 内置了一个 URLClassLoader,用户只需要传递规范的网络路径给构造器,使用 URLClassLoader 来加载远程类库了。URLClassLoader 不但可以加载远程类库,还可以加载本地路径的类库,取决于构造器中不同的地址形式。Extension ClassLoader 和 App ClassLoader 都是 URLClassLoader 的子类,它们都是从本地文件系统里加载类库。

OJI Class Loader


下面是 OpenTelemetry Java 项目服务对应 Java ClassLoader 加载的分布图。

 


System class loader


我们 Java Agent 实现类 io.opentelemetry.javaagent.OpenTelemetryAgent 通过这个 Classloader 在应用程序启动进行初始化。


它的唯一职责是将 Agent 相关的类推送到 JVM 的引导类加载器中,这个实现交给io.opentelemetry.javaagent.bootstrap.AgentInitializer类。该类位于引导类加载器内,在 javaagent jar 中,这个类位于 io/opentemetry/javaagent/目录中。


比如之前提到 AgentInitializer 加载 javaagent-tools 相关 Agent 类。


public final class AgentInitializer {private static AgentStarter createAgentStarter(  ......  Class<?> starterClass =  agentClassLoader.loadClass("io.opentelemetry.javaagent.tooling.AgentStarterImpl");     }}
复制代码

Bootstrap class loader


根加载器加载了 OJI 主要的组件 modules :


  • javaagent-bootstrap : 它由OpenTelemetryAgent 启动,继续做 Agent

初始化过程

  • instrumentation-api and instrumentation-api-semconv :

instrumentation-api-semconv 是 OpenTelemetry 提供链路操作的基本 API, instrumentation-api-semconv 提供链路核心组件的数据定义,比如 Metrics、组件 Attribute、Method 等等。

  • instrumentation-annotations-support OJI 支持用注解 annotation 方式进行自动化注入。比如我们可以用 @WithSpan 注解。


比如 OJI 支持在 Java spring-boot 下通过注解自动生成 Span 的源代码大致实现。


package io.opentelemetry.instrumentation.annotations;public @interface WithSpan {  String value() default "";  /** Specify the {@link SpanKind} of span to be created. Defaults to {@link SpanKind#INTERNAL}. */  SpanKind kind() default SpanKind.INTERNAL;}/** spring-boot 声明注解 @WithSpan,通过 instrumentation-annotations-support ** 自动创建Span,通过关联到对应的Trace 中   **/package io.opentelemetry.instrumentation.spring.autoconfigure.aspects;@Aspectclass InstrumentationWithSpanAspect extends WithSpanAspect {  @Override  @Around("@annotation(io.opentelemetry.instrumentation.annotations.WithSpan)")  public Object traceMethod(ProceedingJoinPoint pjp) throws Throwable {    return super.traceMethod(pjp);  }
复制代码


  • javaagent-extension-api 最重要里面提供开发 Agent Instrument 最基本服务模块,包含开发核心组件的基类InstrumentationModule 、组件接口 TypeInstrumentation 、字节码增强的接口 TypeTransformer我们后面开发组件基于这个 modules 封装。

  • 依赖 otel.javaagent-bootstrap Gradle plugin 的所有 modules : 这些 modules 定义了基础功能类库在 bootstrap 类加载器里全局使用. 比如用来保存 Servlet 上下文路径功能库,用来给 Kafka 消费者的本地线程切换机。这些 modules 命名规则类似: :instrumentation:...:bootstrap

Agent class loader


  • javaagent-tooling这个模块由javaagent-bootstrap 的 OpenTelemetryAgent 初始化,它调用 Java Instrument API 实现了 Agent 具体安装过程。采用是 ByteBuddy 字节码增强框架去构建 Java Agent 的类转化器ClassFileTransformer 。这个模块还实现了 OpenTelemetry SDK 的初始化和启动。

  • muzzle这个模块主要为了解决我们开发的 Instrument 增强应用程序字节码时,如何和应用程序的代码出现冲突或者不匹配时做的相关处理。


Muzzle is a safety feature of the Java agent that prevents applying instrumentation when a mismatch between the instrumentation code and the instrumented application code is detected.
复制代码


  • javaagent-extension-api 这个模块的 io.opentelemetry.javaagent.extension  这个 package 实现了 Java Instrument API 封装的 Instrument Module ,我们开发 Instrument 就是继承这些 Module。Instrument 的发布通过 SPI 技术注册服务实现。

  • 依赖 otel.javaagent-instrumentation Gradle plugin 的所有 modules : 我们开发 Instrument 就是写成这样一个个 module。



它们所有都会实现InstrumentationModule, 有一些中间件比较复杂,module 下还会有一个library instrumentation这些 modules 命名规则似:instrumentation:...:javaagent

 

Agent ClassLoader 加载字节码特殊处理


在编译后的探针 Jar 文件中, 由 AgentClassLoader 加载的类和资源文件会被存放在 inst/ 目录中. 其中所有的 .class 都会替换成.classdata ,脚本函数代码。


fun CopySpec.isolateClasses(jar: Provider<RegularFile>) {  from(zipTree(jar)) {    // important to keep prefix "inst" short, as it is prefixed to lots of strings in runtime mem    into("inst")    rename("(^.*)\\.class\$", "\$1.classdata")    // Rename LICENSE file since it clashes with license dir on non-case sensitive FSs (i.e. Mac)    rename("""^LICENSE$""", "LICENSE.renamed")    ........  }}
复制代码


这样设计的目的是针对加载了应用程序类的一些通用类加载器,规避它们再去加载这些 Agent 类,可能导致的类冲突。这样,从 Javaagent 内部与应用程序的代码完全隔离。比如 Instrument 包含库依赖,可能应用程序也会用到这个库依赖,但是它们用的是不同版本!这种情况是常见的场景,没有办法避免有冲突。下面是编译探针包inst/ 对应.classdata



Extension class loader


OJI 除了核心组件放在otel.javaagent-instrumentation module 外,还设计了一个 Extensions 框架,它主要支持扩展和增强 Agent 功能,后面单独介绍。


The extension class loader(s) 就是被用来加载这些定制化的 Extension, 具体用法就是启动 OpenTelemetry Agent 配置otel.javaagent.extensions, 或者嵌入相应的 Extension Jar 包 到 extensions/ 目录下。比如用参数方式启动 OpenTelemetry extensions。


java -javaagent:path/to/opentelemetry-javaagent.jar      -Dotel.javaagent.extensions=opentelemetry-extension-demo-1.0.jar     -jar myapp.jar
复制代码

opentelemetry-extension-demo-1.0.jar 里面就是我们写的 Extension。 为了避免类冲突,每个 Extension 都是独立编译打包。


这里单独提一下,Extension 、Instrument 执行顺序,OJI 在相应基类提供 Order方法设置他们在 ClassLoader 中加载顺序权重。

Agent 组件 Instrument 加载过程


介绍完 OJI 的 Class Loader 的设计原理和运行机制,现在基于这些知识我们能更好理解 OJI 启动 Agent 的过程:


  • AgentStarterImpl 通过createExtensionClassLoader创建了一个 URLClassLoader 对象叫ExtensionClassLoader,同时 AgentInstaller.installBytebuddyAgent 通过 Bytebuddy 框架初始化 Agent 对象AgentBuilder 对象,底层调用 Java Instrument API。

  • AgentInstaller.installBytebuddyAgent 通过 loadOrdered静态方法从ExtensionClassLoader 类加载器把发布的 InstrumentationLoader 对象获取出来。


private static void installBytebuddyAgent(  for (AgentExtension agentExtension : loadOrdered(AgentExtension.class, extensionClassLoader)) {      ......      agentBuilder = agentExtension.extend(agentBuilder, sdkConfig);  }  )  
复制代码


对于的 InstrumentationLoader 类结构,调用它的extend方法通过 loadOrdered 静态方法获取发布的 InstrumentationModule,然后通过InstrumentationModuleInstaller 对象的install方法来实现 Instrument 的加载过程。


@AutoService(AgentExtension.class)public class InstrumentationLoader implements AgentExtension {  public AgentBuilder extend(AgentBuilder agentBuilder, ConfigProperties config) {    int numberOfLoadedModules = 0;    for (InstrumentationModule instrumentationModule :        loadOrdered(InstrumentationModule.class, Utils.getExtensionsClassLoader())) {        //讲发布的instrumentation 打印出来        logger.log(            FINE,            "Loading instrumentation {0} [class {1}]",            new Object[] {              instrumentationModule.instrumentationName(),              instrumentationModule.getClass().getName()            });     agentBuilder =            instrumentationModuleInstaller.install(instrumentationModule, agentBuilder, config);              }} 
复制代码


这里面特别说明一下:之前给大家介绍 Java Agent 启动原理提到,我们自己写的 Module 在 Premain 或者 Agentmain 方法中,会显式调用 Instrumentation 对象写入我们的 Module。


 public static void premain(String args, Instrumentation inst) {    inst.addTransformer(new PreMainTransformerDemo()); }
复制代码


那 OJI 怎么写入Transformer呢?OJI 并没有手动硬编码方式写入 Module。考虑到我们探针支持组件非常多,每一个硬编码 Module 管理复杂而且耦合性太紧。OJI 利用了 Java SPI 技术,每个 Module 按照一定固定配置,动态的发布到 ClassLoader 中,需要用到时候通过 Java 的ServiceLoader 类获取。下面章节我会详细介绍 SPI 技术和ServiceLoader


  • InstrumentationModuleInstaller.install 方法构建 Instrument Module 基本服务 Module 注册探针相关作用范围:比如通过 classLoaderMatcher

设置只对指的的 ClassLoader 下所有应用类做字节码增强。比如通过typeMatcher 设置对指的 Java package 下所有类做字节码增强。

  • Module contextProvider保留业务类相关类、方法、变量的上下文信息。比如我们字节码增强时,想要获取一些监控有用的 Metric ,可以从上下文获取。


for (TypeInstrumentation typeInstrumentation : typeInstrumentations) {  ElementMatcher<TypeDescription> typeMatcher =      new NamedMatcher<>(          instrumentationModule.getClass().getSimpleName()              + "#"              + typeInstrumentation.getClass().getSimpleName(),          new IgnoreFailedTypeMatcher(typeInstrumentation.typeMatcher()));  ElementMatcher<ClassLoader> classLoaderMatcher =      new NamedMatcher<>(          instrumentationModule.getClass().getSimpleName()              + "#"              + typeInstrumentation.getClass().getSimpleName(),          moduleClassLoaderMatcher.and(typeInstrumentation.classLoaderOptimization()));
复制代码


也可看到 typeMatcher 通过调用 Module 自身的typeInstrumentation.typeMatcher()来实现的,下面一个例子就是我写的一个 Instrument Module,通过 Debug OJI,我们可以看到上面提到整个 OJI 启动 Agent ,特别是如何加载 Instrument Module 过程。

 


Java SPI 技术


SPI 全称 Service Provider Interface,即服务提供接口,基于服务的注册与发现机制,服务提供者向系统注册服务,服务使用者通过查找发现服务,可以达到服务的提供与使用的分离,甚至完成对服务的管理。通过解耦服务具体实现和使用,使得程序的扩展性大大增强,甚至可插拔。


Java 基于 SPI 机制提供了一套用来被第三方实现的 API,主要用于框架的开发。


通过 Java SPI 我们可以动态加载组件和动态服务发现。比如数据库驱动 java.sql.Driver 接口,其他不同厂商可以针对同一接口做出不同的实现,MySQL 和 PostgreSQL 都有不同的实现提供给用户,而 Java 的 SPI 机制可以为某个接口寻找服务实现。



Java SPI 是“基于接口的编程+策略模式+配置文件”组合实现的动态加载机制。

Java ServiceLoader


ServiceLoader 是 JDK1.6 基于 SPI(Service Provider Interfaces)思想引入的一个实现类。

使用场景


基于 SPI 动态替换、发现服务机制, ServiceLoader 在许多框架有着应用, 常见的例子。


  • 数据库驱动 JDBC 加载不同数据库的连接配置,比如 Mysql、Oracle、SQLServer,

  • 日志框架 SLF4J 加载不同提供商的日志实现类,

  • Spring 通过 spring.handlers 和 spring.factories 两种方式实现 SPI 机制,可以在不修改 Spring 源码的前提下,做到对 Spring 框架的扩展开发。

ServiceLoader 原理


ServiceLoader 数据结构。


public final class ServiceLoader<S> implements Iterable<S>{private static final String PREFIX = "META-INF/services/";    // 代表被加载的类或者接口    private final Class<S> service;    // 用于定位,加载和实例化providers的类加载器    private final ClassLoader loader;    // 创建ServiceLoader时采用的访问控制上下文    private final AccessControlContext acc;    // 缓存providers,按实例化的顺序排列    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();    // 懒查找迭代器    private LazyIterator lookupIterator;    ...... }
复制代码


ServiceLoader 在实现方式上采用的是迭代器模式。在迭代器实现中采用的是懒加载方式,即用到时才加载(这里加载指的是解析 SPI 接口资源文件)。ServiceLoader 首先从类加载路径下读取 SPI 接口配置文件,将所有的配置文件地址解析到一个 Enumeration 的集合对象中,然后逐个解析配置文件,将配置文件中的每一行解析为一个类的全限定名存入一个 Iterator 中,通过对 Iterator 逐个迭代读取类名,并将解析的类名通过 Class.forName()得到每个类的 Class 对象,最后通过 ClassLoader 反射的方式创建 SPI 实现类并缓存到一个 Map 集合中。

 

OpenTelemetry SafeServiceLoader 调用 ServiceLoader 的基本实现。


public static <T> List<T> load(Class<T> serviceClass, ClassLoader classLoader) {    ......  ServiceLoader<T> services = ServiceLoader.load(serviceClass, classLoader);   ...... }
复制代码


使用 ServiceLoader 几个标准约定步骤:


  • 创建一个接口文件,声明需要实现的方法;

  • 在 resources 资源目录下创建 META-INF/services 文件夹,resources 其实是和类的根路径在同一级目录,示例中 resources 目录仅仅是为了易于区分资源文件和 Java 类文件,其实也可以将 META-INF/services 建立在 src 目录下,运行效果也是一样的。如果使用的是 IDEA 开发工具,META-INF/services 可以放在与 java 目录同级的某个资源文件夹下。

  • 在 services 文件夹中创建文件,以接口全限定名命名,文件必须使用 UTF-8 编码,可以使用"#"作为注释符。

  • 创建接口实现类,并将该实现类的全限定名注册到接口文件中。



核心代码解析


  1. 应用程序调用 ServiceLoader.load 方法。


先创建一个新的 ServiceLoader 对象,实例化该类中的成员变量。

loader(ClassLoader类型,类加载器)acc(AccessControlContext类型,访问控制器)providers(LinkedHashMap类型,用于缓存加载成功的类)lookupIterator(实现迭代器功能)
复制代码


  1. 应用程序通过迭代器访问对象实例。

 

ServiceLoader 先判断成员变量 providers 对象中(LinkedHashMap<String,S>类型)是否有缓存实例对象,如果有缓存,直接返回。


如果没有缓存,执行类的装载,实现如下:

 

(1) 读取 META-INF/services/下的配置文件,获得所有能被实例化的类的名称,值得注意的是,ServiceLoader 可以跨越 jar 包获取 META-INF 下的配置文件,具体加载配置的实现代码如下:


/** * Implements lazy service provider lookup where the service providers are * configured via service configuration files. Service providers in named * modules are silently ignored by this lookup iterator. */private final class LazyClassPathLookupIterator<T>    implements Iterator<Provider<T>>{    static final String PREFIX = "META-INF/services/";    Set<String> providerNames = new HashSet<>();  // to avoid duplicates    Enumeration<URL> configs;    Iterator<String> pending;      private Class<?> nextProviderClass() {        ......        String fullName = PREFIX + service.getName();        if (loader == null)            configs = ClassLoader.getSystemResources(fullName);        else            configs = loader.getResources(fullName);           ......     }}  
复制代码

 

(2) 通过反射方法 Class.forName()加载类对象,并用 instance()方法将类实例化。

(3) 把实例化后的类缓存到 providers 对象中,(LinkedHashMap 类型)然后返回实例对象。

ServiceLoader 加载 OJI Instrument 过程


上面我们提到:InstrumentationLoader 调用extend方法通过 loadOrdered 静态方法加载 InstrumentationModule ,这个静态方法来自 SafeServiceLoader,可以看到,该方法里面就是调用了 ServiceLoader 类的load 方法实例化我们的 Instrument Module 类。


public final class SafeServiceLoader {  public static <T extends Ordered> List<T> loadOrdered(      Class<T> serviceClass, ClassLoader classLoader) {    List<T> result = load(serviceClass, classLoader);    result.sort(Comparator.comparing(Ordered::order));  }  public static <T> List<T> load(Class<T> serviceClass, ClassLoader classLoader) {    //调用Java ServiceLoader 实例化已经发布的服务类    ServiceLoader<T> services = ServiceLoader.load(serviceClass, classLoader);    ....   }
复制代码

动手如何写组件 Instrument 模块

OJI InstrumentationModule 模块


InstrumentationModule 是 OpenTelemetry Javaagent 核心组件. OpenTelemetry 为了给 Java 核心的中间件和语言框架的数采支持,OJI 提供非常丰富和兼容不同版本的组件 Module 实现。既有 Jetty、Kafka、Mongo、JDBC 这样流行的中间件,也有 Spring、Struct、RMI、Servlet 语言框架支持,而且最大化兼容不同版本。


如果想支持自定义的组件,我们可以自己来实现一个新的InstrumentationModule


@AutoService(InstrumentationModule.class)class MyLibraryInstrumentationModule extends InstrumentationModule {  public MyModule() {  // 此处定义的是组件的名称,以及组件的别名,会在配置组件的开关时使用  super("laziobird", "laziobird-1.0");  }}
复制代码


一个InstrumentationModule 必须定义一个组件名称。OJI 用 Gradle 脚本编译时,需要通过在配置文件opentelemetry-java-instrumentation/settings.gradle.kts中通过hideFromDependabot引入这个 Module ,这样打包时 Module 自动打入 Agent Jar 中。


// 通过name 引入我写的 module instrument hideFromDependabot(":instrumentation:laziobird:javaagent")// OJI自带的 module instrument hideFromDependabot("instrumentation:akka:akka-actor-2.3:javaagent")
复制代码

TypeInstrumentation 的介绍


InstrumentationModule包含了一组TypeInstrumentation ,一个TypeInstrumentation 对应组件下具体的一个字节码增强实现,在 Module 中通过链表可以有多个实现。这样针对业务类不同场景,采取不同的字节码增强策略。


上面介绍 SPI 技术已经提到,InstrumentationModule 通过配置 META-INF/services/ 文件找到对应的 Module 接口服务。


OJI 提供了注解 ,用@AutoService标签自动生成资源文件。


public class MyModule extends InstrumentationModule {  ......  public List<TypeInstrumentation> typeInstrumentations() {    // 组件内包含的TypeInstrumentation,是一个list,    // 我们添加一个实现的Instrument到 InstrumentationModule    return Collections.singletonList(new Myinstrument());  }}
复制代码

OJI Extensions 组件


OJI 为了支持新特性、扩展功能单独设计一个独立扩展组件。比如它提供了IdGenerator 接口,你可以不用系统自动生成,自己实现 Trace、Span 的 Id 生成规则。


限于篇幅,后面单独做一个技术分享介绍 Extensions,有兴趣的可以参考。


https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/examples/extension/README.md

字节码增强框架


Agent 本质是通过操作字节码,动态修改运行时 Java 对象。


我们把一类对现有字节码进行修改或者动态生成全新字节码文件的技术叫做字节码增强技术。

字节码增强技术的实现有很多方式,简单整理下目前比较成熟的一些操作字节码的框架。



JDK动态代理运行期动态的创建代理类,只支持接口;


ASM一个 Java 字节码操控框架。它能够以二进制形式修改已有类或者动态生成类。不过 ASM 在创建 class 字节码的过程中,操纵的级别是底层 JVM 的汇编指令级别,这要求 ASM 使用者要对 class 组织结构和 JVM 汇编指令有一定的了解;


Javassist一个开源的分析、编辑和创建 Java 字节码的类库(源码级别的类库)。Javassist 是 Jboss 的一个子项目,其主要的优点,在于简单,而且快速。直接使用 Java 编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构,或者动态生成类。


Byte Buddy是一个较高层级的抽象的字节码操作工具,相较于 ASM 而言。Byte Buddy 本身也是基于 ASM API 实现的。Byte Buddy 以出色的性能,被著名的框架和工具(例如 Mockito,Hibernate,Jackson,Google 的 Bazel 构建系统等)使用。

Byte Buddy


Byte Buddy 是致力于解决字节码操作和 instrumentation API 的复杂性的开源框架。


使用 Byte Buddy,任何熟悉 Java 编程语言的人都容易地进行字节码操作。

 

官网的示例展现了如何生成一个简单的类,这个类是 Object 的子类,并且重写了 toString 方法,用来返回“Hello World!”


Class<?> dynamicType = new ByteBuddy()  .subclass(Object.class)  .method(ElementMatchers.named("toString"))  .intercept(FixedValue.value("Hello World!"))  .make()  .load(getClass().getClassLoader())  .getLoaded();System.out.println(dynamicType.getSimpleName());
复制代码

Demo 来源 bytebuddy.net

ByteBuddy Advice 技术


限于篇幅,后面文章单独介绍 ByteBuddy 的东西。

一个完整的探针开发、部署、运行的实例

目标


基于 OJI 开发新的 Java 探针,功能是:


开发一个探针组件,该组件统计指定 Java 程序下所有方法的执行时间。

这个指定 Java 程序:基于 OpenTelemetry + Jagger 链路演示系统, 探针打印相关链路生成 Trace、Span 等相关信息

新建 Java Instrument 项目


我们按照 OpenTelemetry 开发组件规范, 在instrumentation目录下新建一个目录作为组件的目录,命名为laziobird


  1. 在此目录中新建javaagent目录,目录下创建 Gradle 配置文件build.gradle.kts,我们需要引入otel.javaagent-instrumentation依赖开发组件。


plugins {  id("otel.javaagent-instrumentation")}dependencies {}
复制代码


2、在 OJI 全局的 Gradle 配置文件settings.gradle.kts中添加 Instrument 引入。


// my instrument hideFromDependabot(":instrumentation:laziobird:javaagent")hideFromDependabot("instrumentation:akka:akka-actor-2.3:javaagent")hideFromDependabot(":instrumentation:akka:akka-actor-fork-join-2.5:javaagent")
复制代码


和其它通用 Java 框架组件平级,我们声明新的 Instrument 叫 laziobird


javaagent目录下构建我们的项目结构,大致如下:



开发 InstrumentationModule 组件


我们组件定义的一个 InstrumentationModule 叫 MyModule,代码如下:


@AutoService(InstrumentationModule.class)public class MyModule extends InstrumentationModule {  public MyModule() {    // 此处定义的是组件的名称,以及组件的别名,会在配置组件的开关时使用    super("laziobird", "laziobird-1.0");  }  @Override  public List<TypeInstrumentation> typeInstrumentations() {    // 组件内包含的TypeInstrumentation,是一个list,    // 我们添加一个实现的Instrument到 InstrumentationModule     return Collections.singletonList(new Myinstrument());  }}
复制代码


这个 InstrumentationModule 在我们打成探针 Jar 包后,可以从 SPI 的services找到它。



接下来,我们实现TypeInstrumentation接口核心方式,写我们自己的 Instrument 组件。


首先,我们通过typeMatcher()方法,声明 Instrument 对那些类起作用。为了方便演示,我们探针限定测试 Java 程序里的 Spring MVC Controller 类,效果就是 Instrument 对所有 API 方法做字节码增强。

transform()方法可以看到,com.observable.trace.otel.controller下所有方法,都会被 AOP 方式拦拦截进 LaziobirdAdvice这个 Advice。


public class Myinstrument implements TypeInstrumentation {  @Override  public ElementMatcher<TypeDescription> typeMatcher() {    return nameStartsWith("com.observable.trace.otel.controller");  }  @Override  public void transform(TypeTransformer transformer) {    transformer.applyAdviceToMethod(        isMethod()            .and(isPublic()),        // 可以细分方法  .and(named("test")),        this.getClass().getName() + "$LaziobirdAdvice");  }
复制代码

LaziobirdAdvice具体实现:我们通过 ByteBuddy Advice 在所有方法进入前,创建一个内部变量laziobirdStartTime,记录开始时间。然后调用 OJI Span API ,将改变量保存到 Span 中。同时打印一些链路,TraceId、SpanId 等信息。 在方法执行完退出前,获取局部变量laziobirdStartTime ,最后和当前时间差值,就统计出方法的执行时间。


@Advice.OnMethodEnter(suppress = Throwable.class)  public static void methodEnter(@Advice.Origin String method,@Advice.AllArguments Object[] allArguments,      @Advice.Local("laziobirdStartTime") long startTime) {    System.out.println();    System.out.println("["+method.toString()+"] ------------> methodEnter start ! ------------>");    // Around Advice 打印方法所有入参 AllArguments    if (allArguments != null) {      for (Object a : allArguments      ) { System.out.println(            "- - - type : " + a.getClass().getName() + ", value : " + a.toString());}    }    System.out.println(" method methodEnter | local var laziobirdStartTime : " + startTime);    if (startTime <= 0) { //通过字节码增强的局部变量附上初始化时间      startTime = System.currentTimeMillis();    }    //从Span中获取方法开始时间    Span span = Span.current();    System.out.println(        "OnMethodEnter traceId:" + span.getSpanContext().getTraceId() + " | spanId:"            + span.getSpanContext().getSpanId());    span.setAttribute("tagTime", System.currentTimeMillis());    System.out.println("["+method.toString()+"] ------------> methodEnter end ! ------------>");  }  @Advice.OnMethodExit(suppress = Throwable.class)  public static void methodExit(@Advice.Origin String method,@Advice.Local("laziobirdStartTime") long startTime) {    System.out.println();    System.out.println("["+method.toString()+"] <------------ methodExit start ! <------------ ");    //从Span中获取方法开始时间    Span span = Span.current();    System.out.println(        "OnMethodEnter traceId:" + span.getSpanContext().getTraceId() + " | spanId:"            + span.getSpanContext().getSpanId());    //通过 Advice.Local 拿到时间戳,统计 method 执行时间    System.out.println(method.toString()+ " method cost time :" + (System.currentTimeMillis() - startTime) + " ms ");    System.out.println("["+method.toString()+"] <------------ methodExit end ! <------------ ");  }}
复制代码

探针编译部署


运行 OJI Gradle 脚本 maven assembly,进行编译打包 。第一次默认打包需要 30 分钟上,后面下载完依赖包后,编译速度快很多了。



运气不错,我们只花了不到两分钟。对于探针叫:


opentelemetry-java-instrumentation/javaagent/build/libs/opentelemetry-javaagent-1.22.0-jiangzhiwei.jar

测试用例部署探针


我们写了一个完整链路测试用例程序。

OpenTelemetry+Jaeger 的分布式链路追踪演示案例


项目大概架构长这样:



项目包含一个 Java 程序、我们写的 OpenTelemetry Java 探针、最后是 OpenTelemetry 上报 Trace 给 Jeager UI 程序。


我们启动 Java 程序、同时通过参数形式启动 Java Agent。


java -javaagent:/Users/jiangzhiwei/Desktop/gitproject/opentelemetry-java-instrumentation/javaagent/build/libs/opentelemetry-javaagent-1.22.0-jiangzhiwei.jar  -Dotel.resource.attributes=service.name=trace-demo -Dotel.traces.exporter=jaeger  -jar target/otel.jar
复制代码


其中otel.jar是我写的的这个应用程序,我们可以从启动日志看到探针自动注入的信息。另外我们指定 OpenTelemetry Trace 通过设置Dotel.traces.exporter=jaeger 上报到 Jaeger。



测试用例展示探针链路追踪的效果


运行程序的步骤:


1、访问一个后端请求,叫做loadBalancer,代码这样。


public class SpanController extends BaseController {  @GetMapping("/loadBalancer")  @ResponseBody  public String loadBalancer(String tag) {     Span span = Span.current();     // Baggage 的用法: save a key     Baggage.current().toBuilder().put("baggage.key", "蒋志伟").build().makeCurrent();     // add tag into span     span.setAttribute("username", tag);     httpTemplate.getForEntity(apiUrl + "/resource", String.class).getBody();     httpTemplate.getForEntity(apiUrl + "/auth", String.class).getBody();     return httpTemplate.getForEntity(apiUrl + "/billing?tag=" + tag, String.class).getBody();       }}
复制代码


从代码看到,请求一个 Controller,这个方法里面又跳转请求三个 Controller,下面页面效果。



探针二次开发组件的拦截效果


我们通过探针打印的日志信息,看到业务程序每个方法完整调用过程。




最后上报给 Jeager 生成链路图效果,可以看到探针打印的链路 TraceId 和 SpanId 一一对应上。



OJI 探针上报 Jeager 链路图

Github 案例地址


为了方便大家上手实践,我贡献案例到 Github,其实基于 Java Agent 性能诊断工具、链路分析的 Java 探针基本都是类似实现,大部分区别在于字节码增强实现的差异。


当然,要求更高的性能和底层功能,可以直接编写 C、C++的 JVMT 动态链接库。


探针实现

https://github.com/laziobird/opentelemetry-java-instrumentation

分布式链路演示程序

https://github.com/laziobird/opentelemetry-jaeger

建议使用非容器简单部署方式

 

作者介绍

蒋志伟,爱好技术的架构师,曾就职于阿里、Qunar、美团,前 pmcaffCTO,目前 Opentelemetry 中国社区发起人。

相关阅读:

OpenTelemetry 日志体系

云原生的基建:我理解的可观测性和 OpenTelemetry

云原生观测性 --OpenTelemetry 之实战篇

一文读懂可观测性与Opentelemetry

2023-02-28 18:0518323

评论 1 条评论

发布
用户头像
/Users/wangjinxiang/data/javaCode/opentelemetry-java-instrumentation/javaagent-bootstrap

OpenTelemetryAgent 是整个探针入口

2024-04-26 14:54 · 北京
回复
没有更多了
发现更多内容

火爆火爆!最新分享给大家:Boss直聘转发超80W次的一套超全面试题总结

比伯

Java 架构 面试 程序人生 计算机

我是收藏一把好手

yes

学习

Java高手是怎样炼成的?阿里P8技术大牛这份手写笔记告诉你答案!

Java架构之路

Java 程序员 架构 面试 编程语言

架构实战营模块一作业

刁寿钧

架构实战营

平面设计之PS(后)

空城机

PhotoShop ps 4月日更

亚马逊云科技 x 外研在线 | 将科技融入教育,用 AI 点亮数字化创新!

亚马逊云科技 (Amazon Web Services)

【资深猎头分享】程序员面试如何避坑?

Java架构师迁哥

潮汐交替,价值重组:2021数字化转型看什么?

脑极体

电商/零售行业速入!开启个性化推荐,让新品购买率无限 up~

亚马逊云科技 (Amazon Web Services)

flutter开发环境搭建,京东面试真题解析,分享PDF高清版

欢喜学安卓

android 程序员 面试 移动开发

211本科面试阿里挂了:分布式+限流+通讯+数据库被问懵了!

码农之家

Java 编程 程序员 面试 刷题笔记

我们帮助 IoTeX 实现万物可信互联,用区块链连接世界 | 精选案例

亚马逊云科技 (Amazon Web Services)

架构训练营模块 1 作业 - 张动动

张大彪

架构训练营

DEX领域第一个运用整合思维的DeFi协议 SumSwwap潜力巨大

币圈资讯

数万人跪求!阿里P8大牛手写的《Java程序性能优化实战笔记》,GitHub标星已高达十万!

Java架构之路

Java 程序员 架构 面试 编程语言

某招聘平台流出一套超级全面的《Java面试题总结》被疯狂转发近百万次,已助我拿下5个大厂offer。

Java架构之路

Java 程序员 架构 面试 编程语言

刚刚!字节跳动发布了 1179 个 Java 岗,平均薪资40K

钟奕礼

Java 编程 程序员 架构 面试

HBase三个重要机制

五分钟学大数据

HBase 4月日更

COBIT2019中核心模型(目标)、设计因素、治理系统和系统组件的关系

ming_hgm

cobit

Nocode 进阶 | 迎接 Amazon Honeycode,敲开 NoCode 开发大门(上)

亚马逊云科技 (Amazon Web Services)

framework源码,Android学习路线指南,已整理成文档

欢喜学安卓

android 程序员 面试 移动开发

领域驱动设计101 - 分层

luojiahu

领域驱动设计 DDD

C++ 虚函数表剖析

赖猫

c++ C/C++ 封装、继承、多态

【LeetCode】丑数Java题解

Albert

算法 LeetCode 4月日更

智慧城市应该装上怎样的“大脑”,“学霸”海淀分享了一些心得

脑极体

计算机原理学习笔记Day1

穿过生命散发芬芳

计算机原理 4月日更

一文搞定数据响应式原理

执鸢者

Vue 数据响应式原理

华米科技与亚马逊云科技宣布战略合作,加速覆盖全球“芯端云”战略布局

亚马逊云科技 (Amazon Web Services)

别当工具人了,手摸手教会你 Jenkins ! 码农很低调

比伯

Java 程序员 架构 计算机 技术宅

In-depth Java synchronized

0xABC

Java并发 并发编程 Monitor

亚马逊云科技宣布为 Amazon EFS 提供低成本存储级别!

亚马逊云科技 (Amazon Web Services)

深入OpenTelemetry源代码:Java探针的实现和二次开发_编程语言_蒋志伟_InfoQ精选文章