這裡有一份《客戶端日誌列印規範》呈現給您!

語言: CN / TW / HK

前言

「日誌」對於客戶端開發人員來講,可以算是既熟悉又陌生了,它和程式碼註釋、程式設計風格一樣,本身不會為功能帶來任何增益,也通常不會與你的KPI掛鉤。但當有線上問題產生而你無從排查的時候,多少曾經感嘆過“我當時要是在這裡列印一條日誌就好了”,由此可見列印日誌的重要性。

但由於長期缺少一個合理的日誌規範,導致濫用、亂用日誌列印的現象層出不窮。為了解決這個痛點,今天,我們將以「為什麼要列印日誌」、「應該怎樣列印日誌?」以及「什麼時候該打日誌」三個方面為切入點,來擬定一份適合客戶端開發中使用的《日誌列印規範》。

WHY:為什麼要列印日誌?

就客戶端開發來講,常見的需要列印日誌的原因不外乎以下幾點:

1.驗證邏輯執行的對錯

與低效的斷點除錯方式相比,通過在邏輯執行過程中的關鍵節點輸出有效的日誌,可以快速地驗證相關的引數、流程、結果等是否符合預期,以及時做出相應的程式碼調整,從而提高開發/測試階段的除錯效率。

2.監控元件執行的狀態

當產品的功能依賴於某一項或幾項長時間執行的元件或服務時,以日誌系統為核心的應用監控體系,能夠實時監控其執行狀態,以在故障發生時及時預警,並通知相關開發人員處理,避免故障進一步擴大化。

3.還原故障發生的現場

由於終端裝置的碎片化問題,以及使用者行為的不可預知性,產品功能上線前的測試階段通常無法覆蓋所有的意外情況。

當線上問題發生時,日誌是否能提供足夠多的資訊來還原使用者當時的場景和行為,以快速定位問題的原因,減少無謂的爭執和甩鍋,就顯得尤為重要了。

4.記錄使用者操作的軌跡

以日誌資料為畫筆,並藉助資料化分析工具,可以分析使用者習慣和偏好,勾勒出使用者畫像,從而為使用者提供個性化的定製服務,提高產品競爭力。

HOW:應該怎樣列印日誌?

在講之前,我們先來理清都有哪些日誌級別:

日誌級別

按重要性級別從低到高分別是:

· DEBUG(除錯):僅在開發期間有用的除錯資訊。

該級別的日誌主要應對以上原因1的場景,主要集中在開發/測試階段使用,輸出的日誌內容及形式可以根據開發人員的實際除錯需要來調整,靈活度較大,一般會包含引數資訊/流程資訊/返回值資訊等內容。

特別要注意的是,該級別的日誌不能被帶到生產環境,建議在封裝的日誌工具類API中加上當前是否是除錯模式的判斷。

· INFO(資訊):常規使用情況的預期日誌資訊。

該級別的日誌主要是應對以上原因2、3、4的場景,應作為預設的輸出級別,用於記錄具體的業務行為資訊,需要有選擇地使用,只輸出對結果有實際意義的內容,避免日誌輸出量過大,造成裝置儲存空間不足。

· WARN(警告):尚未引發嚴重錯誤的潛在問題資訊。

該級別的日誌主要應對以上原因2、3的場景,涉及的通常是可提前預知並且影響範圍可控的問題,一般不影響業務流程的正常執行,包括但不限於引數缺失/引數錯誤/任務超時等情況。對該級別的日誌,要求對於問題發生時的上下文資訊要儘可能詳盡地記錄下來,以便事後的日誌分析。

· ERROR(錯誤):已經引發嚴重錯誤的問題資訊。

該級別的日誌主要應對以上原因2、3的場景,涉及的通常是不可預知的並且影響範圍較廣的異常或錯誤,可能會導致應用崩潰,或者嚴重阻塞業務流程正常執行,需要人工及時干預。對該級別的日誌,除了要記錄問題發生時的上下文資訊,還要包括完整的異常堆疊資訊,以便快速定位問題發生的地方並及時修復。

下面以常見的登入模組流程來對不同日誌級別的使用進行舉例:

  1. 首先假設我們的登入模組包含三種登入方式,分別是驗證碼登入、密碼登入以及第三方平臺登入,我們可以為不同的登入方式定義不同的TAG:

// 父模組-登入 public static final String TAG_LOGIN = "login"; // 子模組-驗證碼登入 public static final String TAG_LOGIN_IDENTIFYING_CODE = "login_identifying_code"; // 子模組-密碼登入 public static final String TAG_LOGIN_PASSWORD = "login_password"; // 子模組-第三方平臺登入 public static final String TAG_LOGIN_THIRD_PARTY = "login_third_party";

  1. 假設使用者選擇了驗證碼登入,輸入了手機號碼之後點選「獲取驗證碼」按鈕,此時需要請求獲取驗證碼介面,並將按鈕置為不可用,然後開始計時,超過一分鐘後才將按鈕恢復為可用,允許重新獲取驗證碼。在開始計時之前不允許使用者重複點選按鈕,以免重複發起請求。

  2. 為了驗證防止重複獲取驗證碼和計時按鈕恢復可用的程式碼邏輯是否生效,我們可以使用驗證碼登入的TAG,分別列印以下DEBUG級別的日誌,驗證流程是否如預期執行:

LogUtil.d(TAG_LOGIN_IDENTIFYING_CODE, "處於重複點選判斷時間區間,返回不處理") ... LogUtil.d(TAG_LOGIN_IDENTIFYING_CODE, "開始計時,按鈕不可用"); ... LogUtil.d(TAG_LOGIN_IDENTIFYING_CODE, "當前剩餘秒數:" + second); ... LogUtil.d(TAG_LOGIN_IDENTIFYING_CODE, "計時結束,按鈕恢復可用"); 通過以上日誌,我們還可以覆蓋其他測試場景下的情況,比如應用退到後臺之後倒計時是否還能正常執行,以及中斷驗證碼登入切到其他登入方式後倒計時有沒有正常結束等僅通過肉眼難以驗證的問題。

  1. 假設使用者正常收到了驗證碼簡訊,並且輸入了驗證碼後成功登陸,我們需要將過程中的具體的業務行為用INFO級別打印出來,以便後期日誌分析時可以還原使用者的行為軌跡以及有故障發生時的現場資訊:

LogUtil.i(TAG_LOGIN_IDENTIFYING_CODE, "請求獲取驗證碼介面, phone:" + phone); ... LogUtil.i(TAG_LOGIN_IDENTIFYING_CODE, "請求獲取驗證碼介面成功, response: " + response.toString()); ... LogUtil.i(TAG_LOGIN_IDENTIFYING_CODE, "請求驗證碼登入介面, phone:" + phone + ", identifyingCode: " + identifyingCode); ... LogUtil.i(TAG_LOGIN_IDENTIFYING_CODE, "請求驗證碼登入介面成功, response: " + response.toString()); ... LogUtil.i(TAG_LOGIN, "開始同步使用者配置..."); ... LogUtil.i(TAG_LOGIN, "開始同步訊息通知..."); ...

  1. 當偶現獲取驗證碼超時的情況,雖然使用者可以通過再次點選「獲取驗證碼」按鈕來重新獲取,但是仍要將該情況記錄到WARN級別的日誌,並提供當時的上下文資訊,以便後期統計出現此情況的頻率,從而尋找可以優化的空間。

String msg = new StringBuilder("獲取驗證碼介面超時:") .append("countryCode").append(countryCode) .append("phone:").append(phone) .append("time:").append(DateUtil.format(System.currentTimeMillis())) .append("networkAvailable:").append(NetworkUtil.isNetworkAvailable(getContext())) .append("networkType:").append(NetworkUtil.getNetworkType(getContext())); LogUtil.w(TAG_LOGIN_IDENTIFYING_CODE, msg);

  1. 而當「獲取驗證碼」介面不可用,使用者登入流程受阻時,我們則要在介面請求失敗回撥方法中用ERROR級別打印出介面返回的錯誤碼和錯誤資訊,或者丟擲的異常堆疊,以協助快速定位問題的原因並及時修復:

@Override public void onFailure(Response response, IOException e) { if(response != null) { LogUtil.e(TAG_LOGIN_IDENTIFYING_CODE, "獲取驗證碼介面請求失敗,code: " + response.getCode() + ", msg: " + response.getMsg()); } else { LogUtil.e(TAG_LOGIN_IDENTIFYING_CODE, "獲取驗證碼介面請求失敗", e); } }

