项目
版本

Autofac 控制作用域和生命周期

你可能还记得在 注册主题 中提到的,你向容器添加实现服务组件。然后你会 解析服务 并使用这些服务实例来完成你的工作。不过,你仍然可能会想知道:

  • 组件何时实例化?
  • 组件何时被丢弃?
  • 如何确保单例在我的应用程序中正确共享?
  • 如何控制这些?

注意:这里大部分内容基于 Nick Blumhardt 的 Autofac 生命周期入门。虽然随着时间的推移 Autofac 的一些功能有所变化,但其中描述的概念仍然有效,并有助于理解生命周期的作用域。

基本概念和术语

服务的生命周期是指它在应用程序中的存在时间,从最初的实例化到 丢弃 。例如,如果你创建了一个实现了 IDisposable 接口的对象,并稍后调用 Dispose() 方法,那么该对象的生命周期就是从实例化开始,直到 Dispose() 被调用(或者如果没有主动丢弃,垃圾回收器会处理)。

// 使用 C# 语言的一个生命周期类比:
using (var component = new DisposableComponent())
{
  // 这里组件的“生命周期”是从构造函数被调用时开始,
  // 直到`Dispose()`被调用。
}

服务的作用域是该服务可以在其中与其他使用它的组件共享的区域。例如,在你的应用程序中,你可能有一个全局静态单例——这个全局对象实例的作用域将是整个应用程序。另一方面,你可能在一个 for 循环中创建一个局部变量,该变量使用全局单例——局部变量的作用域远小于全局。

// 在Autofac中,作用域类似于C#中的变量作用域。
//
// 这个静态字符串的作用域是全局的——任何地方都可以访问它。
public static string Singleton = "single-instance";
using (var component = new DisposableComponent())
{
  // "component" 的作用域仅限于这些大括号内。
  // 你不能在那之外使用它,但是组件可以使用共享的单例。
  for (var i = 0; i < 10; i++)
  {
    // "i" 的作用域仅限于 for 循环内部,
    // 单例和组件仍然是可用的。
    component.DoWork(Singleton, i);
  }
}

Autofac 中的生命周期作用域概念结合了这两个概念。实际上,生命周期作用域等同于应用程序中的一个工作单元。一个工作单元可能会开始一个生命周期作用域,然后根据需要从该作用域中获取所需的其他服务。当你从作用域中解析服务时,Autofac 会跟踪可丢弃( IDisposable )组件,并在工作单元结束时自动清理它们。

生命周期作用域控制的主要两件事是共享和丢弃。

  • **生命周期作用域是嵌套的,它们控制组件如何共享。**例如,一个 "单例" 服务可能从根生命周期作用域中解析,而单独的工作单元可能需要其自己的其他服务实例。你可以通过 在注册时设置组件实例作用域 来确定组件如何共享。
  • **生命周期作用域跟踪可丢弃的对象,并在作用域被丢弃时自动处理它们。**例如,如果你有一个实现 IDisposable 的组件,并且从生命周期作用域中解析它,作用域将持有它,并为你自动处理丢弃,这样你的服务消费者就不必了解底层实现。你可以选择控制这种行为或添加新的丢弃行为

当你在应用程序中工作时,记住这些概念可以帮助你最有效地利用资源。

**始终从生命周期作用域而不是根容器解析服务非常重要。**由于生命周期作用域的丢弃跟踪特性,如果你从容器(根生命周期作用域)大量解析可丢弃组件,你可能会无意中导致内存泄漏。根容器将一直持有这些可丢弃组件的引用,直到应用结束(通常情况下,这是整个应用程序的生命周期),以便它可以丢弃它们。你可以选择更改丢弃行为 ,但最好只从作用域中解析。如果 Autofac 检测到使用单例或共享组件,它会自动将其放入适当的跟踪作用域中。

作用域和层次结构

可视化生命周期作用域的最简单方法就像一棵树。你从根容器(即根生命周期作用域)开始,每个工作单元(如 Web 请求等)——每个子生命周期作用域——从那里分支出来。

image

当你构建 Autofac 容器时,你创建的就是那个根容器/生命周期作用域。集成包 或应用程序代码可以从容器创建子生命周期作用域,甚至可以从其他子作用域创建子作用域。

生命周期作用域有助于确定依赖关系来自何处。一般来说,组件会尝试从解析它的作用域中获取依赖项。例如,如果你在一个子生命周期作用域中尝试解析某个东西,Autofac 会尝试从子作用域中获取组件的所有依赖项。

影响这一机制的是 “生命周期” 方面的 “生命周期作用域” 。有些组件,如单例,需要跨多个作用域共享。这会影响依赖项的定位。基本规则如下:

  • 子生命周期作用域可以从父作用域获取依赖项,但父作用域不能深入到子作用域中。(你可以通过 “向上移动” 在树中查找,但不能 “向下移动” 。)
  • 组件将从拥有组件的作用域获取其依赖项,即使组件是由树中较深的作用域解析的。我们将在下面的单例生命周期示例中对此进行说明。

生命周期作用域的部分工作是 处理你从作用域中解析的组件的丢弃 。当你解析一个实现 IDisposable 的组件时,拥有该组件的作用域将持有对该组件的引用,以便在作用域被丢弃时正确地丢弃它。如果你想深入了解如何处理丢弃,你可以考虑一些基本事项:

  • 如果你从根生命周期作用域(容器)解析 IDisposable 项,它们将被保留,直到容器被丢弃(通常在应用结束时)。**这可能导致内存泄漏。**总是尝试从子生命周期作用域中解析并处理完作用域后丢弃它们。
  • 丢弃父作用域并不会自动丢弃子作用域。以图为例,如果你丢弃根生命周期作用域,它不会丢弃外面的四个子作用域。负责正确丢弃作用域的责任在于创建作用域的你。
  • 如果你丢弃了父作用域但仍继续使用子作用域,事情就会失败。你不能从已丢弃的作用域中解析依赖项。建议按照创建的顺序反向丢弃作用域。

