文章目录
基层程序员世界中,我们经常会遇到形形色色的产品需求,在快速的迭代中,我们设计的代码会变得越来越臃肿。之所以如此,来源于我们没有抽出时间来做更好的抽象设计,仅仅是基于SpringMVC的
Controller
、Service
、Repository
三层分层设计。我们把诸如更多的业务逻辑代码通过一个个方法成员方法不断编织在Service
层,呈现给后续的其他研发人员,就是各种private
修饰的方法,一个Service
类代码行数随着日积月累不断堆积,我相信大家心有体会。试想一下,我们未尝不想更好的设计,以提高程序的可维护性,但是对于我们应该怎么可以做到更好的抽象设计,来达到避免这种现象产生呢,本文就是一篇指导案例,带给你心灵的共鸣。
一、需求背景
有个产品需求,需要做一个统计查询,从交互设计上就是两个选项卡,也代表两种不同统计口径。
- 第一种口径就是:广告曝光日期+直播间ID,作为查询条件。
- 第二种口径就是:广告曝光日期+直播间ID+职位编号,作为查询条件。
本质上这两种查询统计口径,其实业务处理逻辑有相似之处,但亦有差异之处,我们怎么设计才能更好的复用共性代码呢?同时也能处理各自场景的差异化代码呢?其实这里我们汇能想到,学过设计模式的程序员都能想到,使用“模板方法模式”,就是可以针对处理这种需求呃。同时后续再有其他场景的统计也可以进而复用这部分抽象设计呢,鉴于此,我通过思考这种问题,有了本篇文章的产生。
二、详细设计
UML设计
如上图,在这其中,有四个关键的类,StatCaliberEnum
、StatHandleDispatcher
、StatContext
、AbstractStatHandle
,这四个类是整个模块的关键所在。
包设计
整个统计模块包划分,位于business
层的dashboard
包下,后续新增场景只需要在handler
子包增加子包(类似videoad
,这是一个视频广告的统计查询)。如果某个场景增加统计口径,只需要在对应场景子包中新增子类Handler
来继承基类(AbstractHandler
)即可。
我们可以看出一个子包,只需要创建一个上下文类(譬如VideoAdStatContext
)来继承基类StatContext
,同时新增Hander
来负责具体的业务处理。
每个子类做到职责单一,符合开闭原则,这就是整个模块的设计巧妙之处。
三、程序设计
1、StatCaliberEnum
所有统计场景模块的统计口径枚举。
它起到对具体场景的细分传统模式下,我们可能都会使用Integer
或者String
来表示,方法中来通过if else 程序条件来控制路由。但枚举的好处在于,我们把这种分支通过枚举成员来统一维护,通过查看枚举成员我们就可以知道当前场景的分类。后续增加场景,也只需要在枚举中增加成员即可。
/**
* 数据看板统计口径
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/30 9:39 AM
*/
@Getter
public enum StatCaliberEnum {
/**
* (视频广告)按天的明细数据
*/
VIDEO_AD_BY_DAILY(1, "按天的明细数据"),
/**
* (视频广告)按岗位的明细数据
*/
VIDEO_AD_BY_JOB_NUMBER(2, "按岗位的明细数据"),
;
private final int value;
private final String name;
StatCaliberEnum(Integer value, String name) {
this.value = value;
this.name = name;
}
}
2、VideoAdStatCaliberEnum
定义一个枚举,通过这个枚举对外暴露内部支持的业务场景,这是具体某个场景的细分。
它起到对具体场景的细分传统模式下,我们可能都会使用Integer
或者String
来表示,方法中来通过if else 程序条件来控制路由。但枚举的好处在于,我们把这种分支通过枚举成员来统一维护,通过查看枚举成员我们就可以知道当前场景的分类。后续增加场景,也只需要在枚举中增加成员即可。
/**
* 视频广告数据看板统计口径
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/30 9:39 AM
*/
@Getter
@ThriftStruct
public enum VideoAdStatCaliberEnum {
/**
* 未知
*/
UNKNOWN(-1, "未知"),
/**
* 按天的明细数据
*/
BY_DAILY(1, "按天的明细数据"),
/**
* 按岗位的明细数据
*/
BY_JOB_NUMBER(2, "按岗位的明细数据"),
;
@ThriftField(1)
private final int value;
private final String name;
VideoAdStatCaliberEnum(Integer value, String name) {
this.value = value;
this.name = name;
}
public static VideoAdStatCaliberEnum valueOf(Integer value) {
for (VideoAdStatCaliberEnum each : VideoAdStatCaliberEnum.values()) {
if (each.getValue() == value) {
return each;
}
}
return VideoAdStatCaliberEnum.UNKNOWN;
}
}
3、LiveDashboardBusiness
对外暴露的业务类,相当于一个门面。
从该类注入的成员我们可以看到,它仅仅依赖StatHandleDispatcher
这个类,并没有直接依赖底层的Handler
,做到降低耦合。内部方法中,通过实例化Context
对象,并调用StatHandleDispatcher
的方法来进行处理分发。
/**
* @author : 石冬冬-Sieg Heil
* @since 2022/11/29 4:02 PM
*/
@Service
public class LiveDashboardBusiness {
@Autowired
private StatHandleConverter statHandleConverter;
@Autowired
private StatHandleDispatcher statHandlerDispatcher;
/**
* 获取视频广告数据看板
* <p>
* 可以参考:{@link com.zhaopin.c.live.operation.business.LiveRoomBusiness#getDashboard(DashboardRequestBO)}
*
* @param request 请求参数
* @return 返回值
*/
public VideoAdDashboardBO getVideoAdDashboard(VideoAdDashboardRequestBO request) {
VideoAdStatCaliberEnum statCaliber = VideoAdStatCaliberEnum.valueOf(request.getCaliber());
if (Objects.equals(VideoAdStatCaliberEnum.UNKNOWN, statCaliber)) {
throw new ForbiddenException("非法参数,statCaliber={}", statCaliber.getName());
}
StatCaliberEnum caliber = null;
if (Objects.equals(VideoAdStatCaliberEnum.BY_DAILY, statCaliber)) {
caliber = StatCaliberEnum.VIDEO_AD_BY_DAILY;
}
if (Objects.equals(VideoAdStatCaliberEnum.BY_JOB_NUMBER, statCaliber)) {
caliber = StatCaliberEnum.VIDEO_AD_BY_JOB_NUMBER;
}
VideoAdStatContext context = statHandleConverter.convertToVideoAdStatContext(caliber, request);
statHandlerDispatcher.execute(context);
VideoAdDashboardBO response = context.getResponse();
return response;
}
}
4、StatHandleDispatcher
统计处理的路由分发器,该类封装了一系列处理类集合。
从分发的处理逻辑来看,输入参数Context
封装了场景的枚举;同时,Handler对外暴露了获取处理场景的枚举。这就是可以查找到对应一个Handler
来处理。整个内部封装,无需关注所有的Handler
子类,增加子类,也无需修改这个类。
/**
* 统计处理分发器
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/30 10:00 AM
*/
@Component
public class StatHandleDispatcher {
@Autowired
private List<AbstractStatHandler> handlers;
/**
* 分发处理
*
* @param context 统计上下文对象
*/
public void execute(StatContext context) {
handlers.stream()
.filter(each -> Objects.equals(each.getStatCaliber(), context.getStatCaliber()))
.forEach(each -> each.execute(context));
}
}
5、StatContext
统计上下文对象,封装了内部处理所依赖的请求参数,以及输出的统计结果。
该类是一个泛型基类,需要具体场景来继承该类。
/**
* 统计上下文对象
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/30 9:46 AM
*/
@ToString
@Getter
@Setter
public class StatContext<Request, Response> {
/**
* 统计口径
*/
private StatCaliberEnum statCaliber;
/**
* 请求参数
*/
private Request request;
/**
* 统计结果
*/
private Response response;
}
5、AbstractStatHandler
统计处理类,所有子类需要继承该抽象类。
相关子类需要实现抽象方法doHandle(Context context)
。
/**
* 抽象统计处理器
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/30 9:46 AM
*/
public abstract class AbstractStatHandler<Context extends StatContext> implements LoggerService {
/**
* 统计口径
*/
protected StatCaliberEnum statCaliber;
/**
* 构造函数
*
* @param statCaliber 统计口径
*/
public AbstractStatHandler(StatCaliberEnum statCaliber) {
this.statCaliber = statCaliber;
}
/**
* 公共方法
*
* @param context 上下文对象
*/
public void execute(Context context) {
if (logDebug()) {
getLog().info("[StatHandle]req={}", JsonUtils.toJson(context.getRequest()));
}
doHandle(context);
if (logDebug()) {
getLog().info("[StatHandle]res={}", JsonUtils.toJson(context.getResponse()));
}
}
/**
* 处理
*
* @param context 上下文对象
*/
protected abstract void doHandle(Context context);
public StatCaliberEnum getStatCaliber() {
return statCaliber;
}
}
6、LoggerService
日志DEBUG服务接口,相关子类可以实现该接口,来通过配置进而控制日志debug输出。
import org.slf4j.Logger;
/**
* 日志DEBUG服务接口
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/29 11:40 AM
*/
public interface LoggerService {
/**
* 日志门面
*
* @return 日志门面
*/
Logger getLog();
/**
* 是否启用日志输出
*
* @return 是否启用日志输出
*/
default boolean logDebug() {
return true;
}
}
7、AbstractVideoAdStatHandler
具体场景模块的抽象类,这个是视频广告统计查询的进一步抽象封装,从业务逻辑看来,它实现了抽象方法doHandle(Context context)
,同时又定义一个抽象方法abstract VideoAdEffectBO doQueryEffect(VideoAdDashboardRequestBO request);
,意味着子类来实现。
/**
* 抽象 视频广告统计处理器
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/30 10:20 AM
*/
public abstract class AbstractVideoAdStatHandler extends AbstractStatHandler<VideoAdStatContext> {
@Autowired
protected LiveDashboardConverter liveDashboardConverter;
@Autowired
protected ThirdLiveRoomBusiness liveRoomBusiness;
@Autowired
protected ThirdVideoAdEffectBusiness thirdVideoAdEffectBusiness;
/**
* 构造函数
*
* @param statCaliber 统计口径
*/
public AbstractVideoAdStatHandler(StatCaliberEnum statCaliber) {
super(statCaliber);
}
@Override
protected void doHandle(VideoAdStatContext context) {
VideoAdDashboardRequestBO request = context.getRequest();
Long roomId = request.getRoomId();
RoomBasicInfoBO basicInfo = liveRoomBusiness.getRoomByRoomId(roomId);
if (Objects.isNull(basicInfo)) {
throw new NotExistException("直播间不存在[" + roomId + "]", JsonUtils.toJson(request));
}
if (!Objects.equals(ProductTypeEnum.VIDEO_AD.getValue(), basicInfo.getProductType())) {
throw new ServerException("非视频广告直播间[" + roomId + "]", JsonUtils.toJson(request));
}
VideoAdEffectBO adEffectBO = doQueryEffect(request);
VideoAdDashboardBO dashboard = liveDashboardConverter.convertToVideoAdDashboardBO(adEffectBO, request, basicInfo);
context.setResponse(dashboard);
}
/**
* 统计查询处理
*
* @param request 请求参数
* @return 统计结果
*/
abstract VideoAdEffectBO doQueryEffect(VideoAdDashboardRequestBO request);
}
1、VideoAdStatContext
视频广告统计上下文对象
/**
* 视频广告统计上下文对象
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/30 9:49 AM
*/
@ToString(callSuper = true)
@Getter
@Setter
public class VideoAdStatContext extends StatContext<VideoAdDashboardRequestBO, VideoAdDashboardBO> {
/**
* 请求参数
*/
private VideoAdDashboardRequestBO request;
/**
* 统计结果
*/
private VideoAdDashboardBO response;
}
2、VideoAdStatByDailyHandler
具体场景模块的子类
/**
* 视频广告统计处理器(按天)
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/30 9:55 AM
*/
@Service
@Slf4j
public class VideoAdStatByDailyHandler extends AbstractVideoAdStatHandler {
/**
* 构造函数
*/
public VideoAdStatByDailyHandler() {
super(StatCaliberEnum.VIDEO_AD_BY_DAILY);
}
@Override
VideoAdEffectBO doQueryEffect(VideoAdDashboardRequestBO request) {
VideoAdEffectRequestBO adEffectRequestBO = liveDashboardConverter.convertToVideoAdEffectRequestBO(request);
VideoAdEffectBO adEffectBO = thirdVideoAdEffectBusiness.getByDaily(adEffectRequestBO);
return adEffectBO;
}
@Override
public Logger getLog() {
return log;
}
}
3、VideoAdStatByJobNumberHandler
具体场景模块的子类
/**
* 视频广告统计处理器(按职位)
*
* @author : 石冬冬-Sieg Heil
* @since 2022/11/30 9:55 AM
*/
@Service
@Slf4j
public class VideoAdStatByJobNumberHandler extends AbstractVideoAdStatHandler {
/**
* 构造函数
*/
public VideoAdStatByJobNumberHandler() {
super(StatCaliberEnum.VIDEO_AD_BY_JOB_NUMBER);
}
@Override
VideoAdEffectBO doQueryEffect(VideoAdDashboardRequestBO request) {
VideoAdEffectRequestBO adEffectRequestBO = liveDashboardConverter.convertToVideoAdEffectRequestBO(request);
VideoAdEffectBO adEffectBO = thirdVideoAdEffectBusiness.getByDailyWithJobNumber(adEffectRequestBO);
return adEffectBO;
}
@Override
public Logger getLog() {
return log;
}
}
四、总结
- 1、
StatHandleDispatcher
这个类相当于统计处理的路由分发类,它通过公共方法execute(StatContext context)
来对外调用;从源码我们看到,通过@Autowired
自动装配了AbstractStatHandler
的所有子类实现;它起到的另外一个作用,就是外部不需要知道某种场景具体该调用某个处理类,起到一个桥梁代理的左右。 - 2、
StatContext
这个类也是个关键类,具体内部处理逻辑都依赖这个对象,它是一个上下文,所有封装请求参数和处理结果。具体相关场景需要继承该类,并指定具体的入参类和出参类。 - 3、
StatCaliberEnum
这个类,是个统计口径枚举,外部通过查看这个类,就可以知道当前内部统计处理支持哪些场景,它不仅作为StatContext
这个类的成员变量,同时也作为AbstractStatHandler
这个类的构造函数成员,意味着所有处理类都需要重写抽象类的构造函数,进而指定某个处理类是支持统计场景。 - 4、整个类通过上下线接,各司其职,最终达到开闭原则。如果修改某个统计处理只需要找到处理类即可;如果新增场景,只需要新增一个处理类来扩展即可。这就是抽象设计的美妙之处。