前言
在项目开发时遇到这样一个场景:从上游传过来一个实体类对象 newEntity,但它只有部分字段,需要去库中查出对应的旧对象 oldEntity 做一次补全(相当于一次部分更新)。
一开始我们这样编码的:
| 1 | public FlightBasic merge(FlightBasic newEntity){ | 
很快我们发现了问题:不仅类的字段很多,这样的 Entity 也有很多,所以到处都是又臭又长的 merge 方法。
其实不难总结这些代码的共性:
- 一个 Entity 只能和本身的类型 Merge
- 代码都是判空后对属性的 getter/setter方法的重复调用
为了简化代码(偷懒),就在想能不能通过统一的处理完成这些 Merge 逻辑。首先想到的是代码生成,类似 MyBatis Generator,重复的工作交给脚本或者工具多好。但是很快就否定了这种方案,配置这些类的工作量也不小,而且很多类中有自己的业务逻辑,一不小心覆盖了也不行。那注解行不行呢?Spring Boot 就大量使用注解替换了之前配置繁杂的 XML 文件。实际编码后验证是可行的!本文将介绍如何配合使用 Java 的注解和反射实现上述问题中的 Merge 操作。示例代码已上传到 GitHub 上。
注解
每当你创建描述符性质的类或者接口时,一旦其中包含重复性的工作,就可以考虑使用注解来简化与自动化该过程。一个注解准确意义上来说,只不过是一种特殊的注释而已,如果没有解析它的代码,它可能连注释都不如。而解析注解往往有两种方式,一种是编译期扫描,典型的例如 @Override,这种只适用于编译器已经认识的注解,一般都是 JDK 内置注解;另一种则是通过运行期反射,也是在我们自定义注解后需要自己编码的。
元注解
Java提供了四种元注解,专门负责修饰其他注解:
- @Target:注解的作用目标
- @Retention:注解的生命周期
- @Documented:注解是否要包含在 JavaDoc 文档中
- @Inherited:子类是否继承该注解
@Target
@Target 用于定义注解的作用域,源码如下:
| 1 | 
 | 
其中 ElementType 是一个枚举类型,包含以下值:
- ElementType.TYPE:允许注解作用在类、接口和枚举上
- ElementType.FIELD:允许作用在属性字段上
- ElementType.METHOD:允许作用在方法上
- ElementType.PARAMETER:允许作用在方法参数上
- ElementType.CONSTRUCTOR:允许作用在构造器上
- ElementType.LOCAL_VARIABLE:允许作用在本地局部变量上
- ElementType.ANNOTATION_TYPE:允许作用在注解上
- ElementType.PACKAGE:允许作用在包上
当允许有多个作用域时使用花括号 {} 包裹,比如这样:@Target({ ElementType.TYPE, ElementType.FIELD })
@Retention
@Retention 用于标明注解存在的生命周期,源码如下:
| 1 | 
 | 
RetentionPolicy 也是一个枚举类型,包含以下值:
- RetentionPolicy.SOURCE:在源文件中保留,编译为 .class 文件时会被丢弃,比如- @Override、- @SuppressWarnings
- RetentionPolicy.CLASS:.class 文件中会保留,但运行时丢弃
- RetentionPolicy.RUNTIME:运行时保留,可以通过反射获取
剩下两种类型的注解用的不多,也比较简单,这里不再详细的介绍了,只需要知道他们各自的作用即可。@Documented 修饰的注解,当执行 JavaDoc 文档打包时会被保存进文档,否则将被丢弃。@Inherited 注解修饰的注解是有继承性的,也就说我们的注解修饰了一个类,而该类的子类将自动继承父类的该注解。该注解只作用于类,对属性或方法无效。
反射
Java 反射机制是指在程序运行时(Runtime)识别和使用对象的类型信息。以下内容摘自《Thinking in Java》,是我认为的有利于理解反射的核心概念。
要理解反射的工作原理,首先必须知道类型信息在运行时是如何表示的。这项工作是由称为 Class 对象的特殊对象完成的,它包含了与类有关的信息。事实上,Class 对象就是用来创建类的所有“常规”对象的。
类是程序的一部分,每个类都有一个 Class 对象。换言之,每当编写并且编译了一个新类,就会产生一个 Class 对象(更恰当地说,是被保存在一个同名的
.class文中)。为了生成这个类的对象,运行这个程序的 Java 虚拟机(JVM)将使用被称为类加载器的子系统。类加载器首先检查这个类的 Class 对象是否已经加载。如果尚未加载,默认的类加载器就会根据类名查找.class文件。一旦某个类的 Class 对象被载入内存,它就被用来创建这个类的所有对象。
Class类和java.lang.reflect类库一起对反射的概念提供了支持,该类库包含Field、Method以及Constructor类。这些类型的对象是由 JVM 在运行时创建的,用以表示未知类里对应的成员。这样你就可以使用Constructor创建新的对象,用get()和set()方法读取和修改与Field对象关联的字段,用invoke()方法调用与Method对象关联的方法等等。另外,还可以调用getFields()、getMethods()和getConstructors()等方法以返回表示字段、方法以及构造器的对象的数组(可以查看 Class 类源码了解更多资料)。重要的是,要认识到反射机制并没有什么神奇之处。当通过反射与一个未知类型的对象打交道时,JVM 只是简单地检查这个对象,看它属于哪个特定的类。在用它做其他事情之前必须先加在那个类的 Class 对象。对于反射机制来说,
.class文件在编译时是不可获取的,所以是在运行时打开和检查.class文件。
这里简单的介绍一下我们编码时用到的与反射有关的方法:
- obj.getClass();Java 提供了三种方式获取类的 Class 对象的引用:- Class.forName()通过类名获取;- obj.getClass();通过对象获取和- Object.class通过类字面常量获取。
- getDeclaredFields();获取类中声明的所有字段,不包括父类和接口中的字段。它与- getFields()的区别在于,- getFields()是获取所在类以及父类和接口中的所有访问修饰符为- public的字段。
- getAnnotation();获取标注的注解实例对象。
实现
Merge 粒度与等级
回到我们的问题上来,首先考虑一个问题:Merge 的粒度应该位于什么层次?如果是类级别的,意味着整个类的所有属性将一视同仁,要么都合并要么都不,显然这样是不行的。Merge 的粒度应该在属性字段上,为此,我们专门定义了字段的 Merge 等级 Level:
| 1 | public enum Level { | 
自定义注解 @MergeOn
接下来就需要自定义我们的注解:@MergeOn,作用在字段上,在运行时有效:
| 1 | 
 | 
使用 default 关键字可以设置缺省等级为 Required。
通过反射将字段设值
现在,还差最后一步,就是如何通过反射将字段设值。要想让实体类实现 Merge,首先需要定义一个接口 Merging 让实体类去继承它,然后接口中得有一个默认方法 mergeWith(T newEntity),它将做下面这几件事:
- 通过反射获取 Entity 声明的所有字段
- 通过反射获取字段上 @MergeOn注解的 Level
- 通过 Level 走不同的分支去给字段设值,是强制或是忽略亦或是只有非空情况下才设值。
| 1 | public interface Merging<T extends Merging> { | 
我们看看代码具体的实现步骤(注释中标明了顺序):
- this.getClass().getDeclaredFields();获取类中声明的所有字段,不包含父类和接口中的字段。- this指向的是实现接口的实体类。
- field.getAnnotation(MergeOn.class);获取字段上的- @MergeOn注解实例。
- field.setAccessible(true);由于字段通常是- private修饰的,就需要获取访问权(并不是修改实际权限),否则将抛出- IllegalAccessException异常。由此可见,反射有可能破环封装性。
- 根据 Level 做相应设值。如果无注解或 Level 为 Required,则新值非空时覆盖旧值;如果是强制(Mandatory)则始终覆盖;否则(Ignored),忽略不做处理。
- field.set(this, field.get(newEntity));字段设值的实际操作,接口设计看上去有点反人类。第一个参数为需要设置字段的对象,此处为 oldEntity,第二个参数为要设置的值,此处为新对象的字段值。
验证
接下来我将演示如何使用这个接口,并编写单元测试验证其正确性。
首先定义实体类 Entity。
- 为了测试全面性,加入了各种类型的属性字段,包括基础类型、集合、数组和类;同时,@MergeOn注解覆盖了所有 Level,还包括缺省和无注解的情况。
- 为了简单,直接在字段声明时赋初值。即 new Entity()出来的可以看成旧的模型对象(oldEntity)。
| 1 | public class Entity implements Merging<Entity> { | 
理想情况下,string 字段新值应当被忽略;stringList 在新值为 null 时也会被强制覆盖;其他缺省和无注解的应当行为与 @MergeOn(level = Level.Required) 一致,即非空时才会覆盖旧值。以下为测试代码:
| 1 | 
 | 
参考
- JAVA 注解的基本原理 —— 掘金
- Thinking in Java 第14章:类型信息
