利用javaagent修改字节码实现aop
起因:
在用seata AT模式时想在全局事务提交后触发事件,解析undo_log执行一些操作,比如异步写入es。由于seata没有提供代码切入点,那么只能在项目中建一个和框架代码相同的包,再拷贝框架中的类源码放到包下,重写其中的源码,classpath优先加载原则达到覆盖原类目的。这种做法实属不够『优雅』。
那么怎么才能做到无声无息的对源码做到增强呢?
想到spring aop,但是源代码是new出来的,并且不受spring工厂托管,那么这条路走不通了。
又想到利用ClassLoader,自定义加载我们复写的代码,但是框架没法用到我们自定义的ClassLoader,此路也行不通。
那么只能在jvm加载该class解析成字节码的过程中,偷偷给原字节码替换掉,『偷天换日』计划就此形成。
利用java5提供的agent特性来完成这一操作。这篇文章:Java 5 特性 Instrumentation 实践 介绍了如何使用。
那么开始吧!~
-
准备工作,定义 Advice 接口、pom 引入 javassist 依赖
public interface Advice { void before(Object[] params); void after(Object[] params); } // pom.xml 引入依赖 <dependency> <groupId>org.javassist</groupId> <artifactId>javassist</artifactId> <version>3.27.0-GA</version> </dependency>
-
在应用中添加 premain 方法,为 agent 提供入口。类名随意,类似于main方法,放在哪个类里不重要。
public class Premain { public static void premain(String agentArgs, Instrumentation inst) { // Transformer 类提供转换方法 inst.addTransformer(new Transformer()); } }
-
添加Transformer类,按照规定重写 transform 方法
public class Transformer implements ClassFileTransformer { // 缓存的所有配置 private volatile Map<String, Pack> map = null; /** * 参数: * loader - 定义要转换的类加载器;如果是引导加载器,则为 null * className - 完全限定类内部形式的类名称和 The Java Virtual Machine Specification 中定义的接口名称。例如,"java/util/List"。 * classBeingRedefined - 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null * protectionDomain - 要定义或重定义的类的保护域 * classfileBuffer - 类文件格式的输入字节缓冲区(不得修改) * 返回: * 一个格式良好的类文件缓冲区(转换的结果),如果未执行转换,则返回 null。 * 抛出: * IllegalClassFormatException - 如果输入不表示一个格式良好的类文件 */ public byte[] transform(ClassLoader l, String className, Class<?> c, ProtectionDomain pd, byte[] b) { if (map == null) { // 加载配置,全局只加载一次 scanProps(); } // 包含配置的类需要做aop if (map.containsKey(className)) { // 拿出当前类的配置 Pack p = map.get(className); // 调用字节码翻译工具,返回做好aop的字节码 return TransformUtil.getNewByteCode(p.targetClassName, p.targetMethodName); } // 返回null 表示使用默认字节码,不做转换 return null; } /** * 初始化所有工程中的advice.properties配置文件,加载到本类的缓存中 * 配置示例: * io.seata.rm.datasource.undo.AbstractUndoLogManager.batchDeleteUndoLog=com.a.b.c.AbstractUndoLogManagerAdvice * key 配置到方法,value 配置 Advice 的实现类 */ private synchronized void scanProps() { if (map != null) { // double check return; } Map<String, Pack> m = new HashMap<>(); Enumeration<URL> urls = null; try { // 搜索所有项目下的 advice.properties 配置文件 urls = Thread.currentThread().getContextClassLoader().getResources("advice.properties"); } catch (IOException e) { e.printStackTrace(); } if (urls != null) { while (urls.hasMoreElements()) { InputStream inStream = null; Properties property = new Properties(); try { inStream = urls.nextElement().openStream(); property.load(inStream); property.stringPropertyNames().forEach(key -> { // 取出 key 的 类名 String targetClassName = key.substring(0, key.lastIndexOf(".")); // 取出 key 的 方法名 String targetMethodName = key.substring(key.lastIndexOf(".") + 1); // 取出 advice 实现类名 String adviceClassName = (String) property.get(key); // 缓存的key为 io/seata/rm.datasource/undo/AbstractUndoLogManager 格式 String k = targetClassName.replaceAll("\\.", "/"); Pack p = m.get(k); // 添加到配置缓存中 if (p == null) { p = new Pack(targetClassName, targetMethodName, adviceClassName); m.put(k, p); } else { p.add(targetMethodName, adviceClassName); } }); } catch (Exception e) { e.printStackTrace(); } finally { if (inStream != null) { try { inStream.close(); } catch (IOException e) { e.printStackTrace(); } } } } } // map 为 volatile 声明,保证全局可见 map = m; } static class Pack { String targetClassName; // 因为一个类可能有多个方法需要做 advice,这里用<methodName, AdviceImplName>保存 Map<String, String> targetMethodName = new HashMap<>(); public Pack(String targetClassName, String targetMethodName, String adviceClassName) { this.targetClassName = targetClassName; this.targetMethodName.put(targetMethodName, adviceClassName); } public void add(String targetMethodName, String adviceClassName) { this.targetMethodName.put(targetMethodName, adviceClassName); } } }
-
现在知道了哪些类、哪些方法、需要用哪些Advice 做代理,现在需要修改字节码把代理用TransformUtil 支持进去,javassist 如何使用可以参考这个文档:
http://www.javassist.org/tutorial/tutorial.htmlpublic class TransformUtil { /** * 目标类的目标方法做代理 * 支持普通方法、static方法。 * 不支持构造方法、重载方法、抽象方法 * * @param targetClassName 目标类全名 Examples: java.lang.String * @param method_advice <目标方法名 Examples: getBytes, Advice实现类的全名 Examples: java.lang.String> * @return 重写的类的字节码 */ public static byte[] getNewByteCode(String targetClassName, Map<String, String> method_advice) { try { ClassPool pool = ClassPool.getDefault(); CtClass ctClass = pool.get(targetClassName); // 循环代理一个类中的多个方法 for (Map.Entry<String, String> entry : method_advice.entrySet()) { String targetMethodName = entry.getKey(); String adviceClassName = entry.getValue(); CtMethod originalMethod = ctClass.getDeclaredMethod(targetMethodName); CtClass originalReturnType = originalMethod.getReturnType(); // advice name String adviceFieldName = targetMethodName + "$advice"; // 原始方法重命名后的名字 String newMethodName = "original$" + targetMethodName; // 添加Advice到类的成员变量 String adviceField = "private" + // 判断目标方法是否是static的 ((originalMethod.getMethodInfo().getAccessFlags() & AccessFlag.STATIC) == AccessFlag.STATIC ? " static " : " ") // 需要改成Advice接口存放的路径 + "final com.aop.javassist.Advice " + adviceFieldName + " = new " + adviceClassName + "(); "; ctClass.addField(CtField.make(adviceField, ctClass)); // ===================组装方法体开始=================== // 拼装新方法的方法体 StringBuilder methodBody = new StringBuilder(); methodBody.append("{ "); methodBody.append("Object[] params = $args; "); // 调用before methodBody.append("try { "); methodBody.append(adviceFieldName) .append(".before(params); "); methodBody.append("} catch (Exception e) { "); methodBody.append("e.printStackTrace(); "); methodBody.append("} "); // 获取原始方法的返回值类型,调用原始方法 if (!CtClass.voidType.equals(originalReturnType)) { methodBody.append(originalReturnType.getName()) .append(" result = ") .append(newMethodName) .append("($$); "); } else { methodBody.append(newMethodName) .append("($$); "); } // 调用after methodBody.append("try { "); methodBody.append(adviceFieldName) .append(".after(params); "); methodBody.append("} catch (Exception e) { "); methodBody.append("e.printStackTrace(); "); methodBody.append("} "); // 如果原方法有返回值 那么将它返回出去 if (!CtClass.voidType.equals(originalReturnType)) { methodBody.append("return result; "); } methodBody.append("} "); // ==================组装方法体结束================= // 更改原始方法名 originalMethod.setName(newMethodName); // 复制一个原始方法的镜像方法,并将新的方法体填充到镜像方法体中 CtMethod copyMethod = CtNewMethod.copy(originalMethod, targetMethodName, ctClass, null); copyMethod.setBody(methodBody.toString()); // 将新生成的方法添加到类中 ctClass.addMethod(copyMethod); } byte[] b = ctClass.toBytecode(); // 释放加载的CtClass内存 ctClass.detach(); return b; } catch (Exception e) { e.printStackTrace(); } return null; } }
这里做增强的思路是把原class方法改成一个新名字,再添加一个原方法声明相同的方法,在新方法中依次调用Advice的before、原方法、Advice的after,如果原方法有返回值,在调用after后把结果返回出去。
如果对 String 类的 split 方法 做 aop,那么String类大概会被改成这个样子:private final a.b.c.Advice split$advice = new a.b.c.AdviceImpl(); public String[] split(String regex) { Object[] params = new Object[]{regex}; try { split$advice.before(params); } catch (Exception e) { e.printStackTrace(); } String[] result = original$split(regex); try { split$advice.after(params); } catch (Exception e) { e.printStackTrace(); } return result; } /** * 原来的 split 方法,现在已被更名 */ public String[] original$split(String regex) { return split(regex, 0); }
原谅我并没有对String类的split方法做过测试,这里只是举一个栗子。
这个转换器目前不支持 构造方法、重载方法、抽象方法 转换,并且也没做校验、也算是不足之处吧。
-
接下来使以上的『一顿操作』生效
在工程中加入 advice.properties 配置文件,配置如下内容:需要代理的类名.需要代理的方法名=AdviceImpl类全路径名 例如 对 Test 类的 operation 方法 配置代理: a.b.c.d.Test.operation=d.c.b.a.AdviceImpl
工程中加入 Advice 实现类:
public class AdviceImpl implements Advice { @Override public void before(Object[] params) { System.out.println("before"); } @Override public void after(Object[] params) { System.out.println("after"); } }
工程的MANIFEST.MF 需要配置以下两项:
Manifest-Version: 1.0 Premain-Class: com.aop.premain.Premain
如果用mvn打包,可以在pom中添加插件做到打包后自动加入上述配置:
<plugin> <artifactId>maven-jar-plugin</artifactId> <version>3.0.2</version> <configuration> <archive> <manifestEntries> <!-- 本篇开始写的那个Premain类的全名--> <Premain-Class>com.aop.premain.Premain</Premain-Class> </manifestEntries> </archive> </configuration> </plugin>
打包工程
在工程的启动参数中添加:-javaagent:jar路径/test-aop.jar
这样,在工程启动后使用的Test类实例就是被修改过的了。
ps:-javaagent 配置的jar包可以是一个外部包含 premain 方法的jar,并且在外部jar中加入MANIFEST.MF配置。 在工程启动后会在这个jar中寻找 premain 方法并执行后续逻辑,得到的字节码加载在当前jvm中。这里为了方便,将本工程自己作为外部的 jar,自己 agent 自己。