想象你走进一家餐厅。你不需要亲自去菜市场挑肉,不需要在后厨颠勺,更不需要研究每道菜的配方。你只做一件事:看着菜单,告诉服务员“我要一份红烧肉”。至于猪肉来自哪头猪、酱油用什么牌子、火候怎么拿捏,全都交给后厨处理。这就是控制反转(IoC)——把“做菜的控制权”从你手里,交到了厨房手上。 而依赖注入(DI)呢?你点的菜端上来时,红烧肉里已经配好了佐料、点缀、摆盘。这些“依赖”不是你自己跑去后厨拿的,而是厨师做好后“注入”到你的餐盘里。你的嘴巴只管享受,完全不需要关心菜是怎么被做出来的。 这篇教程要讲的,就是软件世界里的“后厨”——IoC容器。在程序中,对象之间常有错综复杂的依赖关系:一个“订单服务”依赖“支付服务”和“库存服务”。传统做法是让订单服务自己new出这些依赖,就像顾客自己跑去菜市场买菜再亲自下厨。而有了IoC容器,对象只需要声明“我要什么”,容器就会把装配好的依赖送过来。让我们一起走进这间“中央厨房”,看看DI和IoC容器到底是怎么运转的。

岗位关联

对AI从业者而言,依赖注入是组织机器学习代码、解耦数据管道与模型服务的关键技能——它让模型训练、特征工程和推理服务各自独立,又能在容器中灵活装配,极大提升工程可维护性与可测试性。

目录
  1. 可视化演示
  2. 基本概念辨析
  3. IoC容器原理
  4. 注入方式详解
  5. 解耦工程实践
  6. 高级应用模式
  7. 性能优化策略
  8. 常见反模式
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

基本概念辨析

你走进餐厅坐下,绝不会自己跑进后厨抢过炒勺说“我来做”。但很多程序却不是这样:一个订单服务在创建时,经常自己new一个支付服务,再new一个库存服务,就像顾客既当食客又当厨子又当采购员。这样的代码,好比特级大厨不得不从种菜开始做起——每个对象都不得不亲手构建自己需要的所有东西。

在软件中,一个对象(比如订单服务)完成自身职责时,往往要调用其他对象(支付服务、库存服务)的功能。这些“被依赖的其他人”,就叫做依赖(Dependency)。当一个对象自行创建依赖时,控制和依赖关系都死死绑定在代码里,改一处就牵动全局。这正是设计原则要解决的问题。

依赖注入(Dependency Injection,DI)的核心思想是:对象不自己创建依赖,而是由外部传入。这个“外部”可以是构造函数参数、属性赋值,或者专门的容器。控制反转(Inversion of Control,IoC)则是更宏观的指导原则:把程序流程的控制权从业务代码反向转移到框架或容器。DI是IoC最常见的一种落地方式,但不是唯一方式——事件回调、模板方法同样是IoC的表现形式。一句话概括:IoC是思想,DI是实践。

有了DI,订单服务就像会点菜的顾客——告诉厨房“我要一份能支付的东西”,至于端上来的是微信支付还是支付宝,由厨房决定。这份从容,正是解耦带来的自由。

D(C)={I}而非D(C)={S具体}D(C) = \{ I \} \quad \text{而非} \quad D(C) = \{ S_{\text{具体}} \}
这个公式表示:客户端C的依赖集合从“具体实现类S”变成了“抽象接口I”。就像顾客的依赖从“老王家的猪肉”松散为“符合标准的猪肉”。
  1. 依赖集合$D(C)$ 表示客户端对象C所依赖对象的集合,也就是C完成工作必须携带的“配套清单”。
    D(C)={I}D(C) = \{ I \}
  2. 面向抽象$I$ 代表接口或抽象类型,C只依赖约定、不依赖具体实现——就像顾客只依赖“菜单”而不依赖“某位厨师”。
    而非D(C)={S具体}\text{而非} \quad D(C) = \{ S_{\text{具体}} \}
  3. 避免绑定$S_{\text{具体}}$ 是具体实现类,直接用会令C与实现死死绑定,换实现就必改C的代码。

IoC容器原理

