Skip to content

依赖注入

ServiceCollection 收敛注册,用构造函数声明依赖,用作用域管理资源释放。

适用场景

  • 应用里已经有日志、缓存、HTTP、数据库等基础设施,初始化方式开始分散。
  • 业务类只想声明"我需要什么",不想自己 new 依赖。
  • 需要区分全局单例、请求级对象和每次调用的新对象。
  • 希望把一组内部注册封成一个模块入口,对外只暴露一行 services.addXxx()

如果这些问题已经同时出现,需要统一的往往就不是某一个组件。

真正需要统一的是"注册、构建、解析、释放"这一整条对象管理链路。

核心问题

依赖注入解决的是"谁负责创建对象、谁负责拼装依赖、谁负责管理生命周期"这三个问题。

在当前实现里:

  • ServiceCollection 负责收集注册。
  • build() 负责构建根容器。
  • IServiceProvider 负责解析服务。
  • IServiceScope 负责管理作用域。

build() 之后,注册集合会被锁定。后续所有解析,都围绕根容器和作用域进行。

这张图的主线是:所有注册收敛到 ServiceCollectionbuild() 之后进入解析阶段,后续通过 IServiceProviderIServiceScope 获取服务实例。

这个模型的关键价值不是"少写几个 new",而是把模块接入统一收敛到注册阶段,把对象创建和释放交给容器。

快速开始

先引入 soulsoft_extensions_injection,按"定义服务 → 注册服务 → 构建容器 → 解析服务"这条最小路径走一遍。

cangjie
import soulsoft_extensions_injection.*

class ChatOptions {
    public var apiKey: String = ""
    public var model: String = "gpt-4o"
}

class ChatClient {
    public init(options: ChatOptions) {}
}

main(): Int64 {
    let services = ServiceCollection()
    let options = ChatOptions()
    options.apiKey = "sk-xxx"

    services.addSingleton<ChatOptions>(options)
    services.addTransient<ChatClient, ChatClient>()

    // build() 之后注册集合锁定,开始进入解析阶段
    let provider = services.build()
    // 容器会递归解析 ChatClient 构造函数里的 ChatOptions
    let client = provider.getOrThrow<ChatClient>()
    return 0
}

信息

这段最小示例已在 E:\gitcode\demo 中通过 cjpm build 验证。

当容器解析 ChatClient 时,会继续解析它构造函数里的 ChatOptions,业务代码不需要再手动把依赖一层层传进去。

核心概念

注册阶段

注册阶段的职责只有一个:把"服务类型"和"实现方式"放进 ServiceCollection

当前实现支持三类实现方式:

  • 类型映射:addSingleton<I, T>()
  • 现成实例:addSingleton<T>(instance)
  • 工厂函数:addSingleton<T> { sp => ... }

ServiceCollectionbuild() 之前是可变的;build() 之后再调用 addremoveclear 会抛 UnsupportedException

构建阶段

build() 不只是"拿到一个 provider",它还做了三件关键事情:

  1. 锁定 ServiceCollection,禁止后续继续改注册。
  2. 创建根 IServiceProvider 和根作用域。
  3. 根据选项决定是否启用作用域校验和构建期校验。

如果开启 validateOnBuild = true,容器会在构建阶段遍历服务图;如果多个服务同时校验失败,会聚合成 AggregateException

解析阶段

build() 返回 IServiceProvider。之后常用的解析方式有三种:

  • get<T>():返回 Option<T>,适合可选依赖。
  • getOrThrow<T>():解析失败直接抛 UnsupportedException,适合必需依赖。
  • getAll<T>():返回同一服务类型的全部实现;无注册时返回空集合。

同一服务类型注册多个实现时:

  • getOrThrow<T>() 返回最后注册的那个实现。
  • getAll<T>() 按注册顺序返回全部实现。

生命周期与作用域

当前容器支持三种生命周期:

