在项目开发时遇到这样一个场景:从上游传过来一个实体类对象 newEntity
,但它只有部分字段,需要去库中查出对应的旧对象 oldEntity
做一次补全(相当于一次部分更新)。
一开始我们这样编码的:
public FlightBasic merge(FlightBasic newEntity){ if (newEntity.getAirportCode() != null){ this.setAirportCode(newEntity.getAirportCode()); } if (newEntity.getCraftNo() != null){ this.setCraftNo(newEntity.getCraftNo()); } if (newEntity.getCraftType() != null){ this.setCraftType(newEntity.getCraftType()); } // too long ... return this; }
很快我们发现了问题:不仅类的字段很多,这样的 Entity 也有很多,所以到处都是又臭又长的 merge
方法。
其实不难总结这些代码的共性:
getter/setter
为了简化代码(偷懒),就在想能不能通过统一的处理完成这些 Merge 逻辑。首先想到的是代码生成,类似 MyBatis Generator,重复的工作交给脚本或者工具多好。但是很快就否定了这种方案,配置这些类的工作量也不小,而且很多类中有自己的业务逻辑,一不小心覆盖了也不行。那注解行不行呢?Spring Boot 就大量使用注解替换了之前配置繁杂的 XML 文件。实际编码后验证是可行的!本文将介绍如何 配合使用 Java 的注解和反射 实现上述问题中的 Merge 操作。示例代码已上传到 GitHub 上。
每当你创建描述符性质的类或者接口时,一旦其中包含重复性的工作,就可以考虑使用注解来简化与自动化该过程。一个注解准确意义上来说,只不过是一种特殊的注释而已,如果没有 解析
它的代码,它可能连注释都不如。而解析注解往往有两种方式,一种是 编译期扫描
,典型的例如 @Override
,这种只适用于编译器已经认识的注解,一般都是 JDK 内置注解;另一种则是通过 运行期反射
,也是在我们自定义注解后需要自己编码的。
Java提供了四种元注解,专门负责 修饰其他注解 :
@Target @Retention @Documented @Inherited
@Target
用于定义注解的 作用域
,源码如下:
@Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.ANNOTATION_TYPE) public @interface Target { /** * Returns an array of the kinds of elements an annotation type * can be applied to. */ ElementType[] value(); }
其中 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
用于标明注解存在的 生命周期
,源码如下:
@Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.ANNOTATION_TYPE) public @interface Retention { /** * Returns the retention policy. */ RetentionPolicy value(); }
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 等级 Level
:
public enum Level { /* 无论新值为何值,都会覆盖旧值 */ Mandatory, /* 如果新值不为null,覆盖旧值,否则不覆盖 */ Required, /* 忽略,不做合并处理 */ Ignored; }
接下来就需要自定义我们的注解: @MergeOn
,作用在字段上,在运行时有效:
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface MergeOn { Level level() default Level.Required; }
使用 default
关键字可以设置缺省等级为 Required
。
现在,还差最后一步,就是如何通过反射将字段设值。要想让实体类实现 Merge,首先需要定义一个接口 Merging
让实体类去继承它,然后接口中得有一个默认方法 mergeWith(T newEntity)
,它将做下面这几件事:
@MergeOn
public interface Merging<T extends Merging> { default void mergeWith(T newEntity) { if (this.getClass() != newEntity.getClass()) { throw new MergeWithDifferentClassTypeException( String.format("<%s> can not merge with other class type <%s>", this.getClass().getName(), newEntity.getClass().getName())); } Field[] fields = this.getClass().getDeclaredFields(); // 1 for (Field field : fields) { MergeOn mergeOn = field.getAnnotation(MergeOn.class); // 2 field.setAccessible(true); // 3 try { if (mergeOn == null || mergeOn.level().isRequired()) { // 4... Object newFieldValue = field.get(newEntity); if (newFieldValue != null) { field.set(this, newFieldValue); } continue; } if (mergeOn.level().isMandatory()) { // 4... field.set(this, field.get(newEntity)); // 5 } } catch (IllegalAccessException ex) { throw new MergeOnFieldIllegalAccessException(field.getName(), ex); } } } }
我们看看代码具体的实现步骤(注释中标明了顺序):
this.getClass().getDeclaredFields();
获取类中声明的所有字段,不包含父类和接口中的字段。 this
指向的是实现接口的实体类。 field.getAnnotation(MergeOn.class);
获取字段上的 @MergeOn
注解实例。 field.setAccessible(true);
由于字段通常是 private
修饰的,就需要 获取访问权
(并不是修改实际权限),否则将抛出 IllegalAccessException
异常。由此可见,反射有可能破环封装性。 field.set(this, field.get(newEntity));
字段设值的实际操作,接口设计看上去有点反人类。第一个参数为需要设置字段的对象,此处为 oldEntity,第二个参数为要设置的值,此处为新对象的字段值。 接下来我将演示如何使用这个接口,并编写单元测试验证其正确性。
首先定义实体类 Entity。
@MergeOn new Entity()
public class Entity implements Merging<Entity> { @MergeOn(level = Level.Ignored) private String string = "string"; @MergeOn private int anInt = 1; @MergeOn(level = Level.Mandatory) private List<String> stringList = Arrays.asList("a", "b", "c"); @MergeOn(level = Level.Required) private String[] strings = new String[]{"sArr1", "sArr2"}; private Email email = new Email("zz@163.com"); private Set<Email> emails = Sets.newLinkedHashSet(new Email("zz@163.com"), new Email("zz@gmail.com")); public static class Email { private String value; public Email(String value) { this.value = value; } } }
理想情况下, string
字段新值应当被忽略; stringList
在新值为 null 时也会被强制覆盖;其他缺省和无注解的应当行为与 @MergeOn(level = Level.Required)
一致,即非空时才会覆盖旧值。以下为测试代码:
@Test public void should_merge_new_entity_with_different_merge_level() { Entity newEntity = new Entity(); newEntity.setString("newString"); // Ignored newEntity.setAnInt(2); newEntity.setStringList(null); // Mandatory newEntity.setStrings(new String[]{"newS1", "newS2", "newS3"}); newEntity.setEmail(new Entity.Email("newEmail@163.com")); newEntity.setEmails(null); // Required, wished not be overwrited Entity entity = new Entity(); // oldEntity entity.mergeWith(newEntity); assertThat(entity.getString()).isEqualTo("string"); assertThat(entity.getAnInt()).isEqualTo(2); assertThat(entity.getStringList()).isNull(); assertThat(entity.getStrings()).containsSequence("newS1", "newS2", "newS3"); assertThat(entity.getEmail().getValue()).isEqualTo("newEmail@163.com"); assertThat(entity.getEmails()).hasSize(2); assertThat(entity.getEmails()).contains(new Entity.Email("zz@163.com")); assertThat(entity.getEmails()).contains(new Entity.Email("zz@gmail.com")); }