记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步
软件设计大师Martion Fowler定义:重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。
可以把重构理解为,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。
大重构包括:系统、模块、代码结构、类与类之间的关系等重构。重构的手段有:分层、模块化、解耦、抽象可复用组件等。影响较大,代码改动较多,难度大,耗时长,引入bug的风险相对较大。
小重构指的是对类、方法、变量的代码级别的重构,如规范命名、规范注释、消除超大类或方法、提取重复代码等。
可持续、可演进的方式,持续重构。修改、添加某个功能代码的时候,顺手把不符合规范、不好的设计重构下。让重构作为开发的一部分,成为一种开发习惯。
持续重构意识更重要,时刻具有持续重构意识,才能避免开发初期就过度设计。
大重构:提前做好完善的重构计划,分阶段进行。每阶段完成一部分的重构,然后提交、测试、运行,发现没问题,再继续下一阶段的重构,保证代码仓库的代码一直可运行、逻辑正确。每个阶段,控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要时写一些过渡代码。每个阶段最好一天内完成。
小重构:除了人工发现低层次的质量问题,还可借助静态代码分析工具,如CheckStyle、FindBugs、PMD,自动发现代码中的问题,针对性的重构优化。
重构这种事情,资深工程师、项目leader要负责,没事就重构代码,时刻保证代码质量处于良好状态。否则,一旦出现破窗效应,一个人往里面堆了烂代码,之后会有更多烂代码,此外,最好打造好的技术氛围,驱动大家主动关注代码质量,持续重构代码。
单元测试unit testing
单元测试由研发工程师自己编写,用来测试自己写的代码的正确性。相对于集成测试integration testing来说,测试粒度更小。集成测试的测试对象是整个系统或某个功能模块,如测试用户注册、登录功能是否正常,端到端测试。而单元测试测试对象是类或方法,测试一个类或方法是否按照预期的逻辑执行,代码层级的测试。
单元测试主要看是否能设计出覆盖各种正常及异常情况的测试用例,来保证代码再任何预期或非预期的情况下都能正确运行。
java比较出名的单元测试框架如Junit、TestNG、Spring Test等,提供了通用的执行流程(如执行测试用例的TestCaseRunner)和工具类库(如各种Assert判断函数)。只需关注测试用例本身的编写即可。
此外, 一些经验总结:
知易行难,开发任务紧,放低对单元测试的要求,慢慢就都不写了。关键是没有建立对单元测试正确认知。
其中,Transaction是抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。Transaction类的execute()方法负责执行转账操作,将钱从买家的钱包转到卖家的钱包。真正的转账操作是通过调用WalletRpcService RPC服务来完成的。此外,代码还涉及一个分布式锁DistributedLock单例类,用来避免Transaction并发执行,导致用户的钱被重复转出。
public class Transaction{
private String id;
private Long buyerId;
private Long sellerId;
private Long productId;
private String orderId;
private Long createTimestamp;
private Double amount;
private STATUS status;
private String walletTransactionId;
//...get() methods...
public Transaction(String preAssignedId,Long buyerId,Long sellerId,Long productId,String orderId){
if(preAssignedId!=null && !preAssignedId.isEmpty()){
this.id = preAssignedId;
}else{
this.id = IdGenerator.generateTransactionId();
}
if(!this.id.startWith("t_")){
this.id = "t_"+preAssignedId;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.createTimestamp = System.currentTimestamp();
this.status = STATUS.TO_BE_EXECUTED;
}
public boolean execute() throws InvalidTransactionException{
if(buyerId==null || (sellerId==null || amount<0.0)){
throw new InvalidTransactionException(...);
}
if(status==STATUS.EXECUTED) return true;
boolean isLocked = false;
try{
isLocked = RedisDistributedLock.getSingletonInstance().lockTransaction(id);
if(!isLocked){
return false;//锁定未成功,返回false job兜底执行
}
if(status==STATUS.EXECUTED) return true;//double check
long executionInvokedTimestamp = System.currentTimestamp();
if(executionInvokedTimestamp - createdTimestamp > 14days){
this.status = STATUS.EXPIRED;
return false;
}
WalletRpcService walletRpcService = new WalletRpcService();
String walletTransactionId = walletRpcService.moveMoney(id,buyerId,seller,amount);
if(walletTransactionId!= null){
this.walletTransactionId = walletTransactionId;
this.status = STATUS.EXECUTED;
return true;
}else {
this.status = STATUS.FAILED;
return false;
}
}finally{
if(isLocked){
RedisDistributedLock.getSingletonInstance().unlockTransaction(id);
}
}
}
}
这段代码较为复杂,要如何写单元测试呢?
在Transaction类中,主要逻辑集中在execute()方法中,所以它是测试的重点对象。为了尽可能全面覆盖各种正常和异常情况,针对该方法,设计6个测试用例:
对于上述测试用例,第2个实现很简单,重点看其中的1和3
先看测试用例1的代码实现,具体:
public void testExecute(){
Long buyerId = 123L;
Long sellerId = 234L;
Long orderId = 456L;
Long productId = 345L;
Transaction transaction = new Transaction(null,buyerId,sellerId,productId,orderId);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
}
execute()方法的执行以来两个外部的服务,一个是RedisDistributedLock,一个是WalletRpcService,导致上面的单元测试代码存在下面问题:
回到单元测试的定义看,主要是测试程序员自己编写的代码逻辑的正确性,并非端到端的集成测试,不需要依赖外部系统(分布式锁、Wallet RPC服务)的逻辑正确性。如果代码依赖外部环境或不可控组件,如需要依赖数据库、网络通信、文件系统等,需要将被测代码和外部系统解依赖,这种方法叫mock,也就是用一个假的服务替换真正的服务,mock的服务完全在我们的控制下,模拟输出我们想要的数据。
如何mock呢?有两种,手动mock和利用框架mock。利用框架mock仅仅是为了简化代码编写。展示手动mock,通过继承WalletRpcService类,并且重写其中的moveMoney()方法的方式实现mock。
public class MockWalletRpcServiceOne extends WalletRpcService{
public String moveMoney(Long id,Long fromUserId,Long toUserId,Double amount){
return "123bac";
}
}
public class MockWalletRpcServiceTwo extends WalletRpcService{
public String moveMoney(Long id,Long fromUserId,Long toUserId,Double amount){
return null;
}
}
如何用他们替代真正的WalletRpcService呢?
因为WalletRpcService是在execute()方法中通过new方式创建的,无法动态的替换。也就是说该方法可测试性很差,需要重构。如何重构?
依赖注入,将WalletRpcService对象的创建反转给上层逻辑,外部创建好之后,注入到Transaction类中。
public class Transaction{
//...
//添加一个成员变量及其set方法
private WalletRpcService walletRpcService;
public void setWalletRpcService(WalletRpcService walletRpcService){
this.walletRpcService = walletRpcService;
}
public boolean execute() throws InvalidTransactionException{
if(buyerId==null || (sellerId==null || amount<0.0)){
throw new InvalidTransactionException(...);
}
if(status==STATUS.EXECUTED) return true;
boolean isLocked = false;
try{
isLocked = RedisDistributedLock.getSingletonInstance().lockTransaction(id);
if(!isLocked){
return false;//锁定未成功,返回false job兜底执行
}
if(status==STATUS.EXECUTED) return true;//double check
long executionInvokedTimestamp = System.currentTimestamp();
if(executionInvokedTimestamp - createdTimestamp > 14days){
this.status = STATUS.EXPIRED;
return false;
}
String walletTransactionId = walletRpcService.moveMoney(id,buyerId,seller);
if(walletTransactionId!= null){
this.walletTransactionId = walletTransactionId;
this.status = STATUS.EXECUTED;
return true;
}else {
this.status = STATUS.FAILED;
return false;
}
}finally{
if(isLocked){
RedisDistributedLock.getSingletonInstance().unlockTransaction(id);
}
}
}
}
现在在单元测试中,就可以替换WalletRpcService了。
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
// 使用mock对象来替代真正的RPC服务
transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
再看RedisDistributedLock,它的mock和替换要复杂一些,因为它是单例类,单例相当于一个全局变量,无法mock(无法继承和重写),也无法通过依赖注入的方式替换
如果RedisDistributedLock是自己维护的,可自由修改、重构,就可以将其改为非单例的模式,或者定义一个接口,如IDistributedLock,让RedisDistributedLock实现该接口。但如果RedisDistributedLock不是自己维护的,无权限修改,怎么办?
可以对transaction上锁的部分的逻辑重新封装,如下
public class TransactionLock{
public boolean lock(String id){
return RedisDistributedLock.getSingletonInstance().lockTransaction(id);
}
public void unlock(){
RedisDistributedLock.getSingletonInstance().unlockTransaction(id);
}
public class Transaction{
//...
private TransactionLock lock;
public void setTransactionLock(TransactionLock lock){
this.lock = lock;
}
}
public boolean execute(){
//...
try{
isLocked = lock.lock();
//...
}finally{
if(isLocked){
lock.unlock();
}
}
//...
}
}
针对重构过的代码,单元测试代码修改后,可隔离分布式锁:
public void testExecute(){
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
TransactionLock mockLock = new TransactionLock(){
public boolean lock(String id){
return true;
}
public void unlock(){
}
};
Transaction transaction = new Transaction(null,buyerId,sellerId,productId,orderId);
transaction.setWalletRpcService(new MockWalletRpcServiceOne());
transaction.setTransactionLock(mockLock);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED,transaction.getStatus());
}
测试用例1写好了。再看测试用例3:交易已过期,交易状态设置为EXPIRED,返回false。针对这个单元测试,先写出代码
public void testExecute_with_TransactionIsExpired(){
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transaction transaction = new Transaction(null,buyerId,sellerId,productId,orderId);
transaction.setCreatedTimestamp(System.currentTimeMillis()-14days);
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED,transaction.getStatus());
}
上面代码看似没有问题,我们将transaction的创建时间createdTimestamp设置为14天前,也就是说,单元测试代码运行时,transaction一定处于过期状态,但是,如果在transaction类中,并没有暴露修改createdTimestamp成员变量的set方法(也就是没有定义setCreatedTimestamp()方法)呢?
在Transaction类的设计中,createdTimestamp是在交易生成时(也即是构造方法中)自动获取的系统时间,本就不该人为的轻易修改,针对这种代码中包含跟“时间”有关的“未决行为”逻辑,一般的处理方法是将这种未决行为逻辑重新封装,针对该类,只需将交易是否过期,封装到isExpired()方法中即可。
public class Transaction {
protected boolean isExpired(){
long executionInvokedTimestamp = System.currentTimeMillis();
return executionInvokedTimestamp - createTimestamp >14days;
}
public boolean execute() throws InvalidTransactionException{
//...
if(isExpired()){
this.status = STATUS.EXPIRED;
return false;
}
//...
}
}
针对重构后的代码,测试用例3的代码实现如下:
public void testExecute_with_TransactionIsExpired(){
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transaction transaction = new Transaction(null,buyerId,sellerId,productId,orderId){
protected boolean isExpired(){
return true;
}
};
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED,transaction.getStatus());
}
通过重构,Transaction代码的可测试性提高了。不过,Transaction类的构造方法的设计还有些不妥。里面交易id的赋值逻辑稍微复杂,最好测试下,保证这部分逻辑的正确性。为方便测试,可把id赋值这部分逻辑单独抽象到一个方法中,具体代码如下:
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
//...
fillTransactionId(preAssignedId);
//...
}
protected void fillTransactionId(String preAssignedId){
if (preAssignedId != null && !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith("t_")) {
this.id = "t_" + preAssignedId;
}
}
到此为止,我们一步步的将Transaction从不可测试代码重构为测试性良好的代码。其实,代码的可测试性可以从侧面反映代码设计是否合理。
总结下,有哪些典型的、常见的测试性不好的代码,也就是常说的anti-patterns
大型重构是对系统、模块、代码结构、类和类之间关系等顶层代码设计进行的重构。最有效的手段之一即是“解耦”。解耦的目的是实现高内聚、低耦合。
软件设计与开发的最重要的工作之一就是应对复杂性。人处理复杂性的能力是有限的。过于复杂的代码往往可读性、可维护性都不友好。如何控制代码的复杂性呢?最关键的就是解耦。如果说重构是保证代码质量不至于腐化到无可救药的地步的有效手段,那么利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。
如何判断代码的耦合程度呢?
有个直接的衡量标准,就是把模块和模块之间、类和类之间的依赖关系画出来,根据依赖关系图的复杂性判断是否需要解耦重构。
如果依赖关系复杂、混乱,从代码结构上讲,可读性和可维护性肯定不够友好,需要考虑解耦,让依赖关系变得清晰、简单。
模块化,让我突然想到隋唐设置的三省六部制就是这种思想的产物,皇帝老儿没法管理所有事务,分模块,高内聚、低耦合,将各个事务分解为六部:吏部(组织人才的选拔)、户部(户籍财政)、礼部(外宣、科举)、兵部(国防军事、装备研发)、刑部(公检法)、工部(治水、技术研发);按流程分解为中书省(草拟诏令,国务院)、门下省(审核,人大)、尚书省(执行,各部委)。
实在找不到,就在github上用相关的关键词联想搜索下,看类似的代码如何命名的。其实很多大牛都有自己的心得,如effective java一书中就有推荐。
包含做什么、为什么、怎么做。如下:
/**
* (what) Bean factory to create beans
*
* (why) The class likes Spring IOC framework, but is more lightweight
*
* (how) Create objects from different sources sequentially
* use specified object > SPI > configuration > default object
**/
public class BeansFactory {
//...
}
当然注释也不是越多越好,毕竟代码会持续演进的,有时候代码改了,注释忘了同步修改,会造成代码阅读者的困扰。类和方法一定要写注释,而且写得全面、详细,方法内部的注释相对少些,靠好的命名、提炼方法、解释性变量、总结性注释来提高代码的可读性。
代码逻辑较为复杂时,建议提炼类或者方法。举例如下,inverse()方法,最开始的处理时间的代码,是不是很难懂?
public void invest(long userId,long financialProductId){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE,(calendar.get(Calendar.DATE)+1));
if (calendar.get(Calendar.DAY_OF_MONTH) == 1){
return;
}
//...
}
对其重构,将这部分逻辑抽象为一个方法,命名为isLastDayOfMonth,从名字上就能清晰的了解功能,判定今天是否为当月的最后一天,提高了代码的可读性
public void invest(long userId,long financialProductId){
if (isLastDayOfMonth(new Date())){
return;
}
//...
}
public boolean isLastDayOfMonth(Date date){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.DATE,(calendar.get(Calendar.DATE)+1));
return calendar.get(Calendar.DAY_OF_MONTH) == 1;
}
针对过多参数,有两种解决方案:拆分为多个方法来减少参数;将方法的参数封装为对象
不要在方法中用boolean类型的标识参数控制内部逻辑,true的时候走这块,false走另一块,违反了单一职责原则和接口隔离原则。建议拆分为两个方法,可读性也更好。
public void buyCourse(long userId,long courseId,boolean isVip);
//拆分为两个方法
public void buyCourse(long userId,long courseId);
public void buyCourse4Vip(long userId,long courseId,boolean isVip);
当然,如果方法为private,或者拆分后两个方法经常同时被调用,可以酌情考虑保留标识参数
除boolean类型,还有一种根据参数是否为null来控制逻辑的情况。应该将其拆分为多个方法。拆分后方法职责更明确,代码如下
public List<Transaction> selectTransactions(Long userId,Date startDate,Date endDate){
if (startDate != null && endDate != null){
//查询两个时间区间的transactions
}
if (startDate != null && endDate == null){
//查询startDate之后所有transactions
}
if (startDate == null && endDate != null){
//查询endDate 之前所有transactions
}
if (startDate == null && endDate == null){
//查询所有transactions
}
}
//拆分为多个public方法,更清晰易用
public List<Transaction> selectTransactionsBetween(Long userId,Date startDate,Date endDate){
return selectTransactions(userId, startDate, endDate);
}
public List<Transaction> selectTransactionsStartWith(Long userId,Date startDate){
return selectTransactions(userId, startDate, null);
}
public List<Transaction> selectTransactionsEndWith(Long userId,Date endDate){
return selectTransactions(userId, null, endDate);
}
public List<Transaction> selectAllTransaction(Long userId){
return selectTransactions(userId, null, null);
}
private List<Transaction> selectTransactions(Long userId,Date startDate,Date endDate){
//...
}
过深是因为if-else,switch-case、for循环过度嵌套导致。嵌套最好不超过两层。解决方法有3种思路:
a. 去掉多余的if或else语句
public double caculateTotalAmount(List<Order> orders){
if (orders == null || orders.isEmpty()){
return 0.0;
}else{//此处的else可以去掉
//...主逻辑
}
}
b. 调整执行顺序减少嵌套 先判空,再执行主逻辑
c. 将部分嵌套逻辑封装为方法,减少嵌套