餐厅后厨最忙碌的时刻是午市高峰:十几个灶台同时开工,食材从冷库到案板到炒锅再到传菜口,都靠一条流水线调度。这套流水线就是后厨的“容器”——它知道每道菜需要哪些食材、什么顺序、多少分量。

IoC容器(如Spring IOC Container、ASP.NET Core的ServiceCollection)正是程序世界的中央厨房。它的核心职责有三个:注册(Register)——告诉容器“有这道菜、这么做”;解析(Resolve)——当有人点餐时,按配方把依赖逐一做好;管理(Manage)——决定每道菜是招牌主厨(单例)还是每桌现做(原型)。

在最简单的形式中,容器的解析过程可以看成一个“查找并构建”的过程:根据接口类型查表,找到对应的实现定义,递归地构建该实现自身的依赖,最后返回一个完整可用的对象。这个“查表-构建-递归注入”的流程,是理解所有DI框架的关键。容器通过注册表(Registry)维护“接口到实现”的映射,通过作用域(Scope)管理实例的存活范围——比如单例作用域全局只有一个实例,而每次请求作用域则每个请求一个新实例。

所以,当你的代码调用container.resolve(接口)时,就像顾客报出一个菜名,后厨立刻通过流水线把热腾腾的菜端到面前——你永远不用关心后厨是怎么买菜的。

fresolve:I→Of_{\text{resolve}}: \mathcal{I} \rightarrow \mathcal{O}
容器提供“解析函数”:输入一个接口类型(你要的菜名),输出一个完整可用的对象实例(做好的菜)。
  1. 解析函数$f_{\text{resolve}}$ 表示容器对外提供的解析能力,接受一个接口类型的请求。
    fresolve:I→Of_{\text{resolve}}: \mathcal{I} \rightarrow \mathcal{O}
  2. 接口空间$\mathcal{I}$ 是所有已注册接口的集合,相当于后厨墙上的完整菜单。
    I={接口1,接口2,… }\mathcal{I} = \{ \text{接口}_1, \text{接口}_2, \dots \}
  3. 对象空间$\mathcal{O}$ 是容器能产出的对象实例集合,菜单上每道菜都有对应的实物成品。

注入方式详解

餐厅有三种上菜方式:第一种是套餐制——下单时一次性定好主菜、配菜、饮料,全部装在一个托盘里端上来;第二种是自助餐——菜已摆在台上,想加什么自己去拿;第三种是扫码点餐——通过固定的小程序入口,你点什么由系统自动配送到桌。

对应到代码中,这三种方式正是三种主要的依赖注入手法。构造器注入(Constructor Injection)要求对象在构造时把所有依赖作为参数传入——依赖是“一次性配齐的套餐”,不可中途更换,最适合强制依赖。属性注入(Property/Setter Injection)允许对象创建后再通过属性赋值依赖——像自助餐一样灵活,适合可选依赖,但容易留下“没吃饱”的状态(对象未完整初始化)。接口注入(Interface Injection)要求对象实现一个注入接口,容器通过该接口把依赖传进去——现在这种方式用得较少,更多被前两种取代。

现代框架(如Spring)更常用的是自动装配(Autowired):容器根据类型或名称,自动把匹配的对象注入进来。自动装配极大减少了显式配置,但也埋下隐式依赖的隐患(后文误区会谈到)。构造器注入可以写成下面的形式:客户端在接受构造参数时,就已经声明了自己需要什么。

三种注入方式没有绝对优劣:套餐(构造器注入)适合“少了它就没法开张”的依赖;自助餐(属性注入)适合“锦上添花”的依赖。选对方式,比强行统一更重要。

C(s)=new Client(s),s∈ServiceC(s) = \text{new Client}(s), \quad s \in \text{Service}
构造器注入:客户端的构造过程显式声明需要服务s才能创建,就像点套餐时必须选定主菜。
  1. 构造过程$C(s)$ 表示“提供服务s后构造出客户端C”这一操作,可以理解为套餐制作流程。
    C(s)=new Client(s)C(s) = \text{new Client}(s)
  2. 显式声明$\text{new Client}(s)$ 把s作为必填参数传入,一眼就能看出C需要什么依赖。
    new Client(s)\text{new Client}(s)
  3. 合法性校验$s \in \text{Service}$ 表示s必须是服务类型集合中的合法成员——上桌的必须是符合约定的菜品。

