我该把所有这些接口放在哪里?

34

我正在尝试进行单元测试。目前我不习惯为类编写接口,除非我预见到需要替换其他实现。嗯,现在我预见到了一个原因:模拟。

考虑到我要从一些接口增加到可能有数百个接口,首先进入我的脑海的是:这些接口应该放在哪里呢?我是将它们与所有具体实现混合在一起还是将它们放在子文件夹中。例如,控制器接口应该放在根/控制器/接口、根/控制器还是完全不同的位置?你有何建议?

6个回答

24

在我讨论组织之前:

好了,现在我预见到一个原因:模拟。

你也可以使用类进行模拟。子类化作为一种选项对于模拟非常有效,而不是总是制作接口。

接口非常有用 - 但我建议只有在需要制作接口时才制作接口。 我经常看到在逻辑上一个类可以很好地工作并且更合适时仍然创建接口。 你不应该需要制作“数百个接口”来允许自己模拟实现 - 封装和子类化非常适用于此。

话虽如此 - 我通常会将我的接口与我的类一起组织,因为将相关类型分组到相同的命名空间中似乎是最明智的选择。主要例外情况是接口的内部实现 - 这些可以放在任何地方,但是我有时会制作一个“Internal”文件夹+一个专门用于“私有”接口实现的Internal命名空间(以及其他纯内部实现的类)。这有助于使主命名空间保持整洁,所包含的类型仅限于与API本身相关的主类型。


谢谢,Reed,+1。我很感激你对不总是需要接口来模拟的评论。我想我有点不舒服,因为我觉得我需要将所有公共方法标记为“virtual”(因为这似乎是moq所需的)。我在这里读到了一些答案,给我留下了过度使用“virtual”可能是个坏主意的印象。但也许这不是一个重大问题。你能对此发表评论吗? - devuxer
@DanM:相反,如果您使用接口,则将有效地执行相同的操作。任何接口都将强制使用callvirt。我同意它有其适用的时间和场合,但模拟往往会产生副作用(不幸的是)。 - Reed Copsey
1
忽略callvirt问题,到处使用virtual确实是一个设计问题,因为它可能允许类修改其他类的现有行为。而使用接口时,由于没有关联的行为,你并不会遇到这种情况。 - Matt H
@Matt H:没错 - 不过如果你在传递一个接口,实际上你就允许类被注入以替换行为。如果你想要能够模拟整个类,你在某种程度上别无选择,只能允许这种情况的发生。 - Reed Copsey
我不同意的另一种做法是,在使用 DI 和“服务提供程序”时,在组合根中声明所有接口。因为组合根将设置所有必需的依赖项,如果您想在多个项目之间重用接口,则这更有意义。 - Morten Bork

17

这里有一个建议,如果几乎所有的接口都只支持一个类,将接口添加到与该类相同的命名空间下的同一文件中。这样,您就不需要为接口单独创建一个文件,这可能会混乱项目或需要子文件夹来存放接口。

如果您发现自己使用相同接口创建不同的类,我会将接口拆分到与类相同的文件夹中,除非它变得非常混乱。但我不认为会发生这种情况,因为我怀疑您是否在同一个文件夹中有数百个类文件。如果是这样,应该根据功能进行整理和子文件夹分类,剩下的问题就会得到解决。


十年过去了,我坐在一个项目前面,其中每个类都有一个接口(出于其他答案中列出的各种良好原因)。它们之所以放在不同的文件中只是因为一条代码风格检查规则... - Yarek T

2
编写代码以接口为基础不仅仅能够用于测试代码。它也可以在产品需求变化时灵活地替换实现。
依赖注入是编写代码以接口为基础的另一个好处。
如果我们有一个名为Foo的对象被十个客户使用,现在客户x希望以不同的方式使用Foo。如果我们已经编写了一个接口(IFoo),我们只需要按照新要求在CustomFoo中实现IFoo。只要我们不更改IFoo,就不需要做太多的修改。客户x可以使用新的CustomFoo,其他客户可以继续使用旧的Foo,并且需要进行很少的其他代码更改以适应此更改。
然而,我真正想要强调的是,接口可以帮助消除循环引用。如果我们有一个对象X,它依赖于对象Y,而对象Y又依赖于对象X。我们有两个选项:1.对象x和y必须在同一程序集中,或者2.我们必须找到某种方法来打破循环引用。我们可以通过共享接口而不是共享实现来实现这一点。
/* Monolithic assembly */
public class Foo
{
    IEnumerable <Bar> _bars;
    public void Qux()
    {
       foreach (var bar in _bars)
       {
           bar.Baz();
       }

    }
    /* rest of the implmentation of Foo */
}

public class Bar
{
    Foo _parent;
    public void Baz()
    {
    /* do something here */
    }
    /* rest of the implementation of Bar */
}

如果foo和bar有完全不同的用途和依赖关系,那么我们可能不希望它们在同一个程序集中,尤其是如果该程序集已经很大了。

