深入了解反向依赖的世界
介绍控制反转容器及其目的
引言
你可能已经听说过控制反转容器(或称 IoC 容器)。如果你还没有听说过,你应该锁好门,然后非常缓慢地阅读这篇文章,以理解所有好处。本文旨在揭示秘密,并为你介绍一个全新的世界,在这个世界里,注入的依赖将使你的应用程序更加精炼和高效。
但在我们开始之前,我们必须理解 IoC 容器为我们解决的问题。这一切都始于依赖注入,或者正如 SOLID 先生所说:“让我们反转那些依赖”。看下面的简单示例
public clas Car
{
Engine _engine = new V5();
public void Start()
{
_engine.Start();
}
}
public class RaceProgram
{
public static void Main(string[] args)
{
Car car = new Car();
car.Start();
}
}
挺不错的,是吧?嗯。我想 Cole Trickle 可能不会同意你的看法。他希望能随时更换发动机。所以他说:“我们不需要什么 V5”。
反转它!
所以,让我们反转那个依赖
public class Car
{
Engine _engine;
public Car(Engine engine)
{
_engine = engine;
}
public void Start()
{
_engine.Start();
}
}
public class RaceProgram
{
public static void Main(string[] args)
{
var engine = new BigBadV10();
Car car = new Car(engine);
car.Start();
}
}
就这样。我们将依赖管理从类本身转移到了调用代码。通过这样做,我们促进了代码重用,因为我们现在可以控制汽车的制造方式以及它应该使用哪些部件。汽车行业的首席执行官们会同时尖叫和哭泣,如果他们的事情有这么简单的话。所以,亲爱的程序员同仁:利用好这个力量。
现在你可能会问自己:“我为什么要为这个东西需要一个 IoC 容器?我自己就能创建这些依赖”。没错。绝对正确。我现在应该去睡觉了……
让我们再举一个例子。我们有一个相当大的应用程序,它使用了 存储库模式。并且我们在存储库中使用数据库作为数据源。由于我们在没有容器的情况下使用依赖反转,我们有以下代码
public class UserRepository
{
public UserRepository(IDbConnection connection)
{
}
}
public class MessageController : MyAspMvcController
{
UserRepository _userRepos;
MessageRepository _messageRepos;
public MessageController()
{
var connection = new SqlClient(ConfigurationManager.ConnectionStrings["SomeString"].ConnectionString);
_userRepos = new UserRepository(connection)
_messageRepos = new UserRepository(connection)
}
}
我们需要遍历大约 20 个控制器来切换实现。由于我们是针对类而不是抽象进行编码,正如 依赖反转原则所说的那样,我们还必须用新的实现替换掉旧的实现。因此,破坏了 二进制兼容性(这意味着我们不能用另一个 DLL 替换一个 DLL,我们必须重新编译整个应用程序)。
使用 IoC(以及针对抽象的依赖),我们将改为这样写
public class UserRepository : IUserRepository
{
public UserRepository(IDbConnection connection)
{
}
}
public class MessageController : MyAspMvcController
{
IUserRepository _userRepos;
IMessageRepository _messageRepos;
public MessageController(IUserRepository userRepository, IMessageRepository messageRepository)
{
_userRepos = userRepository;
_messageRepos = messageRepository;
}
}
魔力在于以下两个配置语句(语法取决于容器的实现)
container.RegisterType<IUserRepository, DbUserRepository>();
container.RegisterType<IMessageRepository, DbMessageRepository>();
这将替换为
container.RegisterType<IUserRepository, UserRepository>(); container.RegisterType<IMessageRepository, MessageRepository>();
看?我们只需要更改两行,而不是大约 20 行(即在之前使用依赖的每个地方)。
链式依赖
当我们开始真正利用 IoC 容器时,整个事情就变得令人惊叹了。假设我们有以下依赖链
这给了我们以下的构造函数
TodoService(IUserRepository, ITodoRepository, IDomainEventDispatcher)
UserRepository(IEntityValidator, ISession)
TodoRepository(ISession)
DomainEventDispatcher(IServiceLocator)
反过来,这意味着在每个我们想要使用 `TodoService` 的地方,我们都必须使用以下代码
var sessionFactory = new SessionFactory();
// config nhibernate bla bla
var session = sessionFactory.OpenSession();
var userRepository = new UserRepository(new EntityValidator(), session);
var todoRepository = new TodoRepository(session);
var dispatcher = new DomainEventDispatcher(Globals.ServiceLocator);
var service = new TodoService(userRepository, todoRepository, dispatcher);
我们必须在每个想要使用 `TodoService` 的地方重复这种代码臃肿。当然,我们可以使用 抽象工厂模式,建造者模式或单例模式。所有这些都可以解决问题的一部分,但解决方案会比简单地告诉容器我们有哪些类更复杂。
通过我们的容器,我们将每个类映射到它实现的服务的列表
container.RegisterType<IUserRepository,UserRepository>();
container.RegisterType<IMessageRepository, MessageRepository>();
//google "lambda expresions" if you don't understand the line below
container.RegisterFactory<IDomainEventDispatcher>(container => new DomainEventDispatcher(container));
container.RegisterType<IEntityValidator, EntityValidator>();
container.RegisterType<ITodoService, TodoService>();
// user the service location pattern to get the service
var service = container.Resolve<ITodoService>();
看到区别了吗?我们不必知道每个类有哪些依赖。我们只需要知道我们创建了哪些类。*这意味着当一个类更改其依赖时,我们永远不需要做任何事情(除了向容器添加一个新类)。*
所以,让我们看看在使用 IoC 容器时,我们如何看待依赖
我们知道类有依赖,但我们真的不在乎,因为类是为我们创建的。我们已经从必须知道所有依赖以及如何创建它们,转变为只知道我们想使用哪个服务。
奇怪的词
当你与别人谈论 IoC 时,有一些术语是你需要知道的,它们是
术语 | 描述 |
---|---|
Service | 从容器中请求的东西。通常是像 `IUserRepository` 这样的接口,但也可以是类 |
具体实现 | 当请求服务时返回的对象。如果请求 `IUserRepository`,它将是 `DbUserRepository` 对象 |
瞬态 | 每次从容器请求服务时都会返回一个新的对象 |
范围 | 容器的一个子集,包含生命周期有限的对象。当范围关闭时,对象将被处置。 |
子容器 | 范围最典型的实现。(一个只存储作用域对象并使用父容器处理其他所有事的容器)
|
生命周期
所以你已经看到,容器可以帮助你组装/创建你所有的对象。你只需要告诉它你想要获得哪个服务,你就会得到相应的具体实现。这不是很棒吗?然而,这只是容器能为你做的一半事情。
你可能听说过单例模式,它阻止你使用面向对象编程的一个基本原则:通过继承进行扩展。当然,你可以将单例模式与代理模式结合使用,这会让你走得更远。
有了容器,你就不必受这种限制。容器不仅处理依赖关系,还控制每个对象的生命周期。它这样做是因为它持有对每个已创建对象的引用,并且只有在满足某些条件时才会删除该引用(并处置所有实现 `IDisposable` 的类)。
容器的典型用法是,你在应用程序启动时生成它,并在应用程序结束时处置它。这使你能够为所有对象提供两种生命周期:单例和瞬态。
这相当有趣,因为你不必在类中做任何特殊的事情来使它们成为单例(除了确保它们是线程安全的和/或正确管理它们的字段)。换句话说:通过继承进行扩展仍然是可能的。你只需创建一个新的具体类,然后注册新的实现。
范围
我们在上一节介绍了一个新东西。那就是具有有限生命周期的类。nhibernate 会话(可能是 ADO.NET 连接、TCP 连接或类似的东西)。这些类在连接丢失时停止工作,并在所有后续调用中继续失败。
我们也有线程不安全的类。
处理这两个问题的明显方法是在类内部进行处理。但这不幸地会增加这些类的复杂性。而复杂性会带来 bug。
然而,还有另一种解决方案:作用域对象。
控制反转容器可以创建作用域,这意味着对象具有有限的生命周期,并存储在特定于线程的存储中(这使得对象线程安全)。典型的作用域是 HTTP 请求/响应周期。
介绍容器
那么,让我们开始玩一个容器吧。我走遍了世界,尝遍了人间百味。这一切都是为了能给你找到完美的 IoC 容器。信不信由你,**我找到了**!它叫做 Griffin.Container,是我做的 。
注册那些类
我在上一节展示了注册类的传统方法。那就是使用 `RegisterType` 和类似的方法。但这效率低下且耗时。我创建了一种替代的、更常规的注册类型的方式。
你所要做的就是用一个名为 `[Component]` 的属性来装饰每个应该在容器中注册的类。
[Component] //<-- lookie lookie
public class UserRepository : IUserRepository
{
}
对所有实现类所在的程序集中的所有类都这样做。然后用一行代码注册所有这些类。
var registrar = new ContainerRegistrar();
registrar.RegisterComponents(Lifetime.Scoped, Environment.CurrentDirectory, "MyApp.*.dll");
var container = registrar.Build();
传递给 `RegisterComponents` 的第一个参数告诉容器,所有仅使用 `[Component]`(没有指定生命周期)的类都将注册为作用域组件。
主项目(应用程序入口点)甚至不需要引用那些 DLL。它们将被容器加载到你的应用程序中。应用程序的耦合度不能再低了。
这是一种强大的方法,因为它允许你轻松地构建一个插件系统。只需创建一个新的类库,并在其中放置所有扩展点(接口)。然后从每个插件引用该类库,并实现部分或全部扩展点。我在我的博客中详细描述了这种方法(使用 ASP.NET MVC3)。
你还可以通过使用属性的属性来指定对象的生命周期
[Component(Lifetime = Lifetime.Singleton)]
public class UserRepository : IUserRepository
{
}
对于你的代码,`[Component]` 属性应该在 99% 的情况下都能正常工作。*我强烈建议你不要指定一个类实现哪些服务*。如果你必须指定服务,那么你很可能违反了 SOLID 原则之一。我建议你尝试重构你的类(将其分解成更小的类),并使用 `[Component]` 属性注册每个类。
模块
有时无法使用 `[Component]` 属性。例如,当你想要将外部依赖注入到容器中时。为此,你可以直接使用容器注册依赖。但一个更好的解决方案是让代码的每个模块/部分/命名空间管理自己的依赖。这很容易。只需创建一个新类,并让它实现 `IContainerModule` 接口,如下所示
public class UserDependencyRegistrations : IContainerModule
{
public void Register(IContainerRegistrar registrar)
{
registrar.AddService<ISession>(container => container.Resolve<ISessionFactory>().OpenSession());
}
}
然后你必须从应用程序根目录(例如 `Program.cs`)加载模块
public class Program
{
public static Main(string[] args)
{
var registrar = new ContainerRegistrar();
registrar.RegisterModules(Environment.CurrentDirectory, "MyApp.*.dll");
var container = registrar.Build();
// rest of your code here.
}
}
你现在已经加载了当前目录中任何匹配 DLL 的模块中定义的所有服务。
你可能会想,之后如何在应用程序中使用容器。有适用于 WCF、ASP.NET MVC3 的 nuget 包,它们会为你处理集成。对于其他应用程序类型,你需要手动创建作用域并调用根类(这些根类又会获得它们的依赖注入)。
领域事件
Griffin.Container 包含另一项功能,它极大地帮助你保持类之间的耦合度低。那就是领域事件。
领域事件是弱事件,这意味着订阅者无法阻止发布者被垃圾回收。这里有一张很棒的漫画,说明了 .NET 事件模型的弊端
一个示例事件会是这样的
public class UserCreated
{
User _user;
public UserCreated(User user)
{
_user = user;
}
public User User { get { return _user; } }
}
由我们的 `UserService` 生成
[Component]
public class UserService : IUserService
{
IUserRepository _repos;
public UserService(IUserRepository repos)
{
_repos = repos;
}
public void Register(string username, string password, string email)
{
var user = _repos.Create(username, password);
user.Email = email;
_repos.Save(user);
DomainEvent.Publish(new UserCreated(user));
}
}
事件被两个不同的处理程序捕获。
[Component]
public class UserWelcomeEmailSender : ISubscriberOf<UserCreated>
{
ITemplateGenerator _templateGenerator
public UserWelcomeEmailSender(ITemplateGenerator templateGenerator)
{
_templateGenerator = templateGenerator;
}
public void Handle(UserCreated e)
{
var emailBody = _templateGenerator.Generate("UserCreatedTemplate", e.UserName);
var msg = new MailMessage("mysuper@cool.site", e.Email);
// It's possible to configure the SmtpClient in app.Config
// which means that we can just created it and send the email.
var client = new SmtpClient();
client.Send(msg);
}
}
[Component]
public class NotifyAdminsOfNewUser : ISubscriberOf<UserCreated>
{
ITemplateGenerator _templateGenerator
public NotifyAdminsOfNewUser(ITemplateGenerator templateGenerator)
{
_templateGenerator = templateGenerator;
}
public void Handle(UserCreated e)
{
var emailBody = _templateGenerator.Generate("ValidateNewUser", e.UserName);
var msg = new MailMessage("mysuper@cool.site", e.Email);
var client = new SmtpClient();
client.Send(msg);
}
}
看?`UserService` 对订阅者或它们的操作一无所知。订阅者也不知道也不关心事件来自何处。这意味着你可以将 `UserService` 替换为其他东西,或者添加另一个订阅者,而不会影响其他部分。
让我们只看每个类的构造函数
public UserCreated(User user) public UserService(IUserRepository repos) public UserWelcomeEmailSender(ITemplateGenerator templateGenerator) public NotifyAdminsOfNewUser(ITemplateGenerator templateGenerator)
仅仅通过查看构造函数,你能说出这些类负责什么吗?当然。小类的代码**更容易**理解和遵循。完全理解一个类做什么,可以减少你在修复/改进它时引入 bug 的可能性。
这非常重要。容器为你处理所有依赖管理和对象创建,这意味着你创建多少类或者它们有多小并不重要。因此,容器鼓励你创建遵循 SOLID 原则的小型可重用类(因为你可以轻松地将它们添加到容器中)。
最佳实践
我使用了一些实践。请注意,它们是我个人的主观实践。它们基于我的个人经验(我使用容器大约有四年了)。
避免服务定位
容器本身就是一个服务定位器。这意味着你可以使用服务定位,而不是构造函数来查找你依赖的服务。类似这样
public class UserService { public void Register(string userName) { var repository = Container.Instance.Resolve<IUserRepository>(); repository.Create(new User(userName)); } }
这是错误的。你无法通过查看方法代码(或调用方法)来知道 `Register` 方法有一个依赖项。
当应用程序增长时,这会成为一个维护噩梦,因为依赖项是隐藏的。你必须检查每个方法或调用每个方法的每个执行路径,才能确保你已发现并正确配置了所有依赖项。
简单地说:如果可以避免,就不要使用服务定位。
依赖接口,而非具体实现
这是 SOLID 中 D 原则(即依赖反转)的一部分。依赖接口(抽象)而不是具体实现(类)可以更容易地重构代码。例如,如果你决定将具体实现分解成更小的类(并将原始接口分解成更小的接口),你可以为接口创建一个外观。你仍然会有二进制兼容性(因此只需要重新编译和更新类库程序集)。
没有任何规定说一个类和一个它实现的接口之间必须是一对一的映射。事实上,最好是将一个类分解成更小的接口。
这将使未来的代码维护和重构更加容易。
为了更好的性能,不要选择更长的生命周期
复杂性与生命周期有关。长生命周期的对象必须关心线程安全和状态管理。
以数据库事务为例。当使用作用域对象时,你可以简单地将工作单元实现注册为作用域。工作单元将自动被所有作用域对象使用(因此共享事务)。当作用域被处置时(因此工作单元被处置)并且 `SaveChanges()` 没有被调用,事务将被回滚。单例对象无法做到这一点,因为它们会随着应用程序的生命周期而存在。
因此,你需要使用如下代码
public class UserService : IUserService
{
public void Add()
{
var (var uow = UnitOfWork.GetOrCreate())
{
// do stuff.
uow.Save();
}
}
}
而不是:
public class UserService : IUserService
{
public UserService(IUnitOfWork uow)
{
}
public void Add()
{
// do stuff.
}
}
不要混合生命周期
如果你不小心,混合生命周期可能会导致不良后果。尤其是在项目开始增长并出现更复杂的依赖图时。
示例
// EF4 context, scoped lifetime
class Dbcontext : IDbContext
{
}
// short lifetime, takes db context as a dependency
class Service1 : IService1
{
}
// Single instance, keeps a cache etc.
class Service2 : IService2
{
}
`Service2` 以 `service1` 作为依赖项,而 `service1` 又需要一个 dbcontext。假设 `dbcontext` 有一个空闲超时,会在一段时间后关闭连接。这将导致 `service1` 失败,`service2` 也会失败,因为它依赖于 `service1`。
在使用不同生命周期时,请务必小心。
尽量避免使用基本类型作为构造函数参数
尽量避免使用基本类型(例如字符串)作为构造函数参数。最好使用对象,因为它使扩展(子类化)更容易。
“嘿,我需要我的基本类型,”你可能会说。看看这个例子
public class MyRepository
{
public MyRepository(string connectionString)
{
}
}
好吧。你的类违反了 SRP(单一职责原则)。它既是连接工厂又是存储库。将工厂部分分离出去
public class MyRepository
{
public MyRepository(IConnectionFactory factory)
{
}
}
工厂本身可以作为实例注册到容器中
container.RegisterInstance<IConnectionFactory>(new AppConfigConnectionFactory("MyConStr")
避免命名依赖
有些容器允许你用名称注册服务,以便你可以从容器中获取特定的实现。
container.Register<IDataSource, DataSource>("CustomerDb", new ConstructorParameter("theConString"))
不要这样做。命名依赖暗示你想要使用工厂。使用工厂可以使意图更清晰,并且更容易看出需要特定的实现。
使用工厂:
public class AccountantRepository : IAccountantRepository
{
IDbConnection _connection;
public class AccountantRepository(IConnectionFactory factory)
{
_connection = factory.Create("EconomicsDb");
}
}
使用命名依赖:
public class AccountantRepository : IAccountantRepository
{
IDbConnection _connection;
public class AccountantRepository(IDbConnection economicsDb)
{
_connection = economicsDb;
}
}
这个改变可能看起来微不足道。但这个改变有两个原因
- 使用命名依赖与使用魔术字符串而不是常量一样脆弱。
- 一个变量名并没有真正说明你为不同的类使用不同的实现,而工厂却能说明。
避免臃肿的构造函数
一个臃肿的构造函数接收多个依赖项。它最常表明违反了单一职责原则。将类重构为更小的类。如果你在不同事件发生时需要执行操作(如上面的电子邮件示例),请使用领域事件。
我尽量在构造函数中使用最多三个参数。
摘要
我希望我至少让你对容器有了更多的了解,以及为什么你应该使用它们。
我可以滔滔不绝地讲代码质量以及为什么控制反转容器有助于提高代码质量。但我不知道你是否会欣赏。 所以我将在这里停止。
文章历史
- 2012-05-17 初版
- 2012-05-18 添加了最佳实践
- 2012-05-20 添加了臃肿的构造函数,修改了一些措辞。
- 2012-05-26 添加了生命周期