Skip to content

依赖注入

依赖注入(Dependency Injection,简称 DI)用于管理对象的创建、依赖关系和生命周期。 在 Spire 的实现中,这一能力来自源码模块 soulsoft_extensions_injection

soulsoft_extensions_injection 中,依赖注入有 4 个最重要的特性:

  • ServiceCollection 统一注册服务
  • IServiceProvider 统一解析服务
  • 支持 SingletonScopedTransient 三种生命周期
  • 作用域结束时会自动释放实现了 Resource 的服务,并支持工厂注册、keyed service 和反射注册

最小示例

这套容器最常见的使用流程只有 4 步:

  1. 创建 ServiceCollection
  2. 注册服务
  3. 调用 build() 构建容器
  4. 通过 IServiceProvider 解析服务

可以先看一个最小示例。

cangjie
import soulsoft_extensions_injection.*

interface IDbConnection {}

class MySqlConnection <: IDbConnection {}

class DbContext {
    // 通过构造函数声明对 IDbConnection 的依赖。
    public DbContext(public let connection: IDbConnection) {}
}

main(): Int64 {
    // 创建服务集合并注册接口和实现。
    let services = ServiceCollection()
    services.addSingleton<IDbConnection, MySqlConnection>()
    services.addSingleton<DbContext, DbContext>()

    // 构建根容器并解析 DbContext。
    let root = services.build()
    let context = root.getOrThrow<DbContext>()

    println(context.connection is MySqlConnection)
    return 0
}

这个例子体现了 DI 的两个核心价值:

  • DbContext 不需要自己创建 MySqlConnection
  • 容器会在解析 DbContext 时,自动补齐它构造函数里的依赖

核心类型

类型作用
ServiceCollection注册服务。它本质上是一个 List<ServiceDescriptor>
ServiceDescriptor描述一条注册信息,包括服务类型、实现类型、生命周期、可选 key 等
IServiceProvider解析服务
IServiceScope表示一个作用域,内部通过 services 暴露当前作用域的 IServiceProvider
IServiceScopeFactory创建作用域
ServiceProviderOptions控制 validateScopesvalidateOnBuild 等校验行为

其中最常用的是前 3 个:

  • ServiceCollection 注册
  • build() 得到 IServiceProvider
  • IServiceProvider 解析服务

服务注册

泛型注册

这是最常见的形式,表示“请求接口时,创建某个实现类型”。

cangjie
let services = ServiceCollection()
// 注册单例、作用域和瞬时服务。
services.addSingleton<IDbConnection, MySqlConnection>()
services.addScoped<DbContext, DbContext>()
services.addTransient<ReportService, ReportService>()

反射注册

除了泛型重载,容器也公开了 TypeInfo 版本的注册 API。 这类 API 适合“类型在运行时才确定”的场景。

cangjie
import std.reflect.*
import soulsoft_extensions_injection.*

let services = ServiceCollection()

// 直接用 TypeInfo 注册实现类型本身。
services.addSingleton(TypeInfo.of<MySqlConnection>())
// 注册接口到实现类型的映射。
services.addSingleton(TypeInfo.of<IDbConnection>(), TypeInfo.of<MySqlConnection>())
// 注册作用域服务。
services.addScoped(TypeInfo.of<DbContext>())
// 注册瞬时服务。
services.addTransient(TypeInfo.of<MySqlConnection>())

如果你需要自己做按包扫描、约定式注册或其他反射式注册逻辑,通常也是以这些 TypeInfo 重载为基础来实现。

实例注册

如果实例已经由你自己创建好了,可以直接把实例放进容器。

cangjie
let services = ServiceCollection()
// 直接把现成实例放进容器。
services.addSingleton<IDbConnection>(MySqlConnection())

这适合:

  • 配置对象
  • 预热好的客户端
  • 明确只想复用某一个现成实例的场景

工厂注册

当创建逻辑比较特殊时,可以使用工厂。

cangjie
let services = ServiceCollection()

services.addSingleton<IDbConnection, MySqlConnection>()
// 用工厂函数显式控制 DbContext 的创建过程。
services.addSingleton<DbContext> { sp =>
    DbContext(sp.getOrThrow<IDbConnection>())
}

工厂函数的参数类型是 (IServiceProvider) -> TService,这是源码里真实公开的签名,不是文档约定。

这一方式适合:

  • 构造过程需要条件判断
  • 需要手动选择依赖
  • 想避免纯反射式构造,显式控制对象创建

tryAddtryAddEnumerable

当你希望“只有没注册过才注册”时,可以使用 tryAdd*

cangjie
let services = ServiceCollection()
services.addSingleton<IDbConnection, MySqlConnection>()
// 只有在同 serviceType + key 未注册时才添加。
services.tryAddSingleton<IDbConnection, MsSqlConnection>()

