SQL

第25章 魔豆

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

“为什么加这么个小功能要花这么长时间?”经理指派你的团队扩展Bug跟踪系统,增加一个展示每个Bug有多少评论数量的功能。你的团队已经为此工作四周了。

在会议室里,你的组员们都羞于回答老板的问题。作为项目经理,你不得不回答:“我们在最开始的时候犯了几个错误,这个需求最初看起来实现很方便,后来我们意识到在程序中还有其他几个界面也需要展示评论数量。”

“设计这些界面要花四个礼拜?”经理问。

“这倒不是,这些界面只是一些HTML,由于我们用的框架是将代码和展示分离的,这些界面的开发很简单。”你继续说,“但每次我们要往界面上增加一点新的条目,必须复制一份同样的代码到这个界面的后端代码中,用以获取数据。这意味着每个后端的类都需要进行一系列新的测试。”

“我们难道没有使用测试框架?”经理问,“增加一些新的测试用例要花多久?”

“编写测试不像编写代码那么容易,”另一个工程师犹豫地说,“我们还要为编写脚本构建测试数据。接着要在每次测试之前重新将数据载入测试数据库中。我们还要测试前端,每个新增的特性都要针对老功能的每一种组合进行测试。”

经理眼睛瞪得大大的,但你的同事继续说着:“我们现在对于前端已经有600个测试用例了,每一个测试都会创建一个新的浏览器模拟器。时间都花在执行这些测试上了。”他耸耸肩,“我们对此无能为力。”

经理深吸了一口气,说:“好吧……你说的我并不全都理解,我只是想要知道为什么加这么一个简单的功能会变得如此复杂。你们所用的面向对象框架难道不支持快速、简单地添加新功能吗?”

好问题!

25.1 目标:简化MVC的模型

Web程序框架使得往程序中添加新功能变得更简单、快速。在软件项目中,最大的成本就是开发时间。因此,我们所减少的任何一点开发时间,都能使得软件开发的成本降低。

Robert L. Glass认为:“80%的软件工作是智力活动。相当大的比例是创造性的活动。很少是文书性的工作。”Facts and Fallacies of Software Engineering[Gla92]。

有一种方法有助于我们在软件开发过程中思考,那就是采用设计模式的术语和习俗。当我们说单例模式外观模式或者工厂模式的时候,项目组内的其他开发人员都知道我们在说些什么。这样做节省了很多时间。

在任何项目中,大多数的代码都是重复的——几乎都长得一样。框架通过提供可重用的组件和代码生成工具帮助我们提升编写代码的速度,做到少写代码也能开发出可以工作的软件。

当使用模型视图控制器(MVC)架构的时候,我们就是同时在使用设计模式和软件框架。这是一个拆分程序逻辑的技术,就如图25-1所展示的那样。

  • 控制器接收用户的输入,定义一些程序需要完成的响应逻辑,再委托合适的模型执行操作,然后将结果返回给视图。
  • 模型处理所有其他的事情,它们是程序的核心,包括输入验证、业务逻辑和数据库交互。
  • 视图处理在用户界面展示信息。

图25-1 模型—视图—控制器(MVC)

理解控制器和视图的行为很容易,但是模型的目的就比较模糊。在软件开发人员的社区中常讨论的问题便是,为了减少软件设计的复杂度,如何简化和抽象模型。但是通常这个目标会导致他们过度简化模型,以至于把它仅仅视作一个数据访问对象。

25.2 反模式:模型仅仅是活动记录

在简单的程序中,你不需要在模型中定义很多逻辑。相对而言,模型更像是数据库中的某个表的映射对象,也是一种对象关系映射(ORM)。你需要这个对象做的所有事情是知道如何往表中插入新行、读取一行,以及更新和删除自己——基本的CRUD2操作。

2 Create, Read, Update Delete,增删改查操作。——译者注

Martin Fowler将这种映射关系概括为一种设计模式,叫做活动记录3。活动记录模式是一种数据访问模式。其方法是将一个类与数据库中的一张表或者一个视图相关联。你可以调用这个类的find()方法返回一个关联到这张表或者视图的某一行的类对象实例。你还可以使用这个类的构造器来创建一个新行。调用save()方法执行插入或者更新现有的行。

3 参考Patterns of Enterprise Application Architecture中“Active Record”一章。

Magic-Beans/anti/doctrine.php
<?php $bugsTable = Doctrine_Core::getTable('Bugs');
 $bugsTable->find(1234);
 $bug = new Bugs();
 $bug->summary = "Crashes when I save";
 $bug->save();

