曹工說Spring Boot原始碼(25)-- Spring註解掃描的瑞士軍刀,ASM + Java Instrumentation,順便提提Jar包破解
寫在前面的話
相關背景及資源:
曹工說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,我自己稍做了修改,並附上原始碼(原文是貼了程式碼,但是沒有直接提供程式碼地址,不貼心啊)。
初探 Java agent
要達成的目標
目標就是給下面的測試類,加上一點點切面功能。
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的寫法。線上裝不了的,可以離線裝:
asm-bytecode-outline
裝好外掛後,只要在我們的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,大家如果看:
初探 Java agent
會發現,訪問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-d