一个类上叠了六个注解,其中三个控制序列化、两个控制校验、一个控制事务。某次要让这个类在某个渠道下走不同的序列化规则,工程师发现做不到——注解是编译期固定的,想按渠道变化只能在序列化器里写 if。
这就是注解与配置的核心差异:注解表达"这个类本质上是什么",配置表达"这一次怎么处理"。把后者写成注解,就失去了运行时调整的能力;把前者写成配置,就失去了编译期检查。
1. 两者的能力差异
| 维度 | 注解 | 配置 |
|---|---|---|
| 生效时机 | 编译期固定,运行期只读 | 运行期可变(重启或热加载) |
| 检查时机 | 编译期/启动期可校验 | 启动期校验,运行期靠约定 |
| 变更成本 | 改代码、重新发布 | 改配置、重启或热更新 |
| 可见性 | 分散在代码各处 | 集中,可对比环境差异 |
| 表达力 | 受语言限制(常量、类型、枚举) | 可嵌套、可引用、可计算 |
| 出错位置 | 编译期就发现 | 运行到那条路径才发现 |
最后一行是关键的不对称:注解的错误在编译期暴露,配置的错误在运行时暴露。所以"能写成注解的东西写成配置"是一种降级——你把编译期检查换成了运行期风险。
两种方式的另一处差别在"谁能改"。注解只能由提交代码的人改,配置可以由值班的人改。这不是效率差异,而是权限差异:一个写死在注解里的熔断阈值,实际是把运维权限收回了开发手里,而事故发生时拥有权限的人往往不在现场。
// 同一个阈值的两种归属,决定了事故期谁有能力处置
注解里:改成 500ms → 提 PR → 评审 → 构建 → 灰度 → 全量 (典型小时级)
配置里:改成 500ms → 配置中心改 → 生效(或重启单实例) (典型分钟级)
反过来,"只能在运行时决定的东西写成注解"则是一种能力损失,而且这种损失在写的那一刻完全看不见。
2. 四象限:什么该写在哪
| 内容 | 归属 | 理由 |
|---|---|---|
| 路由路径、HTTP 方法 | 注解 | 是接口契约的一部分,随代码演进 |
| 字段的序列化名称 | 注解 | 属于类型定义,改一次影响所有环境 |
| 超时、重试次数、批量大小 | 配置 | 随环境与容量变化 |
| 开关(灰度、降级、实验) | 配置 | 需要运行期调整 |
| 校验规则(必填、长度) | 注解 + 配置 | 结构用注解,阈值用配置 |
| 数据源地址、密钥 | 配置 | 与环境绑定,不能进代码 |
| 事务边界 | 注解 | 属于业务语义,不是运维参数 |
| 缓存 TTL | 配置 | 随数据变化频率调整 |
这张表还有一条隐含的分界值得说明:注解描述"这个东西是什么",配置描述"这次运行怎么处理它"。前者是类型的属性,后者是环境的属性。判断时可以问:换一个环境,这条内容还成立吗?成立的是注解,不成立的是配置。
第五行是一个可复用的模式:结构用注解,阈值用配置。字段"必须有值"是结构,属于注解;"最长多少字符"是阈值,属于配置(或至少是可以调整的常量)。把两者混在一起写死在注解里,业务规则一变就要发版。
3. 注解写配置的三种典型症状
| 症状 | 具体表现 | 后果 |
|---|---|---|
| 环境差异无法表达 | 测试与生产需要不同值,只能靠 profile 分支 | 代码里出现 if (env) |
| 调整需要发版 | 改一个阈值要走完整发布流程 | 应急时无法快速调整 |
| 无法被运维看到 | 值藏在代码里,配置中心看不到 | 变更审计缺失 |
一个更具体的形态:注解里写着 @Scheduled(fixedDelay = 60000),而测试环境希望十秒跑一次。可选做法只有三种——改代码、加 profile 分支、或者放弃注解改用编程式调度。第三种往往才是正确选择,但它的代价是失去注解带来的声明式可读性。
第一项最常见。注解本身不支持"按环境取值",于是团队引入 profile 机制或自定义注解处理器,最终形成的复杂度远超"把值移到配置里"。
第二项在事故中代价最大:一个熔断阈值写死在注解里,故障时需要调整,而调整意味着发版——发版本身在故障期就是风险。
4. 配置写注解内容的三种症状
| 症状 | 具体表现 | 后果 |
|---|---|---|
| 契约漂移 | 路由、序列化名写在配置里,代码看不出接口形状 | 接口文档与实现脱节 |
| 缺编译期检查 | 配置里的类名、方法名写错,启动才发现 | 上线前一刻才暴露 |
| 无法重构 | 改类名要同步改配置,而配置不在 IDE 的引用搜索里 | 重构成本陡增 |
第二项有个具体形态:配置里写 handler: com.example.OrderHandler#handle 这类字符串引用,重命名类之后编译照常通过,运行时才报找不到。这类配置的可靠性远低于直接写注解或代码。
可以给这类配置加一层编译期校验来缓解,但成本不低:
// 用注解处理器或启动期校验,把字符串引用变成可检查的
@ConfigurationProperties(prefix = "handlers")
public class HandlerProps {
private Map<String, Class<? extends Handler>> mapping; // 类型化,IDE 可校验
}
// 启动时校验:每个 Class 必须可实例化,且 implements Handler
// 代价:配置不再是纯文本,需要类型转换与校验逻辑
这三项症状有个共同的前置信号:代码里开始出现"配置化的注解"——自定义注解处理器、profile 条件组合、占位符解析。一旦为了"让注解支持变化"而引入机制,其实已经说明这项内容该搬到配置里了。
第三项是长期成本:重构工具不认识配置里的字符串,一次包重命名会让所有这类配置失效。
5. 混合形态:结构注解 + 参数配置
多数真实系统需要的正是这种混合,而它的关键是注解声明"有一个可调项",配置提供"这一项的值"。
// 注解只声明"这里有个可调的超时",不写死值
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RemoteCall {
String key(); // 配置键名,不是值
long defaultTimeoutMs() default 3000;
}
// 使用处
@RemoteCall(key = "payment.timeout", defaultTimeoutMs = 2000)
public PayResult pay(Order o) { ... }
运行时由切面读取 key,从配置取实际值。这样注解保持了"这里有远程调用"这一结构信息(可被静态扫描、可被文档工具识别),而具体的毫秒数交给配置。
// 配置侧:可以按环境不同,可以被审计,可以热更新
payment:
timeout: 2500
retry: 1
inventory:
timeout: 800
retry: 0
// 切面:注解给键,配置给值,缺配置时用注解默认
long timeout = cfg.getLong(ann.key() + ".timeout", ann.defaultTimeoutMs());
这个模式的价值在于两侧都保留了自己的优势:注解可被静态分析、配置可被运维调整。代价是多了一层间接,读代码时不能一眼看到实际值——所以配置键的命名必须能从注解直接推出来,否则追踪成本会超过收益。
6. 排错:五种"改配置没生效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 改了配置重启后仍不生效 | 值被注解硬编码覆盖 | 检查注解是否同时写了值 |
| 环境间行为不一致但配置相同 | 注解里有环境分支 | 搜索代码中的环境判断 |
| 配置中心有值但用的是默认 | 键名拼写与注解 key() 不一致 |
启动时打印生效键值 |
| 重构后启动报找不到类 | 配置里的字符串引用未更新 | 改注解或加编译期校验 |
| 热更新后部分实例生效 | 缓存了首次读取的值 | 检查是否在构造时一次性读取 |
第三项最值得投入:启动时把生效的配置键与值打印一遍(脱敏后),这类问题就基本消失。它比任何文档都有效,因为它是实际生效的值而不是声明的值。
// 启动自检:打印实际生效的配置,脱敏后进日志
java -jar app.jar --debug-config=true 2>&1 | grep -E "^EFFECTIVE "
// EFFECTIVE payment.timeout=2500 (source=config, default=2000)
// EFFECTIVE inventory.timeout=800 (source=default)
第二行这类输出(source=default)能直接暴露"以为配了其实没配"的一类问题。
7. 边界:什么情况不必区分
| 场景 | 为什么 | 做法 |
|---|---|---|
| 值永不变化(常量语义) | 无调整需求 | 直接写注解或常量 |
| 单环境部署 | 无环境差异 | 写死即可,但保留键名 |
| 变更必须经过发布 | 无运行期调整需求 | 注解更安全 |
| 值是类型而非数值 | 配置无法表达类型 | 用注解或代码 |
还有一条实践中的折中值得记下:键名用注解固定,值用配置给,默认值写注解。这样即使配置系统完全失效(比如配置中心连不上),系统也能用注解里的默认值启动——默认值的存在让配置从"必须项"变成"可调项",可用性反而更高。前提是这个默认值本身是安全值,而不是"任意值"。
判断口径一句话:这个值会不会在某次事故中被临时调整。会,就必须在配置里;不会,注解的编译期检查更值钱。
8. 自测
- 注解与配置在"错误暴露时机"上的不对称是什么?它为什么决定了默认选择?
- "结构用注解,阈值用配置"具体怎么划分?举出一个注解里混写两者的例子。
- 注解写配置内容会带来哪三种症状?哪一项在事故中代价最大?
- 配置写注解内容时,为什么重构成本会陡增?举例说明配置里的字符串引用为何危险。
- "注解声明键名、配置提供值"的混合模式,两侧各保留了什么优势?它的代价是什么?
- 启动时打印生效配置为什么能消除"以为配了其实没配"这类问题?