上面的第二次注册不会生效。对 tryAdd* 来说,判重条件是:

  • serviceType
  • serviceKey

如果你需要“同一服务类型保留多个实现,但避免重复实现重复注册”,可以使用 tryAddEnumerable。 它的判重条件更严格:

  • serviceType
  • implementationType
  • serviceKey

服务解析

get<T>()getOrThrow<T>()

解析服务最常用的两个方法是:

  • get<T>():解析失败时返回 None
  • getOrThrow<T>():解析失败时抛出 UnsupportedException
cangjie
let services = ServiceCollection()
services.addSingleton<IDbConnection, MySqlConnection>()
let root = services.build()

// get 返回 Option,解析失败不会抛异常。
let connection1 = root.get<IDbConnection>()
// getOrThrow 解析失败会直接抛异常。
let connection2 = root.getOrThrow<IDbConnection>()
// 未注册服务时返回 None。
let missing = root.get<DbContext>()

使用建议:

  • 服务是“可选”的时候,用 get<T>()
  • 服务是“必须存在”的时候,用 getOrThrow<T>()

getAll<T>()

如果同一个服务类型注册了多个实现,可以通过 getAll<T>() 一次取回全部实现。

cangjie
let services = ServiceCollection()
services.addSingleton<IDbConnection, MsSqlConnection>()
services.addSingleton<IDbConnection, MySqlConnection>()

let root = services.build()
// 一次性取回该服务类型的全部实现。
let all = root.getAll<IDbConnection>().toArray()

println(all[0] is MsSqlConnection)
println(all[1] is MySqlConnection)

这里有两个很重要的真实行为:

  • getAll<T>() 按注册顺序返回
  • getOrThrow<T>() 在多实现场景下,返回最后一次注册的那个实现
cangjie
// 单个解析时会返回最后一次注册的实现。
let current = root.getOrThrow<IDbConnection>()
println(current is MySqlConnection)

按 key 解析

如果同一个服务类型需要同时保留多套实现,可以使用 keyed service。

cangjie
let services = ServiceCollection()
// 同一服务类型用不同 key 保留多套实现。
services.addKeyedSingleton<IDbConnection, MySqlConnection>("mysql")
services.addKeyedSingleton<IDbConnection, MsSqlConnection>("mssql")

let root = services.build()

// 按 key 解析对应的实现。
let mysql = root.getOrThrow<IDbConnection>("mysql")
let mssql = root.getOrThrow<IDbConnection>("mssql")

对应的解析方法包括:

  • get<T>(serviceKey: String)
  • getOrThrow<T>(serviceKey: String)
  • getAll<T>(serviceKey: String)

生命周期

源码中定义了 3 种生命周期:

生命周期含义典型行为
Singleton整个根容器只保留一份实例多次解析返回同一对象
Scoped每个作用域保留一份实例同一作用域相同,不同作用域不同
Transient每次解析都创建新实例每次解析都不同

Singleton

cangjie
let services = ServiceCollection()
services.addSingleton<IDbConnection, MySqlConnection>()

let root = services.build()
// 同一个根容器里,两次解析拿到同一单例对象。
let a = root.getOrThrow<IDbConnection>()
let b = root.getOrThrow<IDbConnection>()

println(refEq((a as Object).getOrThrow(), (b as Object).getOrThrow()))

输出会是 true

Scoped

下面这个例子能更直观地看到 scoped 的行为。

cangjie
import soulsoft_extensions_injection.*

interface IDbConnection <: Resource {
    func isClosed(): Bool
}

class MySqlConnection <: IDbConnection {
    private var _isClosed = false

    public func isClosed(): Bool {
        _isClosed
    }

    public func close(): Unit {
        // 作用域释放时把连接标记为已关闭。
        _isClosed = true
    }
}

main(): Int64 {
    let services = ServiceCollection()
    // 把连接注册为 scoped 服务。
    services.addScoped<IDbConnection, MySqlConnection>()
    let root = services.build()

    var fromScope1: ?IDbConnection = None
    var fromScope2: ?IDbConnection = None

    try (scope1 = root.createScope()) {
        // 同一作用域内两次解析会返回同一实例。
        fromScope1 = scope1.services.getOrThrow<IDbConnection>()
        let sameScope = scope1.services.getOrThrow<IDbConnection>()
        println(refEq(
            (fromScope1.getOrThrow() as Object).getOrThrow(),
            (sameScope as Object).getOrThrow()
        ))
    }

    try (scope2 = root.createScope()) {
        // 新作用域会得到新的实例。
        fromScope2 = scope2.services.getOrThrow<IDbConnection>()
    }

    // 比较不同作用域的实例,并观察作用域结束后的释放状态。
    println(refEq(
        (fromScope1.getOrThrow() as Object).getOrThrow(),
        (fromScope2.getOrThrow() as Object).getOrThrow()
    ))
    println(fromScope1.getOrThrow().isClosed())
    return 0
}

