Python logging 較佳實踐

未經允許,禁止轉載。

本文只是列出來我自己在實踐中總結出來的幾點,並不一定最佳。當然,我也不認爲有統一的最佳。

記錄日誌是程序中尤其是 web 服務中的重要一環,恰到好處的日誌記錄可以幫助我們瞭解程序運行情況以及
方便排(shuai)錯(guo)。

logger 和 handler

如果使用 logging 不多,可能對 loggerhandler 這兩個概念不熟,大多數還是直接使用 logging.info() 來記錄日誌。

Python 官方給了一個流程圖來說明日誌消息(LogRecord)在 logger 和 handler 之間的流動情況:
logging flow
總體來說,我們創建一個 logger,通過 logger.info() 來通知 handler,讓 handler(如在終端輸出日誌的 StreamHandler,完整 handler 見 Useful Handlers)來執行真正的記錄操作。

打個比方,logger 好比領導,handler 好比員工。默認情況下你都用 logging.info() 來記錄日誌,相當於 CEO 直接命令所有員工做事。而有了 logger,通常我們會配置模塊級的 logger(下文會細說),再使用 logger.info() 來記錄日誌,這就相當於公司拆分成若干個部門,每個部門領導(模塊級 logger)只管自己部門內的員工做事。這樣每個事情誰做的更爲明確,也可分別對不同部門進行不同的管理(配置)。

格式化日誌

logging 的默認日誌格式是這樣的:%(levelname)s:%(name)s。完整的屬性列表見 LogRecord attributes

例如:

>>> logging.warning("這是一條 WARNING 消息。")
WARNING:root:這是一條 WARNING 消息。

然而這樣的格式是完全不夠的。除此之外你可能還想知道時間,那麼你可以加上 %(asctime)s,時間格式默認%Y-%m-%d %H:%M:%S,uuu,通常這已經能夠滿足要求,如果不能,那你可以通過自定義 datefmt 來定義自己的 logging.Formatter

例如:

>>> logging.basicConfig(format="%(asctime)s - %(levelname)s: %(message)s")
>>> logging.warning("這是一條 WARNING 消息。")
2019-12-17 14:22:07,639 - WARNING: 這是一條 WARNING 消息。
>>> logging.basicConfig(format="%(asctime)s - %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")  # 和默認的區別在於不會輸出毫秒
>>> logging.warning("這是一條 WARNING 消息。")
2019-12-17 14:24:37 - WARNING: 這是一條 WARNING 消息。

如果要將全部的屬性全部記錄,那麼就是如下的樣子:

# logging_test.py
import logging

logging.basicConfig(
    format="%(asctime)s - %(created)f - %(filename)s - %(funcName)s - %(levelname)s - %(levelno)s - %(lineno)d - %(module)s - %(msecs)d - %(name)s - %(pathname)s - %(process)d - %(processName)s - %(relativeCreated)d - %(thread)d - %(threadName)s: %(message)s", 
    datefmt="%Y-%m-%d %H:%M:%S"
)

def main():
    logging.warning("這是一條 WARNING 消息。")

if __name__ == "__main__":
    main()

運行 logging_test.py 就會看到:

2019-12-17 14:40:21 - 1576564821.762118 - test_logging.py - main - WARNING - 30 - 9 - test_logging - 762 - root - /data/liyajun/projects/jindu_v3_dev/test_logging.py - 36431 - MainProcess - 0 - 139905813096192 - MainThread: 這是一條 WARNING 消息。

設置恰當的日誌等級

logging 共有 5 種日誌等級,每種等級都有等級名稱和對應的數值,嚴重性由高到低分別爲:

等級 數值
CRITICAL 50
ERROR 40
WARNING 30
INFO 20
DEBUG 10

嚴格上來說,還有第六種等級:NOTSET,數值爲 0,即所有消息都會輸出。但這並不是一種有效等級。

這些等級決定了會輸出哪些日誌,例如如果你設置了等級爲 INFO,那麼比 INFO 更「安全的」DEBUG 日誌便不會輸出。這通常表示了你希望該日誌有多詳細。

