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接口主要三点作用
- 定义实体类中的公共的需要填充数据的字段
- 定义数据填充需要的参数字段
- 封装数据填充的代码实现
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 接口功能杂糅,既定义了相关字段,又有数据填充的实现,可它是一个接口,这种带业务属性的静态方法实现是不提倡的,且后续字段的新增,会导致这个接口越来越臃肿,所以需要进行进一步的优化。
从问题定性的角度来看,主要是功能杂糅所引起的,那我们将它的功能进行拆分。
- 定义字段接口,只用于字段的定义
- 提供统一的数据填充接口服务 FillFieldService。
从问题定量的角度来看,后续字段的新增,可以按照不同的属性范畴来区分。 例:
- 针对cid、cname,属于客户信息,定义客户信息字段接口 CustomerInfoFiller
- 针对 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,核心类图
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