记一次数据填充的代码结构优化过程

378 阅读7分钟

1,背景

交互人员需要对业务系统产生的业务数据进行数据报表的统计,数据报表多维度多指标,而原始业务表繁多,交互人员可能对业务表不是很熟悉,而交互人员对统计的维度和指标比较熟悉。

2,现有方案

由产品从交互人员这边收集数据报表的相关信息,在clickhouse中建设宽表。 宽表由开发编写的定时任务从原始业务表中生成。最开始一个宽表的数据直接用一整个sql通过关联几张重要的业务表生成并写入clickhouse。

3,新需求1

宽表 table_a,需要新增两个字段 cid、cname 用来存客户信息

3.1,解决办法

定义一个填充cid、cname的方法fillCidCname(),在定时任务的方法中,先调用fillCidCname()方法将数据填充再将数据写入

3.2,版本一

public void jobSyncTableA() {
        String sql = "****";
        // 从业务表生成table_a的数据
        List<TableA> all = bBHelper.getRaw(TableA.class, sql);
        // 数据填充
        fillCidCname(all);
        // 数据存储
        store();
    }
private void fillCidCname(List<TableA> result) {
        // 填充 cid、cname到 table_a
    }

4,新需求2

宽表 table_b 也需要新增字段 cid、cname 用来存客户信息。 同时table_a、table_b 都需要新增字段 area_name 、zone_id 来存区域信息

4.1,思考

直接用复制版本一中的fillCidCname()方法,并在同步table_b的定时任务中调用进行cid、cname的数据填充?再仿照cid、cname的做法,去处理area_name、zone_id?

相信你们的答案也是 NO!

4.2,解决办法

我这边经过思考后,决定提取公共代码,抽象出来了一个公共字段的接口ICommonFields,用于对cid、cname、area_name、zone_id的数据填充进行统一的封装。 ICommonFields接口主要三点作用

  1. 定义实体类中的公共的需要填充数据的字段
  2. 定义数据填充需要的参数字段
  3. 封装数据填充的代码实现

4.3,版本二

public interface ICommonFields {

   // 定义数据填充需要的参数字段
    String getUin();
    String getZoneName();

    // 定义实体类中的公共的需要填充数据的字段
    void setCId(String cId);
    void setCName(String cName);
    void setZoneId(String zoneId);
    void setAreaName(String areaName);

    // 封装数据填充的代码实现
    static void fillFieldsByUinAndZoneName(List<? extends ICommonFields> result) {
        if (ListUtils.isEmpty(result)) {
            return;
        }
        fillCidCnameByUin(result);
        fillZoneInfoByZoneName(result);
    }

    static void fillCidCnameByUin(List<? extends ICommonFields> result) {
        // 填充cid、cname
    }
    
    static void fillZoneInfoByZoneName(List<? extends ICommonFields> result) {
        // 填充zone_id、area_name
    }
    
}


@Table("table_a")
@Data 
public class TableA implements ICommonFields {
    private String cid;
    private String cName;
    private String areaName;
    private String zoneId;
    private String zoneName;
    private String uin;
    // 其他 table_a 的字段
    ****
}
@Table("table_b")
@Data 
public class TableB implements ICommonFields {
    private String cid;
    private String cName;
    private String areaName;
    private String zoneId;
    private String zoneName;
    private String bin;
    / / 其他 table_b 的字段
    ****
}

// table_a的定时任务
public void jobSyncTableA() {
        String sql = "****";
        // 从业务表生成table_a的数据
        List<TableA> all = bBHelper.getRaw(TableA.class, sql);
        // 数据填充
        ICommonFields. fillFieldsByUinAndZoneName(all);
        // 数据存储
        store();
    }

// table_b的定时任务
public void jobSyncTableB() {
        String sql = "****";
        // 从业务表生成table_b的数据
        List<TableB> all = bBHelper.getRaw(TableB.class, sql);
        // 数据填充
        ICommonFields. fillFieldsByUinAndZoneName(all);
        // 数据存储
        store();
    }

5,进一步优化

5.1,思考

版本二的代码虽然解决了代码重复的问题,会存在其他问题吗?

