用户登录
用户注册

分享至

构建Java Agent,而不是使用框架

  • 作者: 小白----------------
  • 来源: 51数据库
  • 2020-08-15

Java annotations自从被引入到Java之后,一直扮演着整合各种API的作用,尤其是对大型应用框架而言。在这方面,Spring和Hibernate都是Java annotation应用的好例子——仅仅需要增加几行简单的Java annotation代码,就可以实现非常复杂的程序逻辑。尽管对这些API(的写法)存在一些争论,但是大多数程序员认为,只要使用得当,这种声明式编程在形式上还是很有表达能力的。不过,只有少量程序员基于Java annotation来编写框架API,或者应用程序中间件。之所以造成这种现象很主要的一个原因是,程序员们认为Java annotation会降低代码的可读性。在本文中,我就想告诉大家,实现这些基于annotation的API其实并不是完全无用的,只要使用恰当的工具,其实你也并不需要了解太多Java内部函数的知识。

在 实现基于annotation的API时,很明显的一个问题就是:这些API在Java运行时是不会被JVM处理的。这样造成的结果就是,你没法给一个用 户annotation赋予一个具体的含义。例如:如果我们定义了一个@Log annotation,然后我们期望在标注了@Log的地方,每调用一次就形成一条日志记录。

class Service {
  @Log
  void doSomething() { 
    // do something ...
  }
}

单靠@Log 标注本身写在哪里,是不可能完成执行程序逻辑的任务的,这就需要标注的使用者去发起生成日志的任务。明显,这种工作原理让annotation看上去毫无 意义,因为再调用doSomething方法的时候,我们根本无法去观察生成的log里面相应的状态。因此,annotation仅仅是作为一个标记而存 在,对程序逻辑来说毫无贡献可言。

填坑

为了克服上述的功能性局限,很多基于标注的框架都采用了子类覆盖类方法的模式,来赋予特定标注相关的程序逻辑功能。这种方法普遍使用了面向对象的集成机制。对于我们上面提到的@Log标注来说,子类实现机制会产生一个类似于下面的类LoggingService:

class LoggingService extends Service {
  @Override
  void doSomething() { 
    Logger.log("doSomething() was called");
    super.doSomething();
  }
}

当然,上面定义这些类的代码通常是不需要程序员手写的,而是在Java运行时,通过诸如 cglib或Javassst这样的库来自动生成。上面提到的两个库都提供了简易的API,可以用于生成增强型的子类程序。这种把类定义的过程放到运行时 的做法,其比较好的一个副作用是,在不特别规定程序规范,也不用修改已有的用户代码的前提下,能够有效实现 logging框架的功能。这样就可以避免“显式创建风格”,也就不用新建一个Java源文件去手写代码了。

但是,可伸缩性好吗?

然而,上面的解决方案又带来了另一个不足。我们通过自动生成子类的方式 实现标注的程序逻辑,必须保证在实例化的时候不能使用父类的构造函数。否则调用标注方法的时候,还是无法完成调用添加日志的功能:原因很明显,用父类的构 造函数实例化对象,无法创建出包含子类覆盖方法的正确实例(这是基本的面向对象多态的概念——译者注)。更糟糕的是——当使用上述方法进行运行时代码生成 的时候——LoggingService类无法直接被实例化,因为Java编译器在编译的时候,运行时期间生成的类代码还根本就不存在。

基于上述原因,Spring或者Hibernate这些框架使用了 “对象工厂”的模式。在其框架逻辑的范畴内,不允许直接(通过构造函数)对对象进行实例化,而是通过工厂类来完成新建对象的工作。这种方式在Spring 设计之初就被采纳,用来管理各种bean。Hibernates采用了相似的做法,大多数Hibernates的实例被视为查询的结果对象,因此也不是显 式地来实例化的。然而,有一个特例是,当试图存储一个在数据库中还不存在的对象实例的时候,Hibernates的使用者需要用Hibernates返回 的对象来替换之前存储的对象实例。从这个例子来看Hibernates的问题,忽略上述的替换会造成一个普通的初学者错误。除此之外,幸亏有了这些工厂 类,才能让子类化的方法对框架用户透明,因为Java的类型系统可以用子类实例来替代其父类。因此,只要是用户需要调用自定义服务的地方,都可以用到 LoggingService的实例。

