终于把这份实现写完了,比想象中要花时间,尤其是为了可测试性而增加的代码结构。我并没有使用TDD来开发这个类库,依然是先写代码,再写单元测试,测试代码也只关注了代码主体,没有刻意去测试边界情况。一部分原因是其中都是内部实现,可以把握住输入,令一部分原因是这段实现主要是各种交互,而没有复杂的业务逻辑。我个人满足于单元测试而不是测试驱动开发,但如果您是使用测试驱动开发来实现这个方案,那就更好不过了。
我经常嚷嚷说,为了增加代码的可测试性,我必须在项目里不断引入各种抽象。例如写一个用于连接的MyConnector类,它包含一个构造函数和三个成员,原本我只需要这么写:
internal class MyConnector { public MyConnector(string[] uris, IConnectionEventFirer eventFirer) { ... } public IMyDriverClient Client { get; private set; } public void Connect() { ... } public void CloseClient() { ...} }
但是,假如有其他类使用了MyConnector,会发现MyConnector是没法Mock的。例如,它的所有成员都是非虚(non-virtual)的。有人说,把它全部标为virtual不就行了嘛,像Java里面默认就是virtual的。但我不喜欢这样,因为这个类本来就没打算有扩展的场景,也不想为了单元测试而去改变代码实现。因此,为了可测试性,代码便会变成这个样子:
internal interface IMyConnectorFactory { IMyConnector Create(string[] uris, IConnectionEventFirer eventFirer); } internal interface IMyConnector { IMyDriverClient Client { get; } void Connect(); void CloseClient(); } internal class MyConnector : IMyConnector { private class Factory : IMyConnectorFactory { public IMyConnector Create(string[] uris, IConnectionEventFirer eventFirer) { return new MyConnector(uris, eventFirer); } } public static readonly IMyConnectorFactory DefaultFactory = new Factory(); public MyConnector(string[] uris, IConnectionEventFirer eventFirer) { ... } public IMyDriverClient Client { get; private set; } public void Connect() { ... } public void CloseClient() { ...} }
为了可测试性,我们在具体的实现类以外还增加了:
当然,使用我之前提过的方法便可以省去两个接口,只留两个具体类就行了。
单元测试离不开Mock,但是我发布示例代码之后,有几个人(都是Java同学)向我反映说MyDriver项目里的MyDriverClient类为什么是final的,能否将其去除以便Mock?我说不行,这里我是故意设置的障碍,因为实际情况下第三方的类库可能也是这种情况,因此需要自己在MyClient项目里解决这个问题。
当然解决方法并不难,只需要为MyDriverClient写一个接口及封装类即可,自然还有对应的工厂类型:
internal interface IMyDriverClient : IDisposable { void Connect(); void AddQuery(int queryId); void RemoveQuery(int queryId); MyData Receive(); } internal interface IMyDriverClientFactory { IMyDriverClient Create(string uri); } internal class MyDriverClientWrapper : IMyDriverClient { private class Factory : IMyDriverClientFactory { public IMyDriverClient Create(string uri) { return new MyDriverClientWrapper(new MyDriverClient(uri)); } } public static readonly IMyDriverClientFactory DefaultFactory = new Factory(); private readonly MyDriverClient _client; public MyDriverClientWrapper(MyDriverClient client) { this._client = client; } public void Connect() { this._client.Connect(); } ... }
这么做除了便于单元测试以外,还可以形成一个窄接口,避免在使用的时候迷失在繁复的成员里。
多线程操作会直接用到Thread类以及相关静态方法,这些也是不利于单元测试的地方,为此我抽象出了一个IThreadUtils接口,以及一个默认实现:
internal interface IThreadUtils { void Sleep(int millisecondsTimeout); void StartNew(string name, ThreadStart start); } internal class ThreadUtils : IThreadUtils { public static readonly ThreadUtils Instance = new ThreadUtils(); public void Sleep(int millisecondsTimeout) { Thread.Sleep(millisecondsTimeout); } public void StartNew(string name, ThreadStart start) { var thread = new Thread(start); thread.Name = name; thread.Start(); } }
在代码里所有的线程操作都会使用IThreadUtils完成,便于模拟。不过,在单元测试的时候,我们还必须真正去检查“新线程”有没有执行正确的代码。为此,我还实现了一个DelayThreadUtils类,专供单元测试使用:
public class DelayThreadUtils : IThreadUtils { private List<Action> _actionsToExecute = new List<Action>(); public virtual void StartNew(string name, ThreadStart start) { this._actionsToExecute.Add(() => start()); } public void Sleep(int millisecondsTimeout) { } public void Execute() { foreach (var action in this._actionsToExecute) action(); } }
在DelayThreadUtils中,所有的StartNew调用都只是“收集”操作,并不执行,一切都延迟到Execute方法调用时才真正执行。在单元测试里使用DelayThreadUtils的模式大约为(基于Moq类库):
// 1. 准备Mock对象 var threadUtilsMock = new Mock<DelayThreadUtils> { CallBase = true }; // 2. 使用threadUtilsMock.Object // 3. 确认ThreadUtils上的相关方法已经正确调用 threadUtilsMock.Verify(tu => tu.StartNew("Some Name", It.IsAny<ThreadStart>())); // 4. 确认线程里的操作没有执行 // 5. 执行线程里的操作 threadUtilsMock.Object.Execute(); // 6. 确认线程里的操作已经正确执行
总而言之,我们只是想要确认目标代码的确是在新线程里执行。
还是拿MyConnector为例,它在实际使用时其实只需要这样一个构造函数:
public MyConnector(string[] uris, IConnectionEventFirer eventFirer) { ... }
但在内部实现的时候,我们还需要线程操作,也需要创建MyDriverClient对象,都是涉及单元测试的依赖。因此,我们也会准备另一个“完整”的构造函数,用于注入所有需要的依赖,而真正使用的构造函数则委托至“完整”的构造函数上:
internal MyConnector( string[] uris, IConnectionEventFirer eventFirer, IThreadUtils threadUtils, IMyDriverClientFactory clientFactory) { ... } public MyConnector(string[] uris, IConnectionEventFirer eventFirer) : this(uris, eventFirer, ThreadUtils.Instance, MyDriverClientWrapper.DefaultFactory) { }
为了区分“实际使用”的构造函数和“用于测试”的构造函数,我的规则是使用public和internal进行区分。由于它们大都是定义在内部类里,因此两者效果其实没有什么不同,只是为了“看上去”能分清而已。
MyClient项目唯一暴露的类型便是MyConnection。MyConnection的绝大部分操作都会委托给MySubscriptionManager,其完整功能被拆分成了数个小部分,每个小部分都能独立的实现和测试,代码不多,属于可测试的范围内。
MyConnector类封装了与服务器连接相关的逻辑,包括失败后的重试:
MyConnector的Connect方法总是在一个新线程里执行,连接成功后会触发Connected事件,由MySubscriptionManager的OnConnected方法响应,并开启三个工作线程,它们分别是:
剩下的便是MySubscriptionManager内部的协调工作了,它也会监听Disconnected事件,并重新调用MyConnector.Connect方法,后者会触发Connected事件,并重新开启三个新的MyRequestSender、MyDataReceiver,MyDataDispatcher任务。简单的说:旧的任务会在任意环节出错时停止,而每次重新连接之后,都会开启新的任务。
MyClient的Program.cs项目中包含了简单的使用案例。
所有的代码都可以在GitHub项目里的csharp/Practice01-End目录里找到,包括实现以及单元测试。Java项目我就没有精力再做一份了,但是我想这不会影响交流,使用Java的同学肯定也可以理解C#代码,我也会继续关注一些Java同学所实现的代码,需要的时候也会将其移植为C#代码。
我使用VS 2010编写代码,但没有使用VS集成的单元测试框架。我使用的是xUnit,一方面原因是由于xUnit更符合我的审美(这点以后再说),另一方面原因是我不想让示例与开发环境产生依赖,现在VS 2010 Express甚至Mono下都能运行这些代码。Mock类库使用的是Moq,这应该也是目前最流行的C#标准Mock类库了吧。所有依赖的类库我都用NuGet进行管理,您也可以通过NuGet来安装这些类库。
这只是个练习,因此我也有一些问题还没有完全想明白:
如果我有更多问题也会不断列出,欢迎大家一起来讨论。