SQL

第24章 外交豁免权

人们讨厌改变。他们总是会说:“我们一向如此。”而我尝试改变这一切,这就是为什么我墙上的钟是逆时针的。 -- 葛丽丝•霍普少将

以前的一份工作曾给我上了重要的一课——关于使用软件工程最佳实践的重要性。那是在一次悲惨的事故之后,我开始接管一个重要的数据库程序。

我受聘于Hewlett-Packard公司,负责开发和维护一个基于UNIX的、使用C和HP

ALLBASE/ SQL编写的程序。面试我的经理和员工悲伤地告诉我,他们之前写这个程序的开发人员在一起交通事故中不幸去世了。他们部门中的其他人都不知道如何使用UNIX,也不知道这个程序的任何细节。

我接手之后,发现之前的这个开发人员从来没有写过任何文档或者测试程序,也没有使用任何源代码版本控制程序,甚至连代码注释都没有。所有的源代码都放在同一个目录下,包括线上运行中系统的代码和开发阶段的代码,以及那些不再使用的代码。

这个项目在技术方面背负重重债务——使用捷径而不是最佳实践的后果。技术债务1(technical debt)会不断给项目带来风险和额外的工作,直到你重构、测试并为代码编写文档。

1 Ward Cunningham在他关于OOPSLA 1992的报告中创造了这个单词。

我总共花了六个月的时间来重新整理这些代码,并补全相关文档,因为我不得不花很多时间和精力为它的用户和后续开发做技术支持。然而,就程序本身而言,其实真的非常普通。

很显然,我没有办法让我的前任来帮忙加快项目进度。这个项目的经验证明了让技术债务失控所造成的影响有多严重。

24.1 目标:采用最佳实践

专业程序员总是努力地在其项目中使用好的软件工程习惯,比如下面几种。

  • 将源代码使用版本控制工具管理起来,比如SVN或Git。
  • 为程序编写自动化单元测试脚本或者功能测试脚本。
  • 编写文档、规格说明以及代码注释来记录程序的需求和实现机制。

使用最佳实践来开发软件所花费的时间是绝对有益的,因为这么做能减少很多不必要或者重复的工作。大多数资深开发人员都非常清楚地知道,如果为了方便起见而抛开这些实践方法,项目失败就是必然的。

24.2 反模式:将SQL视为二等公民

即使在接受并应用最佳实践的开发人员之中,也有一种思想趋向认为数据库代码是排除在这些实践方法之外的。我将这个反模式命名为“外交豁免权”,因为它假设程序开发的规则并不需要应用到数据库开发中去。

为什么开发人员会做出这样的决定?下面有几个可能的原因。

  • 在有些公司里,软件工程师和数据库管理员的角色是分开的,DBA通常同时和好几组程序员一起工作,因此可以说,DBA对于任何一个项目组都不是全职的。DBA被训练成一个参观者,并且不用和软件工程师负同样的责任。
  • 使用在关系型数据库中的SQL语言和传统编程语言有很大不同。即使在程序中调用SQL语句的代码,它也看上去和原有代码在风格上格格不入。
  • 高级IDE工具在程序编程语言中非常流行,其中编辑、测试和版本控制的功能非常便捷易学。但是数据库开发的工具就不这么高级了,或者使用范围不广。开发人员可以很简单地使用最佳实践来指导编码,但将这些应用到SQL上相比而言就显得很笨拙了。开发人员趋向于,宁可找点别的事情来做,也不会如此尝试。
  • 在技术团队,对于数据库的通常认识和做法就是全由一个人来负责——DBA。因为DBA是唯一有权限访问数据库服务器的人,他通常被看做是一个活的资料库和版本控制系统。

数据库是一个程序的基础,和软件质量息息相关。你知道怎么写出高质量的代码,但可能会将程序构建在一个无法解决需求或者没人能看懂的数据库上。这样做的风险就是你在开发一个不得不最终放弃的程序。

24.3 如何识别反模式