很遗憾,这种采用工厂类来创建对象的方法虽然(在理论上)被证明是可行的,但(在实际中)用来实现我们提 出的@Log标注的逻辑,却依然非常困难,因为这种方法必须要让每一个标注类都去构建一个对应的工厂类方法。很显然,这么做会让我们的代码模板的尺寸显著 增长。更有甚者,为了避免在产生日志的方法中把逻辑写死(硬编码),我们甚至会为logging标注类创建不止一套代码模板。还有,如果有人不小心调用了 构造函数,那么就可能会有微妙的bug出现,因为在这种情况下,产生的对象实例对标注的处理方式很可能跟我们预期的是不同的。再有,工厂类的设计其实并不 容易。如果我们要添加一个@Log标记到某一个类上面,但是这个类已经被定义成了一个Hibernates bean了,那怎么办? 这听上去好像没什么意义,但是在操作的时候就必须要设计额外的配置去把我们定义的工厂类和框架自带的工厂类整合起来。最后一点,也是结论,采用工厂模式写 出的代码臃肿不堪,如果还要做到让这些代码同时兼备可读性,然后还要所使用的框架完美结合,这实现代价也太高了。这就是为什么我们要引入Java Agent的原因。Java agent这种被低估了的模式能够提供一种优秀的替代方案,用来实现我们想要的子类化方法。

一个简单的Agent

Java agent是用一个简单的jar文件来表示的。跟普通的Java程序很相似,Java agent定义了一些类作为入口点。 这些作为入口点的类需要包含一个静态方法,这些方法会在你原本的Java程序的main方法调用之前被调用:

class MyAgent {
  public static void premain(String args, Instrumentation inst) {
    // implement agent here ...
  }
}

关于处理Java agent时最有趣的部分,是premain方法中的第二个参数。这个参数是以一个Instrumentation接口的实现类实例的形式存在的。这个接 口提供了一种机制,能够通过定义一个ClassFileTransformer,来干预对Java类的加载过程。有了这种转设施,我们就能够在Java类 被使用之前,去实现对类逻辑的强化。

这个API的使用一开始看上去不那么直观,很可能是一种新的(编程模式)挑战。Class文件的转换是通过修改编 译过后的Java类字节码来完成的。 实际上,JVM并不知道什么是Java语言, 它只知道什么是字节码。也正是因为字节码的抽象特性,才让JVM能够具有运行多种语言的能力,例如Groovy, Scala等等。这样一来,一个注册了的类文件转换器就只需要负责把一个字节码序列转换成另外一个字节码序列就可以了。

尽 管已经有了像ASM、BCEL这样的类库,提供了一些简易的API,能够对编译过的Java类进行操作,但是使用这些库的门槛较高,需要开发者对原始的字 节码的工作原理有充分的了解。更可怕的是,想直接操作字节码并做到不出问题,这基本上就是一个冗长拉锯的过程,甚至非常细微的错误,JVM也会直接抛出又 臭又硬的VerifierError。不过还好,我们还有更好,更简单的选择,来对字节码进行操作。

Byte Buddy这是一个我编写,并负责维护的工具库。这个库提供了简洁的API,用来对编译后的Java字节码进行操作,也可以用来创建Java agent. 从某些方面来看,Byte Buddy也是一个代码生成的工具库,这和cglib以及Javassit的功能很类似。然而,跟他们不同的是,Byte Buddy还能够提供统一的API,实现子类化,以及重定义现有类的功能。在本文中,我们只会研究如何用Java agent来重定义一个类。如果读者有更多的兴趣,可以参照Byte Buddy’s webpage which offers a detailed tutorial ,那个里面有很详细的描述。

使用Byte Buddy创建simple agent

Byte Buddy提供的一种定义手段采用了依赖注入的方法。其原理是这样的:使用一个拦截器类——这个类是一个POJO——来获得标注参数所需要的信息。例如: 将Byte Buddy的@Origin标注使用在一个Method类型的参数上,Byte Buddy即可推演出拦截器目前要拦截的就是method变量。这样,我们就可以定义一个泛型的拦截器,只要method一出现,就会被拦截器拦截。

class LogInterceptor {
  static void log(@Origin Method method) {
    Logger.log(method + " was called");
  } 
}

当然,Byte Buddy可以作用于多个标注上。

但是,这些拦截器如何能够表示我们提出的日志框架所需要的代码逻辑呢?到目前为止,我们仅仅是定义了一个拦截器,用来拦截我们的method调用。还缺少对 于method所在的原始代码序列的调用。幸运的是,Byte Buddy提供的手段是可组合(compose)的。首先我们定义一个MethodDelegation类,并将其组合到LogInterceptor 中,这个拦截器类会在每一次method被调用的时候去默认调用拦截器的静态方法。以此为起点,我们可以通过一种序列调用的方式,将代理类和原先调用 method的代码组合起来,就跟Super MethodCall表示的一样:

