由字段变化记录功能引发的从业务层面到技术层面的设计演进

背景:

在诸多系统配置类/信息展示类页面中,多数会需要记录和展示关键字段的变化,如:开关状态,通常需要记录:状态修改时间、原始值、新值、变更说明。但是实际开发中,我们的状态值往往是码值,例如 开-00 关-01,在记录存储中则面临一个问题,需要对类似的字段进行转义,因为我们需要展示。

现在要把字段记录功能进行统一,即统一保存入口和读取反显。此为背景,将围绕此背景展开技术层面的代码设计。

面临的问题

  • 在保存变更的时候,如果需要进行转义的字段如何处理
    • 方案一:保存的时候只保留原值,读取的时候在逻辑中进行转义。
    • 方案二:保存的时候转义并落库,读取的时候直接读。

    以上两个方案都存在一定的局限性,若使用方案一则会导致在读取的时候需要根据业务场景做不同的转义逻辑;同样方案二则会导致在存储的时候也需要根据业务场景做逻辑转义;

    这种情况下,如果做成同一个接口,则堆叠的逻辑越来越多,难以维护。如果分散开来开发,则相同的处理分散到不同的接口,同样难以维护。

  • 字段转义的灵活性
    • 我们在字段转义中,每个人有不同的习惯,或者说不同的字段类型有不同的处理形式,如枚举、字典表等形式,这就很考验设计的灵活性,需要考虑到转义形式扩展。

设计目标

  • 从分散到统一,记录变更应该是一件事,有统一的入口,所以在记录变更行为需要和业务功能解耦。
  • 需要简单易用,开发者应该可以很快上手使用这个功能。
  • 可扩展性要强,开发者无论是修改亦或是新增都应该在很短的时间内完成。
  • 性能要在接受范围之内。

设计思路

  • 领域/业务区分
    • 使用类注解进行领域/业务类的标识。 在Entity(数据库实体)定义业务类型。
  • 新老对象的对比
    • 使用注解的形式对需要记录变更的字段进行标识。
  • 【*】码值转义如何实现

    这个部分是设计的难点,正式因为转义的不确定性,才会扩展到这个问题。其实我们需要做的是如何在入口即定义好转义,即预先定义事件,在字段明确发生变化后进行触发。

    • 预设事件、后置事件执行
    • 特定处理句柄handle
    • 转义相关功能灵活组合

设计实现

首先大概了解一下流程:

image-20250609140739644

痛点分析

从上面的流程图可以分析数据准备阶段是比较简单的。根据字段寻找对应字段处理事件是比较难处理的一个点,在设计成统一入口后,对于传进来的类是未知的,所以直接在Entity中添加方法是不可取的(获取不到真实类型不能调用方法)。

这里考虑将事件和注解结合,如果注解和字段以及字段处理事件绑定那问题就会得到有效解决。

但是随之而来的问题是注解如何和事件进行结合。


代码设计

首先我们定义一个实体类-商店【Shop】接下来将从Shop类中字段修改进行设计实现

package com.yiwyn.domain;

import lombok.Data;

@Data
public class Shop {

    private String shopId;

    private String shopName;

    private String shopAddress;

    private String shopPhone;

    private String shopEmail;

    private String openStatus;
}
  • 实体类注解
package com.yiwyn.utils.modify.anno.modify;


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

// 类注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ModifyType {

    // 领域/业务 类型
    String value() default "";

}

// ===============================================================分割线


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

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface ModifyHandleClassField {

    // 字段类型
    String filedType();
}

在有了基本的类注解和字段注解后,我们可以使用AOP、反射等技术来根据字段注解获取字段并进行字段比较。

package com.yiwyn.utils.modify;

import com.yiwyn.utils.modify.anno.modify.ModifyType;
import com.yiwyn.utils.modify.entity.ModifyEntity;
import com.yiwyn.utils.modify.handle.ModifyAnnoHandleRouter;
import com.yiwyn.utils.modify.handle.event.base.BaseTypeEvent;
import com.yiwyn.utils.modify.handle.router.base.BaseModifyFieldRoute;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;