你可能认为要证明没做什么很难,但并非每次都是如此。下面就是一些能说明问题的证据。

  • “我们正在适应一个新的开发流程:一个轻量级的版本。”这里的“轻量级”通常意味着项目组想要跳过一些开发流程中的环节。其中一些可能是合理的,但也可以理解为这是不遵循重要的最佳实践的借口。
  • “新的版本控制系统的训练不需要DBA员工的参加,因为他们根本用不到。”排除一些技术团队的成员参与培训(或者可能都没有访问权限)确保了他们不会使用到这些工具。
  • “我要怎么跟踪数据库中的表和列的使用情况?我们不清楚有些条目的作用,还有就是如果它们是孤立的,我们就考虑删除它们了。”你没有使用为数据库结构编写的项目文档,或者这份文档过期了,或者无法访问,或者根本就不存在。如果你不知道有些表和列存在的目的,也许它们对别人来说非常重要,你不能随意删除它们。
  • “有没有什么工具能比较两个数据库结构,并给出两者的不同之处,然后生成一个将其中一个结构修改成另一个的脚本?”如果你没有遵循数据库结构变更部署的流程,它们就可能变得不同步,要将两个数据库的结构改成一致是一项很复杂的工程。

24.4 合理使用反模式

对于要多次使用的代码,我都会编写文档和进行测试,然后将代码置于版本控制之中,同时还有一些别的好习惯来处理这些代码。但我也会写一些即兴的代码,比如对一个API函数写的一次性测试代码以提醒我如何使用这个API,或者回答一些用户提问的代码。

判断代码是否真是临时使用的好方法,就是在你使用完之后立刻删掉它们。如果你不能这么做,那这段代码可能就值得保留下来。同时,这也就意味着这段代码值得存进版本控制系统,并且至少需要写一些简明的注释,说明这段代码的作用以及使用方法。

24.5 解决方案:建立一个质量至上的文化

质量对于大多数程序员来说就是测试,但这仅仅是质量控制——整个流程中的一部分。软件工程的质量保证流程包含如下的三步。

  1. 清晰地定义项目需求,并且写成文档。
  2. 设计并实现一个解决方案来满足需求。
  3. 验证并测试解决方案符合需求。

你需要完成这三步操作才能确保正确的质量保证,虽然在某些软件开发方法中,不需要严格按照上面的这个顺序来执行。

通过遵循编写文档源码管理测试的最佳实践,你也可以在数据库开发中保证质量。

24.5.1 陈列A:编写文档

世上没有能够代替文档的代码。尽管一个资深的开发人员能够通过仔细的分析以及凭借自己丰富的经验来解读一段代码,但依旧是非常消耗体力的2。同时,代码也不能告诉你还有哪些问题没解决。

2 如果代码是任何人都能读的,那为什么还叫代码呢?

你应该将数据库的需求和实现也写成文档,就像对待程序代码那样。无论你是数据库的原始设计者,还是从别人那里接手的数据库,尽可能地使用下面的清单来检查文档的完整性。

实体关系图。整份数据库文档中最重要的部分,就是一张描述了数据库中所有表及表之间关系的ER图。本书有几章中使用了简单的ER图。更复杂一点的ER图包含了列、主键、索引和其他数据库对象。

不少绘图软件包含了ER图的标记。还有些工具能够通过SQL脚本或者运行中的数据库直接通过反向工程得到ER图。

当数据库过于复杂,表特别多时,要在一张ER图中将所有条目都表示出来会有点不切实际。在这种情况下,你应该将其拆分到多张图中。通常你可以使用“子组”这个符号,这样一来,ER图中即包含了足够的阅读信息,也不至于让人看得晕头转向。

表、列以及视图。你仍需要为数据库编写文档,因为ER图不是描述每张表、列及其他对象的目的和使用方法的正确形式。

对于表来说,需要描述一张表代表了哪种实体类型。比如Bugs、Products和Accounts很容易就能根据表名看出它们的意思,但对于BugStatus这种查询表,或者BugsProducts这种交叉表,又或者Comments这种依赖表来说,很难从表名上看出它们的作用。同时,还需要说明每张表预期存储多少行数据?你希望如何对这张表进行查询?表中有哪些索引?

每个列都有一个列名和对应的数据类型,但这些信息并不能说清楚这个列所代表的含义。比如说,哪些值对于这一列是有意义的(只有很少的情况下,取值范围是对应数据类型的所有可选值的全集)?这个列能否接受NULL,为什么?有没有针对这一列的唯一性约束?如果有,为什么要有?