生命周期行为典型场景
Singleton根容器内只创建一次默认选项、全局共享客户端
Scoped每个作用域内创建一次单次对话会话、流式会话、请求级状态
Transient每次解析都创建新实例Prompt 构造器、轻量工具服务

IServiceScope 继承 Resource,因此可以直接放进 try。作用域关闭时,当前作用域里捕获到的 Resource 服务会被一起释放。

先只看一个问题:同一次解析、同一作用域、不同作用域下,实例会不会复用。

cangjie
import soulsoft_extensions_injection.*

class ChatOptions {}

let services = ServiceCollection()
services.addSingleton<ChatOptions, ChatOptions>() // 类型映射:根容器内只创建一次

let root = services.build()
let a = root.getOrThrow<ChatOptions>()
let b = root.getOrThrow<ChatOptions>()

// a 和 b 指向同一个实例
cangjie
import soulsoft_extensions_injection.*

class ChatSession {
    public init() {}
}

let services = ServiceCollection()
services.addScoped<ChatSession, ChatSession>() // 类型映射:每个 scope 一个实例

let root = services.build()

try (scope1 = root.createScope()) {
    let a = scope1.services.getOrThrow<ChatSession>()
    let b = scope1.services.getOrThrow<ChatSession>()
    // 同一 scope 内复用同一个实例
}

try (scope2 = root.createScope()) {
    let c = scope2.services.getOrThrow<ChatSession>()
    // c 与 scope1 中的实例不同
}
cangjie
import soulsoft_extensions_injection.*

class ChatPromptBuilder {}

let services = ServiceCollection()
services.addTransient<ChatPromptBuilder, ChatPromptBuilder>() // 类型映射:每次解析都新建

let root = services.build()
let a = root.getOrThrow<ChatPromptBuilder>()
let b = root.getOrThrow<ChatPromptBuilder>()

// a 和 b 是不同实例

警告

不要从根容器直接解析 Scoped 服务。

根容器的生命周期长于单个请求或单个业务作用域。这样解析会把本应短期存在的对象错误地挂到更长生命周期上。开启 validateScopes 后,容器会在根容器解析时直接抛异常。

上面的对比只回答了"是否复用实例"。

ScopedTransient 遇到 Resource 时,释放时机会绑定到作用域边界。作用域内部的 closedInsideScopefalse,作用域结束后的 closedAfterScopetrue

cangjie
import soulsoft_extensions_injection.*

interface IChatSession <: Resource {
    func send(chunk: String): Unit
    func isClosed(): Bool
}

class ChatSession <: IChatSession {
    private var closed = false

    public func send(chunk: String): Unit {
        println(chunk)
    }

    public func close(): Unit {
        closed = true
    }

    public func isClosed(): Bool {
        closed
    }
}

let services = ServiceCollection()
services.addScoped<IChatSession, ChatSession>() // 类型映射:会话跟着当前 scope 生命周期走

let root = services.build()
var session: ?IChatSession = None
var closedInsideScope = false

try (scope = root.createScope()) {
    session = scope.services.getOrThrow<IChatSession>()
    session.getOrThrow().send("hello")
    // scope 还活着,所以会话还没被释放
    closedInsideScope = session.getOrThrow().isClosed()
}

let closedAfterScope = session.getOrThrow().isClosed()

服务实现了 Resource 之后,容器会在作用域结束时自动调用 close()

生命周期铁律:长生命周期服务不要直接持有短生命周期服务。Singleton → Scoped → Transient 方向不能依赖,反向可以。最常见错误是 Singleton 持有 Scoped——资源释放时机错乱或隐藏状态污染。

构造函数注入

类型映射的真正价值在于构造函数注入。容器会读取目标类型的 public 构造函数,并尝试为每个参数继续解析依赖。

多构造函数如何选择

当前实现会遍历 public 构造函数,选择一个"参数都能解析"的构造函数。

如果存在多个可行构造函数,且它们的参数类型集合不同,会抛歧义异常。