有兩個地方需要設置等級:loggerhandlerloggerhandler 的默認等級都是 NOTSET,但是需要注意的是,如果發現當前 logger 的等級是 NOTSET,那麼會自動往上尋找其父級 logger 的等級,直到尋找到一個等級不是 NOTSET 的 logger。

更刺激的來了,root logger 的默認等級是 WARNING。😁

也就是說,默認情況下,只有 WARNING 等級往上的消息纔會輸出:

import logging

logging.debug("這是一條 DEBUG 消息。")
logging.info("這是一條 INFO 消息。")
logging.warning("這是一條 WARNING 消息。")
logging.error("這是一條 ERROR 消息。")
logging.critical("這是一條 CRITICAL 消息。")

#################### 輸出 ####################
# WARNING:root:這是一條 WARNING 消息。
# ERROR:root:這是一條 ERROR 消息。
# CRITICAL:root:這是一條 CRITICAL 消息。

如果你只想顯示 INFO 以上的消息,那麼只需使用 basicConfig 設置即可:

import logging
logging.basicConfig(level=logging.INFO)

logging.debug("這是一條 DEBUG 消息。")
logging.info("這是一條 INFO 消息。")
logging.warning("這是一條 WARNING 消息。")
logging.error("這是一條 ERROR 消息。")
logging.critical("這是一條 CRITICAL 消息。")

#################### 輸出 ####################
# INFO:root:這是一條 INFO 消息。
# WARNING:root:這是一條 WARNING 消息。
# ERROR:root:這是一條 ERROR 消息。
# CRITICAL:root:這是一條 CRITICAL 消息。

使用恰當的日誌等級

不要一味使用 logger.info() 來記錄所有日誌。

  • 只是便於 debug 的詳細日誌使用 debug(),如某變量的值
  • 常規通知性信息使用 info(),如某接口有新請求
  • 可能非正常的程序行爲(如不推薦的輸入)但是又不至於程序崩潰報錯,使用 warning(),如某變量的值可能不正確時,以便後期排查
  • 有非正常的程序行爲而且會導致操作不能正確執行或不能正確返回結果時,使用 error() 或者 exception(),如除零錯誤
  • 非常嚴重的錯誤,嚴重到服務或應用崩潰,如未能捕獲的異常,通常此時服務已經崩潰了。這個用的比較少

記錄 traceback

什麼是 traceback?

如果你還不知道,它就是程序出錯時你看到的報錯信息,類似下面這種你估計很熟悉了:

Traceback (most recent call last):
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'tensorflow'

根據文檔

Traceback objects represent a stack trace of an exception. A traceback object is implicitly created when an exception occurs, and may also be explicitly created by calling types.TracebackType.

即表示了異常的堆棧追蹤信息。這在程序出錯時是非常重要的,不然你都不知道哪裏出錯了。同樣在日誌中也是必須要記錄的。

通常我們會使用 try/except 來捕獲異常,並在 except 中記錄一下,但如果我們只是使用 logging.error(msg) 來記錄,那麼 traceback 是不會輸出的,這樣一來就很不方便排錯了。

解決方法是我們可以加上 exc_info=True 來輸出異常:

logging.error(msg, exc_info=True)

此外,還有個更爲方便的函數:loging.exception(msg),會默認輸出 traceback。

如有必要,把日誌輸出到文件

如果沒有進行任何配置,或者使用 basicConfig 進行配置,那麼就只有一個 StreamHandler,即日誌消息默認輸出到終端。

日誌的意義就在於查詢和排錯,那麼只輸出到終端很明顯不滿足這個需求,我們需要將其持久化保存,保存到文件中。

我們可以使用 FileHandlerRotatingFileHandlerTimedRotatingFileHandler 來將日誌輸出到文件。

其實除此此之外,還有其他 handler,例如常見的 StreamHandlerNullHandlerWatchedFileHandler 等。才疏學淺,在此就不再誤人子弟了。

這三個的一個重要區別是:FileHandler 是將日誌全部輸出到一個文件中,這會造成日誌文件越來越大的問題。而後兩個是將日誌按照某種規則輸出到多個文件中,可以緩解單個日誌文件過大的問題。