解耦工程实践

如果一家餐厅的每道菜谱都直接写明“必须用供应商老王的牛肉”,那只要老王涨价,整本菜单都得改。聪明的餐厅会写“优质谷饲牛肉”——今天老王、明天老李,谁达标就用谁的。

这正是面向接口设计(Program to Interface):让业务代码依赖抽象接口而非具体类。在工程上,这能带来三大直接收益——可测试性:单元测试时可以轻松用一个“假支付服务”(Mock)注入订单服务,不用真的连支付网关;配置外部化:通过配置文件或环境变量决定“用哪个实现”,代码一行不用改;插件化扩展:新功能的实现类只需注册进容器,主流程完全不用动。

这其实是SOLID中依赖倒置原则(Dependency Inversion Principle,DIP)在容器化时代的实际体现:高层模块不应该依赖低层模块,二者都应该依赖抽象。IoC容器是践行DIP的利器——依赖方向被“倒置”了:不是高层去创建低层,而是容器把低层实现注入高层。

解耦后的代码像模块化厨房:灶台、烤箱、蒸箱各自独立,厨房调度(容器)按菜单把任意组合串联起来。新添一台设备,其他设备不用改装。

High↛Low,High→Abstraction←Low\text{High} \nrightarrow \text{Low},\quad \text{High} \rightarrow \text{Abstraction} \leftarrow \text{Low}
高层的“订单服务”和低层的“MySQL支付实现”都只面向“支付接口”编程,谁都不直接依赖对方。
  1. 取消直接依赖$\text{High} \nrightarrow \text{Low}$ 表示高层模块不再直接指向低层模块,箭头被切断。
    High↛Low\text{High} \nrightarrow \text{Low}
  2. 共同面向抽象$\text{High} \rightarrow \text{Abstraction} \leftarrow \text{Low}$ 表示高、低层模块的依赖箭头同时指向抽象接口。
    High→Abstraction←Low\text{High} \rightarrow \text{Abstraction} \leftarrow \text{Low}
  3. 方向即依赖箭头指向谁,就依赖谁;两个箭头都指向中间抽象,高低层便互不相识。

高级应用模式

后厨里,主厨需要洗碗工递锅,洗碗工需要主厨提供台面空间——如果两个人互相等对方先开工,这顿饭就永远做不出来。这在系统里叫做循环依赖(Circular Dependency)。

容器在解析依赖时遇到循环依赖,通常会抛出异常。解决方式有三种:一是重新设计——把互相依赖的逻辑拆成三个组件,打破环;二是使用属性注入,在对象创建后再补齐依赖(Spring在单例模式下支持这种“提前暴露”机制);三是引入延迟加载(Lazy Loading)——先给一个代理占位符,真正使用时才去容器取值,从而打断循环链路。

条件装配是另一个高级特性:根据运行环境(如开发/生产)或配置变量,决定是否装配某个bean。这在AI工程中很实用——开发环境用内存队列、生产环境用Kafka,代码结构完全一样。多实现策略则解决“一个接口多个实现”的场景:通过限定名(如Spring的@Qualifier)指定用哪个,类似点“特色菜”时指定“大厨李师傅”还是“大厨王师傅”。

高级模式解决的是“厨房规模变大后”的问题:人手多了、菜品多了,难免出现互相等菜的情况。容器用各种机制调配好顺序,让每个岗位都能及时开工。

G(D)=(V,E),if ∃ v1→v2→⋯→vk→v1, then 异常G(D) = (V, E),\quad \text{if } \exists\, v_1 \to v_2 \to \cdots \to v_k \to v_1, \text{ then 异常}
把依赖关系看成一个图:依赖图的边上存在环,说明出现了循环依赖,容器无法确定先构建谁。
  1. 依赖图$G(D) = (V, E)$ 将对象依赖关系建模为图,$V$ 是对象节点,$E$ 是依赖边。
    G(D)=(V,E)G(D) = (V, E)
  2. 环检测$v_1 \to v_2 \to \cdots \to v_k \to v_1$ 表示依赖箭头沿着边能走回起点,构成闭环。
    ∃ v1→v2→⋯→vk→v1\exists\, v_1 \to v_2 \to \cdots \to v_k \to v_1
  3. 处理结果检测到环时,容器选择抛出异常,而不是无限递归下去——后厨不会让两个岗位干等。