这个例子会体现 3 件事:

  • 同一作用域内,多次解析得到同一实例
  • 不同作用域内,得到不同实例
  • 如果服务实现了 Resource,作用域结束时会自动释放

Transient

TransientScoped 的区别在于:即使在同一个作用域里,每次解析也会得到新实例。

下面这个例子把 Transient 的几个关键行为都展示出来:

cangjie
import soulsoft_extensions_injection.*

interface IDbConnection <: Resource {
    func isClosed(): Bool
}

class MySqlConnection <: IDbConnection {
    private var _isClosed = false

    public func isClosed(): Bool {
        _isClosed
    }

    public func close(): Unit {
        // 作用域结束时释放瞬时对象。
        _isClosed = true
    }
}

main(): Int64 {
    let services = ServiceCollection()
    // 注册瞬时服务,每次解析都创建新实例。
    services.addTransient<IDbConnection, MySqlConnection>()
    let root = services.build()

    var fromScope1First: ?IDbConnection = None
    var fromScope1Second: ?IDbConnection = None
    var fromScope2: ?IDbConnection = None

    try (scope1 = root.createScope()) {
        fromScope1First = scope1.services.getOrThrow<IDbConnection>()
        fromScope1Second = scope1.services.getOrThrow<IDbConnection>()

        // 同一作用域内,两次瞬时解析得到不同对象。
        println(refEq(
            (fromScope1First.getOrThrow() as Object).getOrThrow(),
            (fromScope1Second.getOrThrow() as Object).getOrThrow()
        ))
    }

    try (scope2 = root.createScope()) {
        fromScope2 = scope2.services.getOrThrow<IDbConnection>()
    }

    // 比较跨作用域实例,并查看资源释放状态。
    println(refEq(
        (fromScope1First.getOrThrow() as Object).getOrThrow(),
        (fromScope2.getOrThrow() as Object).getOrThrow()
    ))
    println(fromScope1First.getOrThrow().isClosed())
    println(fromScope1Second.getOrThrow().isClosed())
    return 0
}

这个例子会体现 3 件事:

  • 同一作用域内,两次解析也会得到不同实例
  • 不同作用域之间,拿到的仍然是不同实例
  • 如果瞬时服务实现了 Resource,作用域结束后也会被释放

生命周期规则

实际使用时,有两条规则需要记住:

  1. 单例不要依赖作用域服务
  2. 开启 validateScopes 后,不要从根容器直接解析 Scoped 服务

这是源码和单测都明确覆盖过的行为。

作用域校验与构建校验

构建容器时可以配置两个开关:

cangjie
// 开启作用域校验和构建期预检查。
let root = services.build({ options =>
    options.validateScopes = true
    options.validateOnBuild = true
})

它们的默认值都为 false

validateScopes

开启后,容器会额外检查作用域相关的错误。

最常见的一类错误是:

  • 从根容器直接解析 Scoped 服务
  • 从根容器解析一个依赖了 Scoped 服务的对象图

validateOnBuild

开启后,容器会在 build() 阶段提前验证服务图。

但这里有一个容易误解的点:

  • 它不是“所有错误都在构建期报出来”
  • 按当前实现,它主要在构建期帮助你尽早发现“单例依赖作用域服务”这类问题

例如,下面这个注册在同时开启两个开关时,会在 build() 阶段失败:

cangjie
let services = ServiceCollection()
services.addScoped<IDbConnection, MySqlConnection>()
services.addSingleton<DbContext, DbContext>()

// 单例依赖 scoped 服务时,构建阶段就会失败。
services.build({ options =>
    options.validateScopes = true
    options.validateOnBuild = true
})

原因是 DbContext 是单例,但它依赖 IDbConnection,而 IDbConnection 被注册成了 Scoped

构造函数注入

对于普通类型映射注册,容器会根据构造函数参数继续解析依赖。

例如:

cangjie
interface IDbConnection {}

class MySqlConnection <: IDbConnection {}

class DbContext {
    public DbContext(public let connection: IDbConnection) {}
}

let services = ServiceCollection()
// 注册依赖关系后,容器会自动完成构造函数注入。
services.addSingleton<IDbConnection, MySqlConnection>()
services.addSingleton<DbContext, DbContext>()

let root = services.build()
let context = root.getOrThrow<DbContext>()