IComonFields 接口功能杂糅,既定义了相关字段,又有数据填充的实现,可它是一个接口,这种带业务属性的静态方法实现是不提倡的,且后续字段的新增,会导致这个接口越来越臃肿,所以需要进行进一步的优化。

从问题定性的角度来看,主要是功能杂糅所引起的,那我们将它的功能进行拆分。

  1. 定义字段接口,只用于字段的定义
  2. 提供统一的数据填充接口服务 FillFieldService。

从问题定量的角度来看,后续字段的新增,可以按照不同的属性范畴来区分。 例:

  1. 针对cid、cname,属于客户信息,定义客户信息字段接口 CustomerInfoFiller
  2. 针对 areaName、zoneId,属于区域信息,定义区域信息字段接口 ZoneInfoFiller

这种设计下,后续再新增字段,就可以考虑是否能纳入已有的字段接口中来,不属于统一业务范畴的话,再新增新的 字段接口,并在 FillFieldService 中定义新的字段填充方法即可。

5.2,版本三

// 客户信息字段
public interface CustomerInfoFiller {

    String getUin();
    void setCId(String cId);
    void setCName(String cName);

}
// 区域信息字段
public interface ZoneInfoFiller {

    String getZoneName();
    void setZoneId(String zoneId);
    void setAreaName(String areaName);

}
// 字段数据填充服务接口
public interface FillFieldService {

    /**
     *   根据 {@link CustomerInfoFiller} 进行客户信息数据填充
     * @param list 待填充的数据
     */
    void fillCustomerInfo(List<? extends CustomerInfoFiller> list);

    /**
     *   根据 {@link ZoneInfoFiller} 进行区域信息数据填充
     * @param list 待填充的数据
     */
    void fillZoneInfo(List<? extends ZoneInfoFiller> list);

}
@Table("table_a")
@Data 
public class TableA implements ZoneInfoFiller, CustomerInfoFiller {
    private String cid;
    private String cName;
    private String areaName;
    private String zoneId;
    private String zoneName;
    private String uin;
    // 其他 table_a 的字段
    ****
}
@Table("table_b")
@Data 
public class TableB implements ZoneInfoFiller, CustomerInfoFiller {
    private String cid;
    private String cName;
    private String areaName;
    private String zoneId;
    private String zoneName;
    private String bin;
    / / 其他 table_b 的字段
    ****
}

// table_a的定时任务
public void jobSyncTableA() {
        String sql = "****";
        // 从业务表生成table_a的数据
        List<TableA> all = bBHelper.getRaw(TableA.class, sql);
        // 数据填充
        fillFieldService.fillCustomerInfo(all);
        fillFieldService.fillZoneInfo(all);
        // 数据存储
        store();
    }

// table_b的定时任务
public void jobSyncTableB() {
        String sql = "****";
        // 从业务表生成table_b的数据
        List<TableB> all = bBHelper.getRaw(TableB.class, sql);
        // 数据填充
        fillFieldService.fillCustomerInfo(all);
        fillFieldService.fillZoneInfo(all);
        // 数据存储
        store();
    }

6,最终版本

6.1,思考

版本三的设计真的完美了吗?

我们不妨再设想一下,如果table_a又又又新增了一个字段other,而且不属于客户信息也不属于区域信息,那我们得新建一个 OtherFiller 接口来定义字段 other,然后在fillFieldService 中定义并实现other的数据填充,然后还得去改 table_a 的定时任务代码。

以上做法实际上是不太符合开闭原则的,定时任务与字段填充服务的方法耦合在了一起。

我们可以定义一个数据填充的适配器,定时任务直接与数据填充适配器交互,适配器负责对具体数据填充服务的调用。

6.2,核心类图

image.png

6.3,版本四

/**
 *   数据填充标识接口
 * @see ZoneInfoFiller
 * @see CustomerInfoFiller
 * @see FillMethod
 * @see FillerAdapter
 */
public interface Filler {
}

