使用ThreadStatic而不是DI容器有什么问题

时间:2012-05-24 17:34:27

标签: .net unit-testing dependency-injection

我正在尝试制作一个非常大的,非常遗留的项目。

我们的大部分代码都使用了许多静态可用的服务。问题是这些很难嘲笑。他们曾经是单身人士。现在它们是伪单体 - 相同的静态接口,但是函数委托给可以切换的实例对象。像这样:

class ServiceEveryoneNeeds
{
    public static IImplementation _implementation = new RealImplementation();

    public IEnumerable<FooBar> GetAllTheThings() { return _implementation.GetAllTheThings(); }
}

现在在我的单元测试中:

void MyTest()
{
    ServiceEveryoneNeeds._implementation = new MockImplementation();
}

到目前为止,这么好。在prod中,我们只需要一个实现。但测试并行运行,可能需要不同的模拟,所以我这样做了:

class Dependencies
{
     //set this in prod to the real impl
     public static IImplementation _realImplementation;

     //unit tests set these
     [ThreadStatic]
     public static IImplementation _mock;

     public static IImplementation TheImplementation
     { get {return _realImplementation ?? _mock; } }

     public static void Cleanup() { _mock = null; }
}

然后:

class ServiceEveryoneNeeds
{
     static IImplementation GetImpl() { return Dependencies.TheImplementation; }

     public static IEnumerable<FooBar> GetAllTheThings() {return GetImpl().GetAllTheThings(); }

}

//and
void MyTest()
{
    Dependencies._mock = new BestMockEver();
    //test
    Dependencies.Cleanup();
}

我们选择了这条路线,因为这是一个庞大的构建工程项目,将这些服务注入到需要它们的每个类中。同时,这些是我们的代码库中的大多数功能所依赖的通用服务。

我理解这种模式在隐藏依赖关系的意义上是不好的,而构造函数注入则使依赖关系显式化。

然而,好处是:
    - 我们可以立即开始单元测试,而不是进行3个月的重构,然后进行单元测试     - 我们仍然有全局变量,但这似乎比我们现在更好。

虽然我们的依赖关系仍然是隐含的,但我认为这种方法比我们的方法更好。除了隐藏的依赖关系,这在某种程度上比使用适当的DI容器更糟糕吗?我会遇到什么问题?

4 个答案:

答案 0 :(得分:4)

它是service locator which is bad。但你已经知道了。如果您的代码库很大,为什么不开始部分迁移?使用容器注册单例实例,并在触摸代码中的类时启动构造函数注入它们。然后,您可以将大多数部件置于(希望)工作状态,并在其他任何地方获得DI的好处。

理想情况下,没有DI的部件会随着时间的推移而缩小。你可以马上开始测试。

答案 1 :(得分:4)

这称为ambient context。如果正确使用和实现,使用环境上下文没有任何问题。当可以使用环境上下文时,有一些先决条件:

  1. 它必须是一个跨领域的关注点,它会返回一些值
  2. 您需要本地默认
  3. 您必须确保无法分配null。 (使用Null implementation代替)
  4. 对于不返回值的横切问题,例如记录你应该更喜欢拦截。对于非交叉问题的其他依赖项,您应该进行构造函数注入。

    您的实现有几个问题(不会阻止分配null,命名,没有默认值)。以下是如何实现它:

    public class SomeCrossCuttingConcern
    {
         private static ISomeCrossCuttingConcern default = new DefaultSomeCrossCuttingConcern();
    
         [ThreadStatic]
         private static ISomeCrossCuttingConcern current;
    
         public static ISomeCrossCuttingConcern Default
         { 
             get { return default; }
             set 
             { 
                 if (value == null) 
                     throw new ArgumentNullException(); 
                 default = value; 
             } 
         }
    
         public static ISomeCrossCuttingConcern Current
         { 
             get 
             { 
                 if (current == null)
                     current = default; 
                 return current; 
             }
    
             set 
             { 
                 if (value == null) 
                     throw new ArgumentNullException(); 
                 current = value; 
             } 
         }
    
         public static void ResetToDefault() { current = null; }
    }
    

    环境上下文具有以下优势:您不会因为交叉问题而污染API。

    但另一方面,关于测试,测试可能会变得依赖。例如。如果您忘记为一个测试设置模拟,如果之前通过另一个测试设置了模拟,它将正确运行。但是当它独立运行或以不同的顺序运行时,它将失败。它使测试更加困难。

答案 2 :(得分:1)

依赖注入和使用DI容器实际上是独立的事业,尽管一方自然而然地引导另一方。使用DI容器意味着代码具有特定结构。这样的结构可能更容易阅读,如果不深入了解隐藏的依赖关系,肯定更容易处理,因此更易于维护。

既然您不再依赖于具体结果,那么您已经实施了一种控制反转形式。我认为这是一个更好的设计,并且代表了使代码更易于测试的良好起点。听起来你从这一步中获得了一些直接的价值。

与隐式依赖关系(换句话说DI与环境上下文)相比,您是否更好地拥有显式依赖关系?我倾向于说是,但这实际上取决于成本与收益。好处取决于诸如引入错误的成本,您可能在代码中看到多少流失,调试有多难,谁将维护它,它的预期寿命等等。

全局可变静态总是很糟糕。一些聪明的灵魂可能会决定他们在打电话时需要换掉全球服务的实施,然后再替换它。如果他们之后没有清理,这可能会出现严重错误。这可能是一个愚蠢的例子,但是这种无意的副作用总是很糟糕,所以最好通过设计完全消除它们。你可以通过纪律和警惕来预防他们,但这更难。

答案 3 :(得分:1)

我认为你所做的事情并不坏。您正试图使您的代码库可测试,并且诀窍是在很短的步骤内完成。阅读Working Effectively With Legacy Code时,您会得到同样的建议。然而,你正在做的事情的缺点是,一旦你开始使用依赖注入,你将不得不再次重构你的代码库。但更重要的是,您将不得不更改大量测试代码。

我同意Alex的观点。更喜欢使用构造函数注入而不是使用环境上下文。你没有必要直接重构你的整个代码库,但是构造函数注入会在调用堆栈中“冒泡”,你必须做一些“切割”以防止它冒泡,因为这迫使你做整个代码库中有很多变化。

我目前在遗留代码库上工作,不能使用DI容器(痛苦)。我仍然在可以使用构造函数注入,这有时意味着我必须恢复在某些类型上使用poor mans dependency injection。这是我用来阻止'构造函数注入气泡'的技巧。不过,这比使用环境上下文要好得多。穷人的DI是次优的,但仍允许你编写适当的单元测试,以便以后更容易打破默认构造函数。