Python 中更優雅的日誌記錄方案

語言: CN / TW / HK

在 Python 中,一般情況下我們可能直接用自帶的 logging 模組來記錄日誌,包括我之前的時候也是一樣。在使用時我們需要配置一些 Handler、Formatter 來進行一些處理,比如把日誌輸出到不同的位置,或者設定一個不同的輸出格式,或者設定日誌分塊和備份。但其實個人感覺 logging 用起來其實並不是那麼好用,其實主要還是配置較為繁瑣。

常見使用
首先看看 logging 常見的解決方案吧,我一般會配置輸出到檔案、控制檯和 Elasticsearch。輸出到控制檯就僅僅是方便直接檢視的;輸出到檔案是方便直接儲存,保留所有歷史記錄的備份;輸出到 Elasticsearch,直接將 Elasticsearch 作為儲存和分析的中心,使用 Kibana 可以非常方便地分析和檢視執行情況。

所以在這裡我基本會對 logging 做如下的封裝寫法:

import logging
import sys
from os import makedirs
from os.path import dirname, exists

from cmreslogging.handlers import CMRESHandler

loggers = {}

LOG_ENABLED = True  # 是否開啟日誌
LOG_TO_CONSOLE = True  # 是否輸出到控制檯
LOG_TO_FILE = True  # 是否輸出到檔案
LOG_TO_ES = True  # 是否輸出到 Elasticsearch

LOG_PATH = './runtime.log'  # 日誌檔案路徑
LOG_LEVEL = 'DEBUG'  # 日誌級別
LOG_FORMAT = '%(levelname)s - %(asctime)s - process: %(process)d - %(filename)s - %(name)s - %(lineno)d - %(module)s - %(message)s'  # 每條日誌輸出格式
ELASTIC_SEARCH_HOST = 'eshost'  # Elasticsearch Host
ELASTIC_SEARCH_PORT = 9200  # Elasticsearch Port
ELASTIC_SEARCH_INDEX = 'runtime'  # Elasticsearch Index Name
APP_ENVIRONMENT = 'dev'  # 執行環境,如測試環境還是生產環境

def get_logger(name=None):
    """
    get logger by name
    :param name: name of logger
    :return: logger
    """
    global loggers

    if not name: name = __name__

    if loggers.get(name):
        return loggers.get(name)

    logger = logging.getLogger(name)
    logger.setLevel(LOG_LEVEL)

    # 輸出到控制檯
    if LOG_ENABLED and LOG_TO_CONSOLE:
        stream_handler = logging.StreamHandler(sys.stdout)
        stream_handler.setLevel(level=LOG_LEVEL)
        formatter = logging.Formatter(LOG_FORMAT)
        stream_handler.setFormatter(formatter)
        logger.addHandler(stream_handler)

    # 輸出到檔案
    if LOG_ENABLED and LOG_TO_FILE:
        # 如果路徑不存在,建立日誌檔案資料夾
        log_dir = dirname(log_path)
        if not exists(log_dir): makedirs(log_dir)
        # 新增 FileHandler
        file_handler = logging.FileHandler(log_path, encoding='utf-8')
        file_handler.setLevel(level=LOG_LEVEL)
        formatter = logging.Formatter(LOG_FORMAT)
        file_handler.setFormatter(formatter)
        logger.addHandler(file_handler)

    # 輸出到 Elasticsearch
    if LOG_ENABLED and LOG_TO_ES:
        # 新增 CMRESHandler
        es_handler = CMRESHandler(hosts=[{'host': ELASTIC_SEARCH_HOST, 'port': ELASTIC_SEARCH_PORT}],
                                  # 可以配置對應的認證許可權
                                  auth_type=CMRESHandler.AuthType.NO_AUTH,  
                                  es_index_name=ELASTIC_SEARCH_INDEX,
                                  # 一個月分一個 Index
                                  index_name_frequency=CMRESHandler.IndexNameFrequency.MONTHLY,
                                  # 額外增加環境標識
                                  es_additional_fields={'environment': APP_ENVIRONMENT}  
                                  )
        es_handler.setLevel(level=LOG_LEVEL)
        formatter = logging.Formatter(LOG_FORMAT)
        es_handler.setFormatter(formatter)
        logger.addHandler(es_handler)

    # 儲存到全域性 loggers
    loggers[name] = logger
    return logger