/**
 * 数据填充方法注解,用于指定数据填充的方法交由{@link FillFieldService} 进行数据填充 <br/>
 * 目前数据填充方法的入参只能是 {@code  List<? extends Filler> list}  <br/>
 * 例: <br/>
 * {@code  List<? extends CustomerInfoFiller> list}  <br/>
 * {@code  List<? extends ZoneInfoFiller> list} <br/>
 * @see FillFieldService 定义数据填充方法的接口
 * @see FillerAdapter 数据填充调用
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface FillMethod {

    /**
     *  指定数据填充的方法名,数据填充方法在{@link PplFillFieldService} 中定义
     * @return 数据填充的方法名
     */
    String methodName();

}

/**
 *  数据填充适配器,用于数据填充与业务代码的解藕,自动适配到各个具体的{@link Filler} 进行数据填充
 * @see Filler
 * @see FillMethod
 * @see FillFieldService
 */
@Component
public class FillerAdapter {

    @Resource
    private FillFieldService fillFieldService;

    /**
     *  数据填充,适配到各个{@link Filler}
     * @param list 待填充的数据
     */
    public void fill(List<? extends Filler> list) {
        if (ListUtils.isEmpty(list)) {
            return;
        }
        Filler filler = null;
        for (Filler item : list) {
            if (item != null) {
                filler = item;
                break;
            }
        }
        if (filler == null) {
            return;
        }
        Class<?>[] interfaces = filler.getClass().getInterfaces();
        for (Class<?> anInterface : interfaces) {
            // 是否为数据填充接口
            if (Filler.class.isAssignableFrom(anInterface)) {
                // 获取指定的数据填充方法,进行数据填充
                FillMethod fillMethod = anInterface.getAnnotation(FillMethod.class);
                ReflectUtil.invoke(pplFillFieldService, fillMethod.methodName(), list);
            }
        }
    }

}
// 客户信息字段
@FillMethod(methodName = "fillCustomerInfo")
public interface CustomerInfoFiller extends Filler {

    String getUin();
    void setCId(String cId);
    void setCName(String cName);

}
// 区域信息字段
@FillMethod(methodName = "fillZoneInfo")
public interface ZoneInfoFiller extends Filler {

    String getZoneName();
    void setZoneId(String zoneId);
    void setAreaName(String areaName);

}

/**
 * 负责字段的填充  <br/>
 * 目前数据填充方法的入参只能是 {@code  List<? extends Filler> list}  <br/>
 * 例: <br/>
 * {@code  List<? extends CustomerInfoFiller> list}  <br/>
 * {@code  List<? extends ZoneInfoFiller> list} <br/>
 * @see FillerAdapter
 * @see FillMethod
 */
public interface FillFieldService {

    /**
     *   根据 {@link CustomerInfoFiller} 进行客户信息数据填充
     * @param list 待填充的数据
     */
    void fillCustomerInfo(List<? extends CustomerInfoFiller> list);

    /**
     *   根据 {@link ZoneInfoFiller} 进行区域信息数据填充
     * @param list 待填充的数据
     */
    void fillZoneInfo(List<? extends ZoneInfoFiller> list);

}

// table_a的定时任务
public void jobSyncTableA() {
        String sql = "****";
        // 从业务表生成table_a的数据
        List<TableA> all = bBHelper.getRaw(TableA.class, sql);
        // 调用适配器进行数据填充
        fillerAdapter.fill(all);
        // 数据存储
        store();
    }

// table_b的定时任务
public void jobSyncTableB() {
        String sql = "****";
        // 从业务表生成table_b的数据
        List<TableB> all = bBHelper.getRaw(TableB.class, sql);
        // 调用适配器进行数据填充
        fillerAdapter.fill(all);
        // 数据存储
        store();
    }

6.4,真的就这样了吗

版本四的模式,一个是使用者更简单了,调用一个fill方法就好了,原来是要调多个不同的fill方法;第二个是filler和fillerService的提供方,直接写自己的定义和实现就好了,不用去某个定时任务里加代码了。

看起很不错,那如果Filler上的FillMethod注解的 methodName 值写错了呢,编译期也检查不出来,而且这个 methodName 是和 FillFieldService 的方法强关联的。

所以后续会加上编译期检查的机制,看看大家有没有什么好的做法。

2023-06-27 加上了后续 优化过程,见 # 记一次数据填充的代码结构优化过程2