自2004年开始,Ruby on Rails使活动记录模式在Web程序开发框架中流行起来,现在大多数的Web程序框架使用这种模式作为数据访问对象(DAO)。使用活动记录模式并没有什么错,这是一个很好的模式,提供了简单访问表中特定行的接口。我们所要说明的反模式是在MVC程序中,所有模型类都继承自同一个活动记录基类这一习惯做法。这是一个“金锤子”反模式的例子:如果你的唯一工具是一把锤子,那么就会将每个东西都看做钉子。

所有能简化软件设计的做法都很吸引人。如果我们愿意牺牲一些灵活性,就能让工作更简单,而且,如果灵活性从一开始就不重要,那就更好了。

但这是个童话故事,就像《杰克与魔豆》一样。杰克相信当他睡着的时候,他的魔豆会长成一个巨大的豆茎。在杰克的故事中的确就是这么发生的,但我们不可能都这么幸运。让我们来看看使用魔豆反模式的后果。

抽象泄露

Joel Spolsky在2002年提出了“抽象泄露”这个词。*抽象简化了一些技术的内部工作原理并且让其更加方便调用。但当由于需要更高效的生产效率而不得不了解内部细节的时候,就称之为抽象泄露。

在MVC架构中,把活动记录模式作为模型层来使用就是抽象泄露的例子。在非常简单的项目中,活动记录模式就像魔法一样神奇。但如果你想要在所有的数据库访问上都使用这个模式,你就会发现很多诸如JOIN或者GROUP BY的这些在SQL中很简单的操作,在活动记录模式中变得很可怕。

有些框架尝试扩展活动记录模式来支持更大规模的SQL语句。但是框架暴露的使用SQL内部接口越多,你就会越觉得不如直接使用SQL来得方便。

抽象模式因此不再能隐藏它的秘密,就像绿野仙踪里的托托发现精灵不过是藏在窗帘后的普通人一样。

参考《抽象泄露法则》[Spo02]

25.2.1 活动记录模式连接程序模型和数据库结构

活动记录模式是一种简单的模式,因为一个普通的活动记录类只能表示数据库中一张表或者一个视图。活动记录对象中的每一个字段对应于相关表中的一列。如果你有16张表,就要定义16个模型子类。

这意味着,如果需要重构数据库来表示一个新数据结构,你的模型类就需要跟着改变,同时,任何使用这些模型类的代码也都需要改变。还有,如果增加了一个控制器来处理新的程序界面,你也需要重复这些查询模型的代码。

25.2.2 活动记录模式暴露了CRUD系列函数

接下来你需要面临的问题就是,其他使用模型类的程序员会无视你设定的使用方法,他们会直接使用CRUD函数来更新数据。

比如,你可能在Bug模型中有一个叫做assignUser()的方法解决每次更新Bug之后发送一封邮件给相关工程师的需求。

Magic-Beans/anti/crud.php
<?php
 class CustomBugs extends BaseBugs {
   public function assignUser(Accounts $a) {
     $this->assigned_to = $a->account_id;
     $this->save();
     mail($a->email, "Assigned bug", "You are now responsible for bug #{$this->bug_id}.");
   }
 }

然而,另一个编程人员忽略了你的方法,他手动地分配了这个Bug却没有发送邮件。

Magic-Beans/anti/crud.php
$bugsTable = Doctrine_Core::getTable('Bugs');
$bugsTable->find(1234);
$bug->assigned_to = $user->account_id; $bug->save();

你的需求是无论何时对一个任务进行变更操作,都要有一封邮件提醒。而这种模型设计允许略过这一步。将活动记录类的CRUD方法暴露给驱动模型类是否真的有意义呢?要如何阻止那些使用这些方法的程序员的不合理调用?如何在编写项目文档和具体代码实现的时候,将这些活动记录的接口从你的模型类中排除呢?

25.2.3 活动记录模式支持弱域模型

另一个非常值得关注的问题,就是一个活动记录模型通常除了CRUD方法之外,没有别的行为。很多程序员扩展了这个基本的活动记录类,却不为这个模型所需处理的业务逻辑添加新的方法。

将模型简单地当成数据访问对象,鼓励开发人员将业务逻辑放在模型类之外去实现,通常这些逻辑就会被拆分到多个控制类中,从而减少了模型本身的内聚行为。Martin Fowler在他的博客里称这种反模式为弱域模型4。比如说,你可能有一系列独立的活动记录类,它们分别关联到Bugs、Accounts和Products表,但在很多地方你都是同时需要这三张表中的数据的。

