前言java
日誌做爲排查問題的重要手段,能夠說是應用集成中必不可少的一環,但在日誌中,又不宜暴露像電話,身份證,地址等我的敏感信息,去年 Q4 我司就開展了對 ELK 日誌脫敏的全面要求。那麼怎樣快速又有效地實現日誌脫敏呢。相信讀者看完標題已經猜到了,沒錯,用註解!那麼用註解該怎麼實現日誌脫敏呢,除了日誌脫敏,註解還能用在哪些場景呢,註解的實現原理又是怎樣的呢。本文將會爲你詳細介紹。程序員
本文將會從如下幾個方面來介紹註解。json
日誌脫敏場景簡介api
巧用註解解決這兩類問題數組
註解的定義與實現原理安全
使用註解解決日誌脫敏網絡
註解高級用法-解決銀行中參數傳遞順序要求app
相信你們看了確定有收穫!分佈式
日誌脫敏場景簡介ide
在日誌裏咱們的日誌通常打印的是 model 的 Json string,好比有如下 model 類
public class Request { /** * 用戶姓名 */ private String name; /** * 身份證 */ private String idcard; /** * 手機號 */ private String phone; /** * 圖片的 base64 */ private String imgBase64; }
有如下類實例
Request request = new Request(); request.setName("愛新覺羅"); request.setIdcard("450111112222"); request.setPhone("18611111767"); request.setImgBase64("xxx");
咱們通常使用 fastJson 來打印此 Request 的 json string:
log.info(JSON.toJSONString(request));
這樣就能把 Request 的全部屬性值給打印出來,日誌以下:
{"idcard":"450111112222","imgBase64":"xxx","name":"張三","phone":"17120227942"}
這裏的日誌有兩個問題
安全性: name,phone, idcard 這些我的信息極其敏感,不該以明文的形式打印出來,咱們但願這些敏感信息是以脫敏的形式輸出的
字段冗餘:imgBase64 是圖片的 base64,是一串很是長的字符串,在生產上,圖片 base64 數據對排查問題幫助不大,反而會增大存儲成本,並且這個字段是身份證正反面的 base64,也屬於敏感信息,因此這個字段在日誌中須要把它去掉。咱們但願通過脫敏和瘦身(移除 imgBase64 字段)後的日誌以下:
{"idcard":"450******222","name":"愛**羅","phone":"186****1767","imgBase64":""}
能夠看到各個字段最後都脫敏了,不過須要注意的這幾個字段的脫敏規則是不同的
身份證(idcard),保留前三位,後三位,其他打碼
姓名(name)保留先後兩位,其他打碼
電話號碼(phone)保持前三位,後四位,其他打碼
圖片的 base64(imgBase64)直接展現空字符串
該怎麼實現呢,首先咱們須要知道一個知識點,即 JSON.toJSONString 方法指定了一個參數 ValueFilter,能夠定製要轉化的屬性。咱們能夠利用此 Filter 讓最終的 JSON string 不展現或展現脫敏後的 value。大概邏輯以下
public class Util { public static String toJSONString(Object object) { try { return JSON.toJSONString(object, getValueFilter()); } catch (Exception e) { return ToStringBuilder.reflectionToString(object); } } private static ValueFilter getValueFilter() { return (obj, key, value) -> { // obj-對象 key-字段名 value-字段值 return 格式化後的value }; }
如上圖示,咱們只要在 getValueFilter 方法中對 value 做相關的脫敏操做,便可在最終的日誌中展現脫敏後的日誌。如今問題來了,該怎麼處理字段的脫敏問題,咱們知道有些字段須要脫敏,有些字段不須要脫敏,因此有人可能會根據 key 的名稱來判斷是否脫敏,代碼以下:
private static ValueFilter getValueFilter() { return (obj, key, value) -> { // obj-對象 key-字段名 value-字段值 if (Objects.equal(key, "phone")) { return 脫敏後的phone } if (Objects.equal(key, "idcard")) { return 脫敏後的idcard } if (Objects.equal(key, "name")) { return 脫敏後的name } // 其他不須要脫敏的按原值返回 return value }; }
這樣看起來確實實現了需求,但僅僅實現了需求就夠了嗎,這樣的實現有個比較嚴重的問題:
脫敏規則與具體的屬性名緊藕合,須要在 valueFilter 裏寫大量的 if else 判斷邏輯,可擴展性不高,通用性不強,舉個簡單的例子,因爲業務緣由,在咱們的工程中電話有些字段名叫 phone, 有些叫 tel,有些叫 telephone,它們的脫敏規則是同樣的,但你不得不在上面的方法中寫出以下醜陋的代碼。
private static ValueFilter getValueFilter() { return (obj, key, value) -> { // obj-對象 key-字段名 value-字段值 if (Objects.equal(key, "phone") || Objects.equal(key, "tel") || Objects.equal(key, "telephone") || ) { return 脫敏後的phone } // 其他不須要脫敏的按原值返回 return value }; }
那麼可否用一種通用的,可擴展性好的方法來解決呢,相信你看到文章的標題已經心中有數了,沒錯,就是用的註解,接下來咱們來看看什麼是註解以及如何自定義註解
註解的定義與實現原理
註解(Annotation)又稱 Java 標註,是 JDK 5.0 引入的一種註釋機制,若是說代碼的註釋是給程序員看的,那麼註解就是給程序看的,程序看到註解後就能夠在運行時拿到註解,根據註解來加強運行時的能力,常見的應用在代碼中的註解有以下三個
@Override 檢查該方法是否重寫了父類方法,若是發現父類或實現的接口中沒有此方法,則報編譯錯誤
@Deprecated 標記過期的類,方法,屬性等
@SuppressWarnings - 指示編譯器去忽略註解中聲明的警告。
那這些註解是怎麼實現的呢,咱們打開 @Override 這個註解看看
@Documented @Retention(RetentionPolicy.RUNTIME) @Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE}) public @interface Deprecated { }
能夠看到 Deprecated 註解上又有 @Documented, @Retention, @Target 這些註解,這些註解又叫元註解,即註解 Deprecated 或其餘自定義註解的註解,其餘註解的行爲由這些註解來規範和定義,這些元註解的類型及做用以下
@Documented 代表它會被 javadoc 之類的工具處理, 這樣最終註解類型信息也會被包括在生成的文檔中
@Retention 註解的保存策略,主要有如下三種
RetentionPolicy.SOURCE 源代碼級別的註解,表示指定的註解只在編譯期可見,並不會寫入字節碼文件,Override, SuppressWarnings 就屬於此類型,這類註解對於程序員來講主要起到在編譯時提醒的做用,在運行保存意義並不大,因此最終並不會被編譯入字節碼文件中
RetentionPolicy.RUNTIME 表示註解會被編譯入最終的字符碼文件中,JVM 啓動後也會讀入註解,這樣咱們在運行時就能夠經過反射來獲取這些註解,根據這些註解來作相關的操做,這是多數自定義註解使用的保存策略,這裏可能你們有個疑問,爲啥 Deprecated 被標爲 RUNTIME 呢,對於程序員來講,理論上來講只關心調用的類,方法等是否 Deprecated 就夠了,運行時獲取有啥意義呢,考慮這樣一種場景,假設你想在生產上統計過期的方法被調用的頻率以評估你工程的壞味道或做爲重構參考,此時這個註解是否是派上用場了。
RetentionPolicy.CLASS 註解會被編譯入最終的字符碼文件,但並不會載入 JVM 中(在類加載的時候註解會被丟棄),這種保存策略不經常使用,主要用在字節碼文件的處理中。
@Target 表示該註解能夠用在什麼地方,默認狀況下能夠用在任何地方,該註解的做用域主要經過 value 來指定,這裏列舉幾個比較常見的類型:
FIELD 做用於屬性
METHOD 做用於方法
ElementType.TYPE: 做用於類、接口(包括註解類型) 或 enum 聲明
@Inherited - 標記這個註解是繼承於哪一個註解類(默認 註解並無繼承於任何子類)
再來看 @interface, 這個是幹啥用的,其實若是你反編譯以後就會發如今字節碼中編譯器將其編碼成了以下內容。
public interface Override extends Annotation { }
Annotation 是啥
咱們能夠看出註解的本質實際上是繼承了 Annotation 這個接口的接口,而且輔以 Retention,Target 這些規範註解運行時行爲,做用域等的元註解。
Deprecated 註解中沒有定義屬性,其實若是須要註解是能夠定義屬性的,好比 Deprecated 註解能夠定義一個 value 的屬性,在聲明註解的時候能夠指定此註解的 value 值
@Documented @Retention(RetentionPolicy.RUNTIME) @Target(value={CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE}) public @interface Deprecated { String value() default ""; }
這樣我將此註解應用於屬性等地方時,能夠指定此 value 值,以下所示
public class Person { @Deprecated(value = "xxx") private String tail; }
若是註解的保存策略爲 RetentionPolicy.RUNTIME,咱們就能夠用以下方式在運行時獲取註解,進而獲取註解的屬性值等
field.getAnnotation(Deprecated.class);
巧用註解解決日誌脫敏問題
上文簡述了註解的原理與寫法,接下來咱們來看看如何用註解來實現咱們的日誌脫敏。
首先咱們要定義一下脫敏的註解,因爲此註解須要在運行時被取到,因此保存策略要爲 RetentionPolicy.RUNTIME,另外此註解要應用於 phone,idcard 這些字段,因此@Target 的值爲 ElementType.FIELD,另外咱們注意到,像電話號碼,身份證這些字段雖然都要脫敏,可是它們的脫敏策略不同,因此咱們須要爲此註解定義一個屬性,這樣能夠指定它的屬性屬於哪一種脫敏類型,咱們定義的脫敏註解以下:
// 敏感信息類型 public enum SensitiveType { ID_CARD, PHONE, NAME, IMG_BASE64 } @Target({ ElementType.FIELD }) @Retention(RetentionPolicy.RUNTIME) public @interface SensitiveInfo { SensitiveType type(); }
定義好了註解,如今就能夠爲咱們的敏感字段指定註解及其敏感信息類型了,以下
public class Request { @SensitiveInfo(type = SensitiveType.NAME) private String name; @SensitiveInfo(type = SensitiveType.ID_CARD) private String idcard; @SensitiveInfo(type = SensitiveType.PHONE) private String phone; @SensitiveInfo(type = SensitiveType.IMG_BASE64) private String imgBase64; }
爲屬性指定好了註解,該怎麼根據註解來實現相應敏感字段類型的脫敏呢,能夠用反射,先用反射獲取類的每個 Field,再斷定 Field 上是否有相應的註解,如有,再判斷此註解是針對哪一種敏感類型的註解,再針對相應字段作相應的脫敏操做,直接上代碼,註釋寫得很清楚了,相信你們應該能看懂
private static ValueFilter getValueFilter() { return (obj, key, value) -> { // obj-對象 key-字段名 value-字段值 try { // 經過反射獲取獲取每一個類的屬性 Field[] fields = obj.getClass().getDeclaredFields(); for (Field field : fields) { if (!field.getName().equals(key)) { continue; } // 斷定屬性是否有相應的 SensitiveInfo 註解 SensitiveInfo annotation = field.getAnnotation(SensitiveInfo.class); // 如有,則執行相應字段的脫敏方法 if (null != annotation) { switch (annotation.type()) { case PHONE: return 電話脫敏; case ID_CARD: return 身份證脫敏; case NAME: return 姓名脫敏; case IMG_BASE64: return ""; // 圖片的 base 64 不展現,直接返回空 default: // 這裏能夠拋異常 } } } } } catch (Exception e) { log.error("To JSON String fail", e); } return value; }; }
有人可能會說了,使用註解的方式來實現脫敏代碼量翻了一倍不止,看起來好像不是很值得,其實否則,以前的方式,脫敏規則與某個字段名強藕合,可維護性很差,而用註解的方式,就像工程中出現的 phone, tel,telephone 這些都屬於電話脫敏類型的,只要統一標上 **@SensitiveInfo(type = SensitiveType.PHONE) ** 這樣的註解便可,並且後續若有新的脫敏類型,只要從新加一個 SensitiveType 的類型便可,可維護性與擴展性大大加強。因此在這類場景中,使用註解是強烈推薦的。
註解的高級應用-利用註解消除重複代碼
在與銀行對接的過程當中,銀行提供了一些 API 接口,對參數的序列化有點特殊,不使用 JSON,而是須要咱們把參數依次拼在一塊兒構成一個大字符串。
按照銀行提供的 API 文檔的順序,把全部參數構成定長的數據,而後拼接在一塊兒做爲整個字符串。
由於每一種參數都有固定長度,未達到長度時須要作填充處理:
字符串類型的參數不滿長度部分須要如下劃線右填充,也就是字符串內容靠左;
數字類型的參數不滿長度部分以 0 左填充,也就是實際數字靠右;
貨幣類型的表示須要把金額向下舍入 2 位到分,以分爲單位,做爲數字類型一樣進行左填充。
對全部參數作 MD5 操做做爲簽名(爲了方便理解,Demo 中不涉及加鹽處理)。簡單看兩個銀行的接口定義
一、建立用戶
在這裏插入圖片描述
二、支付接口
常規的作法是爲每一個接口都根據以前的規則填充參數,拼接,驗籤,以以上兩個接口爲例,先看看常規作法
建立用戶與支付的請求以下:
// 建立用戶 POJO @Data public class CreateUserRequest { private String name; private String identity; private String mobile; private int age; } // 支付 POJO @Data public class PayRequest { private long userId; private BigDecimal amount; } public class BankService { //建立用戶方法 public static String createUser(CreateUserRequest request) throws IOException { StringBuilder stringBuilder = new StringBuilder(); //字符串靠左,多餘的地方填充_ stringBuilder.append(String.format("%-10s", request.getName()).replace(' ', '_')); //字符串靠左,多餘的地方填充_ stringBuilder.append(String.format("%-18s", request.getIdentity()).replace(' ', '_')); //數字靠右,多餘的地方用0填充 stringBuilder.append(String.format("%05d", age)); //字符串靠左,多餘的地方用_填充 stringBuilder.append(String.format("%-11s", mobile).replace(' ', '_')); //最後加上MD5做爲簽名 stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString())); return Request.Post("http://baseurl/createUser") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } //支付方法 public static String pay(PayRequest request) throws IOException { StringBuilder stringBuilder = new StringBuilder(); //數字靠右,多餘的地方用0填充 stringBuilder.append(String.format("%020d", request.getUserId())); //金額向下舍入2位到分,以分爲單位,做爲數字靠右,多餘的地方用0填充 stringBuilder.append(String.format("%010d",request.getAmount().setScale(2,RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue())); //最後加上MD5做爲簽名 stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString())); return Request.Post("http://baseurl//pay") .bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON) .execute().returnContent().asString(); } }
能夠看到光寫這兩個請求,邏輯就有不少重複的地方:
一、 字符串,貨幣,數字三種類型的格式化邏輯大量重複,以處理字符串爲例
能夠看到,格式化字符串的的處理只是每一個字段的長度不一樣,其他格式化規則徹底同樣,但在上文中咱們卻爲每個字符串都整了一套相同的處理邏輯,這套拼接規則徹底能夠抽出來(由於只是長度不同,拼接規則是同樣的)
二、 處理流程中字符串拼接、加簽和發請求的邏輯,在全部方法重複。
三、 因爲每一個字段參與拼接的順序不同,這些須要咱們人肉硬編碼保證這些字段的順序,維護成本極大,並且很容易出錯,想象一下若是參數達到幾十上百個,這些參數都須要按必定順序來拼接,若是要人肉來保證,很難保證正確性,並且重複工做太多,得不償失
接下來咱們來看看如何用註解來極大簡化咱們的代碼。
一、 首先對於每個調用接口來講,它們底層都是須要請求網絡的,只是請求方法不同,針對這一點 ,咱們能夠搞一個以下針對接口的註解
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented @Inherited public @interface BankAPI { String url() default ""; String desc() default ""; }
這樣在網絡請求層便可統一經過註解獲取相應接口的方法名
二、 針對每一個請求接口的 POJO,咱們注意到每一個屬性都有 類型(字符串/數字/貨幣),長度,順序這三個屬性,因此能夠定義一個註解,包含這三個屬性,以下
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @Documented @Inherited public @interface BankAPIField { int order() default -1; int length() default -1; String type() default ""; // M表明貨幣,S表明字符串,N表明數字 }
接下來咱們將上文中定義的註解應用到上文中的請求 POJO 中
對於建立用戶請求
@BankAPI(url = "/createUser", desc = "建立用戶接口") @Data public class CreateUserAPI extends AbstractAPI { @BankAPIField(order = 1, type = "S", length = 10) private String name; @BankAPIField(order = 2, type = "S", length = 18) private String identity; @BankAPIField(order = 4, type = "S", length = 11) //注意這裏的order須要按照API表格中的順序 private String mobile; @BankAPIField(order = 3, type = "N", length = 5) private int age; }
對於支付接口
@BankAPI(url = "/bank/pay", desc = "支付接口") @Data public class PayAPI extends AbstractAPI { @BankAPIField(order = 1, type = "N", length = 20) private long userId; @BankAPIField(order = 2, type = "M", length = 10) private BigDecimal amount; }
接下來利用註解來調用的流程以下
根據反射獲取類的 Field 數組,而後再根據 Field 的 BankAPIField 註解中的 order 值對 Field 進行排序
對排序後的 Field 依次進行遍歷,首先判斷其類型,而後根據類型再對其值格式化,如判斷爲"S",則按接口要求字符串的格式對其值進行格式化,將這些格式化後的 Field 值依次拼接起來並進行簽名
拼接後就是發請求了,此時再拿到 POJO 類的註解,獲取註解 BankAPI 的 url 值,將其與 baseUrl 組合起來便可構成一個完整的的 url,再加上第 2 步中拼接字符串便可構造一個徹底的請求
代碼以下:
private static String remoteCall(AbstractAPI api) throws IOException { //從BankAPI註解獲取請求地址 BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class); bankAPI.url(); StringBuilder stringBuilder = new StringBuilder(); Arrays.stream(api.getClass().getDeclaredFields()) //得到全部字段 .filter(field -> field.isAnnotationPresent(BankAPIField.class)) //查找標記了註解的字段 .sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order())) //根據註解中的order對字段排序 .peek(field -> field.setAccessible(true)) //設置能夠訪問私有字段 .forEach(field -> { //得到註解 BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class); Object value = ""; try { //反射獲取字段值 value = field.get(api); } catch (IllegalAccessException e) { e.printStackTrace(); } //根據字段類型以正確的填充方式格式化字符串 switch (bankAPIField.type()) { case "S": { stringBuilder.append(String.format("%-" + bankAPIField.length() + "s", value.toString()).replace(' ', '_')); break; } case "N": { stringBuilder.append(String.format("%" + bankAPIField.length() + "s", value.toString()).replace(' ', '0')); break; } case "M": { if (!(value instanceof BigDecimal)) throw new RuntimeException(String.format("{} 的 {} 必須是BigDecimal", api, field)); stringBuilder.append(String.format("%0" + bankAPIField.length() + "d", ((BigDecimal) value).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue())); break; } default: break; } }); //簽名邏輯 stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString())); String param = stringBuilder.toString(); long begin = System.currentTimeMillis(); //發請求 String result = Request.Post("http://localhost:45678/reflection" + bankAPI.url()) .bodyString(param, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); log.info("調用銀行API {} url:{} 參數:{} 耗時:{}ms", bankAPI.desc(), bankAPI.url(), param, System.currentTimeMillis() - begin); return result; }
如今再來看一下建立用戶和付款的邏輯
//建立用戶方法 public static String createUser(CreateUserAPI request) throws IOException { return remoteCall(request); } //支付方法 public static String pay(PayAPI request) throws IOException { return remoteCall(request); }
能夠看到全部的請求如今都只要統一調用 remoteCall 這個方法便可,remoteCall 這個方法統一了全部請求的邏輯,省略了巨量無關的代碼,讓代碼的可維護性大大加強!使用註解和反射讓咱們能夠對這類結構性的問題進行通用化處理,確實 Cool!
總結
若是說反射給了咱們在不知曉類結構的狀況下按照固定邏輯處理類成員的能力的話,註解則是擴展補充了這些成員的元數據的能力,使用得咱們在利用反射實現通用邏輯的時候,能夠從外部獲取更多咱們關心的數據,進而對這些數據進行通用的處理,巧用反射,確實能讓咱們達到事半功倍的效果,能極大的減小重複代碼,有效解藕,使擴展性大大提高。
【編輯推薦】
【責任編輯:武曉燕 TEL:(010)68476606】