模版模式+策略模式重构订单
1、SOLID设计原则
S:Single Responsibility Principle(单一职责)
一个类只负责一个功能领域中的相应职责。
O:Open Closed Principle(开闭原则)
对扩展开放,对修改关闭。
L:Liskov Substitution Principle(里氏替换)
所有引用基类的地方必须能透明地使用其子类的对象。
I:Interface Segregation Principle(接口隔离)
类之间的依赖关系应该建立在最小的接口上。
D:Dependency Inversion Principle(依赖反转)
依赖于抽象,不能依赖于具体实现。
还有其他的一些设计原则,例如:DRY,KISS等。设计原则是衡量代码好坏的一把尺子,设计模式只是围绕着设计原则总结出来的一些常用套路。
下面针对单一职责和开闭原则进行单独的说明。
1.1、Single Responsibility Principle
单一职责可以指在一个类,或者一个模块中,只包含单一的职责。
从模块层面上来看,当前我们的微服务包含底层的业务服务和上层的聚合服务。例如:在查询预约挂号订单详情时,需要返回医生的头像,职称等信息。预约挂号订单信息当中,不会冗余医生头像这些信息,所以在进行查询的时候,我们可以有两种方式:
- appointmentOrderDao.getOrderInfoAndDoctorInfo(),直接在预约挂号订单的Dao层查出订单信息和医生信息。显然这种违背了我们微服务拆分的原则和单一职责原则,预约挂号模块只需要关注自己模块的操作,不需要关注医生信息,只需要知道预约挂号订单当中的医生的doctorId和doctorName即可。
- 在medical聚合层分别调用transaction和user,查询出预约挂号订单信息和医生信息,并且组合出参返回给前端,这种做法是我们比较推荐的。
当然,有时候单一职责并不是这么好区分的。例如下面的一个类,我们分析下是否符合单一职责原则。
public class UserInfo {
private long userId;
private String username;
private String email;
private String telephone;
private long createTime;
private long lastLoginTime;
private String avatarUrl;
private String provinceOfAddress; // 省
private String cityOfAddress; // 市
private String regionOfAddress; // 区
private String detailedAddress; // 详细地址
...
}
对于这个问题,有两种不同的观点。
- UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则;
- 地址信息在 UserInfo 类中,所占的比重比较高,可以继续拆分成独立的 UserAddress 类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类的职责更加单一。
实际上,要从中做出选择,我们不能脱离具体的应用场景。
如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。但是,如果添加了电商的模块,用户的地址信息还会用在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。
所以,不同的业务场景,单一职责原则下的类或模块的划分都不一定一样。
1.2、Open Closed Principle
软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。这个描述比较简略,如果我们详细表述一下,添加一个新的功能应该是在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
单看这个理论,确实不太好理解,下面我们通过创建订单的各种校验这个例子来体会下。
1.2.1、不符合“开闭原则”的代码示例
public class OrderService {
public static void main(String[] args) {
OrderService orderService = new OrderService();
orderService.createOrder3(new CreateOrderReq());
}
public Integer createOrder1(Long userId, Map<String, Object> orderInfo) {
// 校验
OpposeChecker.check(userId, (Long) orderInfo.get("skuId"));
// 创建订单
return this.doCreateOrder1(userId, orderInfo);
}
public Integer createOrder2(Long userId, Map<String, Object> orderInfo) {
// 改动1
// 如果这个方法被多个地方调用,那么多个地方都需要修改
OpposeChecker.check(userId, (Long) orderInfo.get("skuId"), (Long) orderInfo.get("areaId"));
// 创建订单
return this.doCreateOrder1(userId, orderInfo);
}
public Integer createOrder3(CreateOrderReq createOrderReq){
CheckerFactory.getInstance().check(createOrderReq);
return doCreateOrder2(createOrderReq);
}
private Integer doCreateOrder2(CreateOrderReq createOrderReq) {
System.out.println("创建订单成功");
return 1;
}
private Integer doCreateOrder1(Long userId, Map<String, Object> orderInfo) {
System.out.println("创建订单成功");
return 1;
}
}
public class OpposeChecker {
public static Boolean check(Long userId, Long skuId) {
// 校验黑名单
System.out.println("校验用户黑名单, userId = " + userId);
// 校验库存
System.out.println("校验库存, skuId = " + skuId);
return true;
}
// 改动2
// 新增商品配送地区校验
public static Boolean check(Long userId, Long skuId, Long areaId) {
// 校验黑名单
System.out.println("校验用户黑名单, userId = " + userId);
// 校验库存
System.out.println("校验库存, skuId = " + skuId);
// 改动3
// 校验配送地址
System.out.println("校验库存, areaId = " + areaId);
return true;
}
}
显然上面的代码就是大家口中的垃圾代码,新增一种校验逻辑时,有三个地方做了改动:
- OpposeChecker#check()新增了入参,如果这个方法只是某个业务使用,影响范围还比较小,如果是公共的,那其他业务线的都得进行改动;
- OpposeChecker#check()方法内部逻辑进行了改动,需要重新进行单元测试;
- OrderService#createOrder2进行了入参修改。
试想,如果我们后面有更加多的业务校验,是否都需要进行上述的三个改动。这样check函数将会越来越庞大,可读性极差。这个只是举例单纯的校验,可能直接在最后添加一个方法就行了,如果是复杂的业务逻辑也这样去修改,最后可能会导致没有人看得懂这个代码,也没有人敢改这个代码。
1.2.2、符合“开闭原则”的代码示例
public class CheckerFactory {
private static RecommendChecker checker = new RecommendChecker();
static {
checker.addCheckStrategy(new StockCheckHandler());
checker.addCheckStrategy(new BlackListCheckHandler());
// TODO 改动1
checker.addCheckStrategy(new AreaCheckHandler());
}
private CheckerFactory() {}
public static RecommendChecker getInstance(){
return checker;
}
}
public class CreateOrderReq {
private Long userId;
private Long skuId;
// TODO 改动2,新增属性
private Long areaId;
// ...setter,getter
}
public interface CheckCreateOrderStrategy {
Boolean checkCreateOrder(CreateOrderReq createOrderReq);
}
public class BlackListCheckHandler implements CheckCreateOrderStrategy {
@Override
public Boolean checkCreateOrder(CreateOrderReq createOrderReq) {
System.out.println("黑名单校验");
return true;
}
}
public class StockCheckHandler implements CheckCreateOrderStrategy {
@Override
public Boolean checkCreateOrder(CreateOrderReq createOrderReq) {
System.out.println("库存校验");
return true;
}
}
// TODO 改动3,新增一个配送地校验类
public class AreaCheckHandler implements CheckCreateOrderStrategy {
@Override
public Boolean checkCreateOrder(CreateOrderReq createOrderReq) {
System.out.println("配送地校验");
return true;
}
}
public class RecommendChecker {
List<CheckCreateOrderStrategy> checkStrategyList = new ArrayList<>();
public void addCheckStrategy(CheckCreateOrderStrategy checkCreateOrderStrategy) {
checkStrategyList.add(checkCreateOrderStrategy);
}
public Boolean check(CreateOrderReq createOrderReq) {
for (CheckCreateOrderStrategy strategy : checkStrategyList) {
if (!strategy.checkCreateOrder(createOrderReq)) {
return false;
}
}
return true;
}
}
当前的这一版代码,改动点同样有三个:
- CheckerFactory中需要新增一个校验策略类实现;
- CreateOrderReq中新增一个配送地属性;
- 新增一个配送类校验处理类AreaCheckHandler。
虽然同样是有三个改动点,但是对比上一版代码,扩展性和新增代码的可测试性提升了很多。如果后续需要再添加新的校验规则,我们新增策略类和属性即可,不需要改动原有的check()方法。如果我们直接使用Spring按照类型注入,CheckerFactory都不需要修改,这点在预约挂号订单的重构中可以看到使用。
当然,有的人可能会说CreateOrderReq中新增了属性,改动了代码,那也违法了“开闭原则”。
其实这个问题也需要从不同的维度去看待,如果我们从类的维度去看,当前的修改确实是违法了“开闭原则”,但是从方法的维度看,我们并没有去修改原有属性的方法,所以可以看作是新增。
想要完全不修改代码是不可能的,所以,我们可以尽量通过新增的方式去“扩展”原有的代码逻辑,不修改原有方法和属性的方式去“闭合”改动。这样,我们代码的扩展性和可测试性都会提高很多。
2、模版方法模式
模板方法模式在一个方法中定义算法骨架,并将某些步骤推迟到子类中实现。
模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。
“算法”,我们可以理解为广义上的“业务逻辑”,并不特指数据结构和算法中的“算法”。
这里的算法骨架就是“模板”,包含算法骨架的方法就是“模板方法”,这也是模板方法模式名字的由来。
模版方法的两大作用:复用和扩展。
2.1、复用
在Java中,常见的代码复用方式有继承、组合、代理。
模板模式把一个算法中不变的流程抽象到父类的模板方法 templateMethod() 中,将可变的部分 abstractMethod1()、abstractMethod2() 留给子类 ContreteClass1 和 ContreteClass2 来实现。
所有的子类都可以复用父类中模板方法定义的流程代码。
2.2、扩展
模板模式常用在框架的开发中,让框架用户可以在不修改框架源码的情况下,定制化框架的功能。
例如Spring在初始化Bean的过程中的一些扩展点,HttpServlet等。
- ApplicationListener
- InitializingBean
- BeanPostProcessor
- BeanFactoryPostProcessor
- BeanDefinitionRegistryPostProcessor
- Aware
2.3、源码中的模版方法模式
- HttpServlet#service(ServletRequest req, ServletResponse res)
- AbstractApplicationContext#refresh()
2.4、使用回调替代模版方法模式
回调同样可以实现模版方法模式的功能,并且在某些情况下,比模版方法模式更加简洁。
相对于普通的函数调用来说,回调是一种双向调用关系。A 类事先注册某个函数 F 到 B 类,A 类在调用 B 类的 P 函数的时候,B 类反过来调用 A 类注册给它的 F 函数。这里的 F 函数就是“回调函数”。A 调用 B,B 反过来又调用 A,这种调用机制就叫作“回调”。
回调不仅可以应用在代码设计上,在架构设计上也经常使用。比如,通过三方支付系统来实现支付功能,用户在发起支付请求,三方支付系统执行完成之后,将结果通过回调接口返回给用户,这种属于异步回调。
代码实例:AbstractBeanFactory#doGetBean()
2.5、回调 VS 模版方法模式
应用场景:同步回调跟模板模式几乎一致。它们都是在一个大的算法骨架中,自由替换其中的某个步骤,起到代码复用和扩展的目的。而异步回调跟模板模式有较大差别,更像是观察者模式。
代码实现:回调基于组合关系来实现,把一个对象传递给另一个对象,是一种对象之间的关系;模板模式基于继承关系来实现,子类重写父类的抽象方法,是一种类之间的关系。
组合优于继承。在代码实现上,回调相对于模板模式会更加灵活,主要体现在下面几点。
- Java 只支持单继承的语言,基于模板模式编写的子类,已经继承了一个父类,不再具有继承的能力。
- 回调可以使用匿名类来创建回调对象,可以不用事先定义类;而模板模式针对不同的实现都要定义不同的子类。
- 如果某个类中定义了多个模板方法,每个方法都有对应的抽象方法,那即便我们只用到其中的一个模板方法,子类也必须实现所有的抽象方法。而回调就更加灵活,我们只需要往用到的模板方法中注入回调对象即可。
2.6、应用场景
- 先实现算法的不变部分,然后再将可变的操作留给子类实现,动静分离。
- 提取一些公共的操作到父类以避免重复。
3、策略模式
定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端。
策略模式包含如下角色:
- Context: 环境类
- Strategy: 抽象策略类
- ConcreteStrategy: 具体策略类
3.1、策略的定义
策略往往被定义为一个接口,这样客户端可以针对接口编程,而非针对实现编程。
3.2、策略的创建
策略模式会包含一组策略,在使用它们的时候,一般会通过类型(type)来判断创建哪个策略来使用。
3.3、策略的使用
- 运行时动态指定:根据用户配置,计算结果,或者业务类型进行动态选择。
- 静态指定:在使用时直接指定策略。
// 策略接口:EvictionStrategy
// 策略类:LruEvictionStrategy、FifoEvictionStrategy、LfuEvictionStrategy...
// 策略工厂:EvictionStrategyFactory
public class UserCache {
private Map<String, User> cacheData = new HashMap<>();
private EvictionStrategy eviction;
public UserCache(EvictionStrategy eviction) {
this.eviction = eviction;
}
//...
}
// 运行时动态确定,根据配置文件的配置决定使用哪种策略
public class Application {
public static void main(String[] args) throws Exception {
EvictionStrategy evictionStrategy = null;
Properties props = new Properties();
props.load(new FileInputStream("./config.properties"));
String type = props.getProperty("eviction_type");
evictionStrategy = EvictionStrategyFactory.getEvictionStrategy(type);
UserCache userCache = new UserCache(evictionStrategy);
//...
}
}
// 非运行时动态确定,在代码中指定使用哪种策略
public class Application {
public static void main(String[] args) {
//...
EvictionStrategy evictionStrategy = new LruEvictionStrategy();
UserCache userCache = new UserCache(evictionStrategy);
//...
}
}
3.4、应用场景
- 对于同一项任务,有多种实现方式,需要针对不同的场景使用不同的策略。
- 避免代码中冗长的if else。
ps:以上内容参照极客时间《设计模式之美》。
4、预约挂号订单重构
预约挂号订单当前包含策略类型:自建渠道号源策略、160对接号源策略。
同时两种策略包含许多相同操作,例如:查询就诊人信息、就诊卡信息、公共参数的组装等。
基于上述的一些业务特征,可以考虑使用模版方法模式+策略模式实现预约挂号下单逻辑。
4.1、UML
- AppointmentStrategyContext:预约挂号策略上下文
- AppointmentStrategy:预约挂号策略接口
- AbstractAppointmentMedicalService:预约挂号抽象父类
- ChannelAppointmentMedicalService:第三方渠道预约挂号实现
- SelfAppointmentMedicalService:自建渠道预约挂号实现
4.2、code review
参照类图或联系博主,掌握思想即可。
4.3、扩展
当前系统中,包含预约挂号、咨询问诊、团队订单、春雨医生等多种业务订单,后续可考虑使用相同方法进行重构。
4.3.1、创建订单
各个业务创建订单时,都会操作基础订单表,订单状态改变记录表分别插入基础订单表和订单状态变更记录,需要支付的订单还需要发送超时支付取消订单MQ消息。这种公共的操作,可以提取到抽象父类中,创建各个业务子订单的操作,可以定义为抽象方法,让各个子业务分别实现。这样就可以做到代码的复用,同时将变化的业务订单操作隔离,修改子业务订单代码时,不会影响基础订单的代码。
4.3.2、取消订单
取消订单时,同样会操作基础订单表,订单状态改变记录表这两张表,如果有支付的订单,还需要进行退款操作,同时在退款单表中生成订单退单记录。在这过程中,还会有订单状态的合法性及幂等性校验。这些公共的操作,抽取到抽象父类,各个子业务的操作在各自的实现中进行操作。