让我们看一段Bug跟踪程序中的代码,其简单地实现了Bug指派、数据输入、Bug显示和搜索的工作。它使用的PHP框架叫做Doctrine,这个框架提供简单的活动记录接口,并且使用Zend框架来实现MVC架构。

Magic-Beans/anti/anemic.php
<php?
 class AdminController extends Zend_Controller_Action {
	public function assignAction() {
		$bugsTable = Doctrine_Core::getTable("Bugs");
		$bug = $bugsTable->find($_POST["bug_id"]);
		$bug->Products[] = $_POST["product_id"];
		$bug->assigned_to = $_POST["user_assigned_to"];
		$bug->save();
	}
}
class BugController extends Zend_Controller_Action {
	public function enterAction() {
		$bug = new Bugs();
		$bug->summary = $_POST["summary"];
		$bug->description = $_POST["summary"];
		$bug->status = "NEW";
		$accountsTable = Doctrine_Core::getTable("Accounts");
		$auth = Zend_Auth::getInstance();
		if ($auth && $auth->hasIdentity()) {
			$bug->reported_by = $auth->getIdentity();
		}
		$bug->save();
	}
	public function displayAction() {
		$bugsTable = Doctrine_Core::getTable("Bugs");
		$this->view->bug = $bugsTable->find($_GET["bug_id"]);
		$accountsTable = Doctrine_Core::getTable("Accounts");
		$this->view->reportedBy = $accountsTable->find($bug->reported_by);
		$this->view->assignedTo = $accountsTable->find($bug->assigned_to);
		$this->view->verifiedBy = $accountsTable->find($bug->verified_by);
		$productsTable = Doctrine_Core::getTable("Products");
		$this->view->products = $bug->Products;
	}
}
class SearchController extends Zend_Controller_Action {
	public function bugsAction() {
		$q = Doctrine_Query::create() ->from("Bugs b") ->join("b.Products p") ->where("b.status = ?", $_GET["status"]) ->andWhere("MATCH(b.summary, b.description) AGAINST (?)", $_GET["search"]);
		$this->view->searchResults = $q->fetchArray();
	}
}

使用活动记录的控制器代码逐渐变成组织程序逻辑的技术手段。如果数据库的结构或者程序的期望行为改变了,你就需要更新代码中的很多地方。同时,如果增加了一个控制器,即使这个控制器对模型对象的查询逻辑和其他的控制器中的实现很类似,你也要编写新的代码来处理。

类交互图(图25-2)很混乱而且难以阅读,当增加了新的控制器和DAO类时,它只会变得更糟糕。这是一个很强烈的信号——这些同时使用不同模型的代码在多个控制器复制来复制去,你需要使用另一种方法来简化和压缩程序。

图25-2 使用魔豆造成混乱

25.2.4 魔豆难以进行单元测试

使用了魔豆反模式,你会发现测试MVC的每一层都变得更加困难。

  • 测试模型:由于将模型类等同于活动记录类,你无法将数据访问与模型行为分开测试。要测试这些模型,你就必须连上真实的数据库执行查询。很多人使用数据库固定工具。数据库固定工具将数据载入到测试数据库中,来确保每个测试使用的是同样的标准数据。这样复杂的步骤使得测试模型的过程变得缓慢且容易出错,就和请求真实数据库进行测试一样麻烦。
  • 测试视图:测试视图包括将视图呈现成HTML内容并解析其结果,来验证由模型提供的动态HTML条目确实出现在了输出中。即使你所使用的框架简化了测试脚本中的判定过程,框架还是要执行复杂且耗时的代码来呈现及解析HTML中指定的条目。
  • 测试控制器:你将发现测试控制器也变得很复杂,因为模型就是导致同样的代码在多个控制器中重复出现的数据访问对象,所有的代码都需要测试。

要测试控制器,你需要模拟一个HTTP请求。Web程序的输出是一个HTTP响应包的包头和包体。要验证这个测试的结果,就不得不拆分由控制器返回的HTTP响应的内容。这需要很多的代码来测试业务逻辑,导致测试进行得很慢。

如果你能够将业务逻辑和数据库访问以及展示层拆分开,将有助于达成MVC的目标,同时也会让测试变得更简单。

25.3 如何识别反模式