定義完了怎麼使用呢?只需要使用定義的方法獲取一個 logger,然後 log 對應的內容即可:

logger = get_logger()
logger.debug('this is a message')

執行結果如下:

DEBUG - 2019-10-11 22:27:35,923 - process: 99490 - logger.py - __main__ - 81 - logger - this is a message

我們看看這個定義的基本實現吧。首先這裡一些常量是用來定義 logging 模組的一些基本屬性的,比如 LOG_ENABLED 代表是否開啟日誌功能,LOG_TO_ES 代表是否將日誌輸出到 Elasticsearch,另外還有很多其他的日誌基本配置,如 LOG_FORMAT 配置了日誌每個條目輸出的基本格式,另外還有一些連線的必要資訊。這些變數可以和執行時的命令列或環境變數對接起來,可以方便地實現一些開關和配置的更換。

然後定義了這麼一個 get_logger 方法,接收一個引數 name。首先該方法拿到 name 之後,會到全域性的 loggers 變數裡面查詢,loggers 變數是一個全域性字典,如果有已經宣告過的 logger,直接將其獲取返回即可,不用再將其二次初始化。如果 loggers 裡面沒有找到 name 對應的 logger,那就進行建立即可。建立 logger 之後,可以為其新增各種對應的 Handler,如輸出到控制檯就用 StreamHandler,輸出到檔案就用 FileHandler 或 RotatingFileHandler,輸出到 Elasticsearch 就用 CMRESHandler,分別配置好對應的資訊即可。

最後呢,將新建的 logger 儲存到全域性的 loggers 裡面並返回即可,這樣如果有同名的 logger 便可以直接查詢 loggers 直接返回了。

在這裡依賴了額外的輸出到 Elasticsearch 的包,叫做 CMRESHandler,它可以支援將日誌輸出到 Elasticsearch 裡面,如果要使用的話可以安裝一下:

pip install CMRESHandler
其 GitHub 地址是:https://github.com/cmanaha/py...,具體的使用方式可以看看它的官方說明,如配置認證資訊,配置 Index 分隔資訊等等。

好,上面就是我之前常用的 logging 配置,通過如上的配置,我就可以實現將 logging 輸出到三個位置,並可以實現對應的效果。比如輸出到 Elasticsearch 之後,我就可以非常方便地使用 Kibana 來檢視當前執行情況,ERROR Log 的比例等等,
也可以在它的基礎上做更進一步的統計分析。

loguru
上面的實現方式已經是一個較為可行的配置方案了。然而,我還是會感覺到有些 Handler 配起來麻煩,尤其是新建一個專案的很多時候懶得去寫一些配置。即使是不用上文的配置,用最基本的幾行 logging 配置,像如下的通用配置:

import logging
logging.basicConfig(level = logging.INFO,format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

我也懶得去寫,感覺並不是一個優雅的實現方式。

有需求就有動力啊,這不,就有人實現了這麼一個庫,叫做 loguru,可以將 log 的配置和使用更加簡單和方便。

下面我們來看看它到底是怎麼用的吧。

安裝
首先,這個庫的安裝方式很簡單,就用基本的 pip 安裝即可,Python 3 版本的安裝如下:

pip3 install loguru

安裝完畢之後,我們就可以在專案裡使用這個 loguru 庫了。

基本使用
那麼這個庫怎麼來用呢?我們先用一個例項感受下:

from loguru import logger

logger.debug('this is a debug message')

看到了吧,不需要配置什麼東西,直接引入一個 logger,然後呼叫其 debug 方法即可。

在 loguru 裡面有且僅有一個主要物件,那就是 logger,loguru 裡面有且僅有一個 logger,而且它已經被提前配置了一些基礎資訊,比如比較友好的格式化、文字顏色資訊等等。

上面的程式碼執行結果如下:

2019-10-13 22:46:12.367 | DEBUG    | __main__:<module>:4 - this is a debug message

可以看到其預設的輸出格式是上面的內容,有時間、級別、模組名、行號以及日誌資訊,不需要手動建立 logger,直接使用即可,另外其輸出還是彩色的,看起來會更加友好。

以上的日誌資訊是直接輸出到控制檯的,並沒有輸出到其他的地方,如果想要輸出到其他的位置,比如存為檔案,我們只需要使用一行程式碼宣告即可。

例如將結果同時輸出到一個 runtime.log 檔案裡面,可以這麼寫:

from loguru import logger

logger.add('runtime.log')
logger.debug('this is a debug')

很簡單吧,我們也不需要再宣告一個 FileHandler 了,就一行 add 語句搞定,執行之後會發現目錄下 runtime.log 裡面同樣出現了剛剛控制檯輸出的 DEBUG 資訊。

上面就是一些基本的使用,但這還遠遠不夠,下面我們來詳細瞭解下它的一些功能模組。

詳細使用
既然是日誌,那麼最常見的就是輸出到檔案了。loguru 對輸出到檔案的配置有非常強大的支援,比如支援輸出到多個檔案,分級別分別輸出,過大建立新檔案,過久自動刪除等等。

下面我們分別看看這些怎樣來實現,這裡基本上就是 add 方法的使用介紹。因為這個 add 方法就相當於給 logger 添加了一個 Handler,它給我們暴露了許多引數來實現 Handler 的配置,下面我們來詳細介紹下。

首先看看它的方法定義吧:

def add(
        self,
        sink,
        *,
        level=_defaults.LOGURU_LEVEL,
        format=_defaults.LOGURU_FORMAT,
        filter=_defaults.LOGURU_FILTER,
        colorize=_defaults.LOGURU_COLORIZE,
        serialize=_defaults.LOGURU_SERIALIZE,
        backtrace=_defaults.LOGURU_BACKTRACE,
        diagnose=_defaults.LOGURU_DIAGNOSE,
        enqueue=_defaults.LOGURU_ENQUEUE,
        catch=_defaults.LOGURU_CATCH,
        **kwargs
    ):
    pass

看看它的原始碼,它支援這麼多的引數,如 level、format、filter、color 等等。

sink

另外我們還注意到它有個非常重要的引數 sink,我們看看官方文件:https://loguru.readthedocs.io...,可以瞭解到通過 sink 我們可以傳入多種不同的資料結構,彙總如下:

•sink 可以傳入一個 file 物件,例如 sys.stderr 或者 open('file.log', 'w') 都可以。
•sink 可以直接傳入一個 str 字串或者 pathlib.Path 物件,其實就是代表檔案路徑的,如果識別到是這種型別,它會自動建立對應路徑的日誌檔案並將日誌輸出進去。
•sink 可以是一個方法,可以自行定義輸出實現。
•sink 可以是一個 logging 模組的 Handler,比如 FileHandler、StreamHandler 等等,或者上文中我們提到的 CMRESHandler 照樣也是可以的,這樣就可以實現自定義 Handler 的配置。
•sink 還可以是一個自定義的類,具體的實現規範可以參見官方文件。

所以說,剛才我們所演示的輸出到檔案,僅僅給它傳了一個 str 字串路徑,他就給我們建立了一個日誌檔案,就是這個原理。

format、filter、level

下面我們再瞭解下它的其他引數,例如 format、filter、level 等等。

其實它們的概念和格式和 logging 模組都是基本一樣的了,例如這裡使用 format、filter、level 來規定輸出的格式:

logger.add('runtime.log', format="{time} {level} {message}", filter="my_module", level="INFO")

刪除 sink

另外新增 sink 之後我們也可以對其進行刪除,相當於重新重新整理並寫入新的內容。

刪除的時候根據剛剛 add 方法返回的 id 進行刪除即可,看下面的例子:

from loguru import logger

trace = logger.add('runtime.log')
logger.debug('this is a debug message')
logger.remove(trace)
logger.debug('this is another debug message')

看這裡,我們首先 add 了一個 sink,然後獲取它的返回值,賦值為 trace。隨後輸出了一條日誌,然後將 trace 變數傳給 remove 方法,再次輸出一條日誌,看看結果是怎樣的。

控制檯輸出如下:

2019-10-13 23:18:26.469 | DEBUG    | __main__:<module>:4 - this is a debug message
2019-10-13 23:18:26.469 | DEBUG    | __main__:<module>:6 - this is another debug message

日誌檔案 runtime.log 內容如下:

2019-10-13 23:18:26.469 | DEBUG    | __main__:<module>:4 - this is a debug message

可以發現,在呼叫 remove 方法之後,確實將歷史 log 刪除了。

這樣我們就可以實現日誌的重新整理重新寫入操作。

rotation 配置

用了 loguru 我們還可以非常方便地使用 rotation 配置,比如我們想一天輸出一個日誌檔案,或者檔案太大了自動分隔日誌檔案,我們可以直接使用 add 方法的 rotation 引數進行配置。

我們看看下面的例子:

logger.add('runtime_{time}.log', rotation="500 MB")

通過這樣的配置我們就可以實現每 500MB 儲存一個檔案,每個 log 檔案過大就會新建立一個 log 檔案。我們在配置 log 名字時加上了一個 time 佔位符,這樣在生成時可以自動將時間替換進去,生成一個檔名包含時間的 log 檔案。

另外我們也可以使用 rotation 引數實現定時建立 log 檔案,例如:

logger.add('runtime_{time}.log', rotation='00:00')

這樣就可以實現每天 0 點新建立一個 log 檔案輸出了。

另外我們也可以配置 log 檔案的迴圈時間,比如每隔一週建立一個 log 檔案,寫法如下:

logger.add('runtime_{time}.log', rotation='1 week')

這樣我們就可以實現一週建立一個 log 檔案了。

retention 配置

很多情況下,一些非常久遠的 log 對我們來說並沒有什麼用處了,它白白佔據了一些儲存空間,不清除掉就會非常浪費。retention 這個引數可以配置日誌的最長保留時間。

比如我們想要設定日誌檔案最長保留 10 天,可以這麼來配置:

logger.add('runtime.log', retention='10 days')

這樣 log 檔案裡面就會保留最新 10 天的 log,媽媽再也不用擔心 log 沉積的問題啦。

compression 配置

loguru 還可以配置檔案的壓縮格式,比如使用 zip 檔案格式儲存,示例如下:

logger.add('runtime.log', compression='zip')

這樣可以更加節省儲存空間。

字串格式化

loguru 在輸出 log 的時候還提供了非常友好的字串格式化功能,像這樣:

logger.info('If you are using Python {}, prefer {feature} of course!', 3.6, feature='f-strings')

這樣在新增引數就非常方便了。

Traceback 記錄

在很多情況下,如果遇到執行錯誤,而我們在列印輸出 log 的時候萬一不小心沒有配置好 Traceback 的輸出,很有可能我們就沒法追蹤錯誤所在了。

但用了 loguru 之後,我們用它提供的裝飾器就可以直接進行 Traceback 的記錄,類似這樣的配置即可:

@logger.catch
def my_function(x, y, z):
    # An error? It's caught anyway!
    return 1 / (x + y + z)

我們做個測試,我們在呼叫時三個引數都傳入 0,直接引發除以 0 的錯誤,看看會出現什麼情況:

my_function(0, 0, 0)
執行完畢之後,可以發現 log 裡面就出現了 Traceback 資訊,而且給我們輸出了當時的變數值,真的是不能再讚了!結果如下:

> File "run.py", line 15, in <module>
    my_function(0, 0, 0)
    └ <function my_function at 0x1171dd510>

  File "/private/var/py/logurutest/demo5.py", line 13, in my_function
    return 1 / (x + y + z)
                │   │   └ 0
                │   └ 0
                └ 0

ZeroDivisionError: division by zero

因此,用 loguru 可以非常方便地實現日誌追蹤,debug 效率可能要高上十倍了?

以上就是本次分享的所有內容,想要了解更多 python 知識歡迎前往公眾號:Python 程式設計學習圈 ,傳送 “J” 即可免費獲取,每日干貨分享