起因:实体类变更带来的“遗漏风险”

在之前撰写《程序设计中的接口准确性与变更扩散问题的讨论》一文时,我提到了一个常见的开发痛点:当核心业务实体类(如 User)发生变化时,与其高度相关的类(如用于接口参数的 UserDTO)往往容易被遗漏更新,导致运行时错误或数据丢失。

这类问题本质上是类之间的强耦合性带来的维护成本。虽然可以通过代码审查、文档规范等方式缓解,但都无法做到“编译即发现”。

于是,我开始思考:能否在编译阶段就自动检测出这种结构不一致?

经过调研和与 AI 的深入讨论,最终确定了一个可行方案:利用 Java 的编译期注解处理机制(Annotation Processing) + SPI 服务发现机制,实现一个自定义的编译期校验工具。


设计思路:通过 @Src 注解建立父子类关系

设想设计一个注解 @Src(Parent.class),将其标注在子类(如 DTO)上。在编译期间,自动检查该子类的所有字段是否都存在于父类(如实体类)中。若存在子类有而父类没有的字段,则编译失败并输出提示信息。

这样就能强制开发者在修改实体类后,必须同步更新所有相关 DTO 类,从而避免遗漏。

@Src(User.class)
public class UserLoginDTO {
    private String username;
    private String password;
}

✅ 目标:确保 UserLoginDTO 中所有字段都在 User 类中存在。


技术选型:为什么是编译期注解处理器?

我了解到 Lombok 正是通过编译期注解处理实现了“魔法”功能,因此判断这条路是可行的。

Java 提供了标准的注解处理接口:javax.annotation.processing.Processor,它允许我们在编译期间访问 AST(抽象语法树),进行代码分析甚至生成新类。

虽然编译期环境受限(如不能使用反射操作 Class 对象),但 JDK 提供的 javax.lang.model 包已经足够完成类型和元素的分析任务。

⚠️ 注意:这与运行时的 SPI 不同,属于编译期 SPI,由编译器自动加载处理器,无需手动调用 ServiceLoader


核心实现:SrcProcessor 注解处理器

下面是核心的注解处理器代码(由 AI 辅助生成并调试成功,过程确实不易 😅):

package com.momo.annotation.processor;

import com.momo.annotation.Src;

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.type.DeclaredType;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.ElementFilter;
import javax.lang.model.util.Types;
import javax.tools.Diagnostic;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@SupportedAnnotationTypes("com.momo.annotation.Src")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class SrcProcessor extends AbstractProcessor {

    private Types typeUtils;
    private Messager messager;

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        this.typeUtils = processingEnv.getTypeUtils();
        this.messager = processingEnv.getMessager();
        messager.printMessage(Diagnostic.Kind.NOTE, "SrcProcessor initialized");
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for (Element element : roundEnv.getElementsAnnotatedWith(Src.class)) {
            try {
                processAnnotatedElement(element);
            } catch (Exception e) {
                messager.printMessage(Diagnostic.Kind.ERROR,
                        "Failed to process @Src annotation: " + e.getMessage(), element);
                e.printStackTrace();
            }
        }
        return true;
    }

    private void processAnnotatedElement(Element element) {
        if (element.getKind() != ElementKind.CLASS) {
            messager.printMessage(Diagnostic.Kind.ERROR,
                    "@Src can only be applied to classes", element);
            return;
        }

        TypeElement classElement = (TypeElement) element;
        TypeElement parentElement = getAnnotationValueAsTypeElement(element);
        if (parentElement == null) return;

        Set<String> classFields = getFieldNames(classElement);
        Set<String> parentFields = getAllFieldNames(parentElement);

        if (!parentFields.containsAll(classFields)) {
            Set<String> extraFields = new HashSet<>(classFields);
            extraFields.removeAll(parentFields);

            messager.printMessage(Diagnostic.Kind.ERROR,
                    "Class contains fields not present in parent " + parentElement.getQualifiedName() +
                            ": " + extraFields, element);
        } else {
            messager.printMessage(Diagnostic.Kind.NOTE,
                    "All fields are present in parent class " + parentElement.getQualifiedName(), element);
        }
    }

    private TypeElement getAnnotationValueAsTypeElement(Element element) {
        List<? extends AnnotationMirror> annotations = element.getAnnotationMirrors();
        for (AnnotationMirror annotationMirror : annotations) {
            if ("com.momo.annotation.Src".equals(annotationMirror.getAnnotationType().toString())) {
                for (ExecutableElement method : annotationMirror.getElementValues().keySet()) {
                    if ("value".equals(method.getSimpleName().toString())) {
                        AnnotationValue value = annotationMirror.getElementValues().get(method);
                        if (value.getValue() instanceof TypeMirror) {
                            TypeMirror typeMirror = (TypeMirror) value.getValue();
                            if (typeMirror instanceof DeclaredType) {
                                Element typeElement = ((DeclaredType) typeMirror).asElement();
                                if (typeElement instanceof TypeElement) {
                                    return (TypeElement) typeElement;
                                }
                            }
                        }
                    }
                }
            }
        }
        messager.printMessage(Diagnostic.Kind.ERROR, "Failed to get @Src annotation value", element);
        return null;
    }

    private Set<String> getAllFieldNames(TypeElement typeElement) {
        Set<String> fieldNames = new HashSet<>();
        fieldNames.addAll(getFieldNames(typeElement));

        TypeMirror superClass = typeElement.getSuperclass();
        Element superElement = typeUtils.asElement(superClass);
        if (superElement instanceof TypeElement) {
            TypeElement superTypeElement = (TypeElement) superElement;
            if (!superTypeElement.getQualifiedName().toString().equals("java.lang.Object")) {
                fieldNames.addAll(getAllFieldNames(superTypeElement));
            }
        }
        return fieldNames;
    }

    private Set<String> getFieldNames(TypeElement typeElement) {
        Set<String> fieldNames = new HashSet<>();
        for (VariableElement field : ElementFilter.fieldsIn(typeElement.getEnclosedElements())) {
            fieldNames.add(field.getSimpleName().toString());
        }
        return fieldNames;
    }
}

