作者:京东科技 宋慧超
一、前言
最近在开发一个功能模块时,在功能自测阶段,通过使用单测测试功能的完整性,在测试单测联通性使用到静态方法测试时,发现单测报错,通过查阅解决方案发现需要对 Javaassist 包进行排包或者升版本处理。通过排包解决掉单测报错,在部署项目时发现频繁报 bean 注入失败问题,最终定位发现是因为 对Javaassist包排包引起的bean加载失败 。故而对 Javaassist 包相关知识进行学习整理文章如下。
单测相关报错信息如下:
Powermock - java.lang.IllegalStateException: Failed to transform class
解决单测报错的文章链接:
https://stackoverflow.com/questions/32854688/powermock-java-lang-illegalstateexception-failed-to-transform-class
二、问题复现
1、前期准备
首先使用了Spring框架新建一个demo,并写一个简单测试类对问题进行复现。
UserService 的定义:
public interface UserService {
void save(User user);
}
UserServiceImpl 的实现代码:
@Service
public class UserServiceImpl implements UserService {
private UserDao userDao;
@Autowired
public UserServiceImpl(UserDao userDao) {
this.userDao = userDao;
}
@Override
public void save(User user) {
userDao.save(user);
}
}
这里我们使用了Spring框架的 @Service 和 @Autowired 注解,以便让Spring框架自动装配 UserDao 实例。
但是,在我们的POM文件中,虽然我们添加了对Spring框架的依赖,但是并没有添加 Javaassist 库的依赖。而 UserServiceImpl 中确实使用了 Javaassist 库来进行字节码操作, UserServiceImpl 的具体实现代码:
public class UserServiceImpl implements UserService {
// ...
private static final String USER_CLASS_NAME = "com.example.User";
private static final Class<?> USER_CLASS;
static {
try {
USER_CLASS = Class.forName(USER_CLASS_NAME);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
public void save(User user) {
try {
// 创建一个ClassPool对象
ClassPool cp = ClassPool.getDefault();
// 从ClassPool中获取一个CtClass对象
CtClass ctClass = cp.get(USER_CLASS_NAME);
// 获取无参构造器
CtConstructor ctConstructor = ctClass.getDeclaredConstructor(new CtClass[]{});
// 获取save方法
CtMethod saveMethod = ctClass.getDeclaredMethod("save");
// 生成代码
saveMethod.insertBefore("{System.out.println(\"插入代码前\");}");
saveMethod.insertAfter("{System.out.println(\"插入代码后\");}");
// 生成新的字节码并装载到内存
Class<?> targetClass = ctClass.toClass();
Object instance = targetClass.newInstance();
// 调用save方法
Method method = targetClass.getMethod("save", USER_CLASS);
method.invoke(instance, user);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
在这段代码中,我们通过 Javaassist 库生成了一个新的字节码,并使用 反射机制 将其实例化,并在调用 save() 方法前后插入了一些代码。但是,由于 Javaassist 库缺失,导致项目在 启动 过程中无法正确加载 UserServiceImpl 的实例,从而出现了下述错误信息。
2、报错信息
在部署程序时发现,应用无法正常启动,并出现如下错误信息:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'userService' defined in file [C:\workspace\project\target\classes\com\example\UserServiceImpl.class]: Instantiation of bean failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.example.UserService]: No default constructor found; nested exception is java.lang.NoSuchMethodException: com.example.UserService.<init>()
从错误信息中我们可以看到,应用在创建 UserService 的实例时遇到了问题,无法实例化成功。
3、解决方案
为了修复这个问题,我们需要在POM文件中加入对 Javaassist 库的依赖:
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>
添加依赖后,重新编译并部署应用程序即可 正常运行 。
三、Javaassist包
1、什么是Javaassist?
Javaassist 是由东京工业大学数学和计算机科学系的 Shigeru Chiba (千叶滋)教授创造的。 Javaassist 作为实现动态字节码生成的一个开源类库,极大地简化了 Java 开发者对底层字节码操作的难度,让开发者能够更加轻松地在 运行时动态 生成类、修改类文件来达到轻量级 AOP、ORM、基于代理的远程方法调用等功能。
( Javaassist 已加入了开放源代码 JBoss 应用服务器项目,通过使用 Javaassist 对字节码操作为 JBoss 实现动态AOP框架。)
2、 什么是动态编程?
动态编程 是相对于静态编程而言的,平时我们讨论比较多的就是静态编程语言,例如Java,与动态编程语言,例如JavaScript。那二者有什么明显的区别呢?简单的说就是在静态编程中,类型检查是在 编译时 完成的,而 动态编程 中类型检查是在 运行时 完成的。所谓 动态编程 就是绕过编译过程在运行时进行操作的技术,在Java中有如下几种方式:
• 反射
这个搞Java的应该比较熟悉,原理也就是通过在 运行时 获得类型信息然后做相应的操作。由于Java执行过程中是将类型载入虚拟机中的,在运行时我们就可以 动态 获取到所有类型的信息。只能 获取 却不能 修改 类型信息。
• 动态编译
动态编译 是从Java 6开始支持的,主要是通过一个 JavaCompiler 接口来完成的。通过这种方式我们可以 直接编译 一个已经存在的java文件,也可以在内存中 动态生成 Java代码,动态编译执行。
• 调用JavaScript引擎
早在Java 6就加入了对Script(JSR223)的支持。这是一个脚本框架,提供了让脚本语言来访问Java内部的方法。你可以在运行的时候找到脚本引擎,然后调用这个引擎去执行脚本。这个脚本API允许你为脚本语言提供Java支持。
• 动态生成字节码
这种技术通过操作Java字节码的方式在JVM中生成新类或者对已经加载的类动态添加元素。
3、动态编程解决什么问题?
在静态语言中引入动态特性,主要是为了解决一些使用场景的痛点。其实完全使用静态编程也办的到,只是付出的代价比较高,没有动态编程来的优雅。例如依赖注入框架Spring使用了反射,而Dagger2 却使用了代码生成的方式(APT)。
例如:
a: 在那些依赖关系需要动态确认的场景: b: 需要在运行时动态插入代码的场景,比如动态代理的实现。 c: 通过配置文件来实现相关功能的场景
4、Javassit使用方法
javassist 是 jboss 的一个子项目,其主要的优点,在于 简单 ,而且 快速 。直接使用java编码的形式,而不需要了解虚拟机指令,就能 动态 改变类的结构,或者 动态 生成类。
操作java字节码的工具有两个比较流行,一个是 ASM ,一个是 Javassit 。
◦ ASM : 直接 操作字节码指令, 执行效率高 ,要求使用者 掌握 Java类字节码文件格式及指令,对使用者的要求比较高。
◦ Javassit 提供了更高级的API,执行效率相对较 差 ,但无需掌握字节码指令的知识,对使用者要求较低。
应用层面来讲一般使用建议 优先 选择Javassit,如果后续发现Javassit 成为了整个应用的效率瓶颈的话可以再考虑ASM。当然如果开发的是一个 基础类库 ,或者 基础平台 ,还是直接使用ASM吧,相信从事这方面工作的开发者能力应该比较高。
Javassist 中最为重要的是 ClassPool , CtClass , CtMethod 以及 CtField 这几个类。

• ClassPool :一个基于 HashMap 实现的 CtClass 对象容器,其中 键 是类名称, 值 是表示该类的 CtClass 对象。默认的 ClassPool 使用与底层JVM相同的类路径,因此在某些情况下,可能需要向 ClassPool 添加类路径或类字节。
◦ getDefault (): 返回默认的 ClassPool ,单例模式,一般通过该方法创建我们的 ClassPool ;
◦ appendClassPath(ClassPath cp), insertClassPath(ClassPath cp) : 将一个 ClassPath 加到类搜索路径的末尾位置或插入到起始位置。通常通过该方法写入额外的类搜索路径,以解决多个类加载器环境中找不到类问题;
◦ importPackage(String packageName) :导入包;
◦ makeClass(String classname) :创建一个空类,没有变量和方法,后序通过 CtClass 的函数进行添加;
◦ get(String classname)、getCtClass(String classname) : 根据类路径名获取该类的 CtClass 对象,用于后续的编辑。
• CtClass :表示一个类,这些CtClass对象可以从ClassPool获得。
◦ debugDump ; String类型,如果生成.class文件,保存在这个目录下。
◦ setName(String name) : 给类重命名;
◦ setSuperclass(CtClass clazz) : 设置父类;
◦ addField(CtField f, Initializer init) : 添加字段(属性),初始值见CtField;
◦ addMethod(CtMethod m) : 添加方法(函数);
◦ toBytecode() : 返回修改后的字节码。需要注意的是一旦调用该方法,则无法继续修改CtClass;
◦ toClass() : 将修改后的CtClass加载至当前线程的上下文类加载器中,CtClass的toClass方法是通过调用本方法实现。需要注意的是一旦调用该方法,则无法继续修改已经被加载的CtClass;
◦ writeFile(String directoryName): 根据CtClass生成 .class 文件;
◦ defrost() : 解冻类,用于使用了toclass()、toBytecode、writeFile(),类已经被JVM加载,Javassist冻结CtClass后;
◦ detach() : 避免内存溢出,从ClassPool中移除一些不需要的CtClass。
• CtMethods :表示类中的方法。
◦ insertBefore(String src) :在方法的起始位置插入代码;
◦ insertAfter(String src) :在方法的所有 return 语句前插入代码以确保语句能够被执行,除非遇到exception;
◦ insertAt(int lineNum, String src) :在指定的位置插入代码;
◦ addCatch(String src, CtClass exceptionType) :将方法内语句作为try的代码块,插入catch代码块src;
◦ setBody(String src) :将方法的内容设置为要写入的代码,当方法被 abstract修饰时,该修饰符被移除;
◦ setModifiers(int mod) :设置访问级别,一般使用Modifier调用常量;
◦ invoke(Object obj, Object... args) :反射调用字节码生成类的方法。
• CtFields :表示类中的字段。
◦ CtField(CtClass type, String name, CtClass declaring) :构造函数,添加字段类型,名称,所属的类;
◦ CtField.Initializer constant() :CtClass使用addField时初始值的设置;
◦ setModifiers(int mod) :设置访问级别,一般使用Modifier调用常量。
• $开头的特殊字符
|
符号 |
具体含义 |
|
$0, $1, $2, … |
$0=this,$1表示方法的第一个参数,依次类推,如果方法是静态的,则 $0 不可用 |
|
$args |
方法参数数组.它的类型为 Object[],$args[0]=1 , 1,1,args[1]=$2 |
|
$r |
返回结果的类型,用于强制类型转换 |
|
$w |
包装器类型,用于强制类型转换,当返回值是包装类型时,可以用此来强转 |
|
$_ |
返回值,一般在insertAfter中用到,用于得到原方法的返回值 |
|
$slg |
参数类型数组,$sig[0]表示第一个参数类型 |
|
$type |
返回值类型,一般在insertAfter中用到,即$_的类型 |
|
$class |
$0或this的类型 |
|
$e |
异常类型 |
5、常用的Java插桩工具有哪些?
Java 插桩工具 是一种能够修改 Java 字节码 的工具,通过在应用程序运行时 动态 修改字节码来实现对程序的 监控 、 跟踪 、 调试 和 优化 等功能。
|
工具 |
字节码抽象级别 |
具体描述 |
|
ASM、BCEL |
低级 |
库需要直接在字节码级别上进行操作。通常,它们提供大多数功能丰富的功能,但与其他字节码操作工具相比,它们的使用也最复杂。 |
|
Javaassist |
中级 |
库提供了字节码的某种抽象级别,并简化了其修改。例如,代替修改字节码,可以使用类似于Java的语法进行更改,然后将其编译为字节码,然后由使用的库修改为原始字节码。通常,它们缺少修改后的代码验证的功能-这意味着,错误可能在修改准备过程中被忽略,然后在运行时被发现。 |
|
AspectJ、CGLib |
高级 |
库使用高级指令进行操作,并且通常配备有用于语法验证的工具集。不幸的是,从修改后的字节码进行的最高抽象化通常会导致某些功能的丧失,这些功能仅在直接修改字节码时可用。 |
四、总结
本文通过对由于 Javaassist 包缺失导致项目启动过程中 bean 加载失败的问题进行复现,并通过demo进行实例分析,解释了因为缺失 Javaassist 库导致的应用程序启动失败问题。并对 Javaassist 包相关知识进行介绍,后续会继续对 Javaassist 相关知识进行学习补充。
建议大家在构建Maven项目时,仔细检查POM文件中的依赖,确保没有漏掉任何必要的库,以免因为遗漏而引起不必要的问题。