在使用依赖注入时,我在界面和具体类之间建立了一对一的关系。当我需要向接口添加方法时,我最终会破坏实现该接口的所有类。
这是一个简单的例子,但我们假设我需要在其中一个类中注入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) { }
}
同样,这是一个简单的例子,但是当我应该选择接口时呢?
答案 0 :(得分:31)
“快速”答案
我会坚持使用接口。它们设计是外部实体的消费合同。
@JakubKonecki提到了多重继承。我认为这是坚持使用界面的最大理由,因为如果你强迫他们选择基类,它将在消费者方面变得非常明显......没有人喜欢基类被强加给他们。
更新后的“快速”答案
您已经说明了控件之外的接口实现问题。一个好的方法是简单地创建一个继承旧的接口并修复自己的实现。然后,您可以通知其他团队新的界面可用。随着时间的推移,您可以弃用旧接口。
不要忘记你可以使用explicit interface implementations的支持来帮助在逻辑上相同但不同版本的接口之间保持良好的划分。
如果您希望所有这些都适合DI,那么尽量不要定义新的接口,而是支持添加。或者,如果要限制客户端代码更改,请尝试从旧接口继承新接口。
实施与消费
实施界面与消费之间存在差异。添加方法会破坏实现,但不会破坏消费者。
删除方法显然会破坏消费者,但不会破坏实施 - 但如果您对消费者具有向后兼容性,则不会这样做。
我的经历
我们经常与接口建立一对一的关系。它在很大程度上是一种形式,但你偶尔会得到很好的实例,其中接口是有用的,因为我们存根/模拟测试实现,或者我们实际上提供客户端特定的实现。如果我们碰巧改变界面,这经常打破一个实现的事实不是代码气味,在我看来,它只是你如何对抗接口。
我们基于接口的方法现在正处于有利地位,因为我们利用工厂模式和DI元素等技术来改善老化的遗留代码库。在找到“确定”用法之前,测试能够快速利用代码库中存在接口的事实多年(即,不仅仅是1-1与具体类别的映射)。
基类缺点
基类用于向普通实体共享实现细节,在我看来,他们能够通过公开共享API做类似的事情是副产品。接口旨在公开共享API,因此请使用它们。
对于基类,您还可能会泄漏实现细节,例如,如果您需要为实现的另一部分公开使用某些内容。这些都不利于维护一个干净的公共API。
打破/支持实施
如果你沿着界面路线走下去,由于合同中断,你甚至可能难以改变界面。此外,正如您所提到的,您可能会破坏控件之外的实现。有两种方法可以解决这个问题:
我亲眼目睹了后者,我认为它有两种形式:
MyInterfaceV1
,MyInterfaceV2
。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();
}
答案 1 :(得分:11)
当您开始添加ILogger
之外的Debug
,Error
和Critical
方法时,Info
界面正在打破interface segregation principle。看看horrible Log4Net ILog interface,你就会知道我在说什么。
不是按日志严重性创建方法,而是创建一个采用日志对象的方法:
void Log(LogEntry entry);
这完全解决了您的所有问题,因为:
LogEntry
将是一个简单的DTO,您可以为其添加新属性,而不会破坏任何客户端。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,
});
}
}
有关此设计的更详细讨论,请阅读this。
答案 2 :(得分:4)
你应该总是喜欢这个界面。
是的,在某些情况下,您将在类和接口上使用相同的方法,但在更复杂的情况下,您不会。还要记住,.NET中没有多重继承。
您应该将接口保存在单独的程序集中,并且您的类应该是内部的。
对接口进行编码的另一个好处是能够在单元测试中轻松模拟它们。
答案 3 :(得分:0)
我喜欢接口。鉴于存根和模拟也是实现(有点),我总是至少有两个任何接口的实现。此外,可以对接口进行存根和模拟以进行测试。
此外,Adam Houldsworth提到的契约角度是非常有建设性的。恕我直言,它使代码更清洁,而不是1-1的接口实现让它变得臭。