如果读者接触过一些java项目, 可能看到过lombok这个依赖, 其提供的@Getter @Setter等注解会在编译时往源类添加/修改代码, 从而方便开发。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Getter @Setter public String str; public String str; @Generated public String getStr () { return this .str; } @Generated public void setStr (String str) { this .str = str; }
然而如果你真的试图复现一个简单的@Getter @Setter类似物, 会发现java提供的公开API只能通过注解处理器创建新的.java文件并写入新源码, 并不能修改源类代码. 所以本文下面简单介绍如何不使用外部依赖的情况下通过纯注解处理器修改编译时源类代码. 并逐步实现一个可以简化Minecraft模组网络包编写与注册的注解。
本文所示例的功能已在笔者个人项目XorLib 上实现, 代码与文中并不完全相同, 欢迎使用并提出意见。
本文不是对lombok的原理分析, 后文也与lombok无关
目标 我们使用的java版本是openjdk-21.0.2, 使用的Minecraft和模组加载器版本是1.21.1NeoForge。
目标是让网络包类只用提供编解码器和包处理方法即可, 省去创建器类型字段和注册网络包的重复代码。
先看效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 @NetworkPacket(type = NetworkPacket.Type.PLAY_SERVER_TO_CLIENT) public record ANetworkPack (ItemStack forExample) { @NetworkPacket .Codec public static final StreamCodec<RegistryFriendlyByteBuf, ANetworkPack> STREAM_CODEC = StreamCodec.composite( ItemStack.STREAM_CODEC, ANetworkPack::forExample, ANetworkPack::new ); @NetworkPacket .Handler public void handle (IPayloadContext context) { context.enqueueWork(() -> { }); } } @EventBusSubscriber( modid = "your mod id", bus = Bus.MOD ) @NetworkPacket(type = Type.PLAY_SERVER_TO_CLIENT) public record ANetworkPack (ItemStack stack) implements CustomPacketPayload { @Codec public static final StreamCodec<RegistryFriendlyByteBuf, ANetworkPack> STREAM_CODEC; public static final CustomPacketPayload.Type TYPE; @Handler public void handle (IPayloadContext context) { context.enqueueWork(() -> { }); } public CustomPacketPayload.Type type () { return TYPE; } @SubscribeEvent public static void register (RegisterPayloadHandlersEvent event_) { PayloadRegistrar register = event_.registrar("your mod id" ); register.playToClient(TYPE, STREAM_CODEC, ANetWorkPack::handle); } static { STREAM_CODEC = StreamCodec.composite(ItemStack.STREAM_CODEC, ANetworkPack::stack,ANetworkPack::new ); TYPE = new CustomPacketPayload .Type(ResourceLocation.fromNamespaceAndPath("your mod id" , "a_network_pack" )); } }
然后创建将被处理的注解本身。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @Target({ElementType.TYPE}) @Retention(RetentionPolicy.CLASS) public @interface NetworkPacket { Type type () ; @Target({ElementType.FIELD}) @Retention(RetentionPolicy.CLASS) @interface Codec{ } @Target({ElementType.METHOD}) @Retention(RetentionPolicy.CLASS) @interface Handler{ } enum Type { COMMON_CLIENT_TO_SERVER("commonToServer" ), COMMON_SERVER_TO_CLIENT("commonToClient" ), PLAY_CLIENT_TO_SERVER("playToServer" ), PLAY_SERVER_TO_CLIENT("playToClient" ); private final String methodName; Type(String methodName) { this .methodName = methodName; } public String getMethodName () { return methodName; } } }
其实是三个注解, @NetworkPacket需要在类上使用, 同时需要写明包的发送方向, 这里图方便不考虑双向包。@Codec注解要求使用在流式编解码器字段上使用 (其实Codec也可以实现注解生成, 有兴趣的读者可以研究一下) , @Handler要求在声明为IPayloadContext -> void的方法上使用。
准备工作 创建注解处理器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @AnnotationProcessor @SupportedSourceVersion(SourceVersion.RELEASE_21) @SupportedAnnotationTypes({"com.xkball.xorlib.api.annotation.NetworkPacket"}) public class NetworkPacketProcessor extends AbstractProcessor { static { JavaWorkaround.init(); } @Override public synchronized void init (ProcessingEnvironment processingEnv) { super .init(processingEnv); } @Override public boolean process (Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { } }
注解处理器使用java的SPI(服务提供接口)进行加载.只要在jar里的META-INF/services文件夹下创建javax.annotation.processing.Processor文件并每行写入一个注解处理器的类名, 编译器就会在合适的时候自动实例化并调用我们所写的AnnotationProcessor。
添加jvm参数或者…… java9添加了模块系统, 模块不能访问甚至不能反射其他模块未exports的类, 所有没有使用模块系统的代码都属于一个叫做ALL-UNNAMED的特殊模块。
显然接下来我们需要使用很多javac的内部不公开部分, 为此需要绕开模块系统的限制。
最简单的方法添加jvm参数, --add-exports可以添加另一模块对某一模块的访问权限, --add-opens可以添加另一模块对某一模块的反射权限.你可能需要添加类似下面的jvm参数。
1 2 3 4 5 6 7 --add-exports =jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-exports =jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports =jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports =jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-exports =jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED --add-exports =jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-exports =jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
编译注解处理器和使用注解处理器时都需要添加这个编译参数, 显然这很麻烦, 由于使用注解处理器也需要添加运行时参数也让用户不能开箱即用, 需要根据不同构建工具进行适配。
所以我们有更黑魔法的方式解决这个问题, 其实只用调用java.lang.Module#implAddExportsOrOpens就能让一个模块对一个模块开放访问权限或者反射权限.
这个方法是private的, 你需要反射或者其他一些手段来调用它.但是java.lang.Module并没有open不能反射,但是我们可以使用java.lang.invoke.MethodHandles.Lookup#IMPL_LOOKUP来绕开限制进行反射, 但是这个字段也不是public的 幸好我们可以用sun.misc.Unsafe#getObject来获得IMPL_LOOKUP. 但是theUnsafe也是private的… 终于, 这一次, 至少在一定版本范围的java内, 终于可以用普通反射来获得对象了.
什么你说太长太绕不想看, 可以直接看抄这里 . 这也是上文JavaWorkaround.init()的作用.
让我们开始 获取javac内部一些实用类 你也许已经注意到注解处理器的public synchronized void init(ProcessingEnvironment processingEnv)传入了ProcessingEnvironment, 我们需要从这个环境中掏一些上下文出来.这些上下文类可以从ProcessingEnvironment中的com.sun.tools.javac.util.Context类字段获得, 相信你一定猜到了这个字段也需要反射来获得。 需要注意的是这些上下文类虽然能获得实例但是并不总是可用, 这跟javac编译步骤有关, 实际上某种意义上注解处理器就是只能在一个特定步骤运行的编译器插件。 此处不展开有关编译器插件的更多内容, 因为笔者也不会(
以下是笔者使用过的一些上下文类, 功能说明基于个人不完整阅读代码的总结, 不保证准确:
类名
功能说明
com.sun.tools.javac.api.JavacTrees
可以获得AST(抽象语法树)
com.sun.tools.javac.tree.TreeMaker
用于创建新AST节点
com.sun.tools.javac.model.JavacElements
可以查询各种Symbol,进而可以获得对于的jctree
com.sun.tools.javac.util.Names
可以获得各种标识符名称
com.sun.tools.javac.code.Symtab
可以获得符号表, 由此获得依赖的类
com.sun.tools.javac.comp.Enter
符号结构相关? 可以和下一位一起查泛型类型
com.sun.tools.javac.comp.Attr
类型推断相关? 可以和上一位一起查泛型类型
抽象语法树
AST(Abstract Syntax Tree,抽象语法树)是源代码的结构化表示,用树状节点表达程序的语法与语义层次关系。 —— ChatGPT
笔者没有学过编译原理在此不班门弄斧介绍AST了。我们在注解处理器拿到的AST还比较高度抽象, 还没有进行抹除泛型等步骤,
javac的AST基类是com.sun.tools.javac.tree.JCTree, 各种代表语法树不同类型的节点均是其子类。根节点应该是com.sun.tools.javac.tree.JCTree.JCCompilationUnit,但是对于注解处理器, 我们有更方便的方法获得包含使用了目标注解的类的JCTree, 现在我们注解处理器应该如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Override public synchronized void init (ProcessingEnvironment processingEnv) { super .init(processingEnv); this .processingEnv = processingEnv; this .jcTrees = JavacTrees.instance(processingEnv); } @Override public final boolean process (Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { if (roundEnv.processingOver()) return false ; Set<Element> symbols = roundEnv.getElementsAnnotatedWith(NetworkPacket.class); for (var ele : symbols){ if (!(ele instanceof Symbol.ClassSymbol clazz)) continue ; JCClassDecl classTree = jcTrees.getTree(clazz); } return true ; }
修改抽象语法树 让我们直接看JCClassDecl的代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public static class JCClassDecl extends JCStatement implements ClassTree { public JCModifiers mods; public Name name; public List<JCTypeParameter> typarams; public JCExpression extending; public List<JCExpression> implementing; public List<JCExpression> permitting; public List<JCTree> defs; public ClassSymbol sym; }
可见树的节点均为public成员, 可以直接修改。其他各种类型的JCTree节点均类似, 可以直接修改值得一提的是, 这里的List并不是java.util.List, 而是com.sun.tools.javac.util.List, 是编译器内部使用的一个链表。通过List.form()可以轻松的把常见的List转换为这个专用List。
各种JCTree的节点的构造方法基本都是public的, 但是一般不推荐手动new节点, 因为节点内存在一个上下文相关的字段public int pos;, 使用com.sun.tools.javac.tree.TreeMaker以创建拥有正确上下文的节点。
好罢也不一定就有正确的上下文, 你得手动维护上下文正确. 这个pos是行号列号的组合, 用于报错的时候提示错误的位置, int的高22位为行号(所以一个文件只能写四百万行代码, 真是太少了), 低10位为列号, 这个列号似乎不是字符的列号, 而是以经过分词器分词后单元的列号。错误的位置大部分情况下不影响编译(影响报错), 但是在一些情况下会导致生成的代码无法编译。一个例子是方法的参数, TreeMaker创建的节点依然可能缺少一些上下文, 在这个情况下编译器会根据pos推测方法参数的位置, 方法的每个参数间没有递增的pos自然就会导致问题。(尚不知道为什么不用列表里的顺序。)
经常用于访问类型等功能的节点叫做JCIdent, 但是依旧大概是因为缺少import等的上下文, 直接使用TreeMaker#Ident(com.sun.tools.javac.util.Name)经常无法编译, 这时候可以使用多层JCFieldAccess节点逐层访问类的完全限定名称, 下面直接给出工具方法。
1 2 3 4 5 6 7 8 public static JCTree.JCExpression makeIdent (String name) { var ele = name.split("\\." ); JCTree.JCExpression e = treeMaker.Ident(names.fromString(ele[0 ])); for (int i = 1 ; i < ele.length; i++) { e = treeMaker.Select(e, names.fromString(ele[i])); } return e; }
JCTree足足有82个继承者, 记住每个类型并分析你想写的java代码应该怎么用AST形式表达几乎不可能, 最简单的知道如何给AST添加想要的内容的方法其实就是写一遍想要生成的代码, 然后在编译时打断点直接看AST长什么样, 后文对@NetworkPacket的实现就是这样完成的。
实现@NetworkPacket 经过简单分析我们可以发现@NetworkPacket需要对源类对以下修改:
给类添加一个@ModBusSubscriber
给类实现CustomPacketPayload接口
为了实现接口需要实现一个type方法
创建一个CustomPacketPayload.Type字段, 总不能让上面的方法返回null
创建一个register方法, 用来向neoforge注册我们的网络包
给register方法添加@SubscribeEvent
上一节已经讲述怎么修改JCTree, 这里直接给出结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 @AnnotationProcessor @SupportedSourceVersion(SourceVersion.RELEASE_21) @SupportedAnnotationTypes({"com.xkball.xorlib.api.annotation.NetworkPacket"}) public class NetworkPacketProcessor extends AbstractProcessor { private static JavacTrees jcTrees; private static Elements elementUtils; private static TreeMaker treeMaker; private static Names names; static { JavaWorkaround.init(); } public static Context getContext (ProcessingEnvironment processingEnv) { try { var f = processingEnv.getClass().getDeclaredField("context" ); f.setAccessible(true ); return (Context) f.get(processingEnv); } catch (NoSuchFieldException | IllegalAccessException e) { throw new RuntimeException (e); } } @Override public synchronized void init (ProcessingEnvironment processingEnv) { super .init(processingEnv); this .jcTrees = JavacTrees.instance(processingEnv); this .elementUtils = processingEnv.getElementUtils(); var context = getContext(processingEnv); this .treeMaker = TreeMaker.instance(context); this .names = Names.instance(context); } @Override public boolean process (Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { if (roundEnv.processingOver()) return false ; Set<Element> symbols = roundEnv.getElementsAnnotatedWith(NetworkPacket.class); for (var ele : symbols){ if (!(ele instanceof Symbol.ClassSymbol clazz)) continue ; JCClassDecl classTree = jcTrees.getTree(clazz); var usingHandlers = roundEnv.getElementsAnnotatedWith(NetworkPacket.Handler.class); var usingCodec = roundEnv.getElementsAnnotatedWith(NetworkPacket.Codec.class); var codec = (Symbol.VarSymbol)usingCodec.stream().filter(symbol -> ((Symbol.VarSymbol)symbol).owner.equals(clazz)).findFirst().orElseThrow(); var handler = (Symbol.MethodSymbol)usingHandlers.stream().filter(symbol -> ((Symbol.MethodSymbol)symbol).owner.equals(clazz)).findFirst().orElseThrow(); var selfAnno = clazz.getMetadata().getDeclarationAttributes().stream().filter(a -> a.type.equals(elementUtils.getTypeElement("com.xkball.xorlib.api.annotation.NetworkPacket" ).asType())).findFirst().orElse(null ); var type = selfAnno.values.stream().filter(p -> "type" .equals(p.fst.name.toString())).findFirst().map(p -> { if (p.snd instanceof Attribute.Enum) { return Enum.valueOf(NetworkPacket.Type.class, ((Symbol.VarSymbol) p.snd.getValue()).name.toString()); } else throw new RuntimeException ("result is not an Enum" ); } ).orElseThrow(); String modid = "your mod id" ; String CUSTOM_PACKET_PAYLOAD_TYPE = "net.minecraft.network.protocol.common.custom.CustomPacketPayload" ; treeMaker.pos = classTree.pos; var modid_ = treeMaker.Assign(ident("modid" ), treeMaker.Literal(modid)); var bus_ = treeMaker.Assign(ident("bus" ), treeMaker.Select(makeIdent("net.neoforged.fml.common.EventBusSubscriber.Bus" ),name("MOD" ))); addAnnotation2Class(classTree,"net.neoforged.fml.common.EventBusSubscriber" ,List.of(modid_,bus_)); addImplement2Class(classTree, CUSTOM_PACKET_PAYLOAD_TYPE); var publicModifier = treeMaker.Modifiers(Modifier.PUBLIC); var typeMethodBlock = treeMaker.Block(0 ,List.of(treeMaker.Return(ident("TYPE" )))); addMethod2Class(classTree,"type" ,makeIdent(CUSTOM_PACKET_PAYLOAD_TYPE),publicModifier,List.nil(),typeMethodBlock); var publicStaticFinalModifier = treeMaker.Modifiers(Modifier.PUBLIC | Modifier.STATIC | Modifier.FINAL); var typeInit = treeMaker.NewClass(null , List.nil(), treeMaker.TypeApply(makeIdent(CUSTOM_PACKET_PAYLOAD_TYPE), List.nil()), List.of(treeMaker.Apply(List.nil(),makeIdent("net.minecraft.resources.ResourceLocation.fromNamespaceAndPath" ),List.of(treeMaker.Literal(modid),treeMaker.Literal(StringUtils.toSnakeCase(classTree.name.toString()))))), null ); addField2Class(classTree,CUSTOM_PACKET_PAYLOAD_TYPE,"TYPE" ,publicStaticFinalModifier,typeInit); var publicStaticModifier = treeMaker.Modifiers(Modifier.PUBLIC | Modifier.STATIC); var registerMethodBlock = treeMaker.Block(0 ,List.of( treeMaker.VarDef(treeMaker.Modifiers(0 ),name("register" ),makeIdent("net.neoforged.neoforge.network.registration.PayloadRegistrar" ),treeMaker.Apply(List.nil(),treeMaker.Select(ident("event_" ),name("registrar" )),List.of(treeMaker.Literal(modid)))), treeMaker.Exec(treeMaker.Apply(List.nil(),treeMaker.Select(ident("register" ),name(type.getMethodName())),List.of(makeIdent(target.sym.fullname.toString()+".TYPE" ),makeIdent(codecName),treeMaker.Reference(MemberReferenceTree.ReferenceMode.INVOKE,name(handleName),makeIdent(handleOwnerName),null )))))); addMethod2Class(classTree,"register" ,treeMaker.TypeIdent(TypeTag.VOID),publicStaticModifier, List.of(treeMaker.VarDef(treeMaker.Modifiers(Flags.PARAMETER),name("event_" ),makeIdent("net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent" ),null )),registerMethodBlock); var registerMethod = findMethods(classTree,"register" ).stream().findFirst().orElseThrow(); addAnnotation2Method(registerMethod,"net.neoforged.bus.api.SubscribeEvent" , List.nil()); } return true ; } public static JCTree.JCIdent ident (String name) { return treeMaker.Ident(names.fromString(name)); } public static Name name (String name) { return names.fromString(name); } public static JCTree.JCExpression makeIdent (String name) { var ele = name.split("\\." ); JCTree.JCExpression e = treeMaker.Ident(names.fromString(ele[0 ])); for (int i = 1 ; i < ele.length; i++) { e = treeMaker.Select(e, names.fromString(ele[i])); } return e; } public static void addAnnotation2Class (JCTree.JCClassDecl target, String annotationName, List<JCTree.JCExpression> args) { target.mods.annotations = target.mods.annotations.append(treeMaker.Annotation(makeIdent(annotationName), args)); } public static void addAnnotation2Method (JCTree.JCMethodDecl target, String annotationName, List<JCTree.JCExpression> args) { target.mods.annotations = target.mods.annotations.append(treeMaker.Annotation(makeIdent(annotationName), args)); } public static void addImplement2Class (JCTree.JCClassDecl target, String interfaceName) { target.implementing = target.implementing.append(makeIdent(interfaceName)); } public static void addMethod2Class (JCTree.JCClassDecl target, String methodName, JCTree.JCExpression returnType, JCTree.JCModifiers modifiers, List<JCTree.JCVariableDecl> args, JCTree.JCBlock block) { target.defs = target.defs.append(treeMaker.MethodDef(modifiers,name(methodName),returnType,List.nil(),args,List.nil(),block,null )); } public static void addField2Class (JCTree.JCClassDecl target, String fieldType, String fieldName, JCTree.JCModifiers modifiers, JCTree.JCExpression initValue) { target.defs = target.defs.append(treeMaker.VarDef(modifiers,name(fieldName),makeIdent(fieldType),initValue)); } public static java.util.List<JCTree.JCMethodDecl> findMethods(JCTree.JCClassDecl target, String methodName){ return target.defs.stream().filter(t -> t instanceof JCTree.JCMethodDecl m && m.name.toString().equals(methodName)).map(t -> (JCTree.JCMethodDecl) t).toList(); } }
结语 注解处理器功能强大, 还不需要添加运行时依赖, 当然这是有代价的:
编写困难以及和javac高度耦合
经常需要写IDE插件配合, 否则可能搞坏语法高亮等
生成代码的行号基本是乱的, 难以调试
灵活度有限, 只能通过注解可以接收的基础类型, String和Enum传递信息, 一旦需要更复杂数据可能需要扫类等手段
延长编译时间, 尤其是如果有类似上条所述扫类的行为
难以去依赖
除了特定情况( 大部分已经被lombok做了) 或者造DSL( 领域特定语言) ( 某种意义上写Minecraft模组一定会接触到的Mixin就是, 它也确实有注解处理器) 外, 笔者认为注解处理器生成代码缺点明显, 研究技术的价值大于实际生产价值。
当然造DSL更推荐相关框架, 笔者对此就没什么了解了。