SQLAlchemy需要开发人员进行数周研究才能明智地使用它的客观事实
相比MVC缺乏更好的整体架构
开发人员对于MCV的理解不一致
开发人员还没意识到自动化测试应该告知主代码被分解的方式
需要实践TDD(测试驱动开发),即先编写单元测试
关系型数据库对于测试组件速度上的有害影响
需要学习什么是真正的单元测试:一些开发者认为集成测试就是单元测试
我将讨论所有这些方面,包括它们的关系以及一些解决方案。
草率是不好的…不要着急…MKay?
除非一个软件的生命周期真的很短,否则匆忙地编写永远是得不偿失的。编写一个软件需要时间、调研、学习、实验、重构和测试。每一个让步都会让你的草率产生糟糕的影响。但是不要相信我;每个人都需要亲自地去尝试这种痛苦。
谨慎地选择你的框架
本节中提到的建议仅针对目前(2014年4月)而言。
当编写大型应用时,每个人都应该谨慎地选择所使用的工具。不要只是去走捷径。举例来说,选择一个更加复杂的web框架是值得的,比如功能齐全、设计精美以及丰富文档的Pyramid,它比起其他像Flask等一些框架具有更好的定义范围以及更完善的解耦。虽然Flask之类的框架可以在完成小型工作时更加迅速,但是却要承受着无处不丰的线程局部变量,不完整的文档(好像只是给已经了解它的人去阅读),以及疯长的插件(Flask爱好者希望系统中所有东西都成为一个Flask插件)。
可以肯定地说,在Python的领域里SQLAlchemy相比于其他的ORMs软件是非常先进的。如果你通过其他的方式来访问关系型数据库,那么你将错过它。这也是你不应该去选择web2py框架的原因,而且,它并不包含ORM,仅仅是通过简单的DAL来生成SQL。
既然我已经推荐了Pyramid和SQLAlchemy,为什么只字不提表单验证工具?从古老的FormEncode至今,许多库都为此而创建,比如说WTForms和ToscaWidgets。我只会说你可以亲自尝试下将Deform和Colander结合在一起——它们具有不同的功能,比如说将数据转换成Python结构,以及部件(Deform)的模式分离(Colander),它真的能正确地解决问题。这种架构清晰性导致它比竞争对手而言更加有力。同样的是,你需要花费稍微多些的时间去学习如何使用这些工具。使用其他工具的学习曲线可能会比较平缓,但是你将在今后的工作中深受其扰。
MVC不足以应对大型项目
在Web应用开发中,你可能已经知道了MVC架构(model,view,controller)。如果你不清楚,你可能并没有准备好去开发大型项目;可以先使用一个小型的MVC框架,然后在数月或者几年之后再来看这篇文章吧。
严格来说,MVC是一个古老的概念,从早期的Smalltalk开始已经不再适合于web应用开发。Django的开发人员已经正确意识到在Python中我们实际使用的是MTV(model,template,view):
模板包含了HTML的内容以及页面显示逻辑。它是使用模板语言如Kajiki来编写,从视图(view)中获取数据,然后展示在页面中。
视图(有时也称为“控制器”),仅仅是使用Python语言编写的中间代码。它借助于web框架将所有的内容放在一起。它可以看到其他的所有层,并且定义了URLs,将它们映射到web框架中用于接收数据的函数,然后利用其他层以最终发送响应给web框架。它应该尽可能得小,因为它的代码是不能重复利用的,即使你尽量地缩减它,web表单也会促使其逐渐地变得复杂。
模型层本质上是一个持久层:它最重要的依赖就是SQLAlchemy。模型知道如何去保存数据,构成整个项目中最可重用的代码。当然它并不清楚HTTP相关的内容和你所使用的框架。它代表了排除用户界面细节的系统本质内容。
但是稍等下,哪里?在视图还是模型中?你应该在哪里放置程序的灵魂:业务规则?模板层已经自动被排除掉,因为它并不是Python编写的。所以剩下3个可能的答案:
视图层,这是最糟糕的选择。视图层应该仅仅包含中间代码,将代码数量保持尽可能得小,并且同系统中的其他部分隔离开,所以系统应该能在web框架中、使用中以及单元测试中独立访问。另外,业务逻辑应该存在于更加可重用的地方。视图层被视为展示逻辑的一部分,所以业务逻辑被排除在外。实际上,除了Web UI之外,在创建desktop UI时,开发者应该忽略视图和HTTP相关的内容,需要业务逻辑尽可能地被重用,因此,我们应该排除掉视图层。
模型层,这个是可能的选择,因为模型层至少是可重用的。但是模型层主要关注于持久化,它应该更少地依赖于SQLAlchemy(它已经是一个非常复杂的东西)。
新的层,这才是正确的答案。下面将举例来更好地理解这部分内容。
如果你要创建一个博客,那么MTV正好满足你的需求。但是对于更加复杂的项目来说,其实还是至少缺了一层。你应该将业务逻辑旋转到一个新的、可重用的层次中,大多数人称之为“Service”层,但是我更喜欢称之为”Action“层。
为什么你需要这层?
在大型应用中,单个用户的操作引起多项活动是非常常见的。比如,用户成功地注册了你的服务,那么你的业务逻辑中可能会触发非常多的后台处理:
在关系数据库多张表中新增数据,使用了模型层。
将发送邮件给用户的任务放置到队列中。
将发送短信给用户的任务放置到队列中。
将创建实际使用服务时必需的空间和其他准备性资源的任务放置到队列中。
将更新用户数据的任务放置到队列中。
….
这是一个理解“业务逻辑“的好例子:给定一个用户操作(比如注册),系统就要做一些必需的操作。这种业务逻辑被单个函数捕获会更好;这个函数应该在哪一层呢?
如果所有的这些都实现在模型层,你能想象到它将变得多复杂吗?模型层在只面对持久化时已经很艰难了。现在想象一下模型层处理所有这些事务,它要使用多少外部服务?文件头部应该包括多少imports?反过来看,有多少模块愿意引入这个模型,可能在系统启动前就因为创建的循环依赖而导致系统崩溃。
循环依赖其实就是你没有正确认清系统架构的明显的标识。
对于依赖于Celery的模型来说,了解如何去发送邮件、短信以及使用外部服务等是不应该的做法。持久化对于模型层来说已经是非常复杂的主题了。你应该在模型层之外去处理这些业务逻辑——在模型层和视图层之间的一层。所以称之为”Action“层。
另外,模型层在关系型数据库中经常被映射到一张单独的表。如果你在用户表和订阅表中插入一条记录,哪一个模型应该包含上述逻辑呢?这几乎是不能确定的。因为实际执行的操作远超过了用户表和订阅表的范围。因此,业务逻辑应该被定义在任何模型之外。
当开发人员执行维护时,有时她想按步骤地执行每一步,而有时她想一次性执行完所有操作。分别地实现这些操作并在单个Action层函数中调用是有帮助的。
你可能会怀疑我提出的方法难道不是反面模式域模型的一种吗?没有动作的模型恰恰与面向对象设计相反!我并没有说“将所有的方法从模型中移除”。我的意思是指“将需要外部调用的方法移除”。模型中的方法仅仅用来使用它所需要的数据,并且属于模型中的那些数据。一个面向世界的,调用外部服务的并且很少使用自身数据的方法不应该被放置在模型中。
另一个使得这种架构成功的原因就是测试。TDD教会了程序员去让程序变得解耦,这样做通常会使得软件更加健壮。如果你想要创建一个Celery的应用,并且在你测试之前已经知道了其他的外部服务,那么你将经常陷入头痛中。
还有将业务逻辑放置在视图层之外的最后一个原因。在未来,当你最终决定从Flask过渡到Pyramid时,你很乐意将视图层保持简洁。如果所有的视图都是在与web框架间交互,并且动作层会执行所有的函数,那么你的代码就做到了非常好的隔离。Web框架通常都很贪婪,不要让你的系统跟随他们的脚步。
所以下面就是我所提议的在Python中构建大型应用的层次结构:
模型层是最底层的,最可重用并且可见的层。它仅专注于持久化,模型层是可以包含动作的,只不过这个动作仅仅属于这个模型。模型可以被其他层所返回,以各种方式在请求的结尾返回给模板。
外部服务。对每个服务都创建一个比如说发送邮件。
动作层。这是系统中的核心层,它包含了业务逻辑以及工作流。它使用外部服务去实现特定的目标并且借助模型层来持久化数据。通过以上这些层,它支撑起了整个系统,包括配置,除了用户界面。
模板层仅包括了页面展示逻辑比如说从列表中循环输出构成一个HTML表格。
视图层,这是最高层的,最不可重用的层。它依赖于(与系统中其他层隔离)web框架。并且依赖于表单验证库。它可以看到模板层以及动作层,但是不能直接调用模型层——它必须通过动作层。但是当一个动作层返回了模型数据,那么它可以被传递给模板中(一个Celery任务可以类比于一个web视图)。
这种体系结构有助于避免了在会话层中进行调试因为它清楚地定义了各自的职责。同时它也是明显经得起检测的,因为它做到了很好的解耦,因此可以减少测试的并且减少了模拟的次数。
好的架构总是解耦性非常好的。如果你曾经陷入到一个循环依赖中,你可以想一下是否真的定义好了每一层的职责。当你放弃了并且从一个函数中引入内容,那么你的架构已经失败了。
这并不是说你的web应用必须与Celery应用隔离开。在他们之间可以重用代码——特别是模型——但是对于Celery应用来说,不应该引入web框架!获取配置也不例外,因为在Python中读取配置是非常简单的。
自动化测试也是个巨大的挑战
Python是一个非常灵活,富有表现力,反射型语言。隐藏在动态机制后的不利之处在于“编译期”内很少能发现错误。如今使用Java语言构建大型系统时已经离不开自动化测试了;Python中更是如此。
一旦你意识到它对你软件健壮性的重要性时你就会开始编写测试用例。你理解了它并开始编写用例,你编写的第一个用例具有极大的价值,它会给你增加对系统的不可思议的信心。这是非常有趣的。
然而,你很快就会感觉测试对你来说更像是一种负担。你会拥有数百个的测试用例去运行,而且通常要运行很久。在这种情况下,每一个编写的新测试用例都会让你的生活更加糟糕。这个时候,一些人可能会觉得幻想破灭并得出测试是不值得去做的。这个结论往往言之过早。
你认为你已经知道了如何去编写测试代码,其实不然,你其实是在编写一个综合测试。虽然你称之为“单元测试”,但是实际并非如此。每一个测试用例都贯穿了系统整个的运行过程。你把你的模拟覆盖到了尽可能远的位置,你认为这是好的(通过这种方式可以测试更多)。但是你将发现这其实并不好。
单元测试其实是完全相反的,真正的单元测试会像激光一样,它仅仅执行某一层的某个函数,并且使用模拟来避免陷入到其他层中,它永远不会到达其他的外部资源,它断言只有一个条件而且运行速度像光一样快。
雪上加霜的是当测试用例开始工作时,显示的结果会让你陷入混乱。不同于针对单个错误的测试用例可以准确地告知你哪里出现了问题,数十个测试用例执行失败(所有的失败可能都是因为一个原因,因为它们贯穿了整个系统),但是你需要花费很长的时间去找出bug的真正所在。因而你需要编写出更好的测试用例。
专家推荐你编写99%的真实、专注、模拟性的单元测试,而仅1%的贯穿所有层次的综合测试。如果你从开始就这样做了,那么你的测试集会在数秒钟内就运行完毕,这样才能使得TDD是可行的。如果某个单元测试运行时间较长(超过了10毫秒),那么它可能是其他类型的测试而非单元测试。
如果你要编写的仅仅是个小的应用,那么使用综合测试的方法或许也可行。但是我们要讨论的是大型应用,因此,在这个层面上来说,要么你去优化测试用例的性能,要么你根本应用不了TDD。
另外,你要记得有一些测试用例是比较难编写的,它们需要大量的工作才能完成。有的人解释说这是因为你的测试用例并非真正的单元测试,而且你并没有以测试为先——你在给现有的未充分解耦的代码编写测试用例时会很难。现在你知道TDD其实并非只改变了测试相关的东西,还有将你实际的系统变得更好。
关于测试用例的编写可以参考以下资料:
快速测试,慢速测试
综合测试是一个骗局
停止模拟,开始测试(实际上并不是在攻击模拟实践,只是提出要重用模拟和存根)
如果想发现最慢的两个测试,可以使用如下命令:
py.test -s --tb=native -x --durations 2
SQLAlchemy和测试
但是系统使用的是SQLAlchemy!数据要以模型实例的方式在不同的层之间流动。执行一次数据库查询,可能已经使用了超过10毫秒的时间。这迫使你意识到,如果这次查询命中了数据库,那么它就不再是单元测试。(想要实例化SQLAlchemy模型是非常迅速的,但是与SQLite的交互却很耗时,即使它是保存在内存中)。TDD迫使你将查询,session.flush()和session.commit()等放到适合于单元测试的函数之外。
即使如此,你仍然需要编写一些综合测试。它可以测试出不同层次之间的联系,并且捕获到单元测试不能查出的BUG。对于综合测试,John Andreson有一个很好的使用方法:使用SQLAlchemy,但是永远不要允许提交事务。在每一个测试结尾处,加上session.rollback()以使得下一次的测试可以在数据库未被改变的情况下执行。这种方法可以让你不用每次测试都重新建立数据表。
为了实现这一点,你不能到处提交会话,最好的是制定一个规则:系统只能在最外面的层中通过调用session.commit()实现提交,即web视图层或者Celery任务中。不要在模型层提交,也不要在动作层提交!
这会导致最后一个问题:如果一个任务是用来提交事务的,怎么为该任务编写单元测试?我需要一种测试调用任务的说法:异常,就这一次(因为它是测试),其他的请不要提交。否则单元测试就会命中服务器,并且执行超过10ms的时间限制。
最终我会给出一个外部的函数(比如测试用例)去控制是否提交事务。使用这种方案,默认情况下会提交事务,但是允许测试用例告知可以不提交。下面就是这段示例代码:
from functools import wraps
def transaction(fn):
'''Decorator that encloses the decorated function in a DB transaction.
The decorated function does not need to session.commit(). Usage::
@transaction
def my_function(): # (...)
If any exception is raised from this function, the session is rewinded.
But if you pass ``persist=None`` when calling your decorated function,
the session is neither committed nor rewinded. This is great for tests
because you are still in the transaction when asserting the result.
If you pass ``persist=False``, the session is always rewinded.
The default is ``persist=True``, meaning yes, commit the transaction.
'''
@wraps(fn)
def wrapper(*a, **kw):
persist = kw.pop('persist', True)
try:
fn(*a, **kw)
except:
db.session.rollback()
raise
else:
if persist is False:
db.session.rollback()
elif persist is True:
db.session.commit()
return wrapper
谨以此文献给我的朋友Luiz Honda,是他教会了我这些知识。
英文原文:http://nando.oui.com.br/2014/04/01/large_apps_with_sqlalchemy__architecture.html?ref=dzone
译者:donglei-1009
阅读原文