重构大型项目的DI

时间:2013-01-11 17:42:34

标签: c# dependency-injection refactoring dependencies code-injection

我在一个大型平台项目上工作,支持大约10个使用我们代码的产品。

到目前为止,所有产品都使用了我们平台的全部功能:
- 从数据库中检索配置数据
- 远程文件系统访问
- 安全授权
- 基本逻辑(我们付钱提供的东西)

对于新产品,我们被要求在没有平台带来的基础设施的情况下支持较小的功能子集。我们的架构很旧(从2005年左右开始编码)但相当稳固。

我们相信我们可以在我们现有的课程中使用DI来做到这一点,但估计的时间从5到70周不等,取决于你与谁交谈。

有很多文章告诉你如何做DI,但我找不到任何告诉你如何以最有效的方式重构DI?是否有工具可以执行此操作而不必通过30.000行代码并且必须按CTRL + R来移除接口并将它们添加到构造函数中太多次? (如果有帮助,我们有resharper)如果没有,你发现什么是快速实现这一目标的理想工作流程?

7 个答案:

答案 0 :(得分:3)

我假设您正在使用像StructureMap,Funq,Ninject等IoC工具。

在这种情况下,重构的工作实际上始于更新代码库中的入口点(或Composition Roots)。这可能会产生很大的影响,特别是如果您正在普遍使用静态和管理对象的生命周期(例如缓存,延迟加载)。一旦你有一个IoC工具并连接了对象图,你就可以开始分配你对DI的使用并享受其中的好处。

我首先关注类似设置的依赖项(应该是简单的值对象)并开始使用IoC工具进行解析调用。接下来,查看创建Factory类并注入它们以管理对象的生命周期。感觉你会向后(并且缓慢),直到你到达大多数物体正在使用DI的顶部,并且随心所欲地SRP - 从那里它应该是下坡。一旦您更好地分离了关注点,您的代码库的灵活性和您可以进行更改的速度将会大幅提升。

谨慎提醒:不要让自己陷入困境,想到在任何地方洒上“服务定位器”是你的灵丹妙药,它实际上是DI antipattern。我认为你首先需要使用它但是你应该使用构造函数或setter注入完成DI工作并删除服务定位器。

答案 1 :(得分:3)

感谢所有回复。我们现在差不多一年了,我想我可以回答我自己的问题。

正如lasseeskildsen指出的那样,我们当然只转换了我们平台中要在新产品中重复使用的部分。由于这只是代码库的部分转换,我们采用了DIY方法来依赖注入。

我们的重点是使这些部件可用而不会带来不必要的依赖性,而不是对它们进行单元测试。这会对您解决问题的方式产生影响。在这种情况下,没有真正的设计变更。

所涉及的工作是平凡的,因此问题是如何快速甚至自动地这样做。 答案是它不能自动化,但使用一些键盘快捷键和resharper它可以很快完成。对我来说,这是最佳流程:

  1. 我们致力于多种解决方案。我们创建了一个临时“主”解决方案,其中包含所有解决方案文件中的所有项虽然重构工具并不总是足够聪明,无法理解二进制和项目引用之间的差异,但至少可以使它们在多个解决方案中部分工作。

  2. 创建需要剪切的所有依赖项的列表。按功能分组。在大多数情况下,我们能够同时处理多个相关的依赖关系。

  3. 您将在许多文件中进行许多小代码更改。此任务最好由单个开发人员完成,最多两个,以避免不断合并您的更改。

  4. 首先摆脱单身人士: 将它们转换为远离此模式后,提取界面(resharper - > refactor - > extract interface) 删除单件访问器以获取构建错误列表。到第6步。

  5. 为了摆脱其他参考: 一个。如上提取界面。 湾评论原始实施。这会为您提供构建错误列表。

  6. Resharper现在成为一个很大的帮助:

    • Alt + shift + pg down / up可快速浏览损坏的参考文献。
    • 如果多个引用共享一个公共基类,请导航到其构造函数并单击ctrl + r + s(“更改方法签名”)以将新接口添加到构造函数。 Resharper 8为您提供了“通过调用树解析”的选项,这意味着您可以使继承类自动更改其签名。这是一个非常简洁的功能(似乎是版本8中的新功能)。
    • 在构造函数体中将注入的接口分配给不存在的属性。按alt + Enter选择“创建属性”,将其移动到需要的位置,然后就完成了。从5b取消注释代码。
  7. 测试!冲洗并重复。

  8. 为了在没有重大代码更改的原始解决方案中使用这些类,我们创建了重载的构造函数,通过服务定位器检索它们的依赖关系,正如Brett Veenstra所提到的那样。这可能是反模式,但适用于此方案。在所有代码都支持DI之前,它不会被删除。

    我们将大约四分之一的代码转换为DI,大约需要2-3周(1.5人)。 再过一年,我们现在将所有代码都转换为DI。这是一个不同的情况,因为焦点转移到单元可测试性。我认为上面的一般步骤仍然有效,但这需要一些实际的设计变更来强制执行SOC。

