Hello World
Spiga

使用Mono.Cecil解决无法Mock非虚方法和密闭类的问题

2012-01-12 22:50 by 老赵, 4768 visits

终于把上次那份需求的实现写完了,比想象中要花时间,尤其是为了可测试性而增加的代码结构。我并没有使用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类的成员。
  • 工厂接口:注入使用者,便于创建实例,相当于Mock构造函数。
  • 具体类的默认工厂:定义在具体类内部,用于创建一个具体类的实例。

当然,使用我之前提过的方法便可以省去两个接口,只留两个具体类就行了。

模拟MyDriver代码

单元测试离不开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进行区分。由于它们大都是定义在内部类里,因此两者效果其实没有什么不同,只是为了“看上去”能分清而已。

MyConnection实现简要描述

MyClient项目唯一暴露的类型便是MyConnection。MyConnection的绝大部分操作都会委托给MySubscriptionManager,其完整功能被拆分成了数个小部分,每个小部分都能独立的实现和测试,代码不多,属于可测试的范围内。

MyConnector类封装了与服务器连接相关的逻辑,包括失败后的重试:

  • Connect方法:尝试连接服务器,直至成功。连接失败时会发起ConnectFailed事件,成功后发起Connected事件,并在Client属性里暴露出可用的MyDriverClient对象。
  • CloseClient方法:关闭已经打开的MyDriverClient对象,并发起Disconnect事件。如正在尝试连接但还未完成,则停止尝试。该方法调用后可以重新调用Connect方法。
  • Client属性:可用的MyDriverClient对象,如果尚未成功连接,则该属性为null。

MyConnector的Connect方法总是在一个新线程里执行,连接成功后会触发Connected事件,由MySubscriptionManager的OnConnected方法响应,并开启三个工作线程,它们分别是:

  • MyRequestSender:从一个BlockingCollection<MyRequest>集合中不断获取MyRequest请求(Subscribe或Unsubscribe),并使用MyConnector.Client上的AddQuery或RemoveQuery方法。假如从集合中获取请求时操作被取消,则退出该任务。假如AddQuery或RemoveQuery时抛出异常,则关闭MyConnector对象。
  • MyDataReceiver:使用MyConnector.Client上的Receive方法不断获取MyData数据,并放入DataProduced集合。假如Receive方法抛出异常,则关闭MyConnector对象。MyDataReceiver同时还暴露出一个CancellationToken表明操作是否已经取消。假如Receive方法返回null(意味着了MyConnector已在其他地方关闭)或抛出异常,则都会将这个CancellationToken取消,并退出任务。简单地说,MyDataReceiver就是“生产者”。
  • MyDataDispatcher:从一个BlockingCollection<MyData>集合中不断获取MyData对象,并分派至对应的IMySubscriber对象中。假如从集合中获取对象时操作被取消,则退出该任务。简单地说,MyDataDispatcher是一个“消费者”,消费MyDataReceiver生产出来的MyData对象。

剩下的便是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来安装这些类库。

这只是个练习,因此我也有一些问题还没有完全想明白:

  • 设计上有没有什么问题?以上为了可测试性引入的代码结构是否必要?如果不必要,可以怎么做?
  • 所有代码依旧是设计先行,然后实现,最后再单元测试,而不是使用TDD进行开发。从最后的结果来看,似乎我更多是在测试“交互行为”而不是输入输出,是否合适?这是否是因为没有TDD的缘故?
  • 但是,对于此类非“逻辑验证”类型的代码,直接让我用TDD来开发,我真心感到无从下手。因为如果不是事件划分好职责,我很难获得很好的可测试性,不知如何单元测试,更不论测试驱动开发了。
  • 并发方面的代码能否进行单元测试?例如MyConnector里就要考虑在Connect方法还没有返回的时候,使用CloseClient进行中断。这部分能用TDD实现吗?

如果我有更多问题也会不断列出,欢迎大家一起来讨论。

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

