曹工說Spring Boot源碼(25)– Spring註解掃描的瑞士軍刀,ASM + Java Instrumentation,順便提提Jar包破解
- 2020 年 3 月 27 日
- 筆記
寫在前面的話
相關背景及資源:
曹工說Spring Boot源碼(1)– Bean Definition到底是什麼,附spring思維導圖分享
曹工說Spring Boot源碼(2)– Bean Definition到底是什麼,咱們對着接口,逐個方法講解
曹工說Spring Boot源碼(3)– 手動註冊Bean Definition不比遊戲好玩嗎,我們來試一下
曹工說Spring Boot源碼(4)– 我是怎麼自定義ApplicationContext,從json文件讀取bean definition的?
曹工說Spring Boot源碼(5)– 怎麼從properties文件讀取bean
曹工說Spring Boot源碼(6)– Spring怎麼從xml文件里解析bean的
曹工說Spring Boot源碼(7)– Spring解析xml文件,到底從中得到了什麼(上)
曹工說Spring Boot源碼(8)– Spring解析xml文件,到底從中得到了什麼(util命名空間)
曹工說Spring Boot源碼(9)– Spring解析xml文件,到底從中得到了什麼(context命名空間上)
曹工說Spring Boot源碼(10)– Spring解析xml文件,到底從中得到了什麼(context:annotation-config 解析)
曹工說Spring Boot源碼(11)– context:component-scan,你真的會用嗎(這次來說說它的奇技淫巧)
曹工說Spring Boot源碼(12)– Spring解析xml文件,到底從中得到了什麼(context:component-scan完整解析)
曹工說Spring Boot源碼(13)– AspectJ的運行時織入(Load-Time-Weaving),基本內容是講清楚了(附源碼)
曹工說Spring Boot源碼(14)– AspectJ的Load-Time-Weaving的兩種實現方式細細講解,以及怎麼和Spring Instrumentation集成
曹工說Spring Boot源碼(15)– Spring從xml文件里到底得到了什麼(context:load-time-weaver 完整解析)
曹工說Spring Boot源碼(16)– Spring從xml文件里到底得到了什麼(aop:config完整解析【上】)
曹工說Spring Boot源碼(17)– Spring從xml文件里到底得到了什麼(aop:config完整解析【中】)
曹工說Spring Boot源碼(18)– Spring AOP源碼分析三部曲,終於快講完了 (aop:config完整解析【下】)
曹工說Spring Boot源碼(19)– Spring 帶給我們的工具利器,創建代理不用愁(ProxyFactory)
曹工說Spring Boot源碼(20)– 碼網恢恢,疏而不漏,如何記錄Spring RedisTemplate每次操作日誌
曹工說Spring Boot源碼(21)– 為了讓大家理解Spring Aop利器ProxyFactory,我已經拼了
曹工說Spring Boot源碼(22)– 你說我Spring Aop依賴AspectJ,我依賴它什麼了
曹工說Spring Boot源碼(23)– ASM又立功了,Spring原來是這麼遞歸獲取註解的元註解的
曹工說Spring Boot源碼(24)– Spring註解掃描的瑞士軍刀,asm技術實戰(上)
工程結構圖:
概要
上一篇,我們講了ASM基本的使用方法,具體包括:複製一個class、修改class版本號、增加一個field、去掉一個field/method等等;同時,我們也知道了怎麼才能生成一個全新的class。
但是,僅憑這點粗淺的知識,我們依然不太理解能幹嘛,本篇會帶大家實現簡單的AOP功能,當然了,學完了之後,可能你像我一樣,更困惑了,那說明你變強了。
本篇的核心是,在JVM加載class的時候,去修改class,修改class的時候,加入我們的aop邏輯。JVM加載class的時候,去修改class,這項技術就是load-time-weaver,實現load-time-weaver有兩種方式,這兩種方式,核心差別在於修改class的時機不同。
- 第一種,定製classloader,在把位元組碼交給JVM去defineClass之前,去織入切面邏輯
- 第二種,利用Java 官方提供的instrumentation機制,註冊一個類轉換器到 JVM。JVM在加載class的時候,就會傳入class的原始位元組碼數組,回調我們的類轉換器,我們類轉換器中可以修改原始位元組碼,並將修改後的位元組碼數組返回回去,JVM就會用我們修改後的位元組碼去defineClass了
在直接開始前,聲明本篇文章,是基於下面這篇文章中的代碼demo,我自己稍做了修改,並附上源碼(原文是貼了代碼,但是沒有直接提供代碼地址,不貼心啊)。
要達成的目標
目標就是給下面的測試類,加上一點點切面功能。
package org.xunche.app; public class HelloXunChe { public static void main(String[] args) throws InterruptedException { HelloXunChe helloXunChe = new HelloXunChe(); helloXunChe.sayHi(); } public void sayHi() throws InterruptedException { System.out.println("hi, xunche"); sleep(); } public void sleep() throws InterruptedException { Thread.sleep((long) (Math.random() * 200)); } }
我們希望,class在執行的時候,能夠打印方法執行的耗時,也就是,最終的class,需要是下面這樣的。
package org.xunche.app; import org.xunche.agent.TimeHolder; public class HelloXunChe { public HelloXunChe() { } public static void main(String[] args) throws InterruptedException { TimeHolder.start(args.getClass().getName() + "." + "main"); // 業務邏輯開始 HelloXunChe helloXunChe = new HelloXunChe(); helloXunChe.sayHi(); //業務邏輯結束 HelloXunChe helloXunChe = args.getClass().getName() + "." + "main"; System.out.println(helloXunChe + ": " + TimeHolder.cost(helloXunChe)); } public void sayHi() throws InterruptedException { TimeHolder.start(this.getClass().getName() + "." + "sayHi"); System.out.println("hi, xunche"); // 業務邏輯開始 this.sleep(); //業務邏輯結束 String var1 = this.getClass().getName() + "." + "sayHi"; System.out.println(var1 + ": " + TimeHolder.cost(var1)); } public void sleep() throws InterruptedException { TimeHolder.start(this.getClass().getName() + "." + "sleep"); // 業務邏輯開始 Thread.sleep((long)(Math.random() * 200.0D)); //業務邏輯結束 String var1 = this.getClass().getName() + "." + "sleep"; System.out.println(var1 + ": " + TimeHolder.cost(var1)); } }
所以,我們大概就是,要做下面的這樣一個切面:
@Override protected void onMethodEnter() { //在方法入口處植入 String className = getClass().getName(); String s = className + "." + methodName; TimeHolder.start(s); } @Override protected void onMethodExit(int i) { //在方法出口植入 String className = getClass().getName(); String s = className + "." + methodName; long cost = TimeHolder.cost(s); System.out.println(s + ": " + cost); }
但是,習慣了動態代理的我們,看上面的代碼可能會有點誤解。上面的代碼,不是在執行目標方法前,調用切面;而是:直接把切面代碼嵌入了目標方法。
想必大家都明確了要達成的目標了,下面說,怎麼做。
java agent/instrumentation機制
這部分,大家可以結合開頭那個鏈接一起學習。
首先,我請大家看看java命令行的選項。直接在cmd里敲java,出現如下:
看了和沒看一樣,那我們再看一張圖,在大家破解某些java編寫的軟件時,可能會涉及到jar包破解,比如:
大家可以使用jad這類反編譯軟件,打開jar包看下,看看裏面是啥:
可以發現,裏面有一個MANIFEST.MF文件,裏面指定了Premain-Class這個key-value,從這個名字,大家可能知道了,我們平時運行java程序,都是運行main方法,這裡來個premain,那這意思,就是在main方法前面插個隊唄?
你說的沒有錯,確實是插隊了,拿上面的破解jar包舉例,裏面的Premain-Class方法,對應的Agent類,反編譯後的代碼如下:
核心代碼就是圖裡那一行:
java.lang.instrument.Instrumentation public interface Instrumentation { /** * Registers the supplied transformer. All future class definitions * will be seen by the transformer, except definitions of classes upon which any * registered transformer is dependent. * The transformer is called when classes are loaded, when they are * {@linkplain #redefineClasses redefined}. and if <code>canRetransform</code> is true, * when they are {@linkplain #retransformClasses retransformed}. * See {@link java.lang.instrument.ClassFileTransformer#transform * ClassFileTransformer.transform} for the order * of transform calls. * If a transformer throws * an exception during execution, the JVM will still call the other registered * transformers in order. The same transformer may be added more than once, * but it is strongly discouraged -- avoid this by creating a new instance of * transformer class. * <P> * This method is intended for use in instrumentation, as described in the * {@linkplain Instrumentation class specification}. * * @param transformer the transformer to register * @param canRetransform can this transformer's transformations be retransformed * @throws java.lang.NullPointerException if passed a <code>null</code> transformer * @throws java.lang.UnsupportedOperationException if <code>canRetransform</code> * is true and the current configuration of the JVM does not allow * retransformation ({@link #isRetransformClassesSupported} is false) * @since 1.6 */ void addTransformer(ClassFileTransformer transformer, boolean canRetransform); ... }
這個類,就是官方jdk提供的類,官方的本意呢,肯定是讓大家,在加載class的時候,給大家提供一個機會,去修改class,比如,某個第三方jar包,我們需要修改,但是沒有源碼,就可以這麼干;或者是一些要統一處理,不方便在應用中耦合的功能:比如埋點、性能監控、日誌記錄、安全監測等。
說回這個方法,參數為ClassFileTransformer,這個接口,就一個方法,大家看看注釋:
/** * ... * * @param classfileBuffer the input byte buffer in class file format - must not be modified * * @throws IllegalClassFormatException if the input does not represent a well-formed class file * @return a well-formed class file buffer (the result of the transform), or <code>null</code> if no transform is performed. * @see Instrumentation#redefineClasses */ byte[] transform( ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException;
- classfileBuffer,就是原始class位元組碼數組,官方注釋說:一定不能修改它
- 返回的byte[]數組,注釋:一個格式正確的class文件數組,或者null,表示沒有進行轉換
別的也不多說了,反正就是:jvm給你原始class,你自己修改,還jvm一個改後的class。
所以,大家估計也能猜到破解的原理了,但我還是希望大家:有能力支持正版的話,還是要支持。
接下來,我們回到我們的目標的實現上。
agent模塊開發
完整代碼:https://gitee.com/ckl111/all-simple-demo-in-work/tree/master/java-agent-premain-demo
增加類轉換器
package org.xunche.agent; import org.objectweb.asm.*; import org.objectweb.asm.commons.AdviceAdapter; import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.Instrumentation; import java.security.ProtectionDomain; public class TimeAgentByJava { public static void premain(String args, Instrumentation instrumentation) { instrumentation.addTransformer(new TimeClassFileTransformer()); } }
類轉換器的詳細代碼如下:
private static class TimeClassFileTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { if (className.startsWith("java") || className.startsWith("jdk") || className.startsWith("javax") || className.startsWith("sun") || className.startsWith("com/sun")|| className.startsWith("org/xunche/agent")) { //return null或者執行異常會執行原來的位元組碼 return null; } // 1 System.out.println("loaded class: " + className); ClassReader reader = new ClassReader(classfileBuffer); // 2 ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); // 3 reader.accept(new TimeClassVisitor(writer), ClassReader.EXPAND_FRAMES); // 4 return writer.toByteArray(); } }
-
1處,將原始的類位元組碼加載到classReader中
ClassReader reader = new ClassReader(classfileBuffer);
-
2處,將reader傳給ClassWriter,這個我們沒講過,大概就是使用classreader中的東西,來構造ClassWriter;可以差不多理解為複製classreader的東西到ClassWriter中。
大家可以看如下代碼:
public ClassWriter(final ClassReader classReader, final int flags) { super(Opcodes.ASM6); symbolTable = new SymbolTable(this, classReader); ... }
這裡new了一個對象,SymbolTable。
SymbolTable(final ClassWriter classWriter, final ClassReader classReader) { this.classWriter = classWriter; this.sourceClassReader = classReader; // Copy the constant pool binary content. byte[] inputBytes = classReader.b; int constantPoolOffset = classReader.getItem(1) - 1; int constantPoolLength = classReader.header - constantPoolOffset; constantPoolCount = classReader.getItemCount(); constantPool = new ByteVector(constantPoolLength); constantPool.putByteArray(inputBytes, constantPoolOffset, constantPoolLength); ... }
大家直接看上面的注釋吧,
Copy the constant pool binary content
。反正吧,基本可以理解為,classwriter拷貝了classreader中的一部分東西,應該不是全部。為什麼不是全部,因為我試了下:
public static void main(String[] args) throws IOException { ClassReader reader = new ClassReader("org.xunche.app.HelloXunChe"); ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); byte[] bytes = writer.toByteArray(); File file = new File( "F:\gitee-ckl\all-simple-demo-in-work\java-agent-premain-demo\test-agent\src\main\java\org\xunche\app\HelloXunChe.class"); FileOutputStream fos = new FileOutputStream(file); fos.write(bytes); fos.close(); }
上面這樣,出來的class文件,是破損的,格式不正確的,無法反編譯。
-
3處,使用TimeClassVisitor作為writer的中間商,此時,順序變成了:
classreader –> TimeClassVisitor –> classWriter
-
4處,返回writer的位元組碼,給jvm;jvm使用該位元組碼,去redefine一個class出來
類轉換器的具體實現
public static class TimeClassVisitor extends ClassVisitor { public TimeClassVisitor(ClassVisitor classVisitor) { super(Opcodes.ASM6, classVisitor); } // 1 @Override public MethodVisitor visitMethod(int methodAccess, String methodName, String methodDesc, String signature, String[] exceptions) { MethodVisitor methodVisitor = cv.visitMethod(methodAccess, methodName, methodDesc, signature, exceptions); // 2 return new TimeAdviceAdapter(Opcodes.ASM6, methodVisitor, methodAccess, methodName, methodDesc); } } }
- 1處,visitMethod方法,會返回一個MethodVisitor,ASM會拿着我們返回的methodVisitor,去訪問當前這個方法
- 2處,new了一個適配器,TimeAdviceAdapter。
我們這裡的TimeAdviceAdapter,主要是希望在方法執行前後做點事,類似於切面,所以繼承了一個AdviceAdapter,這個AdviceAdaper,幫我們實現了MethodVisitor的全部方法,我們只需要覆寫我們想要覆蓋的方法即可。
比如,AdviceAdaper,因為繼承了MethodVisitor,其visitCode方法,會在訪問方法體時被回調:
@Override public void visitCode() { super.visitCode(); // 1 onMethodEnter(); } //2 protected void onMethodEnter() {}
- 1處,回調本來的onMethodEnter,是一個空實現,就是留給子類去重寫的。
- 2處,可以看到,空實現。
所以,我們最終的TimeAdviceAdaper,代碼如下:
public static class TimeAdviceAdapter extends AdviceAdapter { private String methodName; protected TimeAdviceAdapter(int api, MethodVisitor methodVisitor, int methodAccess, String methodName, String methodDesc) { super(api, methodVisitor, methodAccess, methodName, methodDesc); this.methodName = methodName; } @Override protected void onMethodEnter() { //在方法入口處植入 if ("<init>".equals(methodName)|| "<clinit>".equals(methodName)) { return; } String className = getClass().getName(); String s = className + "." + methodName; TimeHolder.start(s); } @Override protected void onMethodExit(int i) { //在方法出口植入 if ("<init>".equals(methodName) || "<clinit>".equals(methodName)) { return; } String className = getClass().getName(); String s = className + "." + methodName; long cost = TimeHolder.cost(s); System.out.println(s + ": " + cost); } }
這份代碼看着可還行?可惜啊,是假的,是錯誤的!寫asm這麼簡單的話,那我要從夢裡笑醒。
為啥是假的,因為:真正的代碼,是長下面這樣的:
看到這裡,是不是想溜了,這都啥玩意,看不懂啊,不過不要着急,辦法總比困難多。
類轉換器的真正實現方法
我們先裝個idea插件,叫:asm-bytecode-outline
。這個插件的作用,簡而言之,就是幫你把java代碼翻譯成ASM的寫法。在線裝不了的,可以離線裝:
裝好插件後,只要在我們的TimeAdviceAdapter類,點右鍵:
就會生成我們需要的ASM代碼,然後拷貝:
什麼時候拷貝結束呢?
基本上,這樣就可以了。
填坑指南
作為一個常年掉坑的人,我在這個坑裡也摸爬了整整一天。
大家可以看到,我們的java寫的方法里,是這樣的:
@Override protected void onMethodEnter() { //在方法入口處植入 if ("<init>".equals(methodName)|| "<clinit>".equals(methodName)) { return; } String className = getClass().getName(); // 1. String s = className + "." + methodName; TimeHolder.start(s); }
- 1處,訪問了本地field,methodName
所以,asm也幫我們貼心地生成了這樣的語句:
mv.visitFieldInsn(Opcodes.GETFIELD, "org/xunche/agent/TimeAgentByJava$TimeAdviceAdapter", "methodName", "Ljava/lang/String;");
看起來就像是說,訪問org/xunche/agent/TimeAgentByJava$TimeAdviceAdapter類的methodName字段。
但是,這是有問題的。因為,這段代碼,最終aop切面會被插入到target:
public class HelloXunChe { private String methodName = "abc"; public static void main(String[] args) throws InterruptedException { HelloXunChe helloXunChe = new HelloXunChe(); helloXunChe.sayHi(); } public void sayHi() throws InterruptedException { System.out.println("hi, xunche"); sleep(); } public void sleep() throws InterruptedException { Thread.sleep((long) (Math.random() * 200)); } }
我實話跟你說,這個target類里,壓根訪問不到org/xunche/agent/TimeAgentByJava$TimeAdviceAdapter類的methodName字段。
我是怎麼發現這個問題的,之前一直報錯,直到我在target後來加了這麼一行:
public class HelloXunChe { private String methodName = "abc"; ... }
哎,沒個大佬帶我,真的難。
當然,我是通過這個確認了上述問題,最終解決的思路呢,就是:把你生成的class,反編譯出來看看,看看是不是你想要的。
所以,我專門寫了個main測試類,來測試改後的class是否符合預期。
public class SaveGeneratedClassWithOriginAgentTest { public static void main(String[] args) throws IOException { //1 ClassReader reader = new ClassReader("org.xunche.app.HelloXunChe"); ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); reader.accept(new TimeAgentByJava.TimeClassVisitor(writer), ClassReader.EXPAND_FRAMES); byte[] bytes = writer.toByteArray(); // 2 File file = new File( "F:\ownprojects\all-simple-demo-in-work\java-agent-premain-demo\test-agent\src\main\java\org\xunche\app\HelloXunCheCopy2.class"); FileOutputStream fos = new FileOutputStream(file); fos.write(bytes); fos.close(); } }
- 1處這段代碼,就是模擬在classTransformer中的那段。
- 2處,將最終要返回給jvm的那段class位元組碼,寫到一個文件里,然後我們就可以反編譯,看看有問題沒。
所以,上面那段asm,大家如果看:
會發現,訪問methodname那句代碼,是這麼寫的:
mv.visitLdcInsn(methodName);
這就是,相當於直接把methodName寫死到最終的class里去了;最終的class就會是想要的樣子:
public void sayHi() throws InterruptedException { //1 TimeHolder.start(this.getClass().getName() + "." + "sayHi"); System.out.println("hi, xunche"); this.sleep(); // 2 String var1 = this.getClass().getName() + "." + "sayHi"; System.out.println(var1 + ": " + TimeHolder.cost(var1)); }
- 1/2處,直接把sayHi寫死到target了,而不是此時再去訪問field。
maven插件配置premain-class
插件中,配置Premain-Class
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>2.3.1</version> <configuration> <archive> <manifest> <addClasspath>true</addClasspath> </manifest> <manifestEntries> <Premain-Class> org.xunche.agent.TimeAgent </Premain-Class> </manifestEntries> </archive> </configuration> </plugin>
測試模塊開發
測試模塊,沒啥開發的,就只有那個target那個類。
運行
最終我是這麼運行的:
java -javaagent:agent.jar -classpath lib/*;java-agent-premain-demo.jar org/xunche/app/He lloXunChe
這裡指定了lib目錄,主要是agent模塊需要的jar包:
簡單的運行效果如下:
loaded class: org/xunche/app/HelloXunChe methodName = 0 <init> methodName = 0 main methodName = 0 sayHi methodName = 0 sleep hi, xunche org.xunche.app.HelloXunChe.abc: 129 org.xunche.app.HelloXunChe.abc: 129
總結
ASM這個東西,想要不熟悉位元組碼就去像我上面這樣傻瓜操作,坑還是比較多的,比較難趟。回頭有空再介紹位元組碼吧。我也是半桶水,大家一起學習吧。
本節源碼:
https://gitee.com/ckl111/all-simple-demo-in-work/tree/master/java-agent-premain-demo