public class ModifyComponent {



    public static <T> ModifyEntity record(T oldObject, T newObject) throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {

        Class<?> clazz = oldObject.getClass();
        ModifyType modifyTypeAnnotation = clazz.getAnnotation(ModifyType.class);
        if (modifyTypeAnnotation == null) {
            return null;
        }

        ModifyEntity modifyEntity = new ModifyEntity();
        modifyEntity.setModifyType(modifyTypeAnnotation.value());
        ArrayList<ModifyEntity.ModifyItem> modifyItems = new ArrayList<>();
        modifyEntity.setModifyItems(modifyItems);

        Field[] declaredFields = clazz.getDeclaredFields();

        for (Field field : declaredFields) {


            Annotation[] annotations = field.getAnnotations();
            for (Annotation annotation : annotations) {

                Class<? extends Annotation> annotationClazz = annotation.annotationType();
                if (!ModifyAnnoHandleRouter.containsRoute(annotationClazz)) {
                    continue;
                }

                field.setAccessible(true);

                // 获取新老值并进行对比,若相同则跳过
                Object oldObj = field.get(oldObject);
                Object newObj = field.get(newObject);

                if (Objects.equals(oldObj, newObj)) {
                    continue;
                }

                ModifyEntity.ModifyItem item = new ModifyEntity.ModifyItem();

                item.setOldValue(oldObj);
                item.setNewValue(newObj);

                // 设置可见性
                field.setAccessible(true);

                此处省略处理逻辑

                // 添加对变更字段列表中
                modifyItems.add(item);
            }
        }
        return modifyEntity;
    }
}

以上流程完成之后,我们最简单的部分已经实现完成了,实现了变化自动的记录。

