java之徒手实现源码的动态生成,编译以及加载

Robit低代码平台在去年的时候进一步改造升级,确定了用户设计的应用必须生成可阅读可二次开发的源码。考虑到当前平台使用的技术栈,确定应用源码的前端依旧使用Vue,后端使用spring boot。

在改造生成应用源码之前,我们需要先技术选型,确定如何生成源码。在考虑这个问题之前,平台当时也做过类似的需求,不过那个时候是生成实体和结构体的源码,因为代码结构比较简单直接使用字符串拼接的方式生成的。

核心代码如下,比较特殊的是实体的属性都是public类型且又配置了get和set方法,因为低代码平台允许用户使用is开头的bool类型值的属性名,这种命名可能导致序列化反序列丢失值,所以属性都改成public,且配置了
jackson的注解@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY, getterVisibility = JsonAutoDetect.Visibility.NONE),直接把get方法屏蔽掉。

    public String build(String clazzName) {
        // 构建头部
        fileContent.append("package ").append(packageName).append(";\n\n");
        imports.add("import com.fasterxml.jackson.annotation.JsonAutoDetect;\n");
        for (String anImport : imports) {
            fileContent.append(anImport);
        }
        fileContent.append(
            "@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY, getterVisibility = JsonAutoDetect.Visibility.NONE)\n");
        fileContent.append("public class ").append(clazzName).append(" {\n");
        fileContent.append("public ").append(clazzName).append("(){}\n\n");
        fileContent.append("    private Set<String> changedFields = new HashSet<>();\n");
        // 构建属性
        props.forEach((propertyName, className) -> {
            String content = MessageFormat.format("    public {1} {0};\n\n", propertyName, className);
            fileContent.append(content);
        });

        // 构建 getter setter
        props.forEach((propertyName, className) -> {
            String fixedName = propertyName.substring(0, 1).toUpperCase(Locale.ROOT) + propertyName.substring(1);
            String content = MessageFormat.format(
                    "    public {2} get{1}() '{'\n"
                            + "        return {0};\n"
                            + "    '}'\n"
                            + "    public void set{1}({2} {0}) '{'\n"
                            + "        this.{0} = {0};\n"
                            + "    '}'\n", propertyName, fixedName, className);
            fileContent.append(content);
        });

        // 构建ToMap方法
        fileContent.append("  public Map<String,Object> toMap(){\n"
                + "        Map<String,Object> toMap = new HashMap<>();\n");

        props.forEach((column, type) ->
                fileContent.append(
                        ("    if(changedFields.contains(\"_NAME\") || \"id\".equals(\"_NAME\") ) {\n"
                                + "            toMap.put(\"_NAME\",_NAME);\n"
                                + "        }\n")
                                .replaceAll("_NAME", column)));

        fileContent.append(
                "        return toMap;\n"
                        + "    }\n\n");

        // 构建尾部
        return fileContent.append("}\n").toString();
    }

然后调用jdk自带的JavaCompiler,实际调用的是javac执行的编译,然后从生成的class文件读取内容存入数据库。在很久之前jdk内置的工具都改成了java实现,那些exe文件其实都是空壳文件,文件大小都是24k。

java之徒手实现源码的动态生成,编译以及加载-我的技术分享

compiler.run方法的前三个参数分别是标准输入流,标准输出流,标准错误输出流,可以通过这三个参数细化编译,比如记录编译日志。 run方法的返回值为0的时候表示成功,其他值都是失败。为什么返回值是个数字呢?并且编译失败还不抛异常? 很简单,这个是调用javac这个命令行程序的代码实现的,哪来的java异常。。。返回值是个数字也很简单,这个值就是标准的程序退出状态码。

核心代码中不考虑这些复杂问题,我们只需要查看是否存在class文件即可,不存在就是编译出错了。本方法返回的字节数组或base64编码后插入数据库。

    public byte[] compiler(String name) {
        String javaPackageName = name.replace(".", File.separator) + ".java";
        String javaAbsolutePath = javaFileHome + javaPackageName;
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        compiler.run(null, null, null, "-encoding", "UTF-8", "-classpath", javaFileHome, javaAbsolutePath);
        String classFile = javaFileHome + name.replace(".", File.separator) + ".class";
        try {
            return Files.readAllBytes(new File(classFile).toPath());
        } catch (IOException exception) {
            throw new LogicCompileException("类编译失败", exception);
        }
    }

自定义的classload核心代码如下,类继承自ClassLoader,ClassLoader已经实现了加载类的全部给你了,我继承它只要为了修改读取类文件的方式,需要从数据库里读取。 这个类实现了核心的加载类定义的功能。具体的说明我写到代码的注释里了。


public class EntityClassLoader extends ClassLoader {
    // 读取编译的类的数据 mapper
    private final ClassCacheMapper mapper;

    public EntityClassLoader(ClassCacheMapper mapper) {
        // 使用spring的 classloader 作为父加载器
        super(ClassUtils.getDefaultClassLoader());
        this.mapper = mapper;
    }

    // 加载类的方法 没有缓存情况下调用
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] data;
        String[] splitName = name.split("\\.");
        data = loadClassBytes(splitName[splitName.length - 1]);
        // 直接把数据喂给defineClass方法
        return defineClass(name, data, 0, data.length);
    }
    // 读取类定义的方法 如果缓存内加载不到,会内部调用findClass执行查找
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 补全类的完整包名
        String fixedName = name.contains(".") ? name : "cn.xxx.logic.custom._"
                + CurrentTenantIdentifierResolverImpl.getTenantIdStatic().replace("-", "_") + "." + name;
        // 基于双亲委派原则 否则类中使用的String Integer之类的非手动生成的类是无法加载的
        return super.loadClass(fixedName);
    }
    // 核心代码 就是个select操作 哈哈哈
    private byte[] loadClassBytes(String name) throws ClassNotFoundException {
        String result = mapper.find(name);
        if (result == null || result.length() == 0) {
            throw new ClassNotFoundException("class " + name + " not found in database");
        }
        // base64解码
        return Base64.decodeBase64(result);
    }
}

通过以上的代码,我们是无法通过编译步骤的,因为最开始的生成源码的代码中确定了生成的源码里使用jackson的注解,编译的时候会报错提示找不到这些注解。

    public void copyJsonAutoDetect() throws IOException {
        copyFile("JsonAutoDetect$1.class");
        copyFile("JsonAutoDetect$Value.class");
        copyFile("JsonAutoDetect$Visibility.class");
        copyFile("JsonAutoDetect.class");
    }

我们需要把这几个文件拷贝到编译历史目录内才行。