规范与重构
- 一.理论一:什么情况下要重构?到底重构什么?又该如何重构?
- 二.理论二:为了保证重构不出错,有哪些非常能落地的技术手段?
- 三.理论三:什么是代码的可测试性?如何写出可测试性好的代码?
- 三.理论四:如何通过封装、抽象、模块化、中间层等解耦代码?
- 四.理论五:让你最快速地改善代码质量的20条编程规范
- 1. 命名
- 2. 注释
- 3. 类、函数多大才合适?
- 4. 一行代码多长最合适?
- 5. 善用空行分割单元块
- 6. 四格缩进还是两格缩进?
- 7. 大括号是否要另起一行?
- 8. 类中成员的排列顺序
- 9. 把代码分割成更小的单元块
- 10. 避免函数参数过多
- 11. 勿用函数参数来控制逻辑
- 12. 函数设计要职责单一
- 13. 移除过深的嵌套层次
- 14. 学会使用解释性变量
- 五.实战一(上):通过一段ID生成器代码,学习如何发现代码质量问题
- 六.实战一(下):将ID生成器代码从“能用”重构为“好用”
- 七. 实战二(上):程序出错该返回啥?NULL、异常、错误码、空对象?
- 八. 实战二(下):重构ID生成器项目中各函数的异常处理代码
一.理论一:什么情况下要重构?到底重构什么?又该如何重构?
1. 重构的目的:为什么要重构(why)?
- 重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。
2. 重构的对象:到底重构什么(what)?
- 大规模高层次重构(简称为“大型重构”)和小规模低层次的重构(以下简称为“小型重构”)
3. 重构的时机:什么时候重构(when)?
- 持续重构
4. 重构的方法:又该如何重构(how)?
二.理论二:为了保证重构不出错,有哪些非常能落地的技术手段?
1. 什么是单元测试?
- 单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试
2. 为什么要写单元测试?
- 单元测试能有效地帮你发现代码中的 bug
- 写单元测试能帮你发现代码设计上的问题
- 单元测试是对集成测试的有力补充
- 写单元测试的过程本身就是代码重构的过程
- 单元测试是 TDD 可落地执行的改进方案
三.理论三:什么是代码的可测试性?如何写出可测试性好的代码?
1. 单元测试
- 单元测试主要是测试程序员自己编写的代码逻辑的正确性,并非是端到端的集成测试,它不需要测试所依赖的外部系统(分布式锁、Wallet RPC 服务)的逻辑正确性。所以,如果代码中依赖了外部系统或者不可控组件,比如,需要依赖数据库、网络通信、文件系统等,那我们就需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作“mock”。所谓的 mock 就是用一个“假”的服务替换真正的服务。mock 的服务完全在我们的控制之下,模拟输出我们想要的数据
三.理论四:如何通过封装、抽象、模块化、中间层等解耦代码?
1. “解耦”为何如此重要?
- 如果说重构是保证代码质量不至于腐化到无可救药地步的有效手段,那么利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。
- 代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差
2. 代码是否需要“解耦”?
- 问题:该怎么判断代码的耦合程度呢?或者说,怎么判断代码是否符合“高内聚、松耦合”呢?再或者说,如何判断系统是否需要解耦重构呢?
比如,看修改代码会不会牵一发而动全身。除此之外,还有一个直接的衡量标准,也是我在阅读源码的时候经常会用到的,那就是把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。
3. 如何给代码“解耦”?
- 封装与抽象
- 中间层
中间层的引入明显地简化了依赖关系,让代码结构更加清晰
- 模块化
很多大型软件(比如 Windows)之所以能做到几百、上千人有条不紊地协作开发,也归功于模块化做得好。不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统。
我们在开发代码的时候,一定要有模块化意识,将每个模块都当作一个独立的 lib 一样来开发,只提供封装了内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度
- 其他设计思想和原则
(1). 单一职责原则
内聚性和耦合性并非独立的。高内聚会让代码更加松耦合,而实现高内聚的重要指导原则就是单一职责原则。模块或者类的职责设计得单一,而不是大而全,那依赖它的类和它依赖的类就会比较少,代码耦合也就相应的降低了。
(2). 基于接口而非实现编程
基于接口而非实现编程能通过接口这样一个中间层,隔离变化和具体的实现。这样做的好处是,在有依赖关系的两个模块或类之间,一个模块或者类的改动,不会影响到另一个模块或类。实际上,这就相当于将一种强依赖关系(强耦合)解耦为了弱依赖关系(弱耦合)
(3). 依赖注入
跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。尽管依赖注入无法将本应该有依赖关系的两个类,解耦为没有依赖关系,但可以让耦合关系没那么紧密,容易做到插拔替换
(4). 多用组合少用继承
继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活,所以,对于继承结构比较复杂的代码,利用组合来替换继承,也是一种解耦的有效手段。
(5). 迪米特法则
迪米特法则讲的是,不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。从定义上,我们明显可以看出,这条原则的目的就是为了实现代码的松耦合
四.理论五:让你最快速地改善代码质量的20条编程规范
1. 命名
- 命名多长最合适?
实际上,在足够表达其含义的情况下,命名当然是越短越好。但是,大部分情况下,短的命名都没有长的命名更能达意。所以,很多书籍或者文章都不推荐在命名时使用缩写
熟知的词,推荐使用缩写,比如,sec 表示 second、str 表示 string、num 表示 number、doc 表示 document。
对于作用域比较小的变量,我们可以使用相对短的命名,比如一些函数内的临时变量。相反,对于类名这种作用域比较大的,我更推荐用长的命名方式。
- 利用上下文简化命名
(1). 例子:
public class User {
private String userName;
private String userPassword;
private String userAvatarUrl;
//...
}
(2). 在 User 类这样一个上下文中,我们没有在成员变量的命名中重复添加“user”这样一个前缀单词,而是直接命名为 name、password、avatarUrl。在使用这些属性时候,我们能借助对象这样一个上下文,表意也足够明确
- 命名要可读、可搜索
(1). 可读:指的是不要用一些特别生僻、难发音的英文单词来命名
- 如何命名接口和抽象类?
(1). 接口命名
对于接口的命名,一般有两种比较常见的方式。一种是加前缀“I”,表示一个 Interface。比如 IUserService,对应的实现类命名为 UserService。另一种是不加前缀,比如 UserService,对应的实现类加后缀“Impl”,比如 UserServiceImpl。
(2). 抽象类
对于抽象类的命名,也有两种方式,一种是带上前缀“Abstract”,比如 AbstractConfiguration;另一种是不带前缀“Abstract”
2. 注释
/**
* (what) Bean factory to create beans.
*
* (why) The class likes Spring IOC framework, but is more lightweight.
*
* (how) Create objects from different sources sequentially:
* user specified object > SPI > configuration > default object.
*/
public class BeansFactory {
// ...
}
3. 类、函数多大才合适?
- 当一个类的代码读起来让你感觉头大了,实现某个功能时不知道该用哪个函数了,想用哪个函数翻半天都找不到了,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明类的行数过多了
4. 一行代码多长最合适?
- 总体上来讲我们要遵循的一个原则是:一行代码最长不能超过 IDE 显示的宽度。需要滚动鼠标才能查看一行的全部代码,显然不利于代码的阅读。当然,这个限制也不能太小,太小会导致很多稍长点的语句被折成两行,也会影响到代码的整洁,不利于阅读
5. 善用空行分割单元块
- 在类的成员变量与函数之间、静态成员变量与普通成员变量之间、各函数之间、甚至各成员变量之间,我们都可以通过添加空行的方式,让这些不同模块的代码之间,界限更加明确
6. 四格缩进还是两格缩进?
- Java 语言倾向于两格缩进,PHP 语言倾向于四格缩进。
- 不管是用两格缩进还是四格缩进,一定不要用 tab 键缩进。因为在不同的 IDE 下,tab 键的显示宽度不同,有的显示为四格缩进,有的显示为两格缩进。如果在同一个项目中,不同的同事使用不同的缩进方式(空格缩进或 tab 键缩进),有可能会导致有的代码显示为两格缩进、有的代码显示为四格缩进
7. 大括号是否要另起一行?
- 推荐,将括号放到跟语句同一行的风格。理由跟上面类似,节省代码行数
8. 类中成员的排列顺序
- 排列顺序
在类中,成员变量排在函数的前面。成员变量之间或函数之间,都是按照“先静态(静态函数或静态成员变量)、后普通(非静态函数或非静态成员变量)”的方式来排列的
9. 把代码分割成更小的单元块
- 要有模块化和抽象思维,善于将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节,让阅读代码的人不至于迷失在细节中,这样能极大地提高代码的可读性
10. 避免函数参数过多
- 函数包含 3、4 个参数的时候还是能接受的,大于等于 5 个的时候,我们就觉得参数有点过多了,会影响到代码的可读性,使用起来也不方便。针对参数过多的情况,一般有 2 种处理方法。
(1). 考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数。示例代码如下所示
public User getUser(String username, String telephone, String email);
// 拆分成多个函数
public User getUserByUsername(String username);
public User getUserByTelephone(String telephone);
public User getUserByEmail(String email);
(2). 将函数的参数封装成对象。示例代码如下所示
public void postBlog(String title, String summary, String keywords, String content, String category, long authorId);
// 将参数封装成对象
public class Blog {
private String title;
private String summary;
private String keywords;
private Strint content;
private String category;
private long authorId;
}
public void postBlog(Blog blog);
11. 勿用函数参数来控制逻辑
-
不要在函数中使用布尔类型的标识参数来控制内部逻辑,true 的时候走这块逻辑,false 的时候走另一块逻辑。这明显违背了单一职责原则和接口隔离原则。我建议将其拆成两个函数,可读性上也要更好
-
代码
public void buyCourse(long userId, long courseId, boolean isVip);
// 将其拆分成两个函数
public void buyCourse(long userId, long courseId);
public void buyCourseForVip(long userId, long courseId);
-
不过,如果函数是 private 私有函数,影响范围有限,或者拆分之后的两个函数经常同时被调用,我们可以酌情考虑保留标识参数
-
除了布尔类型作为标识参数来控制逻辑的情况外,还有一种“根据参数是否为 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> selectAllTransactions(Long userId) {
return selectTransactions(userId, null, null);
}
private List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
// ...
}
12. 函数设计要职责单一
- 单一职责原则的时候,针对的是类、模块这样的应用对象。实际上,对于函数的设计来说,更要满足单一职责原则。相对于类和模块,函数的粒度比较小,代码行数少,所以在应用单一职责原则的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一。
- 例子代码:
public boolean checkUserIfExisting(String telephone, String username, String email) {
if (!StringUtils.isBlank(telephone)) {
User user = userRepo.selectUserByTelephone(telephone);
return user != null;
}
if (!StringUtils.isBlank(username)) {
User user = userRepo.selectUserByUsername(username);
return user != null;
}
if (!StringUtils.isBlank(email)) {
User user = userRepo.selectUserByEmail(email);
return user != null;
}
return false;
}
// 拆分成三个函数
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);
13. 移除过深的嵌套层次
- 过深的嵌套本身理解起来就比较费劲,除此之外,嵌套过深很容易因为代码多次缩进,导致嵌套内部的语句超过一行的长度而折成两行,影响代码的整洁。
14. 学会使用解释性变量
- 常量取代魔法数字。
public double CalculateCircularArea(double radius) {
return (3.1415) * radius * radius;
}
// 常量替代魔法数字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
return PI * radius * radius;
}
- 使用解释性变量来解释复杂表达式
if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
// ...
} else {
// ...
}
// 引入解释性变量后逻辑更加清晰
boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer) {
// ...
} else {
// ...
}
五.实战一(上):通过一段ID生成器代码,学习如何发现代码质量问题
1. ID 生成器代码
public class IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);
public static String generate() {
String id = "";
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\\.");
if (tokens.length > 0) {
hostName = tokens[tokens.length - 1];
}
char[] randomChars = new char[8];
int count = 0;
Random random = new Random();
while (count < 8) {
int randomAscii = random.nextInt(122);
if (randomAscii >= 48 && randomAscii <= 57) {
randomChars[count] = (char)('0' + (randomAscii - 48));
count++;
} else if (randomAscii >= 65 && randomAscii <= 90) {
randomChars[count] = (char)('A' + (randomAscii - 65));
count++;
} else if (randomAscii >= 97 && randomAscii <= 122) {
randomChars[count] = (char)('a' + (randomAscii - 97));
count++;
}
}
id = String.format("%s-%d-%s", hostName,
System.currentTimeMillis(), new String(randomChars));
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return id;
}
}
2. 如何发现代码质量问题?
-
常规checkList
-
根据常规checkList问题分析
(1). 目录设置是否合理、模块划分是否清晰、代码结构是否满足“高内聚、松耦合”?
IdGenerator 的代码比较简单,只有一个类,所以,不涉及目录设置、模块划分、代码结构问题
(2).是否遵循经典的设计原则和设计思想(SOLID、DRY、KISS、YAGNI、LOD 等)?
只有一个类,也不违反基本的 SOLID、DRY、KISS、YAGNI、LOD 等设计原则
(3). 设计模式是否应用得当?是否有过度设计?
它没有应用设计模式,所以也不存在不合理使用和过度设计的问题。
(4).代码是否容易扩展?如果要添加新功能,是否容易实现?
IdGenerator 设计成了实现类而非接口,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想。实际上,将 IdGenerator 设计成实现类,而不定义接口,问题也不大。如果哪天 ID 生成算法改变了,我们只需要直接修改实现类的代码就可以。但是,如果项目中需要同时存在两种 ID 生成算法,也就是要同时存在两个 IdGenerator 实现类
(5).代码是否容易测试?单元测试是否全面覆盖了各种正常和异常的情况?
把 IdGenerator 的 generate() 函数定义为静态函数,会影响使用该函数的代码的可测试性。同时,generate() 函数的代码实现依赖运行环境(本机名)、时间函数、随机函数,所以 generate() 函数本身的可测试性也不好,需要做比较大的重构
- 业务需求checkList
六.实战一(下):将ID生成器代码从“能用”重构为“好用”
1. 重构计划
- 循序渐进、小步快跑。重构代码的过程也应该遵循这样的思路。每次改动一点点,改好之后,再进行下一轮的优化,保证每次对代码的改动不会过大,能在很短的时间内完成
- 第一轮重构:提高代码的可读性
- 第二轮重构:提高代码的可测试性
- 第三轮重构:编写完善的单元测试
- 第四轮重构:所有重构完成之后添加注释
2. 第一轮重构:提高代码的可读性
- 问题
- hostName 变量不应该被重复使用,尤其当这两次使用时的含义还不同的时候;
- 将获取 hostName 的代码抽离出来,定义为 getLastfieldOfHostName() 函数;
- 删除代码中的魔法数,比如,57、90、97、122;
- 将随机数生成的代码抽离出来,定义为 generateRandomAlphameric() 函数
- generate() 函数中的三个 if 逻辑重复了,且实现过于复杂,我们要对其进行简化
- 对 IdGenerator 类重命名,并且抽象出对应的接口
- 重构后代码
public interface IdGenerator {
String generate();
}
public interface LogTraceIdGenerator extends IdGenerator {
}
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@Override
public String generate() {
String substrOfHostName = getLastfieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastfieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
String[] tokens = hostName.split("\\.");
substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}
private String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
//代码使用举例
LogTraceIdGenerator logTraceIdGenerator = new RandomIdGenerator();
3. 第二轮重构:提高代码的可测试性
-
可测试性问题包含两方面
-
generate() 函数定义为静态函数,会影响使用该函数的代码的可测试性
我们将 RandomIdGenerator 类中的 generate() 静态函数重新定义成了普通函数。调用者可以通过依赖注入的方式,在外部创建好 RandomIdGenerator 对象后注入到自己的代码中,从而解决静态函数调用影响代码可测试性的问题。
- generate() 函数的代码实现依赖运行环境(本机名)、时间函数、随机函数,所以 generate() 函数本身的可测试性也不好
(1). 从 getLastfieldOfHostName() 函数中,将逻辑比较复杂的那部分代码剥离出来,定义为 getLastSubstrSplittedByDot() 函数。因为 getLastfieldOfHostName() 函数依赖本地主机名,所以,剥离出主要代码之后这个函数变得非常简单,可以不用测试。我们重点测试 getLastSubstrSplittedByDot() 函数即可
(2). 将 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 这两个函数的访问权限设置为 protected。这样做的目的是,可以直接在单元测试中通过对象来调用两个函数进行测试
(3). 给 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 两个函数添加 Google Guava 的 annotation @VisibleForTesting。这个 annotation 没有任何实际的作用,只起到标识的作用,告诉其他人说,这两个函数本该是 private 访问权限的,之所以提升访问权限到 protected,只是为了测试,只能用於单元测试中。
- 重构后代码
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@Override
public String generate() {
String substrOfHostName = getLastfieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastfieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
4. 第三轮重构:编写完善的单元测试
5. 第四轮重构:添加注释
七. 实战二(上):程序出错该返回啥?NULL、异常、错误码、空对象?
1. 函数出错应该返回啥?
- 返回4种情况:错误码、NULL 值、空对象、异常对象。
2. 错误码
- 尽量不要使用错误码。异常相对于错误码,有诸多方面的优势,比如可以携带更多的错误信息(exception 中可以有 message、stack trace 等信息)等。
3. 返回 NULL 值
- 尽管返回 NULL 值有诸多弊端,但对于以 get、find、select、search、query 等单词开头的查找函数来说,数据不存在,并非一种异常情况,这是一种正常行为。所以,返回代表不存在语义的 NULL 值比返回异常更加合理。
- 看项目中的其他类似查找函数都是如何定义的,只要整个项目遵从统一的约定即可
4. 返回空对象
- 当函数返回的数据是字符串类型或者集合类型的时候,我们可以用空字符串或空集合替代 NULL 值,来表示不存在的情况。这样,我们在使用函数的时候,就可以不用做 NULL 值判断。
5. 抛出异常对象
- 直接吞掉
如果 func1() 抛出的异常是可以恢复,且 func2() 的调用方并不关心此异常,我们完全可以在 func2() 内将 func1() 抛出的异常吞掉
public void func1() throws Exception1 {
// ...
}
public void func2() {
//...
try {
func1();
} catch(Exception1 e) {
log.warn("...", e); //吐掉:try-catch打印日志
}
//...
}
- 原封不动地 re-throw。
如果 func1() 抛出的异常对 func2() 的调用方来说,也是可以理解的、关心的 ,并且在业务概念上有一定的相关性,我们可以选择直接将 func1 抛出的异常 re-throw;
public void func1() throws Exception1 {
// ...
}
public void func2() throws Exception1 {//原封不动的re-throw Exception1
//...
func1();
//...
}
- 包装成新的异常 re-throw
如果 func1() 抛出的异常太底层,对 func2() 的调用方来说,缺乏背景去理解、且业务概念上无关,我们可以将它重新包装成调用方可以理解的新异常,然后 re-throw。
public void func1() throws Exception1 {
// ...
}
public void func2() throws Exception2 {
//...
try {
func1();
} catch(Exception1 e) {
throw new Exception2("...", e); // wrap成新的Exception2然后re-throw
}
//...
}
八. 实战二(下):重构ID生成器项目中各函数的异常处理代码
1. 重构 generate() 函数
- 重构前
public String generate() {
String substrOfHostName = getLastFieldOfHostName();
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
-
主机名有可能获取失败。在目前的代码实现中,如果主机名获取失败,substrOfHostName 为 NULL,那 generate() 函数会返回类似“null-16723733647-83Ab3uK6”这样的数据
-
更倾向于明确地将异常告知调用者。所以,这里最好是抛出受检异常,而非特殊值
-
重构后
public String generate() throws IdGenerationFailureException {
String substrOfHostName = getLastFieldOfHostName();
if (substrOfHostName == null || substrOfHostName.isEmpty()) {
throw new IdGenerationFailureException("host name is empty.");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
2. 重构 getLastFieldOfHostName() 函数
- 重构前
private String getLastFieldOfHostName() {
String substrOfHostName = null;
try {
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
} catch (UnknownHostException e) {
logger.warn("Failed to get the host name.", e);
}
return substrOfHostName;
}
- 问题:
现在的处理方式是当主机名获取失败的时候,getLastFieldOfHostName() 函数返回 NULL 值。我们前面讲过,是返回 NULL 值还是异常对象,要看获取不到数据是正常行为,还是异常行为。获取主机名失败会影响后续逻辑的处理,并不是我们期望的,所以,它是一种异常行为。这里最好是抛出异常,而非返回 NULL 值
- 是否封装成新的异常抛出
要看函数跟异常是否有业务相关性。getLastFieldOfHostName() 函数用来获取主机名的最后一个字段,UnknownHostException 异常表示主机名获取失败,两者算是业务相关,所以可以直接将 UnknownHostException 抛出,不需要重新包裹成新的异常。
- 重构后
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
-
getLastFieldOfHostName() 函数修改之后,generate() 函数也要做相应的修改。我们需要在 generate() 函数中,捕获 getLastFieldOfHostName() 抛出的 UnknownHostException 异常。当我们捕获到这个异常之后,应该怎么处理呢?
-
在 generate() 函数中,我们需要捕获 UnknownHostException 异常,并重新包裹成新的异常 IdGenerationFailureException 往上抛出。之所以这么做,有下面三个原因
- 调用者在使用 generate() 函数的时候,只需要知道它生成的是随机唯一 ID,并不关心 ID 是如何生成的。也就说是,这是依赖抽象而非实现编程。如果 generate() 函数直接抛出 UnknownHostException 异常,实际上是暴露了实现细节。
- 从代码封装的角度来讲,我们不希望将 UnknownHostException 这个比较底层的异常,暴露给更上层的代码,也就是调用 generate() 函数的代码。而且,调用者拿到这个异常的时候,并不能理解这个异常到底代表了什么,也不知道该如何处理。
- UnknownHostException 异常跟 generate() 函数,在业务概念上没有相关性
- 再次重构
public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFieldOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException("host name is empty.");
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
3. 重构 getLastSubstrSplittedByDot() 函数
- 对于 getLastSubstrSplittedByDot(String hostName) 函数,如果 hostName 为 NULL 或者空字符串,这个函数应该返回什么
- 重构前
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
-
理论上讲,参数传递的正确性应该有程序员来保证,我们无需做 NULL 值或者空字符串的判断和特殊处理。调用者本不应该把 NULL 值或者空字符串传递给 getLastSubstrSplittedByDot() 函数。如果传递了,那就是 code bug,需要修复
-
如果函数是 public 的,你无法掌控会被谁调用以及如何调用(有可能某个同事一时疏忽,传递进了 NULL 值,这种情况也是存在的),为了尽可能提高代码的健壮性,我们最好是在 public 函数中做 NULL 值或空字符串的判断。
-
重构后
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw IllegalArgumentException("..."); //运行时异常
}
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
- 我们在使用这个函数的时候,自己也要保证不传递 NULL 值或者空字符串进去。所以,getLastFieldOfHostName() 函数的代码也要作相应的修改
- 重构后
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
if (hostName == null || hostName.isEmpty()) { // 此处做判断
throw new UnknownHostException("...");
}
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
4. 重构 getLastSubstrSplittedByDot() 函数
- 对于 generateRandomAlphameric(int length) 函数,如果 length < 0 或 length = 0,是一种异常行为,所以,当传入的参数 length < 0 的时候,我们抛出 IllegalArgumentException 异常
- 重构前
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}
5. 重构之后的 RandomIdGenerator 代码
- 重构后
public class RandomIdGenerator implements IdGenerator {
private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);
@Override
public String generate() throws IdGenerationFailureException {
String substrOfHostName = null;
try {
substrOfHostName = getLastFieldOfHostName();
} catch (UnknownHostException e) {
throw new IdGenerationFailureException("...", e);
}
long currentTimeMillis = System.currentTimeMillis();
String randomString = generateRandomAlphameric(8);
String id = String.format("%s-%d-%s",
substrOfHostName, currentTimeMillis, randomString);
return id;
}
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
if (hostName == null || hostName.isEmpty()) {
throw new UnknownHostException("...");
}
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
if (hostName == null || hostName.isEmpty()) {
throw new IllegalArgumentException("...");
}
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
@VisibleForTesting
protected String generateRandomAlphameric(int length) {
if (length <= 0) {
throw new IllegalArgumentException("...");
}
char[] randomChars = new char[length];
int count = 0;
Random random = new Random();
while (count < length) {
int maxAscii = 'z';
int randomAscii = random.nextInt(maxAscii);
boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
if (isDigit|| isUppercase || isLowercase) {
randomChars[count] = (char) (randomAscii);
++count;
}
}
return new String(randomChars);
}
}