2937 字
约 9 分钟
16
S04-A11 · 注解与配置的分界:行为写在哪一层

一个类上叠了六个注解,其中三个控制序列化、两个控制校验、一个控制事务。某次要让这个类在某个渠道下走不同的序列化规则,工程师发现做不到——注解是编译期固定的,想按渠道变化只能在序列化器里写 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. 自测

  1. 注解与配置在"错误暴露时机"上的不对称是什么?它为什么决定了默认选择?
  2. "结构用注解,阈值用配置"具体怎么划分?举出一个注解里混写两者的例子。
  3. 注解写配置内容会带来哪三种症状?哪一项在事故中代价最大?
  4. 配置写注解内容时,为什么重构成本会陡增?举例说明配置里的字符串引用为何危险。
  5. "注解声明键名、配置提供值"的混合模式,两侧各保留了什么优势?它的代价是什么?
  6. 启动时打印生效配置为什么能消除"以为配了其实没配"这类问题?
S04-A11 · 注解与配置的分界:行为写在哪一层
https://www.clxhxhhr.top/posts/6393/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。