性能优化策略

一家餐厅最核心的资产是招牌主厨——十年功力,请一次极难。聪明的老板绝不会让主厨每道菜都亲自颠勺,而是让他负责预调味和监控出品,其他环节交给标准化的普通厨师。

对应到容器中,“招牌主厨”就是单例(Singleton)——整个应用生命周期内只有一个实例,全局复用。像数据库连接池、配置管理器这类“无状态或只读状态”的对象,适合单例,能避免反复创建的巨大开销。而“每桌现做”的原型(Prototype)适合有状态、短生命周期的小对象——比如一次请求的上下文记录器。选错生命周期是性能灾难的常见来源:一旦把重量级服务注册成原型,每次都new一个连接池,系统立刻被拖垮。

容器还提供懒加载代理(Lazy Proxy):先注入一个“空壳代理”,直到第一次使用时才真正构建对象。这对大量可选依赖非常有效——点哪道菜才做哪道,而不是把所有豆子都提前煮一遍。不过容器本身也有开销:注册表查询、代理生成、反射调用,这些在冷启动时尤其明显。现代框架如Spring Boot通过“自动配置+条件装配”把启动时间优化到秒级,原理就是尽量精简启动时必须解析的组件。

性能优化的本质,是把“每桌都要做的菜”提前备好,把“极少点的菜”从启动清单里划掉。主厨专注最难的那道,普通菜走标准化流水线——这就是容器的锅灶分工智慧。

T总=∑i∈单例O(1)+∑j∈延迟O(0)启动+O(构建)T_{\text{总}} = \sum_{i \in \text{单例}} O(1) + \sum_{j \in \text{延迟}} O(0)_{\text{启动}} + O(\text{构建})
总耗时中,单例对象启动一次;延迟加载对象启动阶段不创建,只在首次使用时构建。
  1. 单例成本$\sum_{i \in \text{单例}} O(1)$ 表示每个单例只创建一次,均摊到每次使用几乎为零。
    ∑i∈单例O(1)\sum_{i \in \text{单例}} O(1)
  2. 延迟成本$\sum_{j \in \text{延迟}} O(0)_{\text{启动}}$ 表示延迟加载对象在启动时成本为零,不计入启动开销。
    ∑j∈延迟O(0)启动\sum_{j \in \text{延迟}} O(0)_{\text{启动}}
  3. 构建开销$O(\text{构建})$ 表示首次创建对象时固定付出的一次性成本。

常见反模式

假如每桌客人都跑去后厨门口大喊“谁是做鱼的?把鱼端过来!”——后厨立刻乱成一锅粥。客人不通过服务员点单,而是自己钻进后厨。这种场景在软件中叫服务定位器(Service Locator)。

服务定位器本质上是一个全局的“服务查询器”:对象不通过构造器接收依赖,而是随时调用locator.get(Service.class)获取依赖。表面看它也是“不自己new”,但危害在于:依赖变成了隐式的——阅读代码时看不出这个类到底需要什么;测试时还得先配置定位器;而且任何位置都能拿到服务,代码容易退化成一锅粥。因此它被普遍视为IoC容器的一种“近亲反模式”。

另一类反模式是构造器过载:一个类的构造函数塞进十几个依赖参数,说明这个类干了太多事(违背单一职责原则),容器固然能注入,但代码已臭不可闻。还有隐式依赖:依赖不通过构造器/属性声明,而是直接从静态方法、ThreadLocal里获取,测试只能干瞪眼。以及容器万能论——把业务逻辑里的new全部换成容器调用,认为用了容器就解耦了,实际上只是把耦合从代码搬到了配置里。判断标准很简单:离开容器,你的业务对象还能不能直接new出来用?

