用Java日誌來寫詩

crazyacking發表於2016-07-18

工欲善其事,必先利其器

很多程式設計師可能都忘了記錄應用程式的行為是一件多麼重要的事,當遇到多執行緒環境下高壓力導致的併發bug時,你就能體會到記錄log的重要性。

有的人很高興的就在程式碼里加上了這麼句:

log.info("Happy and carefree logging");

他可能都沒有意識到應用程式的日誌在維護,調優和故障識別中的重要性。

我認為slf4j是最好的日誌API,最主要是因為它支援一個很棒的模式注入的方式:

log.debug("Found {} records matching filter: '{}'", records, filter);

log4j的話你只能這樣:

log.debug("Found " + records + " recordsmatching filter: '" + filter + "'");

這樣寫不僅更囉嗦和可讀性差,而且字串拼接影響效率(當這個級別並不需要輸出的時候)。

slf4j引入了{}注入特性,並且由於避免了每次都進行字串拼接,toString方法不會被呼叫,也不再需要加上isDebugEnabled了。

slf4j是外觀模式的一種應用,它只是一個門面。具體實現的話我推薦logback框架,之前已經做過一次廣告了,而不是已經很完備的log4j。它有許多很有意思的特性,和log4j不同的是,它還在積極的開發完善中。

還有一個要推薦的工具是perf4j:

Perf4J is to System.currentTimeMillis() as log4j is to System.out.println()

就好比log4j是System.out.println的一種更好的替換方式一樣,perf4j更像是System.currentTimeMillis()的替代。

我已經在一個專案中引入了perf4j,並在高負載的情況下觀察它的表現。管理員和企業使用者都被這個小工具提供的漂亮的圖表驚呆了。

我們可以隨時檢視效能問題。perf4j應該專門開一篇文章來講,現在的話可以先看下它的開發者指南。

還有一個Ceki Gülcü(log4j,slf4j和logback工程的建立者)提供了一個簡單的方法供我們移除對commons-logging的依賴。

不要忘了日誌級別

每次你要加一行日誌的時候,你都會想,這裡該用哪種日誌級別?大概有90%的程式設計師都不太注意這個問題,都是用一個級別來記錄日誌,通常不是INFO就是DEBUG。為什麼?

日誌框架和System.out相比有兩大優勢:分類和級別。兩者可以讓你可以選擇性的過濾日誌,永久的或者只是在排查錯誤的時候。

  • ERROR 發生了嚴重的錯誤,必須馬上處理。這種級別的錯誤是任何系統都無法容忍的。比如:空指標異常,資料庫不可用,關鍵路徑的用例無法繼續執行。
  • WARN 還會繼續執行後面的流程,但應該引起重視。其實在這裡我希望有兩種級別:一個是存在解決方案的明顯的問題(比如,”當前資料不可用,使用快取資料”),另一個是潛在的問題和建議(比如“程式執行在開發模式下”或者“管理控制檯的密碼不夠安全”)。應用程式可以容忍這些資訊,不過它們應該被檢查及修復。
  • DEBUG 開發人員關注的事。後面我會講到什麼樣的東西應該記錄到這個級別。
  • TRACE 更為詳盡的資訊,只是開發階段使用。在產品上線之後的一小段時間內你可能還需要關注下這些資訊,不過這些日誌記錄只是臨時性的,最終應該關掉。DEBUG和TRACE的區別很難區分,不過如果你加了一行日誌,在開發測試完後又刪了它的話,這條日誌就應該是TRACE級別的。

上面的列表只是一個建議,你可以根據自己的規則來記錄日誌,但最好要有一定的規則。我個人的經驗是:在程式碼層面不要進行日誌過濾,而是用正確的日誌級別能夠快速的過濾出想要的資訊,這樣能節省你很多時間。

最後要說的就是這個臭名昭著的is*Enabled的條件語句了。有的人喜歡把每次日誌前加上這個:

if(log.isDebugEnabled())
    log.debug("Place for your commercial");

個人認為,應該避免在程式碼里加入這個亂哄哄的東西。效能看起來沒有什麼提升(尤其是用了slf4j之後),更像是過早的優化。還有,沒發現這麼做有點多餘麼?很少有時候是明確需要這種顯式的判斷語句的,除非我們證明構造日誌訊息本身開銷太大。不然的話,該怎麼記就怎麼記,讓日誌框架去操心這個吧。

你清楚你在記錄什麼嗎?

每次你寫下一行日誌,花點時間看看你到底在日誌檔案裡列印了些什麼。讀一遍你的日誌,找出異常的地方。首先,至少要避免空指標異常:

log.debug("Processing request with id: {}", request.getId());

你確認過request不是null了嗎?

記錄集合也是一個大坑。如果你用Hibernate從資料庫裡獲取領域物件的集合的時候,不小心寫成了這樣:

log.debug("Returning users: {}", users);

slf4j只會在這條語句確實會列印的時候呼叫toString方法,當然這個很酷。不過如果記憶體溢位了,N+1選擇問題,執行緒餓死,延遲初始化異常,日誌儲存空間用完了…這些都有可能發生。

最好的方式是隻記錄物件的ID(或者只記錄集合的大小)。不過收集ID需要對每個物件呼叫getId方法,這個在Java裡可真不是件簡單的事。Groovy有個很棒的展開操作符(users*.id),在Java裡我們可以用Commons Beanutils庫來模擬下:

log.debug("Returning user ids: {}", collect(users, "id"));

collect方法大概是這麼實現的:

public static Collection collect(Collection collection, String propertyName) {
    return CollectionUtils.collect(collection, new BeanToPropertyValueTransformer(propertyName));
}

最後要說的是,toString方法可能沒有正確的實現或者使用。

首先,為了記錄日誌,為每個類建立一個toString的做法比比皆是,最好用 ToStringBuilder來生成(不過不是它的反射實現的那個版本)。

第二,注意陣列和非典型的集合。陣列和一些另類的集合的toString實現可能沒有挨個呼叫每個元素的toString方法。可以使用JDK提供的Arrays#deepToString方法。經常檢查一下你自己列印的日誌,看有沒有格式異常的一些資訊。

避免副作用

日誌列印一般對程式的效能沒有太大影響。最近我一個朋友在一些特殊的平臺上執行的一個系統丟擲了Hibernate的LazyInitializationException異常。你可能從這已經猜到了,當會話連線進來的時候,一些日誌列印導致延遲初始化的集合被載入。在這種情況下,把日誌級別提高了,集合也就不再被初始化了。如果你不知道這些上下文資訊,你得花多長時間來發現這個BUG。

另一個副作用就是影響程式的執行速度。快速回答一下這個問題:如果日誌列印的過多或者沒有正確的使用toString和字串拼接,日誌列印就會對效能產生負面影響。能有多大?好吧,我曾經見過一個程式每15分鐘就重啟一次,因為太多的日誌導致的執行緒餓死。這就是副作用!從我的經驗來看,一小時列印百來兆差不多就是上限了。

當然如果由於日誌列印異常導致的業務程式中止,這個副作用就大了。我經常見到有人為了避免這個而這麼寫:

try {
    log.trace("Id=" + request.getUser().getId() + " accesses " + manager.getPage().getUrl().toString())
} catch(NullPointerException e) {}

這是段真實的程式碼,但是為了讓世界清淨點,請不要這麼寫。

描述要清晰

每個日誌記錄都會包含資料和描述。看下這個例子:

log.debug("Message processed");
log.debug(message.getJMSMessageID());
log.debug("Message with id '{}' processed", message.getJMSMessageID());

當在一個陌生的系統裡排查錯誤的時候,你更希望看到哪種日誌?相信我,上面這些例子都很常見。還有一個反面模式:

if(message instanceof TextMessage)
    //...
else
    log.warn("Unknown message type");

在這個警告日誌里加上訊息型別,訊息ID等等這些難道很困難嗎?我是知道發生錯誤了,不過到底是什麼錯誤?上下文資訊是什麼?

第三個反面例子是“魔法日誌”。一個真實的例子:團隊裡的很多程式設計師都知道,3個&號後面跟著!號再跟著一個#號,再跟著一個偽隨機數的日誌意味著”ID為XYZ的訊息收到了”。沒人願意改這個日誌,某人敲下鍵盤,選中某個唯一的”&&&!#”字串,他就能很快找到想要的資訊。

結果是,整個日誌檔案看起來像一大串隨機字元。有人不禁會懷疑這是不是一個perl程式。

日誌檔案應當是可讀性強的,清晰的,自描述的。不要用一些魔數,記錄值,數字,ID還有它們的上下文。記錄處理的資料以及它的含義。記錄程式正在幹些什麼。好的日誌應該是程式程式碼的一份好的文件。

我有提過不要列印密碼還有個人資訊嗎?相信沒有這麼傻的程式設計師。

調整你的格式