源码里没有对构造函数做显式排序,因此不要依赖重载顺序。最稳妥的做法是:每个服务类只保留一个 public 构造函数。这样做的目的不是"代码更好看",而是避免容器在多个可选构造函数之间出现歧义,让依赖关系始终可预测。

构建期校验

validateScopesvalidateOnBuild 解决的是两类不同问题:

  • validateScopes:拦截根容器误解析 Scoped,并检查 Singleton 是否消费 Scoped。
  • validateOnBuild:在 build() 时提前遍历服务图,尽早暴露缺失依赖或无法构造的服务。

下面这个违规场景:ChatClient 是 Singleton,却依赖了 IChatSession(Scoped),违反生命周期铁律。开启 validateScopesvalidateOnBuild 两个选项后,这类问题会在构建时更早暴露:

cangjie
import soulsoft_extensions_injection.*

interface IChatSession <: Resource {
    func send(chunk: String): Unit
}

class ChatSession <: IChatSession {
    private var closed = false

    public func send(chunk: String): Unit {}

    public func close(): Unit {
        closed = true
    }

    public func isClosed(): Bool {
        closed
    }
class ChatClient {
    public let session: IChatSession

    // 故意让 Singleton 依赖 Scoped 服务,用来演示校验行为
    public init(session: IChatSession) {
        this.session = session
    }
}

let services = ServiceCollection()
services.addScoped<IChatSession, ChatSession>()
services.addSingleton<ChatClient, ChatClient>() // Singleton 消费 Scoped,构建时会被拦截

let root = services.build({ options =>
    options.validateScopes = true   // 校验作用域违规
    options.validateOnBuild = true  // 在 build() 时提前验证服务图
})

如果只开 validateOnBuild 而不开 validateScopes,就不会触发 Singleton 消费 Scoped 这一类规则检查。

原因是前者负责"服务图能不能构建",后者负责"生命周期边界有没有被破坏"。两者解决的不是同一个问题。

内置服务

容器构建后,下面 4 个服务会自动可解析,不需要手工注册:

服务用途
IServiceProvider当前容器本身
IServiceScopeFactory创建作用域
IServiceProviderIsService判断某个类型是否可解析
IServiceProviderIsKeyedService判断某个 key 下的类型是否可解析
cangjie
import soulsoft_extensions_injection.*

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

let inspector = root.getOrThrow<IServiceProviderIsService>()
let canResolve = inspector.isService<IServiceProvider>() // 内置服务会返回 true

这 4 个内置服务中最常用的两个是:需要创建作用域时用 IServiceScopeFactory,需要判断某类型是否已注册时用 IServiceProviderIsService

常见用法

注册与解析

同一个目标可以用不同方式注册,什么时候选哪一种,取决于实例由谁决定。

cangjie
import soulsoft_extensions_injection.*

interface IChatTransport {
    func send(prompt: String): String
}

class OpenAiChatTransport <: IChatTransport {
    public func send(prompt: String): String {
        return "ok"
    }
}

let services = ServiceCollection()
services.addSingleton<IChatTransport, OpenAiChatTransport>()
cangjie
import soulsoft_extensions_injection.*

class ChatOptions {
    public init(
        public let apiKey: String,
        public let model: String
    ) {}
}

let services = ServiceCollection()
let options = ChatOptions("sk-xxx", "gpt-4o")
services.addSingleton<ChatOptions>(options)
cangjie
import soulsoft_extensions_injection.*

interface IChatTransport {
    func send(prompt: String): String
}

class OpenAiChatTransport <: IChatTransport {
    public init(baseUrl: String) {}
    public func send(prompt: String): String {
        return "openai"
    }
}

class MockChatTransport <: IChatTransport {
    public func send(prompt: String): String {
        return "mock"
    }
}

func isProduction(): Bool {
    return true
}

let services = ServiceCollection()
services.addSingleton<IChatTransport> { sp =>
    if (isProduction()) {
        OpenAiChatTransport("https://api.openai.com")
    } else {
        MockChatTransport()
    }
}

三种方式的适用场景分别是:类型映射适合"接口到实现的固定绑定"、实例注册适合"调用方已创建好并希望容器持有的对象"、工厂注册适合"需要运行时条件判断或构造参数需要来自其他服务"。

如果你不希望后续注册覆盖已有服务,用 tryAdd*。它按 serviceType + serviceKey 判重。

如果你需要"保留多个实现,但避免同一个实现重复加入",用 tryAddEnumerable。它按 serviceType + implementationType + serviceKey 判重,更适合多实现枚举场景。

解析阶段最常用的是 getgetOrThrowgetAll。下面这三行分别对应可选依赖、必需依赖和全量获取:

cangjie
import soulsoft_extensions_injection.*

interface IChatTransport {
    func send(prompt: String): String
}

class OpenAiChatTransport <: IChatTransport {
    public func send(prompt: String): String {
        return "ok"
    }
}

let services = ServiceCollection()
services.addSingleton<IChatTransport, OpenAiChatTransport>()

let provider = services.build()
let optional = provider.get<IChatTransport>()         // 可选依赖:返回 Option<IChatTransport>
let required = provider.getOrThrow<IChatTransport>()  // 必需依赖:缺失时直接抛异常
let all = provider.getAll<IChatTransport>()           // 同类型全部实现;无注册时返回空集合

getAll<T>() 解析。transports 包含全部实现,transport 只拿到最后一个:

cangjie
import soulsoft_extensions_injection.*

interface IChatTransport {
    func send(prompt: String): String
}

class OpenAiChatTransport <: IChatTransport {
    public func send(prompt: String): String {
        return "openai"
    }
}

class ClaudeChatTransport <: IChatTransport {
    public func send(prompt: String): String {
        return "claude"
    }
}

let services = ServiceCollection()
services.addSingleton<IChatTransport, OpenAiChatTransport>()
services.addSingleton<IChatTransport, ClaudeChatTransport>()

let provider = services.build()
let transports = provider.getAll<IChatTransport>().toArray()
let transport = provider.getOrThrow<IChatTransport>() // 返回最后注册的 ClaudeChatTransport

这里的结论是:getOrThrow<T>() 的覆盖语义是"最后注册胜出";如果需要保留全部实现,唯一的正确用法是 getAll<T>()

作用域与资源释放

作用域的基本用法是:把请求级或资源型对象放进 Scoped,然后在业务边界上创建作用域。如果服务持有连接、句柄或其他需要显式释放的资源,让它实现 Resource,由作用域负责回收。

警告

根容器解析 Scoped 是常见错误。因为在根容器层面不存在"请求结束"这种边界,Scoped 对象会被错误地挂到应用级生命周期上。始终在 createScope() 返回的作用域内解析 Scoped 服务。

带键服务

当同一个接口需要并存多个实现时,可以按 key 注册,并在解析或构造函数参数上显式指定 key。注册阶段用 addKeyedSingleton<T, Impl>("key"),解析阶段用 getOrThrow<T>("key")

cangjie
import soulsoft_extensions_injection.*

interface IChatTransport {
    func send(prompt: String): String
}

class OpenAiChatTransport <: IChatTransport {
    public func send(prompt: String): String {
        return "openai"
    }
}

class ClaudeChatTransport <: IChatTransport {
    public func send(prompt: String): String {
        return "claude"
    }
}

let services = ServiceCollection()
services.addKeyedSingleton<IChatTransport, OpenAiChatTransport>("openai")
services.addKeyedSingleton<IChatTransport, ClaudeChatTransport>("claude")

let provider = services.build()
let openai = provider.getOrThrow<IChatTransport>("openai")     // 解析 openai 通道
let claude = provider.getOrThrow<IChatTransport>("claude")     // 解析 claude 通道

构造函数参数支持两种注解:

