此翻译已经合并到Byte Buddy官网,请去官网阅读最新版的文档。
https://bytebuddy.net/#/tutorial-cn
为什么要在运行时生成代码
Java语言带有相对严格的类型系统。Java要求所有变量和对象都属于特定类型,任何分配不兼容类型的尝试都会导致错误发生。当非法转换类型时,这些错误通常由 Java 编译器或至少由 Java运行时产生。这种严格的类型通常是可取的,例如在编写业务应用时。业务领域通常可以用一种明确的方式来描述,其中任何领域术语都代表它自己的类型。通过这种方式,我们可以使用 Java 来构建非常易读且健壮的应用程序,其中错误在靠近其源头的位置被就捕获。此外,Java 的类型系统是其在企业编程中流行的原因。
但是,通过强制执行其严格的类型系统,Java 强加了该语言在其他领域中范围的限制。例如,在编写供其他 Java 应用程序使用的通用库时,我们通常无法引用用户应用程序中定义的任何类型,因为在编译我们的库时,这些类型对我们来说是未知的。为了调用用户代码中的方法或访问用户代码中的字段,Java 类库自带了一个反射 API,使用反射 API,我们能够自省(introspect)未知类型并调用方法或访问字段。不幸的是,使用反射 API 有两个明显的缺点:
- 使用反射API要慢于硬编码方式的方法调用:首先,需要执行相当昂贵的方法查找来获取描述特定方法的对象。当一个方法被调用时,需要 JVM 运行本地代码(native code),这比直接调用需要更长的运行时间。然而,现代 JVM 知道一个叫做膨胀(inflation)的概念, 其中基于 JNI 的方法调用被生成的字节码替换,这些字节码被注入到动态创建的类中。(甚至 JVM 本身也使用代码生成!)毕竟,Java 的膨胀系统存在生成非常通用的代码的缺点,例如仅适用于装箱的基本类型,因此性能缺陷并未完全解决。
- 反射 API 破坏了类型安全性:尽管 JVM 能够通过反射调用代码,但反射 API 本身并不是类型安全的。当编写库时,只要我们不需要将反射 API 暴露给库的用户,这不是问题。毕竟,在编译期间我们不知道用户代码,也无法根据其类型校验我们的库代码。然而,有时需要向用户暴露反射 API,比如让库调用我们自己的方法。这就是使用反射 API 会出现问题的地方,因为 Java 编译器将拥有所有信息来验证我们程序的类型安全性。例如,在实现方法级安全性的库时,该库的用户希望该库仅在强制执行安全约束后调用方法。为此,库需要在用户移交此方法所需的参数后反射性地调用该方法。这样做的话,如果这些方法的参数与方法的反射调用匹配,则不再进行编译时类型校验。方法调用仍然是被校验过的,但检查延迟到运行时。这样的话,我们就弃用了ava 编程语言的一个重要特性。
这就是运行时代码生成可以帮助我们的地方。它允许我们在不弃用Java 的静态类型检查的情况下模拟一些通常只能在用动态语言编程时才能访问的功能。这样,我们可以两全其美,并进一步提高运行时性能。为了更好地理解这个问题,让我们看一下实现上述方法级安全库的示例。
编写一个安全的库
业务应用程序可能增长的很大,有时候很难在我们的应用程序中保持栈调用的概述。当我们的应用程序中存在仅应在特定条件下调用的重要方法时,这可能会成为问题。想象一个业务应用程序,它实现了一个允许从应用程序的数据库中删除所有内容的重置功能。
class Service {
void deleteEverything() {
// delete everything ...
}
}
这样的重置功能当让应该只能被管理员执行,而不能由应用程序的普通用户执行。通过分析我们的源码,我们当然可以确保这永远不会发生。然而,我们可以期待我们的应用程序在未来增长并且被改变。因此,我们想要实现一个更严格的安全模型,其中方法调用被对应用程序当前用户的显式检查保护。我们通常会使用一个安全框架来确保除了管理员之外的任何人都不会调用该方法。
为此,假设我们使用具有公共 API 的安全框架,如下所示:
@Retention(RetentionPolicy.RUNTIME)
@interface Secured {
String user();
}
class UserHolder {
static String user;
}
interface Framework {
<T> T secure(Class<T> type);
}
在这个框架中,Secured
注解应该用于标记只能由给定用户访问的方法。这里的UserHolder
用于全局定义当前登录的用户。Framework
接口允许通过调用给定类型的默认构造器来创建安全实例。当然,这个框架过于简单,但原则上这就是安全框架(例如流行的 Spring Security
)的工作方式。这个安全框架的一个特点是我们保留了用户的类型。根据我们框架接口的约定,我们承诺用户返回他接收的T
的任何实例。由于这一点,用户能够与他自己的类型进行交互,就像安全框架不存在一样。在测试环境中,用户甚至可以创建他的类的不安全实例并使用。你会同意这真的很方便!众所周知,此类框架与POJO(plain old Java objects)交互,这个术语是为了描述不将自己的类型强加给用户的非侵入性框架而创造的。
现在想象一下,我们知道传递给Framework
的类型只能T = Service
,并且该deleteEverything
方法用 @Secured("ADMIN")
注解. 这样,我们可以通过简单地让子类继Service承来轻松实现这种特定类型的安全版本:
class SecuredService extends Service {
@Override
void deleteEverything() {
if(UserHolder.user.equals("ADMIN")) {
super.deleteEverything();
} else {
throw new IllegalStateException("Not authorized");
}
}
}
用这个额外的类,我们可以按如下方式实现框架:
class HardcodedFrameworkImpl implements Framework {
@Override
public <T> T secure(Class<T> type) {
if(type == Service.class) {
return (T) new SecuredService();
} else {
throw new IllegalArgumentException("Unknown: " + type);
}
}
}
当然,这种实现并没有什么用。通过secure
方法签名,我们建议该方法可以为任何类型提供安全性,但实际上,一旦遇到Service
以外的其他类型,将会抛出异常。此外,这将需要我们的安全库在编译时了解这个特定Service
类型。显然,这不是实现框架的可行方案。那么我们如何解决这个问题呢?好吧,因为这是一个关于代码生成库的教程,你已经猜到了答案:当我们的安全框架通过调用secure方法第一次配到这个Service
类的时候,我们会根据需要在运行时创建一个子类。通过代码生成,我们可以采用任何给定的类型,在运行时对其进行子类化并覆写我们想要保护的方法。在我们的例子中,我们覆写了所有带 @Secured
注解的方法,并从注解的user
属性中读取所需的用户。许多流行的Java框架都是使用类似的方法实现的。
一般信息
在我们全面学习代码生成和Byte Buddy之前,请注意你应该谨慎使用代码生成。Java 类型对于 JVM 来说是相当特殊的,通常不会被GC回收。因此,永远不要过度使用代码生成,除非生成代码是用来解决问题的唯一方式。但是,如果你需要像前面的示例一样增强未知类型,代码生成很可能是你唯一的选择。框架安全、事务管理、对象关系映射或模拟对于代码生成库的用户来说是典型的用法。
当然,在JVM上,Byte Buddy并不是第一个代码的库。但是,我们相信 Byte Buddy 知道一些其他框架无法应用的技巧。通过专注于其领域特定语言和注解的使用,以声明方式工作是Byte Buddy的总体目标。我们所知道的其他的JVM代码生成库中没有 以这种方式工作的。不过,你可能想看一下其他的代码生成框架,以找出最合适的。其中,以下库在Java领域比较流行:
- Java代理 Java类库自带的一个代理工具包,它允许创建实现了一组给定接口的类。这个内置的代理很方便,但是受到的限制非常多。例如,上面提到的安全框架不能以这种方式实现,因为我们想要扩展类而不是接口。
- cglib 该代码生成库是在Java开始的最初几年实现的,不幸的是,它没有跟上Java平台的发展。尽管如此,cglib仍然是一个相当强大的库,但它是否积极发展变得很模糊。出于这个原因,许多用户已不再使用它。
- Javassist 该库带有一个编译器,该编译器采用包含Java源码的字符串,这些字符串在应用程序运行时被翻译成Java字节码。这是非常雄心勃勃的,原则上是一个好主意,因为Java源代码显然是描述Java类的非常的好方法。但是,Javassist编译器在功能上无法与javac编译器相比,并且在动态组合字符串以实现更复杂的逻辑时容易出错。此外,Javassist带有一个代理库,它类似于Java的代理程序,但允许扩展类并且不限于接口。然而,Javassist代理工具的范围在其API和功能方面同样受限限制。
自己评估这些框架,但我们相信Byte Buddy提供了功能性和便利性,否则你将徒劳。Byte Buddy 附带了一种富有表现力的领域特定语言,它允许通过编写纯 Java 代码和为您自己的代码使用强类型来创建非常自定义的运行时类。同时,Byte Buddy 对定制非常开放,不会限制您使用开箱即用的功能。如果需要,您甚至可以为任何已实现的方法定义自定义字节码。但即使不知道字节码是什么或它是如何工作的,您也可以在不深入研究框架的情况下做很多事情。例如,您是否看过Hello World!示例?使用Byte Buddy就是这么简单。
当然,一个优雅的API并不是选择代码生成库时要考虑的唯一特性。对于许多应用程序而言,生成代码的运行时特性更有可能决定最好的选择。除了生成的代码本身的运行时之外,创建动态类的运行时也需要考虑。声称我们是最快的!但是很难为库的速度提供一个有效的指标。尽管如此,我们还是想提供这样一个指标作为基本方向。但是,请记住,这些结果不一定会转化为你更具体的用例,你应该进行自己的指标。
在讨论我们的指标之前,让我们看一下原始数据。下表显示了一个操作的平均运行时间(单位:纳秒),其中标准差附在括号中:
baseline | Byte Buddy | cglib | Javassist | Java proxy | |
---|---|---|---|---|---|
trivial class creation | 0.003 (0.001) | 142.772 (1.390) | 515.174 (26.753) | 193.733(4.430) | 70.712 (0.645) |
interface implementation | 0.004 (0.001) | 1’126.364 (10.328) | 960.527 (11.788) | 1’070.766(59.865) | 1’060.766 (12.231) |
stub method invocation | 0.002 (0.001) | 0.002 (0.001) | 0.003 (0.001) | 0.011 (0.001) | 0.008 (0.001) |
class extension | 0.004 (0.001) | 885.983 (7.901) 5’408.329 (52.437) | 1’632.730 (52.737) | 683.478 (6.735) | - |
super method invocation | 0.004 (0.001) | 0.004 (0.001) 0.004 (0.001) | 0.021 (0.001) | 0.025 (0.001) | - |
与静态编译器类似,代码生成库面临生成快速代码和快速生成代码之间的权衡。在这些相互冲突的目标之间进行选择时,Byte Buddy的主要关注点在于以最少的运行时间生成代码。通常,类型创建或修改不是程序里的常见步骤,并且不会显著影响任何长时间运行的应用程序;特别是因为类加载或类检测是运行此类代码时最耗时且不可避免的步骤。
上表中的第一个基准测试测试了类库在不实现或者覆写任何方法的情况下,子类化Object
的运行时间。这让我们对库在代码生成中的一般开销有一个印象。在这个基准测试中,由于假定始终扩展接口时会有优化,所以Java代理的性能要优于其他库。Byte Buddy还要检查类的泛型类型和注解,导致需要额外的时间。这种性能开销在创建类的其他基准测试中也可以看见。基准测试(2a)显示了用于创建(和加载)实现了具有18个方法的单个接口的类的测试运行时间,(2b)显示了为此类生成的方法的执行时间。类似地,(3a) 显示了继承一个实现了相同的18个方法的类的基准测试。由于可能对始终执行超类方法的拦截器的优化,所以Byte Buddy提供了两个基准测试。有时候创建类时会牺牲一些时间,Byte Buddy创建类的执行时间通常会达到基线,这意味着检测不会产生任何开销。应该注意的是,如果元数据的处理被禁用,Byte Buddy在创建类时要胜过其他任何代码生成库。由于生成代码的时间与程序的总运行时间相比相当少,这种禁用是不可用的,因为这样会获得很少的性能提升,同时让库代码变得复杂。
最后,需要注意,我们的指标衡量的是经过及时编译器优化过的代码性能。如果你的代码是偶然执行,将会比上面指标预测的要差。在这种情况下,你的代码一开始并不是注重性能的。这些指标的代码会随着Byte Buddy一起发布,你可以在自己的电脑上运行,上述指标可能会有变化,这取决于电脑的处理能力。鉴于此,不要绝对解释上述指标,而是将而是将它们视为比较不同库的相对衡量标准。在后面开发Byte Buddy时,我们会监控这些指标,从而避免添加新功能时引起的性能损失。
在下面的教程中,我们将逐步解释Byte Buddy的功能。我们将从大多数用户最有可能使用的更通用的功能开始。然后,我们将考虑越来越高级的主题,并简要介绍 Java 字节码和类文件格式。如果你快进到后面的材料,请不要气馁!在不了解任何JVM细节的情况下,使用Byte Buddy的标准API,你几乎可以做任何事情。要了解标准API,请继续阅读。