接下来设计字段事件如何和字段注解结合起来。

  • 为了提高复用性,我们首先将转义行为进行区分,通用型和个性化型。

    • 通用型:具有通用性,如用户id和用户名转义。
    • 个性化型:某些字段个性化的字段,如商店里面的某个货架摆放商品类型。
  • 分别通过两类场景进行不同的实现

    • 通用型实现:注解中可以存在的类型包含 Class<?> ,有了这个信息我们就可以很好的约定处理方式。

      public class BaseHandle {
      
          public void action(ModifyEntity.ModifyItem modifyItem) {
              // 什么都不做
          }
      }
      
      // 扩展 。。。。 扩展
      public class ComCdeHandle extends BaseHandle {
      
          @Override
          public void action(ModifyEntity.ModifyItem modifyItem) {
              Object newValue = modifyItem.getNewValue();
              modifyItem.setNewValueDescription("修改前" + newValue);
              Object oldValue = modifyItem.getOldValue();
              modifyItem.setOldValueDescription("修改后" + oldValue);
          }
      }
      

      我们的注解就可以这样改造,这样设计后,我们可以在注解中标识某个字段的同时标识处理的方法(事件)

      @Retention(RetentionPolicy.RUNTIME)
      @Target(ElementType.FIELD)
      public @interface ModifyHandleClassField {
      
          String filedType();
      
          Class<? extends BaseHandle> handleClass() default BaseHandle.class;
      
      }
      

      Shop类中的改善后,可以看到shopEmail、openSts都可以使用这个注解进行字段的个性化处理

      package com.yiwyn.domain;
      
      import com.yiwyn.utils.modify.anno.modify.ModifyEventField;
      import com.yiwyn.utils.modify.anno.modify.ModifyHandleClassField;
      import com.yiwyn.utils.modify.anno.modify.ModifyType;
      import com.yiwyn.utils.modify.handle.event.ShopModifyTypeEvent;
      import com.yiwyn.utils.modify.handle.handleClass.ComCdeHandle;
      import lombok.Data;
      
      @Data
      @ModifyType(value = "Shop")
      public class Shop {
      
          private String shopId;
      
          private String shopName;
      
          private String shopAddress;
      
          private String shopPhone;
      
          @ModifyHandleClassField(filedType = "shopEmail", handleClass = ComCdeHandle.class)
          private String shopEmail;
      
          @ModifyHandleClassField(filedType = "openSts", handleClass = ComCdeHandle.class)
          private String openStatus;
      }
      

      @ModifyHandleClassField 相应的处理逻辑同步改造

              ModifyHandleClassField modifyFieldEventAnno = field.getAnnotation(ModifyHandleClassField.class);
              String filedType = modifyFieldEventAnno.filedType();
              Class<? extends BaseHandle> handleClass = modifyFieldEventAnno.handleClass();
              // 因为demo环境是纯java,所以使用反射来创建这个类,在spring框架中,可以快捷的使用class从springContext中获取示例bean。
              BaseHandle baseHandle = handleClass.newInstance();
              modifyItem.setModifyType(filedType);
              baseHandle.action(modifyItem);
      

      其实为什么需要区分的原因也能发现了,如果每个定制化的字段转义都这样来一套,会造成java类激增,同时如果使用spring等框架,context中bean数据也会激增,所以针对个性化处理字段我们选择另一种处理方案。

    • 个性化型处理实现方案:在目前的设计中,我们尝试把事件的概念扩大,首先我们需要对字段进行事件注册,同一个实体中的字段处理总是不变的,我们可以把事件处理的注册和触发进行分离;于是有了以下方案

      把这个类(实体)的事件测试放到类注解中来。

      @Retention(RetentionPolicy.RUNTIME)
      @Target(ElementType.TYPE)
      public @interface ModifyType {
      
          // 领域/业务 类型
          String value() default "";
      
      
          Class<? extends BaseTypeEvent> typeEventClazz() default BaseTypeEvent.class;
      
      }
      
      // 事件注册
      public class ShopModifyTypeEvent extends BaseTypeEvent<Shop> {
      
          public static final String EVENT_ADDRESS = "event.address";
      
          @Override
          public void registerEvent() {
              eventMap.put(EVENT_ADDRESS, modifyItem -> {
                  Object newValue = modifyItem.getNewValue();
                  modifyItem.setNewValueDescription("事件触发" + newValue);
                  Object oldValue = modifyItem.getOldValue();
                  modifyItem.setOldValueDescription("事件触发" + oldValue);
              });
      
          }
      }
      

      实际使用, 可以看到我们首先在ShopModifyTypeEvent类中注册了事件,同时添加ModifyEventField注解,注解中包含了eventId,即事件注册的id。

      // 实际使用
      @Data
      @ModifyType(value = "Shop", typeEventClazz = ShopModifyTypeEvent.class)
      public class Shop {
      
          private String shopId;
      
          private String shopName;
      
          @ModifyEventField(filedType = "address", eventId = ShopModifyTypeEvent.EVENT_ADDRESS)
          private String shopAddress;
      
          private String shopPhone;
      
          @ModifyHandleClassField(filedType = "shopEmail", handleClass = ComCdeHandle.class)
          private String shopEmail;
      
          @ModifyHandleClassField(filedType = "openSts", handleClass = ComCdeHandle.class)
          private String openStatus;
      }
      
      // 事件注解
      @Retention(RetentionPolicy.RUNTIME)
      @Target(ElementType.FIELD)
      public @interface ModifyEventField {
      
          String filedType();
      
          String eventId() default "";
      
      }
      

      触发机制,同通用型字段处理一样,我们拿到的不再是Class,而是eventId,再通过eventId ,去到这个类(这里是Shop)中找到响应的注册的事件。

              ModifyEventField modifyFieldEventAnno = field.getAnnotation(ModifyEventField.class);
              String filedType = modifyFieldEventAnno.filedType();
              String eventId = modifyFieldEventAnno.eventId();
              modifyItem.setModifyType(filedType);
              baseTypeEvent.triggerEvent(eventId, modifyItem);
      
  • 通用变更方法

    package com.yiwyn.utils.modify;
    
    import com.yiwyn.utils.modify.anno.modify.ModifyType;
    import com.yiwyn.utils.modify.entity.ModifyEntity;
    import com.yiwyn.utils.modify.handle.ModifyAnnoHandleRouter;
    import com.yiwyn.utils.modify.handle.event.base.BaseTypeEvent;
    import com.yiwyn.utils.modify.handle.router.base.BaseModifyFieldRoute;
    
    import java.lang.annotation.Annotation;
    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationTargetException;
    import java.util.ArrayList;
    import java.util.HashMap;
    import java.util.Map;
    import java.util.Objects;
    
    
    public class ModifyComponent {
    
    
        private static final Map<Class<?>, BaseTypeEvent<?>> baseTypeEventMap = new HashMap<>();
    
    
        public static <T> ModifyEntity record(T oldObject, T newObject) throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
    
            Class<?> clazz = oldObject.getClass();
            ModifyType modifyTypeAnnotation = clazz.getAnnotation(ModifyType.class);
            if (modifyTypeAnnotation == null) {
                return null;
            }
    
            Class<? extends BaseTypeEvent> typeEventClazz = modifyTypeAnnotation.typeEventClazz();
    
            BaseTypeEvent<?> baseTypeEvent = baseTypeEventMap.computeIfAbsent(typeEventClazz, aClass -> {
                // 获取类事件
                try {
                    return typeEventClazz.newInstance();
                } catch (Exception ignored) {
                }
                // 高版本java使用以下 「jdk9 将 newInstance标记为过期了」
                // BaseTypeEvent<?> baseTypeEvent = modifyTypeAnnotation.typeEventClazz().getDeclaredConstructor().newInstance();
                return null;
            });
    
            ModifyEntity modifyEntity = new ModifyEntity();
            modifyEntity.setModifyType(modifyTypeAnnotation.value());
            ArrayList<ModifyEntity.ModifyItem> modifyItems = new ArrayList<>();
            modifyEntity.setModifyItems(modifyItems);
    
            Field[] declaredFields = clazz.getDeclaredFields();
    
            for (Field field : declaredFields) {
    
    
                Annotation[] annotations = field.getAnnotations();
                for (Annotation annotation : annotations) {
    
                    Class<? extends Annotation> annotationClazz = annotation.annotationType();
                    if (!ModifyAnnoHandleRouter.containsRoute(annotationClazz)) {
                        continue;
                    }
    
                    field.setAccessible(true);
    
                    // 获取新老值并进行对比,若相同则跳过
                    Object oldObj = field.get(oldObject);
                    Object newObj = field.get(newObject);
    
                    if (Objects.equals(oldObj, newObj)) {
                        continue;
                    }
    
                    ModifyEntity.ModifyItem item = new ModifyEntity.ModifyItem();
    
                    item.setOldValue(oldObj);
                    item.setNewValue(newObj);
    
                    // 设置可见性
                    field.setAccessible(true);
                    // 匹配处理方法
                    BaseModifyFieldRoute modifyFieldRoute = ModifyAnnoHandleRouter.getModifyFieldRoute(annotationClazz);
                    // 加工item对象
                    modifyFieldRoute.processField(field, item, baseTypeEvent);
    
                    // 添加对变更字段列表中
                    modifyItems.add(item);
                }
            }
            return modifyEntity;
        }
    
    }
    

以上,基本的框架已经实现。HandleClass实现了通用转义逻辑的处理;事件注册,事件id的形式解决了个性化处理的问题。

总结

本次改造中,基于现有的业务逻辑从开发者的角度组件分析、拆解到实现整体逻辑架构,同时依赖注解中参数可以包含Class进行扩展,使用元类进行部分改造。同时强调事件(以上的事件其实在Java中可以理解为函数式接口)通过注册和触发的机制,使得同领域(实体)中的注册集中,使用分散,有效解决业务逻辑和基础系统工具强耦合的问题。