【数据脱敏方案】不使用 AOP + 注解,使用 SpringBoot+YAML 实现_脱敏实现不存在, 采用默认处理
文章目录
引入
在项目中遇到一个需求,需要对交易接口返回结果中的指定字段进行脱敏操作,但又不能使用AOP+注解的形式,于是决定使用一种比较笨的方法:
- 首先将所有需要脱敏字段及其对应脱敏规则存储到 Map 中。
- 在接口返回时,遍历结果中的所有字段,判断字段名在 Map 中是否存在:
- 如果不存在:说明该字段不需要脱敏,不做处理即可。
- 如果存在:说明该字段需要脱敏,从 Map 中获取对应的脱敏规则进行脱敏。
- 最后返回脱敏之后的结果。
认识 YAML 格式规范
由于返回的结果涉及到嵌套 Map,所以决定采用 YAML 格式的文件存储脱敏规则,那么为了大家统一维护和开发,就需要大家对 YAML 格式进行了解,遵守规范,不易出错,少走弯路。
YAML(YAML Ain’t Markup Language)与传统的 JSON、XML 和 Properties 文件一样,都是用于数据序列化的格式,常用于配置文件和数据传输。
相比于其他格式,YAML 是一种轻量级的数据序列化格式,它的设计初衷是为了简化复杂性,提高人类可读性,并且易于实现和解析。
-
与 JSON 相比:YAML 在语法上更为灵活,允许使用更简洁的方式来表示数据结构。
-
与 XML 相比:YAML 的语法更为简洁,没有繁琐的标签和尖括号。
-
与 Properties 相比:YAML 支持更复杂的数据结构,包括嵌套的键值对和列表。
除此之外,YAML 还支持跨平台、跨语言,可以被多种编程语言解析,这使得YAML非常适合用于不同语言之间的数据传输和交换。
YAML 文件的语法非常简洁明了,以下是它的语法规范:
-
基本语法:
- 使用 缩进表示层级关系,可以使用空格或制表符进行缩进,但不能混用。
- 使用冒号(
:
)表示键值对,键值对之间使用换行分隔。 - 使用破折号(
-
)表示列表项,列表项之间也使用换行分隔。
# 使用缩进表示层级关系server: port: 8080# 使用冒号表示键值对name: John Smithage: 30# 使用破折号表示列表项hobbies: - reading - hiking - swimming
-
注释:
- 使用井号(
#
)表示注释,在#
后面的内容被视为注释,可以出现在行首或行尾。
# 这是一个注释name: John Smithage: 30 # 这也是一个注释
- 使用井号(
-
字符串:
- 字符串可以使用单引号或双引号括起来,也可以不使用引号。
- 使用双引号时,可以使用转义字符(如
\\n
表示换行)和转义序列(如\\u
表示Unicode
字符)。
# 使用双引号表示字符串name: \"John Smith\"# 使用单引号表示字符串nickname: \'Johnny\'
-
键值对:
- 键值对使用冒号(
:
)表示,键和值之间使用一个 空格 分隔。 - 键可以是字符串或纯量(如整数、布尔值等)。
- 值可以是字符串、纯量、列表或嵌套的键值对。
# 键和值之间使用一个空格分隔name: John Smith# 键可以是字符串或纯量age: 30# 值可以是字符串、纯量、列表或嵌套的键值对address: city: San Francisco state: California zip: 94107
- 键值对使用冒号(
-
列表:
- 使用破折号(
-
)表示列表项。 - 列表项可以是字符串、纯量或嵌套的列表或键值对。
# 使用破折号表示列表项hobbies: - reading - hiking - swimming # 列表项可以是字符串、纯量或嵌套的列表或键值对people: - name: John Smith age: 30 - name: Jane Doe age: 25
- 使用破折号(
-
引用:
- 使用
&
表示引用,使用*
表示引用的内容。
# 使用&表示引用address: &myaddress city: San Francisco state: California zip: 94107 # 使用*表示引用的内容shippingAddress: *myaddress
- 使用
-
多行文本块:
- 使用
|
保留换行符,保留文本块的精确格式。 - 使用
>
折叠换行符,将文本块折叠成一行,并根据内容自动换行。
# 使用|保留换行符description: | This is a multi-line string. # 使用>折叠换行符summary: > This is a summary that may contain line breaks.
- 使用
-
数据类型:
- YAML支持多种数据类型,包括字符串、整数、浮点数、布尔值、日期和时间等。
- 可以使用标记来表示一些特殊的数据类型,如
!!str
表示字符串类型、!!int
表示整数类型等。
# 使用标记表示数据类型age: !!int 30weight: !!float 65.5isMale: !!bool truecreated: !!timestamp \'2022-01-01 12:00:00\'
-
多文件:
- 可以使用—表示多个 YAML 文件之间的分隔符。每个文件可以使用任何 YAML 语法。
# 第一个YAML文件name: John Smithage: 30---# 第二个YAML文件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\")
的形式获取到指定交易,指定字段的脱敏规则。
脱敏逻辑实现
读取 YAML 配置文件获取脱敏规则
-
首先创建 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\"
-
定义脱敏工具类
DataDesensitizationUtils
编写我们的脱敏逻辑:public class DataDesensitizationUtils {}
-
在
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 语句来自动关闭输入流。
通过键路径获取对应字段规则
原始
-
在上文中我们已经将
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 格式显示如下:
-
输出 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\": { \"rule\": \"\\\\d(?=\\\\d{4})\", \"format\": \"+\" } } }}
-
输出
Y3800
层级下的数据:{ \"phone\": { \"rule\": \"(\\\\d{3})\\\\d{4}(\\\\d{4})\", \"format\": \"$1****$2\" }, \"idCard\": { \"rule\": \"(?<=\\\\w{3})\\\\w(?=\\\\w{4})\", \"format\": \"*\" }}
-
输出
phone
层级下的数据:{ \"rule\": \"(\\\\d{3})\\\\d{4}(\\\\d{4})\", \"format\": \"$1****$2\"}
-
在这里,我们需要仔细思考一下,在我们通过 Key 获取指定层级下的数据时,我们需要不断的调用 Map.get(\"Key\")
方法,即结构每嵌套一次,就需要一次 getKey,那么这里是否有优化的方法呢?
答案是:有的,因为有问题就会有答案。
优化后
首先我们需要先了解一个概念:
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,直到找到键路径所对应的最里层的嵌套 Map,并返回该 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 = YamlUtils.getNestedMap(yamlMap, \"Y3800\", \"phone\"); System.out.println(\"Y3800 -> phone : \" + y3800NameMap);}
具体来说,主要分为以下几步:
- 首先判断键路径是否为空或者第一个键是否在 Map 中。如果键路径为空或者第一个键不在 Map 中,则返回 null。
- 获取第一个键对应的嵌套对象。通过 get 方法获取第一个键对应的嵌套对象。
- 判断是否到达最里层的嵌套 Map。如果键路径长度为 1,说明已经到达最里层的嵌套 Map,直接返回该 Map 对象。
- 继续递归查找下一个键的嵌套 Map。如果嵌套对象是 Map,则继续递归查找下一个键的嵌套 Map。
- 返回结果。返回递归查找的结果。
对数据进行脱敏处理
获取到字段的脱敏规则后,我们就可以编写方法实现对源数据做脱敏处理,脱敏方法如下:
/** * 使用指定规则对数据进行脱敏处理 * * @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); // 对当前节点的值进行脱敏处理 String desensitized = desensitizeLogic((String) entry.getValue(), nestedMap); entry.setValue(desensitized); // 记录日志 log.info(\"脱敏值:【{}:{}】\",entry.getKey(),entry.getValue()); log.info(\"-----------------交易【{}】,字段【{}】脱敏结束-----------------\",servNo,currentPath.replace(\",\",\"->\")); } } } }}
该方法接收一个实体数据 entity
,一个服务号 servNo
和一个路径 path
作为参数。在方法体内,会遍历实体数据的键值对,并根据具体情况递归处理子节点或进行脱敏处理。
- 当实体数据的值为 Map 类型时,方法会递归处理子节点;
- 当值为 List 类型时,方法会遍历列表中的每个元素并递归处理子节点;
- 当值既不是 Map 也不是 List 时,方法会根据服务号和路径获取脱敏规则,并对当前节点的值进行脱敏处理,并记录脱敏日志。
脱敏处理的具体逻辑和规则通过调用 getNestedMap
方法和 desensitizeLogic
方法来实现,其中 getNestedMap
方法用于获取脱敏规则,desensitizeLogic
方法用于根据脱敏规则对数据进行脱敏处理。
注:请注意本文中提供的数据样例的层次结构是和 YAML 中定义的结构是一样的,再通过上述方法递归后生成的键路径是和从 YAML 中获取规则所需的键路径是一致的,因此可以直接调用 getNestedMapValues()
获取脱敏规则。在实际使用中,其他数据结构需要重写该逻辑。
脱敏测试
编写 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(\"txHeader\")).get(\"servNo\"); DataDesensitizationUtils.parseData(data.get(\"txEntity\"), servNo, \"\"); } }}
运行测试,控制台输出如下:
-----------------交易【Y3801】,字段【idCard】开始脱敏-----------------原始值:【idCard:130428197001180384】脱敏规则:{rule=(?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\" }}
完整工具类
封装成完整的工具类如下:
/** * @ClassName DataDesensitizationUtils * @Description 数据脱敏工具类 * @Author 赵士杰 * @Date 2024/1/25 20:15 */@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> 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; } } } /** * 对指定实体数据进行脱敏处理 * * @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) { parseData(entry.getValue(), servNo, currentPath); } else if (entry.getValue() instanceof List) { for (Object item : (List) entry.getValue()) { if (item instanceof Map) { parseData(item, servNo, currentPath); } } } else { 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); String desensitized = desensitizeLogic((String) entry.getValue(), nestedMap); entry.setValue(desensitized); log.info(\"脱敏值:【{}:{}】\", entry.getKey(), entry.getValue()); log.info(\"-----------------交易【{}】,字段【{}】脱敏结束-----------------\", servNo, currentPath.replace(\",\", \"->\")); } } } } } /** * 脱敏逻辑 * @param data 源数据 * @param map 脱敏规则 * @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; }}