class LogAgent {
MethodDelegation.to(LogInterceptor.class)
  .andThen(SuperMethodCall.INSTANCE)
}

最后,我们还需要通知Byte Buddy,将被拦截的方法与特定的逻辑绑定。就像我们在前面阐述的一样,我们想把一段逻辑(就是记录日志的功能——译者注施加到每一个加了@Log标 注的地方。在Byte Buddy中,通过使用ElementMatcher方法,(被标注的)方法就会被识别出来,这和Java 8的断言机制很类似。在静态工具类ElementMatcher中,我们可以用相应的matcher来识别我们(用@Log)标注后的方 法:ElementMatchers.isAnnotatedWith(Log.class)。

通 过上述的方式,我们就实现一个agent的定义,可以完成我们提出logging framework的要求。就跟我们在前文叙述的原理一样,Byte Buddy提供了一套工具API来构建Java agent,这些工具API则是基于可对(编译后的)class进行修改的(JavaEE原生)API。就跟下面这段API一样,其设计上与面向领域语言 相似,从代码的字面上就可以轻松弄懂其含义。显然,定义一个agent就仅仅需要几行代码而已:

class LogAgent {
  public static void premain(String args, Instrumentation inst) {
    new AgentBuilder.Default()
      .rebase(ElementMatchers.any())
      .transform( builder -> return builder
                              .method(ElementMatchers.isAnnotatedWith(Log.class))
                              .intercept(MethodDelegation.to(LogInterceptor.class)
                                  .andThen(SuperMethodCall.INSTANCE)) )
      .installOn(inst);
  }
}

注意,上面这段最简Java agent代码不会对原有的代码产生干扰。对于已有的代码来说,附加的逻辑代码就仿佛是直接把硬编码插入到带有标注的方法处一样(类似于C++内联的效果——译者注)

现实情况是怎样的?

当然,我们在这里展示的基于agent的logger只是一个教学例子。通常情况下,那些覆盖面很广的框架也都会提供类似的 功能特性,直接调用即可。例如Spring或者Dropwizard的类似功能都很好用。然而,这些框架提供的功能基本上都着眼于处理(具体的)编程问 题。对大多数软件应用来说,这种思路也许还不错。再者,这些框架的思路有时也着眼于大规模的(应用)。如此一来,使用这些框架来做事,就有可能造成很多问 题,通常是会导致有漏洞的抽象逻辑,并可能进一步造成软件运维成本的爆炸性增长。这种假设绝对不是危言耸听,特别是在你的应用规模增长,需求变更频繁和分 叉的时候,又要用框架提供的功能来解决问题,就很可能出现上述麻烦。

相反的做法,我们可以去构建一个更加有针对性的框架或者类库,采用“挑选&融入”的风格,每次用一个完备的组件去替换原有的存在问题的组件。如果这样还不能解决问题,我们还可以干脆去搞一个自定义的解决 方案,并保证新的解决方案不会影响到应用中的原有代码。据我们所知,第二种做法对于JVM来说实现起来有些困难,主要原因是因为Java的强类型机制造成 的。不过,通过使用Java agents,克服这些类型限制也不是完全不可能的。

概括地来说,我认为所有涉及到横向操作的概念,都应该采用agent驱动的方式 来实现,并且应该使用针对性的框架,而不是采用那些大得吓死人的框架给你提供的内置方法。我也真心希望有更多的应用能够考虑采用上述的方法。在一般情况 下,使用agent来注册特定方法的listener,并加以实现,是完全可以满足需求的。根据我对大体积Java应用代码的观察,这种间接的模块编码方 法能够避免(模块间)的强耦合性。还有一个甜蜜的副作用就是,这种方法让代码的测试变得很容易。跟测试的原理相同,在启动应用的时候不加载agent,就 能按需关闭相应的应用特性(例如本文中的logging例子)。所有这些操作都不需要改动任何一行代码,也就不会造成程序的崩溃,因为JVM会自动忽略掉 那些在运行时无法解析的标注。安全、日志、缓存还有很多其他的方面,有很多理由,需要采用本文的方式来处理。因此,我们要说,采用agent,不要用框 架。

软件
前端设计
程序设计
Java相关