答案 2 :(得分:1)

你问过工具。可能有助于像这样的大型重构的一个工具是nDepend。我用它来帮助确定目标重构工作的地方。

我毫不犹豫地提到它,因为我不想给人一种像nDepend这样的工具来承担这个项目的印象。但是,可视化代码库中的依赖项是有帮助的。它配备了为期14天的全功能试用版,可能足以满足您的需求。

答案 3 :(得分:0)

不要认为有任何工具可以进行代码转换。

因为 - >

在现有代码库中使用DI会涉及,

  • 使用interface / abstract class。再次,这应该采取正确的措施,以保持转换保持DI原则&代码功能。

  • 有效隔离/统一多个/单个类中的现有类,以保持代码模块化或小型可恢复单元。

答案 4 :(得分:0)

我接近转换的方式是查看永久修改状态的系统的任何部分;文件,数据库,外部内容。一旦改变并重新阅读,它是否变好了?这是第一个要改变它的地方。

所以你要做的第一件事是找到一个修改这样的来源的地方:

class MyXmlFileWriter
{
   public bool WriteData(string fileName, string xmlText)
   {   
      // TODO: Sort out exception handling
      try 
      {
         File.WriteAllText(fileName, xmlText);  
         return true; 
      } 
      catch(Exception ex) 
      { 
         return false; 
      }
   }
}

其次,您编写单元测试以确保在重构​​时不会破坏代码。

[TestClass]
class MyXmlWriterTests
{
   [TestMethod]
   public void WriteData_WithValidFileAndContent_ExpectTrue()
   {
      var target = new MyXmlFileWriter();
      var filePath = Path.GetTempFile();

      target.WriteData(filePath, "<Xml/>");

      Assert.IsTrue(File.Exists(filePath));
   }

   // TODO: Check other cases
}

接下来,从原始类中提取接口:

interface IFileWriter
{
   bool WriteData(string location, string content);
}

class MyXmlFileWriter : IFileWriter 
{ 
   /* As before */ 
}

重新进行测试并希望一切顺利。保持原始测试,因为它检查您的旧实现是否有效。

接下来写一个什么都不做的虚假实现。我们只想在这里实现一个非常基本的行为。

// Put this class in the test suite, not the main project
class FakeFileWriter : IFileWriter
{
   internal bool WriteDataCalled { get; private set; }

   public bool WriteData(string file, string content)
   {
       this.WriteDataCalled = true;
       return true;
   }
}

然后进行单元测试......

class FakeFileWriterTests
{
   private IFileWriter writer;

   [TestInitialize()]
   public void Initialize()
   {
      writer = new FakeFileWriter();
   }

   [TestMethod]
   public void WriteData_WhenCalled_ExpectSuccess()
   {
      writer.WriteData(null,null);
      Assert.IsTrue(writer.WriteDataCalled);
   }
}

现在,经过单元测试和重构的版本仍然有效,我们需要确保在注入时,调用类使用的是接口,而不是具体的版本!

// Before
class FileRepository
{
   public FileRepository() { }

   public void Save( string content, string xml )
   {
      var writer = new MyXmlFileWriter();
      writer.WriteData(content,xml);
   }
}

// After
class FileRepository
{
   private IFileWriter writer = null;