下面的线索可能意味着你用了魔豆反模式。

  • “我要怎么传递一个自定义的SQL查询给模型?”这个问题表示了你正在使用一个数据库访问类做为模型类。你不应该将SQL查询语句传递给模型对象——模型类应该囊括了所有它需要的查询。
  • “我应该复制复杂的模型查询到我所有的控制器内,还是在一个抽象控制器内实现这些代码?”这两种方案都无法给你带来简单性或者稳定性。你应该将复杂的查询代码写在模型类里面,作为模型类的接口暴露出来。这样,就遵循了“不要重复自己(DRY5)”的原则,并且模型使用起来更简单。

    5 DRY出现在The Pragmatic Programmer[HT00],由Andy Hunt和Dave Thomas所著。

  • “我不得不写更多的数据库固定工具来对模型进行单元测试。”如果你正在使用数据库固定工具,就是说正在测试数据库访问而不是业务逻辑。你应该将对模型的单元测试和数据库分离开。

25.4 合理使用反模式

本质上说,活动记录模式并没有什么错,对于简单的CRUD操作来说,这是一个很方便的模式。在大多数程序中,总有一些情况只需要简单的数据访问对象,来提供一些简单的对于表的行操作。此时,你可将模型和DAO视作同一个东西。

另一个使用活动记录的好地方是原型开发。当快速编码比写代码的可测试性和可维护性还重要的时候,捷径是关键。在早期的工作中展示一个原型来获得积极的反馈,通常是提炼项目的一个好方法。任何你能加速原型开发的方法在这些情况下都是很有帮助的,使用一个简单的程序框架也很有用。

此外,你必须要确认你留有一定的时间来重构代码,从而偿还在原型开发阶段的技术债务。

25.5 解决方案:模型包含活动记录

控制器处理程序输入,视图处理程序输出,两者的任务都相对简单且定义清楚。框架是帮助你将这些东西快速融合在一起的最好方法。但是对于框架来说,很难给模型提供一个“通吃”的解决方案,因为模型包含了面向对象设计中的其余部分。

这是你确实需要仔细考虑的部分,关于你的程序中的对象是什么,以及这些对象包含什么数据和行为。还记得Robert L. Glass评估软件开发中的主要工作是智力活动和创造力吗?

25.5.1 领会模型的意义

所幸的是,在面向对象设计领域,有很多格言警句能指导你。比如,Graig Larman的《UML和模式应用》(Applying UML and Patterns[Lar04])一书,描述了很多指导方针,被称为通用职责软件分配模式(GRASP)。其中的一些指导原则和分离模型和数据访问对象尤其相关。

信息专家

一个对象应该有所有需要的数据来满足它所负责的操作。由于程序中的一些操作可能会包含多个表(或者没有表),并且活动记录只适用于一次操作一张表,我们需要另一个类来聚合多个数据库访问对象,并利用这些对象进行组合操作。

模型和活动记录之类的DAO之间的关系应该是HAS-A(聚合)而不是IS-A(继承)。大多数依赖于活动记录模式的框架都使用IS-A的解决方案。如果你的模型使用DAO而不是直接从DAO类继承,那么你就能将这个模型设计成包含所有的数据和代码来表达它代表的领域的形式——即使需要用多张表来表示。

创造者

一个模型如何维护数据库应该是它的内部实现细节,一个聚集了一系列DAO的领域模型应该负责创建这些对象。

程序的控制器和视图应该使用领域模型的接口,而不用关心这个模型使用哪种数据库交互方式对数据进行存取。这使得日后修改数据库查询变得更加容易,只需要修改一个地方。

低耦合

解耦程序中的逻辑区块是非常重要的,这么做能够让你更加灵活地改变某一个类的实现,同时又不影响这个类的调用方式。程序的需求是无法简化的,一些复杂的逻辑无法在程序中舍弃,但你可以对在何处实现这些复杂的逻辑做出正确的选择。

高内聚

领域模型的接口应该反映出它所期望的调用方式,而不是数据库物理结构或者CRUD操作。通常的活动记录模式的接口,如find()、first()、insert()或者save(),并没有给出多少对软件需求实现方面的信息。而类似于assignUser()的方法则更加具有说明性,并且控制器代码也能变得更容易理解。

将模型类和它所使用的DAO解耦后,你甚至可以为同一个DAO设计多个模型类。这比将所有关于给定表的相关工作都合并到单一展开的活动记录类中要好很多。

25.5.2 将领域模型应用到实际工作中

在《领域驱动设计:软件核心复杂性应对之道》(Domain-Driven Design:Tackling Complexity in the Heart of Software[Eva 03])一书中,Eric Evans介绍了一个更好地解决方案:领域模型。

