写高质量的代码
有人曾经说过我们正在临近代码的终点。很快代码就会自动生产出来,不需要再人工编写。程序员完全没用了,因为商务人士 可以从规约直接生成代码。除非我们的人工智能达到真的可以像人一样思考,否则说这些都是扯淡!就目前来看我们在未来好多好多年还是无法抛弃掉代码, 因为代码呈现了需求的细节。在某些层面上,这些细节无法被忽略或者抽象,必须明确之。将需求明确到机器可以执行的细节程度,就是编程所要做的事。而这种规约正是代码。
我相信我们每个人都见过糟糕的代码, 看这些代码一般都令人痛苦不堪,到处都暗藏的沼泽地,一不留神就会陷入深渊。那么为什么要写这样糟糕的代码呢?是想快点完成?还是赶时间?都有可能。或许你觉得自己要干好所需的时间不够;假使花时间清理代码,老板就会大发雷霆。或许你只是不耐烦再搞这套程序,期望早点结束。或许你看了看自己承诺要做的其他事,意识到需要尽快完成手头上的工作,好接着完成下一件工作。虽然每个人的理由大概不会相同,但我相信这种事我们都干过。我们都曾经瞟过一眼自己亲手造成的混乱,决定弃之不顾,走向新的一天。我们都曾经看到自己的烂程序居然能运行,然后断言能运行的烂程序总比什么都没有强。但当我们自己看到别人写的烂程序时,肯定都说过这么烂的代码还不如干掉自己重新写。我们都曾经说过有朝一日再回头清理。但是对于终日忙碌的程序员来说:稍后等于永远不。因此我们必须时刻对自己严格要求,不仅仅只是按照需求完成开发任务,时刻保持代码的整洁,否则都最后肯定是自作自受。现在踩得坑,很多都是以前蹦跶留下的。
1. 有意义的命名
我们的代码其实就是无数的类名,方法名和变量名的一个大集合。如果随心所欲的乱起名字,那么看你代码的人将相当痛苦,想必就是自己过一段时间来看自己的代码, 也得不会那么容易吧。好的命名使人读你的代码像是读文章一样简单明了,通过名字其他人就可以知道你的代码的业务逻辑是什么,不必再花费时间在看代码的具体实现细节。
- 类名使用UpperCamelCase风格,必须遵从驼峰形式。但是一常见的英文缩写除外。例如: TCP/ DTO / AO / UID等
- 方法名、参数名、成员变量、局部变量都统一使用lowerCamelCase风格,必须遵从驼峰形式。
- 常量命名全部大写,单词间用下划线隔开,力求语义表达完整清楚,不要嫌名字长。
- 杜绝完全不规范的缩写,避免望文不知义。例如: AbstractClass“缩写”命名成AbsClass;condition“缩写”命名成 condi,此类随意缩写严重降低了代码的可阅读
- 接口类中的方法和属性不要加任何修饰符号,保持代码的简洁性。
- 类中布尔类型的变量,都不要加is前缀。虽然在Hybris中不会引起错误,但是看看生成的get方法为isIsXXXXX,生成的set方法为setIsXXXXXX,不易阅读。
- 杜绝使用让人产生误解的名字。例如: 之前我使用过一个叫做getConsultantForUID的一个方法,但是返回值确实一个CustomerData。
上述这一些规则的最终目的只有一个,就是让人更容易阅读和理解你写的代码。我知道起一个恰当的命名确实是一件费时费力的事,但是花费一些时间在这些命名上确实是有意义的,这将大大提高之后维护这些代码人的工作效率, 避免一些错误的产生。
2.方法
对于方法来说重要的一定是短,单一职权,不要做过多的事。我在之前项目见到几百行代码的方法以及上千行代码的类。我特别尤其讨厌那种我滚动了几下也没有到头的方法, 看了后边忘了前边,来来回回反复的滚动来查看代码,看这些代码使人抓狂。
- 每个方法应该只做一件事,做好这件事,只做这一件事。这样可以是复杂的事情变得简单化,代码也更加清晰明了。
- 方法的参数不要太多,一般如果超过三个参数我们就需要考虑封装参数对象。
- 不要重复自己,不要写重复的代码。重复不仅后期代码难以维护,而且是很多Bug的源头。在之前的一个项目,做一件事的方法我曾经见过4种以上的实现,这些重复的逻辑分布在不同的层级中,不仅维护困难而且导致与这个逻辑相关的功能Bug不断。
- 参数中不要传递标识参数如True或者False,这样不仅丑陋不堪,而且使方法签名立刻变得复杂,我们需要铭记方法只做一件事。
- 抽离Try/Catch,将错误处理逻辑与业务逻辑分离,错误处理本就是另外一件事。
3.注释
关于注释,我想说是清晰明了的高质量代码根本就不需要写注释,如果你打算通过写注释来向别人解释你的代码,请在这之前考虑能否通过优化代码,通过代码来阐述你的意思而不是注解。保证方法和类的短小再加上有意义的命名,那么别人看我们的代码一目了然,根本就不需要注释。
- 尽量不要写//TODO 如果非写不可,请加上作者名,和预计需要完成的时间,不然到了后期别人根本不知道这是谁写的,到底需不需要完成或者删除。
- 如果写了注解,便要维护这个注解,不然改了代码,但是不改注解,这就是在挖坑。
- 不需要的代码就直接删除,不要注解掉了事,这会使别人产生困惑。
- 接口过时必须加@Deprecated注解,并清晰地说明采用的新接口或者新服务是什么。
4.错误处理
当我们讨论错误处理时,就一定要提及那些容易引发错误的做法。最常见的就是Null判断。我们可以想象得到我们的项目中有多少检查Null值得判断,有多少Bug是因为Null导致的。下面是我之前改的一个Bug:
public void removeUserGroup(String userGroupCode) { B2BCustomerModel b2bCustomer = (B2BCustomerModel)getUserService().getCurrentUser(); UserGroupModel userGroup = getContactService().getUserGroupByCode(userGroupCode); Set<ContactModel> contacts = userGroup.getContacts();//throw a NPE } public UserGroupModel getUserGroupByCode(final B2BCustomerModel consultant, String code) { UserGroupModel userGroupModel = consultant.getContactGroups().stream() .filter(c -> StringUtils.equals(code, c.getCode())).findFirst().orElse(null); return userGroupModel; }
当时在执行contactGroup.getContacts()的时候出现了NPE, 我们一般的想法就是那就在加一个非空判断不就行了吗。但是这个Bug的真正原因却是因为我们在之前处理userGroupCode的时候代码出现了错误,导致传入了一个错误的userGroupCode。如果修改这个Bug的人并没有注意到这一点的话,就只是简单的添加了一个非空判断,这就使这个问题隐藏的更深。但是如果当时getUserGroupByCode的方法写的如下:
public UserGroupModel getUserGroupByCode(final B2BCustomerModel consultant, String code) { UserGroupModel userGroupModel = consultant.getContactGroups().stream() .filter(c -> StringUtils.equals(code, c.getCode())).findFirst().orElse(null); if(userGroupModel == null){ throw new UserGroupNotFoundException(String.format("User Group[%s] not found", code)) } return userGroupModel; }
修改Bug的人很明显的就可以看到这个错误是因为userGroupCode传入错误导致的,并且找到这个Bug的真正原因。方法返回Null, 基本上是在给自己增加工作量,也是在给调用者添乱。只要有一处没有检查Null,应用程序就会失控。
5.控制语句
在高并发场景中避免使用“等于”作为中断或者退出的条件。如果并发控制没有处理好,容易产生等值判断被击穿的情况,可以使用大于或者小于的区间判断条件来替代。
不要在判断条件中写入执行复杂业务逻辑的代码,将复杂业务逻辑的执行结果赋值给一个有意义的布尔变量名,可以大大提高代码的可阅读性。如下代码:
if ((isAfterPickUpTime && openCloseDayPart != null && !DAY_PART_STATUS_INSIDE.equals(openCloseDayPart.getDayPartStatus())) || (openCloseDayPart != null && DAY_PART_STATUS_ENDINGSOON.equals(openCloseDayPart.getDayPartStatus()))){..............}
这段代码对于阅读它的人来说简直是一种折磨。可以改成如下代码来提高阅读性:
boolean productIsAvailableInDayPart = (isAfterPickUpTime && openCloseDayPart != null && !DAY_PART_STATUS_INSIDE.equals(openCloseDayPart.getDayPartStatus())) || (openCloseDayPart != null && DAY_PART_STATUS_ENDINGSOON.equals(openCloseDayPart.getDayPartStatus())); if(productIsAvailableInDayPart ){..............}
循环体中执行的语句要考虑代码性能,尽量将定义对象、变量、获取数据库连接、try-catch等与循环变量无关的业务逻辑移至循环体外。
对象应该在其需要使用的范围内在创建,例如:
for (final String code : pendingPromotionCodes){ final PromotionSourceRuleModel sourceRule = commercePromotionService.getPromotionSourceRuleByCode(code); if (sourceRule != null) { final MyOfferData myOfferData = offerConverter.convert(sourceRule); if (commercePromotionService.isPromotionAvailable(sourceRule.getCode())) { pendingOffers.add(myOfferData); } } }
当sourceRule不为空时offerConverter.convert(sourceRule)总会执行,但是我们只是在它有效的时候才需要执行它,因此可以做如下改变:
for (final String code : pendingPromotionCodes){ final PromotionSourceRuleModel sourceRule = commercePromotionService.getPromotionSourceRuleByCode(code); if (sourceRule != null) { if (commercePromotionService.isPromotionAvailable(sourceRule.getCode())) { final MyOfferData myOfferData =offerConverter.convert(sourceRule); pendingOffers.add(myOfferData); } } }
6.集合处理
ArrayList的subList结果不可以强转成ArrayList,否则会抛出ClassCastException异常。subList 返回的是 ArrayList 的内部类 SubList,并不是 ArrayList而是ArrayList 的一个视图,对于SubList子列表的所有操作最终会反映到原列表上。在subList场景中,高度注意对原集合元素的增加或删除,均会导致子列表的遍历、增加、删除产生ConcurrentModificationException 异常。
使用工具类Arrays.asList()把数组转换成集合时,不能使用其修改集合相关的方法,它的add/remove/clear方法会抛出UnsupportedOperationException异常。asList的返回对象是一个Arrays内部类,并没有实现集合的修改方法。Arrays.asList体现的是适配器模式,只是转换接口,后台的数据仍是数组。
集合泛型定义时,在JDK7及以上,使用diamond语法或全省略。菱形泛型,即diamond,直接使用<>来指代前边已经指定的类型。如下:
// <> diamond方式 HashMap<String, String> userCache = new HashMap<>(16); // 全省略方式 ArrayList<User> users = new ArrayList(10);
使用集合转数组的方法,必须使用集合的toArray(T[] array),传入的是类型完全一样的数组,大小就是list.size()。使用toArray带参方法,入参分配的数组空间不够大时,toArray方法内部将重新分配内存空间,并返回新数组地址;如果数组元素个数大于实际所需,下标为[ list.size() ]的数组元素将被置为null,其它数组元素保持原值,因此最好将方法入参数组大小定义与集合元素个数一致。直接使用toArray无参方法存在问题,此方法返回值只能是Object[]类,若强转其它类型数组将出现ClassCastException错误。
集合初始化时,指定集合初始值大小。例如HashMap使用HashMap(int initialCapacity) 初始化。initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意 负载因子(即loader factor)默认为 0.75,如果 暂时无法 确定 初始值大小,请设置为 16(即默认值)。假如一个HashMap需要 放置 1024个元素, 由于 没有设置容量 初始大小,随着元素不断增加容 量 7次被迫扩大, resize需要重建 hash表,严重影响性能。
Map类集合K/V能不能存储null值的情况,如下表格:
集合类 | key | value | super | 说明 |
Hashtable | 不允许为null | 不允许为null | Dictionary | 线程安全 |
ConcurrentHashMap | 不允许为null | 不允许为null | AbstractMap | 锁分段技术(JDK8:CAS) |
TreeMap | 不允许为null | 允许为null | AbstractMap | 线程不安全 |
HashMap | 允许为null | 允许为null | AbstractMap |
线程不安全 |
合理利用好集合的有序性(sort)和稳定性(order),避免集合的无序性(unsort)和不稳定性(unorder)带来的负面影响。有序性是指遍历的结果是按某种比较规则依次排列的。稳定性指集合每次遍历的元素次序是一定的。例如:ArrayList是order/unsort;HashMap是unorder/unsort;TreeSet是order/sort。
7.并发处理
线程池不要使用 Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的人更加明确线程池运行规则,避资源耗尽风险。Executors返回的线程池对象的弊端 如下 :
- FixedThreadPool和SingleThreadPool线程的等待队列长度为Integer.MAX_VALUE,可能会堆积大量请求,从而导致OOM。
- CachedThreadPool和ScheduledThreadPool的线程最大数量为Integer.MAX_VALUE,可能会创建大量请求,从而导致OOM。
对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。线程一需要对表A、B、C依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序也必须是A、B、C,否则可能出现死锁。
并发修改同一记录时,避免更新丢失,需要加锁。要么在应用层加锁,要么在缓存加锁,要么在数据库层使用乐观锁,使用version作为更新依据。
使用CountDownLatch进行异步转同步操作,每个线程退出前必须调用countDown方法,线程执行代码注意catch异常,确保countDown方法被执行到,避免主线程无法执行至await方法,直到超时才返回结果。子线程抛出异常堆栈,不能在主线程try-catch到。
避免Random实例被多线程使用,虽然共享该实例是线程安全的,但会因竞争同一实例导致的性能下降。Random实例包括java.util.Random 的实例或者 Math.random()的方式。在JDK7之后,可以直接使用API ThreadLocalRandom,而在 JDK7之前,需要编码保证每个线程持有一个实例。
在并发场景下,通过双重检查锁(double-checked locking)实现延迟初始化的优化问题隐患(可参考 The "Double-Checked Locking is Broken" Declaration),推荐解决方案中较为简单一种(适用于JDK5及以上版本),将目标属性声明为 volatile型。如下:
public class DoubleCheckedDemo { private DoubleCheckedDemo(){} private static volatile DoubleCheckedDemo doubleCheckedDemo = null; public static DoubleCheckedDemo getInstance() { if (doubleCheckedDemo == null){ synchronized (DoubleCheckedDemo.class){ if(doubleCheckedDemo == null) { doubleCheckedDemo = new DoubleCheckedDemo(); } } } return doubleCheckedDemo; } }
volatile解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。如果是count++操作,使用如下类实现:AtomicInteger count = new AtomicInteger(); count.addAndGet(1); 如果是JDK8,推荐使用LongAdder对象,比AtomicLong性能更好(减少乐观锁的重试次数)。
HashMap在容量不够进行resize时由于高并发可能出现死链,导致CPU飙升,在开发过程中可以使用其它数据结构或加锁来规避此风险。
参考资料:
代码整洁之道 Clean Code
https://www.jianshu.com/p/59f5a5cbdf7c
集合类