使用接口或类进行依赖注入

44

在使用依赖注入时,我曾经犯过将接口与具体类之间的关系设为一对一的错误。当我需要向接口添加一个方法时,就会破坏所有实现该接口的类。

这只是个简单例子,但假设我需要向其中一个类注入ILogger

public interface ILogger
{
    void Info(string message);
}

public class Logger : ILogger
{
    public void Info(string message) { }
}

像这样拥有一个一对一的关系感觉就像是代码异味。由于我只有一个实现,如果我创建一个类并将Info方法标记为虚拟以在我的测试中覆盖它,而不是只为单个类创建一个接口,是否存在任何潜在问题?

public class Logger
{
    public virtual void Info(string message)
    {
        // Log to file
    }
}

如果我需要另一个实现,我可以重写 Info 方法:

public class SqlLogger : Logger
{
    public override void Info(string message)
    {
        // Log to SQL
    }
}

如果每个类都具有特定的属性或方法会导致泄漏的抽象,那么我可以提取出一个基类:

public class Logger
{
    public virtual void Info(string message)
    {
        throw new NotImplementedException();
    }
}

public class SqlLogger : Logger
{
    public override void Info(string message) { }
}

public class FileLogger : Logger
{
    public override void Info(string message) { }
}
我之所以没有将基类标记为抽象类,是因为如果我后续需要添加其他方法,就不会破坏现有的实现。例如,如果我的FileLogger需要一个Debug方法,我可以更新基类Logger而不会破坏现有的SqlLogger
public class Logger
{
    public virtual void Info(string message)
    {
        throw new NotImplementedException();
    }

    public virtual void Debug(string message)
    {
        throw new NotImplementedException();
    }
}

public class SqlLogger : Logger
{
    public override void Info(string message) { }
}

public class FileLogger : Logger
{
    public override void Info(string message) { }
    public override void Debug(string message) { }
}

再次说明,这只是一个简单的例子,但在什么情况下我应该优先选择接口呢?


1
我没有将基类标记为抽象的原因是,如果我想要添加另一个方法,那么就无法实例化该类。嗯,抽象类可以包含实现。您可以将 Debug 方法添加到抽象 Logger 类中。 - Anton Sizikov
破坏现有实现只有在编写可重用库时才是问题。你是吗?还是你只是在编写一行业务应用程序? - Steven
这不是问题的范围,但是继承被高估了。SqlLogger只是一个具体的Logger,带有SqlLogPersistenceStrategy。在大多数情况下,组合比继承更好。对于你的问题,接口隔离原则怎么样?ILogInfoILogError等等。 - plalx
4个回答

35

“快速”答案

我建议使用接口,它们是为了外部实体的消费而设计的契约。

@JakubKonecki提到了多重继承。我认为这是坚持使用接口的最大原因,如果你强制让消费者采取基类,他们会非常不满意。

更新的“快速”答案

你已经说明了你无法控制接口实现的问题。一个好的方法是创建一个新接口继承旧接口并修改自己的实现。然后你可以通知其他团队有一个新的接口可用。随着时间的推移,你可以废弃旧的接口。

别忘了你可以使用显式接口实现来帮助保持逻辑上相同但版本不同的接口之间的良好分离。

如果你想让这一切与 DI 配合使用,那么尽量不要定义新接口,而是更喜欢添加。或者为了限制客户端代码的更改,尝试从旧接口继承新接口。

实现 vs. 消费

实现接口和消费接口之间有区别。添加一个方法会破坏实现,但不会破坏消费。

移除一个方法会破坏消费,但不会破坏实现——但是如果你考虑到你的消费者的向后兼容性,你不会这么做。

我的经验

我们经常与接口建立一对一的关系。这在很大程度上是形式主义,但有时候接口也可以派上用场,因为我们使用存根/模拟测试实现,或者我们确实提供了客户端特定的实现。在我看来,如果我们改变接口时频繁地破坏一个实现,这并不是代码异味,而是如何使用接口的简单体现。

我们的基于接口的方法现在为我们利用工厂模式和 DI 的元素来改善古老的遗留代码库提供了良好的支持。在找到“明确”的用途(即不仅仅是与具体类进行一对一映射)之前,测试能够迅速利用这些接口存在于代码库中多年的事实。

基类缺点

基类是用来共享实现细节给公共实体,它们能够使用公共 API 其实是我的一种副产品。接口的设计目的是公开共享 API,因此要使用它们。

