脱敏写在 Service 里,漏一个接口就泄露;用 AOP 递归遍历对象树,性能和嵌套处理都很糟。这篇文章讲怎么把脱敏挂到 JSON 序列化这个唯一出口上,以及为什么
@JacksonAnnotationsInside和ContextualSerializer是这个方案能成立的两个前提。
一、痛点:脱敏是怎么漏掉的
1.1 现象
我们有个用户中心,手机号、邮箱、身份证散落在十几个 DTO 里。最开始的做法是在 Service 层手动脱敏:
public UserVO toVO(User user) {
UserVO vo = new UserVO();
vo.setPhone(maskPhone(user.getPhone()));
vo.setEmail(maskEmail(user.getEmail()));
return vo;
}
第一个月没问题。第三个月出了事:
- 新同事加了「用户导出」接口,直接返回了
List<User>,没走toVO——Excel 里是完整手机号; - 另一个接口返回分页结果
Page<UserVO>,但有个字段是嵌套的List<ContactVO>,转换时漏了; - 最隐蔽的是日志:
log.info("query result={}", users)直接把完整对象打进了日志文件,被日志采集系统收走后扩散到了三个环境。
合规审计时这三个点同时被查出来,问题不在「某个人写错了」,而在于脱敏依赖每个人的记性。
1.2 为什么会这样
我当时试过几种「更可靠」的做法,每种都有硬伤:
| 做法 | 硬伤 |
|---|---|
Service 层手动 mask() | 无法枚举全部出口,漏一个是时间问题 |
| AOP 拦 Controller 返回值,递归遍历对象树 | 要处理 List/Map/嵌套/循环引用;性能随对象图规模线性下降;分页结果里的元素容易漏 |
给每个字段写一个 @JsonSerialize(using = XxxSerializer.class) | 一个序列化器类只能对应一种策略,字段多了就是类爆炸 |
| 数据库层加密 | 解决的是存储安全,不解决「有权限的人查出来也要脱敏」 |
关键认知:脱敏的本质是「输出视图」问题,不是「数据」问题。同一份数据,给管理员看是全的,给普通用户看是脱敏的,给日志看可能是另一种形态。既然是视图问题,它就该属于序列化层,而不是业务层。
而且——JSON 序列化是几乎所有对外输出的必经之路。挂在这里,等于挂在了唯一的收口上。
1.3 我最初的错误做法
错误一:只用 @JsonSerialize 指到自定义序列化器
public class PhoneSerializer extends JsonSerializer<String> {
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeString("138****8000"); // 硬编码的掩码规则
}
}
问题立刻暴露:规则写死在类里。想改「保留前 3 后 4」为「保留前 4 后 4」,得改类;想给另一个字段用不同参数,得再写一个类。
错误二:想从序列化器里读字段注解,但读不到
public class SensitiveSerializer extends JsonSerializer<String> {
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider) {
// 这里拿不到「当前序列化的是哪个字段」
// 因为 JsonSerializer 实例是无状态的,可能被多个字段复用
}
}
这是我最开始卡住的地方:序列化器的 serialize() 方法签名里没有字段信息。它只知道「有个 String 要序列化」,不知道这个 String 来自哪个字段。
二、问题拆解:六个子问题
| 子问题 | 约束 | 现成方案为什么不够 | 判定标准 |
|---|---|---|---|
| ① 挂在哪 | 要在所有 JSON 出口统一生效 | AOP 遍历对象树成本高、易漏嵌套 | 只依赖 Jackson,零遍历 |
| ② 怎么拿到字段配置 | 一个序列化器实例要服务多个字段,配置各不同 | JsonSerializer 接口本身无状态 | 每个字段一个配置化实例 |
| ③ 策略可扩展 | 手机/邮箱/身份证/银行卡/姓名… | 硬编码 switch 无法扩展 | 策略接口 + 注解指定类 |
| ④ 条件化 | 有时要「仅对非管理员脱敏」 | 注解只能表达静态值 | 条件接口 + 注解指定类 |
| ⑤ 集合类型 | List<String> 存的是手机号列表 | 只处理 String 会漏 | 逐元素处理 |
| ⑥ 性能 | 每次序列化都反射读注解会很慢 | —— | 策略/条件/配置都要缓存 |
这六个问题合起来意味着什么:核心是第 ② 个——只要解决了「序列化器怎么知道自己是哪个字段的」,其余五个都是自然延伸。
而这个问题,Jackson 早就留了口子:ContextualSerializer。
这六个子问题的依赖关系可以这样理解——第 ② 个是枢纽,其余五个都围绕它展开:
flowchart TD
A['① 挂在哪<br/>统一出口'] --> B['② 怎么拿到字段配置<br/>核心枢纽']
B --> C['③ 策略可扩展']
B --> D['④ 条件化']
B --> E['⑤ 集合类型']
B --> F['⑥ 性能缓存']
C --> G['策略接口 + 注解指定类']
D --> H['条件接口 + 注解指定类']
E --> I['逐元素处理']
F --> J['策略/条件/配置三级缓存']
三、实现:从注解到序列化器实例
3.1 整体结构
flowchart TD
A["字段标注 @Sensitive"] -->|"@JacksonAnnotationsInside<br/>+ @JsonSerialize"| B["SensitiveJsonSerializer"]
B --> C["createContextual(prov, property)"]
C --> D["从 BeanProperty 读注解<br/>strategy / condition / begin / end"]
D --> E{"CLASS_FIELD_CACHE 命中?"}
E -->|"是"| F["createFromConfig 复用配置"]
E -->|"否"| G["getOrCreateStrategy<br/>getOrCreateCondition"]
G --> H["写入缓存"]
H --> F
F --> I["serialize(value, gen)"]
I --> J{"值类型"}
J -->|"String"| K["condition.valid? → strategy.desensitize"]
J -->|"Collection"| L["逐元素脱敏"]
J -->|"异常"| M["写 MASK_ALL(严禁回退原值)"]
看图要点:createContextual 是拿到字段注解的唯一入口,它每个字段只调一次;三个缓存在这里收口;异常路径必须回退全掩码而不是原值。
3.2 注解:两个元注解缺一不可
代码坐标:annotation/Sensitive.java
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveJsonSerializer.class)
public @interface Sensitive {
Class<? extends DesensitizeStrategy> strategy() default CommonDesensitizeStrategy.class;
Class<? extends DesensitizeCondition> condition() default AlwaysTrueDesensitizeCondition.class;
String pattern() default "";
String patternMask() default "";
String replaceChar() default "*";
int replaceSize() default 0;
int begin() default 0;
int end() default 0;
}
@JacksonAnnotationsInside 是整个方案能成立的前提,这点值得单独说。
Jackson 识别的是它自己认识的注解(@JsonSerialize、@JsonFormat 等)。你自定义一个 @Sensitive,Jackson 默认完全看不见——它不会去读你的注解,更不会因为你的注解上组合了 @JsonSerialize 就自动生效。
@JacksonAnnotationsInside 就是告诉 Jackson:「这个注解内部组合的 Jackson 注解,应当在被标注的字段上生效」。
没有它会怎样:编译通过、启动正常、运行不报错,脱敏静默失效。我在第一次实现时就漏了这个注解,测试环境看了半天日志,最后发现字段是原样输出的。
这个坑之所以危险,是因为它完全静默——没有异常、没有警告。
3.3 ContextualSerializer:解决「序列化器不知道自己是哪个字段」
代码坐标:SensitiveJsonSerializer#createContextual
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
if (property == null) {
return this;
}
Sensitive annotation = property.getAnnotation(Sensitive.class);
if (annotation == null) {
return this;
}
// 优先从缓存获取
SensitiveFieldConfig cachedConfig = findCachedConfig(property);
if (cachedConfig != null) {
return createFromConfig(cachedConfig);
}
// 缓存未命中,构建新的序列化器
SensitiveJsonSerializer serializer = new SensitiveJsonSerializer();
serializer.strategy = getOrCreateStrategy(annotation.strategy());
serializer.condition = getOrCreateCondition(annotation.condition());
serializer.pattern = annotation.pattern();
serializer.patternMask = annotation.patternMask();
serializer.replaceChar = annotation.replaceChar();
serializer.replaceSize = annotation.replaceSize();
serializer.begin = annotation.begin();
serializer.end = annotation.end();
cacheFieldConfig(property, serializer);
return serializer;
}
机制讲解:
- Jackson 在处理某个字段时,如果发现该字段声明了
@JsonSerialize(using = X),会先检查这个序列化器是否实现了ContextualSerializer。 - 如果实现了,就调用
createContextual(prov, property),传入当前的BeanProperty。 BeanProperty携带该字段的全部注解,所以能读到@Sensitive的strategy、begin、end等。- 返回一个新的、已经配置好的序列化器实例。
- 之后该字段的每次序列化都走这个实例,不再需要反射读注解。
这一步是每个字段只执行一次(Jackson 会缓存上下文化后的序列化器),所以「反射读注解」的成本被摊薄到了几乎为零。
整个上下文化过程可以用一张时序图串起来:
sequenceDiagram
participant J as Jackson
participant S as SensitiveJsonSerializer
participant C as CLASS_FIELD_CACHE
participant P as BeanProperty
J->>S: createContextual(prov, property)
S->>P: getAnnotation(Sensitive.class)
P-->>S: 返回注解配置
S->>C: findCachedConfig(property)
alt 缓存命中
C-->>S: 返回 SensitiveFieldConfig
S->>S: createFromConfig 复用配置
else 缓存未命中
C-->>S: null
S->>S: getOrCreateStrategy / getOrCreateCondition
S->>C: cacheFieldConfig 写入缓存
end
S-->>J: 返回已配置的序列化器实例
J->>S: serialize(value, gen)
3.4 三级缓存:性能的关键
代码坐标:SensitiveJsonSerializer 的静态字段
/** 策略实例缓存,Key 为策略类,Value 为策略单例 */
private static final Map<Class<? extends DesensitizeStrategy>, DesensitizeStrategy> STRATEGY_CACHE
= new ConcurrentHashMap<>(16);
/** 条件实例缓存 */
private static final Map<Class<? extends DesensitizeCondition>, DesensitizeCondition> CONDITION_CACHE
= new ConcurrentHashMap<>(16);
/** 类级别的敏感字段配置缓存,外层 Key 为声明类,内层 Key 为字段名 */
private static final Map<Class<?>, Map<String, SensitiveFieldConfig>> CLASS_FIELD_CACHE
= new ConcurrentHashMap<>(32);
三级缓存各自解决一个问题:
| 缓存 | 解决什么 | 命中率 |
|---|---|---|
STRATEGY_CACHE | 避免每个字段都 newInstance() 一次策略类 | 高(策略类就那么几种) |
CONDITION_CACHE | 同上,针对条件类 | 高 |
CLASS_FIELD_CACHE | 避免同一个字段被多次上下文化时重复读注解 | 高(字段数固定) |
为什么第三级要按「声明类 + 字段名」两层组织:
Class<?> declaringClass = property.getMember().getDeclaringClass();
String fieldName = property.getName();
Map<String, SensitiveFieldConfig> classFields = CLASS_FIELD_CACHE.get(declaringClass);
return (classFields != null) ? classFields.get(fieldName) : null;
如果只用字段名做 key,两个不同类里都叫 phone 的字段会互相污染——而它们的脱敏策略可能完全不同。
没有缓存会怎样:在 100 条记录 × 5 个敏感字段的列表接口上,每次序列化都要 newInstance() 5 个策略对象 + 反射读 5 次注解。实测这个开销是肉眼可见的(P99 从 12ms 涨到 45ms,后来加缓存回到 13ms)。
3.5 策略与条件:两个接口,各管一件事
代码坐标:strategy/DesensitizeStrategy.java、condition/DesensitizeCondition.java
public interface DesensitizeStrategy {
String desensitize(String source, String replaceChar, int replaceSize,
String pattern, String patternMask, int begin, int end);
}
public interface DesensitizeCondition {
boolean valid(String value); // 返回 false 时不脱敏,原样输出
}
为什么必须拆成两个接口:
- 策略回答「怎么遮」:
138****8000 - 条件回答「遮不遮」:管理员看到完整号码
如果混在一起,策略类里就会出现「判断当前用户是否管理员」这种与脱敏无关的逻辑,而且每个策略都要重复写一遍。
拆开之后,组合是自由的:
@Sensitive(
strategy = PhoneDesensitizeStrategy.class,
condition = NonAdminCondition.class
)
private String phone;
策略实现的一个细节:PhoneDesensitizeStrategy 内部其实是委托给正则策略的:
public class PhoneDesensitizeStrategy implements DesensitizeStrategy {
private static final CommonPatternDesensitizeStrategy PATTERN_STRATEGY
= new CommonPatternDesensitizeStrategy();
private static final String DEFAULT_PATTERN = "(\\d{3})\\d*(\\d{4})";
private static final String DEFAULT_PATTERN_MASK = "$1****$2";
@Override
public String desensitize(String source, String replaceChar, int replaceSize,
String pattern, String patternMask, int begin, int end) {
if (StringUtils.isBlank(source)) {
return source;
}
// 注意:忽略入参的 pattern/patternMask,用手机号自己的默认值
return PATTERN_STRATEGY.desensitize(source, replaceChar, replaceSize,
DEFAULT_PATTERN, DEFAULT_PATTERN_MASK, begin, end);
}
}
这里有个容易误解的地方:PhoneDesensitizeStrategy 会忽略注解上传入的 pattern,强制使用手机号自己的正则。这是刻意的——既然选了「手机号策略」,就不该再让调用方覆盖正则,否则容易配出「看起来用了手机号策略、实际用了别的正则」这种坑。
内置的 8 种策略输出示例:
| 策略 | 输入 → 输出 |
|---|---|
PhoneDesensitizeStrategy | 13800138000 → 138****8000 |
EmailDesensitizeStrategy | example@mail.com → exa****@mail.com |
IdCardDesensitizeStrategy | 110101199001011234 → 110101********1234 |
BankCardDesensitizeStrategy | 6222021234567890123 → 6222 **** **** 0123 |
NameDesensitizeStrategy | 张三丰 → 张*丰 |
AccountDesensitizeStrategy | 账号中间位遮蔽 |
PasswordDesensitizeStrategy | 全部替换为 ****** |
CommonDesensitizeStrategy | 按 begin/end 保留首尾 |
3.6 类型处理:String 与 Collection
代码坐标:SensitiveJsonSerializer#serialize
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
try {
if (value == null) {
gen.writeNull();
return;
}
if (value instanceof String strValue) {
serializeString(strValue, gen);
return;
}
if (value instanceof Collection<?> collection) {
serializeCollection(collection, gen);
return;
}
// 其他类型直接输出
gen.writeObject(value);
} catch (Exception e) {
// 异常回退:严禁返回原始敏感值!
log.error("敏感数据序列化异常,已按全掩码降级处理(不返回原始值)。异常类型={}",
e.getClass().getSimpleName(), e);
gen.writeString(MASK_ALL);
}
}
Collection 的处理是逐元素判断:
private void serializeCollection(Collection<?> collection, JsonGenerator gen) throws IOException {
gen.writeStartArray();
for (Object element : collection) {
if (element instanceof String strElement) {
serializeString(strElement, gen);
} else {
gen.writeObject(element); // 非 String 原样输出
}
}
gen.writeEndArray();
}
这个异常兜底值得单独说。脱敏代码抛异常时,最「自然」的写法是 gen.writeObject(value) 把原值写出去——但这等于泄露。所以代码里定了一个常量:
/**
* 异常兜底用的全掩码字符串。
* 当脱敏策略执行过程中抛异常时,必须返回此固定字符串而**严禁返回原始值**,
* 否则身份证/手机号/密码等敏感数据将绕过全部脱敏校验被原样写入响应。
*/
private static final String MASK_ALL = "********";
这是一个安全默认值的设计:宁可输出一串星号让用户投诉「数据显示异常」,也不能输出原始敏感数据。
3.7 什么时候不脱敏:条件的实际用法
private void serializeString(String value, JsonGenerator gen) throws IOException {
if (StringUtils.isBlank(value)) {
gen.writeString("");
return;
}
if (!condition.valid(value)) {
gen.writeString(value); // 条件不满足 → 原样输出
return;
}
String desensitized = strategy.desensitize(value, replaceChar, replaceSize,
pattern, patternMask, begin, end);
gen.writeString(desensitized);
}
注意这里是 condition.valid(value) 而不是 condition.valid(config)——条件可以基于「值本身」判断。比如「只有长度足够的身份证才脱敏」这种规则就容易实现。
3.8 边界:只支持 String 和 Collection
这一点必须明确写进文档,因为它会影响数据结构设计:
if (value instanceof String strValue) { ... }
if (value instanceof Collection<?> collection) { ... }
gen.writeObject(value); // 其他类型:不脱敏
推论:手机号如果存成 Long,这套方案对它无效。
这不是缺陷,是设计选择——Long 类型的手机号会丢掉前导零(013800138000 → 13800138000),本来就不该用数值类型存手机号。但如果项目里已经有这种脏数据,需要额外处理。
四、权衡、边界与坑
4.1 取舍表
| 决策 | 选择 | 备选 | 代价 / 边界 |
|---|---|---|---|
| 挂载点 | Jackson 序列化 | AOP 遍历对象树 | 只对 JSON 生效;导出 Excel 要另做 |
| 字段配置读取 | ContextualSerializer | 自定义 AnnotationIntrospector | 后者更底层,但复杂得多 |
| 策略实例 | 全局缓存复用 | 每次 new | 策略必须无状态 |
| 条件判定 | 每次调用 | 缓存结果 | 条件是运行时相关,不能缓存 |
| 异常处理 | 回退全掩码 | 回退原值 / 抛异常 | 选择可用性换安全;会让用户看到 ******** |
| 支持类型 | String + Collection | 全类型 | 数值型手机号需另处理 |
4.2 踩过的四个坑
坑 1:漏了 @JacksonAnnotationsInside,脱敏完全静默失效
- 现象:接口返回的手机号是完整的,但没有任何异常或警告。
- 定位:Jackson 不认识自定义注解,所以
@JsonSerialize根本没生效。 - 修法:补上
@JacksonAnnotationsInside。
这个坑最值得警惕的地方在于「静默」——没有报错,只有安全测试能发现。建议在 CI 里加一条断言:拿一个带敏感字段的 DTO 序列化,断言输出里不含原始手机号。
坑 2:策略类有状态,缓存后互相污染
- 现象:A 字段配置的「保留前 3 后 4」,B 字段也变成了一样。
- 定位:策略类里存了
begin/end字段,STRATEGY_CACHE复用同一个实例导致互相覆盖。 - 修法:策略类必须无状态,所有配置从方法入参传入。
这是我设计接口时的一个关键决定:
// ✅ 无状态:配置全在入参
String desensitize(String source, String replaceChar, int replaceSize,
String pattern, String patternMask, int begin, int end);
参数确实多,但换来的是「实例可以安全共享」。
坑 3:@Sensitive 加在 DTO 上,但接口返回的是 Entity
- 现象:注解明明加了,脱敏不生效。
- 定位:实际被序列化的是
User(Entity),而注解加在UserVO上。 - 修法:注解必须加在实际被序列化的类上。如果分层是 Entity → DTO,就要在 DTO 上加。
坑 4:日志打印绕过 Jackson
- 现象:所有 JSON 接口都脱敏了,但日志文件里还是完整的手机号。
- 定位:
log.info("{}", user)走的是toString(),不经过 Jackson。 - 修法:①
toString()里也用脱敏工具;② 或者干脆禁止直接打印含敏感字段的实体;③ 或者用@ToString.Exclude排除敏感字段。
这个坑揭示了方案的边界:它只保护 JSON 出口。
4.3 什么时候你不该用这套方案
第一,非 JSON 的输出场景。
Excel 导出、CSV、Protobuf、邮件模板——这些都要各自处理。如果项目里导出场景很多,建议在「导出」这一层再建一套脱敏(可以复用 DesensitizeStrategy 接口)。
第二,需要「同一个类在不同接口返回不同脱敏程度」时。
比如 /api/me 返回自己的完整号码,/api/users/{id} 对别人脱敏。注解是类级别的静态配置,无法表达「取决于调用方」。这种场景需要:
- 要么用两个不同的 VO;
- 要么用条件(
DesensitizeCondition)读取当前登录用户——但要注意,条件里读 ThreadLocal 在异步场景会失效。
第三,脱敏规则需要动态下发时。
注解是编译期固定的。如果规则要支持运营配置(比如「这周开始身份证只保留后 2 位」),注解就无能为力了,需要改成配置驱动。
4.4 这套方案防不住什么
| 攻击/泄露路径 | 能否防住 | 说明 |
|---|---|---|
| 绕过 API 直接读库 | ❌ 防不住 | 这是输出层防护,不是存储加密 |
| 接口返回了未标注的字段 | ❌ 防不住 | 依赖开发者标注正确,没有兜底 |
| 日志/异常栈打印对象 | ❌ 防不住 | 不走 Jackson |
| 导出文件 | ❌ 防不住 | 需要单独处理 |
| JSON 接口泄露 | ✅ 防住 | 只要标注了就对 |
一句话总结:它是「JSON 出口的最后一道闸」,不是「数据安全的全部」。
五、可以直接抄走的清单
- 自定义注解必须同时加
@JacksonAnnotationsInside和@JsonSerialize(using = ...),缺一个就静默失效。 - 序列化器实现
ContextualSerializer,从BeanProperty#getAnnotation读字段配置。 createContextual返回新的已配置实例,不要修改this。- 三级缓存:策略实例、条件实例、字段配置(按「声明类 + 字段名」两层)。
- 策略接口必须无状态,所有配置从方法入参传入。
- 策略(怎么遮)和条件(遮不遮)拆成两个接口。
- 异常兜底必须写全掩码常量,严禁回退原值。
- 支持
String与Collection<String>,其他类型明确不支持并写进文档。 - 在 CI 里加一条断言:序列化含敏感字段的 DTO,输出里不含原始值。
- 明确写下「不保护日志、导出、直连数据库」——别让人误以为加了注解就安全了。
附:一个最小可运行的验证
如果你要验证自己的实现是否正确,用这个最小例子:
public class Demo {
static class UserVO {
@Sensitive(strategy = PhoneDesensitizeStrategy.class)
private String phone = "13800138000";
@Sensitive
private String nickname = "张三丰";
@Sensitive(strategy = PhoneDesensitizeStrategy.class)
private List<String> contacts = List.of("13900139000", "13700137000");
// getter 省略
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
System.out.println(mapper.writeValueAsString(new UserVO()));
}
}
期望输出:
{"phone":"138****8000","nickname":"张三*","contacts":["139****9000","137****7000"]}
如果 phone 仍是完整号码,第一个要检查的就是 @JacksonAnnotationsInside 有没有加。