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