21 条回复

  1. 链接

    麒麟.NET 2012-01-12 23:13:40

    看到Mono发展的那个链接,感觉C#之所以在排行榜上上升那么快,跟Mono密不可分啊。

  2. mathgl
    42.2.39.*
    链接

    mathgl 2012-01-12 23:22:06

    mono game 可能是一次编写,到处可以玩的唯一工具

    mono就是gc还不给力,还要继续努力

  3. 链接

    noav 2012-01-12 23:24:58

    if (method.IsStatic) continue;
    if (method.IsConstructor) continue;
    if (method.IsAbstract) continue;
    

    这几句 怎么 好像是在废话啊...

    if (method.IsFinal)
    {
        method.IsFinal = false;
    }
    
    if (!method.IsVirtual)
    {
        method.IsVirtual = true;
    }
    

    这个 也是在废话...

    其实 循环里 只需要写

    method.IsFinal = false;
    method.IsVirtual = true;
    

    这两句就行..

    sorry.我实在找不出 别的词 来形容..只能用 "废话" 包含..呵呵/

  4. 链接

    noav 2012-01-12 23:37:11

    请教下 判断后 赋值

    if (type.IsSealed)
    {
        type.IsSealed = false;
    }
    

    和全部重新赋值

    type.IsSealed = false;
    

    是否 有性能上的 差异.

    如果 判断后 赋值 快的话 收回 俺那句废话,呵呵//

  5. 链接

    cj05767 2012-01-12 23:40:52

    说那些if是“废话”也只能说明你不太严谨吧。你不写那些if,就好比在一个大循环里,不满足if限制的情况也会继续执行,后续的每个无用功都要做,这是时间上的浪费。

  6. 链接

    noav 2012-01-12 23:51:57

    额.是我理解有误? 按照 博主 提出的 Mock 一个 类型 需要 满足 条件

    1. IsSealed =false;
    2. IsFinal =false;
    3. IsVirtual =true;

    这三个 都需要 做检查的啊.并且设定为指定值的.不需要 If 去判断.

    或许 全部重设 这些值 要比 判断后重设 浪费时间.这个我没做测试.所以请教博主.呵呵.

  7. 老赵
    admin
    链接

    老赵 2012-01-13 00:09:34

    @noav

    这跟性能什么都没有关系,那3个条件是我写的,如果确定可以偷懒的话我也会偷懒。

    不过我不知道能不能去掉,我是担心比如IsStatic和IsVirtual同时为true什么的会不会运行失败。不想一个个试,这里严谨点肯定不会有问题,概念上也更为正确。反正就是个小程序我打算不深究,这个偷懒更有效。

    PS:外国友人?中文不是用空格断词的,也需要标点符号,文末也不用双斜线的……

  8. noav
    58.33.127.*
    链接

    noav 2012-01-13 00:25:33

    soga.误解了.天朝 //->键盘太小..

    最近也在玩 Mock.

  9. 链接

    张志敏 2012-01-13 02:36:21

    果然是使用Cecil来处理的, Mono 真是一个好东西。

    建议老赵同志装一个ReSharper来试试, 借助ReSharper的代码分析功能, 可以帮助我们写出更加简洁的代码。

  10. 老赵
    admin
    链接

    老赵 2012-01-13 09:41:25

    @张志敏

    公司提供License,其实我是MVP所以也有免费License但是不习惯所以都没装……

  11. earthengine
    27.32.228.*
    链接

    earthengine 2012-01-13 17:37:14

    刚开始用Mono,在Ubuntu的MonoDevelop下编译以下程序通过,但运行出现TypeLoadException

    using System;
    using System.Threading.Tasks;
    using System.Threading;
    
    namespace L
    {
        //这个delegate对应于[谢费尔竖线](http://zh.wikipedia.org/wiki/%E8%B0%A2%E8%B4%B9%E5%B0%94%E7%AB%96%E7%BA%BF)
        delegate void I<A, B>(A a, B b);
    
        //这些空类用于占位,调试时可以区分不同的函数实例
        class D1{}
        class D2{}
        class D3{}
        class D4{}
        class D5{}
        class D6{}
        class D7{}
    
        class MainClass
        {
            //推理规则: (A|(B|C)), A |-- C
            static C Rule<A, B, C>(I<A, I<B, C>> abc, A a){
                var ts = new TaskCompletionSource<C>();
                abc(a, (b, c) => ts.SetResult(c));
                return ts.Task.Result;
            }    
            //公理: (A|(B|C))|((D|(D|D))|((E|B)|((A|E)|(A|E))))
            public static I<I<A, I<B, C>>, I<I<D, I<D, D>>, I<I<E, B>, I<I<A, E>, I<A, E>>>>> Axiom<A, B, C, D, E>(){
                return (abc, dddebaeae) => dddebaeae((d, dd) => dd(d, d),
                    (eb, aeae) => aeae(
                        (a, e) => abc(a, (b, c) => eb(e, b)), 
                        (a, e) => abc(a, (b, c) => eb(e, b))));
    
            }
    
            //一系列推理规则,最终要推出同一律
            static I<I<A, B>, I<I<C, A>, I<C, A>>> ExRule_1<A, B, C, D>(I<C, I<B, D>> cbd){
                return Rule(Axiom<C, B, D, D1, A>(), cbd);
            }
    
            static I<A, B> ExRule_2<A, B, C, D>(I<A, I<C, D>> acd, I<B, C> bc){
                return Rule(ExRule_1<B, C, A, D>(acd), bc);
            }
    
            static I<I<A, B>, C> ExRule_3<A, B, C, D, E>(I<C, I<D, A>> cda, I<D, I<B, E>> dbe){
                return ExRule_2(ExRule_1<A, B, D, E>(dbe), cda);
            }
    
            static I<A, I<B, C>> ExRule_4<A, B, C, D, E, F, G>(I<A, I<D, E>> ade, I<D, I<F, B>> dfb, I<F, I<C, G>> fcg){
                return ExRule_2(ade, ExRule_3(dfb, fcg));
            }
    
            static A ExRule_5<A, B, C, D, E, F, G>(I<B, I<C, D>> bcd, I<C, I<E, F>> cef, I<E, I<A, G>> eag, B b){
                return Rule(ExRule_4(bcd, cef, eag), b);
            }
    
            static A ExRule_6<A, B, C, D>(I<A, I<A, B>> aab, I<C, I<A, D>> cad, C c){
                return ExRule_5(cad, aab, aab, c);
            }
    
            static I<A, I<A, A>> ExRule_7<A, B, C>(I<B, I<I<A, I<A, A>>, C>> bac, B b){
                return ExRule_6(Axiom<A, A, A, A, D2>(), bac, b);
            }
    
            static I<A, I<A, A>> ExRule_8<A, B, C, D>(I<B, I<I<A, C>, D>> bcd){
                return ExRule_7(Axiom<B, I<A, C>, D, A, D3>(), bcd);
            }
    
            //样板定理,同一律(A|(A|A))
            static I<A, I<A, A>> Theorom<A>(){
                return ExRule_8(Axiom<D4, D5, D6, A, D7>());
            }
    
            public static void Main (string[] args)
            {
                Theorom<int>()(10, (i, j) => { System.Console.WriteLine(i+j); });
            }
        }
    }
    

    运行结果为

    Unhandled Exception: System.TypeLoadException: A type load exception has occurred.
      at L.MainClass.Rule[I`2,I`2,I`2] (L.I`2 abc, L.I`2 a) [0x00011] in     /home/joe/Projects/classicallogic/classicallogic/Main.cs:21 
      at L.MainClass.Main (System.String[] args) [0x00006] in /home/joe/Projects/classicallogic/classicallogic/Main.cs:74 
    [ERROR] FATAL UNHANDLED EXCEPTION: System.TypeLoadException: A type load exception has occurred.
      at L.MainClass.Rule[I`2,I`2,I`2] (L.I`2 abc, L.I`2 a) [0x00011] in         /home/joe/Projects/classicallogic/classicallogic/Main.cs:21 
      at L.MainClass.Main (System.String[] args) [0x00006] in /home/joe/Projects/classicallogic/classicallogic/Main.cs:74
    

    同样的程序在Visual Studio 2010下完全没有问题,输出是预期的结果20.

    顺便说一句,这个程序是专门写来验证 Curry–Howard correspondence 的。所验证的逻辑为古典逻辑。

  12. 银光小子
    210.75.15.*
    链接

    银光小子 2012-01-31 12:28:04

    你的博客咋更新得这么慢啊!!!!

  13. 老赵
    admin
    链接

    老赵 2012-01-31 23:56:42

    @银光小子

    看我的GitHub就知道我一直在写代码。

  14. Dozer
    180.169.28.*
    链接

    Dozer 2012-11-27 15:05:46

    老赵你是不是改错文章了?这篇文章和另一篇是一样的,而且文不对题了~

  15. 老赵
    admin
    链接

    老赵 2012-11-28 18:18:59

    @Dozer

    完了……

  16. Dozer
    58.34.156.*
    链接

    Dozer 2012-12-04 23:43:50

    经过实现后,我发现这种方法在某一种场景下会产生诡异的问题。 有2个Public的非虚方法,其中A调用了B。 如果我是为了测试A方法而Mock了B方法,就会出现一个奇怪的问题:我明明用Moq覆盖了B方法,没有报错,但是测试的时候A方法依然调用了基类中的B方法… 然后我给B方法加上了virtual关键字,就正常了…

    后来反编译后发现,虽然用Cecil修改后的B方法和手动加virtual的效果是一样的,但是调用B方法的A方法内部IL代码是不一样的。 不加virtual用Cecil修改的版本,A方法中是IL代码是call;而手动加virtual的时候A方法中的IL代码是callvirt。 所以,在这种场景下,就算成功在子类中override了B方法,A方法依然会调用基类中的B方法… 所以我想,还是老老实实自己加virtual关键字吧~

  17. 老赵
    admin
    链接

    老赵 2012-12-05 11:46:18

    @Dozer

    不知道你是怎么用的,我是在某个项目Post Build里修改的。换句话说,在使用到它的代码被编译之前它已经变成virtual的了,所以应该不会有问题。

    还有真神奇……我还以为都用callvirt了呢……到底啥时用call,啥时用callvirt的呢?

  18. Dozer
    180.169.28.*
    链接

    Dozer 2012-12-06 09:17:26

    我是在 Test项目的 后期生成事件命令行中用的。 https://github.com/dozer47528/MockHelper

  19. 老赵
    admin
    链接

    老赵 2012-12-06 10:50:35

    @Dozer

    这就是你的不对了,应该跟我一样,在被处理的项目编译后调用,其他任何依赖它的代码编译前使用。

  20. Dozer
    180.169.28.*
    链接

    Dozer 2012-12-06 12:05:54

    我这么做是为了只有在Test中修改,别的项目不受影响。 但是,就算在被处理的项目编译后调用不也一样吗?这个项目已经生成 IL 代码了,call 已经是 call 了,不能变成 callvirt 了

  21. 老赵
    admin
    链接

    老赵 2012-12-06 13:22:06

    @Dozer

    也是啊…

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我