在我們系統中有這麼一個需求,業務方會通過mq將一些使用者資訊傳給我們,我們的服務處理完後,再將資訊轉發給子系統。mq的內容如下:
@Data
public class Person {
//第一部分
private Integer countryId;
private Integer companyId;
private String uid;
//第二部分
private User userBaseInfo;
private List<UserContact> contactList;
private List<UserAddress> addressList;
private UserEducation educationInfo;
private UserProfession professionInfo;
private List<Order> orderList;
private List<Bill> billList;
private List<UserMerchant> merchantList;
private List<UserOperate> operateList;
private BeneficialOwner beneficialOwnerInfo;
}
主要分為兩部分,第一部分是使用者id,這部分用於唯一標識一個使用者,不會改變。第二部分是一些基礎資訊,賬單、訂單、聯絡方式、地址等等,這部分資訊內容經常增加。
後面業務新增了一個邏輯,會對第二部某些資訊進行剔除,最後這部分資訊如果還有,才轉發到子系統。所以開發同學新增這麼一個很長的條件判斷:
public static boolean isNull(BizData bizData) {
return CollectionUtils.isEmpty(bizData.getBillList()) && CollectionUtils.isEmpty(bizData.getOrderList()) && CollectionUtils.isEmpty(bizData.getAddressList()) && CollectionUtils.isEmpty(bizData.getContactList()) && bizData.getEducationInfo() == null && bizData.getProfessionInfo() == null && bizData.getUserBaseInfo() == null && CollectionUtils.isEmpty(bizData.getMerchantList()) && CollectionUtils.isEmpty(bizData.getOperateList()) && bizData.getBeneficialOwnerInfo() == null;
}
在review程式碼的時候,發現這裡是一個「坑」,是一個會變化的點,以後新增資訊,很可能會漏了來改這裡,在我的開發過程中,最擔心的就是遇到這些會變化點又寫死邏輯的,過段時間我就會忘記,如果換個人接手,那更難以發現,容易出現bug。因為這個條件判斷並不會自動隨著我們新增欄位而自動修改,完全靠人記憶,容易遺漏。
那有沒有辦法做到新增資訊不需要修改這裡嗎,也就是isNull方法可以自動動態判斷屬性是否為空呢?
首先我們都會想到反射,通過反射可以讀取class所有欄位,每次處理都反射判斷一下欄位值是否為空即可做到動態判斷。但反射的效能太低了,對於我們來說這是個呼叫量非常大的方法,儘量做到不損失效能,所以反射不在本次考慮範圍內。
既然有不變和變化的兩部分,那麼我們可以先將其分離,將不變的抽取到一個基礎類別去。為了簡化程式碼,第二部分我們只保留兩個屬性。
@Data
public class PersonBase {
//第一部分
private Integer countryId;
private Integer companyId;
private String uid;
}
@Data
public class Person extend PersonBase {
//第二部分...
private User userBaseInfo;
private List<UserContact> contactList;
}
要動態生成isNull方法,可以先從結果反推是怎麼樣的。可以有如下兩種方式:
1、在原Person類新增一個isNull方法,這種方式的特點是我們可以直接通過物件直接呼叫方法,如:
@Data
public class Person extend PersonBase {
private User userBaseInfo;
private List<UserContact> contactList;
public boolean isNull() {
return this.userBaseInfo != null && this.contactList != null;
}
}
2、動態新增一個類,動態新增一個isNull方法,引數是BizData。這種方式無法通過Preson物件呼叫方法,甚至無法直接通過生成類呼叫方法,因為動態類的名稱我們都無法預知。如:
public class Person$Generated {
public boolean isNull(BizData bizData) {
return bizData.getUserBaseInfo() != null && bizData.getContactList() != null;
}
}
這就是我們本篇要解決的問題,通過靜態/動態編譯技術生成程式碼。這裡靜態是指「編譯期」,也就是類和方法在編譯期間就存在了,動態是指「執行時」,意思編譯期間類還不存在,等程式執行時才被載入,連結,初始化。
這兩種方式大家實際都經常接觸到,lombok可以幫我們生成getter/setter,本質就是在編譯期為類新增方法,spring無處不在的動態代理就是執行時生成的類。
我們先來看動態編譯,因為動態編譯我們都比較熟,也比較簡單,在spring中隨處可見,例如我們熟悉的動態代理類就是動態生成的。
我們編寫的java程式碼會先經過編譯稱為位元組碼,位元組碼再由jvm載入執行,所以動態生成類就是要編寫相應的位元組碼。
但由於java位元組碼太複雜了,需要熟悉各種位元組碼指令,一般我們不會直接編寫位元組碼程式碼,會藉助位元組碼框架或工具來生成。例如檢視簡單的hello world類的位元組碼,idea -> view -> show bytecode。
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}
ASM是一個通用的 Java 位元組碼操作和分析框架。它可用於直接以二進位制形式修改現有類或動態生成類。ASM 提供了一些常見的位元組碼轉換和分析演演算法,可以從中構建自定義的複雜轉換和程式碼分析工具。ASM 提供與其他 Java 位元組碼框架類似的功能,但重點關注效能。因為它的設計和實現儘可能小且儘可能快,所以它非常適合在動態系統中使用(但當然也可以以靜態方式使用,例如在編譯器中)。
接下來我們用asm來生成hello world,如下:
public class HelloWorldGenerator {
public static void main(String[] args) throws Exception {
// 建立一個ClassWriter,用於生成位元組碼
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES);
// 定義類的頭部資訊
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "HelloWorld", null, "java/lang/Object", null);
// 生成預設建構函式
MethodVisitor constructor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
constructor.visitCode();
constructor.visitVarInsn(Opcodes.ALOAD, 0);
constructor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
constructor.visitInsn(Opcodes.RETURN);
constructor.visitMaxs(1, 1);
constructor.visitEnd();
// 生成main方法
MethodVisitor mainMethod = cw.visitMethod(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
mainMethod.visitCode();
// 列印"Hello, World!"到控制檯
mainMethod.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mainMethod.visitLdcInsn("Hello, World!");
mainMethod.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mainMethod.visitInsn(Opcodes.RETURN);
mainMethod.visitMaxs(2, 2);
mainMethod.visitEnd();
// 完成類的生成
cw.visitEnd();
// 將生成的位元組碼寫入一個類檔案
byte[] code = cw.toByteArray();
MyClassLoader classLoader = new MyClassLoader();
Class<?> helloWorldClass = classLoader.defineClass("HelloWorld", code);
// 建立一個範例並執行main方法
helloWorldClass.getDeclaredMethod("main", String[].class).invoke(null, (Object) new String[0]);
}
// 自定義ClassLoader用於載入生成的類
private static class MyClassLoader extends ClassLoader {
public Class<?> defineClass(String name, byte[] b) {
return defineClass(name, b, 0, b.length);
}
}
}
上面的程式碼我是用chatgpt生成的,只需要輸入:「幫我用java asm位元組碼框架生成一個hello world,並註釋每行程式碼寫明它的作用。」
相比直接編寫位元組碼指令,asm將其封裝成各種類和方法,方便我們理解和編寫,實際上asm還是比較底層的框架,所以許多框架會再它的基礎上繼續封裝,如cglib,byte buddy等。
可以看到生成的結果和我們自己編寫的是一樣的。
接下來我們就用asm來動態生成上面的isNull方法,由於目標類是動態生成的,類名我們都不知道,但我們最終是要呼叫它的isNull方法,這怎麼辦呢?
我們可以定義一個介面,然後動態生成的類實現它,最終通過介面來呼叫它,這就是介面的好處之一,我們可以不關注具體類是誰,內部怎麼實現。
如定義介面如下:
public interface NullChecker<T> {
/**
* 引數固定為origin
*
* @param origin 名稱必須為origin
*/
Boolean isNull(T origin);
}
這是個泛型介面,也就是所有型別都可以這麼用。isNull方法引數名稱必須為origin,因為在生成位元組碼時寫死了這個名稱。
接下來編寫核心的生成方法,如下:
public class ClassByteGenerator implements Opcodes {
public static byte[] generate(Class originClass) {
ClassWriter classWriter = new ClassWriter(0);
MethodVisitor methodVisitor;
//將.路徑替換為/
String originClassPath = originClass.getPackage().getName().replace(".", "/") + "/" + originClass.getSimpleName();
//動態生成類的名稱:原類$ASMGenerated
String generateClassName = originClass.getSimpleName() + "$ASMGenerated";
String generateClassPatch = ClassByteGenerator.class.getPackage().getName().replace(".", "/") + "/" + generateClassName;
String nullCheckerClassPath = NullChecker.class.getPackage().getName().replace(".", "/") + "/" + NullChecker.class.getSimpleName();
classWriter.visit(V1_8, ACC_PUBLIC | ACC_SUPER, generateClassPatch, null, "java/lang/Object", new String[]{nullCheckerClassPath});
classWriter.visitSource(generateClassName + ".java", null);
{
methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
methodVisitor.visitCode();
Label label0 = new Label();
methodVisitor.visitLabel(label0);
methodVisitor.visitLineNumber(7, label0);
methodVisitor.visitVarInsn(ALOAD, 0);
methodVisitor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
methodVisitor.visitInsn(RETURN);
Label label1 = new Label();
methodVisitor.visitLabel(label1);
methodVisitor.visitLocalVariable("this", "L" + generateClassPatch + ";", null, label0, label1, 0);
methodVisitor.visitMaxs(1, 1);
methodVisitor.visitEnd();
}
{
methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "isNull", "(L" + originClassPath + ";)Ljava/lang/Boolean;", null, null);
methodVisitor.visitParameter("origin", 0);
methodVisitor.visitCode();
Label label0 = new Label();
methodVisitor.visitLabel(label0);
methodVisitor.visitVarInsn(ALOAD, 1);
Label label1 = new Label();
int index = 0;
//過濾掉基礎類別的
PropertyDescriptor[] propertyDescriptors = Arrays.stream(BeanUtils.getPropertyDescriptors(originClass))
.filter(p -> p.getReadMethod().getDeclaringClass() == originClass)
.toArray(PropertyDescriptor[]::new);
for (PropertyDescriptor pd : propertyDescriptors) {
String descriptor = "()" + Type.getDescriptor(pd.getPropertyType());
if (index == 0) {
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, originClassPath, pd.getReadMethod().getName(), descriptor, false);
} else if (index > 0 && index < propertyDescriptors.length - 1) {
methodVisitor.visitJumpInsn(IFNULL, label1);
methodVisitor.visitVarInsn(ALOAD, 1);
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, originClassPath, pd.getReadMethod().getName(), descriptor, false);
} else {
methodVisitor.visitJumpInsn(IFNULL, label1);
methodVisitor.visitVarInsn(ALOAD, 1);
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, originClassPath, pd.getReadMethod().getName(), descriptor, false);
methodVisitor.visitJumpInsn(IFNULL, label1);
methodVisitor.visitInsn(ICONST_1);
}
index++;
}
Label label2 = new Label();
methodVisitor.visitJumpInsn(GOTO, label2);
methodVisitor.visitLabel(label1);
methodVisitor.visitFrame(Opcodes.F_SAME, 0, null, 0, null);
methodVisitor.visitInsn(ICONST_0);
methodVisitor.visitLabel(label2);
methodVisitor.visitFrame(Opcodes.F_SAME1, 0, null, 1, new Object[]{Opcodes.INTEGER});
methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/Boolean", "valueOf", "(Z)Ljava/lang/Boolean;", false);
methodVisitor.visitInsn(ARETURN);
Label label3 = new Label();
methodVisitor.visitLabel(label3);
methodVisitor.visitLocalVariable("this", "L" + generateClassPatch + ";", null, label0, label3, 0);
methodVisitor.visitLocalVariable("origin", "L" + originClassPath + ";", null, label0, label3, 1);
methodVisitor.visitMaxs(1, 2);
methodVisitor.visitEnd();
}
{
methodVisitor = classWriter.visitMethod(ACC_PUBLIC | ACC_BRIDGE | ACC_SYNTHETIC, "isNull", "(Ljava/lang/Object;)Ljava/lang/Boolean;", null, null);
methodVisitor.visitParameter("origin", ACC_SYNTHETIC);
methodVisitor.visitCode();
Label label0 = new Label();
methodVisitor.visitLabel(label0);
methodVisitor.visitLineNumber(7, label0);
methodVisitor.visitVarInsn(ALOAD, 0);
methodVisitor.visitVarInsn(ALOAD, 1);
methodVisitor.visitTypeInsn(CHECKCAST, originClassPath);
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, generateClassPatch, "isNull", "(L" + originClassPath + ";)Ljava/lang/Boolean;", false);
methodVisitor.visitInsn(ARETURN);
Label label1 = new Label();
methodVisitor.visitLabel(label1);
methodVisitor.visitLocalVariable("this", "L" + generateClassPatch + ";", null, label0, label1, 0);
methodVisitor.visitMaxs(2, 2);
methodVisitor.visitEnd();
}
classWriter.visitEnd();
return classWriter.toByteArray();
}
}
程式碼有點長,可能你會想還是用chatgpt生成,但這種邏輯性比較強的它就無能為力了。不過我們還有工具可以生成它,我使用的是ASM Bytecode Viewer,idea中安裝外掛即可。
首先將要實現的結果用程式碼寫出來,然後右鍵使用ASM Bytecode Viewer,就可以看到對應的asm程式碼。當然實際我們是要遍歷類的所有欄位,就是for迴圈遍歷屬性的那一部分,這需要自己寫,也不難,在外掛生成程式碼後稍微調整下即可。
public class MyPersonGenerated implements NullChecker<Person> {
@Override
public Boolean isNull(Person person) {
return person.getUserBaseInfo() != null && person.getContactList() != null;
}
}
八股文背多了就知道類生命週期是:載入 -> 連結(驗證,準備,解析) -> 初始化 -> 使用 -> 解除安裝。所以首先要使用ClassLoader將動態類載入到jvm,我們可以定義一個類繼承抽象類ClassLoader,呼叫它的defineClass。
public class MyClassLoader extends ClassLoader {
public Class<?> defineClass(byte[] b) {
return super.defineClass(null, b, 0, b.length);
}
}
使用如下,當然實際情況中我們會將生成的NullChecker賦值給一個全域性變數快取,不用每次都newInstance建立。
MyClassLoader myClassLoader = new MyClassLoader();
byte[] bytes = ClassByteGenerator.generate(Person.class);
Class<?> personNullCheckerCls = myClassLoader.defineClass(bytes);
NullChecker personNullChecker = (NullChecker) personNullCheckerCls.newInstance();
boolean result = o.isNull(person);
也可以將生成類的位元組儲存到檔案,然後拖到idea觀察結果,如下:
try (FileOutputStream fos = new FileOutputStream("./Person$ASMGenerated.class")) {
fos.write(bytes); // 將位元組陣列寫入.class檔案
} catch (IOException e) {
throw e;
}
看完動態編譯我們再看靜態編譯。java程式碼編譯和執行的整個過程包含三個主要機制:1.java原始碼編譯機制 2.類載入機制 3.類執行機制。其中java原始碼編譯由3個過程組成:1.分析和輸入到符號表 2.註解處理 3.語意分析和生成class檔案。如下:
在介紹mapstruct這篇時我們也有提到,其中主要就是在原始碼編譯的註解處理階段,可以插入我們的自定義程式碼。
例如我們新建工程,定義如下註解,它標識的類就會對應生成一個含isNull方法的類。其中RetentionPolicy.SOURCE表示在原始碼階段生效,在執行時是讀不到這個註解的,lombok的註解也是這個道理。
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface GenerateIsNullMethod {
String value() default "";
}
接著編寫註解處理器,在發現GenerateIsNullMethod註解時,進入處理邏輯。
@SupportedAnnotationTypes("com.example.mapstruct.processor.GenerateIsNullMethod")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@AutoService(Processor.class)
public class IsNullAnnotationProcessor extends AbstractProcessor {
private ProcessingEnvironment processingEnv;
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.processingEnv = processingEnv;
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.WARNING, "GenerateIsNullMethod start===");
Set<? extends Element> set = roundEnv.getElementsAnnotatedWith(GenerateIsNullMethod.class);
for (Element classElement : set) {
generateIsNullMethod(classElement);
}
return true;
}
private void generateIsNullMethod(Element classElement) {
//javapoet只能建立新的檔案,不能修改https://github.com/square/javapoet/issues/505
String packageName = processingEnv.getElementUtils().getPackageOf(classElement).toString();
String className = classElement.getSimpleName().toString();
String newClassName = className + "Ext";
MethodSpec.Builder p = MethodSpec.methodBuilder("isNull")
.addModifiers(Modifier.PUBLIC)
.addModifiers(Modifier.STATIC)
.addParameter(ClassName.bestGuess(packageName + "." + className), "p")
.returns(Boolean.class);
String statement = "return ";
for (Element ee : classElement.getEnclosedElements()) {
if (ee.getKind().isField()) {
String eeName = ee.getSimpleName().toString();
statement += "p.get" + eeName.substring(0, 1).toUpperCase() + eeName.substring(1, ee.getSimpleName().length()) + "()" + " != null && ";
}
}
statement = statement.substring(0, statement.length() - 4);
processingEnv.getMessager().printMessage(Diagnostic.Kind.WARNING, statement + "===");
MethodSpec isNullMethod = p.addStatement(statement).build();
TypeSpec updatedClass = TypeSpec.classBuilder(newClassName)
.addModifiers(Modifier.PUBLIC)
.addMethod(isNullMethod)
.build();
JavaFile javaFile = JavaFile.builder(packageName, updatedClass)
.build();
try {
javaFile.writeTo(processingEnv.getFiler());
} catch (IOException e) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Failed to generate isNull method: " + e.getMessage());
}
}
}
@AutoService是google一個工具包,幫我們在META-INF/services路徑下生成設定,註解處理器才會生效。
這裡生成程式碼使用到了javapoet工具,用於生成.java原始檔。
其它的就都是在生成程式碼了,需要注意的是,既然是在編譯期,那就不要想用執行時的東西,例如反射,都還沒到那個階段。
匯入這個工程使用GenerateIsNullMethod標記Person類,編譯後就可以觀察到生成一個PersonExt的類,它的isNull方法會判斷Person引數每個屬性是否為空。
這裡我並沒有像lombok一樣在原類上新增方法,而是新增一個Ext類,因為那樣做要解析語法樹,比較複雜,我沒有實現,有興趣的可以參考lombok自己實現一下。
本篇介紹瞭如何使用靜態/動態編譯生成程式碼,這種方式在許多框架、工具都非常常見,只是我們平時比較少接觸到。
通過學習我們可以更好了解平時使用的技術的原理,知其然知其所以然,以後遇到類似的場景也能想到用這類解決方案來實現。