代码精进之路
2022-12-20 16:57:09 1 举报
AI智能生成
学习极客时间代码精进专栏。整理成思维导图。代码从规范、经济、安全三个部分描述了高质量代码的重要性。如总结有误,欢迎指正,谢谢。
作者其他创作
大纲/内容
代码规范
把错误关在笼子里五道关卡
第一道关:程序员
提高程序员的修养,是一个永不过时的课题。从别人的失败和自己的失败中学习、积累、提高,是一个程序员成长的必修课
如果你能够找到哪怕仅仅是一个小问题的一个小小的改进办法,都有可能会给你的代码质量带来巨大的提升和改变。
第二道关:编译器
第三道关:回归测试 (Regression Testing)
第四道关:代码评审 (Code Review)
代码评审是一个有效的在软件研发过程中抵御人类缺陷的制度。
第五道关:代码分析 (Code Analysis)
优秀程序员的六个关键特质
掌握一门编程语言
解决现实的问题
优秀的程序员,是一个内外双修的程序员。如果一个程序员可以熟练使用工具,有清晰的解决问题思路,能明晰地传达产品价值,那么他编写代码就不存在什么巨大的困难了。
子主题发现关键的问题
能够发现关键的问题,我觉得是一个好程序员和优秀程序员的分水岭。
能够发现关键的问题,意味着我们可以从一个被动的做事情的程序员,升级为一个主动找事情的程序员。
沉静的前行者
优秀的程序员,一定是懂得妥协,懂得选择,一步一步把事情沉静地朝前推动的人。
可以依赖的伙伴
优秀的程序员,知道团队合作的重要性,是一个优秀的团队成员。他在团队中能够快速学习、成长,变得越来越优秀,也能够帮助其他团队成员变得越来越优秀。
优秀的程序员是一个领导型的人。他能够倾听,持续地获取他人的优秀想法,以及不同的意见。他能够表达,准确地传递自己的想法,恰当地陈述自己的意见
他是一个给予者,给别人尊重,给别人启发,给别人指导,给别人施展才华的空间
他是一个索取者,需要获得尊重,需要获得支持,需要持续学习,需要一个自主决策的空间。他能够应对压力,承担责任,积极主动,大部分时候保持克制和冷静,偶尔也会表达愤怒。
他具有一定的影响力,以及良好的人际关系,能够和各种类型的人相处,能够引发反对意见,但是又不损害人际关系。他知道什么时候可以妥协,什么时候应该坚持。
如果说,编程语言、花样工具、逻辑思维、解决问题这些“硬技能”可以决定我们的起点的话,影响力、人际关系这些“软技能”通常影响着我们可以到达的高度。
时间管理者
优秀的程序员会更好地管理时间,或者提高效率,或者用好时间。
要做只有你才能做的事情。
要坚持做需要做的事情。
选择最合适的人做最合适的事,这不仅是领导的工作分配,也可以是我们自己的协商选择。
不需要的、不紧急的、价值不大的,我们可以暂时搁置起来。
代码规范的价值
编码规范指的是针对特定编程语言约定的一系列规则,通常包括文件组织、缩进、注释、声明、语句、空格、命名约定、编程实践、编程原则和最佳实践等。
一旦学会了编码规范,并且严格地遵守它们,可以让我们的工作更简单,更轻松,少犯错误。
规范的代码,可以降低代码出错的几率
复杂是代码质量的敌人。
在编码的时候,我们应该尽量使代码风格直观、逻辑简单、表述直接。
规范的代码,可以提高编码的效率
在代码制造的每一道关卡,规范执行得越早,问题解决得越早,整个流水线的效率也就越高。
规范的代码,降低软件维护成本
在一个软件生命周期里,软件维护阶段花费了大约 80% 的成本。
编码规范越使用越高效
快系统和慢系统分工协作,快系统搞不定的事情,就需要慢系统接管。 快系统处理简单、固定的模式,而慢系统出面解决异常状况和复杂问题。
尽早地使用编码规范,尽快地培养对代码风格的敏感度。 良好的习惯越早形成,我们的生活越轻松。
优秀的代码不光是给自己看的,也是给别人看的,而且首先是给别人看的。
如何给你的代码起好名字
名字要准确地代表它背后的东西,并且还能让代码干净漂亮。
使用一个好的命名规范是非常重要的,我们都能获得哪些好处呢?
为标识符提供附加的信息,赋予标识符现实意义。帮助我们理顺编码的逻辑,减少阅读和理解代码的工作量;
使代码审核变得更有效率,专注于更重要的问题,而不是争论语法和命名规范这类小细节,提高开发效率;
提高代码的清晰度、可读性以及美观程度;
避免不同产品之间的命名冲突。
常见的命名方法
1. 驼峰命名法(CamelCase)
大驼峰命名法(UpperCamelCase)
大驼峰命名法的第一个单词以大写字母开始,其余的和小驼峰命名法相同。 比如:LastName, InputStream。
小驼峰命名法(lowerCamelCase)
小驼峰命名法的第一个单词以小写字母开始,其他单词以大写字母开始,其余字母使用小写字母。 比如:firstName, toString。
2. 蛇形命名法(snake_case)
在蛇形命名法中,单词之间通过下划线“_”连接,比如“out_of_range”。
3. 串式命名法(kebab-case)
在蛇形命名法中,单词之间通过连字符“-”连接,比如“background-color”。
4. 匈牙利命名法
在匈牙利命名法中,标识符由一个或者多个小写字母开始,这些字母用来标识标识符的类型或者用途。标识符的剩余部分,可以采取其他形式的命名法,比如大驼峰命名法。
Java命名规范
子主题
起名字三条原则
1. 要有准确的意义
名字要能够准确、完整地表达出它代表的意义,可以见字知意,名副其实。
2. 严格遵守命名规范
3. 可读性优先
名字的可读性一定要优先考虑,一般需要注意以下几点。
可读性强的名字优先于简短的名字,尽量使用完整的词汇。
不要使用缩写、简写、缩略词,除非这些词语被广泛使用。
不要使用太短的名字,比如一个字母,除非是广泛接受的特例(i/j/k/m/n 表示临时使用的整数,c/d/e 表示临时使用的字符)。
避免含糊、混淆或者误导。
代码整理的关键逻辑
程序代码分块时,我们需要注意以下三个点。
保持代码块的单一性,一个代码块只能有一个目标。代码块内所有的内容都是为了一个目标服务的,不能把无关的内容放在同一个代码块里。同一个代码块里语句的相互联系比与相邻代码块里的语句关系更为紧密;
注意代码块的完整性。代码块是一个完整的信息块。一个代码块要表达一个相对完整的意思,不能一个意思没说完就分块了,就像话说了半句一样;
代码块数量要适当。代码块过多,会让人觉得路径太长,逻辑复杂,不容易阅读理解。一个基础的代码块最好不要超过 25 行(通常显示屏小半个页面),否则就会有增加阅读理解的困难。
使用空白空间
给代码分块的办法之一,就是有效地使用空白空间。空白的空间虽然没有内容,但会给我们重要的信息提示。因此我们写代码的时候,一定要合理地运用空白。
同级别代码块靠左对齐
同级别代码块空行分割
下一级代码块向右缩进
区分不同行的不同级别的代码,可以使用缩进。缩进的目的是为了让我们更直观地看到缩进线,从而意识到代码之间的关系。
同行内代码块空格区隔
一行一个行为
if (variable != null) {
variable.doSomething();
}
if (variable != null) {
variable.doSomething();
}
基本的换行原则
基本的换行规范需要考虑以下三点。
每行代码字符数的限制。一般情况下,每行代码不要超出 80 个字符( 80 个字符是传统终端的宽度,比如 vi 编译器)。由于屏幕尺寸和代码阅读终端的变化,现在的很多规范,开始使用 120 个字符的限制。所以我们编码的时候,需要留意一下;
如果一行不足以容纳一个表达式,就需要换行;
一般的换行原则包括以下五点。
在逗号后换行。
String variable = anObject.getSomething(longExpressionOne,
longExpressionTwo, longExpressionThree);
String variable = anObject.getSomething(longExpressionOne,
longExpressionTwo, longExpressionThree);
在操作符前换行。
String varibale = longStringOne + longStringTwo
+ longStringThree;
String varibale = longStringOne + longStringTwo
+ longStringThree;
高级别的换行优先。
anObject.methodOne(parameterForMethodOne,
anObject.methodTwo(parameterForMethodTwo));
/ conventional indentation
int runningMiles = runningSpeedOne * runningTimeOne
+ runningSpeedTwo * runningTimeTwo;
// confusing indentation
int runningMiles = runningSpeedOne
* runningTimeOne + runningSpeedTwo
* runningTimeTwo;
anObject.methodOne(parameterForMethodOne,
anObject.methodTwo(parameterForMethodTwo));
/ conventional indentation
int runningMiles = runningSpeedOne * runningTimeOne
+ runningSpeedTwo * runningTimeTwo;
// confusing indentation
int runningMiles = runningSpeedOne
* runningTimeOne + runningSpeedTwo
* runningTimeTwo;
新的换行与上一行同级别表达式的开头对齐。
anObject.methodOne(parameterOne,
parameterTwo,
parameterTwo);
anObject.methodOne(parameterOne,
parameterTwo,
parameterTwo);
如果上述规则导致代码混乱或者代码太靠右,使用 8 个空格作为缩进(两个缩进单位)。
anObject.methodOne(parameterForMethodOne,
anObject.methodTwo(parameterOneForMethodTwo,
parameterTwoForMethodTwo,
parameterThreeForMethodTwo));
// bad indentation
if ((conditionOne && conditionTwo)
|| (conditionThree && conditionFour)) {
doSomething();
}
// a better indentation, using 8 spaces for the indentation
if ((conditionOne && conditionTwo)
|| (conditionThree && conditionFour)) {
doSomething();
}
anObject.methodOne(parameterForMethodOne,
anObject.methodTwo(parameterOneForMethodTwo,
parameterTwoForMethodTwo,
parameterThreeForMethodTwo));
// bad indentation
if ((conditionOne && conditionTwo)
|| (conditionThree && conditionFour)) {
doSomething();
}
// a better indentation, using 8 spaces for the indentation
if ((conditionOne && conditionTwo)
|| (conditionThree && conditionFour)) {
doSomething();
}
清晰的代码结构,可以帮助我们理顺编码的思路,提高编码的效率,减少编码的错误,提高代码的可读性,降低代码的维护成本。
注释
几种常见注释类型
第一种类型,是记录源代码版权和授权的
/*
* Copyright (c) 2018, FirstName LastName. All rights reserved.
*/
/*
* Copyright (c) 2018, FirstName LastName. All rights reserved.
*/
需要注意的是,如果文件有变更,记得更改版权信息的年份(比如上例中的 2018)。
第二种类型,是用来生成用户文档的,比如 Java Doc。
这部分的作用,是用来生成独立的、不包含源代码的文档。 这些文档帮助使用者了解软件的功能和细节,主要面向的是该软件的使用者,而不是该软件的开发者。 比如 Java 的 API 规范的文档。
第三种类型,是用来解释源代码的。
简化注释的风格
针对第一种注释类型,也就是固定的版权和授权信息,使用一般的星号注释符(/-/)。
/*
* Copyright (c) 2018, FirstName LastName. All rights reserved.
*/
/*
* Copyright (c) 2018, FirstName LastName. All rights reserved.
*/
针对第二种注释类型,即生成用户文档的注释,使用 Javadoc 要求的格式,文档注释符(/-*/)。 除了首行使用特殊的文档注释符(/),其他的格式和第一种风格保持一致。
/**
* A {@code Readable} is a source of characters. Characters from
* a {@code Readable} are made available to callers of the read
* method via a {@link java.nio.CharBuffer CharBuffer}.
*
* @since 1.5
*/
public interface Readable {
...
}
/**
* A {@code Readable} is a source of characters. Characters from
* a {@code Readable} are made available to callers of the read
* method via a {@link java.nio.CharBuffer CharBuffer}.
*
* @since 1.5
*/
public interface Readable {
...
}
针对第三种注释类型,也就是代码解释注释,只使用行注释符(//)。 每行长度限制,和代码块的每行长度限制保持一致。
// Verify that the buffer has sufficient remaining
private static void verifyLength(
ByteBuffer buffer, int requiredLength) {
...
}
String myString; // using end-to-line comment
// This is a multiple line comment. This is a multiple
// line comment.
if (!myString.isEmpty()) {
...
}
// Verify that the buffer has sufficient remaining
private static void verifyLength(
ByteBuffer buffer, int requiredLength) {
...
}
String myString; // using end-to-line comment
// This is a multiple line comment. This is a multiple
// line comment.
if (!myString.isEmpty()) {
...
}
注释的三项原则
准确,错误的注释比没有注释更糟糕。
必要,多余的注释浪费阅读者的时间。
清晰,混乱的注释会把代码搞得更乱。
写好声明的“八项纪律”
取一个好名字
一行一个声明
局部变量需要时再声明
类属性要集中声明
声明时就初始化
尾随的花括号
靠紧的小括号
搜索优化的换行
注解
什么是 Java 注解
Java 注解是 Java 1.5 引入的一个工具,类似于给代码贴个标签,通过注解可以为代码添加标签信息。
在声明继承关系中,Java 注解该如何使用?
第一个麻烦是,识别子类的方法是不是重写方法。
第二个麻烦是,重写方法可以不遵守父类方法的规范。
重写的方法,一定要加上 Override 注解。这个注解,既可以提醒代码的阅读者,也提醒代码的书写者,要谨慎对待该方法在父类定义的规范。
在废弃退役接口的情况下,如何使用注解?
第一件事情是,如果接口的设计存在不合理性,或者新方法取代了旧方法,我们应该尽早地废弃该接口。
第二件事情是,如果我们在现有的代码中使用了废弃的接口,要尽快转换、使用替换的方法。等到废弃方法删除的时候,再去更改,就太晚了,不要等到压力山大的时候才救火。
不要轻易地更改现有的代码,即使这些代码很丑陋,散发着浓浓的腐臭味。但是,有一个例外,如果看到了使用 SuppressWarnings 的代码,我们要尽可能地想办法把相关的警告消除掉、把这个注解去掉,越快越好。
总结
重写的方法,总是使用;
过时的接口,尽早废弃;
废弃的接口,不要使用。
异常处理
分清异常的类别
非正常异常(Error)
这类异常的命名以 Error 结尾,比如 OutOfMemoryError,NoSuchMethodError。这类异常,编译器编译时不检查,应用程序不需要处理,接口不需要声明,接口规范也不需要纪录;
运行时异常(RuntimeException)
这类异常的命名通常以 Exception 结尾,比如 IllegalArgumentException,NullPointerException。这类异常,编译器编译时不检查,接口不需要声明,但是应用程序可能需要处理,因此接口规范需要记录清楚;
非运行时异常
除了运行时异常之外的其他的正常异常都是非运行时异常,比如 InterruptedException,GeneralSecurityException。和运行时异常一样,命名通常以 Exception 结尾。这类异常,编译器编译时会检查异常是否已经处理或者可以抛出,接口需要声明,应用程序需要处理,接口规范需要记录清楚。
异常总结
子主题
标记清楚抛出异常
应用程序需要处理异常(CheckedException 和 RuntimeException),就需要我们在方法的规范描述文档中清楚地标记异常。没有标记的异常,应用程序没有办法通过文档了解哪些异常需要处理、什么状况下会抛出异常以及该怎么处理这些异常。
如果一个方法既没有异常的声明,又没有异常的规范描述,调用者一般不会进行异常处理,也不在规范描述中加入抛出异常的描述。
处理好捕获异常
Java 异常的四个要素:
异常类名(IllegalArgumentException, FileNotFoundException)
异常描述(“Invalid file path”)
异常堆栈(at sun.security.ssl.InputRecord.read(InputRecord.java:504))
异常转换(Caused by: javax.net.ssl.SSLException: Unrecognized SSL message, plaintext connection?)
其中 JVM 自动帮我们处理异常堆栈,我们需要特别关注的就只有三点了。
1. 对于异常类名,我们要准确地选择异常类。
我们应该尽量减少超级异常类的使用,而是选择那些意义明确、覆盖面小的异常类,比如 FileNotFoundException。
2. 对于异常描述,我们要清晰地描述异常信息。
子主题
3. 对于异常转换,我们要恰当地转换异常场景。
第一个情况,就是需要编写转换的代码,这当然没有异常一抛到底方便。
第二个情况,就是信息的冗余。如果转换场景有两三层,异常打印出来的堆栈信息就会很长,而最有用的信息其实只有最原始的异常。
第三个情况,就是信息的丢失。有些信息的丢失是有意的,比如对敏感信息的过滤而丢掉的异常信息。有些信息的丢失是无意的过失。信息的丢失很难让我们排查出异常问题,于是错误的源头被硬生生地隐匿了起来。所以,除非有明确的需求,我们要尽量保留所有的异常信息以及转换场景。
子主题
小结
不要使用异常机制处理正常业务逻辑;
异常的使用要符合具体的场景;
具体的异常要在接口规范中声明和标记清楚。
组织好代码段
代码文件头部结构
版权和许可声明;
命名空间(package);
外部依赖(import)。
代码文件对象结构
类的规范;
类的声明;
类的属性和方法。
子主题
类的内部代码结构
类的属性;
构造方法;
工厂方法;
其他方法。
子主题
方法的代码结构
方法的规范;
一个典型的规范,应该包含以下十个部分:
方法的简短介绍;
方法的详细介绍(可选项);
规范的注意事项 (使用 apiNote 标签,可选项);
实现方法的要求 (使用 implSpec 标签,可选项);
实现的注意事项 (使用 implNote 标签,可选项);
方法参数的描述;
返回值的描述;
抛出异常的描述:需要注意的是,抛出异常的描述部分,不仅要描述检查型异常,还要描述运行时异常;
参考接口索引(可选项);
创始版本(可选项)。
方法的声明;
方法的实现。
按顺序使用限定词
限定词推荐使用顺序:
public/private/protected (访问控制限定词,制定访问权限)
abstract (抽象类或者抽象方法,具体实现由子类完成)
static (静态类、方法或者类属性)
final (定义不能被修改的类、方法或者类属性)
transient(定义不能被序列化的类属性)
volatile(定义使用主内存的变量)
default(声明缺省的方法)
synchronized(声明同步的方法)
native(声明本地的方法,也就是 Java 以外的语言实现的方法)
strictfp(声明使用精确浮点运算)
子主题
使用空行分割代码块
我们可以使用空行分割如下的代码块:
版权和许可声明代码块;
命名空间代码块;
外部依赖代码块
类的代码块;
类的属性与方法之间;
类的方法之间;
方法实现的信息块之间。
子主题
组织好代码文件
逻辑之一:软件是干什么的?
可以回答这个问题的文件叫做 README,它的命名全部使用大写字母。需要被放在一个软件工程的根目录里,方便人或者机器第一时间找到,从而轻而易举地找到并进行阅读。
逻辑之二:软件可以拿来用吗?
这种情况下,版权描述一般放在每一个源文件的头部。不同的源文件可以有不同的版权,同一个源文件也可以有一个以上的版权所有者。
如果版权来源只有一个,而且源文件头部没有版权描述,我们就需要把版权描述放到最显眼的地方。这个地方就是软件工程的根目录,命名为 COPYRIGHT,全部使用大写字母。
有了版权保护,不代表我们就不能使用这个软件了。我们能不能使用、怎么使用,是由软件的许可证确定的。
当使用软件的时候,不能超越许可证约定的范围。 一个没有许可证的软件,我们是不能使用的,因为不知道许可的范围,也不知道应承担的义务。同样,如果一个软件的许可证不清晰,或者我们不了解,那么使用起来也会有很多法律问题。
逻辑之三:软件是怎么实现的?
使用命名空间的区隔至少有三个好处:
可以把一个组织的代码和另外一个组织的代码区隔开;
可以把一个项目的代码和另外一个项目的代码区隔开;
可以把一个模块的代码和另外一个模块的代码区隔开。
逻辑之四:软件该怎么测试?
如果要使用或者发布一个软件,最值得关注的还有软件的质量。软件的质量,首先要通过软件测试这一关
有很多传统的软件项目,测试代码和功能代码是放在同一个目录下的。如果一个项目比较小,那么这样做也许没什么大问题。一旦软件工程变得复杂,这样做就会让项目变得难以管理,尤其是在测试人员和开发人员分离的情况下。
仅仅放置在对应的目录下还不够,测试文件本身还有一个需要注意的地方,一个测试文件,最好执行一个独立的任务。如果测试出错误,我们就能够快速定位错误。这也要求测试的目标要小,测试的结果要清晰,测试的组织要照应功能代码的组织。
软件该怎么使用?
一个好的软件,要尽可能降低使用门槛。编写使用指南和代码示例是两个常用的办法。一份好的用户文档,应该让软件的用户快速入门,然后再逐步深入地了解整个软件的使用细节,以及潜在的问题。
作为程序员,我们不仅要熟悉源代码,还要熟悉文档。当需要更直观的用户指南或者代码示例时,就要写作这样的软件文档。对于每一行的代码变更,我们都要问,需不需要文档变更?如果代码和文档一致的话,就会节省我们大量的维护时间和维护成本。
接口规范
接口规范是协作合约
由于外部接口是协作的界面,是调用者和实现者之间的合约,所以对它就有了更加严格的要求。这里我总结了合约的四个原则:成文、清楚、稳定、变更要谨慎。
合约要成文
无论对于调用者,还是实现者来说,外部接口的使用都要有章可循,有规可依。
比如说,Java 的每个版本的 API 文档和指南,就是 Java 语言的合约。
合约要清楚
合约既然是我们协作的依靠,就一定要清晰可靠、容易遵循,不能有模棱两可的地方。如果接口规范描述不清,既误导调用者,也误导实现者。
接口规范主要用来描述接口的设计和功能,包括确认边界条件、指定参数范围以及描述极端状况。比如,参数错了会出什么错误?
需要注意的是,接口规范不是我们定义术语、交代概念、提供示例的地方。这些应该在其他文档中解决,比如我们下次要聊的面向最终用户的文档。
合约要稳定
既然是合约,意味着调用者必须依赖于现有的规范。
接口的设计和规范的制定,一定要谨慎再谨慎,小心再小心,反复推敲,反复精简。一旦接口合约制定,公布,然后投入使用,就尽最大努力保持它的稳定,即使这个接口或者合约存在很多不足。
变更要谨慎
能不变更就不变更;必须的变更,一定要反复思量该怎么做才能把影响降到最低。
使用 Java Doc
JavaDoc 就是一种顾及了多方利益的一种组织形式。它通过文档注释的形式,在接口声明的源代码定义和描述接口规范。这种和源代码结合的方式,可以方便我们维护接口规范,也有利于保持接口规范和接口声明的一致性。
JavaDoc 工具可以把文档注释,转换为便于阅读为 HTML 文档。这样就方便规范的使用者阅读了。
谁来制定接口合约?
既然是合约,就是大家都认可并且接受的规范和细节,只有形成共识才能编辑和修订。合约的编写和修订,一般不应该由源代码的维护者一人决定,而应该由参与各方充分沟通和协商。
一个软件项目,不管大小,只要参与者超过两个,都要讨论清楚彼此之间的分工协作方式。这当然也包括,讨论清楚如何制定、修改程序接口。
比如,OpenJDK 的接口制定和修订,就一定要经过下面的步骤:
起草接口规范,或者起草提议的修订规范;
找相关领域的专家,审议草案,并根据评审意见,修改接口规范;
如果领域专家审议通过,提交兼容性和规范性审查程序; 并根据审查意见,相应地修改接口规范;
兼容性和规范性审查通过,修改接口合约;
按照议定的接口规范,编写最终的实现的代码。
编写规范代码检查清单
为什么需要编码规范?
1. 提高编码的效率
在不损害代码质量的前提下,效率可以节省我们的时间和成本。这种节省不仅仅停留在编码阶段,更体现在整个软件的生命周期里。
2. 提高编码的质量
代码的质量在于它和预期规范的一致性。一致、简单、规范的代码易于测试。相反,复杂的代码会加大测试的难度,难以达到合适的测试覆盖率。另外,代码的复用也意味着质量的复用,所以代码的问题会随着它的复用成倍地叠加,提高了软件的使用或者维护成本。
3. 降低维护的成本
代码的维护要求代码必须能够修改,增加新功能,修复已知的问题。如果代码结构的清晰、易于阅读理解,那么问题就容易排查和定位。
4. 扩大代码的影响
要想让更多的人参与,就需要一致的编码风格,恰当地使用文档。要方便他们阅读,便于解释。
编码规范的心理因素
两种思维模式
自主模式(快系统)
自主模式的运行是无意识的、快速的、不怎么耗费脑力;
自主模式在熟悉的环境中是精确的,所作出的短期预测是准确的,遇到挑战时,会第一时间做出反应
然而,它存在成见,容易把复杂问题简单化,在很多特定的情况下,容易犯系统性的错误。比如说,第一印象、以貌取人,就是自主模式遗留的问题。
控制模式(慢系统)
控制模式需要集中注意力,耗费脑力,判断缓慢,如果注意力分散,思考就会中断。
当自主模式遇到麻烦时,控制模式就会出面解决,控制模式能够解决更复杂的问题。
但刻意掌控会损耗大脑能量,而且很辛苦。处于控制模式中太长时间,人会很疲惫,丧失一部分动力,也就不愿意去做启动控制模式了
自主模式和控制模式的分工合作是高效的,损耗最小,效果最好。快速的、习惯性的决断交给勤快省力的自主模式,复杂的、意外的决断由耗时耗力的控制模式接管。
识别模式
一份好的编码规范,刚开始接受会有些困难。我们甚至会找很多借口去拒绝。但是,一旦接受下来,我们就成功地改进了我们的识别模式。
模式识别的认知是一柄双刃剑,既是福音也是祸害。它可以帮助我们毫不费力地使用经验,但习惯一旦养成就很难改变,我们不愿意打破旧模式,去学习新模式和接受新技术。
猜测模式
对于既定模式的识别,是通过猜测进行的。对于每一个新场景,大脑立即会把它起始部分当作一个线索,然后通过拟合所有已知模式的起始部分,来预测模式的其余部分,猜测“言外之意”。
我们掌握的信息越少,就越有可能犯错误。
记忆模式
感官
感官记忆是对我们感官体验的记忆,非常短暂(大约三秒钟),比如我们刚刚看到的和听到的
短期
短期记忆是我们可以回忆的,刚刚接触到的信息的短暂记忆。短期记忆很快,但是很不稳定,并且容量有限。如果中途分心,即便只是片刻,我们也容易忘记短期记忆的内容。
工作
工作记忆是我们在处理认知任务时,对信息进行短暂存贮并且执行操作的记忆。工作记忆将短期记忆和长期记忆结合起来,处理想法和计划,帮助我们做出决策。
长期记忆
长期记忆涵盖的记忆范围从几天到几十年不等。为了成功学习,信息必须从感官或短期记忆转移到长期记忆中。和短期记忆相比,长期记忆记忆缓慢,但是保持长久,并且具有近乎无限的容量。
我们在组织代码时,不要让短期记忆超载,要使用短小的信息快,方便阅读;要适当分割需要长期记忆和短期记忆的内容,比如接口规范和代码实现,帮助读者在工作记忆和长期记忆中组织和归档信息。
眼睛的运动
有时候,我们需要反复研读一段代码。如果这段代码可以在一个页面显示,我们的眼睛就很容易反复移动,寻找需要聚焦的目标。如果这段代码跨几个页面,阅读分析就要费力得多。
当我们阅读时,我们的眼睛习惯从左到右,从上到下移动,所以靠左的信息更容易被接受,而靠右的信息更容易被忽略。
当我们快速阅读或者浏览特定内容时(比如搜索特定变量),眼睛就会只喜欢上下移动,迅速跳过。聚焦区域小,眼睛倾向于上下移动,这就是报纸版面使用窄的版面分割,而不是整幅页面的原因之一。
在编码排版时,要清晰分块,保持布局明朗,限制每行的长度,这样可以方便眼睛的聚焦和浏览。
编码规范的检查清单
代码是按照编码指南编写的吗?
代码能够按照预期工作吗?
文件是不是在合适的位置?
支撑文档是不是充分?
代码是不是易于阅读、易于理解?
代码是不是易于测试和调试?
有没有充分的测试,覆盖关键的逻辑和负面清单?
名字是否遵守命名规范?
名字是不是拼写正确、简单易懂?
名字是不是有准确的意义?
代码的分块是否恰当?
代码的缩进是否清晰、整洁?
有没有代码超出了每行字数的限制?
代码的换行有没有引起混淆?
每一行代码是不是只有一个行为?
变量的声明是不是容易检索和识别?
变量的初始化有没有遗漏?
括号的使用是不是一致、清晰?
源代码的组织结构是不是一致?
版权信息的日期有没有变更成最近修改日期?
限定词的使用是不是遵循既定的顺序?
有没有注释掉的代码?
有没有执行不到的代码?
有没有可以复用的冗余代码?
复杂的表达式能不能拆解成简单的代码块?
代码有没有充分的注释?
注释是不是准确、必要、清晰?
不同类型的注释内容,注释的风格是不是统一?
有没有使用废弃的接口?
能不能替换掉废弃的接口?
不再推荐使用的接口,是否可以尽早废弃?
子主题继承的方法,有没有使用 Override 注解?
有没有使用异常机制处理正常的业务逻辑?
异常类的使用是不是准确?
异常的描述是不是清晰?
是不是需要转换异常的场景?
转换异常场景,是不是需要保留原异常信息?
有没有不应该被吞噬的异常?
外部接口和内部实现有没有区分隔离?
接口规范描述是不是准确、清晰?
接口规范有没有描述返回值?
接口规范有没有描述运行时异常?
接口规范有没有描述检查型异常?
接口规范有没有描述指定参数范围?
接口规范有没有描述边界条件?
接口规范有没有描述极端状况?
接口规范的起草或者变更有没有通过审阅?
接口规范需不需要标明起始版本号?
产品设计是不是方便用户使用?
用户指南能不能快速上手?
用户指南的示例是不是可操作?
用户指南和软件代码是不是保持一致?
代码经济
为什么需要经济的代码?
需不需要“跑得快”的代码?
怎么理解代码的性能?
代码的性能和算法密切相关,但是更重要的是,我们必须从架构层面来考虑性能,选择适当的技术架构和合适的算法。
为了管理代码的性能,在一定程度上,我们需要很好地了解计算机的硬件、操作系统以及依赖库的基本运行原理和工作方式。一个好的架构师,一定会认真考虑、反复权衡性能要求。
需不需要学习性能?
好的性能可以把 Java 垃圾管理器的效率提高 50%,或者把列表的查询速度提高 50%
成熟的解决方案就在那儿,容易理解,也容易操作。只是,我们没有想到,没有看到,也没有用到这些解决方案。我们越不重视性能,这些知识离我们就越远。
一个好的程序员,他编写的代码一定兼顾正确和效率的。事实上,只有兼顾正确和效率,编程才有挑战性,实现起来才有成就感。如果丢弃其中一个指标,那么大多数任务都是小菜一碟。
需不需要考虑代码性能?
我们可以问自己一些简单的问题。比如说,一万个用户会同时访问吗?如果一秒钟你需要处理一万个用户的请求,这就需要有百万用户、千万用户,甚至亿万用户的架构设计。
再比如说,会有一万个用户同时访问吗?也许系统没有一万个真实用户,但是可能会有一万个请求同时发起,这就是网络安全需要防范的网络攻击。系统保护的东西越重要,提供的服务越重要,就越要防范网络攻击。而防范网络攻击,只靠防火墙等边界防卫措施,是远远不够的,代码的质量才是网络安全防护的根本。
越早考虑性能问题,我们需要支付的成本就越小,带来的价值就越大。甚至是,和不考虑性能的方案相比,考虑性能的成本可能还要更小。
什么时候开始考虑性能问题?
硬件扩展能解决性能问题吗?
扩展硬件并不是总能够线性地提高系统的性能。出现性能问题,投入更多的设备,只是提高软件性能的一个特殊方法。而且,这不是一个廉价的方法。
性能问题能滞后处理吗?
没有高质量的工作作为基础,敏捷开发模式就会越走越艰难,越走越不敏捷,越走成本越高。而性能问题,是最重要的质量指标之一。
性能问题,有很多是架构性问题。一旦架构性问题出现,往往意味着代码要推倒重来,这可不是我们可以接受的快速迭代。当然,也有很多性能问题,是技术性细节,是变化性的问题。对于这些问题,使用快速迭代就是一个经济的方式。
要有性能工程的思维
采用性能工程思维,才能确保快速交付应用程序,而不用担心因为性能耽误进度。性能工程思维通过流程“左移”,把性能问题从一个一次性的测试行为,变成一个贯穿软件开发周期的持续性行为;从被动地接受问题审查,变成主动地管理质量。
在软件研发的每一步,每一个参与人员,都要考虑性能问题。整个过程要有计划,有组织,能测量,可控制。
采用性能工程思维,架构师知道他们设计的架构支持哪些性能的要求;开发工程师清楚应该使用的基本技术,而不是选择性地忽略掉性能问题;项目管理人员能够在开发软件过程中跟踪性能状态;性能测试专家有时间进行负载和压力测试,而不会遇到重大意外。实现性能要求的风险在流程早期得到确认和解决,这样就能节省时间和金钱,减轻在预算范围内按时交付的压力。
小结
编写有效率的代码是我们的一项基本技能。我们千万不要忽视代码的性能要求。越早考虑性能问题,需要支付的成本就越小,带来的价值就越大,不要等到出现性能问题时,才去临时抱佛脚。另外,性能问题,大部分都是意识问题和见识问题。想得多了,见得多了,用得多了,技术就只是个选择的问题,不一定会增加我们的编码难度和成本。
什么样的代码才是高效的代码?
用户的真实感受
最直接的指标就是用户的真实感受。用户的感受是我们软件开发最基本的风向标,当然也是代码性能追求的终极目标。
用户对于软件性能的要求,和我们超市结账时的要求差不多:等待时间要短,出错的概率要小。
等待时间要短
根据任务的响应时间,应用程序性能指数定义了三个用户满意度的区间:
满意:如果任务的响应时间小于 T,用户感觉不到明显的阻碍,就会比较满意;
容忍:如果任务的响应时间大于 T,但是小于 F,用户能感觉到性能障碍,但是能够忍受,愿意等待任务的完成;
挫败:如果任务的响应时间大于 F 或者失败,用户就不会接受这样的等待。挫败感会导致用户放弃该任务。
在互联网领域,最佳等待时间(T)和最大可容忍等待时间(F)的选择有着非常经典的经验值,那就是最佳等待时间是 2 秒以内,最大可容忍等待时间是最佳等待时间的 4 倍,也就是 8 秒以内。
体验要一致
一致性原则是一个非常基本的产品设计原则,它同样也适用于性能的设计和体验。
代码的资源消耗
管理好计算机资源主要包括两个方面,一个方面是把有限的资源使用得更有效率,另一个方面是能够使用好更多的资源。
把资源使用得更有效率
完成同一件事情,尽量使用最少的计算机资源,特别是使用最少的内存、最少的 CPU 以及最少的网络带宽。
计算机资源的使用,也是一个策略。不同的计算场景,需要匹配不同的策略。只有这样,才能最大限度地发挥计算机的整体的计算能力,甚至整个互联网的计算能力。
能够使用好更多的资源
不是所有的应用程序设计都能够用好更多的资源。这是我们在架构设计时,就需要认真考量的问题。
算法的复杂程度
一个最重要、最常用、最直观的指标就是算法复杂度。对于计算机运算,算法复杂度又分为时间复杂度和空间复杂度。我们可以使用两个复杂度,来衡量 CPU 和内存的使用效率。
怎样避免过度设计?
避免需求膨胀
频繁的需求变更确实让人抓狂。它变更的可不仅仅只是需求,还有不断重构的代码,不断延长的工期,不断增长的投入,以及越来越多的加班。
软件是为现实服务的,而现实总是变化的。作为程序员,我们是没有办法抵制住所有的需求变更的。为了限制无节制的需求变更,适应合理的需求进化,我们要使用两个工具,一个工具是识别最核心需求,另一个工具是迭代演进。
识别最核心需求
所以,我们要回归到最终用户。只有从最终用户的眼里看需求,才能够识别什么是最核心的需求,什么是衍生的需求,什么是无效的需求。这样,我们才能找到一个最小的子集,那就是现在就必须满足的需求。
首先就必须满足的需求,是优先级最高的、最重要的事情,这些事情要小而精致,是我们的时间、金钱、智力投入效率最高的地方,也是回报最丰厚的地方。我们要把这些事情做到让竞争对手望尘莫及的地步。
不要一步到位
有一些需求很重要,但不是现在就必须做的。这就需要另外一个方法——迭代演进。第一次我们没有办法完成的事情,就放在第二次考虑。
迭代演进不仅仅需要考虑上一次没有完成的事情,还要考虑变化促生的新需求。所以,在这一步,还要像第一次一样,先找到最小的子集,也就是现在就必须满足的需求。然后,全力以赴地做好它。
这样迭代了几轮之后,一定有一些第一次看起来很重要的需求,再看反而不重要了,根本就不需要解决。
是不是迭代的时候,就可以考虑一些不重要的需求了呢?不,永远不要考虑不重要的需求。有时候,遏制住添加新功能、新接口的渴望,是一个困难的事情。我们需要学会放手,学会休假,以及拥有空闲时间。
避免过度设计
过度设计导致过度复杂,过度复杂导致效率降低、危险加剧、性能降低。如果保持简单而不是复杂化,大多数系统都能发挥最佳作用。这就是“少就是多”的魅力。
避免过度设计,和避免需求膨胀一样,我们要时刻准备提问和回答的两个问题:什么是必须做的?什么是现在就必须做的?
简单和直观,是永恒的解决方案
为什么需要简单直观?
简单直观是快速行动的唯一办法
我们真正要的不是简单直观的代码,而是轻松快速的行动。编写简单直观的代码只是我们为了快速行动而不得不采取的手段。有一个说法,如果面条代码能够让我们行动更快,我们就会写出面条代码,不管是刀削面还是担担面。
这些对代码整洁充满热情的工程师,会对整个团队产生积极的、至关重要的影响。这种影响,不仅仅关乎到工程进展的速度,还关系到工程的质量。真正能够使得产品获得成功,甚至扭转科技公司命运的,不是关键时刻能够救火的队员,而是从一开始就消除了火灾隐患的队员。
简单直观减轻沟通成本
简单直观的解决方案,有一个很大的优点,就是容易理解,易于传达。事情越简单,理解的门槛越低,理解的人越多,传达越准确。一个需要多人参与的事情,如果大家都能够清晰地理解这件事情,这就成功了一半。
简单直观降低软件风险
软件最大的风险,来源于软件的复杂性。软件的可用性,可靠性,甚至软件的性能,归根到底,都是软件的复杂性带来的副产品。越复杂的软件,我们越难以理解,越难以实现,越难以测量,越难以实施,越难以维护,越难以推广。如果我们能够使用简单直接的解决方案,很多棘手的软件问题都会大幅地得到缓解。
该怎么做到简单直观?
使用小的代码块
做小事的一个最直观的体现,就是代码的块要小,每个代码块都要简单直接、逻辑清晰。整洁的代码读起来像好散文,赏心悦目,不费力气。
为了保持代码块的简单,给代码分块的一个重要原则就是,一个代码块只做一件事情。
遵守约定的惯例
把代码块做小,背后隐含一个重要的假设:这些小代码块要容易组装。不能进一步组装的代码,如同废柴,没有一点儿价值。
而能够组装的代码,接口规范一定要清晰。越简单、越规范的代码块,越容易复用。这就是我们前面反复强调的编码规范。
花时间做设计
大部分的优秀的程序员,是这两个风格某种程度的折中,早拆解、早验证,边拆解、边验证,就像剥洋葱一样。
拆解和验证,看起来很花时间。是的,这两件事情的确很耗费时间。但是,如果我们从整个软件的开发时间来看,这种方式也是最节省时间的。如果拆解和验证做得好,代码的逻辑就会很清晰,层次会很清楚,缺陷也少。
一个优秀的程序员,可能 80% 的时间是在设计、拆解和验证,只有 20% 的时间是在写代码。但是,拿出 20% 的时间写的代码,可能要比拿出 150% 时间写的代码,还要多,还要好。这个世界真的不是线性的。
借助有效的工具
我自己最常使用的工具,就是圆珠笔和空白纸。大部分问题,一页纸以内,都可以解决掉。当然,这中间的过程,可能需要一打甚至一包纸。
现在我最喜欢的工具有思维导图、时序图和问题清单。在拆解问题时,思维导图可以帮助我厘清思路,防止遗漏。时序图可以帮助我理解关键的用例,勾画清楚各个部件之间的联系。而问题清单,可以记录下要解决和已经解决的问题,帮助我记录状态、追踪进度。
怎么设计一个简单又直观的接口?
从问题开始
一个解决方案,是从需要解决的现实问题开始的。要解决的问题,可以是用户需求,也可以是现实用例。面对要解决的问题,我们要把大问题分解成小问题,把小问题分解成更小的问题,直到呈现在我们眼前的是公认的事实或者是可以轻易验证的问题。
分解问题时,我们要注意分解的问题一定要“相互独立,完全穷尽”(Mutually Exclusive and Collectively Exhaustive)。这就是 MECE 原则。使用 MECE 原则,可以帮助我们用最高的条理化和最大的完善度理清思路。
为什么从问题开始?
只有相互独立,才能让解决方案简单。否则,不同的因素纠缠在一起,既容易导致思维混乱,也容易导致不必要的复杂。
从问题开始,是为了让我们能够找到一条主线。然后,围绕这条主线,去寻找解决问题的办法,而不是没有目标地让思维发散。这样,也可以避免需求膨胀和过度设计。
自然而来的接口
把大问题分解成小问题,再把小问题分解成更小的问题。在这个问题逐层分解的过程中,软件的接口以及接口之间的联系,也就自然而然地产生了。这样出来的接口,逻辑直观,职责清晰。对应的,接口的规范也更容易做到简单、稳定。
一个接口一件事情
对于一件事的划分,我们要注意三点。
一件事就是一件事,不是两件事,也不是三件事。
这件事是独立的。
这件事是完整的。
减少依赖关系
有时候,“一个接口一件事情”的要求有点理想化。如果我们的设计不能做到这一点,一定要减少依赖关系,并且声明依赖关系。
使用方式要“傻”
所有接口的设计,都是为了最终的使用。方便、皮实的接口,才是好用的接口。接口要很容易理解,能轻易上手,这就是方便。此外还要限制少,怎么用都不容易出错,这就是皮实。
小结
从真实问题开始,把大问题逐层分解为“相互独立,完全穷尽”的小问题;
问题的分解过程,对应的就是软件的接口以及接口之间的联系;
一个接口,应该只做一件事情。如果做不到,接口间的依赖关系要描述清楚。
高效率,从超越线程同步开始!
什么时候需要同步?
线程的并发执行和共享进程资源,是为了提高效率。可是线程间如何管理共享资源的变化,却是一个棘手的问题,甚至是一个损害效率的问题。如果有两个以上的线程,关心共享资源的变化,一旦共享资源发生变化,就需要同步。线程同步的意思,就是一个接一个来,上一个线程没有完成一项任务之前,下一个线程不能开始相关的行动。简单地说,就是排队。
需要同步的场景,要同时满足三个条件:
使用两个以上的线程;
关心共享资源的变化;
改变共享资源的行为。
同步是损害效率的
线程同步也是这样的,同步需要排队,同步的管理需要时间。所以,实践中,我们要想尽办法避免线程的同步。如果实在难以避免,就减少线程同步的排队时间。
避免线程同步
对应上述的同步场景所需的三个条件,我们只要打破其中的任何一个条件,就不需要线程同步了:
使用单线程;
不关心共享资源的变化;
没有改变共享资源的行为。
减少线程同步时间
在阻塞的这段时间里,做的事情越少,阻塞时间一般就会越短。
减少阻塞时间的一个办法,就是只同步和共享资源变化相关的逻辑。引起共享资源变化的事前准备以及善后处理,属于线程内部变化,不需要同步处理。
在设计接口或者实现代码时,有一项很重要的一个工作,就是反复考虑在多线程环境下,怎么做才能让线程同步的阻塞时间最小。这是一个很值得花费时间去琢磨的地方
怎么减少内存使用,减轻内存管理负担?
使用更少的内存
提高内存使用最有效率的办法,就是使用更少的内存。这听起来像是废话,却也是最简单直接、最有用的办法。减少内存的使用,意味着更少的内存分配、更少的内存填充、更少的内存释放、更轻量的垃圾回收。内存的使用减少一倍,代码的效率会成倍地提升,这不是简单的线性关系。
减少内存的使用,办法有且只有两个。第一个办法是减少实例的数量。第二个办法是减小实例的尺寸。
减少实例数量
对于数量有限的对象,我们应该优先考虑使用枚举类型,比如交通标志,国家名称等等。其实,枚举类型就是一种常用的数据静态化的范例。我们还会在后面讨论其他类似的数据静态化的处理方式。
避免不必要的实例
不要使用不必要的构造函数
子主题
避免使用原始数据类
涉及到 Java 原始数据类型的自动装箱(boxing)与拆箱(unboxing)的类型转换。
使用单实例模式
由于 Java 内在的单实例模式,我们可以很方便地使用 Java 的原始数据类型,而不用担心实例数量的增长。对于复合的类,我们也可以自己设计单实例模式,从而减少多实例带来的不必要的开销。
减小实例的尺寸
减少内存的使用还有另外一个办法,就是减小实例的尺寸。所谓减少实例的尺寸,就是减少这个实例占用的内存空间。这个空间,不仅包括实例的变量标识符占用的空间,还包括标识符所包含对象的占用空间。
尽可能多地共享资源,这是一条提高效率的基本原则。在编写代码时,如果能够引用,就坚决不要拷贝;如果能够复用,就坚决不要新创。当然,资源的共享,除了上一次提到的线程同步问题,还有一个资源的维护问题。一个资源,如果不需要维护,那就太理想了。
不可变的类
由于不可变的类一旦实例化,就不再变化,我们可以放心地在不同的地方使用它的引用,而不用担心任何状态变化的问题。无法修改的对象
无法修改的对象
还有一类对象,虽然不是不可变的类的实例,但是它的修改方法被禁止了。当我们使用这些对象的代码时,没有办法对它做出任何修改。这样,这些对象就有了和不可变的实例一样的优点,可以放心地引用。
从 Java 8 开始,Java 核心类库通过 Collections 类提供了一系列的生成不可更改的集合的方法。这些方法,极大地减轻了集合的共享和维护问题。
黑白灰,理解延迟分配的两面性
延迟分配
改动后的缺省构造方法,不再分配内部数组,而是使用了一个空数组。要等到真正需要存储数据的时候,才为这个数组分配空间。这就是所谓的延迟初始化。
局部变量标识符的声明应该和它的使用尽可能地靠近。这样的规范,除了阅读方面的便利之外,还有效率方面的考虑。局部变量占用的资源,也应该需要时再分配,资源的分配和它的使用也要尽可能地靠近。
延迟初始化
public class CodingExample {
private Map<String, String> helloWordsMap;
private void setHelloWords(String language, String greeting) {
if (helloWordsMap == null) {
helloWordsMap = new HashMap<>();
}
helloWordsMap.put(language, greeting);
}
// snipped
}
延迟分配的思路,就是用到声明时再初始化,这就是延迟初始化。
声明时就初始化的好处是简单、直接、代码清晰、容易维护。但是,如果初始化占用的资源比较多或者占用的时间比较长,这个方案就有可能带来一些负面影响。我们就要慎重考虑了。
public class CodingExample {
private Map<String, String> helloWordsMap;
private void setHelloWords(String language, String greeting) {
if (helloWordsMap == null) {
helloWordsMap = new HashMap<>();
}
helloWordsMap.put(language, greeting);
}
// snipped
}
上面的例子中,实例变量 helloWordsMap 只有需要时才初始化。这的确可以避免内存资源的浪费,但代价是要使用更多的 CPU。检查实例变量是否已经能初始化,需要 CPU 的额外开销。这是一个内存和 CPU 效率的妥协与竞争。
除非是静态变量,否则使用延迟初始化,一般也意味着放弃了使用不可变的类可能性。这就需要考虑多线程安全的问题。上面例子的实现,就不是多线程安全的。对于多线程环境下的计算,初始化时需要的线程同步也是一个不小的开销。
延迟初始化到底好不好,要取决于具体的使用场景。一般情况下,由于规范性带来的明显优势,我们优先使用“声明时就初始化”这个方案。
只有初始化占用的资源比较多或者占用的时间比较长的时候,我们才开始考虑其他的方案。复杂的方法,只有必要时才使用。
小结
对于局部变量,我们应该坚持“需要时再声明,需要时再分配”的原则。
对于类的变量,我们依然应该优先考虑“声明时就初始化”的方案。如果初始化涉及的计算量比较大,占用的资源比较多或者占用的时间比较长,我们可以根据具体情况,具体分析,采用延迟初始化是否可以提高效率,然后再决定使用这种方案是否划算。
使用有序的代码,调动异步的事件
为什么需要异步编程?
为了更有效地利用计算资源,我们使用有序的代码,调动起独立的事件。
从过程到事件
异步模式在网络阻塞期间,能够更好地利用其他的计算资源,从而提高整体的效率。
异步
// Create an HTTP client that prefers HTTP/2.
HttpClient httpClient = HttpClient.newBuilder()
.version(Version.HTTP_2)
.build();
// Create a HTTP request.
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create("https://www.example.com/"))
.build();
// Send the request and set the HTTP response handler
httpClient.sendAsync(httpRequest, BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(System.out::println);
// next action
// Create an HTTP client that prefers HTTP/2.
HttpClient httpClient = HttpClient.newBuilder()
.version(Version.HTTP_2)
.build();
// Create a HTTP request.
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create("https://www.example.com/"))
.build();
// Send the request and set the HTTP response handler
httpClient.sendAsync(httpRequest, BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(System.out::println);
// next action
而 HttpClient 接口的代码,指令发布完,就可以执行下一步操作了。这个指令的执行时间,一般是毫秒以下的数量级别。
同步
// Open the connection
URL url = new URL("https://www.example.com/");
HttpsURLConnection urlc = (HttpsURLConnection)url.openConnection();
// Read the response
try (InputStream is = urlc.getInputStream()) {
while (is.read() != -1) { // read to EOF
// dump the response
// snipped
}
}
// next action
// Open the connection
URL url = new URL("https://www.example.com/");
HttpsURLConnection urlc = (HttpsURLConnection)url.openConnection();
// Read the response
try (InputStream is = urlc.getInputStream()) {
while (is.read() != -1) { // read to EOF
// dump the response
// snipped
}
}
// next action
使用 HttpURLConnection 接口的代码,无论是连接过程,还是响应数据的读取过程,都依赖于网络环境,而不仅仅是计算机的环境。如果网络环境的响应时间是三秒,那么上面的代码就要阻塞三秒,无法执行下一步操作。
异步是怎么实现的?
异步的实现,依赖于底层的硬件和操作系统;如果操作系统不支持,异步也可以通过线程来模拟。
异步编程对性能的爆炸性的提升来自于硬件和操作系统对异步的支持。
零拷贝,进一步的性能提升
使用共享内存,减少内存拷贝,甚至是零拷贝,可以减少 CPU 消耗,也是减少实例数量和减少实例尺寸的一个办法。
减少中间环节的内存拷贝,把套接字的缓存数据,直接拷贝到应用程序操作的内存空间里。这样,就减少了内存的占用、分配、拷贝和废弃,提高了内存使用的效率。
需要注意的是,这种方式分配的内存,分配和废弃的效率一般比常规的 Java 内存分配差一些。所以,只建议用在数据量比较大,存活时间比较长的情况下,比如网络连接的 I/O。而且,一个连接最多只用一个读、一个写两块空间。这样,才能把它的效率充分发挥出来。
有哪些招惹麻烦的性能陷阱?
使用性能测试工具
JMH
怎么编写可持续发展的代码?
规模扩张能力,是依赖于具体的代码的。不是所有的代码都能适应规模的扩张。
两种规模扩张方式
规模垂直扩张(scale in/out)
指的是提高同一个处理单元处理更多负载的能力。比如,硬件上,增加服务器的硬盘、内存和 CPU;软件上,优化算法、程序和硬件的使用等。
规模垂直扩张是传统的提高负载的方式,方式方法都比较直观,效果也立竿见影。但是,规模垂直扩张成本很高,而且是非线性的。比如说,4 倍的 CPU 可能只提高 2 倍的负载,而 8 倍的 CPU 可能只提高 2.5 倍的负载。
另外,规模垂直扩张是有上限的,一台服务器的处理能力不是可以无限扩展的。还有,硬件的规模扩张,可能要更换机器,停止软件运行。这种规模扩张方式,不太适用于可用性要求高的产品。比如我们常用的微信,出现 5 分钟的停机都是天大的事故。
规模水平扩张(scale up/down)
指的是通过增加更多的处理单元,来处理更多的负载。我们常见的例子,就是增加服务器。分布式系统、负载均衡、集群系统这些技术,提供的就是规模水平扩张的能力。
优秀的规模水平扩张技术,可以使用很多廉价的机器,提供大规模的计算能力。一般情况下,规模水平扩张的成本要低于规模垂直扩张。而且,如果其中一个节点出了问题,只要其他节点还在正常工作,整个系统也可以照常运转。如果想要添加一个新节点,系统也不需要停顿。规模水平扩张技术的这些特点,非常适用于高可用性系统。
和规模垂直扩张相比,规模水平扩张的方式方法并不直观。支持规模水平扩张的代码要能够协调地运行在不同的机器上,也就是说要支持分布式计算。很多代码,不是天生就支持分布式计算的,而且修改起来也不容易。
我们常说的优化代码,一般指的是提高每一个处理单元的使用效率,也就是规模垂直扩张能力。其实,我们还要考虑代码是不是能够分布式运行,也就是规模水平扩张能力。
麻烦的状态数据
影响代码水平规模扩张的最重要的一个因素,就是用户的状态数据。比如,用户的登录状态,用户购物车里的商品信息,HTTP 连接里缓存的会话数据等。
现在的软件服务,大都是基于 HTTP 协议提供的 Web 服务。Web 服务本身就是一个无状态的协议。即使可以保持 HTTP 的连接,一般的服务框架也会考虑在连接不能保持情况下的会话管理,也就是保存用户状态。HTTP 协议层面的状态管理,也需要支持分布式计算。搭建支持规模水平扩张的 Web 服务时,要做好 Web 服务框架的选型。
对于规模水平扩张的需求,状态数据是一个很麻烦的存在。甚至,一些基础的,需要保存状态数据的网络协议,在早期的版本中也没有考虑规模水平扩张的问题。这就给规模水平扩张带来了一定的困难。
所以,采用规模水平扩张时,一定要小心代码的状态数据能不能同步。另外,由于软件依赖的基础设施问题,还要测试软件的运行平台是否能够很好地支持规模水平扩张。
无状态数据
如果一个服务是无状态的,规模水平扩张就会非常顺利。比如说,静态的网页,静态的图片,静态的商品描述,静态的 JavaScript 和 CSS 文件等等。由于不需要在服务端保留状态,这些数据资源就不需要在不同的节点间实时同步。无状态的数据,可以降低规模水平扩张的技术复杂性,在技术上有了更多的改进空间。
比如说,现代的浏览器,都可以缓存静态数据,比如说静态的 JavaScript 和 CSS 文件。如果用户访问的两个不同网站,使用了相同的脚本文件。浏览器只需要下载一次脚本文件,就可以在两个网站使用。这样,缓存的脚本文件就可以加速网页的加载,减轻服务器的压力。
分离无状态数据
由于无状态数据有这么多的优点,把无状态数据分离出来,单独提供服务就成了一个常用的解决方案。独立的无状态数据服务,既没有规模水平扩张的羁绊,还能充分利用客户端的缓存。另外,无状态数据和状态数据的分离,可以让状态数据的处理集中资源,也能提高状态数据的处理能力。
所以,如果你要设计一个具有规模扩张能力的软件架构,分离无状态数据和状态数据,既可以提高规模水平能力,也可以提高规模垂直扩张能力。
使用用户资源
对静态数据进行缓存,是现代浏览器提高服务性能的一个重要办法。除此之外,浏览器还可以缓存动态数据,比如 HTTP 的 cookie 以及 HTTPS 的安全连接的参数。
无状态服务,并不一定都没有服务状态。一个典型的做法是,服务端通过一定的数据保护机制,把服务状态保护起来,发送到客户端。然后,客户端缓存封印的服务状态。下次连接时,客户端把封印的服务状态原封不动地送回到服务端。然后,服务端解封客户端发送的封印服务状态,就获得了需要处理的状态数据。这样,既有了无状态服务的便利,解除了规模水平扩张的限制,又解决了服务需要状态的客观需求。
小结
具有规模扩张能力的软件的一些最佳实践:
把无状态数据分离出来,单独提供无状态服务;
把最基本的服务状态封装起来,利用客户端的缓存,实现无状态服务;
小心使用服务状态,编码时要考虑服务状态的规模水平扩张能力。
怎么尽量“不写”代码?
不要重新发明轮子
一般来说,当我们使用类似的代码或者类似的功能超过两次时,就应该考虑这样的代码是不是可以复用了。
推动轮子的改进
“不要重新发明轮子”这句话的另外一层意思,就是改进现有的轮子。如果发现轮子有问题,不要首先试图去重新发明一个相同的轮子,而是去改进它。
每一个可以复用的代码,特别是那些经过时间检验的接口,都踩过了很多坑,经过了多年的优化。如果我们试着重新编写一个相同的接口,一般意味着这些坑我们要重新考虑一遍,还不一定能够做得更好。
如果一个可以复用的代码出了问题,我们要第一时间叫喊起来。
不要重复多个轮子
该放手时就放手
我们每个人都会写很多烂代码,过去写过,未来可能还会再写。这些烂代码,如果运行得很好,没有出现明显的问题,我们就放手吧。
那么代码的修改都有哪些需要注意的地方呢?
代码规范方面的修改,可以大胆些。比如命名规范、代码整理,这些都动不了代码的逻辑,是安全的修改。
代码结构方面的修改,则要谨慎些,不要伤及代码的逻辑。比如把嵌套太多的循环拆分成多个方法,把几百行的代码,拆分成不同的方法,把相似的代码抽象成复用的方法,这些也是相对安全的修改。
代码逻辑方面的修改,要特别小心,除了有明显的问题,我们都尽量避免修改代码的逻辑。即使像上面例子中那样的微小的调用顺序的改变,都可能有意想不到的问题。
小结
要提高代码的复用比例,减少编码的绝对数量;
要复用外部的优质接口,并且推动它们的改进;
烂代码该放手时就放手,以免引起不必要的兼容问题。
编写经济代码的检查清单
为什么需要经济的代码?
1. 提升用户体验
一致性的性能体验,是软件产品赢得竞争的关键指标。复杂的,反应迟钝的软件,很难赢得用户的尊敬。
2. 降低研发成本
通过降低软件的复杂度,提高软件的复用,提前考虑性能问题,可以降低软件研发成本,缩短软件开发周期。
3. 降低运营成本
经济的代码可以降低软件的复杂度,提高计算资源的使用效率,降低运营成本。
4. 防范可用性攻击
复杂的代码和性能低下的代码,更容易成为黑客攻击的目标。如果一个服务器,需要耗费很多资源才能处理一个请求,那么数量很少的模拟请求攻击,就可以导致服务器瘫痪。
怎么编写经济的代码?
1. 避免过度设计
避免需求膨胀的方式主要有两个,第一个是识别核心需求,我们要从用户的角度出发,知道什么是核心需求,什么是衍生需求,什么是无效需求。就像建火车站一样,能够满足乘客出行需求的就是好的设计方案,其他方面再细心认真,起到的也只是锦上添花的效果。那么有一些功能现在好像用不上,但又必须做,该怎么办呢?这就用到了第二个方法:迭代演进,有所主次。
避免过度设计和避免需求膨胀一样,我们需要时刻问自己,什么是现在就必须做的?什么是必须做的?
避免需求膨胀和过度设计,是编写经济代码最需要注意的根基性问题。
2. 选择简单直观
设计一个简单直观的接口,首先,我们要从问题开始。把问题逐步拆解成一个个已经完全穷尽的小问题,这就是我讲到的“相互独立,完全穷尽”原则。在拆解的过程中,软件的接口与接口之间的关系会自然而然地产生。
此外我们还要注意,一个接口只应该做一件事情,如果这个情况太理想化,就要想办法减少接口的依赖关系。
一定记住这个经过实践检验的理念:选择最简单,最直观的解决方案。
3. 超越线程同步
只要满足这三个条件中的一个,我们就不需要线程同步了:使用单线程;不关心共享资源的变化;没有改变共享资源的行为。
4. 减少内存使用
减少内存的使用主要有两个方法,第一个方法是减少实例的数量,第二个办法是减小实例的尺寸。
在减小实例尺寸这一模块,我们要尽量减少独占的空间,尽量使用共享的实例。不可变(immutable)的资源和禁止修改(unmodifiable)的资源,是两类理想的共享资源。5. 规避性能陷阱
5. 规避性能陷阱
我们要学会规避一些常见的性能陷阱,比如字符串的操作、内存泄露、未正确关闭的资源和遗漏的 hashCode 等。
另外,我们还顺便使用了一个基准测试工具 JMH,并通过它分析了一些性能陷阱。我们要有意识地使用一些性能测试工具,通过测试数据来认识、积累性能问题的最佳实践。
6. 规模扩张能力
经济的代码需要跟得上产品的规模扩张。我们要理解规模垂直扩张和规模水平扩张这两种方式,特别是支持规模水平扩张。
状态数据是影响规模水平扩张能力的最重要的因素。分离无状态数据、提供无状态服务,减少有状态服务的规模,是提升规模水平扩张能力的最佳实践。
经济代码的检查清单
需求评审
需求是真实的客户需求吗?
要解决的问题真实存在吗?
需求具有普遍的意义吗?
这个需求到底有多重要?
需求能不能分解、简化?
需求的最小要求是什么?
这个需求能不能在下一个版本再实现?
设计评审
能使用现存的接口吗?
设计是不是简单、直观?
一个接口是不是只表示一件事情?
接口之间的依赖关系是不是明确?
接口的调用方式是不是方便、皮实?
接口的实现可以做到不可变吗?
接口是多线程安全的吗?
可以使用异步编程吗?
接口需不需要频繁地拷贝数据?
无状态数据和有状态数据需不需要分离?
有状态数据的处理是否支持规模水平扩张?
代码评审
有没有可以重用的代码?
新的代码是不是可以重用?
有没有使用不必要的实例?
原始数据类的使用是否恰当?
集合的操作是不是多线程安全?
集合是不是可以禁止修改?
实例的尺寸还有改进的空间吗?
需要使用延迟分配方案吗?
线程同步是不是必须的?
线程同步的阻塞时间可以更短吗?
多状态同步会不会引起死锁?
是不是可以避免频繁的对象创建、销毁?
是不是可以减少内存的分配、拷贝和释放频率?
静态的集合是否会造成内存泄漏?
长时间的缓存能不能及时清理?
系统的资源能不能安全地释放?
依赖哈希值的集合,储存的对象有没有实现 hashCode() 和 equals() 方法?
hashCode() 的实现,会不会产生撞车的哈希值?
代码的清理,有没有变更代码的逻辑?
小结
编写经济的代码,是我们在编程入门之后,需要积累的一项重要技能。正是因为要考虑性能、安全等因素,编写代码才成了一个具有挑战性的工作。
第一个习惯是,要尽早地考虑性能问题。如果你最早接触的是需求制定,就从需求开始考虑;如果你最早接触的是软件架构,就从架构层面开始考虑;如果你最早接触的是软件设计,就从软件设计开始考虑;如果你最早接触到的是代码,代码也有很多性能问题可以考虑。总之,要主动、尽早地考虑效率问题。
第二个习惯是,性能的实践经验需要日积月累。性能的实践经验和技术丰富繁杂,大到产品蓝图,小到每一行代码,中间还有软件的架构、选型、部署等诸多环节,都有很多的最佳实践可以积累。而且这些最佳实践,也会随着时间的推移发生变化,比如说会出现更好的技术方案,曾经的技术满足不了新需求等。所以,我们也要随时更新我们的储备,摒弃过时的经验。
代码安全
为什么安全的代码这么重要?
评审案例
安全编码原则:
清楚调用接口的行为;
跨界的数据不可信任。
真正的威胁
如果你认真学习了本专栏的“安全”模块,发现现存代码的安全问题,并且构造出可行的攻击方案,并不是一件特别困难的事情。如果以后你通过阅读代码,发现了一个漏洞,公布漏洞之前,请务必联系代码的维护者,做好漏洞的保密工作,并给他们预留充足的修复时间。
大部分有效的安全攻击,都是发生在漏洞公布之后,修复版本升级之前。这一段时间,是最危险的一段时间。
Equifax 的教训给我们带来三点启示:
不起眼的代码问题,也可以造成巨大的破坏;
安全修复版本,一定要第一时间更新;
安全漏洞的破坏性,我们很难预料,每个人都可能是安全漏洞的受害者。
编写安全的代码
一般来说,安全的代码是能够抵御安全漏洞威胁的代码。
传统上,我们说到信息安全的时候,最常接触的概念是防火墙、防病毒、防攻击。其实,大部分的安全事故(80%-90%)是由软件的代码漏洞引起的。没有安全保障的代码,是随时都可以坍塌的空中楼阁。
小结
不起眼的小问题,也会有巨大的安全缺陷,造成难以估量的损失;
编写安全的代码,是我们必须要掌握的基础技能;
安全问题,既是技术问题,也是管理问题。
如何评估代码的安全缺陷?
关注用户感受
从用户感受的角度出发,定义和计量软件缺陷,是其中一个比较好的、常用的软件缺陷评估体系。我个人比较倾向一种观点,软件缺陷的严重程度应该和用户的痛苦程度成正比。
从用户感受出发,衡量软件缺陷有两个最常用的指标:
缺陷影响的深度,软件缺陷带来的问题的严重性:软件缺陷导致的最严重的问题是什么?
缺陷影响的广度,软件缺陷带来问题的可能性:软件缺陷导致问题出现的频率是多大?
缺陷,需要从外向内看
在一个好的软件缺陷评估体系中,不是只有代码错误才会被关注,没有错误的代码,也可能存在需要变更或者改进的“缺陷”。这就是我们要强调的,从用户的感受出发,定义和计量软件缺陷。缺陷,需要从外向内看。
从用户视角出发的决策,可以让我们的时间使用得更有市场价值。
细化的优先级
如果我们把每个指标都划分高、中、低三种程度,就可以得到九种情况,定义五种优先等级。五种等级,是一个常用的优先级数目。太少了,显得粗糙;太多了,容易迷糊。
五种等级
第一优先级 (P1): 高严重性、高可能性;
第二优先级 (P2): 高严重性、中可能性;中严重性、高可能性;
第三优先级 (P3): 高严重性、低可能性;中严重性、中可能性;低严重性、高可能性;
第四优先级 (P4): 中严重性、低可能性;低严重性、中可能性;
第五优先级 (P5): 低严重性、低可能性。
优先级的灵活性
软件缺陷优先等级的定义是为了帮助我们更好地解用户的感受程度,以及安排时间和处理事情。
实际工作中,我们有时候需要调节软件缺陷的优先等级,比如说:
如果已经存在应对办法,优先等级可以下调;
如果软件缺陷引起广泛的公开关注,优先等级应该上调;
如果软件缺陷影响了重要的客户,优先等级应该上调;
如果软件缺陷影响了重要的商业运营,优先等级应该上调。
管理好自己的时间
1. 有什么事情是你必须要做的?
P1 的事情需要我们立即全力以赴、必须完成;P2 的事情需要我们协调资源,尽快完成;P3 的事情需要我们密切关注,尽量完成。
2. 哪些事情是只有你能做的?
只有你能够修复的 bug,你可以记到自己名下,负责修复这些缺陷。
3. 哪些事情是别人可以帮你做的?
适合别人修复的 bug,如果还没有记到别人名下,你可以琢磨下谁是最合适的人选,然后和他商量,看他有没有时间,愿不愿意负责这个缺陷。当然,别人也可能会问你愿不愿意修复另外一些缺陷。
安全漏洞,需要大范围协作
由于编写安全代码本身的挑战性,以及消除安全漏洞的复杂性,业界通常需要进行大范围的合作,以便准确、快速、周全地解决安全缺陷问题。
对于安全缺陷的严重性,有四个互相独立的测量维度(量度):
对私密性的影响(Confidentiality)
对完整性的影响(Integrity)
对可用性的影响(Availability)
对授权范围的影响(Authorization Scope)
对于安全缺陷的可能性,有四个互相独立的测量维度(量度):
安全攻击的路径(Attack Vector)
安全攻击的复杂度(Attack Complexity)
安全攻击需要的授权(Privileges Required)
安全攻击是否需要用户参与(User Interaction)
通用缺陷评分系统使用了标识符系统和计分系统,通过标识符来标识测量维度的指标,通过十分制的计分来衡量安全问题的严重程度。由于测量维度的增多以及评分计算的复杂性,我们通常使用工具来记录和查看安全缺陷问题的等级。
安全漏洞和软件缺陷优先级
安全漏洞细节不可泄漏
我们反复强调过,软件的安全漏洞常常会导致非常严重的后果,以及恶劣的影响。最糟糕的是,我们并不能总是预料到谁可以利用这些漏洞,以及由此带来的后果有多严重。所以处理安全漏洞的态度,一定要保守。
安全漏洞不能像普通的代码缺陷那样,可以公开细节、公开讨论。相反,安全漏洞的知情人员一定要控制在一个尽可能小的范围内,知道的人越少越好。如果安全漏洞和普通缺陷共享一个代码缺陷管理系统,一定要想办法做好安全漏洞信息的权限管理。
安全漏洞要优先修复
一旦发现一个安全漏洞,不管是来源于外部情报,还是内部发现,我们都要考虑最快地修复,不要等待,更不要拖沓。即使我们全力以赴地修复,在系统修复之前,安全攻击随时都有可能发生。我们能做的,就是尽最大努力,缩短这段时间。
所以,大部分的安全漏洞问题,都是属于 P1 级别的缺陷。有一小部分深度防御的安全问题,优先级可以是 P2。安全问题,不要使用 P3 及以下优先级。另外在所有的同级缺陷中,安全问题要优先考虑。
怎么处理敏感信息?
什么是敏感信息?
敏感信息的界定范围,也透露了游戏规则制定者对于敏感信息保护的力度和态度。
信息安全最基本的三个元素吗? 私密性、完整性以及可用性是信息安全的三要素。其中,私密性指的是数据未经授权,不得访问,解决的是“谁能看”的问题。在这个框架下,我们可以把敏感信息理解为,未经授权不得泄漏的信息。反过来说,未经授权不得泄漏的信息,都算是敏感信息。
最常见的一些敏感信息:
1. 个人敏感信息
个人信息:姓名、性别、年龄、身份证号码、电话号码。
健康信息:健康记录、服药记录、健康状况。
教育记录:教育经历、学习课程、考试分数。
消费记录:所购货物、购买记录、支付记录。
账户信息:信用卡号、社交账号、支付记录。
隐私信息:家庭成员、个人照片、个人行程。
2. 商业敏感信息
商业秘密:设计程序、制作工艺、战略规划、商业模式。
客户信息:客户基本信息、消费记录、订单信息、商业合作和合同。
雇员信息:雇员基本信息、工资报酬。
敏感信息的保护,需要恰当的管理,也需要适合的技术。
授权,敏感信息谁能看?
敏感信息指的是未经授权不得泄漏的信息。这个概念可以拆分为三部分:
敏感信息是受保护的信息,未经授权,不得访问;
敏感信息是一段有效信息,有信息处理需求,比如产生、传输、存储、读取、更改、废弃等;
敏感信息是有归属的信息,不同的人有不同的权限。经过授权,合适的人可以执行相应的操作。
是否需要授权是敏感信息和普通信息的最关键差异。不同的人有不同的权限,不同的操作需要不同的权限
第一件事情,就是定义权限
只有定义了权限,才能知道如何分配和管理权限。在 JDK 中,权限通过 java.security.Permission 接口来定义。Permission 接口定义权限的名称和操作。比如,java.io.FilePermission 把权限名定义为文件名,把操作定义为:
read
write
execute
delete
readlink
其中,权限的名称就是抽象了的敏感信息;权限的操作就是对信息的处理。如果把权限的名称和权限的操作结合起来,就可以定义特定的权限了。比如,下面的例子就定义了对于文件目录“/home/myhome”的读操作。
permission java.io.FilePermission "/home/myhome", "read";
permission java.io.FilePermission "/home/myhome", "read";
第二件事情,就是定义权限的主体。
也就是说,要明确权限可以分派给谁。在 JDK 中,权限的主体通过 java.security.Principal 接口来定义。Principal 接口可以用来表述个人,组织或者虚拟的账户。比如,com.sun.security.auth.UnixPrincipal 可以用来表述 Unix 的用户。
Principal com.sun.security.auth.UnixPrincipal "duke"
Principal com.sun.security.auth.UnixPrincipal "duke"
第三件事情,就是要定义权限的归属。
也就是说,有了权限的定义和权限主体的定义,我们就可以分配权限了。下面的例子,就是把对“/home/duke”的读写操作权限,赋予给了 Unix 用户 duke。
grant Principal com.sun.security.auth.UnixPrincipal "duke" {
permission java.io.FilePermission "/home/duke", "read, write";
};
grant Principal com.sun.security.auth.UnixPrincipal "duke" {
permission java.io.FilePermission "/home/duke", "read, write";
};
特殊信息,特殊处理
针对敏感信息的操作,需要特殊的处理和特殊的技术。
异常信息可能携带敏感数据,导致敏感数据通过异常信息泄露。
这和我们一般的面向对象的编程习惯是不符合的,这就要求我们特别小心。
在做异常信息净化处理时,可能还需要避免传递捕获异常的堆栈。
小结
要建立主动保护敏感信息的意识;
要识别系统的敏感信息,并且对敏感信息采取必要的、特殊的处理。
预案,代码的主动风险管理
小结
尽管我们无法预料未来可能出现的风险,但是软件的设计和实现依然要考虑抗风险的能力。
对于生死攸关的风险点,我们既要有长期的双引擎设计的意识,也要有权宜的应急预案;
编写安全代码的最佳实践清单
为什么需要安全的代码?
1. 代码质量是信息安全的基础
大部分的信息安全事故,是由软件代码的安全缺陷引起的。没有安全质量保证的代码,建立不起有效、可信的信息系统。信息系统的安全,主要依赖的不是信息安全技术专家,而是我们每一个编写代码的工程师。
2. 安全漏洞的破坏性难以预料
直到真实的安全问题发生之前,我们都难以预料软件的安全漏洞到底有多大的破坏性。一个小小的安全漏洞,如果被攻击者抓住了时机,就可以瞬间摧毁多年的苦心经营和良好声誉,把公司推到舆论的风口浪尖,甚至使公司面临毁灭性的风险和挑战。
3. 安全编码的规则可以学得到
由于安全攻击技术的快速发展,安全编码涉及到的细节纷繁复杂,安全问题的解决甚至需要大规模、大范围的协作。编写安全的代码不是一件轻而易举的事情。但是,安全编码的规则和经验,却是可以学习和积累的。使用必要的安全管理工具,开展代码评审和交流,也可以加速我们的学习和积累,减少编写代码的安全漏洞。
编写安全代码的基本原则
1. 清楚调用接口的行为
使用不恰当的接口,是代码安全风险的主要来源之一。我们一定要了解、掌握每一个调用接口的行为规范,然后在接口规范许可的范围内使用它们。不要去猜测接口的行为方式,没有明文规定的行为,都是不可靠、不可信的行为。
2. 跨界的数据不可信任
跨界的数据面临两大问题:一个问题是数据发送是否可信?另一个问题是数据传递过程是否可靠?这两个有任何一个问题不能解决,跨界的数据都可能被攻击者利用。因此使用跨界的数据之前,要进行校验。
3. 最小授权的原则
信息和资源,尤其是敏感数据,需经授权,方可使用。所授予的权力,能够让应用程序完成对应的任务就行,不要授予多余的权力。
4. 减小安全攻击面
减小、简化公开接口,缩小可以被攻击者利用的攻击界面。比如,设计更简单直观的公开接口,使用加密的数据传输通道,只对授权用户开放服务等等,这些措施,都可以减少安全攻击面。
5. 深度防御的原则
使用纵深防御体系防范安全威胁。要提供深度的防御能力,不能仅仅依靠边界的安全。编写代码,要采用谨慎保守的原则,要解决疑似可能出现的安全问题,要校验来源不确定的数据,要记录不规范的行为,要提供安全的应急预案。
安全代码的检查清单
安全管理
有没有安全更新的策略和落实计划?
有没有安全漏洞的保密共识和规范?
有没有安全缺陷的评估和管理办法?
软件是不是使用最新的安全修复版?
有没有定义、归类和保护敏感信息?
有没有部署多层次的安全防御体系?
安全防御能不能运转良好、及时反应?
不同的安全防御机制能不能独立运转?
系统管理、运营人员的授权是否恰当?
有没有风险管理的预案和长短期措施?
代码评审
数值运算会不会溢出?
有没有检查数值的合理范围?
类、接口的设计,能不能不使用可变量?
一个类支持的是深拷贝还是浅拷贝?
一个接口的实现,有没有拷贝可变的传入参数?
一个接口的实现,可变的返回值有没有竞态危害?
接口的使用有没有严格遵守接口规范?
哪些信息是敏感信息?
谁有权限获取相应的敏感信息?
有没有定义敏感信息的授权方案?
授予的权限还能不能更少?
特权代码能不能更短小、更简单?
异常信息里有没有敏感信息?
应用日志里有没有敏感信息?
对象序列化有没有排除敏感信息?
高度敏感信息的存储有没有特殊处理?
敏感信息的使用有没有及时清零?
一个类,有没有真实的可扩展需求,能不能使用 final 修饰符?
一个集合形式的变量,是不是可以使用不可修改的集合?
一个变量,能不能对象构造时就完成赋值,能不能使用 final 修饰符?
一个方法,子类有没有重写的必要性,能不能使用 final 修饰符?
一个方法的返回值,能不能使用不可修改的变量?
类、方法、变量能不能使用 private 修饰符?
类库有没有使用模块化技术?
模块设计能不能分割内部实现和外部接口?
有没有定义清楚内部数据、外部数据的边界?
外部数据,有没有尽早地完成校验?
有没有标示清楚外部数据的校验点?
能不能跟踪未校验外部数据的传送路径?
有没有遗漏的未校验外部数据?
公开接口的输入,有没有考虑数据的有效性?
公开接口的可变化输出,接口内部行为有没有影响?
有没有完成无法识别来源的数据的校验?
能不能不使用序列化技术?
序列化的使用场景,有没有足够的安全保障?
软件还存在什么样风险?
有没有记录潜在的风险问题?
有没有消除潜在风险的长期预案?
有没有消除潜在风险的短期措施?
潜在的风险问题如果出现,能不能快速地诊断、定位、修复?
0 条评论
下一页