在典型业务场景下如何运用设计模式提高代码可读性和扩展性?
1. 背景
1.1. 什么是代码的可读性?
首先我们来看一段代码,并推测其实现了什么业务?
public double getA(double a, double b) {
return 3.14 * a * a * 2 + 2 * 3.14 * a * b;
}
然后我们再来看一段代码,并推测其实现了什么业务?
public double getSurfaceAreaOfCylinder(double radius, double height) {
double bottomArea = Math.PI * radius * radius;
double lateralArea = (2 * Math.PI * radius) * height;
return (2 * bottomArea) + lateralArea;
}
- 对于计算机而言,这两段代码都实现了相同的业务逻辑:计算给定圆柱体的表面积。
- 对于程序员而言,面对前面的代码可能毫无头绪,而面对后面的代码即便在没有注释的情况下也能推断出业务逻辑。更好的可读性意味着更低的维护成本。
如果你来接手,你更愿意接手前面的代码,还是后面的代码?
1.2. 什么是代码的扩展性?
我们来看两段代码,其业务逻辑均为判断给定的状态是否为成功。
public boolean isSuccess(int status) {
if (status == 1 || status == 2) { return true; }
if (status == 3 || status == 4 || status == 5) { return false; }
throw new IllegalArgumentException("Not support status: " + status);
}
private final Set<Integer> statusForSuccess = Sets.newHashSet(1, 2);
private final Set<Integer> statusForFail = Sets.newHashSet(3, 4, 5);
public boolean isSuccess(int status) {
if (statusForSuccess.contains(status)) { return true; }
if (statusForFail.contains(status)) { return false; }
throw new IllegalArgumentException("Not support status: " + status);
}
业务变更:
- 【修改】将状态2改为失败。
- 【新增】状态6和7视为成功,状态8视为失败。
对于第一段代码,需要修改方法添加判断逻辑。对于第二段代码,需要在集合中配置,无需修改方法,满足开闭原则。更好的扩展性意味着更低的维护成本。
1.3. 如何提高代码的可读性和扩展性?
为什么需要提高代码的可读性和扩展性?
一定程度上,更好的代码可读性和扩展性意味着更低的维护成本。可运行的代码≠可维护的代码。
什么样的代码不需要考虑可读性和扩展性?
运行一次就丢弃,再也不会被维护的代码,如某些脚本语言编写的代码。
如何提高代码的可读性和扩展性?
面向对象分析设计,设计模式,重构,编程范式,函数式编程等。以设计模式为例,当遇到一些特定的业务场景时,可以考虑使用设计模式来解决以提高代码的可读性和扩展性。思路如下:
- 提出业务场景;
- 编写初版代码;
- 引入业务变更,发现初版代码存在的问题;
- 通过设计模式或编程技巧重构代码。
设计模式可以理解为一类问题的典型解决方案。解决钉子的模式是锤子,解决螺丝的模式是螺丝刀。
2. 案例讲解
2.1. 如何创建复杂的对象?
业务场景:创建HTTP请求调用外部系统接口进行交互,HTTP请求对象该如何设计?
private static HttpRequest buildHttpRequestBySetter() {
Map<String, Object> params = new HashMap<>();
params.put("name", "Mr.zhang");
params.put("age", 23);
HttpRequest request = new HttpRequest();
request.setUrl("https://xxx");
request.setParams(params);
return request;
}
业务变更:这样的写法完美运行,直到:
- 【新增】需要为该请求添加请求头和请求方法(如
GET
和POST
)。 - 【新增】需要为该请求添加超时时间,且需要校验超时时间的有效性。
- ……
随着对象的属性和约束多了起来,创建对象的代码逐渐变得复杂难以维护。如何将创建对象的逻辑进行封装?
使用Builder模式进行代码重构:
private static HttpRequest buildRequestByBuilder() {
return HttpRequest.builder()
.url("https://xxx")
.post()
.addHeader("auth", "token123")
.addParam("name", "Mr.zhang")
.addParam("age", 23)
.timeout(30)
.build();
}
模式优点:
- 代码可读性更好,更易用。
- 相比于重载构造器,Builder模式可以自由组合所需的参数,不会产生重叠构造器。
- 相比于setter方法,Builder模式可以更定制化,如添加校验等。
- 相比于setter方法,Builder模式在对象创建完整后才会返回给客户端。
模式应用场景:
当创建的对象较为复杂时,可以考虑使用Builder模式以提高代码的可读性和扩展性。
模式应用实例:
java.lang.StringBuilder
:构建字符串的Builderorg.apache.http.client.methods.RequestBuilder
:足够复杂的HTTP请求的Buildercom.google.common.collect.ImmutableMap.Builder
:创建Map的Builderorg.springframework.beans.factory.support.BeanDefinitionBuilder
:Spring中创建Bean定义的Builder
RequestBuilder.post("https://xxx")
.setCharset(StandardCharsets.UTF_8)
.setConfig(RequestConfig.custom()
.setConnectTimeout(30)
.build())
.addHeader("auth", "token123")
.addParameter("name", "Mr.zhang")
.build();
ImmutableMap.builder()
.put("01", "fundIn")
.put("02", "fundOut")
.build();
2.2. 如何根据不同的条件选择不同的功能实现?
业务场景:对接华为云,百度云和阿里云三个短信平台,需要根据编码通过不同的平台发送短信,该如何设计?
public void sendMessageUsingIfElse(String code, String message) {
if ("aliyun".equals(code)) {
aliyunSender.sendMessage(message);
} else if ("baidu".equals(code)) {
baiduSender.sendMessage(message);
} else if ("huawei".equals(code)) {
huaweiSender.sendMessage(message);
} else {
throw new IllegalArgumentException("Not support code: " + code);
}
}
业务变更:这样的写法完美运行,直到:
- 【新增】添加支持七牛云短信平台和腾讯云平台。
- 【删除】删除华为云短信平台。
- ……
当有新的短信平台需要对接时,需要修改发送短信的代码,违反了开闭原则。如何在不修改代码的情况下添加新的实现?
使用策略模式进行代码重构:
private static Map<String, Class<? extends Sender>> codeBeanTypeMap = ImmutableMap.of(
"aliyun", AliyunSender.class,
"baidu", BaiduSender.class,
"huawei", HuaweiSender.class);
public void sendMessageUsingBeanType(String code, String message) {
Class<? extends Sender> beanTypeOfSender = codeBeanTypeMap.get(code);
if (beanTypeOfSender == null) {
throw new IllegalArgumentException("Not support code: " + code);
}
Sender sender = beanFactory.getBean(beanTypeOfSender);
sender.sendMessage(message);
}
模式优点:
- 满足开闭原则,方便引入新的策略。
上面的写法可能存在的问题?策略选择逻辑被限定死为键值对映射,无法实现复杂的选择策略的逻辑。
使用策略模式进行代码重构(抽象出策略选择逻辑):
@Component
public class AliyunSender implements Sender {
@Override
public void sendMessage(String message) { System.out.println("Send " + message + " by aliyun"); }
@Override
public boolean support(String code) { return "aliyun".equals(code); }
}
@Component
public class HuaweiSender implements Sender { //… }
public void sendMessage(String code, String message) {
Map<String, Sender> senderMap = beanFactory.getBeansOfType(Sender.class);
Sender sender = senderMap.values().stream().filter(e -> e.support(code)).findFirst().orElse(null);
if (sender == null) {
throw new IllegalArgumentException("Not support code: " + code);
}
sender.sendMessage(message);
}
模式应用场景:
当需要根据不同的条件选择不同的功能实现时,可以考虑使用策略模式以提高代码的扩展性。
模式应用实例:
org.springframework.web.method.support.HandlerMethodArgumentResolver
:Spring中提供了解析标注了不同注解的参数的解析策略,如@RequestHeader
,@RequestParam
,@PathVariable
HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
if (resolver == null) {
throw new IllegalArgumentException("Unsupported parameter type [" +
parameter.getParameterType().getName() + "]. supportsParameter should be called first.");
}
return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
2.3. 如何复用公共的业务逻辑代码?
业务场景:对接某银行的出金和入金功能,该如何设计?
public class FundInBankTrans implements BankTrans {
@Autowired private BankTransSupport bankTransSupport;
@Override
public InnerResponse send(InnerRequest innerRequest) {
bankTransSupport.doRecordNewLog(innerRequest);
BankRequest bankRequest = BankRequest.builder().xxx(innerRequest.getXxx()).build();
BankResponse bankResponse = bankTransSupport.doPostBankRequest(bankRequest);
InnerResponse innerResponse = InnerResponse.builder().xxx(bankResponse.getXxx()).build();
bankTransSupport.updateStatusOfLog(innerResponse);
return innerResponse;
}
}
public class FundOutBankTrans implements BankTrans { //… }
业务变更:
- 【新增】对接某银行的查询余额功能,转账功能。
- ……
每次有新的业务出现时,由于步骤类似,会复制代码造成重复。如何复用公共的业务逻辑代码?
使用模板方法模式进行代码重构:
public abstract class AbstractBankTrans implements BankTrans {
@Autowired
private BankTransSupport bankTransSupport;
@Override
public InnerResponse send(InnerRequest innerRequest) {
bankTransSupport.doRecordNewLog(innerRequest);
BankRequest bankRequest = buildBankRequest(innerRequest);
BankResponse bankResponse = bankTransSupport.doPostBankRequest(bankRequest);
InnerResponse innerResponse = buildInnerResponse(bankResponse);
bankTransSupport.updateStatusOfLog(innerResponse);
return innerResponse;
}
protected abstract InnerResponse buildInnerResponse(BankResponse bankResponse);
protected abstract BankRequest buildBankRequest(InnerRequest innerRequest);
}
public class FundInBankTrans extends AbstractBankTrans { //… }
public class FundOutBankTrans extends AbstractBankTrans { //… }
模式应用场景:
当实现的业务逻辑都类似时,可以通过模板方法模式来复用公共业务逻辑代码,消除重复。
模式应用实例:
java.util.AbstractList#addAll
:AbstractList
提供了addAll
的模板方法,子类只需实现add
方法即可。org.springframework.beans.factory.support.AbstractBeanDefinitionReader#loadBeanDefinitions
:Spring中支持加载Xml,Properties和Groovy配置文件得益于AbstractBeanDefinitionReader
提供了模板方法。
public boolean addAll(int index, Collection<? extends E> c) {
rangeCheckForAdd(index);
boolean modified = false;
for (E e : c) {
add(index++, e);
modified = true;
}
return modified;
}
public void add(int index, E element) { throw new UnsupportedOperationException(); }
2.4. 如何处理一对多的调用关系?
业务场景:在用户交易完成后需要生成合同,发送异步短信,发送站内信,该如何设计?
@Component
public class Client {
public void afterFinishTrade(String orderNo) {
generateContract(orderNo);
sendMessageAsync(orderNo);
sendMail(orderNo);
}
private void generateContract(String orderNo) {// generate contract }
private void sendMessageAsync(String orderNo) {// send message by new thread }
private void sendMail(String orderNo) {// send mail }
}
业务变更:这样的写法完美运行,直到:
- 【新增】在交易完成后,需要给用户发放积分。
- 【修改】将发送站内信也改为异步形式。
- ……
当有新的后置处理添加过来时,客户端调用的方法不断膨胀。如何在不修改客户端代码的情况下添加新功能?
使用观察者模式进行代码重构:
@Component
public class Client {
@Autowired
private EventPublisher eventPublisher;
public void afterFinishTrade(String orderNo) {
eventPublisher.publishEvent(new TradeFinishedEvent(orderNo));
}
}
@Component
public class TradeFinishedListeners {
@EventListener
@Async
public void sendMessage(TradeFinishedEvent event) { // send message }
@EventListener
public void generateContract(TradeFinishedEvent event) { // generate contract }
@EventListener
@Async
public void sendMail(TradeFinishedEvent event) { // send mail }
}
模式应用场景:
当处理一对多的调用关系时,考虑使用观察者模式将调用关系解耦。
模式应用实例:
org.springframework.context.ApplicationEvent
:Spring在启动过程中会发送一系列的事件。org.springframework.context.ApplicationEventPublisher
:Spring中的事件发送器,用于发布事件。org.springframework.context.ApplicationListener
:Spring中的事件监听器,用于接收事件。
@Override
public void started(ConfigurableApplicationContext context, Duration timeTaken) {
context.publishEvent(new ApplicationStartedEvent(this.application, this.args,
context, timeTaken));
AvailabilityChangeEvent.publish(context, LivenessState.CORRECT);
}
2.5. 如何将非业务代码与业务代码解耦?
业务场景:在每次进行业务操作前,都需要记录业务日志,该如何设计?
@Override
public void fundIn(InnerRequest request) {
doRecordOperateLog(request);
doFundIn(request);
}
@Override
public void fundOut(InnerRequest request) {
doRecordOperateLog(request);
doFundOut(request);
}
业务变更:
- 【新增】还有几个业务的业务日志也需要记录。
- 【修改】修改业务日志记录的方式。
- ……
非业务代码和业务代码耦合在一起,会产生重复且难以维护。如何将非业务代码与业务代码解耦?
使用代理模式进行代码重构(JDK动态代理):
public class FundLogicProxyFactory {
public static FundLogic createProxy(FundLogic target) {
return (FundLogic) Proxy.newProxyInstance(target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
(proxy, method, args) -> {
doRecordOperateLog((InnerRequest) args[0]);
return method.invoke(target, args);
});
}
private static void doRecordOperateLog(InnerRequest request) {}
}
模式优点:
- 将业务代码和非业务代码解耦。
使用代理模式进行代码重构(Spring AOP):
@BizLog
@Override
public void fundIn(InnerRequest request) {
doFundIn(request);
}
@BizLog
@Override
public void fundOut(InnerRequest request) {
doFundOut(request);
}
Spring AOP失效的场景:
- 发生方法的内部调用,this指向被代理的对象,而非IoC容器中的代理对象。
解决方法:
- 通过依赖查找或依赖注入的方式从IoC容器中获取代理对象,或使用AopContext.currentProxy()获取当前代理对象。
3. 总结
当遇到一些特定的业务场景时,可以考虑使用设计模式来解决,以提高代码的可读性和扩展性。
- 如何创建复杂的对象?Builder模式。
- 如何根据不同的条件选择不同的功能实现?策略模式。
- 如何复用公共的业务逻辑代码?模板方法模式。
- 如何处理一对多的调用关系?观察者模式。
- 如何将非业务代码与业务代码解耦?代理模式。
- 如何进行对象的复制?原型模式。
- 如何在不修改类的基础上对类做增强?装饰器模式。
- 如何兼容历史遗留的类?适配器模式。
- 如何设计向外暴露的接口?外观模式。
- ……
先有钉子,再找到锤子,不要本末倒置。以上均为个人理解,仅供参考。
4. 推荐阅读
- 面向对象分析设计:
- 编码范式与编程技巧:
- 重构:
- 设计模式:
- 函数式编程:
- 开源项目源码: