写点什么

一起单测引起的项目加载失败惨案 | 京东云技术团队

  • 2023-05-05
    北京
  • 本文字数:4645 字

    阅读完需:约 15 分钟

一起单测引起的项目加载失败惨案 | 京东云技术团队

作者:京东科技 宋慧超

一、前言

最近在开发一个功能模块时,在功能自测阶段,通过使用单测测试功能的完整性,在测试单测联通性使用到静态方法测试时,发现单测报错,通过查阅解决方案发现需要对 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**的实现代码:


@Servicepublic 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 中最为重要的是 ClassPoolCtClassCtMethod 以及 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()toBytecodewriteFile(),类已经被 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 调用常量。


$开头的特殊字符


5、常用的 Java 插桩工具有哪些?

Java 插桩工具是一种能够修改 Java 字节码的工具,通过在应用程序运行时动态修改字节码来实现对程序的监控跟踪调试优化等功能。


四、总结

本文通过对由于 Javaassist 包缺失导致项目启动过程中 bean 加载失败的问题进行复现,并通过 demo 进行实例分析,解释了因为缺失 Javaassist 库导致的应用程序启动失败问题。并对 Javaassist 包相关知识进行介绍,后续会继续对 Javaassist 相关知识进行学习补充。


建议大家在构建 Maven 项目时,仔细检查 POM 文件中的依赖,确保没有漏掉任何必要的库,以免因为遗漏而引起不必要的问题。

发布于: 刚刚阅读数: 2
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
一起单测引起的项目加载失败惨案 | 京东云技术团队_spring_京东科技开发者_InfoQ写作社区