编写安全的代码

2018-08-07

有些编程语言里面有个概念叫做"类型安全",本文说的的安全的代码,其目标都是一样的。每种类型都有相应的约束,代码就安全了。比如 int 的约束,它的取值范围肯定是有上下界的,比如集合的约束,它里面所有元素肯定是唯一的。再举个反例,void 指针这东西,就是一个类型不安全的,它指向的目标不知道会是啥。rust 或者许多函数语言里面,不会出现空指针,代码就比较"安全"。

假设我们使用的语言支持 dependent type,我们可以定义一个有序数组类型,它是个数组,里面的元素全部是有序的。编译器要帮我们检查类型,我们无法搞出一个不满足约束的实例出来。实现这种类型有个问题,即类型依赖于值了,值可能在运行时才能得到,于是无法完全在编译期做检查。如果要模拟运行一遍,把所有可能的值都取到,这就很蛋疼了。

类型即约束,强调所谓"类型安全"的语言里面,编译器帮我们把约束检查了。在弱鸡语言里面,这个约束就需要我们手动维护了。如何维护好约束,那就是这里要说的编写安全的代码。

先从最简单的例子开始看,我们要实现一个集合类型,编译器不支持 dependent type,怎么做呢?定义一个集合类,所有这个集合类的修改,都需要走这个类的 API,就可以保证约束满足。面向对象里面的"封装"的概念,其实解决的一个重要问题,是把副作用控制在一个范围内不扩散,从而维护这个类型的对象的约束。良好的封装,类型的修改操作都是由对象的方法来执行,于是代码就是安全的。

接下来就看实际的问题了,数据库的例子。假设数据库的 table 是一种类型,那么这种类型必须要满足一个约束:数据和索引一致性。插入了数据,一定要加索引;删除了数据,也需要删除索引。我们可以提供一个 table 的类型,然后提供修改操作的 API。所有对 table 的操作,都要透过这套 API 来进行,这样我们就要以满足数据索引一致性的约束了。比如 AddRecord 这个 API,如果操作成功了,数据和索引肯定是都写入的。

实现 AddRecord 时,会遇到这样一个问题:写数据成功了,写索引时失败了,返回错误。这时,整个操作是不能有副作用的,数据不能写进去。这是一个比较常见的 case,要么做回滚,要么是原子性 commit。那如果我们无法做回滚操作时,怎么办呢?我们把所有修改先缓存起来,只有全部成功,才去 apply。如果中途遇到任何错误,就丢弃修改了。

再类似的,一个 SQL 里面有多行 statement,需要全部成功时才应用修改,中间失败时,不留下副作用。为此,必须引入一个 dirty table 的概念。写操作需要写到 dirty table 里面,不能直接作用于 table。另外,前面的 statement 需要对后面可见,所以读操作要做一个融合,先读 dirty table,读不到才会穿透了去读 table。

dirty table 的引入,让代码更复杂了。编写安全的代码,要求仍然是,涉及到 dirty table 的修改也一定要通过某一层 API。

除了数据索引一致性外,实现 table 时还会遇到另一个问题:unique key 的唯一性。比如 create table 的时候,可能会有 primary key 或者 unique 的列,这也是约束。on duplicate key 的处理就是一个麻烦的事情。比如插入一条数据,它里面有一列可能跟其它列冲突了,需要 on duplicate key update,改成另一个值。可能修改之后,又冲突了。这里的约束必须保证不管怎么修改,数据都是唯一的。

当我们有了 table,有了 dirty table,on duplicate key 的各种处理等等,所有东西混到一起的时候,代码就很容易乱了。什么时候数据索引没维护好,什么时候改漏了,比如改了 table 没改 dirty table,这些都是 bug 的温床。编写安全的代码,一定要注意好分层,每层的 API 就是用来维护约束的。如果跨了 API 层修改,比如上层直接去改 table 的数据,非常容易出错。如果设计好 API 也是一门学问,下层暴露的东西太少,上层无法透过 API 完成功能,必然引起上层直接绕过 API 把自己的功能再实现一遍,重复代码又是 bug 的温床。下层提供的东西太多,那又跟没封装过没啥区别,最后还是保证不了约束。

这些仍然不够,还需要注意写测试。单元测试的意义,是保证在一定范围内约束不被打破,尤其是当涉及到重构的时候。

小结一下,如何编写安全的代码:

  • 语言的类型系统提供了最基本的保障
  • 更复杂的对象约束,要透过 API 的封装来保证
  • 注意良好的分层,切忌跨 API 访问下层
  • 单元测试