优秀的DI设计是“桌上点菜、菜单清晰”;反模式则是“后厨混乱、入口隐蔽”。认出这些坏味道,才能真正把容器的好处用到极致。

Client→Locator→Service\text{Client} \rightarrow \text{Locator} \rightarrow \text{Service}
服务定位器模式中,客户端直接依赖“定位器”这个全局中间人,相当于每桌顾客都去后厨自取。
  1. 间接访问$\text{Client} \rightarrow \text{Locator}$ 表示客户端先访问定位器,而不是直接获得依赖。
    Client→Locator\text{Client} \rightarrow \text{Locator}
  2. 定位器取服务$\text{Locator} \rightarrow \text{Service}$ 表示定位器负责查找并返回服务——但这个过程对使用者是隐式的。
    Locator→Service\text{Locator} \rightarrow \text{Service}
  3. 隐式耦合链路中客户端没有显式声明需要Service,代码的可读性与可测性被悄悄剥夺。

AI 应用场景

机器学习模型服务的可插拔配置

阶段:模型部署与推理服务

解决的问题:模型服务经常需要切换特征工程管线、模型版本、后处理策略。传统做法用if-else写死环境分支,代码越改越乱。通过IoC容器,按环境注入不同实现:开发环境注入LightGBM模型,生产环境注入TensorFlow Serving客户端,业务代码完全一致。

具体案例:某推荐系统上线时,将FeatureTransformer注册为接口,容器根据env=dev/prod条件装配不同实现。测试环境用内存版特征生成器,线上用Redis缓存版。切换环境不用改一行业务代码,只需调整注册配置。

数据处理管道的依赖解耦

阶段:数据预处理与特征工程

解决的问题:ETL流程中需要频繁替换数据源或清洗逻辑,且单元测试难以mock真实数据源。用DI容器将数据源抽象为接口,测试时注入内存Mock数据,线上注入Kafka流或Hive表。

具体案例:某特征平台团队将DataLoader定义成接口,通过容器注册不同实现:离线训练使用HDFS读取器,实时推理使用Kafka消费器。训练与推理共用一套DataProcessor业务代码。

LLM应用中的模型无缝切换

阶段:LLM推理与应用开发

解决的问题:LLM应用需要在OpenAI、Claude、本地开源模型之间无缝切换。业务层只依赖LLMClient接口,具体模型实现由容器按配置注入,切模型不切代码。

具体案例:某智能客服系统通过DI容器注册llm_client为单例;线上环境注入OpenAI官方实现,内网隔离环境注入基于vLLM的本地实现。切换时只改一行注册配置,服务无需重新发布。

Python 演示

python64 行
class Container:    """极简DI容器:注册-解析-生命周期管理"""     def __init__(self):        self._registry = {}        self._singletons = {}     def register(self, name, factory, singleton=False):        self._registry[name] = {"factory": factory, "singleton": singleton}     def resolve(self, name):        if name not in self._registry:            raise KeyError(f"未注册的依赖: {name}")        entry = self._registry[name]        if entry["singleton"]:            if name not in self._singletons:                self._singletons[name] = entry["factory"](self)            return self._singletons[name]        return entry["factory"](self)  class Config:    def __init__(self, env="dev"):        self.env = env     def model_path(self):        return "./models/demo.pkl" if self.env == "dev" else "/prod/model"  class Model:    def __init__(self, config):        self._config = config     def predict(self, x):        return round(x * 0.7 + 0.1, 2)  class DataPipeline:    def __init__(self, config):        self._config = config     def load(self):        return [1, 2, 3, 4]  class Predictor:    def __init__(self, model, pipeline):        self._model = model        self._pipeline = pipeline     def run(self):        data = self._pipeline.load()        return [self._model.predict(v) for v in data]  if __name__ == "__main__":    container = Container()    container.register("config", lambda c: Config("prod"), singleton=True)    container.register("model", lambda c: Model(c.resolve("config")), singleton=True)    container.register("pipeline", lambda c: DataPipeline(c.resolve("config")), singleton=True)    container.register("predictor", lambda c: Predictor(c.resolve("model"), c.resolve("pipeline")))     predictor = container.resolve("predictor")    print(predictor.run())
目的:用约50行实现一个迷你DI容器,展示注册、解析、单例与递归注入的核心机制。
  1. self._registry[name] = {"factory": factory, "singleton": singleton}注册表保存“如何创建”和“是否单例”,就像后厨菜单上的配方卡片。
  2. if entry["singleton"]:单例分支:如果已创建过就直接复用,相当于招牌主厨只请一次。
  3. self._singletons[name] = entry["factory"](self)构建单例并缓存,注意把容器自身传给工厂,让依赖的依赖也能被解析。
  4. lambda c: Predictor(c.resolve("model"), c.resolve("pipeline"))工厂函数里递归解析依赖,这正是容器“水管工”能力的来源。
  5. container.register("config", lambda c: Config("prod"), singleton=True)注册时外部化配置,一行切换环境。

