走过的世界不管多辽阔,心中的思念 还是相同的地方 - 《回家》
就在那个山顶听听来天堂的声音,就在那个村庄平息难以安静的灵魂 - 《带我到山顶》
在VB中,方法分为函数和过程(Function/Sub),相当于C系语言有返回值方法和void方法。沿用单元测试的叫法,方法称其为单元。很多人会疑问,这个有什么好说的呢?实际上颇有一些研究价值,请看分析。
方法与面向对象
老外有篇文章,《When A Method Can Do Nothing》。很多人表示看不懂,若一个方法什么也不做,要它干嘛?其实如果细品文章中的一段话:
当你告诉一个对象做XX时,有时候它并不去做——发送执行XX的消息,意味着有时XX事件并不一定会发生。这要由这个对象决定。
如果说多态有任何意义,它至少在说对象是自己管理自己的。我们给它发送一个消息,这是由它来决定应该去做什么。这是面向对象的核心,也是 Alan Kay(设计了SmallTalk,获得过图灵奖) 最初的对对象的认识之一——消息只是它们的信息传递。这种观点在如今并不占主导地位。
在面向过程/函数语言中,自然一切都围绕方法,每个方法都有明确的职责,调用某个方法有明确的目的。
到了面向对象编程,方法的业务逻辑则要服从对象的设计,方法的职责和目的要与服务于对象。这很自然,然而却不易深刻理解。
When A Method Can Do Nothing正是揭示了这种的改变,而且是面向对象的核心之一。如果文章标题改为 Don’t Expect A Method To Do Anything,或许更好理解。不要指望你调用的方法会做什么,除了返回值。
脱离外部单元的依赖,外部单元,还有外部单元的外部单元,也需要单元测试,形成级联,每一级都可以明确职责,实现隔离的测试。直至上溯到我们项目代码边界,操作持久层或UI或框架代码。
方法的业务逻辑不应该依赖于它调用的方法,或者说调用的其他方法改变了什么,方法的业务逻辑都能处理,这是真正面向对象的方法。这实际上是SOA(面向服务)思想在方法级别的应用,方法间只有消息(参数)传递的松耦合。
面向对象方法很容易被扩展、测试、重构,是IoC的很好补充。一般用于API上,尤其是UI和持久层。虽然这种方法只占全部方法的一小部分,但对整个系统就是点晴之笔。
方法包含什么
方法是最小粒度的模块,所有业务处理步骤,都可以分为四类,参见下图:
一个方法存在的意义就在于最后的组装,将前四部分编织起来,融入数以千计甚至万计的组成一个复杂的业务逻辑网中。
目前C#等语言方法概念,仍然是沿袭面向过程语言。方法签名只能单向规定被调用的方式,除非看源代码,否则完全不清楚其内部的运作方式。这就像一个黑盒子,虽然输入输出一目了然,但却可能有许多看不见的线,与外部状态关联。
从低耦合角度,应该把方法尽量设计成让人放心的黑盒子,尽可能避免对外部状态的依赖,系统的测试性/维护性/扩展性都会大大提高。然而在整个系统中,必须有模块要与外部状态打交道,除非是一个理想化的模型。好的设计只是封装得更好,限制得更少。即使一个方法看上去好像不操作全局上下文,可它一旦调用其他单元,就可能间接改变外部状态,导致意料之外的结果。
或许现实中真有那种创建时根本无须和外界打交道的对象(业务越复杂可能性越小)。可能是我个人经验不够,还想象不到。
方法还与标准黑盒子有点不同,只须包含加工处理逻辑,并不一定要有输出返回值或“产品”。用函数还是过程,要根据对现实对象连系,以及业务逻辑的抽象程度。反映了系统模块间的连结方式,连结方式反映了业务逻辑的组织方式。什么时候用全局状态,什么时候用函数返回值,这是模块设计中必须不断权衡的问题。
这里的“产品”其实指编程语言可以描述的对象,如果一个处理过程不出“产品”,并不代表没有成果,比如修改全局状态,或者后面提到的操作Database/UI,调用第三方API等等,这些事不能也不必描述为“产品”对象。因为我们的项目和代码,是对现实世界的抽象,现实场景的一个角度和片断。只能尽可能地模拟,而无法完全描述。
方法和业务逻辑
上面提到了方法中各种元素,只有内部计算和封装每个步骤的流程,是没有副作用的,这是方法真正的业务逻辑。
业务逻辑本身就是个非常抽象的词儿,我们最常提到,也最习惯的业务逻辑概念,是从领域模型角度看的,对此阿彬同学有一篇非常独到而精僻阐述其本质。他指出,业务逻辑不是数据库也不是UI,而是Domain(Business Logic) Layer的代码。
对于这些业务逻辑,充分的单元测试代码,既能说明需求,又能检查逻辑,一举多得,对于现在主流的敏捷开发更是不可或缺。
领域角度,类似于像数据库三级模式中的概念模式。DBMS本身也是一个软件系统,其内模式和外模式同样可以套用在一般系统上。
领域视角的系统概念层,对应领域模型的设计,代码上直接反映在BL层。而领域的内模式,则将UI层/持久层/Service等,所有与底层和外部交互的架构层都包括其中。
从方法角度看,业务逻辑的概念可以扩展到BL以外的层,这些架构层也有其自身的处理逻辑。但BL层逻辑仍然是核心,其他层的业务逻辑都是服务于BL层服,只是手段而不是目的。这些层的模块/对象/方法,都是可以隔离,可以模拟,可以替代成不同框架实现的。
比如系统中日志处理模块,可以将日志保存到Server的Event Logger,也可以存到数据库,两种方式虽然数据相同,但读写策略不同。 这些策略虽然不是标准的Business Logic,但可以称之为扩展或广义的业务逻辑。
BL层的方法和其他层方法,结构上没有特别之处。只有一种特殊情况,除BL外的其他层,都应该有一个边界,边界内是我们的代码,“外面的世界”是无限的第三方框架和服务,处于边界的方法,是沟通我们代码,与底层框架/操作系统/第三方服务的桥梁。
应用程序的边界方法会调用许多外部API,平台服务的边界方法会提供API供外部调用。
大家都知道,代码逻辑中处理边界的情况要特别小心注意。对于边界方法也是,要处理可靠性(比如网断了)、安全性、性能方面的要求,这些同样也可以看作扩展的业务逻辑。采用优秀的框架,如WCF,往往能封装并降低实现这些要求的代码负担。
方法的测试与扩展
一般而言,代码越容易被测试,也很容易被修改,也越健壮。总之软件各项标准很大程序上都是统一的。模块/类/方法,各个粒度的代码都适用。
过去的DB和UI框架,很多难以封装抽象,如Asp.Net的控件绑定。如今随着MVC、MVVM、Respostory架构的推广,情况已经大为改观,出现了许多Mock框架。将领域业务和实现业务隔离,避免经常边界操作导致性能时间损失,更有助于推广Unit Test。
现代每个平台,每个团队都提倡,敏捷开发更离不开单元测试,可是为什么大家还是时常很迷茫,无从下手呢。根本原因在于:单元测试验证机制完全靠手工,无法准确反映单元(方法)间的联系和约束,而且随业务逻辑变化需要维护。
举个例子,有下面一个ER图,也可作为领域模型,描述学生/讲师/课程间的关系。
假设有个BL层方法,计算学生选的课,生成一个课程表,方法会调用DAL层返回学生和课程信息。现在需求提出要课程表要加上讲师信息,DAL层采用了EntityFramework,所以讲师信息作为课程的外键属性,不会自动加载。不少人包括我,经常会忘掉加Include方法。这种情况下单元测试如果不更新,增加讲师Null断言,也就无法检测出这种逻辑错误。
未来的编程语言或框架,必须要能支持检测这些约束。还有个问题是,对于课程的讲师信息,其实应该是非空外键属性,只是由于性能考虑,对现实抽象妥协而留空。这种情况的Null很容易与真正业务上的Null混淆。
Spec#语言是C#扩展,提供了方法约束和不可空对象的支持。看下一个求平方根的方法:
int ISqrt(int x){ requires 0 <= x; //参数非负 ensures //检查结果误差 result*result <= x && x < (result+1)*(result+1); { int r = 0; while ((r+1)*(r+1) <= x) invariant r*r <= x; { r++; } return r; }
可能是语法有点繁琐,Spec#没流行起来,但这个探索应该是编程语言未来的方向。而非空对象的特性,我觉得面向对象语言都应该引入,这会使《WhenA Method Can Do Nothing》提到的Null-Object模式变得流行起来,将带来更接近真实,更容易理解的业务逻辑。
理想的完美系统,每个方法职责毫不含糊,每个方法都清晰可测的,方法的外部依赖都应该封装成接口或其他形式的抽象。对一个单元测试,除了BL层那些不会产生副作用的方法,应该Mock所有外部调用。再单独对每个外部调用进行单元测试。因为一个API可能被多个业务逻辑调用,而只需要一次就可以验证其正确性。
预计单元测试未来的发展方向,应该也分成不同的架构层,会和项目业务部分一样重要的地位。
胡扯了不少,该结尾了。其实本文的大部分东西,都是写之前未想到的。能思考出这些东西,还是很开心的。本文和之前一篇框架和应用的文章,作为下一篇随笔的铺垫。最后祝所有人新春快乐,马到成功!