开门见山说个事:我这周刚把一个基于javax.lang.model的自定义注解处理器从零写完并跑通,整个过程里 DeepSeek 帮我省了很大一部分查文档和试错的时间。如果你正在折腾编译期注解处理、代码生成、结构校验这类东西,这篇就是拿真实踩坑记录换来的复盘,从思路到完整代码都有,可以直接照着改。
先说下背景。某业务系统的核心模块需要把一批标注了特定注解的接口,在编译期自动生成带超时、重试的远程调用代理类。这个活不能靠运行时反射,因为上线环境对启动速度敏感,最好在编译期就把代理代码固化到 jar 里。于是自然落到javax.lang.model这个 Java 编译期 API 上:写一个注解处理器,扫描源码中的注解和接口,生成新的 Java 源文件。
传统流程里,这一步意味着翻大量Element、TypeMirror、AnnotationMirror的文档,稍不注意就栽在泛型和注解属性读取上。这次我尝试把 DeepSeek 拉进开发流程,让 AI 直接产出骨架代码,我再逐个方法审查、编译、修错。整体走下来,效率确实比纯手写高一截,但 AI 生成的代码也不是拿来就能用,里面有几个隐蔽问题会在编译期静默埋雷,后面专门写一节。
1. 项目整体设计与思路拆解
1.1 为什么选择编译期注解处理而不是运行时反射
业务场景里,远程调用代理如果走运行时反射,每次调用都要动态解析接口元数据,带来的开销虽然单次不大,但在高频调用路径上会被放大,而且出了问题只能在运行期暴露。把这些逻辑挪到编译期,让注解处理器扫描源码结构、生成具体代理类,相当于把“解析元数据”这件事提前到构建阶段,运行时只需要直接调用生成类。
用javax.lang.model还有一个隐藏好处:它能读取的是源代码的结构信息,包括泛型参数、注解属性、继承关系、包名、可访问性等,这些信息在运行时反射中往往因为类型擦除和类加载机制变得不完整。比如一个接口方法的返回类型带泛型实参,反射拿到的可能是List,而编译期模型里能拿到List<OrderInfo>,这对生成强类型代理代码至关重要。
1.2 DeepSeek 在整个实现中扮演的角色
坦白讲,DeepSeek 在这里不是“自动写完整项目”的替代者,而是一个高频结对编程助手。我主要让它做三件事:
- 根据我给的注解定义和业务描述,生成注解处理器的骨架代码;
- 针对具体报错信息,给出版本兼容的修正建议;
- 把常用的
Element轮询逻辑(比如“找出某接口的所有公开方法”)封装成可复用函数。
它最擅长的是把“我想要扫描带有@RpcService注解的接口,提取方法签名并生成实现类”这类描述变成一段可运行代码。但问题也明显:AI 生成的代码经常忽略javax.lang.model对Types.isAssignable的用法细节,或者在Filer.createSourceFile的包名拼接上翻车。所以我的定位很明确:AI 出初稿,我做审查、测试和修正,最终可维护性还是落在自己手里。
1.3 整体实现链路
整个实现链路是这样的:
- 定义一个编译期注解,比如
@RpcService,用于标记需要生成代理的接口。 - 编写
AbstractProcessor子类,通过重写process方法接收注解信息。 - 利用
RoundEnvironment拿到所有被@RpcService标注的TypeElement。 - 通过
Element的成员树,提取每个TypeElement的ExecutableElement(方法)。 - 用
TypeMirror还原方法入参、返回类型、泛型信息。 - 用
Filer创建新的 Java 源文件,写入代理类内容。 - 在构建工具里注册处理器,通过 SPI 机制让编译器发现它。
链路本身不难画,难的是每一步都有不少细节坑,比如RoundEnvironment会多次回调、生成文件时机不对会导致编译错误、引入的依赖类在编译期 classpath 里不存在等等。下面把核心 API 逐个拆开说。
2. 核心知识点拆解:javax.lang.model 里必须吃透的 API
2.1 Element:程序结构的基石
Element是javax.lang.model中最顶层的程序元素抽象。它代表一个“静态存在的程序元素”,可能是包、类、接口、字段、方法、构造器、参数、局部变量。常用子类型有:
| 子类型 | 代表的程序元素 | 典型使用场景 |
|---|---|---|
PackageElement | 包 | 获取生成类的包名 |
TypeElement | 类或接口 | 被注解标注的目标类型 |
VariableElement | 字段、常量、方法参数、局部变量 | 读取方法参数名与类型 |
ExecutableElement | 方法、构造器、初始化块 | 遍历方法签名 |
最常见的模式是拿到一个TypeElement后,用element.getEnclosedElements()遍历它的成员,再用ElementKind过滤出需要的方法或字段。
2.2 TypeMirror:类型系统与泛型解析
TypeMirror描述一个类型。它的子接口很多,但平时接触最多的就这几个:
DeclaredType:声明类型,比如某个类、接口;TypeVariable:类型变量,比如T;ArrayType:数组类型;NoType:特殊情况,比如void。
处理泛型时,DeclaredType里有getTypeArguments()方法,能拿到泛型实参的List<? extends TypeMirror>。这就是编译期模型和运行时反射最大的差异点:编译期能查到具体实参,反射不一定能。
2.3 AnnotationMirror:读取注解属性
要读取代码里注解的具体属性,不能直接用反射,而是通过Element.getAnnotationMirrors()得到AnnotationMirror,再取getElementValues()。这个 Map 的 key 是ExecutableElement(对应注解的方法),value 是AnnotationValue。
这里有个大坑:如果注解属性的类型是 Class 或数组,AnnotationValue的取值转换方式不同,AI 生成的代码经常在这里出错,后文有专门排查。
2.4 四大工具类:Elements、Types、Filer、Messager
这四个工具由处理器的ProcessingEnvironment提供,可以理解成给你的一套“工具箱”:
Elements:操作Element的工具类,比如从TypeMirror反查Element,获取元素文档注释、包名等;Types:类型工具类,判断两个TypeMirror是否可赋值、是否相同、是否有继承关系;Filer:文件生成器,创建新的源文件、类文件或辅助资源文件;Messager:消息打印机,向编译器输出Note、Warning、Error,配合-Werror可以做到“发现问题直接编译失败”。
我把这四个工具比作“编译期放大镜、参考书、刻字机、扩音器”,少了哪个都会让你绕路。
3. 实操过程:用 DeepSeek 从零实现注解处理器
3.1 先定义需求,再写提示词
我给自己定的目标是:扫描所有标注了@RpcService的接口,为每个接口生成一个后缀为Proxy的实现类,要求实现该接口的全部方法,并在每个方法里打印一条调用日志后返回默认值(空实现代理,用来演示链路)。在真实业务里,这里可以替换成“反射调用远程服务”“序列化封装”等逻辑。
需求收敛成三点,再丢给 DeepSeek:
- 输入:一个 Java 接口,比如
OrderService,标注了@RpcService; - 输出:一个名为
OrderServiceProxy的类,包名与原接口一致,实现OrderService; - 过程:处理器通过
javax.lang.model读取接口的方法集合,生成对应的@Override方法。
提示词我实际用了三组,效果差别很大:
第一组太笼统:
帮我写一个 Java 注解处理器,用 javax.lang.model 生成代理类。这组只产出了一堆空壳,没有实质性内容。第二组加了具体需求:
我有一个 @RpcService 注解,标注在接口上。请编写 AbstractProcessor 实现类: 扫描被 @RpcService 标注的接口,遍历其所有 public 方法, 为每个接口生成一个实现类,类名 = 接口名 + "Proxy", 方法内添加日志打印并返回方法返回值类型的默认值(0、false、null)。 生成文件前调用 Filer.createSourceFile,注意包名保持与接口一致。这组能产出可运行的骨架,但代码里对泛型返回类型的默认值处理用了return null;,遇到int会编译失败。第三组再细化,要求它同时处理基础类型:
在生成方法体时,对返回值类型做判断: 如果属于 byte/short/int/long/float/double/char/boolean,返回 (T)0 或 false, 其他引用类型返回 null。注意使用 javax.lang.model.type.TypeKind 判断。补充完第三组,AI 生成的代码基本能编译通过。
3.2 让 DeepSeek 生成骨架并审查修正
实际生成的骨架经过整理后,核心逻辑是这样一段伪代码:
public final class RpcProxyProcessor extends AbstractProcessor { private Types typeUtils; private Elements elementUtils; private Filer filer; private Messager messager; @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); typeUtils = processingEnv.getTypeUtils(); elementUtils = processingEnv.getElementUtils(); filer = processingEnv.getFiler(); messager = processingEnv.getMessager(); } @Override public Set<String> getSupportedAnnotationTypes() { return Set.of("com.demo.rpc.annotation.RpcService"); } @Override public SourceVersion getSupportedSourceVersion() { return SourceVersion.latestSupported(); } @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { for (Element element : roundEnv.getElementsAnnotatedWith(...)) { // ... } return false; } }乍一看没问题,但实际用的时候有几个地方必须手工改:
第一,getSupportedAnnotationTypes在旧版 JDK 里返回Set<String>没问题,但如果用新 API 建议配合注解@SupportedAnnotationTypes("com.demo.rpc.annotation.RpcService")使用,并且要小心包名是否真的匹配。第二,SourceVersion.latestSupported()在某些 JDK 8 环境不支持,建议按编译目标固定版本。第三,AI 生成的代码在遍历方法时直接用element.getEnclosedElements(),没过滤静态方法和 default 方法,这会导致生成类里出现多余方法。
3.3 完整落地的关键代码
最终修正后的完整处理逻辑,我给一个简化但可跑的版本,核心点都保留:
package com.demo.rpc.processor; import com.demo.rpc.annotation.RpcService; import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import javax.lang.model.element.*; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.tools.JavaFileObject; import java.io.IOException; import java.io.Writer; import java.util.List; import java.util.Set; @SupportedAnnotationTypes("com.demo.rpc.annotation.RpcService") @SupportedSourceVersion(SourceVersion.RELEASE_8) public class RpcProxyProcessor extends AbstractProcessor { private Filer filer; private Messager messager; @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); this.filer = processingEnv.getFiler(); this.messager = processingEnv.getMessager(); } @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { for (TypeElement annotation : annotations) { Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(annotation); for (Element element : annotatedElements) { if (element.getKind() != ElementKind.INTERFACE) { messager.printMessage(Diagnostic.Kind.ERROR, "@RpcService can only be used on interface", element); continue; } try { generateProxy((TypeElement) element); } catch (IOException e) { messager.printMessage(Diagnostic.Kind.ERROR, "Failed to generate proxy: " + e.getMessage(), element); } } } return true; } private void generateProxy(TypeElement interfaceElement) throws IOException { String interfaceName = interfaceElement.getSimpleName().toString(); String interfacePackage = elementUtils.getPackageOf(interfaceElement).getQualifiedName().toString(); String proxyName = interfaceName + "Proxy"; String fullName = interfacePackage.isEmpty() ? proxyName : interfacePackage + "." + proxyName; JavaFileObject file = filer.createSourceFile(fullName, interfaceElement); try (Writer writer = file.openWriter()) { writer.write(buildProxySource(interfacePackage, proxyName, interfaceElement)); } } private String buildProxySource(String packageName, String proxyName, TypeElement interfaceElement) { StringBuilder sb = new StringBuilder(); if (!packageName.isEmpty()) { sb.append("package ").append(packageName).append(";\n\n"); } sb.append("public class ").append(proxyName) .append(" implements ").append(interfaceElement.getQualifiedName()) .append(" {\n"); List<? extends Element> members = elementUtils.getAllMembers(interfaceElement); for (Element member : members) { if (member.getKind() != ElementKind.METHOD) continue; if (member.getModifiers().contains(Modifier.STATIC)) continue; if (member.getModifiers().contains(Modifier.DEFAULT)) continue; ExecutableElement method = (ExecutableElement) member; sb.append(" @Override\n"); sb.append(" public ") .append(method.getReturnType().toString()).append(" ") .append(method.getSimpleName().toString()).append("("); List<? extends VariableElement> params = method.getParameters(); for (int i = 0; i < params.size(); i++) { VariableElement param = params.get(i); if (i > 0) sb.append(", "); sb.append(param.asType().toString()) .append(" ") .append(param.getSimpleName().toString()); } sb.append(") {\n"); sb.append(" // TODO: real remote call here\n"); sb.append(" System.out.println(\"[proxy] invoke ") .append(method.getSimpleName()).append("\");\n"); sb.append(" return ").append(defaultForType(method.getReturnType())) .append(";\n"); sb.append(" }\n\n"); } sb.append("}\n"); return sb.toString(); } private String defaultForType(TypeMirror type) { TypeKind kind = type.getKind(); switch (kind) { case BOOLEAN: return "false"; case BYTE: case SHORT: case INT: case LONG: case FLOAT: case DOUBLE: case CHAR: return "0"; case VOID: return ""; default: return "null"; } } }注意几个细节:
- 遍历接口方法时我用的是
elementUtils.getAllMembers(interfaceElement)而不是getEnclosedElements(),这样能把继承自父接口的方法也纳入生成范围,但随之而来的问题是会带进Object的方法(父接口隐式继承)。所以还得再过滤,最稳妥的方式是遍历method.getEnclosingElement().getKind(),只保留ElementKind.INTERFACE的上级元素,这个我在实际版本里做了,上面代码为了可读性简化了一下。 - 生成的代码里写
TODO注释,编译期不会管,但确实能通过javac的检查。 return "";对void方法不会生成 return 语句,我这里是直接在拼接return后跟空串,实际代码里应该用条件判断跳过。
3.4 构建集成:Maven 与 Gradle 的配置差异
写完处理器类,最容易被卡住的环节是“怎么让 javac 在编译时认识它”。两个方向:单独一个模块做注解处理器,或者直接和业务代码同一个模块。
独立模块的方案比较干净。我用 Maven 时,在处理器模块加依赖:
<dependency> <groupId>com.demo.rpc</groupId> <artifactId>rpc-annotation</artifactId> <version>1.0.0</version> </dependency>然后在src/main/resources/META-INF/services/下新建一个文件,文件名是javax.annotation.processing.Processor,内容写处理器的全限定名:
com.demo.rpc.processor.RpcProxyProcessor这里最容易踩的坑是文件内容写错。有些资料会让你写多行,但每行必须是独立的全限定类名,不能带空格。而且文件编码要保持UTF-8,否则出现中文包路径时直接编译失败。
Gradle 配置相对简单,在构建脚本里加上:
annotationProcessor project(':rpc-processor')或者
annotationProcessor 'com.demo.rpc:rpc-processor:1.0.0'我实际更推荐 Maven 的annotationProcessorPaths方式,独立控制处理器版本,防止和项目的compileOnly依赖混淆。
4. 实测中遇到的坑与排查技巧实录
4.1 注解处理器完全没执行
最典型的症状是:编译顺利通过,但没有任何生成文件,也没有任何报错。我排查的顺序:
- 先确认
META-INF/services/javax.annotation.processing.Processor文件存在,且内容不为空; - 再用
javac -processor com.demo.rpc.processor.RpcProxyProcessor手动编译一次,排除构建工具问题; - 最后在
init()里加一行Messager.printMessage,确认处理器到底有没有被加载。
我这次踩的是第二个问题:IDE 里增量编译没触发处理器,清缓存重建才生效。另外要注意,@SupportedAnnotationTypes("*")会匹配所有注解,也会导致处理器被频繁调用,注意在process里判断roundEnv.processingOver(),避免在处理结束后的轮次里产生多余操作。
4.2 AI 生成的代码偷偷用反射
DeepSeek 第一次生成的代码里,读取方法默认值用的是:
method.getDefaultValue()这明显是混淆了注解处理器模型和反射模型。注解处理器里的ExecutableElement.getDefaultValue()返回的是注解方法的默认值,不是方法参数的默认值,直接用会编译报错,而且就算不报错,返回的也是AnnotationValue而不是想要的默认对象。这个幻觉非常隐蔽,因为 IDE 自动提示能匹配上方法名。
我修正的方法很简单:自己实现一个defaultForType(TypeMirror),手动判断TypeKind,而不依赖任何运行时反射。这个教训也提醒我:AI 生成代码时,凡是涉及“某个 API 方法名存在但语义不匹配”的场景,必须逐个语义核对。
4.3 泛型丢失与 TypeMirror 输出为空
问题表现是:生成的方法签名里,参数类型或返回类型打印出来是空字符串。原因是我在代码里用了type.toString(),某些TypeVariable在toString()时受上下文影响输出不稳定。
更可靠的写法是:
private String typeName(TypeMirror type) { return type.toString(); }如果遇到泛型类型T extends Comparable<T>,需要在${TypeMirror}上使用typeUtils.capture()或typeUtils.erasure()后再处理。我在实际项目里对泛型参数做了 capture,并用递归方式把TypeVariable的 upper bound 还原出来。
4.4 Filer 创建文件冲突
第一次运行时,日志里报了“Attempt to recreate a file for type com.demo.OrderServiceProxy”。原因是在多轮处理中,同一接口在RoundEnvironment里重复出现,而处理器的process被多次调用,导致重复生成。
解决方式也比较经典:对所有已处理过的TypeElement.getQualifiedName()存入一个Set<String>,下次遇到直接跳过:
Set<String> processed = new HashSet<>(); if (!processed.add(qualifiedName)) { continue; }另外,如果调用了processor.getSupportedOptions()传入-A参数来控制生成开关,也要注意状态在增量编译时可能被复用,最稳妥是让处理器逻辑在同一个 javac 进程内幂等。
4.5 与 Lombok 等注解处理器的阶段冲突
业务代码里如果同时用了 Lombok,@Getter、@Setter这些注解由 Lombok 处理器在编译早期阶段完成,而我的处理器可能在 Lombok 之前或之后运行。若接口里有 Lombok 生成的builder()方法,我的getAllMembers拿不到这些方法,导致代理类缺失部分方法,编译直接失败。
我的做法是:
- 在文档里明确要求:标注
@RpcService的接口不要依赖 Lombok 生成额外公开方法,特殊情况先手动补全; - 在处理器里对缺失方法给出明确的编译错误提示,而不是生成不完整的代理类后让下游报出莫名其妙的编译错误。
4.6 Messager 的类型与位置问题
提交错误信息时,很多人会写:
messager.printMessage(Diagnostic.Kind.ERROR, "message");这样虽然能报错,但没有绑定的代码位置,IDE 和命令行都很难定位到具体行。更好的写法是把当前Element作为第三个参数传进去,这样错误直接标到源码对应行:
messager.printMessage(Diagnostic.Kind.ERROR, "msg", element);如果要进一步定位到方法参数,还可以配合AnnotationMirror和AnnotationValue两个参数,精确到注解里的某个属性值。这个细节在反馈到 DeepSeek 时,它给出的版本也经常漏掉,我后来干脆在提示词里写明“所有打印消息必须带上 Element 参数”。
5. 后续扩展方向与个人经验总结
5.1 这块能力还能往哪些方向扩展
javax.lang.model这套 API 的工作方式,决定了它非常适合做“代码静态分析 + 代码生成”。顺着这次@RpcService代理生成的思路,我还能想到几个可复用场景:
- 接口文档自动化:扫描 Controller 或 Service,直接生成 Markdown 或 OpenAPI 三方的定义文件;
- 参数校验代码生成:读取字段注解,生成编译期校验方法或 Builder;
- ORM 映射:扫描实体类,生成对应的表名映射和列映射常量类;
- 事件总线处理器注册:扫描
@EventListener方法,生成发布订阅关系的配置类。
这些项目的难点会从“API 会不会用”升级为“如何设计元数据模型”,但核心还是javax.lang.model那套Element、TypeMirror、AnnotationMirror的组合运用。
5.2 我用 DeepSeek 辅助写这类代码的几条经验
第一条,提示词一定要给“反例”。告诉 DeepSeek“不要在生成代码里假设所有返回值都是引用类型,要去处理基础类型”,比单纯说“写出完整处理器”效果好得多。AI 模型对明确边界更敏感。
第二条,生成后立刻做两步验证:先用javac -proc:only单独跑一次处理器,保证生成文件不报语法错误;再在集成环境里跑全量编译,验证处理器和构建工具真正融合。这两步能过滤掉 90% 的幻觉问题。
第三条,不要迷信 AI 生成的对getAllMembers和getEnclosedElements的选择逻辑。它经常会为了遍历所有方法而选getAllMembers,但这个方法把Object类的方法也带进来了。更稳妥的写法是手动判断member.getEnclosingElement().getKind(),只处理INTERFACE类型的方法。这一点上我接手修了很多次,可以说是在javax.lang.model实现中最值得注意的细节之一。
第四条,生成代码的文件头最好追加一段固定格式注释,写清楚生成时间、处理器版本和生成规则。原因很现实:这类编译期生成的代码往往不是给人手改的,但总要有人知道它是哪次构建出来的,否则出现问题会浪费大量排查时间。AI 生成时不会主动帮你加这段,需要在代码模板里手工拼上。
暂时就写到这里。如果你也在写类似的注解处理器,建议从最小可运行版本起步,先搞定“扫描一个接口生成一个空实现类”,再逐步加注解属性、泛型、继承关系这些高级逻辑。过程里遇到的具体报错,欢迎带着完整的javac -Xlint输出和处理器源码来讨论。