常见误区

误区IoC和DI是一回事,用了DI就是用了IoC,反之亦然。面试时两个词随便换着说都一样。

正解两者是“思想与实现”的关系。控制反转是一种设计原则——把流程控制权交给框架;依赖注入是落地这种原则的具体技术之一。除了DI,事件驱动、模板方法模式也都是IoC的体现。类比:餐厅“不让自己买菜”是思想,而“套餐制、自助餐、扫码点单”是具体实现——实现方式不止一种。

为何常见:很多框架文档把IoC Container和DI Container混着写,初学者自然以为两者完全等价。

误区我给项目引入Spring容器的`@Autowired`注解,把所有对象都交给容器管,项目就算彻底解耦了。

正解容器只是把依赖控制集中管理。如果业务类仍然在构造器里声明一堆具体类型,或者把容器当全局字典到处调用,那只是把耦合从代码挪到了配置。真正的解耦必须结合面向接口设计:客户端依赖接口,具体实现完全可替换且对客户端透明。

为何常见:看到“容器自动装配”的表象,误以为解耦是容器的必然结果,忽略了“面向抽象”这一前提。

误区既然要解耦,就应该把所有的合作者都通过构造器注入,参数多说明这个类功能强大。

正解十几个构造参数意味着这个类承担了太多职责(违背单一职责原则),可读性崩塌。正确做法是拆分:将大对象拆成多个小对象,每个小对象构造参数控制在2-3个。好的设计不是“一个类包办一切”,而是“多个类各司其职”。

为何常见:把“依赖注入”误当“依赖全都塞进来”,忽略了设计本身追求的高内聚低耦合。

误区只要引入容器,所有`new`都要替换成`container.resolve`,这样系统就百分百灵活了。

正解把业务代码里的new全部变成容器调用,是“容器滥用”反模式。像new ArrayList()、new User()这类无依赖对象或原生数据结构,根本不需要容器介入。容器只解决“有外部依赖且需要解耦”的组件。硬套容器,反而让构建过程变得神秘、调试变难。

为何常见:看到容器的好处后走极端,没有理解容器解决的是“对象装配”,而非所有“对象创建”。

面试题

Q1. 依赖注入(DI)的核心目的是什么?

单选题 · easy

  • A.让代码变得更简短
  • B.让对象不自行创建依赖← 正确答案
  • C.让程序运行速度更快
  • D.让对象之间彻底无关

答案:B。DI的核心是把“创建依赖”的控制权从对象内部转移到外部容器。B选项准确表达了“反转”的本质。A、C是可能的副作用但不是目的;D说法极端,对象仍然通过接口发生关联。

Q2. 控制反转(IoC)与依赖注入(DI)的关系是?

单选题 · easy

  • A.IoC是DI的另一种叫法
  • B.DI是IoC的一种落地方式← 正确答案
  • C.IoC是DI的一种落地方式
  • D.两者是完全独立的概念

答案:B。IoC是宏观设计思想(把控制权交给框架),DI是具体的实现技术。就像“健康饮食”是思想,“少油少盐”是落地方式。B正确,A、C把方向说反了,D忽略了两者的从属关系。

Q3. 下列哪种注入方式最适合“缺少该依赖对象就无法工作”的场景?

