命名篇
1、有意思的命名
1.1、变量名
变量名应该是名词,能够正确地描述业务,有表达力。如果一个变量名需要注释来补充说明,那么很可能说明命名就有问题。
int d ; //标识过去的天数
int elapsedTimeInDays;
# 魔术数--见名知意
SECONDS_PER_DAY -> 86400
PAGE_SIZE -> 10
1.2、函数名
函数命名要具体,空泛的命名没有意义。
processData() 差于 validateUserCredentials() 或 eliminateDuplicateRequests()
getLatestEmployee() 好于 popRecord()
1.3、类名
类是面向对象中最重要的概念之一,是一组数据和操作的封装。对于一个应用系统,我们可以将类分为两大类:实体类和辅助类。
实体类承载了核心业务数据和核心业务逻辑,其命名要充分体现业务语义,并在团队内达成共识,如Customer
、Bank
和Employee
等。
辅助类是辅佐实体类一起完成业务逻辑的,其命名要能够通过后缀来体现功能。例如,用来为Customer
做控制路由的控制类CustomerController
、提供Customer
服务的服务类CustomerService
、获取数据存储的仓储类CustomerRepository
。
对于辅助类,尽量不要用Helper
、Util
之类的后缀,因为其含义太过笼统,容易破坏SRP
(单一职责原则)。比如对于处理CSV
,可以这样写:
CSVHelper.parse(String)
CSVHelper.create(int[])
但是我更建议将CSVHelper拆开:
CSVParser.parse(String)
CSVBuilder.create(int[])
1.4、包名
包(Package)代表了一组有关系的类的集合,起到分类组合和命名空间的作用。
包名应该能够反映一组类在更高抽象层次上的联系。例如,有一组类Apple
、Pear
、Orange
,我们可以将它们放在一个包中,命名为fruit
。
包的命名要适中,不能太抽象,也不能太具体。此处以上面提到的水果作为例子,如果包名过于具体,比如Apple,那么Pear和Orange放进该包中就不恰当了;如果报名太抽象,称为Object,而Object无所不包,这就失去了包用来限定范围的作用。
1.5、模块名
相对于包来说,模块的粒度更大,通常一个模块中包含了多个包。
名称要反映模块在系统中的职责。因此,对任何应该遵循COLA规范的应用都有着xxx-controller
、xxx-app
、xxx-domain
和xxx-Infrastructure
这4个标准模块。
2、保持一致性
保持命名的一致性,可以提高代码的可读性,从而简化复杂度。因此,我们要小心选择命名,一旦选中,就要持续遵循,保证名称始终一致。
2.1、每个概念一个词
每个概念对应一个词,并且一以贯之。
例如,fetch、retrieve、get、find和query都可以表示查询的意思,如果不加约定地给多个类中的同种查询方法命名,你怎么记得是哪个类中的哪个方法呢?同样,在一段代码中,同时存在manager、controller和handler,会令人感到困惑。
在实际项目中,按照以下约定,保持命名的一致性:
CURD操作 | 方法名约定 |
---|---|
新增 | create |
添加 | add |
删除 | remove |
修改 | update |
查询(单个结果) | get |
查询(多个结果) | list |
分页查询 | page |
统计 | count |
2.2 使用对仗词
遵守对仗词的命名规则有助于保持一致性,从而提高代码的可读性。
下面列出一些常见的对仗词组:
- add/remove
- increment/decrement
- open/close
- begin/end
- insert/delete
- show/hide
- create/destroy
- lock/unlock
- source/target
- first/last
- min/max
- start/stop
- get/set
- next/previous
- up/down
- old/new
2.3 后置限定词
如果你要用类似Total
、Sum
、Average
、Max
、Min
这样的限定词来修改某个命名,那么记住把限定词加到名字的最后,并在项目中贯彻执行,保持命名风格的一致性。
这种方法有很多优点。首先,变量名中最重要的部分,即为这一变量赋予主要含义的部分应位于最前面,这样可以突出显示,并会被首先阅读到。其次,可以避免同时在程序中使用totalRevenue
和revenueTotal
而产生的歧义。如果贯彻限定词后置的原则,我们就能收获一组非常优雅、具有对称性的变量命名,例如revenueTotal
(总收入)、expenseTotal
(总支出)、revenueAverage
(平均收入)和expenseAverage
(平均支出)。
需要注意的一点是Num这个限定词,Num放在变量名的结束位置表示一个下标,customerNum表示的是当前客户的序号。为了避免Num带来的麻烦,我建议用Count或者Total来表示总数,用Id表示序号。这样,customerCount表示客户的总数,customerId表示客户的编号。
3、自明的代码
3.1、中间变量
我们可以通过添加中间变量让代码变得更加自明,即将计算过程打散成多个步骤,并用有意义的变量名来命名中间变量,从而把隐藏的计算过程以显性化的方式表达出来。
例如,我们要通过Regex来获得字符串中的值,并放到map中。
Marcher matcher = headerPattern.matcher(line);
if(matcher.find()) {
headers.put(matcher.group(1), matcher.group(2));
}
用中间变量,可以写成如下形式:
Marcher matcher = headerPattern.matcher(line);
if(matcher.find()) {
String key = matcher.group(1);
String value = matcher.group(2);
headers.put(key, value);
}
中间变量的这种简单用法,显性地表达了第一个匹配组是key
,第二个匹配组是value
。只要把计算过程打散成一系列良好命名的中间值,不透明的语义自然会变得透明。
3.2、设计模式语言
使用设计模式语言也是代码自明的重要手段之一,在技术人员之间共享和使用设计模式语言,可以极大地提升沟通的效率。当然,前提是大家都要理解和熟悉这些模式,否则就会变成“鸡同鸭讲”。因此,我们有必要在命名上就将设计模式显性化出来,这样阅读代码的人能很快领会到设计者的意图。
例如,Spring
里面的ApplicationListener
就充分体现了它的设计和用处。通过这个命名,我们知道它使用了观察者模式,每一个被注册的ApplicationListener
在Application
状态发生变化时,都会接收到一个notify。这样我们就可以在容器初始化完成之后进行一些业务操作,比如数据加载、初始化缓存等。
3.3、小心注释
- 不要复述功能
- 要解释背后意图
4、规范
4.1、命名规范
在Java中,我们通常使用如下命名约定。
- 类名采用“大驼峰”形式,即首字母大写的驼峰,例如
Object
、StringBuffer
、FileInputStream
。 - 方法名采用“小驼峰”形式,即首字母小写的驼峰,方法名一般为动词,与参数组成动宾结构,例如
Thread
的sleep(long millis)
、StringBuffer
的append(String str)
。 - 常量命名的字母全部大写,单词之间用下划线连接,例如
TOTAL_COUNT
、PAGE_SIZE
等。 - 枚举类以
Enum
或Type
结尾,枚举类成员名称需要全大写,单词间用下划线连接,例如SexEnum.MALE
、SexEnum.FEMALE
。 - 抽象类名使用
Abstract
开头;异常类使用Exception
结尾;实现类以Impl
结尾;测试类以它要测试的类名开始,以Test
结尾。
4.2、日志规范
详细的日志输出级别分为OFF、FATAL、ERROR、WARN、INFO、DEBUG、ALL或者自定义的级别。我认为比较有用的4个级别依次是ERROR、WARN、INFO和DEBUG。通常这4个级别就能够很好地满足我们的需求了。
- 1.ERROR级别
ERROR表示不能自己恢复的错误,需要立即被关注和解决。例如,数据库操作错误、I/O错误(网络调用超时、文件读取错误等)、未知的系统错误(NullPointerException、OutOfMemoryError等)。
对于ERROR,我们不仅要打印线程堆栈,最好打印出一定的上下文(链路TraceId、用户Id、订单Id、外部传来的关键数据),以便于排查问题。
ERROR要接入监控和报警系统。ERROR需要人工介入处理,及时止损,否则会影响系统的可用性。当然也不能滥用ERROR,否则就会出现“狼来了”的情况。我在实际工作中曾碰到过系统每天会发出上千条错误报警的情况,导致根本没有人看报警内容,在真正出现问题时,也没有人关注,从而引发线上故障。因此,一定要做好ERROR输出的场景定义和规范,再配合监控治理,双管齐下,确保线上系统的稳定。 - 2.WARN级别
对于可预知的业务问题,最好不要用ERROR输出日志,以免污染报警系统。例如,参数校验不通过、没有访问权限等业务异常,就不应该用ERROR输出。
需要注意的是,在短时间内产生过多的WARN日志,也是一种系统不健康的表现。因此,我们有必要为WARN配置一个适当阈值的报警,比如访问受限WARN超过100次/分,则发出报警。这样在WARN日志过于频繁时,我们能及时收到系统报警,去跟进用户问题。例如,如果是产品设计上有缺陷导致用户频繁出现操作卡点,可以考虑做一下流程或者产品上的优化。 - 3.INFO级别
INFO用于记录系统的基本运行过程和运行状态。
通常来说,优先根据INFO日志可初步定位,主要包括系统状态变化日志、业务流程的核心处理、关键动作和业务流程的状态变化。适当的INFO可以协助我们排查问题,但是切忌把INFO当成DEBUG使用,这样会导致记录的数据过多,一方面影响系统性能,日志文件增长过快,消耗不必要的存储资源;另一方面也不利于阅读日志文件。 - 4.DEBUG级别
DEBUG是输出调试信息,如request/response的对象内容。在输出对象内容时,要覆盖Object的toString方法,否则输出的是对象的内存地址,就起不到调试的作用了。通常在开发和预发环境下,DEBUG日志会打开,以方便开发和调试。而在线上环境,DEBUG开关需要关闭,因为在生产环境下开启DEBUG会导致日志量非常大,其损耗是难以接受的。只有当线上出现bug或者棘手的问题时,才可以动态地开启DEBUG。为了防止日志量过大,我们可以采用分布式配置工具来实现基于requestId判断的日志过滤,从而只打印我们所需请求的DEBUG日志。
4.3、异常规范
4.3.1、异常处理
建议在业务系统中设定两个异常,分别是BizException(业务异常)和SysException(系统异常),而且这两个异常都应该是UncheckedException。
为什么不建议用Checked Exception呢?
因为它破坏了开闭原则。如果你在一个方法中抛出了Checked Exception,而catch语句在3个层级之上,那么你就要在catch语句和抛出异常处理之间的每个方法签名中声明该异常。这意味着在软件中修改较低层级时,都将波及较高层级,修改好的模块必须重新构建、发布,即便它们自身所关注的任何东西都没有被改动过。这也是C#、Python和Ruby语言都不支持Checked Exception的原因,因为其依赖成本要高于显式声明带来的收益。
最后,针对业务异常和系统异常要做统一的异常处理,类似于AOP,在应用处理请求的切面上进行异常处理收敛,其处理流程如下:
try {
// 业务处理
Response res = process(request);
} catch(BizException e) {
// 业务异常使用 warn 级别
logger.warn("BizException with error code:{}, error message:{}", e.getErrorCode, e.getErrorMeg());
}catch (SysException ex){
//系统异常使用 error 级别
logger.error("System error" + ex.getMessage(), ex);
}catch (Exception ex) {
//兜底
logger.error("System error" + ex.getMessage(), ex);
}
千万不要在业务处理内部到处使用try/catch打印错误日志,这样会使功能代码和业务代码缠绕在一起,让代码显得很凌乱,并且影响代码的可读性。
4.3.2、错误码
(1)编号错误码
对于平台、底层系统或软件产品,可以采用编号式的编码规范,好处是编码风格固定,给人一种正式感;缺点是必须要配合文档才能理解错误码代表的意思。
例如,数据库软件Oracle中总共有2000多个异常,其编码规则是ORA-00001~ORA-02149,每一个错误码都有对应的错误解释。
ORA-00001:违反唯一约束条件。
ORA-00017:请求会话以设置跟踪事件。
ORA-00018:超出最大会话数。
ORA-00019:超出最大会话许可数。
ORA-00023:会话引用进程私用内存;无法分离会话。
ORA-00024:单一进程模式下不允许从多个进程注册。
要注意,对不同的错误波段,一定要预留足够的码号。例如,淘宝开放平台所用的3位数就显得有些拘谨,其支撑的错误数最多不能超过100,超过100后,为了向后兼容,只能通过子错误码的方式进行变通处理。
(2)显性化错误码
显性化的错误码具有更强的灵活性,适合敏捷开发。例如,我们可以将错误码定义成3个部分:类型+场景+自定义标识。每个部分之间用下划线连接,内容以大驼峰的方式书写。这里可以打破Java的常量命名规范,驼峰方式会更方便阅读。
对于错误类型,我们可以做一个约定:P代表参数异常(ParamException)、B代表业务异常(BizException)、S代表系统异常(SystemException)。一个完整的示例如表2-1所示。
错误类型 | 错误码约定 | 举例 |
---|---|---|
参数异常 | P_XX_XX | P_Customer_NameNull:客户姓名不能为空 |
业务异常 | B_XX_XX | B_Customer_NameAlreadyExist:客户姓名已存在 |
系统异常 | S_XX_XX | S_Unknow_Error:未知系统错误 |
如果业务应用的错误都用这种约定来描述和表达,那么只要大家都遵守相同的规范,系统的可维护性和可理解性就会大大提升。