依赖注入
用
ServiceCollection收敛注册,用构造函数声明依赖,用作用域管理资源释放。
适用场景
- 应用里已经有日志、缓存、HTTP、数据库等基础设施,初始化方式开始分散。
- 业务类只想声明"我需要什么",不想自己
new依赖。 - 需要区分全局单例、请求级对象和每次调用的新对象。
- 希望把一组内部注册封成一个模块入口,对外只暴露一行
services.addXxx()。
如果这些问题已经同时出现,需要统一的往往就不是某一个组件。
真正需要统一的是"注册、构建、解析、释放"这一整条对象管理链路。
核心问题
依赖注入解决的是"谁负责创建对象、谁负责拼装依赖、谁负责管理生命周期"这三个问题。
在当前实现里:
ServiceCollection负责收集注册。build()负责构建根容器。IServiceProvider负责解析服务。IServiceScope负责管理作用域。
build() 之后,注册集合会被锁定。后续所有解析,都围绕根容器和作用域进行。
这张图的主线是:所有注册收敛到 ServiceCollection,build() 之后进入解析阶段,后续通过 IServiceProvider 或 IServiceScope 获取服务实例。
这个模型的关键价值不是"少写几个 new",而是把模块接入统一收敛到注册阶段,把对象创建和释放交给容器。
快速开始
先引入 soulsoft_extensions_injection,按"定义服务 → 注册服务 → 构建容器 → 解析服务"这条最小路径走一遍。
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 => ... }
ServiceCollection 在 build() 之前是可变的;build() 之后再调用 add、remove、clear 会抛 UnsupportedException。
构建阶段
build() 不只是"拿到一个 provider",它还做了三件关键事情:
- 锁定
ServiceCollection,禁止后续继续改注册。 - 创建根
IServiceProvider和根作用域。 - 根据选项决定是否启用作用域校验和构建期校验。
如果开启 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 服务会被一起释放。
先只看一个问题:同一次解析、同一作用域、不同作用域下,实例会不会复用。
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 指向同一个实例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 中的实例不同
}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 后,容器会在根容器解析时直接抛异常。
上面的对比只回答了"是否复用实例"。
Scoped 和 Transient 遇到 Resource 时,释放时机会绑定到作用域边界。作用域内部的 closedInsideScope 是 false,作用域结束后的 closedAfterScope 是 true:
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 构造函数。这样做的目的不是"代码更好看",而是避免容器在多个可选构造函数之间出现歧义,让依赖关系始终可预测。
构建期校验
validateScopes 和 validateOnBuild 解决的是两类不同问题:
validateScopes:拦截根容器误解析Scoped,并检查 Singleton 是否消费 Scoped。validateOnBuild:在build()时提前遍历服务图,尽早暴露缺失依赖或无法构造的服务。
下面这个违规场景:ChatClient 是 Singleton,却依赖了 IChatSession(Scoped),违反生命周期铁律。开启 validateScopes 和 validateOnBuild 两个选项后,这类问题会在构建时更早暴露:
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 下的类型是否可解析 |
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。
常见用法
注册与解析
同一个目标可以用不同方式注册,什么时候选哪一种,取决于实例由谁决定。
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>()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)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 判重,更适合多实现枚举场景。
解析阶段最常用的是 get、getOrThrow 和 getAll。下面这三行分别对应可选依赖、必需依赖和全量获取:
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 只拿到最后一个:
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"):
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"] 区分:
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:
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` 由容器补齐:
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() 都采用同一种组织方式:对外只暴露一个注册入口,对内再拆分默认服务、可选配置和补充注册。
如果业务模块已经包含一组固定依赖,推荐把这组注册集中到一个扩展入口里。模块内部维护默认实现,启动代码只决定"接入哪个模块":
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允许在构造函数层面表达带键依赖。 - 能力探测:
IServiceProviderIsService与IServiceProviderIsKeyedService可用于判断某个服务是否可解析。 - 未注册类型实例化:
ActivatorUtilities允许把容器当作构造函数补参器,而不是只能解析已注册类型。
可扩展方向——基于当前公开入口,适合继续往外扩展的方向有三类:
- 为每个功能模块提供统一注册入口,例如
addChatClient()、addChatGatewayClient()、addBlogApplication()。 - 为同一接口提供多实现策略,例如按环境、租户或模型提供方切换
IChatTransport实现。 - 把"模块配置 + 依赖注入"做成固定模式,让调用方只关心一行注册,不关心模块内部有多少依赖。
如果某个"扩展点"既没有公开注册入口,也没有接口/工厂/注解作为依据,就不要把它写成已支持能力。
相关专题
- 基础设施总览 — 七项基础设施的协作关系和组合案例
- 选项模式 — 把配置对象注册进容器,业务服务只注入强类型选项
- 日志记录 —
ILoggerFactory作为 Singleton 注入容器 - SqlSharp 依赖注入集成 —
addDbContext<TDbContext>()注册为 Scoped - 认证与授权 — 认证处理器、策略提供者等服务通过 DI 管理生命周期
生产建议
- 必需依赖优先用
getOrThrow<T>()暴露配置错误;可选依赖再用get<T>()。 - 长生命周期服务不要依赖短生命周期服务,尤其避免 Singleton 持有 Scoped。
validateScopes = true建议长期开启;validateOnBuild = true适合在启动阶段提前暴露缺失依赖,两者建议一起用。- 不要把
IServiceProvider当作通用逃生门到处注入。只有在延迟解析、动态选择实现或ActivatorUtilities这类场景下再使用它。 - 模块对外优先暴露
addXxx(),不要把 10 行注册散落在启动代码里。
常见问题
build() 之后还能继续注册吗
不能。ServiceCollection 在 build() 之后会进入只读状态,再调用 add、remove、clear 会抛异常。
为什么 validateOnBuild 开了,根容器解析 Scoped 还是要到运行时才报错
因为这类错误发生在"解析阶段",不是"构建阶段"。validateOnBuild 负责提前验证服务图;根容器误解析 Scoped 仍然发生在实际 getOrThrow() 时。
什么时候该用 keyed service
同一接口必须并存多个实现,并且调用方需要显式区分它们时再使用。只是"可能以后要扩展"时,不要先上 keyed service。