為了簡寫這行程式碼,我竟使用靜態和動態編譯技術

jtea發表於2023-10-31

背景

在我們系統中有這麼一個需求,業務方會透過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 介紹

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自己實現一下。

總結

本篇介紹瞭如何使用靜態/動態編譯生成程式碼,這種方式在許多框架、工具都非常常見,只是我們平時比較少接觸到。
透過學習我們可以更好了解平時使用的技術的原理,知其然知其所以然,以後遇到類似的場景也能想到用這類解決方案來實現。

相關文章