在最初的MVC概念中(而不是武断的软件设计),一个模型所表示的是程序中某一领域的面向对象的表现手段,也就是说,程序中的业务逻辑和所需要的数据。模型是实现程序业务逻辑的地方,将数据存储在数据库中是模型的内部实现细节。

既然我们让模型设计围绕着程序的逻辑,而不是数据库层面,就可以将数据库操作完全隐藏在模型类里面。看一下可以重构我们早先的例子的几处地方:

Magic-Beans/soln/domainmodel.php
<?php
class BugReport {
	protected $bugsTable;
	protected $accountsTable;
	protected $productsTable;
	public function __construct() {
		$this->bugsTable = Doctrine_Core::getTable("Bugs");
		$this->accountsTable = Doctrine_Core::getTable("Accounts");
		$this->productsTable = Doctrine_Core::getTable("Products");
	}
	public function create($summary, $description, $reportedBy) {
		$bug = new Bugs();
		$bug->summary = $summary     $bug->description = $description     $bug->status = "NEW";
		$bug->reported_by = $reportedBy;
		$bug->save();
	}
	public function assignUser($bugId, $assignedTo) {
		$bug = $bugsTable->find($bugId);
		$bug->assigned_to = $assignedTo"];
                $bug->save();
        }
        public function get($bugId) {
                return $bugsTable->find($bugId);
        }
        public function search($status, $searchString) {
                $q = Doctrine_Query::create() ->from("Bugs b") ->join("b.Products p") ->where("b.status = ?", $status) ->andWhere("MATCH(b.summary, b.description) AGAINST (?)", $searchString]);
                return $q->fetchArray();
        }
}
class AdminController extends Zend_Controller_Action {
	public function assignAction() {
		$this->bugReport->assignUser(       $this->_getParam("bug"),       $this->_getParam("user"));
	}
}
class BugController extends Zend_Controller_Action {
	public function enterAction() {
		$auth = Zend_Auth::getInstance();
		if ($auth && $auth->hasIdentity()) {
			$identity = $auth->getIdentity();
		}
		$this->bugReport->create(       $this->_getParam("summary"),       $this->_getParam("description"),       $identity);
	}
	public function displayAction() {
		$this->view->bug = $this->bugReport->get(       $this->_getParam("bug"));
	}
}
class SearchController extends Zend_Controller_Action {
	public function bugsAction() {
		$this->view->searchResults = $this->bugReport->search(       $this->_getParam("status", "OPEN"),       $this->_getParam("search"));
	}
}

你可能注意到了几处改进。

  • 类交互图(图25-3)变得更加简单且易读了,象征着各个类之间的解耦。
  • 通过解耦模型接口和底层的数据库结构,我们减少且简化了控制器的代码。
  • 模型类创建对象和一个或者多个表进行交互,控制器不需要知道哪张表被引用了。
  • 模型类封装、隐藏了数据库查询,控制器只关心用户的输入,然后通过调用模型的API来执行更高层次的任务。
  • 某些情况下,一个查询太复杂而无法简单地使用一个DAO,编写自定义的SQL就变得很必要。SQL安全地包含在模型类里时,直白地使用它看上去不那么可怕。

图25-3 通过解耦减少混乱

理想情况下,你可以不用连接到真实的数据库来测试模型。如果将模型和DAO解耦,那么可以模拟DAO来帮助对模型进行单元测试。

同样,你可以像其他面向对象的测试那样测试领域模型的接口:调用对象的方法,然后验证这个方法的返回值。这比模拟HTTP请求来填充一个控制器,并且解析HTTP的响应要快得多,容易得多。

你还是需要模拟HTTP请求来测试控制器,但由于控制器的代码变得更简单,所以不需要测试很多逻辑分支。

如果将模型和控制器、数据组件分离,就可以对所有这些类都进行更简单的、更独立的单元测试。这能帮助你更容易地定位缺陷。这可就是单元测试的目的所在啊!

25.5.4 回到地球

你可以在任何软件开发框架中更有效地使用数据访问对象,即使这个框架鼓励使用魔豆反模式。然而,那些不知道如何使用面向对象设计原则的开发人员,注定会写出意大利面条式的代码。

本章所提及和描述的领域模型的基本概念,能帮助你选择最好的设计来支持测试和代码维护,最终会达到高效开发基于数据库的程序的目的。

将模型和表解耦。

下一节:年轻人,在数学里,你们并不理解事情,你们只是去习惯。 -- 约翰•冯•诺伊曼