41

使用依赖注入时,我的接口和具体类之间存在一对一的关系,我对此感到内疚。当我需要向接口添加方法时,我最终会破坏所有实现该接口的类。

这是一个简单的例子,但是让我们假设我需要将一个注入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) { }
}

同样,这是一个简单的例子,但我什么时候应该更喜欢界面?

4

4 回答 4

33

“快速”答案

我会坚持使用接口。它们被设计为外部实体的消费合同。

@JakubKonecki 提到了多重继承。我认为这是坚持使用接口的最大原因,因为如果你强迫它们采用基类,这在消费者方面会变得非常明显......没有人喜欢将基类强加给它们。

更新的“快速”答案

您已经陈述了您无法控制的接口实现的问题。一个好的方法是简单地创建一个继承旧接口的新接口并修复您自己的实现。然后,您可以通知其他团队有新界面可用。随着时间的推移,您可以弃用旧接口。

不要忘记您可以使用显式接口实现的支持来帮助保持逻辑上相同但版本不同的接口之间的良好划分。

如果您希望所有这些都适合 DI,那么请尽量不要定义新接口,而是倾向于添加。或者,为了限制客户端代码更改,尝试从旧接口继承新接口。

实施与消费

实现接口和使用接口是有区别的。添加方法会破坏实现,但不会破坏使用者。

删除方法显然会破坏消费者,但不会破坏实现 - 但是,如果您对消费者具有向后兼容性意识,则不会这样做。

我的经历

我们经常与接口有一对一的关系。这主要是一种形式,但你偶尔会得到很好的接口实例,因为我们存根/模拟测试实现,或者我们实际上提供了客户端特定的实现。如果我们碰巧更改接口,这经常会破坏一个实现这一事实并不是代码异味,在我看来,这只是你如何处理接口。

我们基于接口的方法现在对我们有利,因为我们利用工厂模式和 DI 元素等技术来改进陈旧的遗留代码库。测试能够迅速利用接口在代码库中存在多年的事实,然后才找到“确定的”用途(即,不仅仅是与具体类的 1-1 映射)。

基类缺点

基类用于向公共实体共享实现细节,在我看来,它们能够通过公开共享 API 来做类似的事情是一个副产品。接口旨在公开共享 API,因此请使用它们。

使用基类,您还可能会泄露实现细节,例如,如果您需要公开某些内容以供实现的另一部分使用。这些都不利于维护一个干净的公共 API。

破坏/支持实现

如果您沿着接口路线走,您可能会因为违反合同而难以更改接口。此外,正如您所提到的,您可能会破坏您无法控制的实现。有两种方法可以解决这个问题:

  1. 声明你不会破坏消费者,但你不会支持实现。
  2. 声明接口一旦发布,就永远不会改变。

我目睹了后者,我看到它有两种形式:

  1. 任何新东西的完全独立的接口:MyInterfaceV1, MyInterfaceV2.
  2. 接口继承:MyInterfaceV2 : MyInterfaceV1.

我个人不会选择走这条路,我会选择不支持破坏性更改的实现。但有时我们没有这个选择。

一些代码

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();
}
于 2012-04-25T08:00:48.400 回答
12

当您开始添加,和之外的方法时,您ILogger的接口打破了接口隔离原则。看看可怕的 Log4Net ILog 界面,你就会知道我在说什么。DebugErrorCriticalInfo

不要为每个日志严重性创建一个方法,而是创建一个采用日志对象的方法:

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,
        });
    }
}

有关此设计的更详细讨论,请阅读

于 2012-04-25T11:15:16.107 回答
4

您应该始终喜欢界面。

是的,在某些情况下你会在类和接口上拥有相同的方法,但在更复杂的场景中你不会。还要记住,.NET 中没有多重继承。

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

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

于 2012-04-25T07:57:32.433 回答
0

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

此外,Adam Houldsworth 提到的合同角度非常有建设性。恕我直言,它使代码比接口的 1-1 实现更干净,让它变得很臭。

于 2012-04-25T08:28:47.653 回答