视图存储了对于一张或多张表的常用查询结果。为什么需要创建这样一个视图?哪个产品或者用户需要使用这个视图?这个视图是不是试图提取表之间的复杂关系?这个视图会不会让没有权限的用户查询一张需要授权的表的部分数据?这个视图是否可以更新?

关系。引用完整性约束表示了表和表之间的依赖关系,但可能并没有完整表达出你所设计的约束模型。比如,Bugs.reported_by是非空的,但Bugs.assigned_to是可空的。这是不是意味着一个Bug可以在指派给任何人之前就被修复?如果不是,一个Bug的指派应该要遵循什么样的规则?

在某些情况下,可能存在一些隐式关系,但没有任何相关的约束。如果没有文档,别人很难知道存在这些关系。

触发器。数据验证、数据转换以及记录数据库变更,都是使用触发器的场景。你在触发器中实现了怎样的业务逻辑?这些都是需要记录在文档中的。

存储过程。要像API那样为存储过程编写文档。这个存储过程要解决什么问题?这个存储过程是否会改变数据?输入输出参数的类型和意义是什么?是否使用这个存储过程来替换一种查询请求,就能够解决某个性能瓶颈?使用这个存储过程是否会让没有权限的用户访问到需要权限的表?

SQL安全。为应用程序指定了哪个数据库用户账号?每个用户都有哪些访问权限?你提供了哪些SQL任务,哪些用户可以使用这些任务?是否有些用户是为了特殊的任务所定义的,比如备份或报表?使用哪种系统安全级别规定,比如客户端必须通过SSL和RDBMS服务器端链接吗?使用何种机制检测和屏蔽非法的认证请求,比如暴力破解密码?有没有针对SQL注入做过一次完整的代码审查?

数据库基础设施:这些信息对运维和DBA的同事来说是最重要的,但开发人员最好也对此有些了解。使用哪个厂商的哪个版本的RDBMS服务?数据库服务器的主机名是什么?是否使用了多台数据库服务器、冗余备份、服务器集群、访问代理等?网络结构是什么样的?数据库服务器使用的是什么端口?客户端程序连接的时候有什么特殊的选项?数据库的用户密码是什么?数据库的备份方案是什么?

ORM。你的项目可能将一部分应由数据库处理的逻辑写在了程序代码中,作为基于ORM类的层的一部分。哪些业务逻辑是以这样的方式实现的?数据验证、数据转换、日志、缓存还是配置?

开发人员不喜欢维护工程文档。它们既难以编写,也很难保持实时更新,而且当你写了很多却少有人读的时候,会感到很失落。但即使是经验丰富或者极限编程的程序员也知道,他们可以不为程序的其他部分写文档,但一定要编写数据库部分的文档3。

3 比如,Jeff Atwood和Joel Spolsky认为,除了数据库部分的文档,其他代码的文档基本没有意义。可以在StackOverflow上看到他们的讨论http://blog.stackoverflow.com/2010/01/podcast-80/

结构演化工具

版本管理工具管理了代码,但并没有管理数据库。Ruby on Rails提供了一种技术叫做“迁移”,用来将版本控制应用到数据库实例的升级管理上。我们可以简单地来看一个升级的例子。

可以基于Rails的抽象类来编写一个脚本更新数据库,需写一个能一步完成数据库升级的函数,同时也需要写一个降级函数,能够反转升级函数的操作。

class
AddHoursToBugs < ActiveRecord::Migration
def self.up        add_column:bugs, :hours, :decimal      end       def self.down        remove_column :bugs, :hours      end end

Rails“迁移”工具会自动地创建一张表来记录当前数据库实例的多个版本。Rails 2.1引入的修改让该系统更加的灵活,其随后的版本可能还会改变“迁移”的工作方式。

不断为数据库的每个结构变化创建新的迁移脚本会逐渐积累一系列的脚本,每一个脚本都能对数据库进行一步升级或者降级操作。如果你需要将数据库版本改为5,只需要在运行“迁移”工具的时候指定参数。

$rake db:migrate VERSION=5