为了做到这一点,我们可以在其中一个类上创建一个接口,比如说Foo,并在Bar中引用该接口。现在,我们可以将接口放入第三个程序集中,由FooBar共享。

Original Answer: 最初的回答

/* Shared Foo Assembly */
public interface IFoo
{
    void Qux();
}

/* Shared Bar Assembly (could be the same as the Shared Foo assembly in some cases) */
public interface IBar
{
    void Baz();
}
/* Foo Assembly */
 public class Foo:IFoo
{
    IEnumerable <IBar> _bars;
    public void Qux()
    {
       foreach (var bar in _bars)
       {
           bar.Baz();
       }

    }
    /* rest of the implementation of Foo */
}
/* Bar assembly */
public class Bar:IBar
{
    IFoo _parent;
    /* rest of the implementation of Bar */
    public void Baz()
    {
        /* do something here */
}

我认为保持接口与实现分离并在发布周期中稍微有所不同也有其优点,因为这样可以允许在没有全部编译为相同源代码的情况下,在组件之间进行互操作。如果完全按照接口编码,并且接口只能在主版本增量而不是次版本增量上更改,则同一主版本的任何组件都应该与同一主版本的任何其他组件配合使用,而不考虑次版本。这样,您可以拥有一个库项目,其中包含仅接口、枚举和异常,并具有缓慢的发布周期。
"最初的回答"

1
我发现当我需要在我的项目中隔离依赖关系时,如果我需要数百个接口,那么我的设计可能存在问题。特别是当很多这些接口最终只有一个方法时,这种情况尤其明显。另一种方法是让你的对象引发事件,然后将你的依赖关系绑定到这些事件上。例如,假设你想模拟持久化数据,那么一个完全合理的方法是这样做:
public interface IDataPersistor
{
    void PersistData(Data data);
}

public class Foo
{
    private IDataPersistor Persistor { get; set; }
    public Foo(IDataPersistor persistor)
    {
        Persistor = persistor;
    }

    // somewhere in the implementation we call Persistor.PersistData(data);

}

如果不使用接口或模拟,您可以另一种方法来完成这个:

public class Foo
{
    public event EventHandler<PersistDataEventArgs> OnPersistData;

    // somewhere in the implementation we call OnPersistData(this, new PersistDataEventArgs(data))
}

然后,在我们的测试中,您可以不创建模拟对象而执行以下操作:

Foo foo = new Foo();
foo.OnPersistData += (sender, e) => { // do what your mock would do here };

// finish your test

我认为这比过度使用模拟更加简洁。


不错的想法,但通过可空编译器检查,你会发现必须在构造函数中注入处理程序,或者使其可为空(这对逻辑可能没有意义),从而使你重新需要一个新的接口。 - Yarek T

1

这要看情况。我这么做:如果必须添加一个依赖的第三方程序集,将具体的版本移到不同的类库中。如果不需要,它们可以保留在同一目录和命名空间中。


0

我对被接受的答案有相当大的不同意见。

1:虽然从技术上讲是正确的,但你并不需要一个接口,因为你可以选择模拟一个具体的实现,但你应该出于两个原因而创建一个接口。

你可以通过接口扩展你的代码,具体的实现需要修改,如果你没有扩展,一旦你得到一个变更请求,就会很麻烦。

1.1: 只要你创建接口来测试,你就可以进行TDD(测试驱动开发),而不需要任何实际的实现代码。这也会迫使你在实现之前考虑代码设计。这是一种优秀的编码方法。

1.2:

我建议只有在有理由的情况下才制作接口。我经常看到创建接口时,类可以很好地工作并且在逻辑上更合适。
总是有理由制作接口。因为SOLID的开闭原则说你应该尽量扩展你的代码而不是修改它。这对于多种原因都是正确的。
1.2.1: 这样做更容易编写新的单元测试。您只需要在代码中将依赖项设置为正在测试的具体实现作为主题。(在您拥有具体实现之前,您可以使用模拟)
1.2.2: 当您拥有具体实现时,对该具体实现的引用将在整个系统中传播。使用接口,所有引用都将通过接口而不是具体实现完成。这使得扩展成为可能。
1.2.3: 如果您跟进所有“叶子”代码,遵循该原则,如果方法具有返回值,则该方法不能具有副作用;如果方法没有返回值,则它只能具有1个副作用,您还将自动将代码分解为SOLID的“S”部分,这使得您的单元测试小而非常容易维护。

2: 如果你想写出干净的代码,技术上是需要接口的。如果你想遵循SOLID原则,我不知道你怎么能没有接口来实现。

当你分解职责时,你还需要高效地组织你的代码,因为代码解耦得越多,你就会有越多的接口和接口的实现。因此,你需要一个良好的项目管理系统,这样你就不会随意地拥有“成百上千个接口”。

在书籍、YouTube、Udemy等地方有很多非常好的指南可以教你这些知识(当然也有一些质量较差的,通常情况下,付费的指南更有用)。在做出商业决策之前,你必须对主题有足够的了解,以判断免费的指南是否足够好。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接