演进负责任的、自描述的组件






4.81/5 (9投票s)
自描述组件如何从责任驱动的开发实践中产生,以及Visual Studio“代码区域”工具的使用如何为这一过程增添价值。
引言
本文讨论
- 自描述组件如何从责任驱动的开发实践中产生;
- Visual Studio“代码区域”工具的谨慎使用如何帮助澄清代码意图,以及这在责任驱动的开发中尤其有用;
- 如何使用行为测试和模拟对象测试组件协作。
我遇到的许多.NET类文件的一个共同特点是,它们通过使用Visual Studio“代码区域”工具来模糊其意图。该工具的开箱即用和普遍鼓励的使用方式是按可访问性和作用域对类的成员进行分类(即,将所有公共方法或私有字段分组到所谓的“区域”下)。这种方法充其量未能充分揭示类的意图,最坏的情况是完全迷惑了读者。我在责任驱动开发环境中工作时偶然发现了代码区域工具的另一种用法,并意识到这种特定用法是自然而然地从责任驱动实践中产生的(而不是反过来),因此我认为从责任驱动的角度介绍这种小技术是个好主意。
有关责任驱动设计实践的更深入讨论,我推荐wirfs-brock网站上的资源。
入门
为了探索自描述组件的开发,并演示一些责任驱动和测试驱动技术,我们需要一个例子;因此,让我们从一个简单的消息处理和转发系统的单一需求开始。
故事 #1
消息所有者(发起者)通过队列将消息传递给通道。队列保存消息,直到队列缓冲区达到定义的容量,然后此消息和队列中的所有其他消息被转发到与队列关联的通道。当消息发起者放置在队列中的消息被转发到通道时,发起者会收到通知。在任何时间点,消息发起者都可以清除消息队列中的消息,导致队列中所有当前消息立即转发。
负责任地执行
我们希望考虑责任驱动的开发实践,但我们也意识到测试驱动开发(TDD)推荐的“测试-代码-重构”循环的价值。责任驱动方法——主要侧重于设计实践——可能与TDD格格不入,后者倾向于务实、测试优先的心态;但我们仍在努力尽可能地整合这两种方法,认识到它们的各自价值。
当我们进行责任驱动开发时,我们通过首先考虑需求所暗示的职责来设想需求如何实现。这种“挖掘职责”是我们的主要重点,然后我们才进入传统的测试-代码-重构TDD循环。在编写测试之前识别和勾勒职责的目标是,从需求的抽象限制(可能以自然、会话语言表达,如上面的用户故事)中跳出来,形成更具体的东西。这可能看起来与一些传统测试驱动方法从需求到实现的过程并没有什么不同,但确实存在差异,这些差异稍后会讨论。
一个需求在多大程度上向我们说明职责,很大程度上取决于所使用的语言,但一个典型的写得很好的用户故事自然会暗示至少一些独立的职责。一个注重职责的开发者学会识别自然语言中隐含的职责,这种技能像其他任何技能一样发展。在责任驱动环境中的开发者被鼓励从需求中挖掘独立的活动和具体的“知识保持”职责,如果由适当有能力的组件实现,这些职责应该会产生需求的完整实现。
职责和角色带来设计内聚
责任驱动组件开发的最终结果应该是一个内聚的设计,而这始于开发者首先考虑职责和角色。“以职责思考”是责任驱动开发者所做的事情,正是这种心态帮助开发者得出基于松耦合组件的解决方案,这些组件实现了明确定义的角色。通过角色,我们建议基于接口的开发,其中一个组件通过一个狭窄的、意图明确的接口邀请其他组件与其交互。角色与职责一同出现,一个角色承担多个职责的情况并不少见(就像我们稍后得到的解决方案一样)。
当组件的角色狭窄但足以证明其存在时(即,结果实现不是“懒惰类”(Fowler)),并且当离散的职责在对象网络中得到适当分配时,设计内聚是自然而然的结果。这是因为承担明确定义职责的对象,特别是那些在模型中适当地委派职责的对象,应该永远只有一个变更原因。这是单一职责原则的核心,也是我们希望鼓励的特性,如果我们要实现内聚设计的话。责任驱动开发通过提示开发者首先思考职责和角色,温和地引导我们达成这一结果。
使用表达定义明确角色的系统可以帮助我们在“拥抱变化”的项目环境(如XP或Scrum)中自信地开发组件。当发生变化时,如果我们拥有一组职责和能力明确的组件(例如,在自文档化类代码中),并且它们的角色狭窄而专注,那么更容易想象变化的影响。当角色和职责清晰地呈现给我们时,我们可以决定如何最好地利用我们现有的组件,同时,我们也可以确定哪些区域需要关注(例如重构)以应对变化。
职责分类与分配
在职责挖掘过程中,一种常见的做法是将职责分类为角色原型,例如“知识守护者”(知道并提供信息)、“协调者”(做出决策并密切指导他人的行动)和“控制器”(机械地响应事件)。分配角色原型促使我们思考一个能够实现特定职责的组件的核心活动是什么。
回到我们最初的需求,并牢记刚才提到的角色原型,我们开始看到候选组件从混合中出现。由于我们的目标是在TDD周期内工作,这些候选者成为我们推测可能承担一个或多个已识别职责的尚未编写的组件。与TDD保持一致,我们尚未为该组件编写一行代码。候选名称在此阶段并不关键,但组件最终应尽可能完整地通过单一名称描述自己,这一切都本着意图明确代码的精神。以下是从我们的单一用户故事中提取的职责、角色和候选者列表,显示了我们的候选组件可能如何符合要求(候选者被标识为ComponentX
)
职责 |
角色原型 |
候选角色 |
候选实现 (组件) |
封装消息内容 |
知识守护者 |
|
消息(已提供实现) |
将消息传递到终点 |
服务提供商 |
|
|
将消息排队 |
协调者 |
|
|
管理缓冲区容量 |
知识守护者 |
|
|
了解目标通道 |
知识守护者 |
|
|
接受并存储消息 |
协调者/服务提供商 |
|
|
以“先进先出”顺序存储消息 |
服务提供商 |
|
|
将所有消息转发到目标通道 |
协调者 |
|
|
从消息队列中清除消息 |
协调者/控制器 |
|
|
当消息转发到通道时通知消息发起者 |
控制器 (Controller) |
|
|
看起来我们已经将几乎所有职责都分配给了 ComponentX
,似乎没有 ComponentY
或 ComponentZ
需要担心。现在我们将注意力转向为候选组件定义一个“官方路线”,将我们分配的职责汇总成对组件整体能力的简要描述。同时,我们为组件选择一个恰当的描述性名称。
功能 |
候选实现 (组件) |
接受特定通道的消息,将其存储在缓冲区中,并在缓冲区超出容量时将其转发到通道。 |
|
候选组件的整体能力文本描述与我们需求的抽象内容相符,这不足为奇,除了“通知消息发起者”这一职责(为简洁起见此处省略)之外,没有多余的职责,因此我们似乎已经确定了我们的候选组件。
从发现职责和候选组件的过程描述来看,这样一个过程可能耗时;但在大多数情况下,在此过程中做出的决定只需几分钟,对于精通OO、模式导向的开发者来说,这近乎是一种自动反应。目标不是“边做边设计”,也不是过多地纠结于职责,而是尽快进入测试驱动的节奏,以验证我们的假设。TDD节奏是我们演进设计努力中可靠的伙伴,因此我们旨在尽快进入它。
发现协作
在我们识别离散职责、角色和候选对象的同时,我们也开始识别我们的“负责任组件”之间可能需要协作以满足更广泛需求的地方。当一个组件明确地倾向于将其部分职责委托给另一个组件时,协作就会出现。这有许多原因;也许,“被委托”组件的接口符合我们预期使用的某个已知规范(在这种情况下我们正在适配它),或者该组件以我们自己的组件无法实现的方式适合用途(就像上面 IQueue<IMessage>
的情况一样——当.NET提供一个完美的选择时,我们不会希望实现自己的队列逻辑)。这里所指的委托与传统OO设计中的委托是同义的,也暗示了模式导向解决方案中倾向于委托而非继承。当我们识别负责任组件之间的协作时,我们始终在思考我们所追求的最终结果
- 结果应该是一个松耦合、协作的对象网络.
回到我们的需求,我们可以看到一些明显的协作(注意组件的名称不仅暗示了其自身的能力,而且碰巧也暗示了它与谁协作)
组件 (Component) |
与…协作 |
委托职责 |
候选协作者 |
|
消息队列(名称中的“队列”) |
以“先进先出”顺序存储消息 |
|
|
消息通道(名称中的“通道”) |
将消息传递到终点 |
|
当我们在TDD周期中致力于重构为模式时,协作会迅速被发现,这主要是因为许多常见的重构都倾向于委托而非继承。在这种情况下,提出协作不应被视为自找麻烦——相反,我们正在引入少量工作(定义委托活动),以获得松耦合、可扩展的对象网络这一更大的回报(这还不包括通过重构为模式,我们可以防止设计缺陷)。随着更多协作的出现,人们很想在考虑任何更精细的细节(内部实现、算法等)之前,仅仅通过使用模拟对象和其他测试替身来构建一个松耦合的对象网络。这项技术在这篇文章中有讨论。在实践中,我发现这种方法非常有效,并且它有自己的节奏,与TDD完美契合。
下面是一个静态结构图,总结了我们目前解决方案的角色和协作。上表中列出的协作在图中表示为点“1”和“2”。
实践职责、角色和协作
在编写第一行代码之前,我们应该深入到什么程度去发现和记录职责呢?嗯,大规模地识别职责、角色和协作,然后直接投入开发,这与演进负责任组件的过程无关。我们与其他敏捷友好的实践一样,旨在采用增量方法来开发我们的组件;因此,我们越早进入经验过程来测试我们的假设,就越好。我们不应该带着冗长的职责列表和关于协作的假设进入TDD周期,而应该一次专注于一个职责,然后探索由此产生的协作。一旦我们怀疑某个职责可以由特定类型的组件(例如,一个问题可以用一个简单的类解决)来履行,我们就应该编写一个测试来确认我们的怀疑。
通过实践测试-代码-重构循环,我们可以检查我们关于谁应该负责什么,以及谁应该与谁协作的想法是否现实。这应该鼓励我们编写松耦合的组件,这些组件协同工作,应该能够履行所有已识别的职责。正是测试-代码-重构循环给了我们信心,让我们可以在不担心破坏现有成果的情况下,试验职责的分配,并尝试新的协作。这反过来又应该鼓励进一步发现职责和协作,并给我们信心试验委托技术,以最大限度地解耦组件。
尽管与传统的XP风格TDD相似,但责任驱动测试在启动时“感觉”有些不同。我认为,这是因为首先考虑职责的智力过程。作为从需求到代码的第一步,思考职责很容易在过程中引入一些设计假设。这可以理解地是有争议的,因为如果我们猜测了职责的存在,那么我们实际上在测试之前已经做出了一些设计假设,对于根深蒂固的XP实践者来说,这是YAGNI(你不需要它),并且是一个障碍。然而,我不得不说,在实践中,要从我们有些抽象的需求进入一个经验测试过程,就必须有一个跨越学科的第一步,而这总是会包含某种假设。Jeremy Miller的一篇文章更深入地讨论了在进行责任驱动开发时如何摆脱需求,可以在这里找到。
“打破”需求僵局的一种方法对于TDD实践者来说应该很熟悉:我们设想作为组件的潜在客户,我们理想中希望如何使用该组件,然后我们编写一个表达这种用法的测试。测试无法编译,因为组件不存在,所以我们只实现足以编译并通过测试的组件部分,依此类推……这种方法确实适用于责任驱动开发,但我们沿着这条路走下去的感觉是,在编写第一个测试之前,还有一个额外的“思考”步骤。我们迈向测试的第一步更像是“想象我们希望一个组件如何承担某些责任”,然后迅速接上“编写一个测试来确认某个地方存在一个能够承担这种责任的组件”。这种微妙的语义差异可以在测试中产生一种截然不同的语法表达,这反过来又会导致在测试套件和生成的组件代码中向读者讲述一个不同的故事。也许,在我们的脑海深处,在责任驱动测试期间,应该有以下基本目标,每个责任测试都旨在实现
- 当我们编写测试代码来执行一项职责时,结果应该是一个指向有能力组件的测试。.
也就是说,我们的测试说,“这里有一个组件可以为你执行职责'A'、'B'和'C',这就是如何让组件去做的方法”。
过程指南
考虑到我们产出揭示组件能力的“责任测试”的短期目标,我们可以将我们的长期目标总结为
- 构建一套测试,描述一组内聚的组件,每个组件都能够
- 承担特定职责,以及
- 与其他组件协作以实现目标。
根据我的经验,以下指导方针可以帮助我们实现这一目标
- 一旦我们对如何让一个组件为我们承担一些责任有了想法,我们就会编写一个测试,表达这种责任的委托。
- 测试应确认组件能够承担给定职责。
- 通常编写一个简单的单元测试就足够了;例如,调用组件的一个方法,然后检查组件(或系统)的状态。
- 我们首先设想作为这个尚未创建的组件的客户端,我们可能希望如何使用它。
- 一旦我们有了关于两个对象应该如何协作的想法,我们就编写一个行为测试来检验这种协作。
- 测试应确认组件按预期协作。
- 该测试应该是一个“真实”的行为测试,使用模拟对象来确认行为。它不应依赖于状态检查。
- 这样的测试可能类似于集成测试。
随着我们演进和测试我们的负责任组件,我们开始认识到对象之间协作的价值,这引出了我们的第二条指南
总结我们对不同类型测试的使用
- 离散职责通过状态测试进行练习。
- 协作通过行为测试进行练习。
行为测试
状态测试作为单元测试经常出现,对于检验组件的离散职责非常有用。行为测试——以及模拟对象的使用——是一种在需要检验协作时变得有价值的方法。我们的模型建议的协作是
- 消息从队列缓冲区传递到通道,以及
- 内部排队算法委托给一个适当能力的.NET集合类(
Queue<T>
)。
正是在检验这些协作时,我们应该考虑使用模拟对象,而不是任何其他类型的测试替身。
面向测试设计和YAGNI
例如,从队列传递消息到通道所需的协作。当缓冲区超出容量时,BufferedChannelQueue
的职责是将每条消息转发到目标通道,并且在履行此职责时,BufferedChannelQueue
期望直接与特定的通道实例协作。
如果不使用模拟对象来检验这种协作,唯一的选择就是通过查询某个内部标志或其他状态变化来询问 BufferedChannelQueue
是否已履行其职责。这将导致需要在 BufferedChannelQueue
上引入一个公共属性,用于回答诸如“你发送消息了吗?”或“请提供消息数量?”之类的问题。在极限编程等领域,引入此属性可能被视为“面向测试设计”,因此免除所有 YAGNI(你不需要它)的指控。然而,我建议这个属性是 YAGNI,它的引入是因为没有考虑替代方案(行为测试)。事实是,状态测试对协作不起作用,它只会通过鼓励包含不必要的代码来混淆视听。
- 有时“面向测试设计”会引入 YAGNI 工件
除了引入支持状态检查的伪代码外,过多依赖“询问”组件会鼓励紧耦合模型。这是因为询问意味着对“被询问的组件”(由询问组件发起)的了解可能超出必要。替代方案是鼓励“告诉而非询问”文化,通过使用事件、双重分派和其他设计模式。
使用模拟对象
我们不应该通过检查状态来伪造行为检查,而应该被鼓励编写使用模拟对象的“真实”行为测试。当我们使用 IMessageChannel
的模拟对象测试上述协作时,我们确认 BufferedChannelQueue
已按预期调用了 IMessageChannel
,无需再询问 BufferedChannelQueue
的内部状态——它根本不相关。如果 IMessageChannel
被调用了三次,因为在清除时 BufferedChannelQueue
的消息缓冲区中有三条消息,那么这就是我们需要知道的全部。BufferedChannelQueue
通过了“协作责任”测试,然后我们继续。
下面是一个图表,展示了如何引入协作测试来执行和验证我们模型中的协作“1”。
请查看随附代码示例中的测试类 BufferedChannelQueueTests
;测试 2 和 3 用于突出显示“伪”协作测试(依赖于状态检查)和“真实”协作测试(使用模拟对象)之间的区别。测试编号 3(BufferedChannelQueue_ForwardsAllMessages_WhenQueueReachesCapacity
)是我们的“真实”协作测试,旨在通过模拟 IMessageChannel
接口来执行协作“1”,如上所示。
意图揭示代码的作用
责任驱动的需求实现方法可以有效,但当代码文档化不佳,特别是类模块未能揭示其意图时,这种方法会立即受到损害。当整个团队习惯于使用描述其能力的组件,从而使开发人员-设计师能够对这些组件的使用做出适当决策时,这种损害尤为严重。当一个团队习惯于“以职责思考”时,如果他们阅读的类文件除了成员的可访问性之外,没有提供更多关于类能力的信息,他们就会偏离轨道。
人们普遍认为,一个类以人类可读的形式呈现自身的方式,可以决定它是知识共享的推动者还是障碍。正因为如此,我们的组件应该致力于在代码文件中尽可能多地描述自身,就像它们在其他形式的文档中一样,甚至更多。在敏捷项目环境中习惯于处理快速变化需求的开发人员理解使用不自描述的组件所带来的挫败感——它只会减慢决策过程。在这些常常充满压力的环境下,我们不需要寻求创建越来越多的UML图、冗长的技术架构文档(很快就会过时)以及从未更新的白板上的架构涂鸦,我们需要追求的是
- 描述组件能力的测试。
- 例如,一套证明组件“A”和“B”能够承担职责“X”、“Y”和“Z”的测试。
- 完全自描述的组件。
- 如果你需要某物来为你承担特定的责任,那么“
ComponentX
”可能就是那个能完成任务的组件。 - 一种快速简便的方法,可以搜索责任描述,并将这些描述与有能力的组件匹配。
- 例如,一个工具,能够搜索职责描述,并定位组件(例如类文件)及其测试。
.NET 中的自描述
当识别出的职责由一个或多个 .NET 类实现时,开发团队可以受益于有效利用 Visual Studio “代码区域”工具来澄清意图。在随附的代码示例中,BufferedChannelQueue
组件有两个版本,都作为同名的 .NET 类实现,通过命名空间区分
ResponsibilitiesExample.BufferedChannelQueue
- 使用标准 .NET 代码区域格式的实现。
ResponsibilitiesExample.ResponsibilityFocused.BufferedChannelQueue
- 强调职责的实现。
/*
* Copyright 2008 Oculo Consulting Limited
*
*/
using System;
using System.Collections.Generic;
namespace ResponsibilitiesExample
{
public class BufferedChannelQueue : IMessageQueue
{
#region Private Fields
private Queue<IMessage> _messageQueue;
private int _queueCapacity;
private IMessageChannel _messageChannel;
#endregion
#region Constructors
public BufferedChannelQueue(IMessageChannel messageChannel, int queueCapacity)
{
_messageChannel = messageChannel;
_queueCapacity = queueCapacity;
_messageQueue = new Queue<IMessage>(_queueCapacity);
}
#endregion
#region IMessageQueue Members
public event EventHandler<MessageForwardedEventArgs> MessageForwarded;
public void Purge()
{
ForwardMessages();
}
public int MessageCount
{
get
{
return _messageQueue.Count;
}
}
#endregion
#region IMessageHandler Members
public void PutMessage(IMessage message)
{
_messageQueue.Enqueue(message);
if (_messageQueue.Count >= _queueCapacity)
{
// Queue is at capacity, so forward all messages.
ForwardMessages();
}
}
#endregion
#region Protected Methods
protected void NotifyMessageForwarded(IMessage message)
{
if (MessageForwarded != null)
{
MessageForwarded(this, new MessageForwardedEventArgs(message));
}
}
#endregion
#region Private Methods
private void ForwardMessages()
{
IMessage messageToForward;
while (_messageQueue.Count > 0)
{
messageToForward = _messageQueue.Dequeue();
_messageChannel.PutMessage(messageToForward);
NotifyMessageForwarded(messageToForward);
}
}
#endregion
}
namespace ResponsibilityFocused
{
/// <summary>
/// This version of BufferedChannelQueue provides an identical
/// implementation, but describes itself in terms of responsibility.
/// </summary>
public class BufferedChannelQueue : IMessageQueue
{
#region Responsibilities
#region Provide Instance Configured for Specific
Destination Channel and Queue Capacity
/// <summary>
/// The destination channel is dependency-injected.
/// </summary>
/// <param name="messageChannel"></param>
/// <param name="queueCapacity"></param>
public BufferedChannelQueue(IMessageChannel messageChannel,
int queueCapacity)
{
_messageChannel = messageChannel;
_queueCapacity = queueCapacity;
_messageQueue = new Queue<IMessage>(_queueCapacity);
}
#endregion
#region Manage Buffer Capacity
private int _queueCapacity;
#endregion
#region Know Destination Channel
private IMessageChannel _messageChannel;
#endregion
#region Accept and Store Message
#region Store Messages in a Collection with "First-in, First-out" Order
private Queue<IMessage> _messageQueue;
#endregion
public void PutMessage(IMessage message)
{
QueueMessage(message);
if (_messageQueue.Count >= _queueCapacity)
{
// Queue is at capacity, so forward all messages.
ForwardMessages();
}
}
#endregion
#region Queue a Message
private void QueueMessage(IMessage message)
{
_messageQueue.Enqueue(message);
}
#endregion
#region Know Number of Pending Messages (Message Count)
public int MessageCount
{
get { return _messageQueue.Count; }
}
#endregion
#region Forward All Messages to Destination Channel
private void ForwardMessages()
{
IMessage messageToForward;
while (_messageQueue.Count > 0)
{
messageToForward = _messageQueue.Dequeue();
_messageChannel.PutMessage(messageToForward);
NotifyMessageForwarded(messageToForward);
}
}
#endregion
#region Purge Queued Messages (Forward All Messages)
public void Purge()
{
ForwardMessages();
}
#endregion
#region Notify Message Originator When Message Forwarded
public event EventHandler<MessageForwardedEventArgs> MessageForwarded;
protected void NotifyMessageForwarded(IMessage message)
{
if (MessageForwarded != null)
{
MessageForwarded(this, new MessageForwardedEventArgs(message));
}
}
#endregion
#endregion
}
}
}
当我们比较这两个类时,我们看到了如何通过使用代码区域工具强调组件职责来引入清晰度。首先,我们注意到 ResponsibilitiesExample.BufferedChannelQueue
的特性,它符合“开箱即用”的代码区域格式标准
私有
成员变量在顶部进行分组。protected
和private
成员拥有自己的区域。- 接口实现呈现在独立的区域标题下(“
I
MessageQueue Members
”和“IMessageHandler Members
”)。 - 当读者“折叠到定义”时,该类除了成员作用域之外,几乎没有传达更多信息。
将此与应用于 ResponsibilitiesExample.ResponsibilityFocused.BufferedChannelQueue
的格式进行比较
- 类成员按职责分组。这意味着公共方法、私有方法、重写成员和接口实现实际上可以分组在一起,无论其可访问性如何。
- 类成员——无论是具有自身职责的协作者(例如,_
messageChannel
),还是负责保存状态的简单值对象(例如,_queueCapacity
)——通常与相关方法分组在一起。 - 请参阅
NotifyMessageForwarded(IMessage message)
,它与MessageForwarded
事件耦合(此事件是通知消息发起者的协作者)。 - 将其与拆分协作者,并选择将私有成员分组到文件顶部某个“私有成员”区域的类进行比较。
- 通过“折叠到定义”,读者可以返回一个视图,该视图告诉他们所有他们需要了解的关于类的职责和最终功能的信息。
- 成员的可访问性和范围不是最主要的问题。如果读者需要这类信息,他们可以查阅代码窗格右上角的成员下拉列表,该列表按可访问性对类成员进行分组。
结论
我以回顾我使用Visual Studio代码区域工具来澄清代码意图的经验开始本文。这项技术源于责任驱动开发这一更广泛的学科,我在本文中对此进行了介绍,并强调了测试-代码-重构循环。确认行为和协作的最强大工具之一是使用模拟对象进行测试,我希望我在这里(并在随附的代码中)对其进行了足够的详细描述,以便读者能够理解其有用性。