可以参考“Rails敏捷网站开发,第三版[RTH08]”或者访问 http://guides.rubyonrails.org/migrations.html 来对“迁移”工具进行更深入的学习。

大多数其他的网站开发框架,包括PHP的Doctrine、Python的Django以及微软的ASP.NET,都支持类似于Rails的“迁移”这样的特性,可能集成在框架中,或者以相关项目的形式提供。

“迁移”使很多同步当前数据库实例和源码版本控制服务中指定版本结构的脏活能自动完成。但它们还不是完美的,只能处理一些简单类型的结构变更,而且从根本上说,它们在原有版本控制服务之外又建立了一个版本系统。

24.5.2 寻找证据:源代码版本控制

如果你的数据库服务器彻底挂掉了,要怎么重建一个数据库?跟踪数据库的一个复杂的升级过程的最有效途径是什么?要怎么回滚数据库的变更?

我们知道怎么使用版本控制系统来管理程序代码,解决软件开发中相似的一些问题。一个使用版本控制的项目应该包含在现有程序被损坏时用以重建、部署该项目所需要的一切。版本控制也用来记录历史变更和增量备份,使得你能够回滚任意的修改。

对于数据库的代码,也能使用版本控制,从而获得和程序开发相似的效果。

Diplomatic_immunity/DatabaseTest.php
<?php require_once "PHPUnit/Framework/TestCase.php";
class DatabaseTest extends PHPUnit_Framework_TestCase {
     protected $pdo;
     public function setUp() {
         $this->pdo = new PDO("mysql:dbname=bugs", "testuser", "xxxxxx");
     }
     public function testTableFooExists() {
         $stmt = $this->pdo->query("SELECT COUNT(*) FROM Bugs");
         $err = $this->pdo->errorInfo();
         $this->assertType("object", $stmt, $err[2]);
         $this->assertEquals("PDOStatement", get_class($stmt));
     }
     public function testTableFooColumnBugIdExists() {
         $stmt = $this->pdo->query("SELECT COUNT(bug_id) FROM Bugs");
         $err = $this->pdo->errorInfo();
         $this->assertType("object", $stmt, $err[2]);
         $this->assertEquals("PDOStatement", get_class($stmt));
     }
     static public function main() {
         $suite = new PHPUnit_Framework_TestSuite(__CLASS__);
         $result = PHPUnit_TextUI_TestRunner::run($suite);
     }
  }
  DatabaseTest::main();

你需要将和数据库开发相关的文件都提交到版本控制服务中去,包括如下的这些文件。

  • 数据定义脚本。所有数据库都提供CREATE TABLE或别的定义数据库对象来执行SQL脚本。
  • 触发器和存储过程。很多项目以数据库函数的形式为程序代码提供支持。你的程序可能离开这些函数根本无法工作,因此它们也算做你的项目代码的一部分。
  • 初始数据。检查表可能包含一些数据,用以在任何用户输入新数据之前初始化数据库。你应该将这些初始数据保存起来,以备需要从项目源码重新构建数据库时使用。初始数据也叫做种子数据
  • ER图及文档。这些文件不是代码,但它们和代码、数据库需求、实现机制以及和程序的整合等联系密切。由于项目的升级依赖于数据库和程序的同时升级,你需要将这些文件也保持同步更新。需要确保这些文件切实描述了当前版本的设计。
  • DBA脚本。大多数项目都有一系列的数据处理任务,这些任务都是在程序之外处理的,包括导入/导出数据、同步数据、生成报表、备份、验证数据、测试等。有些可能是用SQL脚本编写的,而非用一般的编程语言。

确保数据库代码文件是和使用当前数据库的程序代码相关联的。使用版本控制的部分好处就是,当从服务器端签出特定的版本、指定的日期或者不同的里程碑时,所得到的文件应该都能正常工作。你最好将数据库代码和程序代码放在同一个版本库中。

24.5.3 举证:测试

质量保证的最后一步就是质量控制——验证程序做了它应该做的。大多数专业的开发人员都很熟悉编写自动化测试脚本来验证程序代码的行为。测试的一个重要原则就是隔离,即同一时间点只测试系统的一个部分,如果存在缺陷,你可以尽可能缩小出错的范围。