其他兩種情況雷同,這裡不再贅述,注意使用正確的TAG就是。接下來,我們就來列舉一些具體的日誌規範:

日誌規範

1.把控日誌級別,嚴禁出現日誌資訊與日誌級別不符的情況

不同的日誌級別代表不同的日誌重要程度,亂用日誌級別將對排查的重要日誌資訊產生嚴重干擾。 // 反例:僅僅出於紅色更顯眼的原因,用ERROR級別列印除錯資訊 LogUtil.e(TAG, "Send a Msg")

2.合理使用TAG,以便分析時能快速過濾出指定日誌

這裡建議的TAG命名方式是按不同模組粒度劃分、根據從屬關係從大到小進行排列、模組名間以下劃線分隔來命名,這樣做的結果是可以更細緻地檢視不同粒度下的模組功能是否正常執行。

但要注意Android舊版本系統對logcat的TAG長度支援最長只有23個字元長度,建議使用合理且易懂的單詞縮寫,且儘量不超過三個模組層級。

比如以下兩個TAG分別代表的是: - msgserv_ws_keepalive——訊息接入服務/WebSocket模組/心跳保活功能 - msgserv_ws_msgqueue——訊息接入服務/WebSocket模組/訊息佇列功能

如果我只想關注心跳保活功能,我可以篩選完整的msgserv_ws_keepalive。而如果我想關注整個WebSocket模組的各項功能是否都執行正常,我可以篩選msgserv_ws。

``` // 正例:合理的TAG命名與使用 public static final String TAG = "msgserv_ws_keepalive"; ... LogUtil.i(TAG, "Received a pong frame, do nothing")

// 反例:以開發者名字為TAG的無意義命名 LogUtil.i("zhangsan", "onResume()")

// 反例:出於方便省略TAG LogUtil.i("onPause()") ```

3.確保重要資訊的完整,避免列印無效的日誌

大量無效的日誌不僅佔用了裝置的儲存空間,更為獲取真正有效的日誌增加了干擾度,不利於快速定位和解決問題。為此,在列印日誌之前請先思考:列印該日誌的目的是什麼?該日誌是否真的有助於解決問題?

``` // 正例:丟擲異常時列印異常堆疊資訊 LogUtil.e(TAG, "Websocket connection was closed", e)

// 正例:問題發生時輸出相關上下文資訊 LogUtil.w(TAG, "Request failed with code: " + code);

// 反例:冗餘日誌——頻繁下載進度回撥列印干擾正常的日誌列印 LogUtil.d(TAG, "Current download progress: " + progress)

// 反例:無效日誌——缺少描述失敗原因的返回碼及描述 LogUtil.w("Download failed")

// 反例:意義不明的日誌—為了驗證流程而輸出無意義的數字 LogUtil.d(TAG, "1") LogUtil.d(TAG, "2") LogUtil.d(TAG, "3") ```

4.力求日誌內容描述簡潔、清晰,又不影響可讀性

``` // 正例:只取出關心的欄位,描述其代表含義 String msg = new StringBuilder() .append("Request method:").append(request.method()).append("\n") .append("Request url:").append(request.url()).append("\n") .append("Request headers:").append(request.headers()).append("\n") .append("Request body:").append(request.body()).append("\n") .toString(); LogUtil.d(TAG, msg);

// 反例:直接列印請求實體,沒有任何描述 LogUtil.i(TAG, request.toString()) ```

5.用StringBuilder替代字串拼接以處理引數較多的情形

採用Java語言開發時,使用字串拼接會產生大量的String物件。當引數較多時,建議使用StringBuilder替代字串拼接。

// 反例:以用字串拼接輸出日誌 LogUtil.d(TAG, "Request method:" + request.method() + "\n" + "Request url:" + request.url() + "\n" + "Request headers:" + request.headers() + "\n" + "Request body:" + request.body());

6.包含敏感資訊的日誌內容需要脫敏,進行加密或不輸出

平時列印的日誌資訊就要注意避免敏感資訊的洩漏,如果有持久化到本地的操作要注意對日誌內容進行加密。

7.列印Java語言定義的實體類必須重寫toString()方法

用Java語言定義的實體類,預設只輸出此物件的hashCode值,沒有任何參考意義。

// 正例:重寫toString()方法,將實體類轉換為JSON字串 @Override public String toString() { return JSONUtil.toJSON(this); }