这里 DbContext 并没有自己写工厂函数,但依然能被正常构造,因为容器会继续解析它构造函数里的 IDbConnection

ActivatorUtilities

有时候你并不想把某个类型注册进容器,但又希望它的依赖仍然从容器中解析。 这时可以使用 ActivatorUtilities

cangjie
import soulsoft_extensions_injection.*

interface IDbConnection {}

class MySqlConnection <: IDbConnection {}

class DbContext {
    public DbContext(public let connection: IDbConnection) {}
}

class DbDataSource {
    public DbDataSource(public let context: DbContext, public let name: String) {}
}

main(): Int64 {
    let services = ServiceCollection()
    services.addSingleton<IDbConnection, MySqlConnection>()
    services.addSingleton<DbContext, DbContext>()

    let root = services.build()
    // 手动传入 name,其余依赖由容器补齐。
    let source = ActivatorUtilities.createInstance<DbDataSource>(root, ["reporting"])

    println(source.name)
    println(source.context.connection is MySqlConnection)
    return 0
}

这个例子里:

  • DbDataSource 没有注册到容器
  • "reporting" 由调用方显式传入
  • DbContext 仍然由容器负责解析

按当前实现,匹配规则是:

  • 用户传入的参数优先匹配构造函数参数
  • 剩余参数再从容器解析
  • 如果找不到合适构造函数,会抛出异常
  • 如果构造函数存在歧义,也会抛出异常

内置服务

有几类服务不需要手动注册,容器会自动提供:

  • IServiceProvider
  • IServiceScopeFactory
  • IServiceProviderIsService
  • IServiceProviderIsKeyedService

例如:

cangjie
let services = ServiceCollection()
let root = services.build()

// 这些基础服务由容器自动提供。
let scopeFactory = root.getOrThrow<IServiceScopeFactory>()
let checker = root.getOrThrow<IServiceProviderIsService>()

println(checker.isService<IServiceProvider>())

其中:

  • IServiceScopeFactory 用来创建作用域
  • IServiceProviderIsService 用来判断某个类型是否可解析
  • IServiceProviderIsKeyedService 用来判断某个 key 下的类型是否可解析

进阶:keyed service 的构造函数注入

如果你已经在使用 keyed service,可以进一步配合两个注解:

  • @FromKeyedServices
  • @ServiceKey

显式指定 key

cangjie
class ExplicitKeyedService {
    public let connection: IDbConnection

    public ExplicitKeyedService(@FromKeyedServices["mysql"] connection: IDbConnection) {
        // 强制从 mysql 这个 key 对应的注册项解析依赖。
        this.connection = connection
    }
}

这表示:无论外层服务怎么注册,这个参数都固定取 "mysql" 对应的实现。

继承当前服务的 key

cangjie
class InheritKeyedService {
    public let connection: IDbConnection

    public InheritKeyedService(@FromKeyedServices connection: IDbConnection) {
        // 不显式写 key 时,继承当前服务的注册 key。
        this.connection = connection
    }
}

如果 InheritKeyedService 自己是以 "mysql" 注册的,那么这里会自动继承 "mysql"

把当前 key 注入为字符串

cangjie
class ServiceKeyReceiver {
    public let key: String

    public init(@ServiceKey key: String) {
        // 把当前服务的 key 直接注入为字符串。
        this.key = key
    }
}

常见注意事项

build() 之后不能再修改 ServiceCollection

ServiceCollection.build() 调用之后,集合会变成只读状态。 如果你继续增删改服务,会抛出异常。

cangjie
let services = ServiceCollection()
services.addSingleton<IDbConnection, MySqlConnection>()
services.build()

// build() 之后再修改集合会抛异常。
services.addSingleton<DbContext, DbContext>() // 这里会抛异常

多实现场景要分清“单个解析”和“全部解析”

  • 想拿“当前默认实现”,用 get<T>()getOrThrow<T>()
  • 想拿“全部实现”,用 getAll<T>()

这两类 API 的行为不同,不要混用。

validateOnBuild 不是万能预检

如果你开启了 validateOnBuild,确实能更早发现一部分问题; 但不要把它理解成“构建成功就代表运行期一定不会出错”。

尤其是作用域问题,还需要结合 validateScopes 一起理解。

小结

初学时,建议按下面的顺序掌握:

  1. 先熟悉 ServiceCollectionbuild()getOrThrow<T>()
  2. 再理解 SingletonScopedTransient 的区别
  3. 接着掌握工厂注册和 ActivatorUtilities
  4. 最后再看 keyed service、@FromKeyedServices@ServiceKey

如果你只记住一句话,可以记这一句:

ServiceCollection 负责“描述要注册什么”,IServiceProvider 负责“真正把对象解析出来”。