我们在数据库测试中也借鉴这种隔离模块测试的方法,需要独立于程序代码对数据库结构及行为进行验证。

下面的例子展示了使用PHP单元测试框架写的一个单元测试4脚本:

4 参考 http://www.phpunit.de/。确切地说,测试数据库的功能并不是严格意义上的单元测试,但还是可以使用这个工具来组织和使测试自动化。

你可以在验证数据库的时候参考如下的清单。

  • 表、列和视图。你应该测试一下所希望存在的表、视图是否真的在数据库中存在。每次你使用新的表、视图或者列扩充数据库时,增加一个确认这些对象存在的新测试任务。你还可以使用负面测试,来验证一张表或者列在当前版本中是否真的删除了。
  • 约束。这是另一个使用负面测试的地方。可以执行INSERT、UPDATE或者DELETE语句来验证约束是否有效,是否正确返回错误。比如,试着违反非空、唯一或者外键约束等。如果所执行的语句没有返回错误,那就意味着对应的约束有问题。你可以通过这样的测试尽早地找到更多的Bug。
  • 触发器。触发器也能进行强制约束。触发器能处理级联效果、转换数据、记录变更等。你应该通过执行一个语句来引发这个触发器测试这些场景,然后再进行一次查询来验证触发器是否按照你所期望的方式执行了。
  • 存储过程。存储过程的测试最接近传统程序代码的单元测试。存储过程有输入参数,如果输入的参数无效则可能会抛出错误。存储过程内部的逻辑可能会有多个分支。存储过程可能会返回单个值,也可能返回一个查询结果集,这取决于输入的参数以及数据库中数据的状态。同时,存储过程也可能会受到数据库正在更新的副作用影响。你可以测试所有这些存储过程的特性。
  • 初始数据。即使理论上完全空白的数据库也会需要一些初始数据,比如检查表中的记录。你可以使用一些查询语句进行查询来验证初始数据是否存在。
  • 查询。程序代码依赖SQL查询。你可以在测试环境中执行一些查询操作来验证语法和结果。确认结果集中包含了所期望的列和数据类型,就像测试表和视图一样。
  • ORM。就像触发器一样,ORM类也包含逻辑、验证、转换或者监控。你应该像对待其他的程序代码那样对基于ORM的数据库抽象代码进行测试。确认这些类在输入不同的参数时的行为如期望的那样,并且它们会拒绝接受非法参数。

如果这些测试中的任何一个没有通过,可能是因为程序在使用错误的数据库实例。总是要反复确认你所连接的数据库是否正确——最常见的错误其实就是连错数据库。如果必要,可以修改配置然后重新执行一遍测试。如果你确信链接是正确的,但需要修改数据库,那可以执行一个迁移脚本来同步这个数据库实例到程序所期望的版本。

24.5.4 例证:同时处理多个分支

在开发程序时,你可能需要同时操作多个版本,甚至可能在同一天就操作多个不同的版本。比如,你可能在当前部署的程序分支中修复了一个紧急Bug,然后很快又回到主分支的长期开发中。

但程序所使用的数据库并没有版本控制。要在一眨眼的功夫内重新部署一个数据库是不现实的,即使你所使用的数据库非常敏捷和易于使用。

理想情况下,可以为每个开发、测试、分阶段进行或者部署的程序版本分支创建独立的数据库实例。同时,每个项目组内的开发人员也需要一个独立的数据库实例,从而在不影响整个团队中其他人开发进度的情况下,他们能够完成开发任务。

在程序配置项中增加选择数据库连接的字段,如此一来,无论你在哪个版本下工作,都可以在不修改代码的情况下指定一个数据库链接。

如今,每个RDBMS品牌的商业版和开源版本,都提供开发及测试的免费解决方案。使用诸如VMware Workstation、Xen和VirtualBox之类的平台虚拟化技术,每个程序员都可以以很小的代价运行一个服务器端基础设施的克隆版本,没有理由让程序员在一个和生产环境不一致的环境中进行开发和测试了。

数据库和程序都需要采用软件开发的最佳实践,包括文档、测试和版本控制。

下一节:总会有解释,人类的每一个问题都会有一个众所周知的解决方案——无论是简洁的、可行的,还是错的。 -- H. L. 门肯