8.避免因日誌系統的引入,為應用增加不穩定性及額外的效能損耗

前面說過,日誌本身並不能為功能帶來增益,但日誌列印畢竟也是編碼的一部分,是編碼就會有隱藏的穩定性風險和效能損耗,需要開發人員特別注意。最好能支援線上的降級手段,當出現了因日誌造成的不良影響時,能停止列印某個級別的日誌或直接不再列印日誌。

// 反例:呼叫物件方法沒有先進行非空判斷,有隱藏的空指標異常風險 LogUtil.d(TAG, "Insert a new message:" + message.getId())

9.為日誌檔案制定合理的快取時間,定時清理過期日誌

建議以FIFO的清除策略,按日期順序移除過期的日誌檔案,日誌檔案的最長快取時間可根據產品的業務特性(比如是否有定期的周活動/月活動)來制定,可以選擇在每次進行讀寫操作時才去檢查日誌檔案是否過期,也可以專門建立一個後臺任務定期檢查過期日誌檔案並刪除。

10.禁止直接使用第三方日誌框架API,避免產生方案碎片化問題

規範建立之後,具體的日誌相關處理可以交由第三方日誌框架來實現,但為了保證方案的統一性和可替換性,需要基於外觀模式,將列印日誌的行為統一封裝到作為外觀角色的Log工具類,專案中統一使用Log工具類來列印日誌。

WHEN:什麼時候該打日誌?

本文無法囊括所有該打日誌的場景,在此只列舉一些常見的場景,可根據專案實際需要進行擴充套件。

1.產品核心業務的執行流程

這個自不必多說,產品核心業務的正常執行與否,決定著產品的最終質量,影響著公司在業界的口碑,並與實際收益掛鉤。一方面需要全面的日誌系統協助排查隱藏的技術漏洞,另一方面頁需要在使用者反饋問題時能用日誌及時定位並快速給予答覆。

2.跨端/跨應用/跨模組等的通訊過程

常見的如外部介面請求與響應過程,內容主要包含影響介面請求成功率及資料展示的請求方法/請求頭/請求引數/響應碼/響應內容等,同理還有不同應用間的資料分享過程以及同一應用不同業務模組間的路由跳轉過程等。

3.重要元件的初始啟動配置

重要元件的初始啟動配置會直接影響到應用的整體表現,我們可以通過列印元件的初始啟動配置引數,驗證是否存在由引數配置錯誤導致的異常。

4.長時間執行元件的行為/狀態切換

長時間執行的元件受裝置記憶體/電量/網路及使用者操作等的影響比較大,需要通過日誌實時關注其執行狀態,確保其執行正常。

5.多個分支邏輯的判斷

典型的情況就是程式碼中出現了多個條件分支,或者存在多個可供選擇的策略類,需要確定是進入了哪個分支或策略,從而驗證流程有沒有按預期的情況執行。

6.對結果有影響的使用者互動

比較有代表意義的就是搜尋模組,使用者通過輸入框搜尋/歷史搜尋詞/熱門搜尋詞/熱搜榜單/聯想詞等模組都可以觸發搜尋行為,為了定位是由哪個模組觸發的搜尋,就有必要記錄具體的使用者互動行為。

7.呼叫大概率可能失敗的方法時

當功能的實現依賴於外部條件的成立與否時,呼叫該功能的實現方法就有大概率可能失敗,比如持久化資料需要有足夠的儲存空間,訪問外部介面需要當前網路可用等。呼叫此類方法時我們通常需要驗證外部條件是否成立,並提供條件不成立時的相應處理方式,適當的日誌列印可以幫助我們驗證流程是否合理且可行。

8.第三方SDK的呼叫過程

由於對第三方SDK提供方技術的不可把控,我們無法保證引入第三方SDK會不會對應用的穩定性造成影響。

為了避免這種情況,我們需要在對第三方SDK的API呼叫過程中,打印出第三方SDK提供給我們的資訊,以便出現問題時能和第三方SDK提供方及時有效地溝通。

結語

擬定規範只是第一步,如何長期堅持執行才是重點。不過,我們也必須明白,好日誌不是一次就能寫好的,而是要在實際使用中根據發現的問題不斷調整的。建議在以後的專案程式碼評審過程中加入對日誌輸出的關注,團隊成員一起探討更好的輸出內容及方式,並及時改正不好的列印習慣。