SpringBoot 使用 YAML 配置实现数据脱敏方案
基于 SpringBoot 项目,通过 YAML 配置文件存储数据脱敏规则,替代传统的 AOP 加注解方式。系统启动时加载规则至内存 Map,运行时递归遍历返回数据,根据交易号和字段路径匹配规则,利用正则表达式对敏感信息进行掩码处理。该方案支持嵌套对象与列表结构,便于动态管理不同接口的脱敏策略。

基于 SpringBoot 项目,通过 YAML 配置文件存储数据脱敏规则,替代传统的 AOP 加注解方式。系统启动时加载规则至内存 Map,运行时递归遍历返回数据,根据交易号和字段路径匹配规则,利用正则表达式对敏感信息进行掩码处理。该方案支持嵌套对象与列表结构,便于动态管理不同接口的脱敏策略。

在项目中遇到一个需求,需要对交易接口返回结果中的指定字段进行脱敏操作,但又不能使用 AOP+ 注解的形式,于是决定使用一种基于配置文件的方法:
由于返回的结果涉及到嵌套 Map,所以决定采用 YAML 格式的文件存储脱敏规则。YAML(YAML Ain't Markup Language)与传统的 JSON、XML 和 Properties 文件一样,都是用于数据序列化的格式,常用于配置文件和数据传输。
相比于其他格式,YAML 是一种轻量级的数据序列化格式,它的设计初衷是为了简化复杂性,提高人类可读性,并且易于实现和解析。
除此之外,YAML 还支持跨平台、跨语言,可以被多种编程语言解析,这使得 YAML 非常适合用于不同语言之间的数据传输和交换。
YAML 文件的语法非常简洁明了,以下是它的语法规范:
:)表示键值对,键值对之间使用换行分隔。-)表示列表项,列表项之间也使用换行分隔。#)表示注释,在 # 后面的内容被视为注释,可以出现在行首或行尾。\n 表示换行)和转义序列(如 \u 表示 Unicode 字符)。:)表示,键和值之间使用一个空格分隔。-)表示列表项。& 表示引用,使用 * 表示引用的内容。| 保留换行符,保留文本块的精确格式。> 折叠换行符,将文本块折叠成一行,并根据内容自动换行。!!str 表示字符串类型、!!int 表示整数类型等。--- 表示多个 YAML 文件之间的分隔符。多文件示例:
# 第一个 YAML 文件
name: John Smith
age: 30
---
# 第二个 YAML 文件
hobbies:
- reading
- hiking
- swimming
数据类型示例:
# 使用标记表示数据类型
age: !!int 30
weight: !!float 65.5
isMale: !!bool true
created: !!timestamp '2022-01-01 12:00:00'
多行文本块示例:
# 使用 | 保留换行符
description: |
This is a multi-line string.
# 使用 > 折叠换行符
summary: >
This is a summary that may contain line breaks.
引用示例:
# 使用 & 表示引用
address: &myaddress
city: San Francisco
state: California
zip: 94107
# 使用 * 表示引用的内容
shippingAddress: *myaddress
列表示例:
# 使用破折号表示列表项
hobbies:
- reading
- hiking
- swimming
people:
- name: John Smith
age: 30
- name: Jane Doe
age: 25
键值对示例:
# 键和值之间使用一个空格分隔
name: John Smith
age: 30
address:
city: San Francisco
state: California
zip: 94107
字符串示例:
# 使用双引号表示字符串
name: "John Smith"
# 使用单引号表示字符串
nickname: 'Johnny'
注释示例:
# 这是一个注释
name: John Smith
age: 30
# 这也是一个注释
基本语法示例:
# 使用缩进表示层级关系
server:
port: 8080
# 使用冒号表示键值对
name: John Smith
age: 30
# 使用破折号表示列表项
hobbies:
- reading
- hiking
- swimming
对于数据结构简单的接口返回结果,脱敏规则格式定义为【交易号->字段->规则】:
交易号:字段名:规则:'/^(1[3-9][0-9])\d{4}(\d{4}$)/'
同时接口返回的结果中可能含有嵌套列表,那么针对这种复杂的结构就定义格式为【交易号->字段(列表)->字段->规则】,即:
交易号:字段名 (列表):字段名:规则:'/^(1[3-9][0-9])\d{4}(\d{4}$)/'
使用这种层级结构,我们完全可以通过 Map.get("Key") 的形式获取到指定交易,指定字段的脱敏规则。
在 DataDesensitizationUtils 工具类中,我们需要实现在项目启动时,读取 desensitize.yml 文件中的内容,并转为 Map 键值对数据类型:
/**
* 读取 yaml 文件内容并转为 Map
* @param yamlFile yaml 文件路径
* @return Map 对象
*/
public static Map<String, Object> loadYaml(String yamlFile) {
Yaml yaml = new Yaml();
try (InputStream in = DataDesensitizationUtils.class.getResourceAsStream(yamlFile)) {
return yaml.loadAs(in, Map.class);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
在上述代码中,我们通过 getResourceAsStream 方法根据指定的 YAML 文件的路径从类路径中获取资源文件的输入流。然后使用 loadAs 方法将输入流中的内容按照 YAML 格式进行解析,并将解析结果转换为指定的 Map.class 类型。最后使用 try-with-resources 语句来自动关闭输入流。
定义脱敏工具类 DataDesensitizationUtils 编写我们的脱敏逻辑:
public class DataDesensitizationUtils {}
首先创建 YAML 文件 desensitize.yml 添加对应交易字段的脱敏规则:
Y3800:
phone:
rule: "(\\d{3})\\d{4}(\\d{4})"
format: "$1****$2"
idCard:
rule: "(?<=\\w{6})\\w(?=\\w{4})"
format: "*"
Y3801:
idCard:
rule: "(?<=\\w{3})\\w(?=\\w{4})"
format: "+"
list:
phone:
rule: "(\\d{3})\\d{4}(\\d{4})"
format: "$1++++$2"
输出 phone 层级下的数据:
{"rule":"(\\d{3})\\d{4}(\\d{4})","format":"$1****$2"}
输出 Y3800 层级下的数据:
{"phone":{"rule":"(\\d{3})\\d{4}(\\d{4})","format":"$1****$2"},"idCard":{"rule":"(?<=\\w{3})\\w(?=\\w{4})","format":"*"}}
输出 YAML 文件中的全部数据:
{"Y3800":{"phone":{"rule":"(\\d{3})\\d{4}(\\d{4})","format":"$1****$2"},"idCard":{"rule":"(?<=\\w{3})\\w(?=\\w{4})","format":"*"}},"Y3801":{"name":{"rule":".(?=.)","format":"+"},"idCard":{"rule":"(?<=\\w{3})\\w(?=\\w{4})","format":"+"},"list":{"card":{
在上文中我们已经将 desensitize.yml 文件中所有的脱敏规则都以 key-Value 的形式存储到了 Map 中,因此我们只需要通过 Key 从 Map 中获取即可。接下来编写方法通过 Key 获取指定字段对应脱敏规则:
public static void main(String[] args) {
// 加载 YAML 文件并获取顶层的 Map 对象,路径基于 resources 目录
Map<String, Object> yamlMap = loadYaml("/desensitize.yml");
System.out.println(yamlMap);
// 从顶层的 Map 中获取名为 "Y3800" 的嵌套 Map
Map<String, Object> Y3800 = (Map<String, Object>) yamlMap.get("Y3800");
System.out.println(Y3800);
// 从 "Y3800" 的嵌套 Map 中获取名为 "phone" 的嵌套 Map
Map<String, Object> phone = (Map<String, Object>) Y3800.get("phone");
System.out.println(phone);
}
输出结果如下:
{Y3800={phone={rule=(\d{3})\d{4}(\d{4}), format=$1****$2}, idCard={rule=(?<=\w{3})\w(?=\w{4}), format=*}},Y3801={name={rule=.(?=.), format=+}, idCard={rule=(?<=\w{3})\w(?=\w{4}), format=+}, list={card={rule=\d(?=\d{4}), format=+}}}}
{phone={rule=(\d{3})\d{4}(\d{4}), format=$1****$2}, idCard={rule=(?<=\w{3})\w(?=\w{4}), format=*}}
{rule=(\d{3})\d{4}(\d{4}), format=$1****$2}
转为 JSON 格式显示如下:
优化思路为:通过递归和判断来遍历嵌套的 Map,直到找到键路径所对应的最里层的嵌套 Map,并返回该 Map 对象。
首先我们需要先了解一个概念:
Y3800:phone:rule:"(\\d{3})\\d{4}(\\d{4})"format:"$1****$2"
当我们要从上述数据中获取 phone 的脱敏规则时,我们需要先从 Map 中 get("Y3800") 获取 Y3800 下的数据,再通过 get("phone") 获取 phone 下的规则,那么 Y3800->phone 就是 phone 的键路径。
基于此,我们可以实现这样一个方法,直接给出指定字段的键路径,在方法中通过递归的方式从 Map 中获取到该键路径下的所有数据,然后返回即可。
优化后方法如下:
/**
* 递归获取嵌套 Map 数据
*
* @param map 嵌套数据源的 Map
* @param keys 嵌套键路径
* @return 嵌套数据对应的 Map
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> getNestedMapValues(Map<String, Object> map, String... keys) {
// 如果键路径为空或者第一个键不在 Map 中,则返回 null
if (keys.length == 0 || !map.containsKey(keys[0])) {
return null;
}
// 获取第一个键对应的嵌套对象
Object nestedObject = map.get(keys[0]);
// 如果键路径长度为 1,说明已经到达最里层的嵌套 Map,直接返回该 Map 对象
if (keys.length == 1) {
if (nestedObject instanceof Map) {
return (Map<String, Object>) nestedObject;
} else {
return null;
}
} else {
// 如果嵌套对象是 Map,继续递归查找下一个键的嵌套 Map
if (nestedObject instanceof Map) {
return getNestedMapValues((Map<String, Object>) nestedObject, Arrays.copyOfRange(keys, 1, keys.length));
} else {
// 嵌套对象既不是 Map 也不是 List,返回 null
return null;
}
}
}
调用方法时传入 Key 的嵌套路径即可:
public static void main(String[] args) {
// 加载 YAML 文件并获取顶层的 Map 对象
Map<String, Object> yamlMap = loadYaml("/desensitize.yml");
System.out.println(yamlMap);
// 获取 Y3800 -> phone 下的数据转为 Map
Map<String, Object> y3800PhoneMap = DataDesensitizationUtils.getNestedMapValues(yamlMap, "Y3800", "phone");
System.out.println("Y3800 -> phone : " + y3800PhoneMap);
}
具体来说,主要分为以下几步:
获取到字段的脱敏规则后,我们就可以编写方法实现对源数据做脱敏处理,脱敏方法如下:
/**
* 使用指定规则对数据进行脱敏处理
*
* @param data 要进行脱敏处理的数据
* @param map 包含脱敏规则和格式的参数映射
* - "rule" 表示脱敏规则的正则表达式
* - "format" 表示替换脱敏部分的字符串,默认为 "*"
* @return 脱敏后的数据
*/
private static String desensitizeLogic(String data, Map<String, Object> map) {
if (map.containsKey("rule")) {
String rule = (String) map.get("rule");
String sign = "*";
if (map.containsKey("format")) {
sign = (String) map.get("format");
}
return data.replaceAll(rule, sign);
}
return data;
}
目前我们已经实现了通过字段的键路径获取到该字段对应规则的方法 getNestedMapValues(),那么接下来我们只需要生成字段对应的键路径,然后调用方法 getNestedMapValues() 获取到脱敏规则后调用 desensitizeLogic() 对源数据进行脱敏即可。
提供源数据格式如下:
{"txEntity":{"idCard":"130428197001180384","name":"赵士杰","list":[{"phone":"17631007015"},{"phone":"17631007015"}]},"txHeader":{"servNo":"Y3801"}}
根据上述数据结构,首先我们需要从 txHeader 中获取 servNo,之后递归遍历 txEntity 中的元素即可。
具体方法如下:
/**
* 对指定实体数据进行脱敏处理
*
* @param entity 要进行脱敏处理的实体数据
* @param servNo 当前交易的服务号,用于记录日志
* @param path 当前实体数据在整个数据结构中的路径,用于记录日志
*/
public static void parseData(Object entity, String servNo, String path) {
if (entity instanceof Map) {
for (Map.Entry<String, Object> entry : ((Map<String, Object>) entity).entrySet()) {
// 计算当前键值对在整个数据结构中的路径
String currentPath = path.isEmpty() ? entry.getKey() : path + "," + entry.getKey();
if (entry.getValue() instanceof Map) {
// 如果当前值是 Map 类型,则递归处理子节点
parseData(entry.getValue(), servNo, currentPath);
} else if (entry.getValue() instanceof List) {
// 如果当前值是 List 类型,则遍历列表中的每个元素并递归处理子节点
for (Object item : (List) entry.getValue()) {
if (item instanceof Map) {
parseData(item, servNo, currentPath);
}
}
} else {
// 如果当前值不是 Map 或 List,则进行脱敏处理
String p = servNo + "," + currentPath;
String[] keyPaths = p.split(",");
// 获取当前节点的脱敏规则和格式
Map<String, Object> nestedMap = getNestedMap(keyPaths);
if (Objects.nonNull(nestedMap)) {
// 记录日志
log.info("-----------------交易【{}】,字段【{}】开始脱敏-----------------", servNo, currentPath.replace(, ));
log.info(, entry.getKey(), entry.getValue());
log.info(, nestedMap);
desensitizeLogic((String) entry.getValue(), nestedMap);
entry.setValue(desensitized);
log.info(, entry.getKey(), entry.getValue());
log.info(, servNo, currentPath.replace(, ));
}
}
}
}
}
该方法接收一个实体数据 entity,一个服务号 servNo 和一个路径 path 作为参数。在方法体内,会遍历实体数据的键值对,并根据具体情况递归处理子节点或进行脱敏处理。
脱敏处理的具体逻辑和规则通过调用 getNestedMap 方法和 desensitizeLogic 方法来实现,其中 getNestedMap 方法用于获取脱敏规则,desensitizeLogic 方法用于根据脱敏规则对数据进行脱敏处理。
注意:实际使用中需确保数据结构与 YAML 中定义的结构一致,生成的键路径需与从 YAML 中获取规则所需的键路径一致。
编写 Main 方法调用:
public class Demo {
public static Map<String, Object> getData() {
HashMap<String, Object> phone = new HashMap<>();
phone.put("phone", "17631007015");
HashMap<String, Object> phone2 = new HashMap<>();
phone2.put("phone", "17631007015");
List<HashMap<String, Object>> list = new ArrayList<>();
list.add(phone);
list.add(phone2);
HashMap<String, Object> txEntity = new HashMap<>();
txEntity.put("name", "赵士杰");
txEntity.put("idCard", "130428197001180384");
txEntity.put("list", list);
HashMap<String, Object> result = new HashMap<>();
result.put("txEntity", txEntity);
HashMap<String, Object> txHeader = new HashMap<>();
txHeader.put("servNo", "Y3801");
result.put("txHeader", txHeader);
return result;
}
public static void main(String[] args) {
Map<String, Object> data = getData();
// 假设 data 中包含接口返回的数据
if (data.containsKey("txHeader") && data.get("txHeader") instanceof Map) {
String servNo ((Map<String, String>) data.get()).get();
DataDesensitizationUtils.parseData(data.get(), servNo, );
}
}
}
运行测试,控制台输出如下:
-----------------交易【Y3801】,字段【idCard】开始脱敏-----------------
原始值:【idCard:130428197001180384】
脱敏规则:{rule=(?<=\w{3})\w(?=\w{4}), format=+}
脱敏值:【idCard:130+++++++++++0384】
-----------------交易【Y3801】,字段【idCard】脱敏结束-----------------
-----------------交易【Y3801】,字段【list->phone】开始脱敏-----------------
原始值:【phone:17631007015】
脱敏规则:{rule=(\d{3})\d{4}(\d{4}), format=$1++++$2}
脱敏值:【phone:176++++7015】
-----------------交易【Y3801】,字段【list->phone】脱敏结束-----------------
-----------------交易【Y3801】,字段【list->phone】开始脱敏-----------------
原始值:【phone:17631007015】
脱敏规则:{rule=(\d{3})\d{4}(\d{4}), format=$1++++$2}
脱敏值:【phone:176++++7015】
-----------------交易【Y3801】,字段【list->phone】脱敏结束-----------------
数据脱敏后如下:
{"txEntity":{"idCard":"130+++++++++++0384","name":"赵士杰","list":[{"phone":"176++++7015"},{"phone":"176++++7015"}]},"txHeader":{"servNo":"Y3801"}}
封装成完整的工具类如下:
/**
* @Description 数据脱敏工具类
*/
@Slf4j
@SuppressWarnings("unchecked")
public class DataDesensitizationUtils {
// YAML 文件路径
private static final String YAML_FILE_PATH = "/tuomin.yml";
// 存储解析后的 YAML 数据
private static Map<String, Object> map;
static {
// 创建 Yaml 对象
Yaml yaml = new Yaml();
// 通过 getResourceAsStream 获取 YAML 文件的输入流
try (InputStream in = DataDesensitizationUtils.class.getResourceAsStream(YAML_FILE_PATH)) {
// 解析 YAML 文件为 Map 对象
map = yaml.loadAs(in, Map.class);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取嵌套的 Map 数据
*
* @param keys 嵌套键路径
* @return 嵌套数据对应的 Map
*/
private static Map<String, Object> getNestedMap(String... keys) {
return getNestedMapValues(map, keys);
}
/**
* 递归获取嵌套 Map 数据
*
* @param map 嵌套数据源的 Map
* @param keys 嵌套键路径
* @return 嵌套数据对应的 Map
*/
private static Map<String, Object> {
(keys.length == || !map.containsKey(keys[])) {
;
}
map.get(keys[]);
(keys.length == ) {
(nestedObject Map) {
(Map<String, Object>) nestedObject;
} {
;
}
} {
(nestedObject Map) {
getNestedMapValues((Map<String, Object>) nestedObject, Arrays.copyOfRange(keys, , keys.length));
} {
;
}
}
}
{
(entity Map) {
(Map.Entry<String, Object> entry : ((Map<String, Object>) entity).entrySet()) {
path.isEmpty() ? entry.getKey() : path + + entry.getKey();
(entry.getValue() Map) {
parseData(entry.getValue(), servNo, currentPath);
} (entry.getValue() List) {
(Object item : (List) entry.getValue()) {
(item Map) {
parseData(item, servNo, currentPath);
}
}
} {
servNo + + currentPath;
String[] keyPaths = p.split();
Map<String, Object> nestedMap = getNestedMap(keyPaths);
(Objects.nonNull(nestedMap)) {
log.info(, servNo, currentPath.replace(, ));
log.info(, entry.getKey(), entry.getValue());
log.info(, nestedMap);
desensitizeLogic((String) entry.getValue(), nestedMap);
entry.setValue(desensitized);
log.info(, entry.getKey(), entry.getValue());
log.info(, servNo, currentPath.replace(, ));
}
}
}
}
}
String {
(map.containsKey()) {
(String) map.get();
;
(map.containsKey()) {
sign = (String) map.get();
}
data.replaceAll(rule, sign);
}
data;
}
}

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online