目录导读
Json在开源SpringBoot/SpringCloud微服务框架中的最佳实践
- Json(JavaScript Object Notation,JavaScript 对象简谱),是一种轻量级的数据交换格式。易于人阅读和编写,可以在多种语言之间进行数据交换;
- 在当下流行的分布式服务开发中,前后端交互、后端之间的接口交互,一般都使用json格式的数据。因此,你只要做后端研发,就很难绕过json数据的转换;
- 在项目中,经常会存在多种类型的json。部分是你主动选型的,部分是被你引用的其它三方件给引用的,不得不用;
- json结构的数据具备可读写强,占用存储空间较小的综合优势。随着当下RESTful风格的流行,HTTP服务逐渐取代了WSDL规范的WebService。基于HTTP的json数据相比基于WebService的xml数据,就具备更好的可读写、更小存储空间的特点;当然json并不是最省存储空间的数据格式,在约定好字段顺序的前提下,完全可以只传输json的value值(类似csv格式),这样占用的存储空间就可以进一步大大减小,但又违背了可读写原则,无法做到自解释。所以,Json数据格式是在可读写和存储空间平衡下的较优选择。
所谓
存储空间
,是相对Json数据而言的。Json数据在网络传输时,存储空间
其实就是占用的带宽大小;Json数据在在内存中处理时,存储空间
对应的就是占用的内存大小;Json数据在持久化至本地磁盘时,存储空间
就是占用的磁盘大小; - Json有非常多的实现方式,包括jackson/fastjson/gson/json-lib/org.json等,做好Json选型也不是件容易的事;
1. Json使用场景
Json通常用在接口交互,随着Json的广泛使用,在不同的业务场景下,会演化出不同的使用方式,随之出现的问题也越来越来。仅从本人的经验来讲,就碰到了Json使用上的如下诉求:
- 在有的项目中,接口的Json参数是驼峰的;在另外一些项目中,接口的Json参数是下划线的;甚至存在同一个接口,在不同的项目中,有要求驼峰的,也有要求下划线的;
有人可能觉得这样很奇怪,但这在企业应用中是真实存在的。存在即合理,为了保障客户使用的延续性,需要架构师/coder做好兼容性设计和编码。
- Json返回参数不能多字段,多了会导致部分客户端报错;
这里有客户设计不合理的地方,也可以通过服务端的Json处理去规避这个问题。
- 部分模型字段涉及敏感数据,在接口返回Json参数或日志打印时,必须脱敏;
这个场景比较复杂:存在日志必须脱敏,但是接口响应Json结果不脱敏的情况;也存在日志脱敏,接口响应Json也要脱敏的情况。接口设计时,必须考虑这些场景的兼容支持。
- Json数据的字段非常大,日志打印时,需要忽略或者缩减,否则日志报文非常大;
直接忽略Json属性的好处是处理简单,缺点是不方便定位问题,缩减则是比较优雅的处理方式。
- Json数据需要转换成复杂的带多层泛型的业务模型,如:
List<Page<User>>
;在Json反序列化成模型时,存在泛型类型内存擦除的问题,必须要做特殊处理。
- 接口入参需要接收多个不同的Json属性名;
可以提升接口的兼容性;
- 接口调用第三方时,模型字段需要同时接收多个不同名字的Json属性名;
可以提升接口的兼容性;
- 接口中存在特殊类型,业务中需要自定义扩展才能满足要求;
如:复用的oauth2框架时,返回JwtToken Json包含了Instant类型,现在需要把默认的秒转换成毫秒;
- 有接口返回Json的某字段是字符串,但在另外场景下,该接口返回Json的该字段为字符串数组;
一般出现在接口的兼容设计中,如:刚开始的字段只考虑了支持单个类型,但是随着业务的发展,接口必须要支持批量。虽然设计了2个不同的接口搞定了这个问题,但是也有更优的复用处理方式。
2. Json类型
Json实现方式非常多,各有各的使用场景,常见的方式是和同源的其他组件绑定。常用的Json主要有jackson/fastjson/gson 3种,其它还包括:json-lib/org.json等。
2.1 Json对比列表
结合网上信息及本人的使用体验,Json对比汇总如下:
Json类型 | 序列化性能 | 反序列化 | 安全性 | 可扩展性 | 生态 |
---|---|---|---|---|---|
Jackson | 最好 | 好 | 好 | 好 | 好,SpringBoot默认集成 |
FastJson | 好 | 最好 | 较差 | NA | 较好,AlibabaSpringCloud默认集成 |
Gson | 较好 | 好 | 好 | NA | 较好,Guava等默认集成 |
JsonLib | 差 | 差 | NA | NA | NA |
org.json | NA | NA | NA | NA | NA |
补充说明:
- 在多年的项目经验中,FastJson是漏洞最多的,因为漏洞导致的发版次数较多;客观来讲,FastJson还是非常优秀的,尤其是在当下,国人更当自强;
- Jackson极少有安全漏洞,且能够支持各种形式的扩展,比如属性忽略、别名注解,以及多种形式的序列化、反序列化注解,扩展非常优雅;
- 参考数据1:Java几种常用JSON库性能比较
- 参考数据2:性能大比拼!这三个主流的JSON解析库,一个快,一个稳,还有一个你想不到!
2.2 Json 选型
综合观察上述对比指标,Jackson和FastJson表现较为突出,结合业务场景,选型如下:
-
以SpringBoot/SpringCloud套件为主的后端服务中,Jackson为默认内置的Json框架,且具有高性能、可扩展性强、生态完善的综合优势,建议采用;
-
以AlibabaSpringCloud为基础的服务中,则建议使用FastJson,因其具备高性能、迭代快的优点。但这几年漏洞较多,建议尽量按照简单优雅的方式去使用,避免深度定制后,漏洞修复困难;
在项目中,尽量只使用一种Json框架的工具类及Web应用封装,方便后续能够快速迁移到其它Json框架,一旦大家开始使用不同的Json框架编写业务逻辑,项目会非常混乱且难以维护;
3. Json在项目中的实践
本人先后使用过FastJson和Jackson,基于上面的实战原因,最终还是回归到Jackson。后面所有的内容,均以Jackson框架为基础,结合个人的经验进行阐述。如有疏误,敬请斧正。
3.1 Json字符串和模型转换
- Json字符串和模型转换,在Java语言中,就是
Json序列化
(Java模型->Json字符串)和Json反序列化
(Json字符串->Java模型),Json反序列化
其实也是深拷贝
种的一种,就是创建出了一个Java模型对象出来。一旦理解了Json 反序列化
和深拷贝
的关系,就会很容易想到Json反序列化
其实和Object.clone()
/org.springframework.beans.BeanUtils.copyProperties
/java.io.ObjectInputStream.readObject
有异曲同工之妙。 - json基础能力的封装,在已开源的Spring基础框架 中,核心包名为:
com.biuqu.json
,Json工具类为com.biuqu.utils.JsonUtil
:<dependency> <groupId>com.biuqu</groupId> <artifactId>bq-base</artifactId> <version>1.0.5</version> </dependency>
如果在代理的maven中央仓库无法下载到上述依赖包,则需要临时更换alibaba maven代理为maven官方代理 :
https://repo1.maven.org/maven2/
,因为alibaba在2022年底之后就不保证新的jar包可以同步了; - Json处理的工具类
com.biuqu.utils.JsonUtil
源码 如下:public final class JsonUtil { /** * 把json字符串转换成指定类型的对象 * * @param json json字符串 * @param clazz 模型的class类型 * @param <T> 模型类型 * @return 业务模型实例 */ public static <T> T toObject(String json, Class<T> clazz) { return toObject(json, clazz, false); } /** * 把json字符串转换成指定类型的对象 * * @param json json字符串 * @param clazz 模型的class类型 * @param snake 是否下划线转驼峰 * @param <T> 模型类型 * @return 业务模型实例 */ public static <T> T toObject(String json, Class<T> clazz, boolean snake) { ObjectMapper mapper = JsonMappers.getMapper(snake); try { return mapper.readValue(json, clazz); } catch (JsonProcessingException e) { LOGGER.error("parse object error.", e); } return null; } /** * 把json字符串转换成指定类型的List集合 * * @param json json字符串 * @param clazz 模型的class类型 * @param <T> 模型类型 * @return 业务模型实例集合 */ public static <T> List<T> toList(String json, Class<T> clazz) { return toList(json, clazz, false); } /** * 把json字符串转换成指定类型的List集合 * * @param json json字符串 * @param clazz 模型的class类型 * @param snake 是否下划线转驼峰 * @param <T> 模型类型 * @return 业务模型实例集合 */ public static <T> List<T> toList(String json, Class<T> clazz, boolean snake) { ObjectMapper mapper = JsonMappers.getMapper(snake); JavaType type = mapper.getTypeFactory().constructParametricType(ArrayList.class, clazz); try { return mapper.readValue(json, type); } catch (JsonProcessingException e) { LOGGER.error("parse list error.", e); } return null; } /** * 获取Map集合 * * @param json json字符串 * @param kClazz 模型的key class类型 * @param vClazz 模型的value class类型 * @param <K> 模型的key类型 * @param <V> 模型的value类型 * @return 业务模型实例集合 */ public static <K, V> Map<K, V> toMap(String json, Class<K> kClazz, Class<V> vClazz) { return toMap(json, kClazz, vClazz, false); } /** * 获取Map集合 * * @param json json字符串 * @param kClazz 模型的key class类型 * @param vClazz 模型的value class类型 * @param snake 是否下划线转驼峰 * @param <K> 模型的key类型 * @param <V> 模型的value类型 * @return 业务模型实例集合 */ public static <K, V> Map<K, V> toMap(String json, Class<K> kClazz, Class<V> vClazz, boolean snake) { ObjectMapper mapper = JsonMappers.getMapper(snake); JavaType type = mapper.getTypeFactory().constructParametricType(Map.class, kClazz, vClazz); try { return mapper.readValue(json, type); } catch (JsonProcessingException e) { LOGGER.error("parse map error.", e); } return null; } /** * 把json字符串转换成指定复杂类型的对象(对象有多层嵌套) * * @param json json字符串 * @param typeRef 模型的依赖类型 * @param <T> 模型类型 * @return 业务模型实例集合 */ public static <T> T toComplex(String json, TypeReference<T> typeRef) { return toComplex(json, typeRef, false); } /** * 把json字符串转换成指定复杂类型的对象(对象有多层嵌套) * * @param json json字符串 * @param typeRef 模型的依赖类型 * @param <T> 模型类型 * @param snake 是否下划线转驼峰 * @return 业务模型实例集合 */ public static <T> T toComplex(String json, TypeReference<T> typeRef, boolean snake) { ObjectMapper mapper = JsonMappers.getMapper(snake); try { return mapper.readValue(json, typeRef); } catch (JsonProcessingException e) { LOGGER.error("parse snake complex error.", e); } return null; } /** * 获取json字符串 * * @param t 业务模型 * @param <T> 模型类型 * @return json字符串 */ public static <T> String toJson(T t) { return toJson(t, false); } /** * 获取json字符串 * * @param t 业务模型 * @param snake 是否支持驼峰转换 * @param <T> 模型类型 * @return json字符串 */ public static <T> String toJson(T t, boolean snake) { ObjectWriter writer = JsonMappers.getMapper(snake).writer(); try { return writer.writeValueAsString(t); } catch (JsonProcessingException e) { LOGGER.error("parse json error.", e); } return null; } /** * 获取带忽略属性列表的json字符串 * * @param t 业务模型 * @param ignoreFields 模型中待忽略的属性列表 * @param <T> 模型类型 * @return json字符串 */ public static <T> String toJson(T t, Set<String> ignoreFields) { return toJson(t, ignoreFields, false); } /** * 获取带忽略属性列表的json字符串 * * @param t 业务模型 * @param ignoreFields 模型中待忽略的属性列表 * @param snake 是否支持驼峰转换 * @param <T> 模型类型 * @return json字符串 */ public static <T> String toJson(T t, Set<String> ignoreFields, boolean snake) { ObjectWriter writer = JsonMappers.getIgnoreWriter(ignoreFields, snake, false); try { return writer.writeValueAsString(t); } catch (JsonProcessingException e) { LOGGER.error("parse json error.", e); } return null; } /** * 获取json字符串 * * @param t 业务模型 * @param <T> 模型类型 * @return json字符串 */ public static <T> String toMask(T t) { return toMask(t, false); } /** * 获取json字符串 * * @param t 业务模型 * @param snake 是否支持驼峰转换 * @param <T> 模型类型 * @return json字符串 */ public static <T> String toMask(T t, boolean snake) { return toMask(t, null, snake); } /** * 获取带忽略属性列表的json字符串 * * @param t 业务模型 * @param ignoreFields 模型中待忽略的属性列表 * @param <T> 模型类型 * @return json字符串 */ public static <T> String toMask(T t, Set<String> ignoreFields) { return toMask(t, ignoreFields, false); } /** * 获取带忽略属性列表的json字符串 * * @param t 业务模型 * @param ignoreFields 模型中待忽略的属性列表 * @param snake 是否支持驼峰转换 * @param <T> 模型类型 * @return json字符串 */ public static <T> String toMask(T t, Set<String> ignoreFields, boolean snake) { ObjectWriter writer = JsonMappers.getIgnoreWriter(ignoreFields, snake, true); try { return writer.writeValueAsString(t); } catch (JsonProcessingException e) { LOGGER.error("parse json error.", e); } return null; } private JsonUtil() { } /** * 日志句柄 */ private static final Logger LOGGER = LoggerFactory.getLogger(JsonUtil.class); }
源码 基本上包含了上述业务场景的大部分解决方案,在后续对应章节再一一阐述。
3.1.1 Json字符串和模型转换
- 验证代码 :
@Test public void toObject() { String json = JsonUtil.toJson(person); Person object1 = JsonUtil.toObject(json, Person.class); System.out.println("object1==" + JsonUtil.toJson(object1)); Assert.assertTrue(object1.getCardId() != null); String json2 = JsonUtil.toJson(person, true); System.out.println("json2==" + json2); Assert.assertTrue(json2.contains("card_id")); Person object2 = JsonUtil.toObject(json2, Person.class, true); System.out.println("object2==" + JsonUtil.toJson(object2)); Assert.assertTrue(object2.getCardId() != null); }
验证了Json字符串和模型的相互转换,包括下划线Json和驼峰模型的转换,API封装相对较为简洁;
3.1.2 Json字符串转换成复杂的Java对象
- 验证代码 :
@Test public void toComplex() { List<Person> persons = Lists.newArrayList(person); ResultCode<List<Person>> result = ResultCode.ok(persons); String json = JsonUtil.toJson(result); System.out.println("complex json==" + json); Assert.assertTrue(json.contains("[")); ResultCode<List<Person>> newResult = JsonUtil.toComplex(json, new TypeReference<ResultCode<List<Person>>>() { }); System.out.println("complex new json==" + JsonUtil.toJson(newResult)); Assert.assertTrue(newResult.getData().size() == 1); }
ResultCode<List<Person>>
对象对应的Json字符串,因为Java泛型编译后内存擦除问题,是不好直接用JsonUtil.toObject
来反序列化的,
Jackson提供了TypeReference<T>
来解决这个问题,但是此方法的性能相对较差,且因为内存擦除的问题,无法抽象使用;
3.1.3 Java特殊类型转换成Json字符串
-
验证代码 :
@Test public void testToJson() { Person person = new Person(); person.setName("haha"); person.setTime(Instant.now()); String json = JsonUtil.toJson(person); System.out.println("json=" + json); Assert.assertTrue(json.contains("time")); JSONObject jsonObject = new JSONObject(json); Object time = jsonObject.get("time"); Assert.assertTrue((time instanceof Long) && time.toString().length() == 13); }
打印的效果为:
json={"name":"haha","time":1686097955210}
。这是因为我在JsonUtil 内引用的JsonMappers 中扩展了对Instant类型的支持,代码如下:JavaTimeModule timeModule = new JavaTimeModule(); JsonInstantSerializer instantSerializer = new JsonInstantSerializer(); //替换其中的Instant时间转换(从秒转到毫秒) timeModule.addSerializer(Instant.class, instantSerializer); SNAKE_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); SNAKE_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); SNAKE_MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); SNAKE_MAPPER.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); SNAKE_MAPPER.setAnnotationIntrospector(new JsonDisableAnnIntrospector()); //注册时间处理模块,注册全部模块方法:findAndRegisterModules SNAKE_MAPPER.registerModule(timeModule);
如果不扩展的话,则会抛出如下异常:
Java 8 date/time type `java.time.Instant` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: com.biuqu.json.JsonUtilTest$Person["time"])
3.1.4 Java Json扩展使用
3.1.4.1 @JsonAlias使用
- 当需要和多个关联方渠道对接时,由于关联方的接口定义存在一定差异,如:A/B/C 3个渠道接口的Json描述字段名分别为
message
/tips
/msg
,则可以采用@JsonAlias
屏蔽这种差异。测试代码 如下:
运行效果如下:@Test public void testAlias() { Map<String, String> map = Maps.newHashMap(); map.put("message", "msg1"); String json = JsonUtil.toJson(map); Result result1 = JsonUtil.toObject(json, Result.class); System.out.println("alias json1=" + JsonUtil.toJson(result1)); Map<String, String> map2 = Maps.newHashMap(); map2.put("tips", "tip1"); String json2 = JsonUtil.toJson(map2); Result result2 = JsonUtil.toObject(json2, Result.class); System.out.println("alias json2=" + JsonUtil.toJson(result2)); Map<String, String> map3 = Maps.newHashMap(); map3.put("msg", "msg2"); String json3 = JsonUtil.toJson(map3); Result result3 = JsonUtil.toObject(json3, Result.class); System.out.println("alias json3=" + JsonUtil.toJson(result3)); } @NoArgsConstructor @Data private static class Result { @JsonAlias({"message", "tips"}) private String msg; @JsonProperty("name") private String user; @JsonIgnore private String spanId; }
alias json1={"msg":"msg1"} alias json2={"msg":"tip1"} alias json3={"msg":"msg2"}
3.1.4.2 @JsonProperty使用
-
当我们需要修改已定义好接口的字段名时,由于对应的Java模型代码在多处被引用,直接改Java属性工作量较大,且容易出错,则可以采用
@JsonProperty
来实现Json字段的重命名。测试代码 如下:@Test public void testProperty() { Map<String, String> map = Maps.newHashMap(); map.put("user", "user1"); String json = JsonUtil.toJson(map); Result result1 = JsonUtil.toObject(json, Result.class); System.out.println("property json1=" + JsonUtil.toJson(result1)); Map<String, String> map2 = Maps.newHashMap(); map2.put("name", "user2"); String json2 = JsonUtil.toJson(map2); Result result2 = JsonUtil.toObject(json2, Result.class); System.out.println("property json2=" + JsonUtil.toJson(result2)); Result param3 = new Result(); param3.setUser("user3"); String json3 = JsonUtil.toJson(param3); Result result3 = JsonUtil.toObject(json3, Result.class); System.out.println("property json3=" + JsonUtil.toJson(result3)); }
运行效果如下:
property json1={} property json2={"name":"user2"} property json3={"name":"user3"}
总结下
@JsonAlias
和@JsonProperty
的差异:@JsonAlias
只参与Json反序列化
,支持多对一转换,即:把Json字符串中的多个不同的字段名,统一转换成Java模型中的属性名。@JsonProperty
既参与Json序列化
,也参与Json反序列化
,只支持唯一转换,即:在Json和模型相互转换时,只使用@JsonProperty
注解中的唯一属性名。
3.1.4.3 @JsonIgnore使用
@JsonIgnore
就是Json序列化
和Json反序列化
时,都忽略属性,使用上非常容易理解。该注解的主要使用场景是Java模型有,Json中没有,且在Java模型中,只能通过Java的方法去赋值。测试代码 如下:
运行效果如下:@Test public void testIgnore() { Map<String, String> map = Maps.newHashMap(); map.put("spanId", "span1"); String json = JsonUtil.toJson(map); Result result1 = JsonUtil.toObject(json, Result.class); System.out.println("ignore json1=" + JsonUtil.toJson(result1)); Result param2 = new Result(); param2.setSpanId("span2"); System.out.println("ignore json2=" + JsonUtil.toJson(param2)); }
ignore json1={} ignore json2={}
3.1.4.4 动态忽略Json字段
- 动态忽略Json字段,同
@JsonIgnore
效果相同。差异点在于:前者可以动态设置(比如说:可在配置文件中配置),且还可以批量设置;后者只能单个属性设置,且是在源码中写死。
运行效果如下:@Test public void testIgnore2() { Result result1 = new Result(); result1.setId("req001"); String json1 = JsonUtil.toJson(result1); System.out.println("json1=" + json1); Assert.assertTrue(json1.contains("id")); Set<String> ignoreFields = Sets.newHashSet("id"); String json2 = JsonUtil.toJson(result1, ignoreFields); System.out.println("json2=" + json2); Assert.assertTrue(!json2.contains("id")); }
动态忽略Json字段核心代码逻辑:json1={"id":"req001"} json2={}
/** * 获取带忽略属性的Mapper对象 * * @param mapper jackson转换器 * @param ignoreFields 忽略的属性列表 * @return Mapper对象的新Writer对象 */ public static ObjectWriter getIgnoreWriter(ObjectMapper mapper, Set<String> ignoreFields) { if (!CollectionUtils.isEmpty(ignoreFields)) { SimpleFilterProvider provider = new SimpleFilterProvider(); SimpleBeanPropertyFilter fieldFilter = SimpleBeanPropertyFilter.serializeAllExcept(ignoreFields); provider.addFilter(IGNORE_ID, fieldFilter); //对所有的Object的子类都生效的属性过滤 mapper.addMixIn(Object.class, JsonIgnoreField.class); return mapper.writer(provider); } return mapper.writer(); }
上述代码最核心逻辑
mapper.addMixIn(Object.class, JsonIgnoreField.class);
其实就是设置了对指定的class类型的属性都做过滤。
3.1.4.5 自定义Json打码
- 自定义Json打码,主要是通过模型的
@JsonMaskAnn
和自定义的JsonRule
规则来实现的。测试代码 如下:
运行效果如下:@Test public void toJson() { //case1:不忽略+不打码 String json1 = JsonUtil.toJson(this.person); System.out.println("json1=" + json1); Assert.assertTrue(json1.contains("20000101")); Assert.assertTrue(json1.contains("pwd")); //case2:不忽略+打码 String json2 = JsonUtil.toMask(this.person); System.out.println("json2=" + json2); Assert.assertTrue(!json2.contains("20000101")); Assert.assertTrue(json2.contains("pwd")); //case3:忽略+不打码 Set<String> ignoreFields = Sets.newHashSet("pwd"); String json3 = JsonUtil.toJson(this.person, ignoreFields); System.out.println("json3=" + json3); Assert.assertTrue(json3.contains("20000101")); Assert.assertTrue(!json3.contains("pwd")); //case4:忽略+打码 String json4 = JsonUtil.toMask(this.person, ignoreFields); System.out.println("json4=" + json4); Assert.assertTrue(!json4.contains("pwd")); Assert.assertTrue(!json4.contains("20000101")); }
json1={"name":"狄仁杰","base64":"/9j/4AAQSkZJRgABAQEAYABgAAD/4QAwRXhpZgAATU0AKgAAAAgAAQExAAIAAAAOAAAAGgAAVpdHUuY29tAP/bAEMA","pwd":"123456","phone":"13234567890","cardId":"444444200001016666","bankNo":"666444444200001016666333"} json2={"name":"狄$$","base64":"/9j/4AAQSkZJRgABAQEAYABgAAD/4QAwRXhpZgAATU0AKgAAAA......","pwd":"123456","phone":"132****7890","cardId":"444444********6666","bankNo":"666444**************6333"} json3={"name":"狄仁杰","base64":"/9j/4AAQSkZJRgABAQEAYABgAAD/4QAwRXhpZgAATU0AKgAAAAgAAQExAAIAAAAOAAAAGgAAVpdHUuY29tAP/bAEMA","phone":"13234567890","cardId":"444444200001016666","bankNo":"666444444200001016666333"} json4={"name":"狄$$","base64":"/9j/4AAQSkZJRgABAQEAYABgAAD/4QAwRXhpZgAATU0AKgAAAA......","phone":"132****7890","cardId":"444444********6666","bankNo":"666444**************6333"}
@JsonMaskAnn
代码如下:/** * Jackson针对对象的属性打码注解 * * @author BiuQu * @date 2023/1/4 15:01 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @JacksonAnnotationsInside @JsonSerialize(using = JsonMaskSerializer.class) public @interface JsonMaskAnn { }
@JsonMaskAnn
中的核心逻辑在其注解的JsonMaskSerializer
类中:
打码的核心逻辑在public class JsonMaskSerializer extends BaseJsonSerializer<String> { @Override protected Object getNewValue(Object object, String key, String value) { if (ApplicationContextHolder.containsBean(Const.JSON_MASK_SVC)) { JsonMaskMgr maskMgr = ApplicationContextHolder.getBean(Const.JSON_MASK_SVC); return maskMgr.applyRule(object.getClass().getName(), key, value); } else { return JsonRuleMgr.applyRule(key, value); } } } public abstract class BaseJsonSerializer<T> extends JsonSerializer<T> { @Override public void serialize(T value, JsonGenerator jsonGen, SerializerProvider provider) throws IOException { String name = jsonGen.getOutputContext().getCurrentName(); Object object = provider.getGenerator().currentValue(); Object newValue = getNewValue(object, name, value); if (value instanceof String) { if (newValue instanceof String) { jsonGen.writeString(newValue.toString()); } } else if (value instanceof Instant) { jsonGen.writeNumber(Long.parseLong(newValue + StringUtils.EMPTY)); } } /** * 获取序列后的新值 * * @param object 原始对象 * @param key 键值对的key * @param value 键值对的value * @return 新值 */ protected abstract Object getNewValue(Object object, String key, T value); }
JsonRule
类中,其实就是字符串判断和替换,逻辑不复杂,就不展示了。注意:Json
打码
和脱敏
都是针对敏感数据的模糊处理方式,二者含义其实是有区别的。打码
是隐藏部分字段,但是还有部分字段是真实的敏感数据,脱敏
则是通过换算,完全不暴露原始敏感信息,也无法还原(如:Hash摘要等)。
3.1.4.6 自定义Json脱敏
- 自定义Json打码,主要是通过模型的
@JsonFuzzyAnn
来实现的。测试代码
如下:
运行结果:@Test public void testFuzzy() { Result result1 = new Result(); result1.setSign("sign001"); String json1 = JsonUtil.toMask(result1); System.out.println("json1=" + json1); Assert.assertTrue(json1.contains("sign")); Assert.assertTrue(!json1.contains(result1.getSign())); }
json1={"sign":"e934a381a2ef37e88d0a5f4e449209ab01f44825da22a9ea5adca7ffa70dbfd98f98d16f32f1a180e8d402cd8602d6cd19f19e52e0e0c5b23b14783cea29df39"}
3.2 Json在spring-boot-starter-web的应用
- Jackson是SpringBoot的内置Json框架,可以做到客户端传入Json字符串时,后端通过
@RestController
注解暴露的Web服务就可以做到反序列化成Java模型对象。当然,Web服务的Java模型入参还需要通过@RequestBody
注解进行标记。 - 在后端Web服务做完业务逻辑处理后,又会通过Json框架把要返回的出参Java模型序列化成Json字符串,返回给客户端。注意:返回数据时,还要指定
ContentType
为:application/json;charset=UTF-8
,避免响应数据乱码。 - SpringBoot也提供了Json框架的扩展点,方便项目对默认的Json框架进行替换,同时也可以指定全局的
ContentType
。扩展方法会在下一小节说明。
3.2.1 Json驼峰/下划线字符串自动转换成Java模型
- 前面提到了有些大公司因为系统复杂,开发团队众多,导致有些服务接口入参和出参的字段名是下划线的,有些是驼峰的。可以通过SpringBoot Web框架的扩展点进行扩展。代码摘录 如下:
项目对应的yaml配置(以@Slf4j @Configuration public class WebMvcConfigurer extends BaseWebConfigurer { /** * actuator健康检查的自定义类型 */ private static final String ACTUATOR_TYPE = "vnd.spring-boot.actuator.v3+json"; /** * 是否驼峰式json(默认支持) */ @Value("${bq.json.snake-case:true}") private boolean snakeCase; @Override protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) { for (int i = 0; i < converters.size(); i++) { HttpMessageConverter<?> converter = converters.get(i); if (converter instanceof MappingJackson2HttpMessageConverter) { ObjectMapper mapper = JsonMappers.getMapper(this.snakeCase); MappingJackson2HttpMessageConverter conv = new MappingJackson2HttpMessageConverter(); conv.setObjectMapper(mapper); //默认返回的Jackson对应的Rest服务的ContentType为:'application/json;charset=UTF-8' List<MediaType> types = Lists.newArrayList(); MediaType utf8Type = new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8); types.add(utf8Type); //兼容支持actuator健康检查 MediaType actuatorType = new MediaType(MediaType.APPLICATION_JSON.getType(), ACTUATOR_TYPE); types.add(actuatorType); conv.setSupportedMediaTypes(types); //把旧的转换器替换成新的转换器 converters.set(i, conv); break; } } } }
bq-service-biz
服务为例),代码示例 如下:bq: json: snake-case: true
通过上述代码设计,把驼峰和下划线转换变成了yaml中的一个配置项。同时约定了所有接口的
ContentType
为:application/json;charset=UTF-8
,使用也非常简单。
3.2.2 Json接口和运行日志脱敏在web中的综合应用
在3.1.4
章节中就介绍了每种扩展的单独使用及验证效果。在实际项目中基本上都是一起使用的。为了一次性说明这个问题,定义了一个入参Java模型和一个出参Java模型,并通过一个RestController来进行串联。设计的样例中,同时包含了脱敏、打码、动态忽略等Json应用。
-
入参模型:
@Data public class UserInner { /** * 用户名(打码到日志) */ @JsonMaskAnn private String name; /** * 密码(不能打印到日志) */ private String pwd; /** * 秘钥key(不能打印到日志) */ private String key; /** * 真实姓名(打码到日志) */ @JsonMaskAnn private String realName; /** * 身份证号(打码到日志) */ @JsonMaskAnn private String cardId; /** * 银行卡号(打码到日志) */ @JsonMaskAnn private String bankNo; /** * 电话号码(打码到日志) */ @JsonMaskAnn private String phone; /** * 头像Base64(打码到日志) */ @JsonMaskAnn private String photo; /** * 头像Base64签名值(打印摘要值到日志) */ @JsonFuzzyAnn private String photoHash; }
-
出参模型:
@Data public class UserOuter { /** * 用户名(打码返回) */ @JsonMaskAnn private String name; /** * 密码(不能接口返回) */ private String pwd; /** * 秘钥key(不能接口返回) */ private String key; /** * 真实姓名(接口返回) */ @JsonMaskAnn private String realName; /** * 身份证号(打码返回) */ @JsonMaskAnn private String cardId; /** * 银行卡号(打码返回) */ @JsonMaskAnn private String bankNo; /** * 电话号码(打码返回) */ @JsonMaskAnn private String phone; /** * 头像Base64(接口返回) */ private String photo; /** * 头像Base64签名值(不能接口返回) */ private String photoHash; }
-
定义RestController:
@Slf4j @RestController public class DemoUserController { @PostMapping("/demo/user/query") public ResultCode<UserOuter> execute(@RequestBody UserInner user) { log.info("current inner 1:{}", JsonUtil.toJson(user)); log.info("current inner snake 2:{}", jsonFacade.toJson(user, true)); log.info("current inner mask 3:{}", jsonFacade.toMask(user, true)); log.info("current inner ignore 4:{}", jsonFacade.toIgnore(user, true)); log.info("current inner all 5:{}", jsonFacade.toJson(user, true, true, true)); UserOuter outer = new UserOuter(); BeanUtils.copyProperties(user, outer); log.info("current outer 1:{}", JsonUtil.toJson(outer)); log.info("current outer snake 2:{}", jsonFacade.toJson(outer, true)); log.info("current outer mask 3:{}", jsonFacade.toMask(outer, true)); log.info("current outer ignore 4:{}", jsonFacade.toIgnore(outer, true)); log.info("current outer all 5:{}", jsonFacade.toJson(outer, true, true, true)); ResultCode<UserOuter> resultCode = ResultCode.ok(outer); return resultCode; } /** * 注入json处理服务 */ @Autowired private JsonFacade jsonFacade; }
-
执行curl命令:
curl --location 'http://localhost:9993/demo/user/query' \ --header 'Content-Type: application/json' \ --data '{ "name": "name123", "pwd": "pwd123", "key": "key123", "real_name": "狄仁杰", "card_id": "123456789876543212", "bank_no": "1234567898765432123456789", "phone": "12345678901", "photo": "1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789", "photo_hash":"fff1234567898765432123456789" }'
-
打码、脱敏、动态忽略后的接口返回结果:
{ "code": "100001", "msg": "通过", "data": { "name": "na$$$23", "real_name": "狄**", "card_id": "123456********3212", "bank_no": "123456***************6789", "phone": "123****8901", "photo": "1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789" }, "cost": 0 }
-
打码、脱敏、动态忽略后的后台日志结果:
current inner 1:{"name":"name123","pwd":"pwd123","key":"key123","realName":"狄仁杰","cardId":"123456789876543212","bankNo":"1234567898765432123456789","phone":"12345678901","photo":"1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789","photoHash":"fff1234567898765432123456789"} current inner snake 2:{"name":"name123","pwd":"pwd123","key":"key123","real_name":"狄仁杰","card_id":"123456789876543212","bank_no":"1234567898765432123456789","phone":"12345678901","photo":"1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789","photo_hash":"fff1234567898765432123456789"} current inner mask 3:{"name":"na$$$23","pwd":"pwd123","key":"key123","real_name":"狄**","card_id":"123456********3212","bank_no":"123456***************6789","phone":"123****8901","photo":"12345678987654321234567891234567898765432123456789......","photo_hash":"40d3b1825d4a47d77df97a383399300a12180961a60492d10d7ac46f85cfd32e81325bbdbc78a7c69b07c0d66122217855048f9234538dceb1cdfd579d64b7bc"} current inner ignore 4:{"name":"name123","real_name":"狄仁杰","card_id":"123456789876543212","bank_no":"1234567898765432123456789","phone":"12345678901","photo":"1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789","photo_hash":"fff1234567898765432123456789"} current inner all 5:{"name":"na$$$23","real_name":"狄**","card_id":"123456********3212","bank_no":"123456***************6789","phone":"123****8901","photo":"12345678987654321234567891234567898765432123456789......","photo_hash":"40d3b1825d4a47d77df97a383399300a12180961a60492d10d7ac46f85cfd32e81325bbdbc78a7c69b07c0d66122217855048f9234538dceb1cdfd579d64b7bc"} current outer 1:{"name":"name123","pwd":"pwd123","key":"key123","realName":"狄仁杰","cardId":"123456789876543212","bankNo":"1234567898765432123456789","phone":"12345678901","photo":"1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789","photoHash":"fff1234567898765432123456789"} current outer snake 2:{"name":"name123","pwd":"pwd123","key":"key123","real_name":"狄仁杰","card_id":"123456789876543212","bank_no":"1234567898765432123456789","phone":"12345678901","photo":"1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789","photo_hash":"fff1234567898765432123456789"} current outer mask 3:{"name":"na$$$23","pwd":"pwd123","key":"key123","real_name":"狄**","card_id":"123456********3212","bank_no":"123456***************6789","phone":"123****8901","photo":"1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789","photo_hash":"fff1234567898765432123456789"} current outer ignore 4:{"name":"name123","real_name":"狄仁杰","card_id":"123456789876543212","bank_no":"1234567898765432123456789","phone":"12345678901","photo":"1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789"} current outer all 5:{"name":"na$$$23","real_name":"狄**","card_id":"123456********3212","bank_no":"123456***************6789","phone":"123****8901","photo":"1234567898765432123456789123456789876543212345678912345678987654321234567891234567898765432123456789"}
可以看到打码、脱敏、动态忽略等既可以单独配置,也可以综合在一起使用生效。
-
配置规则 为:
bq: json: rules: - clazz: com.biuqu.boot.common.demo.model.UserInner rules: #用户名除了前2个字符和后2个字符外的字符都打'$'码 - name: name index: 2 mask: $ len: -2 #超过50个字符的后面都不显示打码 - name: photo index: -1 mask: . len: 50 #手机号前3后4之外的都打码 - name: phone index: 3 len: -4 #身份证号6后4之外的都打码 - name: card_id index: 6 len: -4 #银行卡号前6后4之外的都打码 - name: bank_no index: 6 len: -4 #姓名字段的第一个字符之后的所有字符都打码 - name: real_name index: 1 len: 0 ignores: - clazz: com.biuqu.boot.common.demo.model.UserInner fields: pwd,key - clazz: com.biuqu.boot.common.demo.model.UserOuter fields: pwd,key,photo_hash
-
在SpringBoot中注入Json管理的
JsonMaskMgr
过程说明如下:
JsonMaskMgr
的注入方式,需要引用如下依赖(这样设计的原因,需要关注下Java开源接口微服务代码框架 相关说明):<dependency> <groupId>com.biuqu</groupId> <artifactId>bq-boot-root</artifactId> <version>1.0.4</version> </dependency>
,对应的注入代码为:
@Configuration public class JsonConfigurer { @Bean(CommonBootConst.JSON_RULES) @ConfigurationProperties(prefix = "bq.json.rules") public List<JsonMask> jsonMasks() { return Lists.newArrayList(); } @Bean(Const.JSON_MASK_SVC) public JsonMaskMgr maskMgr(@Qualifier(CommonBootConst.JSON_RULES) List<JsonMask> masks) { return new JsonMaskMgr(masks); } }
JsonMaskMgr
Json打码管理器的代码为:public class JsonMaskMgr { /** * 构造方法,初始化所有规则 * * @param masks 打码规则 */ public JsonMaskMgr(List<JsonMask> masks) { for (JsonMask mask : masks) { String className = mask.getClazz(); List<JsonRule> rules = mask.getRules(); Map<String, JsonRule> ruleCache = Maps.newConcurrentMap(); for (JsonRule rule : rules) { ruleCache.put(rule.getName(), rule); } MASK_CACHE.put(className, ruleCache); } } /** * 应用打码规则,并返回打码后的字符 * * @param className 打码的全路径类名 * @param key 打码的key * @param value 待打码的value * @return 打码后的value */ public String applyRule(String className, String key, String value) { if (MASK_CACHE.containsKey(className)) { Map<String, JsonRule> ruleCache = MASK_CACHE.get(className); if (ruleCache.containsKey(key)) { JsonRule rule = ruleCache.get(key); return rule.apply(value); } } return StringUtils.EMPTY; } /** * 打码的缓存规则 */ private static final Map<String, Map<String, JsonRule>> MASK_CACHE = Maps.newConcurrentMap(); }
此逻辑仅能做到后台日志文件中的脱敏和打码效果,做不到接口的动态忽略效果。
-
为了达成接口动态忽略效果,还必须在
3.2.1
章节的代码
基础上进一步继承覆写其中的MappingJackson2HttpMessageConverter
类才能达成最终效果:public class Json2HttpConverter extends MappingJackson2HttpMessageConverter { /** * 定制Json转换器ObjectMapper的写对象ObjectWriter * * @param objectMapper Json转换器 * @param serializationView Jackson特殊视图对象 * @param object 原始数据对象 * @return Json转换器的定制写对象(支持配置忽略属性列表) */ private ObjectWriter getObjectWriter(ObjectMapper objectMapper, Class<?> serializationView, Object object) { ObjectWriter objectWriter; if (null != serializationView) { objectWriter = objectMapper.writerWithView(serializationView); } else { Set<String> ignoreFields = ignoreMgr.getIgnores(object); objectWriter = JsonMappers.getIgnoreWriter(objectMapper, ignoreFields); } return objectWriter; } public Json2HttpConverter(JsonIgnoreMgr ignoreMgr) { super(); this.ignoreMgr = ignoreMgr; } /** * json属性忽略管理器 */ private final JsonIgnoreMgr ignoreMgr; }
-
脱敏
实现和打码
实现类似,且要简单很多,就没必要详细介绍了。
3.3 Json在spring-boot-starter-webflux的应用
- 基于netty的webflux和基于servlet的web差异较大,核心就是要通过类似的spring框架扩展点实现json的定制。
3.3.1 WebFlux Json驼峰/下划线字符串与Java模型互转应用
- 通过Spring扩展点支持Json驼峰FluxConfigurer 如下:
@Slf4j @Configuration public class FluxConfigurer implements WebFluxConfigurer { @Bean public ServerCodecConfigurer.ServerDefaultCodecs defaultCodecs(ServerCodecConfigurer configurer) { ServerCodecConfigurer.ServerDefaultCodecs codecs = configurer.defaultCodecs(); codecs.maxInMemorySize(maxSize); return codecs; } @Override public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) { configurer.defaultCodecs().maxInMemorySize(maxSize); configurer.defaultCodecs().jackson2JsonEncoder(new JsonEncoder(snakeCase)); } /** * WebFlux json转换 */ private static class JsonEncoder implements Encoder<Object> { public JsonEncoder(boolean snake) { this.snake = snake; } @Override public boolean canEncode(ResolvableType elementType, MimeType mimeType) { return true; } @Override public Flux<DataBuffer> encode(Publisher<?> inputStream, DataBufferFactory bufferFactory, ResolvableType elementType, MimeType mimeType, Map<String, Object> hints) { if (inputStream instanceof Mono) { return Mono.from(inputStream).map(value -> encodeValue(value, bufferFactory)).flux(); } if (inputStream instanceof Flux) { return Flux.from(inputStream).map(value -> encodeValue(value, bufferFactory)); } return null; } @Override public List<MimeType> getEncodableMimeTypes() { MimeType mt = MimeTypeUtils.APPLICATION_JSON; MimeType mimeType = new MimeType(mt.getType(), mt.getSubtype(), StandardCharsets.UTF_8); return Collections.singletonList(mimeType); } /** * 使用系统标准的json处理数据 * * @param value 业务对象 * @param bufferFactory netty对应的数据处理工厂 * @return 经过转换后的netty数据类型 */ private DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory) { DataBuffer buffer = bufferFactory.allocateBuffer(); byte[] bytes = new byte[0]; try { bytes = JsonMappers.getMapper(snake).writeValueAsBytes(value); } catch (JsonProcessingException e) { log.error("failed to write back json.", e); } buffer.write(bytes); return buffer; } /** * 是否驼峰转换 */ private final boolean snake; } /** * 报文的大小限制 */ @Value("${spring.codec.max-in-memory-size}") private int maxSize; /** * 是否驼峰式json(默认支持) */ @Value("${bq.json.snake-case:true}") private boolean snakeCase; }
这样设计的原因,需要关注下Java开源接口微服务代码框架 相关说明。
3.3.2 WebFlux日志脱敏说明
- 在Java开源接口微服务代码框架 中,代码架构明确说明,WebFlux主要应用为鉴权网关,理论上是不应该关注业务模型的,也不应该在网关中配置模型的打码等屏蔽规则。
- 此章节仍然列出的目的是希望大家了解下,所有的架构、代码设计均是以业务为目标,而不要盲目地去做技术实现,也不要轻易打破架构设计和代码设计的责任边界。
3.4 Json在redis中的应用
- 同上面Web和WebFlux对SpringBoot框架的扩展类似,也可以自动注入Spring Redis的RedisConfigurer扩展代码 :
@Configuration public class RedisConfigurer { @Primary @Bean public RedisTemplate<String, Object> instance(RedisConnectionFactory factory) { RedisTemplate<String, Object> redis = new RedisTemplate<>(); redis.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); redis.setKeySerializer(keySerializer); redis.setHashKeySerializer(keySerializer); Jackson2JsonRedisSerializer<?> valueSerializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper mapper = new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); valueSerializer.setObjectMapper(mapper); redis.setValueSerializer(valueSerializer); redis.setHashValueSerializer(valueSerializer); redis.afterPropertiesSet(); return redis; } @Autowired public void redisProperties(RedisProperties redisProperties) { //TODO 支持redis密码托管场景重新设置密码 } }
- 注意:这里因为已经在后台内部转换了,所以就没有必要考虑下划线的适配逻辑了。后台模型及redis数据全部是驼峰式的会更优雅。
- 验证因为涉及到服务初始化、存储初始化,暂未提供。有兴趣的朋友可以去redis中看看存储Java对象的效果。