使用基类可能会导致实现细节泄漏,例如如果你需要为实现的其他部分使某些内容公开。这些都不利于保持干净的公共 API。

破坏/支撑实现

如果你走接口路线,由于破坏了契约,你可能会遇到改变接口时的困难。另外,正如你所提到的,你可能会破坏掉其他团队的实现。解决这个问题有两种方法:

  1. 声明不破坏消费者,但不支持实现。
  2. 声明
    public interface IGetNames
    {
        List<string> GetNames();
    }
    
    // One option is to redefine the entire interface and use 
    // explicit interface implementations in your concrete classes.
    public interface IGetMoreNames
    {
        List<string> GetNames();
        List<string> GetMoreNames();
    }
    
    // Another option is to inherit.
    public interface IGetMoreNames : IGetNames
    {
        List<string> GetMoreNames();
    }
    
    // A final option is to only define new stuff.
    public interface IGetMoreNames 
    {
        List<string> GetMoreNames();
    }
    

我遇到的一个困难是当这个接口在企业中共享时。如果我更新一个接口,它会破坏所有其他使用此接口的团队的实现。目前,我们正在强制对他们进行这些更改。在完美的世界里,每个人都会乐意更新他们的实现,但并非总是如此。 - nivlam
如果您无法更改接口,可以创建另一个接口:interface IDebuger { void Debug (string message); } 并在 FileLogger 中实现它。因此,如果其他团队不需要它,则不会使用和实现它。 - Anton Sizikov
@nivlam 另一种选择是,一旦创建了接口,它就无法更改。创建继承旧接口的新接口,然后实现可以选择性地实现新接口...这意味着只需更改您的内部实现即可。我已经更新了我的答案以适应。 - Adam Houldsworth

12

当您在ILogger接口中除了Info方法之外还添加DebugErrorCritical方法时,会破坏接口隔离原则。看一下可怕的Log4Net ILog接口,你就会明白我在说什么了。

不要为每个日志级别创建一个方法,而是创建一个接受日志对象的单个方法:

void Log(LogEntry entry);

这将完全解决您所有的问题,因为:

  1. LogEntry 将成为一个简单的 DTO,您可以添加新属性而不会破坏任何客户端代码。
  2. 您可以为 ILogger 接口创建一组扩展方法,它们映射到单个 Log 方法。

以下是此类扩展方法的示例:

public static class LoggerExtensions
{
    public static void Debug(this ILogger logger, string message)
    {
        logger.Log(new LogEntry(message)
        {
            Severity = LoggingSeverity.Debug,
        });
    }

    public static void Info(this ILogger logger, string message)
    {
        logger.Log(new LogEntry(message)
        {
            Severity = LoggingSeverity.Information,
        });
    }
}

如果您想深入了解此设计,请阅读此文


现在的问题是合同没有定义支持哪些严重级别或不支持哪些。考虑到ISP,那么ILogInfoILogErrorILogDebug等怎么样? - plalx
我认为这个答案有些不切实际。为每个类可能实现的方法创建一个新接口是荒谬的。自然而然地,你会将相关功能分组在一起。我很难想象只需要记录信息而不记录警告/错误/调试的情况。所以坦率地说,我认为这种代码纯粹是为了追求纯粹而存在的。 - Siddhartha Gandhi
嗨,@SiddharthaGandhi,你可能误解了我的答案(这可能是我没有表述清楚的错)。LoggerExtensions类可以包含任意数量的扩展方法,因此您应该根据需要添加WarningError重载。在另一个SO q/a中,我给出了一个更详细的答案,希望能更好地阅读。请看这里 - Steven

4

你应该始终优先使用接口。

是的,在某些情况下,类和接口可能具有相同的方法,但在更复杂的场景中,它们将不同。还要记住,在.NET中没有多重继承。

你应该将接口保存在单独的程序集中,而你的类应该是内部的。

针对接口进行编码的另一个好处是能够轻松地在单元测试中模拟它们。


1
为什么将接口保留在单独的程序集中是一件好事? - thedev
5
你可以发布接口而不公开实现细节,这样也可以共享接口,避免泄露增加负担或维护问题的无用实现。 - Adam Houldsworth

0

我更喜欢接口。鉴于存根和模拟也是实现(某种程度上),我总是有至少两个任何接口的实现。此外,可以对接口进行存根和模拟以进行测试。

此外,Adam Houldsworth提到的契约角度非常有建设性。在我看来,与接口的1-1实现相比,它使代码更加简洁,而这些实现会让它变得臭气熏天。


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