编写易读的代码
如果没时间,可以直接阅读 编程的智慧
如果有时间,可以购买《Clean Code》
项目代码是“编写一次,阅读多次“。 阅读者包括代码编写者,架构师,审查人员,以及后来的维护人员。能让阅读代码更轻松,有利于增强项目或者产品的可维护性。是各种软件工程方法,面向对象实践,重构,以及新技术应用到到项目里的一个重要前提,如果代码难以阅读,所有这些方法和理论都难以在项目中实施,如果代码难以维护,那么性能优化也无从谈起
本章不是一个Java代码规范和设计原则,本章主要聚焦如何编写可读性强的代码。 可能会让代码变多,也可能会让代码变少,目的是让代码更容易被别人和3个月后的自己读懂。
- 无效注释干扰阅读
- 日志和埋点对阅读的帮助
- 变量命名和变量的位置有助于阅读
- 小方法代替代码段让阅读更轻松
- If-else, 保持主逻辑畅通
- 发现对象,使用对象。代替string和map
1.1 精简注释
在代码块里应该尽量减少注释编写,尤其是描述设计思想和实现过程的注释,这是因为代码会不停的随着业务需求变化而变化,注释往往在重构中被IDE忽略,也会被人为忽略,用《clean code》里的话来说,注释容易腐烂。避免注释腐烂需要我们尽力维护代码和注释的同步,代码中精简注释也是防止腐烂最好的办法。
1.1.1 注释优化
下列列举一些关于代码内注释使用原则
场景1:被注释的代码及时删除
被注释的代码应该及时删除掉,否则这段代码将传给一代又一代的代码维护者,无人敢删除这段代码,我在2018年曾维护一个工作流引擎,里面有一块07年代码让我一直很迷惑
//别删除这块代码 by WX,01.09
//Task[] tasks = taskService.query();
//.....
Task task = taskService.queryOne();
开发者不应该注释代码块,应该尽快删除。如果想恢复,可以通过git工具来恢复
场景2 通过注释解释代码行为
代码注释应该说明代码的动机,而不是再次用文字描述一边代码过程,如下注释相当于没有
/*字符内容符合18个数字或者15个数字*/
public static final String REGEX_ID_CARD = "(^\\d{18}$)|(^\\d{15}$)";
不如改成
/*身份证号验证*/
public static final String REGEX_ID_CARD = "(^\\d{18}$)|(^\\d{15}$)";
有一个《如何编写无法维护的代码》就提到一个原则,只记录 How 而不是 Why ,就会让代码无法维护
如下代码,对账户状态值进行注释
/* 状态 0 正常 1 异常 2 未知 */
private Integer status;
然而,如果status随后添加更多的含义,比如添加3表示冻结,则很可能忽略修改这里的注释。最好的办法是用枚举,并且取消注释
private Integer status = UserStatus.Nomral.getValue();
场景3 不要在代码中记录更改历史
//原来版本使用StringBuffer,改成StringBuilder bt WX 20190302
StringBuilder sb = ....
这段注释毫无存在必要,可以通过git这样的工具来查看代码更新记录和更新人,以及更新原因。没有必要在代码里标注代码相关人,如果用idea,可以很方便的查看代码的更新人。在Editor 左侧代码行空白处点击右键,在弹出菜单选择Annotation
场景4 方法不仅仅是为了复用,把代码块提取到一个方法里,通过方法名来解释代码作用
//发送短信给用户
StringBuilder sb = new StringBuilder("您的余额是");
sb.append(user.getBalance()).append("元-");
sb.append(platformName).append(""。)
sms.send(sb.toString(),user.getMobile());
可以提取到一个短方法里,代码块调用此方法即可
sendUserBalanceBySms(user,platformName);
这个短方法的意义使得代码容易维护
- 如果需要修改发送短息的内容,可以直接定位到sendUserBalanceBySms,而不需要从上百行代码里找到
- 在阅读sendUserBalanceBySms调用所在代码块的的时候,sendUserBalanceBySms无关紧要,可以习惯性的忽略,如果没有这个方法,你需要阅读数行这种代码,哪怕你已经熟悉代码了。用小方法减轻了阅读负担
场景5 使用一个临时变量代替注释
对于一段计算逻辑,或者一个方法调用,使用临时变量来说明其结果含义,比注释更容易维护,如下代码
//返回一年总的费用
return calcPay(user,type);
可以用一个临时变量表示
BigDecimal payByYear = calcPay(user,type);
return payByYear;
场景6 取消没有必要的注释
有些方法过于简单,没有必要添加注释,比如JavaBean得getter和setter方法,如下没有必要的注释
/*用户名称*/
private String userName;
1.1.2 使用日志和埋点
日志和埋点有效,不会像代码注释那样容易腐烂
老板以为的HelloWorld
public static void main(String[] args){
System.out.println("hello "+args[0]);
}
实际的HelloWorld
public static void main(String[] args){
if(ags.length==0){
log.error("ags is null");//记录日志
Metric.submitEvent("hello-world-ags-error");
return ;
}
if(ags.length>1){
log.error("ags is "+Arrays.asList(ags));
Metric.submitEvent("hello-world-ags-error-2");
//继续执行
}
Metric helloMetric = Metric.of("hello-world");
try{
helloMetric.start();
System.out.println("hello "+args[0]);
log.info("hello world success "+args[0]);
}catch(Exception ex){
if(MyRatelimiter.tryAcquire()){
//阻止大量error日志打爆磁盘
log.error("helloworld error ",ex);
}else{
Metric.submitEvent("too-many-error-log");
}
//TODO 如何降级处理?
helloMetric.error(ex.getMessage());
}finally{
helloMetric.end();
}
}
1.2 变量
变量命名规范已经有大量书籍和文档说明,本章主要解释在容易阅读的变量命名
1.2.1 变量命名
变量名字需要尽量表达其真实含义,不要缩写或者简写
//总的支付费用
BigDecimal p ;
代码阅读到这里,无法知晓含义,如果加上注释,我们在上一节也说过,注释容易腐烂,因此可以改一下,适用有一个有意义的名字
BigDecimal pay ;
现实情况,命名如果较长,能准确表达含义,应该使用较长得名字,比如
public class HelloworldService{
}
就比下面代码好
public class HelloworldServ{
}
或者
public class Helloworld{
}
不需要担心书写费劲,IDE具备智能提示,能提高书写速度。也不要担心阅读费劲,真正费劲的是不常规的命名,比如“HelloworldServ“
有时候变量名太长,意味着想赋予变量得含义太多,如下变量命名原因是想说明类型为Map的变量包含的Key和Value类型
Map strKeyAreayValueMap = getAreaData()
实际上应该是
Map<String,AreaData> areaMap = getAreaData();
如下代码,处理返回结果,由于使用了一个数组而没有采用对象,所以给人感觉命名很别扭
Object[] codeAndObject = rpc.query....
如此命名,固然使得代码阅读者不需要查看rpc.query方法的实现,根据名字就知道该如何处理返回对象,但更好的办法新建一个对象用来表示调用结果,这样命名就简单多了,阅读代码得人可以查看RpcResult的代码和Javadoc了解应该如何处理ret
RpcResult ret = rpc.query....
有些情况下,短名字还是很合适的,比如循环中使用的计数器,通常命名为i,坐标通常命名为x,y等,业务系统也有自己约定习俗得简化的名字,比如wf表示工作流,sku表示商品。
1.2.2 变量的位置
变量的位置应该尽量靠近使用用的地方,如果代码在方法开始处定义了变量,然后在100行代码后才使用,那么代码阅读者心里总是悬着的,觉得不是在看代码,而是在看一本悬疑小说-最后的变量会在哪里用呢
User seller,buyer;
seller = .....
// 50行代码后
buyer = .....
如上代码,最好在使用buyer地方再定义,尤其是业务系统,业务复杂,涉及了很多变量,就近定义变量,减轻阅读负担
代码块的变量命名不要与类变量重名,这会导致阅读困难
public class Point{
private int x;
private int y;
public void calc(Point p){
//定义一个变量
int x = p.getX();
//50行代码后,很难知道x指的哪个
return calcLine(x,y);
}
}
如上代码,在调用return方法的时候,x很容易被误解,误以为传入的类变量x
1.2.3 中间变量
使用一些中间变量来增强代码可读性
return a*b+c/rate+d*e;
上面的代码一气呵成,且只用了一行,但没有下面的代码更容易阅读
int yearTotal = a*b;
int lastYearTotal = c/rate;
int todayTotal = d*e;
int total = yearTotal+lastYearTotal+todayTotal;
return total
看似其他人一行代码完成似乎更牛,你用了多行代码才完成了一个功能,但你的代码显然更容易被后来人阅读。我一直觉得写代码就跟写小说一样,要看得懂才是真正的小说,如果从任何地方切入小说都能看懂,那就是本好小说。
1.3 方法
1.3 .1 方法签名
程序员阅读代码,遇到陌生的方法,第一样看的是方法签名,如果方法签名明确表达了函数的功能,输入和输出,则省去了程序员阅读方法注释的可能,甚至是阅读代码的必要!
public Map buildArea(List<Area> areas)
当代码维护者看到buildArea时候,并不清楚Map返回的是什么,他必须查看方法体才了解返回值是什么,如果方法非常复杂,则让阅读者陷入了代码泥潭。正确的优化方式加上范型
public Map<String,Area> buildArea(List<Area> areas)
方法签名的参数数量应该也是可控,多一个参数,理解多一份难度,可以通过提供默认参数加以改进。比如,启动工作流,,定义如下接口
public String startWorkflow(String workflowType,String userName);
有可能启动工作流还涉及工作流得版本还有用户的身份信息,那么如下方法签名就很长了
public String startWorkflow(String workflowType,int version,String userName,String orgId);
则应该考虑适用对象封装,userName 和 orgId 封装为Participant,而工作流相关封装为StartWorklowInfo
public class StartWorklowInfo{
String workflowType;
int version;
}
public class Participant{
String userName;
String orgId;
}
则startWorkflow 重新定义为
public String startWorkflow(StartWorklowInfo workflowInfo,Participant user);
在重构这个接口后不久,启动工作流需求又更改了,用户还需要用工作流角色标识,因此,我们后来为Participant对象增加了了一个roleId ,就解决问题了。
1.3.2 小方法
为什么要拆分方法,显然的道理。就像我们读书一样,分成章节阅读,无法想象,一本精彩《天龙八部》的小说,如果没有分册分章和相应的标题,不知道你是否还能愉快的阅读。
有一个需求,需要导入excel倒数据库,excel包含了多个业务领域的数据,如下代码看起来就很清爽
public void parse(Sheet sheet){
User user = readUserInfo(sheet);
List<Order> orders = readUserOrderInfo(sheet);
UserCredit credit = readUserCreditInfo(sheet);
}
parse方法一看就很简单,读取用户信息,订单信息还有信用积分信息,在重构parse方法前,此方法并不简单,大概有1000多行,后期的任何维护都是个灾难,比如,excel的中涉及用户领域的信息修改了,你不得不翻看1000多行代码,定位出需要修改的地方,而重构后,只需要顺着readUserInfo方法里去找。
如下方法,处理异常部分实际上也要拆分
try{
sku = rpc.getSkuInfo(id);
return ResponseBuilder.success(sku);
}catch(AppException ex){
if(ex.getType==1){
//省略10行代码
}else if(ex.getType==2){
//省略20行代码
}else{
//省略20行代码
}
return ResponseBuilder.error(ex);
}
这是因为代码主要逻辑是通过rpc调用获取商品信息,错误处理应该是次要部分,但这个代码块有点喧宾夺主,使得代码阅读者不由自主的注意到错误处理部分,可以重构,用一个方法来处理异常
try{
sku = rpc.getSkuInfo(id);
}catch(AppException ex){
handle(ex);
return ResponseBuilder.error(ex);
}
除了通过方法拆分,完成小说章节,也推荐使用小方法,提高的代码的可维护性,我们在精简注释里已经展现了,把发送短信的代码块抽出成一个方法。如下代码片段
boolean success = user!=null&&user.status==1&&!"admin".equals(user.getName())
这个代码尽管很简单,但也可以考虑抽象倒一个方法里
public boolean validate(User user){
return user!=null&&user.status==1&&!"admin".equals(user.getName())
}
这样,只需要调用validate方法即可。代码阅读者,可以选择性的忽略这一行代码,轻松愉快。
boolean success = validate(user);
1.4 分支
1.4.1 if else
代码里有大量的分支语句,分支语句应该尽量保证少的嵌套以方便阅读代码,如下是一个不必要的嵌套
CallInfo info = ....;
if(info!=null){
if(info.isSuccess()){
return true
}else{
log.info(xxxx);
return false;
}
}else{
log.info(yyy);
return false;
}
上面代码,对返回结果进行处理,返回结果可能为空,返回结果值里的状态也可能失败。嵌套了俩层分支,其实可以简化一下,只用一层分支,
CallInfo info = ....;
if(info==null){
log.info(yyy);
return false;
}
if(!info.isSuccess()){
log.info(xxxx);
return false;
}
.....
return true;
重构后的代码保持了最少的分支嵌套,同时**,最重要的是保持主逻辑畅通**,优先处理错误路径分支,剩下的为正常结果,最后再处理。如果维护者看到这儿,无论他是否熟悉这块代码,他总能快速定位到他需要维护的地方
1.4.2 switch case
switch case 用于多分支情况,使用最常见的问题是每个分支 包含了太多的代码 使得代码不容易阅读.这也许初期业务较为简单,开发者把业务逻辑放到case里处理就可以了,但如果一开始不这么做,后来者会沿用这种错误习惯,在分支上增加越来越多的代码。正确的方法是一开始就case分支里只包含方法调用
switch(status){
case START:handleTaskStart(taskId,user);break;
case PAUSE:handlePause(taskId); break;
......
}
1.5 发现对象
对于程序员来说,面向对象设计技巧,相比于对象的组合,继承,多态,或者是设计模式,发现对象能力是一个更重要的OOD技能,在1.3.1,启动工作流的调用里,我们发现了对象Participant表示工作流的参与者,也发现了对象StartWorklowInfo用于代表启动工作流实例的流程定义信息,本章节介绍一些发现对象的例子
当项目里大量有拼接的String和Map的时候,意味着应该用对象来代替
1.3.1 不要用String
在1.3.2提到导入Excel,在我实际项目里,曾经是这个样子
public void parse(Sheet sheet,StringBuilder error){
User user = readUserInfo(sheet,error);
List<Order> orders = readUserOrderInfo(sheet,error);
UserCredit credit = readUserCreditInfo(sheet,error);
}
之所以提供一个StringBuilder 参数,因为需求是如果解析出错,需要显示出错的的位置,项目开发人员因此将错误信息拼接成字符串,最后返回给前端。
如果审查其代码,你会发现该解析方法方法里有数十个类似如下代码
error.append("在"+line+"和"+col+"列错":+"messsage").append("\n");
这两段代码的阅读者困惑之处就是error作为一个StringBuilder,不能说明如何处理解析错误,阅读者不得不看清楚具体实现才恍然大悟--原来我的前任用StringBuilder是想这么干。另外一个困惑之处就是在解析excel的时候,就已经写死了错误输出的样子,如果想更改,就需要改每一处地方 ,我们知道业务的excel解析,几百行代码算是少的了。要阅读者几百行代码重构对后来者并非易事。
有什么模式或者设计原则能解决这个吗?
我想说的是,并没有模式和设计原则能解决,开发者缺少的仅仅是发现和归纳对象的能力,设计模式是锦上添花,对于excel解析的错误信息,实际上就应该定义一个”错误信息“这样的对象。比如
public class ExcelParseError{
public void addError(ParseErrorInfo info ){}
public void addSimpleError(String line,String col,String message ){}
public List<ParseErrorInfo> getMessages(){}
public String toString(){
.....
}
}
因此,excel解析最后是这个样子
public void parse(Sheet sheet,ExcelParseError error){
User user = readUserInfo(sheet,error);
List<Order> orders = readUserOrderInfo(sheet,error);
UserCredit credit = readUserCreditInfo(sheet,error);
}
处理解析错误的代码则变成如下
error.addSimpleError(line,col,message);
在很多项目里,都会出现试图用String来代表对象,本书第一章也有一个使用省+地区的字符串来代表一个对象的问题。因此,我们需要避免用String来表示对象。难以维护和重构,性能也是糟糕的。
一个负面例子,当时系统配置通过一个字符串传入,程序员通过如下代码实现其逻辑
if(config.isopen()){
}
if(config.charAt(7)=='E'||config.charAt(7)=='X'){
}
这种代码也同样难以看到和维护,那怎么做更好呢,其答案还是面向对象,可以定义一个Config对象,这样,任何人都能看懂代码,都能随时维护代码
public class Config{
String str;
public Config(String str){
this.str = str;
}
public boolean isOpen(){
return config.charAt(3)=='D';
}
public boolean isApppend(){
config.charAt(7)=='E'||config.charAt(7)=='X'
}
}
1.3.2 不要用数组,Map和JSONObject
当程序中出现String 参数,数组参数,以及Map,JSONObject的时候,已经在提醒我们是遗漏了系统的对象。 这三个类型参数当然非常灵活,能容纳下任何数据结构,但有可能遗漏了系统隐含的对象。尤其是数组和Map。如下例子
Object[] rets = call();
boolean success = (Boolean)rets[0];
String msg = (String)rets[1];
就没有采用对象定义返回结果好
CallResult rets = call();
boolean success = rets.isSuccess();
String msg = rets.getMessage();
如果CallResult包含了某个返回值,那么,将CallResult定义成泛型就更加容易阅读,比如返回CallResult
public CallResult getUser();
//更好的方式
public CallResult<User> getUser()
关于这一点,我们已经在1.2.1 方法签名中已经出现过类似的例子了
同样,使用Map来表示对象也是非常糟糕的,代码阅读者根本不知道Map里有多少对象的属性,必须阅读完所有代码才知道如何使用Map,如下代码,使用Map表示user对象,在我刚入行的2001年前,Map还是被很多开发者推崇,认为“一个Map走天下",但这实际上是“潇潇洒洒走自己的路,让维护者无路可走“
Map user = new HashMap();
user.put("id",1);
user.put("name","badcode");
user.put("gender",true);
当代码维护者在阅读代码,想知道user对象的的“gender“ 属性是在什么地方设置的,他无法用IDE的 查找引用,他只能搜索整个工程代码,期望能定位到。更糟糕的是,如果需要重构gender从boolean类型变成int类型,比如用boolean值得true和false分别表示性别男和性别女,如果改为男,女和未知三个值,需要更改为int类型,他也不得不小心谨慎的找遍每一处代码。如果一旦漏掉,可能损失巨大。