  • @FromKeyedServices["openai"]:显式指定 key。
  • @FromKeyedServices:不写 key 时继承父服务的注册 key。
  • @ServiceKey:把当前服务的注册 key 作为 String 注入。

两个同类型参数靠不同的 @FromKeyedServices["key"] 区分:

cangjie
import soulsoft_extensions_injection.*

interface IChatTransport {
    func send(prompt: String): String
}

class MultiProviderChatClient {
    public let openai: IChatTransport
    public let claude: IChatTransport

    public init(
        @FromKeyedServices["openai"] openai: IChatTransport,
        @FromKeyedServices["claude"] claude: IChatTransport
    ) {
        this.openai = openai
        this.claude = claude
    }
}

如果你希望把当前注册 key 本身注入进服务,用 @ServiceKey

cangjie
import soulsoft_extensions_injection.*

class KeyedChatTransport {
    public let key: String

    public init(@ServiceKey key: String) {
        this.key = key
    }
}

服务不需要关心自己注册在哪个 key 下,运行时通过 @ServiceKey 就能拿到当前 key。

ActivatorUtilities

有些类型不想注册进容器,但它的依赖仍然希望由容器补齐。这时用 ActivatorUtilities.createInstance。"把下面内容翻译成英文" 由调用方传入,ChatClient` 由容器补齐:

cangjie
import soulsoft_extensions_injection.*

class ChatOptions {
    public init(public let model: String) {}
}

class ChatClient {
    public init(options: ChatOptions) {}
}

class PromptRunner {
    public let client: ChatClient
    public let prompt: String

    public init(client: ChatClient, prompt: String) {
        this.client = client
        this.prompt = prompt
    }
}

let services = ServiceCollection()
services.addSingleton<ChatOptions>(ChatOptions("gpt-4o"))
services.addTransient<ChatClient, ChatClient>()
let provider = services.build()
// prompt 由调用方传入,其余参数仍然从容器补齐
let runner = ActivatorUtilities.createInstance<PromptRunner>(provider, ["把下面内容翻译成英文"])

ActivatorUtilities 适合这样的场景:类型本身不注册进容器,但构造时仍希望由容器补齐依赖。调用方只负责提供容器不知道的那部分参数。

最佳实践

前面的示例解决的是"单个服务怎么注册"。

真正进入项目后,更重要的是"整组注册怎么组织、默认实现怎么留替换点"。

对外收敛成 addXxx() 入口

当前框架里的 addAuthorization()addDistributedMemoryCache()addHttpClient() 都采用同一种组织方式:对外只暴露一个注册入口,对内再拆分默认服务、可选配置和补充注册。

如果业务模块已经包含一组固定依赖,推荐把这组注册集中到一个扩展入口里。模块内部维护默认实现,启动代码只决定"接入哪个模块":

cangjie
import soulsoft_extensions_injection.*

class ChatOptions {
    public var apiKey: String = ""
    public var model: String = "gpt-4o"
}

interface IChatTransport {
    func send(prompt: String): String
}

class OpenAiChatTransport <: IChatTransport {
    public func send(prompt: String): String {
        return "ok"
    }
}

class ChatClient {
    private let transport: IChatTransport
    private let options: ChatOptions

    public init(transport: IChatTransport, options: ChatOptions) {
        this.transport = transport
        this.options = options
    }
}

public interface ChatClientServiceCollectionExtensions {
    func addChatClient(): ServiceCollection
}

extend ServiceCollection <: ChatClientServiceCollectionExtensions {
    public func addChatClient(): ServiceCollection {
        // 默认实现集中在模块入口里,启动代码不用知道内部细节
        this.tryAddSingleton<ChatOptions>(ChatOptions())
        this.tryAddSingleton<IChatTransport, OpenAiChatTransport>()
        // 模块主服务仍由模块入口显式补齐
        this.addSingleton<ChatClient, ChatClient>()
        return this // 保持链式调用
    }
}

main(): Int64 {
    let provider = ServiceCollection()
        .addChatClient()
        .build()

    _ = provider.getOrThrow<ChatClient>() // 能成功解析,说明模块入口已把主流程依赖补齐
    return 0
}

启动代码只决定"接入哪个模块",模块内部自己维护默认注册关系。

默认实现用 tryAdd*,主流程入口保持显式

模块化入口里,推荐把"允许调用方替换的默认实现"和"模块必须具备的主流程服务"分开处理:

场景推荐写法原因
模块默认依赖tryAddSingleton / tryAddScoped / tryAddTransient调用方可以先注册自定义实现,再复用模块入口
多实现枚举tryAddEnumerable保留多个实现,同时避免同一个实现重复加入
模块主服务addSingleton<ChatClient, ChatClient>() 这类显式注册保证 addXxx() 一次调用就能把模块主流程补齐
资源型服务Scoped + createScope()让释放时机和业务边界一致

这也是当前源码里多个模块的共同模式:公开 addXxx() 作为接入入口,内部用 tryAdd* 保留覆盖空间,再按需要继续拆出 addXxxCore() 或 Builder。

可扩展能力

除上述最佳实践外,当前源码已经暴露了这些能力入口和扩展点,分为"已支持能力"和"可扩展方向"两类。

已支持能力

  • 模块化注册入口:通过 extend ServiceCollection 封装 addXxx(),把一组内部注册收敛成一个入口。
  • 条件实现选择:工厂注册可在运行时基于环境、配置或其他服务决定最终实现。
  • 多实现并存:同一服务类型既可以靠"最后注册覆盖默认解析",也可以通过 getAll<T>() 保留全部实现。
  • 按 key 组装对象图:@FromKeyedServices@ServiceKey 允许在构造函数层面表达带键依赖。
  • 能力探测:IServiceProviderIsServiceIServiceProviderIsKeyedService 可用于判断某个服务是否可解析。
  • 未注册类型实例化:ActivatorUtilities 允许把容器当作构造函数补参器,而不是只能解析已注册类型。

可扩展方向——基于当前公开入口,适合继续往外扩展的方向有三类:

  • 为每个功能模块提供统一注册入口,例如 addChatClient()addChatGatewayClient()addBlogApplication()
  • 为同一接口提供多实现策略,例如按环境、租户或模型提供方切换 IChatTransport 实现。
  • 把"模块配置 + 依赖注入"做成固定模式,让调用方只关心一行注册,不关心模块内部有多少依赖。

如果某个"扩展点"既没有公开注册入口,也没有接口/工厂/注解作为依据,就不要把它写成已支持能力。

相关专题

生产建议

  • 必需依赖优先用 getOrThrow<T>() 暴露配置错误;可选依赖再用 get<T>()
  • 长生命周期服务不要依赖短生命周期服务,尤其避免 Singleton 持有 Scoped。
  • validateScopes = true 建议长期开启;validateOnBuild = true 适合在启动阶段提前暴露缺失依赖,两者建议一起用。
  • 不要把 IServiceProvider 当作通用逃生门到处注入。只有在延迟解析、动态选择实现或 ActivatorUtilities 这类场景下再使用它。
  • 模块对外优先暴露 addXxx(),不要把 10 行注册散落在启动代码里。

常见问题

build() 之后还能继续注册吗

不能。ServiceCollectionbuild() 之后会进入只读状态,再调用 addremoveclear 会抛异常。

为什么 validateOnBuild 开了,根容器解析 Scoped 还是要到运行时才报错

因为这类错误发生在"解析阶段",不是"构建阶段"。validateOnBuild 负责提前验证服务图;根容器误解析 Scoped 仍然发生在实际 getOrThrow() 时。

什么时候该用 keyed service

同一接口必须并存多个实现,并且调用方需要显式区分它们时再使用。只是"可能以后要扩展"时,不要先上 keyed service。