15

我从事一个大型平台项目,支持大约 10 种使用我们代码的产品。

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

对于新产品,我们被要求支持较小的功能子集,而无需平台带来的基础设施。我们的架构很旧(从 2005 年左右开始编码)但相当可靠。

我们有信心在我们现有的课程中使用 DI 来做到这一点,但预计这样做的时间从 5 到 70 周不等,具体取决于您与谁交谈。

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

4

7 回答 7

3

我假设您正在寻找使用诸如 StructureMap、Funq、Ninject 等 IoC 工具。

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

我将首先关注类似设置的依赖项(应该是简单的值对象),然后开始使用您的 IoC 工具进行解析调用。接下来,创建工厂类并注入它们来管理对象的生命周期。感觉就像你在倒退(而且很慢),直到你到达你的大多数物体都在使用 DI 的顶部,并且必然是 SRP - 从那里它应该是下坡。一旦你有更好的关注点分离,你的代码库的灵活性和你可以做出改变的速度将大大增加。

提醒一句:不要自欺欺人地认为在任何地方都撒上“服务定位器”是你的灵丹妙药,它实际上是一种DI 反模式。我认为您首先需要使用它,但随后您应该使用构造函数或设置器注入完成 DI 工作并删除服务定位器。

于 2013-05-22T19:50:06.650 回答
3

感谢所有的答复。我们现在已经快一年了,我想我基本上可以回答我自己的问题。

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

我们的重点是在不带来不需要的依赖项的情况下使这些部分可用,而不是启用对它们的单元测试。这会影响您处理问题的方式。在这种情况下,没有真正的设计更改。

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

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

  2. 创建您需要删除的所有依赖项的列表。按功能分组。在大多数情况下,我们能够一次处理多个相关的依赖项。

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

  4. 首先摆脱单例:在将它们从这种模式中转换出来之后,提取一个接口(resharper -> 重构 -> 提取接口)删除单例访问器以获取构建错误列表。进入第 6 步。

  5. 为了摆脱其他参考:提取界面如上。湾。注释掉原来的实现。这将为您提供构建错误列表。

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

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

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

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

于 2013-12-06T04:47:32.063 回答
1

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

我不愿提及它,因为我不想给人这样的印象,即像 nDepend 这样的工具是承担这个项目所必需的。但是,可视化代码库中的依赖关系会很有帮助。它附带 14 天的全功能试用版,可能足以满足您的需求。

于 2013-06-03T14:43:09.237 回答
0

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

因为->

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

  • 使用接口/抽象类。再次在这里,应该采取正确的选择来简化转换,同时牢记 DI 原则和代码功能。

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

于 2013-01-16T10:34:54.453 回答
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);
   }
}

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

假货没有做任何聪明的事——没有参数验证,没有 I/O。它只是坐在那里,以便 FileRepository 可以保存任何工作的内容。它的目的有两个;显着加快单元测试并且不破坏系统状态。

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


因此,随着基础知识的结束 - 你如何处理这个?

在需要大量重构的大型项目中,最好将单元测试合并到任何经历 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);
}

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

我能给出的唯一其他建议是一次做 1 个数据源并坚持做。抵制一次做太多事情的诱惑。如果您真的不得不一键保存到文件、数据库和 Web 服务,请使用提取接口,伪造调用并且不返回任何内容。一口气做很多事情是一种真正的杂耍行为,但是与从第一原则开始相比,您可以更容易地将它们放回原处。

祝你好运!

于 2013-05-08T12:23:33.703 回答
0

这本书可能会很有帮助:

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

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

于 2013-05-25T04:22:54.147 回答
0

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

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

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

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

于 2013-06-10T14:35:59.740 回答