   public FileRepository() : this( new MyXmlFileWriter() ){ }
   public FileRepository(IFileWriter writer) 
   {
      this.writer = writer;
   }

   public void Save( string path, string xml)
   {
      this.writer.WriteData(path, xml);
   }
}

那我们做了什么?

  • 拥有使用普通类型的默认构造函数
  • 拥有一个采用IFileWriter类型
  • 的构造函数
  • 使用实例字段来保存引用的对象。

然后是为FileRepository编写单元测试并检查方法被调用的情况:

[TestClass]
class FileRepositoryTests
{
   private FileRepository repository = null;

   [TestInitialize()]
   public void Initialize()
   {
    this.repository = new FileRepository( new FakeFileWriter() );
   }

   [TestMethod]
   public void WriteData_WhenCalled_ExpectSuccess()
   {
       // Arrange
       var target = repository;

       // Act
       var actual = repository.Save(null,null);

       // Assert
       Assert.IsTrue(actual);
   }
}

好的,但在这里,我们是否真的在测试FileRepositoryFakeFileWriter?我们正在测试FileRepository,因为我们的其他测试正在单独测试FakeFileWriter。这个类 - FileRepositoryTests对于测试空值的传入参数会更有用。

假货没有做任何聪明的事 - 没有参数验证,没有I / O.它只是坐在一起,以便FileRepository可以保存内容任何工作。它的目的是双重的;加快单元测试速度,不破坏系统状态。

如果此FileRepository也必须读取该文件,您也可以实现一个IFileReader(这有点极端),或者只是将最后写入的filePath / xml存储到内存中的字符串并检索它。 / p>


所以有了基础知识 - 你如何处理这个问题?

在需要大量重构的大型项目中,最好将单元测试合并到任何经历过DI更改的类中。理论上,您的数据不应该[在您的代码中]提交到数百个位置,而是通过几个关键位置。在代码中找到它们并为它们添加接口。我使用的一个技巧是隐藏每个数据库或类似索引的源代码,如下所示:

interface IReadOnlyRepository<TKey, TValue>
{
   TValue Retrieve(TKey key);
}

interface IRepository<TKey, TValue> : IReadOnlyRepository<TKey, TValue>
{
   void Create(TKey key, TValue value);
   void Update(TKey key, TValue);
   void Delete(TKey key);
}

这使您能够以非常通用的方式从数据源中检索。您只需替换注入位置即可从XmlRepository切换到DbRepository。这对于从一个数据源迁移到另一个数据源而不影响系统内部的项目非常有用。可以轻易地将XML操作更改为使用对象,但使用此方法维护和实现新功能要容易得多。

我能给出的唯一其他建议是一次做1个数据源并坚持下去。抵制一次性做太多的诱惑。如果您真的最终需要一次性保存到文件,数据库和Web服务,请使用Extract Interface,伪造调用并返回任何内容。一次性做很多事情是一个真正的杂耍行为,但你可以比从第一原则开始更容易地将它们重新插入。

祝你好运!

答案 5 :(得分:0)

这本书可能会非常有用:

有效地使用遗产代码 - Michael C. Feathers - http://www.amazon.com/gp/product/0131177052

我建议从小改动开始。逐渐移动依赖项以通过构造函数注入。始终保持系统正常运行。从构造函数注入的依赖项中提取接口,并使用单元测试开始包装。在有意义的时候带上工具。您不必立即开始使用依赖注入和模拟框架。您可以通过构造函数手动注入依赖项来进行大量改进。

答案 6 :(得分:0)

你所描述的是这个过程的重要部分;浏览每个类,创建一个接口,并注册它。如果您立即提交重构到组合根,这是最成问题的,在MVC的情况下,这意味着假设您要注入控制器。

这可能是很多工作,如果代码执行了很多直接对象创建,那么尝试一次完成所有操作可能会非常复杂。在这些情况下,我认为使用服务定位器模式并手动调用resolve是可以接受的。

首先用服务定位器解析调用替换一些对构造函数的直接调用。这将降低最初需要的重构量,并开始为您提供DI的好处。

随着时间的推移,您的呼叫将越来越接近组合根,然后您可以开始删除服务定位器的使用。