配置方式、扫描导入与候选解析
"写了注解但没生效""两个实现注入谁""缺依赖到底在启动还是调用时爆"是三类不同问题:第一类是定义登记没发生,第二类是候选选择规则,第三类是依赖解析失败的分型。本篇固定 Framework v6.1.12,与生命周期篇、循环依赖篇同一机制基线;AOP 代理细节见AOP 正文,Boot 自动配置候选见Boot 启动篇。运行环境为 JDK 17,pom 显式开启 -parameters(构造器参数名兜底的前提,见下文)。
三类配置来源,同一种 BeanDefinition
XML 的 <bean>、注解组件的 @Component、Java 配置的 @Configuration @Bean 是三份同构的 BeanDefinition 来源:登记的是"怎么创建"的策略,不是实例。生命周期篇的结论全部适用——登记定义不等于执行构造器,refresh 的 finishBeanFactoryInitialization 才预实例化非懒单例。三类来源可以混用:XML 里可以放 <context:component-scan/> 走同一个扫描器,Java 配置可以 @ImportResource 拉 XML。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- 构造参数在构造时解析;property 在其后的属性填充窗口写入 -->
<bean id="xmlGreeter" class="study.spring.ConfigLab$XmlGreeter">
<constructor-arg index="0" value="xml"/>
<property name="suffix" value="!"/>
</bean>
<!-- lazy 定义:登记不实例化,首次 getBean 才创建 -->
<bean id="lazyGreeter" class="study.spring.ConfigLab$XmlGreeter" lazy-init="true">
<constructor-arg index="0" value="lazy"/>
</bean>
</beans>ClassPathXmlApplicationContext("study/spring/u16-config.xml") 实测:xmlGreeter 先 construct:xml 再 property:suffix——构造参数与属性填充是两个窗口;lazyGreeter 在 refresh 后 containsSingleton == false,首次 getBean 才创建且身份稳定。XML 不启用注解处理器:ClassPathXmlApplicationContext 不会自动注册 @Autowired 处理器(XML 用 <context:annotation-config/> 显式开启),而 AnnotationConfigApplicationContext 在 refresh 前自动注册注解处理器——"注解注入不生效"先查容器类型,再查处理器注册。
扫描是定义登记,导入是定义拼接
@ComponentScan 把 basePackage 下的候选类读成定义。原码:ClassPathBeanDefinitionScanner.doScan,v6.1.12,273–298 行。固定来源。
protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
Assert.notEmpty(basePackages, "At least one base package must be specified");
Set<BeanDefinitionHolder> beanDefinitions = new LinkedHashSet<>();
for (String basePackage : basePackages) {
Set<BeanDefinition> candidates = findCandidateComponents(basePackage);
for (BeanDefinition candidate : candidates) {
ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(candidate);
candidate.setScope(scopeMetadata.getScopeName());
String beanName = this.beanNameGenerator.generateBeanName(candidate, this.registry);
if (candidate instanceof AbstractBeanDefinition abstractBeanDefinition) {
postProcessBeanDefinition(abstractBeanDefinition, beanName);
}
if (candidate instanceof AnnotatedBeanDefinition annotatedBeanDefinition) {
AnnotationConfigUtils.processCommonDefinitionAnnotations(annotatedBeanDefinition);
}
if (checkCandidate(beanName, candidate)) {
BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
definitionHolder =
AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);
beanDefinitions.add(definitionHolder);
registerBeanDefinition(definitionHolder, this.registry);
}
}
}
return beanDefinitions;
}三个分支决定"扫描到什么":findCandidateComponents 默认读类路径元数据(不加载类),includeFilters/excludeFilters 在这一步筛;checkCandidate 对同名冲突做自洽检查;applyScopedProxyMode 在登记时就把 @Scope(proxyMode=TARGET_CLASS) 换成代理定义——scope 代理是定义期决定,不是注入期魔法。实测:@ComponentScan 排除 ASSIGNABLE_TYPE LegacyRepo 后,LegacyRepo 定义不存在,@Lazy @Component 的 LazyRepo 只有定义(constructed == 0),首次 getBean 才构造。
@Import 是另一种定义来源:被导入的 @Configuration 不需要进扫描包,类名写死在注解里;ImportSelector 在 ConfigurationClassPostProcessor(定义处理阶段)运行,实现 EnvironmentAware 时拿到的就是环境,可按属性分支导入。实测:默认分支只导入 importedMarker,设置 u16.branch=prod 后选择器产出 ProdConfig,且选择器运行于普通 Bean 创建之前——它是定义拼接,不是运行时开关。
构造器与属性注入:两个解析窗口
构造器依赖在 createBeanInstance 解析,属性/字段注入在 populateBean 由 AutowiredAnnotationBeanPostProcessor.postProcessProperties 完成(流程见生命周期篇)。这个时间差有两个直接后果:构造器里读尚未注入的字段是 null;required 缺依赖在单例预实例化阶段就失败——非懒单例消费方的 @Autowired 找不到候选时,refresh() 抛出,不是第一次调用才爆。实测:无候选的 required 消费方让 refresh 抛 BeanCreationException,异常链根为 NoSuchBeanDefinitionException,消息点名类型 CandidateLab$Repo 与注入注解;同名故障发生在 XML 工厂同样成立。
缺依赖有三种合法出口,失败时点各不相同:required 字段——启动失败;@Autowired(required=false) 字段——启动成功、字段为 null,故障推迟到首次使用;ObjectProvider——启动成功,getIfAvailable() 返回 null,getObject() 才抛 NoSuchBeanDefinitionException。三种都能编译通过,区别是失败转移到哪个时点,选哪个等于选择谁为缺配置付故障代价。
候选选择:primary、qualifier、泛型与名称兜底
单值注入有多个同类型候选时,选择顺序固定。原码:DefaultListableBeanFactory.determineAutowireCandidate,v6.1.12,1713–1733 行。固定来源。
protected String determineAutowireCandidate(Map<String, Object> candidates, DependencyDescriptor descriptor) {
Class<?> requiredType = descriptor.getDependencyType();
String primaryCandidate = determinePrimaryCandidate(candidates, requiredType);
if (primaryCandidate != null) {
return primaryCandidate;
}
String priorityCandidate = determineHighestPriorityCandidate(candidates, requiredType);
if (priorityCandidate != null) {
return priorityCandidate;
}
// Fallback: pick directly registered dependency or qualified bean name match
for (Map.Entry<String, Object> entry : candidates.entrySet()) {
String candidateName = entry.getKey();
Object beanInstance = entry.getValue();
if ((beanInstance != null && this.resolvableDependencies.containsValue(beanInstance)) ||
matchesBeanName(candidateName, descriptor.getDependencyName())) {
return candidateName;
}
}
return null;
}依次是 @Primary(两个 Primary 局部候选并立时直接抛 NoUniqueBeanDefinitionException,见 determinePrimaryCandidate)、@jakarta.annotation.Priority、最后按依赖名兜底匹配 bean 名或别名。兜底的输入来自注入点:字段名始终可用;构造器参数名需要 -parameters 编译选项,否则反射拿到的是 arg0,兜底失配仍报歧义——本实验 pom 已显式开启并断言 isNamePresent()==true。qualifier 在进入这段之前就把候选集合缩小(配合 @Qualifier 的自定义注解同理),泛型也参与:Handler<String> 在 StringHandler/IntHandler 之间唯一命中,实测无歧义。
集合注入规则不同:resolveMultipleBeanCollection 把全部候选收集进 List 后用 AnnotationAwareOrderComparator 排序,@Order 生效(实测 [first, second]);resolveMultipleBeanMap 不排序,Map 的 key 顺序就是候选发现顺序——即定义登记顺序(实测 [second, first],与 @Order 无关)。这两个行为来自 6.1.12 源码分支并经实验证实:给 Map 注入指望 @Order 排序是错误预期,顺序敏感的 Map 消费方必须自行按键排序。
两类失败与诊断
歧义与缺依赖不可互换:NoUniqueBeanDefinitionException 报"候选太多",消息列出候选名(aRepo、bRepo);NoSuchBeanDefinitionException 报"候选为零",消息点名类型。诊断顺序:先看异常类型分型,再回到注入点核对类型/泛型/qualifier/名称,最后查候选定义是否登记(没登记走扫描过滤或 profile 分支,登记了仍歧义走候选选择)。@Lazy 注入点把失败推迟到首次解析——启动变绿不等于缺依赖消失。恢复手段按因选择:加 @Primary 表达业务默认、@Qualifier 表达注入点意图、ObjectProvider 表达真正可选。
scope 代理:交付引用与逐次解析
prototype 注入 singleton 是按注入点一次性解析:两个 holder 各自在创建时解析到自己的 prototype(实测 proto-1、proto-2,互不相同),之后 holder 与新实例完全脱钩——后续 getBean 得到 proto-3,已注入的引用永不更新。要"每次调用拿新对象",把注入点换成 @Scope(value="prototype", proxyMode=ScopedProxyMode.TARGET_CLASS):实测注入引用是共享代理(两个 holder 持同一引用,AopUtils.isAopProxy 为真),每次方法调用从原型作用域解析一个新 target(两次调用构造计数 1、2)。代理在扫描登记期就由 applyScopedProxyMode 安排(见上文 doScan 摘录)。request/session scope 的代理依赖活动请求上下文,后台线程不能靠代理凭空制造作用域——本实验用 prototype 验证机制,Web 作用域不冒充实测。
实验:改变一个条件的候选矩阵
从仓库根执行 JAVA_HOME=$(/usr/libexec/java_home -v17) bash labs/spring-senior/run.sh candidate 与 run.sh config(单模式命令见实验说明)。每个模式独立 JVM、独立 Context,finally 关闭;断言失败非零退出。
| 模式 | 只改的关键条件 | 实测结论 |
|---|---|---|
| ambiguity | 两个同类型候选,无 primary/qualifier | refresh 即失败,消息含两个候选名,context 不再 active |
| primary | 只加 @Primary | 注入解析为 b,非 primary 候选仍登记 |
| qualifier | 注入点加 @Qualifier("bRepo") | 候选缩到 b |
| collection | @Order(1)/(2) 两候选 + List/Map 注入 | List 按 order 排序;Map 按登记顺序,不排序 |
| missing | required 缺依赖 vs required=false vs ObjectProvider | 启动失败 / null / getObject() 才失败 |
| generics | Handler<String> 与两实现 | 泛型唯一命中 StringHandler |
| name-fallback | 字段名与构造器参数名等于 bean 名 | 均兜底命中;parametersFlag=true 证明 -parameters 开启 |
| prototype-inject | prototype 注入两个单例 holder | 各 holder 持有各自一次解析的实例;getBean 再造新实例 |
| config/xml | XML 构造+属性+lazy 定义 | construct→property 顺序;lazy 定义不预实例化 |
| config/scan | ASSIGNABLE_TYPE 排除 + @Lazy 组件 | 被排除定义不存在;lazy 只有定义 |
| config/import | @Import + 环境分支 ImportSelector | 定义期分支导入,选择器先于普通 Bean |
| config/scoped-proxy | prototype + proxyMode=TARGET_CLASS | 共享代理、每次调用新 target |
断言异常链中的语义(异常类型、消息点名、身份 ==),不把任意 BeansException 当成功。本批 16 模式实测全过;输出行如 PASS candidate collection listSortedByOrder=true mapKeys=registrationOrder。未测自定义 scope 全生命周期、父子容器候选、FactoryBean 泛型解析、@Priority 与 @Primary 叠加的完整优先级矩阵;相关结论以源码分支推演为界。
package study.spring;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Scope;
import org.springframework.core.annotation.Order;
/**
* U16 candidate selection: ambiguity, primary, qualifier, collection, missing,
* generics, name fallback and one-shot prototype injection. One JVM per mode;
* assertion failures exit non-zero through the uncaught AssertionError.
*/
public class CandidateLab {
static class Repo {
final String name;
Repo(String name) { this.name = name; }
}
@Order(2) static class SecondRepo extends Repo { SecondRepo() { super("second"); } }
@Order(1) static class FirstRepo extends Repo { FirstRepo() { super("first"); } }
static class MailNotifier extends Repo { MailNotifier() { super("mail"); } }
static class SmsNotifier extends Repo { SmsNotifier() { super("sms"); } }
static class CountedRepo extends Repo {
static final AtomicInteger created = new AtomicInteger();
CountedRepo() { super("proto-" + created.incrementAndGet()); }
}
interface Handler<T> { T handle(); }
static class StringHandler implements Handler<String> {
public String handle() { return "string"; }
}
static class IntHandler implements Handler<Integer> {
public Integer handle() { return 42; }
}
static class TypeConsumer {
@Autowired Repo repo;
}
static class QualifiedConsumer {
@Autowired @Qualifier("bRepo") Repo repo;
}
static class ListMapConsumer {
@Autowired List<Repo> ordered;
@Autowired Map<String, Repo> byName;
}
static class FieldNameConsumer {
@Autowired Repo smsNotifier;
}
static class ParamNameConsumer {
final Repo repo;
ParamNameConsumer(Repo smsNotifier) { this.repo = smsNotifier; }
}
static class GenericConsumer {
@Autowired Handler<String> handler;
}
static class OptionalConsumer {
@Autowired(required = false) Repo absent;
@Autowired ObjectProvider<Repo> provider;
}
static class RequiredAbsentConsumer {
@Autowired Repo noCandidate;
}
static class ProtoHolder {
@Autowired Repo proto;
}
@Configuration static class TwoReposConfig {
@Bean Repo aRepo() { return new Repo("a"); }
@Bean Repo bRepo() { return new Repo("b"); }
@Bean TypeConsumer consumer() { return new TypeConsumer(); }
}
@Configuration static class PrimaryConfig {
@Bean Repo aRepo() { return new Repo("a"); }
@Bean @Primary Repo bRepo() { return new Repo("b"); }
@Bean TypeConsumer consumer() { return new TypeConsumer(); }
}
@Configuration static class QualifierConfig {
@Bean Repo aRepo() { return new Repo("a"); }
@Bean Repo bRepo() { return new Repo("b"); }
@Bean QualifiedConsumer consumer() { return new QualifiedConsumer(); }
}
@Configuration static class CollectionConfig {
@Bean SecondRepo second() { return new SecondRepo(); }
@Bean FirstRepo first() { return new FirstRepo(); }
@Bean ListMapConsumer consumer() { return new ListMapConsumer(); }
}
@Configuration static class MissingRequiredConfig {
@Bean RequiredAbsentConsumer consumer() { return new RequiredAbsentConsumer(); }
}
@Configuration static class MissingOptionalConfig {
@Bean OptionalConsumer consumer() { return new OptionalConsumer(); }
}
@Configuration static class GenericsConfig {
@Bean StringHandler stringHandler() { return new StringHandler(); }
@Bean IntHandler intHandler() { return new IntHandler(); }
@Bean GenericConsumer consumer() { return new GenericConsumer(); }
}
@Configuration static class NameFallbackConfig {
@Bean MailNotifier mailNotifier() { return new MailNotifier(); }
@Bean SmsNotifier smsNotifier() { return new SmsNotifier(); }
@Bean FieldNameConsumer fieldConsumer() { return new FieldNameConsumer(); }
}
@Configuration static class PrototypeInjectConfig {
@Bean @Scope("prototype") CountedRepo proto() { return new CountedRepo(); }
@Bean ProtoHolder holderOne() { return new ProtoHolder(); }
@Bean ProtoHolder holderTwo() { return new ProtoHolder(); }
}
public static void main(String[] args) {
String mode = args.length == 0 ? "ambiguity" : args[0];
switch (mode == null || mode.isEmpty() ? "ambiguity" : mode) {
case "ambiguity" -> ambiguity();
case "primary" -> primary();
case "qualifier" -> qualifier();
case "collection" -> collection();
case "missing" -> missing();
case "generics" -> generics();
case "name-fallback" -> nameFallback();
case "prototype-inject" -> prototypeInject();
default -> throw new IllegalArgumentException(mode);
}
}
static void ambiguity() {
var c = new AnnotationConfigApplicationContext();
c.register(TwoReposConfig.class);
try {
c.refresh();
throw new AssertionError("expected ambiguous refresh failure");
} catch (BeansException e) {
NoUniqueBeanDefinitionException nu = findCause(e, NoUniqueBeanDefinitionException.class);
if (nu == null) throw e;
// Fails fast during preInstantiateSingletons, not on a later getBean.
if (!nu.getMessage().contains("aRepo") || !nu.getMessage().contains("bRepo"))
throw new AssertionError("both candidate names must be reported: " + nu.getMessage());
if (c.isActive()) throw new AssertionError("failed context still active");
} finally {
c.close();
}
System.out.println("PASS candidate ambiguity " + NoUniqueBeanDefinitionException.class.getSimpleName()
+ " during refresh contextInactive=true");
}
static void primary() {
var c = new AnnotationConfigApplicationContext(PrimaryConfig.class);
try {
TypeConsumer consumer = c.getBean(TypeConsumer.class);
if (!"b".equals(consumer.repo.name)) throw new AssertionError("primary must win");
if (!c.containsBean("aRepo")) throw new AssertionError("non-primary candidate still registered");
} finally {
c.close();
}
System.out.println("PASS candidate primary resolved=b bothBeansRegistered=true");
}
static void qualifier() {
var c = new AnnotationConfigApplicationContext(QualifierConfig.class);
try {
QualifiedConsumer consumer = c.getBean(QualifiedConsumer.class);
if (!"b".equals(consumer.repo.name)) throw new AssertionError("qualifier must narrow");
} finally {
c.close();
}
System.out.println("PASS candidate qualifier resolved=bRepo");
}
static void collection() {
var c = new AnnotationConfigApplicationContext(CollectionConfig.class);
try {
ListMapConsumer consumer = c.getBean(ListMapConsumer.class);
if (!List.of("first", "second").equals(List.of(consumer.ordered.get(0).name, consumer.ordered.get(1).name)))
throw new AssertionError("@Order must sort list candidates: " + consumer.ordered);
// Map injection keeps candidate discovery order (definition registration), not @Order.
if (!List.of("second", "first").equals(List.copyOf(consumer.byName.keySet())))
throw new AssertionError("map keys follow registration order: " + consumer.byName.keySet());
if (!"first".equals(consumer.byName.get("first").name) || !"second".equals(consumer.byName.get("second").name))
throw new AssertionError("map values must be the beans: " + consumer.byName);
} finally {
c.close();
}
System.out.println("PASS candidate collection listSortedByOrder=true mapKeys=registrationOrder");
}
static void missing() {
// Required single-value injection fails at refresh; optional paths fail lazily or not at all.
var required = new AnnotationConfigApplicationContext();
required.register(MissingRequiredConfig.class);
try {
required.refresh();
throw new AssertionError("expected missing-dependency refresh failure");
} catch (BeansException e) {
NoSuchBeanDefinitionException nsb = findCause(e, NoSuchBeanDefinitionException.class);
if (nsb == null) throw e;
if (!nsb.getMessage().contains("CandidateLab$Repo"))
throw new AssertionError("failure must name the missing type: " + nsb.getMessage());
if (required.isActive()) throw new AssertionError("failed context still active");
} finally {
required.close();
}
var optional = new AnnotationConfigApplicationContext(MissingOptionalConfig.class);
try {
OptionalConsumer consumer = optional.getBean(OptionalConsumer.class);
if (consumer.absent != null) throw new AssertionError("optional field must stay null");
if (consumer.provider.getIfAvailable() != null) throw new AssertionError("ifAvailable must be null");
try {
consumer.provider.getObject();
throw new AssertionError("getObject must fail on missing candidate");
} catch (NoSuchBeanDefinitionException expected) {
// Lazy failure at resolution time, not at startup.
}
} finally {
optional.close();
}
System.out.println("PASS candidate missing required=refresh-failure optional=null getObject=lazy-failure");
}
static void generics() {
var c = new AnnotationConfigApplicationContext(GenericsConfig.class);
try {
GenericConsumer consumer = c.getBean(GenericConsumer.class);
if (!(consumer.handler instanceof StringHandler))
throw new AssertionError("generic type must narrow candidates: " + consumer.handler);
} finally {
c.close();
}
System.out.println("PASS candidate generics resolved=StringHandler");
}
static void nameFallback() {
// determineAutowireCandidate falls back to the dependency name; constructor
// parameter names require the -parameters compile flag, field names do not.
boolean flag = ParamNameConsumer.class.getDeclaredConstructors()[0].getParameters()[0].isNamePresent();
if (!flag) throw new AssertionError("compiled without -parameters; parameter-name fallback undemonstrable");
var c = new AnnotationConfigApplicationContext();
c.register(NameFallbackConfig.class);
c.registerBean(ParamNameConsumer.class);
c.refresh();
try {
FieldNameConsumer field = c.getBean(FieldNameConsumer.class);
if (!"sms".equals(field.smsNotifier.name)) throw new AssertionError("field-name fallback");
ParamNameConsumer param = c.getBean(ParamNameConsumer.class);
if (!"sms".equals(param.repo.name)) throw new AssertionError("parameter-name fallback");
} finally {
c.close();
}
System.out.println("PASS candidate name-fallback field=smsNotifier param=smsNotifier parametersFlag=true");
}
static void prototypeInject() {
CountedRepo.created.set(0);
var c = new AnnotationConfigApplicationContext(PrototypeInjectConfig.class);
try {
ProtoHolder one = c.getBean("holderOne", ProtoHolder.class);
ProtoHolder two = c.getBean("holderTwo", ProtoHolder.class);
// Each injection point resolves its own prototype instance, exactly once.
if (one.proto == two.proto) throw new AssertionError("injection points must get distinct prototypes");
if (!"proto-1".equals(one.proto.name) || !"proto-2".equals(two.proto.name))
throw new AssertionError("resolved during refresh: " + one.proto.name + "/" + two.proto.name);
// A later lookup resolves a fresh target the holders never see.
Repo fresh = c.getBean(Repo.class);
if (fresh == one.proto || fresh == two.proto || !"proto-3".equals(fresh.name))
throw new AssertionError("lookup must create a new instance: " + fresh.name);
} finally {
c.close();
}
System.out.println("PASS candidate prototype-inject eachInjectionPointOwnInstance=true newOnLookup=true");
}
static <T extends Throwable> T findCause(Throwable failure, Class<T> type) {
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (type.isInstance(cause)) return type.cast(cause);
}
return null;
}
}架构师推演:装配方式是演进成本
给定 80 个服务的团队:方案一把全部依赖用 @Bean 显式声明在一个配置类,启动行为可静态读出、重构安全(IDE 跟踪构造调用),但 300 个 Bean 的配置类成为合并热点;方案二按业务边界 @ComponentScan,每个模块自带组件,接入零样板,但"谁注入谁"要靠运行期证据,且扫描包一旦扩大就会卷入第三方类。判据不是个人口味:变更频率高、依赖需要显式契约的端口层用显式配置;模块内部纯实现类用扫描。歧义治理用 @Primary 表达全局默认要节制——它是隐式规则,新候选加入后改变注入结果;@Qualifier 把决定权放在注入点,局部可读但逐点维护。
装配迁移(XML → 注解配置)按定义来源逐个搬运并保留失败例:迁移前后跑同一组歧义/缺依赖负例,异常类型与消息应一致,不一致即语义漂移。验证应包含"候选矩阵"的四个维度:来源(三种等价)、选择(primary/qualifier/泛型/名称)、时点(refresh 即失败 vs 首次解析)、scope(直注一次 vs 代理逐次)。口述主线:"定义登记 → 候选筛选 → 单值选择 → 集合收集 → 失败分型",连续追问可改变注入形状、编译选项或 scope。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。