注解定义:@Src

// Src.java
package com.momo.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Src {
    Class<?> value();
}

🔍 说明:

  • @Target(ElementType.TYPE):只能用于类、接口等类型上。
  • @Retention(RetentionPolicy.SOURCE):仅保留在源码阶段,不会进入字节码,符合编译期处理需求。

测试案例验证

实体类:User

package com.momo.entity;

public class User {
    private Integer id;
    private String username;
    private String password;
    private String rePassword;
    private String nickname;

    // getter/setter 省略
}

DTO 类:UserLoginDTO

package com.momo.dto;

import com.momo.annotation.Src;
import com.momo.entity.User;

@Src(User.class)
public class UserLoginDTO {
    private String username;
    private String password;

    // getter/setter 省略
}

测试结果:编译通过,日志输出:

Note: All fields are present in parent class com.momo.entity.User

如果在 UserLoginDTO 中添加一个 token 字段而 User 中没有,则编译失败:

Error: Class contains fields not present in parent com.momo.entity.User: [token]

Maven 配置关键点

由于注解处理器本身也需要编译,而它又要在编译期被使用,这就形成了“鸡生蛋蛋生鸡”的问题。

解决方案:使用 maven-compiler-plugin两个 execution 阶段

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>17</source>
                <target>17</target>
            </configuration>
            <executions>
                <!-- 第一阶段:先编译注解处理器本身,不启用注解处理 -->
                <execution>
                    <id>default-compile</id>
                    <configuration>
                        <compilerArgument>-proc:none</compilerArgument>
                    </configuration>
                </execution>
                <!-- 第二阶段:正常编译项目代码,此时处理器已就绪 -->
                <execution>
                    <id>project-compile</id>
                    <phase>compile</phase>
                    <goals>
                        <goal>compile</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

SPI 配置:注册处理器

resources/META-INF/services/ 下创建文件:

javax.annotation.processing.Processor

内容为:

com.momo.annotation.processor.SrcProcessor

这是 Java 编译器查找注解处理器的标准方式。


应用场景扩展

该机制不仅适用于:

  • 实体类 ↔ DTO
  • 主表 ↔ 备份表结构校验
  • API 请求/响应对象一致性检查
  • 配置类与模板字段匹配

未来可扩展方向

虽然当前实现较为基础,但具备良好的扩展潜力:

  • 支持字段排除(如 @IgnoreField
  • 支持嵌套对象结构校验
  • 反向检查:父类新增字段时,提醒 DTO 是否需要同步
  • 支持更复杂的映射规则(如命名转换)

结语:抛砖引玉,探索编译期的力量

本文提出并实现了一种通过编译期注解处理来保障类结构一致性的方案。虽然代码实现略显“简陋”,但它提供了一个可运行、可复用的项目结构和核心思路

你可以将这个模块独立打包为一个 Maven 依赖,也可以直接复制两个类和配置到项目中快速集成。

💡 特别致谢javax.annotation.processing.Processor 相关 API 属于 Java 的“深水区”,资料稀少,调试困难。感谢 AI 在探索过程中的陪伴与辅助生成,让非专业工具开发者也能触达这一领域。

这不仅是技术的胜利,更是自动化与预防性编程思想的体现。


源码可用性:文中所有代码均可直接使用,已在实际项目中验证可行性。


AI写的确实好看多了,如果代码走不通,可以参考原版


Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