你可以阅读更多关于 与生命周期作用域一起工作(包括更多代码示例!),组件丢弃和可用的不同 实例作用域 的内容。

示例:单例生命周期

早些时候我们提到,组件将从拥有组件的作用域获取其依赖项。让我们通过一个例子深入研究:单例。

当你声明单例时,它由声明它的作用域所有。

  • 如果你在构建容器时声明单例,它将由根生命周期作用域持有。当你从这种方式注册的单例中解析时,所有依赖项都将来自根作用域
  • 当创建子生命周期作用域时,你可以在其中添加单例——这些将由它们注册的作用域持有。当你从它那里解析时,所有依赖项都将来自该子生命周期作用域

这样做可以确保你不会在单例之下丢弃依赖项;并且不会因为子作用域被丢弃后仍然持有引用而导致内存泄漏。

假设你有以下几类:

public class Component
{
    private readonly Dependency _dep;

    public string Name => this._dep.Name;

    public Component(Dependency dep)
    {
        this._dep = dep;
    }
}

public class Dependency
{
    public string Name { get; }

    public Dependency(string name)
    {
        this.Name = name;
    }
}

现在假设你有一些代码来构建容器并使用这些类。

var builder = new ContainerBuilder();

// 容器构建时声明的单例将在根作用域中拥有,并从根作用域获取所有依赖项。
builder.RegisterType<Component>().SingleInstance();

// 从根生命周期作用域中解析依赖项时,会将名称视为'root'
builder.Register(ctx => new Dependency("root"));

var container = builder.Build();

// 从根生命周期作用域中解析组件。
var rootComp = container.Resolve<Component>();

// 这将显示"root"
Console.WriteLine(rootComp.Name);

// 即使我们在子作用域中重写了Dependency,它仍然会显示"root"
using (var child1 = container.BeginLifetimeScope(
  b => b.Register(ctx => new Dependency("child1"))))
{
    child1Comp = child1.Resolve<Component>();
    Console.WriteLine(child1Comp.Name);
}

// 可以通过在子作用域中添加新的单例来重写根作用域中的单例。这个单例由子作用域拥有,并在其子作用域中工作,但它不会覆盖根作用域中的单例。
using (var child2 = container.BeginLifetimeScope(
  b => {
      b.RegisterType<Component>().SingleInstance();
      b.Register(ctx => new Dependency("child2"));
  }))
{
    var child2Comp = child2.Resolve<Component>();

    // 这将显示"child2"
    Console.WriteLine(child2Comp.Name);

    // rootComp和child2Comp是两个不同的单例。
    Debug.Assert(rootComp != child2Comp);

    using (var child2SubScope = child2.BeginLifetimeScope(
      b => b.Register(ctx => new Dependency("child2SubScope"))))
    {
        var child2SubComp = child2SubScope.Resolve<Component>();

        // 这将显示"child2"
        Console.WriteLine(child2SubComp.Name);

        // child2Comp和child2SubComp是相同的。
        Debug.Assert(child2Comp == child2SubComp);
    }
}

以图片形式表示,看起来像这样:

image

如您所见,生命周期作用域不仅决定了组件存活的时间,还决定了它从哪里获取依赖项。在设计应用程序时,您需要考虑这一点,以避免遇到以为自己重写了值,但实际上被组件声明的生命周期所阻碍的问题。更多关于组件实例作用域的信息可以在 我们的实例作用域文档中 找到。

示例:Web 应用程序

让我们以一个更具体的 Web 应用程序为例,说明如何使用生命周期作用域。假设您有以下场景:

  • 您有一个全局单例日志服务。
  • 同时有两个请求进入 Web 应用程序。
  • 每个请求是一个逻辑 “工作单元” ,每个都需要自己的订单处理服务。
  • 每个控制器都需要将信息日志到日志服务。

在这种情况下,您将有一个包含单例日志服务的根生命周期作用域,每个请求有一个子生命周期作用域,每个子作用域都有自己的控制器实例。

从类注册的角度来看,可能如下所示:

var builder = new ContainerBuilder();

// 日志服务是单例 - 在所有地方共享。
builder.RegisterType<Logger>().As<ILogger>().SingleInstance();

// 控制器按生命周期作用域创建。它们接受一个ILogger作为参数。
builder.RegisterType<Controller>().InstancePerLifetimeScope();

var container = builder.Build();

当 Web 请求进来时,生命周期作用域可能会像这样:

image

对于像 ASP.NET Core 这样的 Web 应用程序框架,大致的事件顺序可能是这样的:

  1. 当 Web 请求进来时,Web 应用程序框架创建一个子生命周期作用域—— “请求生命周期作用域” 。
  2. Web 应用程序框架从请求生命周期作用域中解析控制器实例。由于控制器注册为 “按生命周期作用域实例”,因此该实例将在该请求中的任何组件之间共享,但不会与其他请求共享。
  3. 应用级别注册为单例的日志服务作为依赖项注入到每个控制器实例中。
  4. 在每个 Web 请求结束时,请求生命周期作用域将被丢弃,控制器将被垃圾回收。日志服务将继续存活并存储在根生命周期作用域中,以便在整个应用程序生命周期中继续注入。
  5. 在 Web 应用程序结束(在关闭期间)时,Web 应用程序框架应丢弃根容器并释放日志服务。
在本文档中