日誌格式是個很有用的工具,無形中在日誌新增了很有價值的上下文資訊。不過你應該想清楚,在你的格式中包含什麼樣的資訊。比如說,在每小時迴圈寫入的日誌中記錄日期是沒有意義的,因為你的日誌名就已經包含了這個資訊。相反的,如果你沒記錄執行緒名的話當兩個執行緒並行的工作的時候,你就無法通過日誌跟蹤執行緒了——日誌已經重疊到一起了。在單執行緒的應用程式中,這樣做沒問題,不過那個已經是過去的事兒了。

從我的經驗來看,理想的日誌格式應當包括(當然除了日誌資訊本身了):當前時間(無日期,毫秒級精度),日誌級別,執行緒名,簡單的日誌名稱(不用全稱)還有訊息。在logback裡會是這樣的:

<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
        <pattern>%d{HH:mm:ss.SSS} %-5level [%thread][%logger{0}] %m%n</pattern>
    </encoder>
</appender>

檔名,類名,行號,都不用列進來,儘管它們看起來很有用。我還在程式碼裡見過空的日誌記錄:

log.info(“”);
因為程式設計師認為行號會作為日誌格式的一部分,並且他知道如果空日誌訊息出現在這個檔案的67行的話,意味著這個使用者是認證過的。不僅這樣,記錄類名方法名,或者行號對效能都有很大的影響。

日誌框架的一個比較高階的特性是診斷上下文對映(Mapped Diagnostic Context)。MDC只是一個執行緒本地的一個map。你可以把任何鍵值對放到這個map裡,這樣的話這個執行緒的所有日誌記錄都能從這個map裡取到相應的資訊作為輸出格式的一部分。

記錄方法的引數和返回值

如果你在開發階段發現了一個BUG,你通常會用偵錯程式來跟蹤具體的原因。現在假設不讓你用偵錯程式了,比如,因為這個BUG幾天前在使用者的環境裡出現了,你能拿到的只有一些日誌。你能從中發現些什麼?

如果你遵循列印每個方法的入參和出參這個簡單的原則,你根本不需要偵錯程式。當然每個方法可能訪問外部系統,阻塞,等待,等等,這些都應該考慮進來。就參考以下這個格式就好:

public String printDocument(Document doc, Mode mode) {
    log.debug("Entering printDocument(doc={}, mode={})", doc, mode);
    String id = ...; //Lengthy printing operation
    log.debug("Leaving printDocument(): {}", id);
    return id;
}

由於你在方法的開始和結束都記錄了日誌,所以你可以人工找出效率不高的程式碼,甚至還可以檢測到可能會引起死鎖和飢餓的誘因——你只需看一下“Entering”後面是不是沒有”Leaving“就明白了。如果你的方法名的含義很清晰,清日誌將是一件愉快的事情。同樣的,分析異常也更得更簡單了,因為你知道每一步都在幹些什麼。程式碼裡要記錄的方法很多的話,可以用AOP切面來完成。這樣減少了重複的程式碼,不過使用它得特別小心,不注意的話可能會導致輸出大量的日誌。

這種日誌最合適的級別就是DEBUG和TRACE了。如果你發現某個方法呼叫 的太頻繁,記錄它的日誌可能會影響效能的話,只需要調低它的日誌級別就可以了,或者把日誌直接刪了(或者整個方法呼叫只留一個?)不過日誌多了總比少了要強。把日誌記錄當成單元測試來看,你的程式碼應該佈滿了日誌就像它的單元測試到處都是一樣。系統沒有任何一部分是完全不需要日誌的。記住,有時候要知道你的系統是不是正常工作,你只能檢視不斷刷屏的日誌。

觀察外部系統

這條建議和前面的有些不同:如果你和一個外部系統通訊的話,記得記錄下你的系統傳出和讀入的資料。系統整合是一件苦差事,而診斷兩個應用間的問題(想像下不同的公司,環境,技術團隊)尤其困難。最近我們發現記錄完整的訊息內容,包括Apache CXF的SOAP和HTTP頭,在系統的整合和測試階段非常有效。

這樣做開銷很大,如果影響到了效能的話,你只能把日誌關了。不過這樣你的系統可能跑的很快,掛的也很快,你還無能為力?當和外部系統進行整合的時候,你只能格外小心並做好犧牲一定開銷的準備。如果你運氣夠好,系統整合由ESB處理了,那在匯流排把請求和響應給記錄下來就最好不過了。可以參考下Mule的這個日誌元件。

有時候和外部系統交換的資料量決定了你不可能什麼都記下來。另一方面,在測試階段和釋出初期,最好把所有東西都記到日誌裡,做好犧牲效能的準備。可以通過調整日誌級別來完成這個。看下下面這個小技巧:

Collection<Integer> requestIds = //...
if(log.isDebugEnabled())
    log.debug("Processing ids: {}", requestIds);
else
    log.info("Processing ids size: {}", requestIds.size());

如果這個logger是配置成DEBUG級別,它會列印完整的請求ID的集合。如果它配置成了列印INFO資訊的話,就只會輸出集合的大小。你可能會問我是不是忘了isInfoEnabled條件了,看下第二點建議吧。這裡還有一個值得注意的是ID的集合不能為null。儘管在DEBUG下,它為NULL也能正常列印,但是當配置成INFO的時候一個大大的空指標。還記得第4點建議中提到的副作用吧?

正確的記錄異常

首先,不要記錄異常,讓框架或者容器來幹這個。當然有一個例外:如果你從遠端服務中丟擲了異常(RMI,EJB等),異常會被序列化,確保它們能返回給客戶端 (API中的一部分)。不然的話,客戶端會收到NoClassDefFoundError或者別的古怪的異常,而不是真正的錯誤資訊。

異常記錄是日誌記錄的最重要的職責之一,不過很多程式設計師都傾向於把記錄日誌當作處理異常的方式。他們通常只是返回預設值(一般是null,0或者空字串),裝作什麼也沒發生一樣。還有的時候,他們會先記錄異常,然後把異常包裝了下再丟擲去:

log.error("IO exception", e);
throw new CustomException(e);

這樣寫通常會把棧資訊列印兩次,因為捕獲了MyCustomException異常的地方也會再列印一次。日誌記錄,或者包裝後再丟擲去,不要同時使用,否則你的日誌看起來會讓人很迷惑。

如果我們真的想記錄日誌呢?由於某些原因(大概是不讀API和文件?),大約有一半的日誌記錄我認為是錯誤的。做個小測試,下面哪個日誌語句能夠正確的列印空指標異常?

try {
    Integer x = null;
    ++x;
} catch (Exception e) {
    log.error(e); //A
    log.error(e, e); //B
    log.error("" + e); //C
    log.error(e.toString()); //D
    log.error(e.getMessage()); //E
    log.error(null, e); //F
    log.error("", e); //G
    log.error("{}", e); //H
    log.error("{}", e.getMessage()); //I
    log.error("Error reading configuration file: " + e); //J
    log.error("Error reading configuration file: " + e.getMessage()); //K
    log.error("Error reading configuration file", e); //L
}

很奇怪吧,只有G和L(這個更好)是對的!A和B在slf4j下面根本就編譯不過,其它的會把棧跟蹤資訊給丟掉了或者列印了不正確的資訊。比如,E什麼也不列印,因為空指標異常本身沒有提供任何異常資訊而棧資訊又沒列印出來 .記住,第一個引數通常都是文字資訊,關於這個錯誤本身的。不要把異常資訊給寫進來,列印日誌後它會自動出來的,在棧資訊的前面。不過想要列印這個,你當然還得把異常傳到第二個引數裡面才行。

日誌應當可讀性強且易於解析

現在有兩組使用者對你的日誌感興趣:我們人類(不管你同不同意,碼農也是在這裡邊),還有計算機(通常就是系統管理員寫的shell指令碼)。日誌應當適合這兩種使用者來理解。如果有人在你後邊看你的程式的日誌卻看到了這個:

那你肯定沒聽從我的建議。日誌應該像程式碼一樣易於閱讀和理解。

另一方面,如果你的程式每小時就生成了半GB的日誌,沒有誰或者任何圖形化的文字編輯器能把它們看完。這時候我們的老傢伙們,grep,sed和awk這些上場的時候就來了。如果有可能的話,你記錄的日誌最好能讓人和計算機都能看明白 ,不要將數字格式化,用一些能讓正則容易匹配的格式等等。如果不可能的,用兩個格式來列印資料:

log.debug("Request TTL set to: {} ({})", new Date(ttl), ttl);
// Request TTL set to: Wed Apr 28 20:14:12 CEST 2010 (1272478452437)
final String duration = DurationFormatUtils.formatDurationWords(durationMillis, true, true);
log.info("Importing took: {}ms ({})", durationMillis, duration);
//Importing took: 123456789ms (1 day 10 hours 17 minutes 36 seconds)

計算機看到”ms after 1970 epoch“這樣的的時間格式會感謝你的,而人們則樂於看到”1天10小時17分36秒“這樣的東西。

總之,日誌也可以寫得像詩一樣優雅,如果你願意琢磨的話。

相關文章