TimedRotatingFileHandler 爲例,該 handler 可以在指定時間點創建新日誌文件。有如下參數可供配置:

  • filename:日誌文件名
  • whenintervalatTime:[共同](https://docs.python.org/3/libr ary/logging.handlers.html#logging.handlers.TimedRotatingFileHandler)決定何時創建新日誌文件
  • backupCount:保留多少份舊日誌文件
  • encoding:編碼
  • delay:是否在第一次往文件中寫日誌時纔打開文件,默認 False
  • utc:是否使用 UTC 時間,默認 False

例如我們想要每天的凌晨 0 點創建新日誌文件,文件名爲 app.log,UTF-8 編碼,保留最新的 7 份舊日誌文件:

import logging
import logging.handlers

fh = logging.handlers.TimedRotatingFileHandler('app.log', when='midnight', backupCount=7, encoding='utf8')
logging.basicConfig(
    format="%(asctime)s %(levelname)s %(message)s",
    level=logging.DEBUG,
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[fh],
)

logging.info("這是一條 INFO 消息。")

使用模塊級的 logger

如果項目文件很多,有時我們可能希望在輸出日誌的同時,能夠直到每條日誌是由哪個文件(模塊)記錄的。我們可以通過模塊級的日誌來實現,只需要在每個模塊的最上方引入如下語句創建一個 logger,並在 logging format 中加上 %(name)s 即可:

logger = logging.getLogger(__name__)

關於 __name__,根據官方文檔

A module is a file containing Python definitions and statements. The file name is the module name with the suffix .py appended. Within a module, the module’s name (as a string) is available as the value of the global variable __name__.

這樣就會創建一個以該模塊名命名的 logger。當然你也可以自己隨便取名,只不過使用 __name__ 更方便。

然後在接下來記錄日誌時使用 logger.info() 等方法即可。

例如,有如下文件結構:

|app.py
|package_a
|    module_a.py
# app.py

import logging
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s:%(message)s')
from package_a import module_a

logger = logging.getLogger(__name__)
logger.warning('來自 app 的日誌。')
# module_a.py

import logging

logger = logging.getLogger(__name__)
logger.warning('來自 module_a 的日誌')
$ python app.py
2019-12-24 21:53:21,915 - package_a.module_a - WARNING:來自 module_a 的日誌
2019-12-24 21:53:21,916 - __main__ - WARNING:來自 app 的日誌。

使用配置文件

根據官方文檔,開發者有三種方式來配置 logging:

  1. 使用 Python 代碼顯式創建 logger、handler 和 formatter,如 logging.basicConfig()
  2. 創建一個 logging 配置文件,然後用 logging.config.fileConfig() 讀取。
  3. 創建一個字典形式的 logging 配置,然後傳給 logging.config.dictConfig()

使用配置文件的方法(2 和 3)相比直接在程序中顯示創建(1)有一些優勢,例如配置和代碼分離,非開發者也可以很容易地修改配置,也方便後人維護。

logging.config.fileConfig() 只能接受 configparser 格式.ini 文件,例如:

[loggers]
keys=root,simpleExample

[handlers]
keys=consoleHandler

[formatters]
keys=simpleFormatter

[logger_root]
level=DEBUG
handlers=consoleHandler

[logger_simpleExample]
level=DEBUG
handlers=consoleHandler
qualname=simpleExample
propagate=0

[handler_consoleHandler]
class=StreamHandler
level=DEBUG
formatter=simpleFormatter
args=(sys.stdout,)

[formatter_simpleFormatter]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
datefmt=

Python 3.2 加入的 logging.config.dictConfig() 使得配置 logging 的方式更加靈活,只需傳入 dict 即可,而以什麼文件格式定義這個 dict 不受限制,.py.json 或者 .yml 均可。其中 .yml 格式可讀性較強,像 Python 一樣沒有那麼多符號累贅,修改起來也容易。缺點是讀取 .yml 文件需要第三方的 PyYAML,而 .json 則有內置的標準庫 json

此處以 .yml 爲例,說明如何用 logging.config.dictConfig() 來配置 logging。

假設我們有如下需求:

  • 日誌同時輸出到終端和文件,終端採用 StreamHandler,志等級爲 INFO。文件採用 TimedRotatingFileHandler,每天凌晨 rollover,UTF8 編碼,文件名爲 app.log,日誌等級爲 DEBUG
  • 日誌格式爲 %(asctime)s - %(name)s - %(levelname)s: %(message)s
  • 使用模塊級 logger

那可以編寫如下配置文件:

# logging_config.yml

version: 1

formatters:
  default:
    format: "%(asctime)s - %(name)s - %(levelname)s: %(message)s"

handlers:
  console:
    class: logging.StreamHandler
    level: INFO
    formatter: default

  file:
    class: logging.handlers.TimedRotatingFileHandler
    level: DEBUG
    filename: log/app.log
    when: midnight
    encoding: utf-8
    formatter: default

loggers:
  app:
    handlers: [console, file]
    level: DEBUG
  package_a.module_a:
    handlers: [console, file]
    level: DEBUG

disable_existing_loggers: false

下面我分別解釋下:

  • version:int 類型,必有參數,目前的可用值只有 1
  • formatters:指定日誌格式
  • handlers:可定義多個 handler,每個 handler 都有一個名字,可以在下文使用,如例子中的 consolefile。每個 handler 都有自己的類型,使用 class 指定,例如 logging.StreamHandler,每種類型的參數可以直接用 key: value 的方式直接指定
  • loggers:和 handlers 結構類似,指定多個 logger。這裏需要注意的是,logger 是有層級的,以 . 分隔,與 Python 的 import 機制類似。所以會出現 package_a.module_a 這種名字,但是實際上我們在代碼種創建 logger 時是不必這麼寫的,直接用 __name__ 就行
  • disable_existing_loggers:默認爲 true,禁用已存在的 logger。fileConfigdictConfig 默認都會如此,如果你發現有的日誌應該出現但是沒有出現,可將此設爲 false,如 gunicorn 就會默認被禁用,詳情可參見 gunicorn accesslog 爲空的一種可能解決辦法

然後在 app.py 程序中使用如下語句讀入配置,並刪除 logging.basicConfig() 語句,然後在各個模塊創建 logger 即可(完整代碼見 GitHub):

爲了保持簡潔,這裏就不放完整代碼了,完整代碼放在 GitHub

with open("logging_config.yml", "r", encoding="utf8") as f:
    logging_config = yaml.safe_load(f)
logging.config.dictConfig(logging_config)
logger = logging.getLogger('app')

注意如果使用 gunicorn 來啓動 flask 服務,那麼在 app.py 種使用 logger = logging.getLogger(__name__) 即可,不必寫成 app。此處寫成 app 是假設直接運行 python app.py 來啓動程序,此時 __name____main__,不是 app

那麼運行 python app.py 可看到終端只輸出了 INFO 等級以上的日誌:

$ python app.py
2019-12-29 20:20:51,231 - app - WARNING: 來自 app 的 WARNING 日誌。
2019-12-29 20:20:51,232 - package_a.module_a - WARNING: 來自 module_a 的 WARNING 日誌

log/app.log 輸出了 DEBUG 等級以上的日誌:

2019-12-29 20:20:51,231 - app - DEBUG: 來自 app 的 DEBUG 日誌。
2019-12-29 20:20:51,231 - app - WARNING: 來自 app 的 WARNING 日誌。
2019-12-29 20:20:51,232 - package_a.module_a - DEBUG: 來自 module_a 的 DEBUG 日誌
2019-12-29 20:20:51,232 - package_a.module_a - WARNING: 來自 module_a 的 WARNING 日誌

如果程序運行時間超過一天,便可以看到 log/ 下會有類似 app.log.2019-12-28 的日誌文件,後面會以日期結尾。

總結

總結起來,就是以下幾點:

  • 按照自己的要求格式化日誌
  • 設置恰當的日誌等級
  • 使用恰當的日誌等級
  • 記錄 traceback
  • 日誌輸出到文件,且使用 RotatingFileHandler 或者 TimedRotatingFileHandler
  • 使用模塊級的 logger
  • 使用配置文件來設置 logging

Reference

發佈了54 篇原創文章 · 獲贊 454 · 訪問量 106萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章