为了简写这行代码,我竟使用静态和动态编译技术

背景

在我们系统中有这么一个需求,业务方会通过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自己实现一下。

总结

本篇介绍了如何使用静态/动态编译生成代码,这种方式在许多框架、工具都非常常见,只是我们平时比较少接触到。

通过学习我们可以更好了解平时使用的技术的原理,知其然知其所以然,以后遇到类似的场景也能想到用这类解决方案来实现。

相关推荐
腥臭腐朽的日子熠熠生辉39 分钟前
解决maven失效问题(现象:maven中只有jdk的工具包,没有springboot的包)
java·spring boot·maven
ejinxian41 分钟前
Spring AI Alibaba 快速开发生成式 Java AI 应用
java·人工智能·spring
杉之1 小时前
SpringBlade 数据库字段的自动填充
java·笔记·学习·spring·tomcat
圈圈编码1 小时前
Spring Task 定时任务
java·前端·spring
俏布斯1 小时前
算法日常记录
java·算法·leetcode
27669582921 小时前
美团民宿 mtgsig 小程序 mtgsig1.2 分析
java·python·小程序·美团·mtgsig·mtgsig1.2·美团民宿
爱的叹息1 小时前
Java 连接 Redis 的驱动(Jedis、Lettuce、Redisson、Spring Data Redis)分类及对比
java·redis·spring
程序猿chen2 小时前
《JVM考古现场(十五):熵火燎原——从量子递归到热寂晶壁的代码涅槃》
java·jvm·git·后端·java-ee·区块链·量子计算
松韬2 小时前
Spring + Redisson:从 0 到 1 搭建高可用分布式缓存系统
java·redis·分布式·spring·缓存
绝顶少年2 小时前
Spring Boot 注解:深度解析与应用场景
java·spring boot·后端