Guava騷操作,10分鐘搞定日志脫敏需求!
你知道的越多,不知道的就越多,業(yè)余的像一棵小草!
你來,我們一起精進(jìn)!你不來,我和你的競爭對手一起精進(jìn)!
編輯:業(yè)余草
來源:juejin.cn/post/7274626136328257588
推薦:https://t.zsxq.com/13XQ7iuQ2
自律才能自由
?「Guava」之于「Javaer」,如同「Excel」之于「辦公達(dá)人」。
都非常好用,但實際上大部分人只用到了其「1%不到」的功能。
?
日志脫敏到底是個啥
「敏感信息脫敏」實際上是隸屬于「安全領(lǐng)域」的一個子領(lǐng)域,而「日志脫敏」又是「敏感信息脫敏」的一個子領(lǐng)域。
好了,打住,不閑聊這些有的沒的,直接開整:到底什么是日志脫敏?
未脫敏之前
如下有一個關(guān)于個人信息的類
public class Person {
private Long id;
private String name;
private String phone;
private String account;
// setter and gettr ...
}
在日志脫敏之前,我們一般會這樣直接打印日志
log.info("個人信息:{}",JsonUtils.toJSONString(person));
然后打印完之后,日志大概是這樣
個人信息:{"id":1,"name":"張無忌","phone":"17709141590","account":"14669037943256249"}
那如果是這種敏感信息打印到日志中的話,安全問題是非常大的。研發(fā)人員或者其他可以訪問這些敏感日志的人就可能會故意或者不小心泄露用戶的個人信息,甚至干些啥壞事。
脫敏后
那日志脫敏最后要達(dá)到什么效果呢?
如下:需要把敏感字段中的一部分字符使用特殊符號替換掉(這里我們用*來做特殊符號)
個人信息:{"id":1,"name":"**忌","phone":"177******90","account":"146*********49"}
所以,很自然的,我們就寫了個「脫敏組件」,在每一個字段上用「注解」來標(biāo)識每一個字段是什么類型的敏感字段,需要怎么脫敏。
比如,對于上面的個人信息,在打印日志的時候需要研發(fā)人員做兩件事:
-
「使用脫敏組件提供的注解來標(biāo)識敏感字段」
public class Person {
// id是非敏感字段,不需要脫敏
private Long id;
@Sensitive(type = SensitiveType.Name)
private String name;
@Sensitive(type = SensitiveType.Phone)
private String phone;
@Sensitive(type = SensitiveType.Account)
private String account;
// setter and gettr ...
}
-
「使用脫敏組件先脫敏,再打印日志」
如下,先使用脫敏組件提供的工具類脫敏個人信息,然后再打印日志
log.info("個人信息:{}", DataMask.toJSONString(person));
具體的使用和實現(xiàn)原理可以參考:
唯品會脫敏說明:https://github.sheincorp.cn/vipshop/vjtools/blob/master/vjkit/docs/data_masking.md。
日志脫敏遇到了什么問題
到這,還只是一般脫敏組件提供的功能范疇。也就是說,到這你基本上都可以在github上搜索到一些現(xiàn)成的解決方案。
但是到了企業(yè)里面,就不是說到這就已經(jīng)結(jié)束了。
到了企業(yè)里面,你得滿足客戶(也就是研發(fā)人員)奇奇怪怪(也許只是一開始「你覺得」是奇奇怪怪,但是實際上很合理)的需求。
比如,我們就有研發(fā)人員提出:「需要按照Map中的Key來配置脫敏規(guī)則」
啥意思呢?簡單來說,如果我有一個Map類型的數(shù)據(jù),如下
Map<String,Object> personMap = new HashMap<>();
personMap.put("name","張無忌");
personMap.put("phone","17709141590");
personMap.put("account","14669037943256249");
personMap.put("id",1L);
那么在配置文件中指定好對應(yīng)的key的脫敏規(guī)則后就可以把Map中的敏感數(shù)據(jù)也脫敏。
大概配置文件如下:
#指定Map中的key為name,name,account的value的脫敏規(guī)則分別是Name,Account,Phone
Name=name
Account=account
Phone=phone
那先不管需求是否合理吧,反正客戶就是上帝,滿足再說。
然后,我們就開始實現(xiàn)了。
基本思路:「復(fù)制Map」,然后遍歷復(fù)制后的Map,找到Key有對應(yīng)脫敏規(guī)則的value,按照脫敏規(guī)則脫敏,最后使用Json框架序列化脫敏后的Map。
public class DataMask{
// other method...
/**
* 將需要脫敏的字段先進(jìn)行脫敏操作,最后轉(zhuǎn)成json格式
* @param object 需要序列化的對象
* @return 脫敏后的json格式
*/
public static String toJSONString(Object object) {
if (object == null) {
return null;
}
try {
// 脫敏map類型
if (object instanceof Map) {
return return maskMap(object);
}
// 其他類型
return JsonUtil.toJSONString(object);
} catch (Exception e) {
return String.valueOf(object);
}
}
private static String maskMap(Object object) {
Map map = (Map) object;
MaskJsonStr maskJsonStr = new MaskJsonStr();
// 復(fù)制Map
HashMap<String, Object> mapClone = new HashMap<>();
mapClone.putAll(map);
Map mask = maskJsonStr.maskMapValue(mapClone);
return JsonUtil.getObjectMapper().writeValueAsString(mask);
}
}
public class MaskJsonStr{
// other method...
public Map<String, Object> maskMapValue(Map<String, Object> map) {
for (Map.Entry<String, Object> entry : map.entrySet()) {
Object val = entry.getValue();
if (val instanceof Map) {
maskMapValue((Map<String, Object>) val);
} else if (val instanceof Collection) {
Collection collVal = maskCollection(entry.getKey(), val);
map.put(entry.getKey(), collVal);
} else {
// 根據(jù)key從脫敏規(guī)則中獲取脫敏規(guī)則,然后脫敏
Object maskVal = maskString(entry.getKey(), val);
if (maskVal != null) {
map.put(entry.getKey(), maskVal);
} else {
map.put(entry.getKey(), val);
}
}
}
return map;
}
}
可以說,「在整體思路上,沒啥毛病」,但是往往「魔鬼就在細(xì)節(jié)中」。
看到這,也許有些大神,直接從代碼中已經(jīng)看出問題了。不急,我們還是悠著點來,給你10分鐘思量一下先。
?「1」分鐘
?
?「2」分鐘
?
?「n」分鐘
?
好的,我知道的,你肯定是不會思考的。
我們直接看問題。有使用我們這個組件的研發(fā)人員找過說:我c a o,你們把我的業(yè)務(wù)對象「Map中的值修改掉了」。
我們本來想直接回懟:你們是不是姿勢不對,不會用啊。但是本著嚴(yán)(zhi)謹(jǐn)(qian)的(bei)原(da)則(nian)(guo),還是問了句,你在本地復(fù)現(xiàn)了嗎?
結(jié)果,還真被打臉了。人家既有截圖,又給我們發(fā)了可復(fù)現(xiàn)的代碼。真是打臉打到家了。
那到底是啥問題呢?按道理,我們肯定是測試過的,正常情況下不會有問題。那到底是什么場景下有問題呢?
我們發(fā)現(xiàn):「有嵌套類型的Map的時候就會有問題」
測試程序如下:
@Test
public void testToJSONString() {
Map<String,Object> personMap = new HashMap<>();
personMap.put("name","張無忌");
personMap.put("phone","17709141590");
personMap.put("account","14669037943256249");
personMap.put("id",1L);
Map<String,Object> innerMap = new HashMap();
innerMap.put("name","張無忌的女兒");
innerMap.put("phone","18809141567");
innerMap.put("account","17869037943255678");
innerMap.put("id",2L);
personMap.put("daughter",innerMap);
System.out.println("脫敏后:"+DataMask.toJSONString(personMap));
System.out.println("脫敏后的原始Map對象:"+personMap);
}
輸出結(jié)果如下:
脫敏后:{"name":"**忌","id":1,"phone":"177*****590","daughter":{"phone":"188*****567","name":"****女兒","id":2,"account":"1***************8"},"account":"1***************9"}
脫敏后的原始Map對象:{phone=17709141590, name=張無忌, id=1, daughter={phone=188*****567, name=****女兒, id=2, account=1***************8}, account=14669037943256249}
我們發(fā)現(xiàn),脫敏時是成功的,但是卻「把原始對象中的內(nèi)嵌innerMap對象中的值修改了」。
?要知道,作為脫敏組件,你可以有點小bug,你也可以原始簡單粗暴,「甚至你都可以脫敏失敗」(本該脫敏的卻沒有脫敏),但是你「千萬不能修改業(yè)務(wù)中使用的對象」啊。
?
?雖然問題很大,但是我們還是要沉著應(yīng)對問題,仔細(xì)分析問題。做到泰山崩于前而色不變,才是打工人的正確處事方式。不然只會「越急越慌,越慌越不冷靜,最后買1送3」(修1個bug,新引入3個bug)
?
簡單debug,加上看源代碼,其實這個問題還是比較容易發(fā)現(xiàn)的,主要問題就是在「復(fù)制Map對象的姿勢不對」
?如下,我們是使用這樣的方式來復(fù)制Map的。本來是想做「深度clone」的,但是這種事做不到深度clone的。對于有內(nèi)嵌的對象的時候只能做到「淺clone」。
?
// 復(fù)制Map
HashMap<String, Object> mapClone = new HashMap<>();
mapClone.putAll(map);
所以,只有一層關(guān)系的簡單Map是可以脫敏成功的,且不會改變原來的Map。但是對于有嵌套的Map對象時,就會修改嵌套Map對象中的值了。
問題的原因是啥,如何解決,思路是啥
?從上面的分析中就可以得出其根本原因:沒有正確地深度clone Map對象
?
那很自然地,我們的解決思路就是找到一種合適的「深度 clone Map對象」的方式就OK了。
然后我就問ChatGPT了,ChatGPT的回答有下面幾個方法
-
「使用序列化和反序列化」:通過將對象序列化為字節(jié)流,然后再將字節(jié)流反序列化為新的對象,可以實現(xiàn)深度克隆。需要注意被克隆的對象及其引用類型成員變量都需要實現(xiàn)Serializable接口。 -
「使用第三方庫」:除了上述兩種方式,還可以使用一些第三方庫,例如Apache Commons的SerializationUtils類、Google的Gson庫等,它們提供了更簡潔的方法來實現(xiàn)深度克隆。 -
「使用JSON序列化和反序列化」:將對象轉(zhuǎn)換為JSON字符串,然后再將JSON字符串轉(zhuǎn)換為新的對象。需要使用JSON庫,如Jackson、Gson等。 -
「使用Apache Commons的BeanUtils類」:BeanUtils提供了一個 cloneBean()方法,可以對JavaBean進(jìn)行深度克隆。需要注意,被克隆的對象及其引用類型成員變量都需要實現(xiàn)Serializable接口。 -
「最佳實踐是根據(jù)需求和具體情況靈活應(yīng)用」,或者采用第三方庫實現(xiàn)對象克隆,如 Apache Commons BeanUtils、Spring BeanUtils 等。
上面幾個方式基本上可以分為3類:
-
「序列化和反序列化」:JDK自帶的序列化(需要實現(xiàn) Serializable接口);利用Gson,FastJson,Jackson等JSON序列化工具序列化后再反序列化;其他序列化框架序(如Hessain等)列化反序列化 -
「利用第三方庫」:第三方庫直接clone對象。但都有一定的限定條件 -
「視情況而定」:基本上沒有一種通用的方法,可以適配是有的深度clone場景。所以ChatGPT提出了這一種不是辦法的辦法
根據(jù)上面的一番探索,發(fā)現(xiàn)還得自己敲代碼造輪子。
?那其實你仔細(xì)分析后發(fā)現(xiàn):我們確實不需要通用的深度clone對象的能力。我們只需要把Map類型深度clone好就行,對于其他自定義DTO,我們是不需要深度clone的。
?
然后就是卡卡一頓猛敲,如下:常規(guī)遞歸操作
public class MapUtil {
private MapUtil() {
}
public static <K, V> Map<K, V> clone(Map<K, V> map) {
if (map == null || map.isEmpty()) {
return map;
}
Map cloneMap = new HashMap();
for (Map.Entry<K, V> entry : map.entrySet()) {
final V value = entry.getValue();
final K key = entry.getKey();
if (value instanceof Map) {
Map mapValue = (Map) value;
cloneMap.put(key, clone(mapValue));
} else if (value instanceof Collection) {
Collection collectionValue = (Collection) value;
cloneMap.put(key, clone(collectionValue));
} else {
cloneMap.put(key, value);
}
}
return cloneMap;
}
public static <E> Collection<E> clone(Collection<E> collection) {
if (collection == null || collection.isEmpty()) {
return collection;
}
Collection clonedCollection;
try {
// 有一定的風(fēng)險會反射調(diào)用失敗
clonedCollection = collection.getClass().newInstance();
} catch (InstantiationException | IllegalAccessException e) {
// simply deal with reflect exception
throw new RuntimeException(e);
}
for (E e : collection) {
if (e instanceof Collection) {
Collection collectionE = (Collection) e;
clonedCollection.add(clone(collectionE));
} else if (e instanceof Map) {
Map mapE = (Map) e;
clonedCollection.add(clone(mapE));
} else {
clonedCollection.add(e);
}
}
return clonedCollection;
}
}
然后,又是一波Junit操作,嘎嘎綠燈,收拾完事。
貌似,到這我們就可以下班了。
但是,等等,這篇文章貌似,好像,的確,應(yīng)該缺點啥吧?
這尼瑪不是講的是Guava嗎,到這為止,貌似跟Guava毛關(guān)系沒有?。。?!
那我們繼續(xù),容你再思考一下,到現(xiàn)在為止,我們的深度clone方案有什么問題。
好的,我懂的。咱看到這,圖的就是一個樂,你讓我思考,這不是強人所難嗎
?思考,思考是不可能存在的。
?
?那咱們就直接看問題:就是啥都好,問題就是「性能比較差?。?!」
?
如果你是一個老司機,你可能看到上面的代碼就已經(jīng)感覺到了,性能是相對比較低的。
?基本上,Map層級越深,字段越多,內(nèi)嵌的集合對象元素越多,性能越差!!!
?
至于差到什么程度,其實是可以通過 微基準(zhǔn)測試框架「JMH」來實際測試一下就知道了。這里我就不測試了。
?那我們還有什么辦法來解決這個性能問題嗎?
?
如果我們還是集中于深度clone對象上做文章,去找尋性能更高的深度clone框架或者是類庫的話,那么其實這個問題就已經(jīng)「走偏」了。
我們再來回顧一下我們之前的問題:
?我們需要對Map對象按照key來脫敏,所以我們選擇了深度clone Map,然后對Map遍歷按照key脫敏后序列化。
?
?那其實我們最終要解決的問題是:Map對象序列化后的字符串得是按照key的脫敏規(guī)則脫敏后的字符串。
?
所以,其實我們除了深度clone這一條路外,還有另外兩條路:
-
1.「自定義Map類型的序列化器」:在序列化Map的時候,如果有脫敏規(guī)則則應(yīng)用脫敏規(guī)則來序列化Map -
2.「轉(zhuǎn)換Map為脫敏后的Map」:自定義Map對象,將脫敏規(guī)則作為轉(zhuǎn)換函數(shù)來把普通的Map轉(zhuǎn)換為脫敏的Map
對于第一種方式,要看你使用的Json框架是否支持(一般Map類型用的都是內(nèi)置的Map序列化器,不一定可以自定義)。
那第二種方式,到底應(yīng)該如何玩呢. 如下:
// 脫敏轉(zhuǎn)換函數(shù)
public interface MaskFunction<K, V, E> {
/**
* @param k key
* @param v value
* @return 根據(jù)key和value得到脫敏(如果有需要的話)后的值
*/
E mask(K k, V v);
}
// 自定義MaskMap對象,經(jīng)過MaskFunction函數(shù),將普通Map轉(zhuǎn)換為脫敏后的Map
public class MaskMap extends HashMap {
private MaskFunction maskFunction;
public MaskMap(MaskFunction maskFunction) {
this.maskFunction = maskFunction;
}
@Override
public Object get(Object key) {
Object value = super.get(key);
if (value == null) {
return null;
}
return maskFunction.mask(key, value);
}
// other function to override ...
}
如上,Map不再是「clone」的玩法,而是「轉(zhuǎn)換」的玩法,所以,這種操作是非常「輕量級」的。
但是這種玩法也有缺點:比較麻煩,需要override好多方法(不然就需要熟讀Map序列化器來找到最小化需要override的方法,并且不太靠譜),并且全部都要是「轉(zhuǎn)換」的玩法。
終于,終于,終于,這個時候該輪到我們「Guava」登場了。
Guava登場
用過Guava的人都知道,Guava中很多好用的方法,但是我們平常用的多的也就是那幾個。所以這個時候你說需要轉(zhuǎn)換Map,那我們是不是可以去Guava中看看有沒有現(xiàn)成的方案,沒準(zhǔn)有驚喜?。?!
一看就是驚喜:
Maps#transformEntries(Map<K,V1>, Maps.EntryTransformer<? super K,? super V1,V2>)
?Returns a view of a map whose values are derived from the original map's entries. In contrast to transformValues, this method's entry-transformation logic may depend on the key as well as the value. All other properties of the transformed map, such as iteration order, are left intact.
?
?返回一個Map的試圖,其中它的值是從原來map中entry派生出來的。相較于transformValues方法,這個基于entry的轉(zhuǎn)換邏輯是既依賴于key又依賴于value。變換后的映射的所有其他屬性(例如迭代順序)均保持不變
?
這不正是我們上面需要實現(xiàn)的 MaskMap嗎!?。?/p>
除此之外,我們還需要支持,對集合類型的脫敏「轉(zhuǎn)換」,再去看一下呢,又是驚喜?。?!
到這,基本上,我們已經(jīng)在Gauva中找到了我們所需要的全部元素了。下面就是簡單集合脫敏規(guī)則使用下就好了
public class MaskEntryTransformer implements Maps.EntryTransformer<Object, Object, Object> {
private static final Maps.EntryTransformer<Object, Object, Object> MASK_ENTRY_TRANSFORMER = new MaskEntryTransformer();
private MaskEntryTransformer() {
}
public static Maps.EntryTransformer<Object, Object, Object> getInstance() {
return MASK_ENTRY_TRANSFORMER;
}
@Override
public Object transformEntry(Object objectKey, Object value) {
if (value == null) {
return null;
}
if (value instanceof Map) {
Map valueMap = (Map) value;
return Maps.transformEntries(valueMap, this);
}
final Maps.EntryTransformer<Object, Object, Object> thisFinalMaskEntryTransformer = this;
if (value instanceof Collection) {
Collection valueCollection = (Collection) value;
if (valueCollection.isEmpty()) {
return valueCollection;
}
return Collections2.transform(valueCollection, new Function<Object, Object>() {
@Override
public Object apply(Object input) {
if (input == null) {
return null;
}
if (input instanceof Map) {
Map inputValueMap = (Map) input;
return Maps.transformEntries(inputValueMap, thisFinalMaskEntryTransformer);
}
if (input instanceof Collection) {
Collection inputValueCollection = (Collection) input;
return Collections2.transform(inputValueCollection, this);
}
if (!(objectKey instanceof String)) {
return input;
}
final String key = (String) objectKey;
return transformPrimitiveType(key, input);
}
});
}
if (!(objectKey instanceof String)) {
return value;
}
final String key = (String) objectKey;
return transformPrimitiveType(key, value);
}
/**
* 按照脫敏規(guī)則脫敏基本數(shù)據(jù)類型
*
* @param key
* @param value
* @return
*/
private Object transformPrimitiveType(final String key, final Object value) {
// ...
}
}
那脫敏的地方只要轉(zhuǎn)換一下Map就可以了,如下:
public class DataMask {
/**
* 將需要脫敏的字段先進(jìn)行脫敏操作,最后轉(zhuǎn)成json格式
* @param object 需要序列化的對象
* @return 脫敏后的json格式
*/
public static String toJSONString(Object object) {
if (object == null) {
return null;
}
try {
if (object instanceof Map) {
Map maskMap = Maps.transformEntries((Map) object, MaskEntryTransformer.getInstance());
return JsonUtil.toJSONString(maskMap);
}
return JsonUtil.toJSONString(object, jsonFilter);
} catch (Exception e) {
return object.toString();
}
}
}
一點疑問
我們調(diào)用了transformEntries方法,是可以根據(jù)key(可以找到脫敏規(guī)則)和value(可以判斷類型)來轉(zhuǎn)換的。但是Map中的有些API實際上可能只有value(比如values())或者只有key(比如get()方法)的,那這種EntryTransformer是如何生效的?
?你是不是有那么一點好奇呢?
?
我們就不去想了,直接看代碼:
-
對于get()方,只有key參數(shù):
但是比較容易通過key拿到對應(yīng)的value,然后把key和value傳給轉(zhuǎn)換函數(shù)就可以了
public V2 get(Object key) {
V1 value = fromMap.get(key);
return (value != null || fromMap.containsKey(key))
? transformer.transformEntry((K) key, value)
: null;
}
?這里的實現(xiàn)其實有一個細(xì)節(jié),不知道大家注意沒有。
就是這一個判斷:
?(value != null || fromMap.containsKey(key)),我們上面實現(xiàn)的時候直接沒有考慮到值為null的情況(但value為null,但是有對應(yīng)的key的時候還是應(yīng)該要調(diào)用轉(zhuǎn)換函數(shù)的)
-
對于values()方,只返回值
public Collection<V2> values() {
return new Values<K, V2>(this);
}
也是一樣,返回一個轉(zhuǎn)換后的 Collection,其中Collection中的值也是經(jīng)過 transformer轉(zhuǎn)換的。
而對于迭代器也是一樣的,都有對應(yīng)的實現(xiàn)類把轉(zhuǎn)換邏輯放進(jìn)去了。
Guava閑聊
Guava的Veiw思想
其實上面的這種 Veiw的基本思想,在Guava中有非常多的場景應(yīng)用的,如
-
com.google.common.base.Splitter#split -
com.google.common.collect.Lists#partition -
com.google.common.collect.Sets#difference -
...other
?這種
?Veiw的基本思想,其實就是「懶加載」的思想:「在調(diào)用的時候不實際做轉(zhuǎn)換,而是在實際使用的時候會做轉(zhuǎn)換」
比如:com.google.common.base.Splitter#split在調(diào)用的時候?qū)嶋H上并不是立刻馬上去分隔字符串,而是返回一個Iterable的View對象。只是在實際迭代這個Iterable的View對象時才會實際去切分字符串。
在比如com.google.common.collect.Sets#difference在調(diào)用的時候并不會立刻馬上去做兩個集合的差操作,而是返回一個 SetView的View對象,只有在實際使用這個View對象的API的時候才會真正做差操作。
?注意點:這種思想大部分場景下,會是性能友好型的,但是也有例外。我們要分清楚場景。
比如,分割字符串的方法
?com.google.common.base.Splitter#split,如果調(diào)用完之后不做任何操作,或者只會遍歷一次(大部分場景都是只遍歷一次),那么這其實就是最好的方法。但是如果調(diào)用完之后,還需要遍歷很多次,那么這種場景下,性能可能不是最好的。
?所以,對于我們來講,如果Guava工具包中沒有我們需要的
?View的方法,那么我們可以自己按照這個思想來造一個。如果已經(jīng)有了,要區(qū)分場景使用.
簡單聊一下Guava API的兼容性
升級過Guava版本的同學(xué)可能深有體會,升級Guava是一件比較頭疼的事情。因為「Gauva API的兼容性是做得很差」的。(當(dāng)然這里主要還是因為大部分同學(xué)沒有認(rèn)真閱讀官方的文檔導(dǎo)致的)
因為,官網(wǎng)首頁就直接提醒了Guava API的兼容性原則:
具體請參考:Guava官網(wǎng)https://guava.dev/。
這對于我們做基礎(chǔ)組件的同學(xué)來講,是一件尤其需要注意的事情。因為一旦使用了前后不兼容的API,那么使用組件的應(yīng)用很可能因為API不兼容,導(dǎo)致無法運行的問題。
?所以對于做組件的同學(xué),對于Guava的使用一定要慎重:能不用就不用,必須要用的話一定不能使用以
?@Beta注解標(biāo)注的方法或者類。
總結(jié)
Gauva確實是一個保藏庫,當(dāng)你要造輪子的時候不妨先看看Gauva中有沒有現(xiàn)成的輪子。每看一次,也許就多一次驚喜。
這一次是Map對象脫敏場景遇上了Guava的 Maps#transformEntries(Map<K,V1>, .Maps.EntryTransformer<? super K,? super V1,V2>) ,那么你又有什么場景偶遇了Guava呢?,可以在評論區(qū)聊一下。