单选题 · easy

  • A.属性注入
  • B.接口注入
  • C.构造器注入← 正确答案
  • D.静态注入

答案:C。构造器注入强制对象创建时必须提供所需依赖,像套餐一样“配齐才能上桌”,最适合必需依赖。属性注入适合可选依赖,因为对象可以先创建后补值。接口注入已较少使用,静态注入并非标准DI方式。

Q4. 关于IoC容器的解析流程,正确的执行顺序是?

单选题 · mid

  • A.构建对象→查表→注入依赖
  • B.查表→构建对象→递归注入← 正确答案
  • C.注入依赖→构建对象→查表
  • D.查表→注入依赖→构建对象

答案:B。容器先根据接口类型查注册表,找到对应的实现定义,再构建对象,构建过程中还要递归解析该对象自己的依赖。类比后厨先查菜谱,再准备食材,最后下锅炒菜。A、C、D顺序都不对。

Q5. 单例(Singleton)在DI容器中的含义是什么?

单选题 · mid

  • A.整个应用只允许创建一个类
  • B.一个接口只允许一种实现
  • C.某种依赖全局只有一个实例← 正确答案
  • D.每次请求都创建新实例

答案:C。单例作用域是指容器对某个依赖只创建一次,之后所有使用方共享同一个实例,就像后厨只有一位招牌主厨,大家都等他出品。A说法太绝对,B说的是接口与实现映射,D是原型作用域的特征。

Q6. 循环依赖导致的核心问题是什么?

单选题 · mid

  • A.容器无法确定对象构建顺序← 正确答案
  • B.代码无法通过编译
  • C.对象创建后无法被销毁
  • D.容器运行速度明显变慢

答案:A。A和B互相依赖,系统无法决定“先有鸡还是先有蛋”。代码编译不报错,问题只出现在运行时容器解析阶段。C、D都不是循环依赖的必然结果。破解之道是拆环、属性注入或延迟加载。

Q7. 服务定位器被视作反模式的主要原因是?

单选题 · hard

  • A.它让程序运行速度显著下降
  • B.它把依赖变成隐式难以测试← 正确答案
  • C.它完全不支持接口编程
  • D.它无法管理对象的生命周期

答案:B。服务定位器最大的问题是“隐式依赖”——类的方法内部随时调用定位器取服务,外部看不出该类需要什么,测试时需要先给定位器塞入一堆全局配置。它不是不能管理生命周期,而是这种管理对调用方不可见。B准确,A、D不成立,C太绝对。

Q8. 下列哪些属于DI容器的反模式或坏味道?

多选题 · mid

  • A.构造器过载十几个参数← 正确答案
  • B.静态方法中获取隐式依赖← 正确答案
  • C.自动装配导致代码混乱
  • D.容器万能论取代所有new← 正确答案

答案:ABD。构造器过载说明类职责过多,是坏味道;静态方法取依赖是隐式依赖的典型表现;把所有new都改成容器调用是容器万能论。自动装配本身是正常特性,不会“必然”导致代码混乱,C中的“导致混乱”说法错误。

Q9. 哪些手段可以破解循环依赖?

多选题 · mid

  • A.重新设计将组件拆分为三方← 正确答案
  • B.使用属性注入在创建后回填← 正确答案
  • C.采用延迟加载代理打断链路← 正确答案
  • D.将所有依赖都改成单例模式

答案:ABC。拆组件是根治方案;属性注入允许先建后补,打破“构造时就要对方”的僵局;延迟加载用代理占位,用到才真正解析。把依赖改成单例并不能消除环,D是干扰项。

Q10. 关于DI容器性能优化,下列做法正确的有哪些?

多选题 · hard

  • A.无状态服务尽量注册为单例← 正确答案
  • B.可选依赖使用懒加载代理← 正确答案
  • C.所有对象都注册为原型实例
  • D.精简启动时需要解析的组件← 正确答案

答案:ABD。无状态服务单例复用,省去反复创建开销;懒加载让冷启动只做必要的事;精简启动组件是Spring Boot秒级启动的秘密。C恰好相反——全部原型会放大创建开销,拖垮系统。