python logging日志模塊以及多進程日志

本篇文章主要對 python logging 的介紹加深理解。更主要是 討論在多進程環(huán)境下如何使用logging 來輸出日志, 如何安全地切分日志文件。

原出處博客

1. logging日志模塊介紹

python的logging模塊提供了靈活的標準模塊,使得任何Python程序都可以使用這個第三方模塊來實現(xiàn)日志記錄。python logging 官方文檔

logging框架中主要由四個部分組成:

  • Loggers: 可供程序直接調(diào)用的接口
  • Handlers: 決定將日志記錄分配至正確的目的地
  • Filters: 提供更細粒度的日志是否輸出的判斷
  • Formatters: 制定最終記錄打印的格式布局

2. logging的組成

loggers

loggers 就是程序可以直接調(diào)用的一個日志接口,可以直接向logger寫入日志信息。logger并不是直接實例化使用的,而是通過logging.getLogger(name)來獲取對象,事實上logger對象是單例模式,logging是多線程安全的,也就是無論程序中哪里需要打日志獲取到的logger對象都是同一個。但是不幸的是logger并不支持多進程,這個在后面的章節(jié)再解釋,并給出一些解決方案。
【注意】loggers對象是有父子關(guān)系的,當(dāng)沒有父logger對象時它的父對象是root,當(dāng)擁有父對象時父子關(guān)系會被修正。舉個例子logging.getLogger("abc.xyz")會創(chuàng)建兩個logger對象,一個是abc父對象,一個是xyz子對象,同時abc沒有父對象所以它的父對象是root。但是實際上abc是一個占位對象(虛的日志對象),可以沒有handler來處理日志。但是root不是占位對象,如果某一個日志對象打日志時,它的父對象會同時收到日志,所以有些使用者發(fā)現(xiàn)創(chuàng)建了一個logger對象時會打兩遍日志,就是因為他創(chuàng)建的logger打了一遍日志,同時root對象也打了一遍日志。

每個logger都有一個日志的級別。logging中定義了如下級別

Level Numeric value
NOTSET 0
DEBUG 10
INFO 20
WARNING 30
ERROR 40
CRITICAL 50

當(dāng)一個logger收到日志信息后先判斷是否符合level,如果決定要處理就將信息傳遞給Handlers進行處理。

Handlers

Handlers 將logger發(fā)過來的信息進行準確地分配,送往正確的地方。舉個栗子,送往控制臺或者文件或者both或者其他地方(進程管道之類的)。它決定了每個日志的行為,是之后需要配置的重點區(qū)域。

每個Handler同樣有一個日志級別,一個logger可以擁有多個handler也就是說logger可以根據(jù)不同的日志級別將日志傳遞給不同的handler。當(dāng)然也可以相同的級別傳遞給多個handlers這就根據(jù)需求來靈活的設(shè)置了。

Filters

Filters 提供了更細粒度的判斷,來決定日志是否需要打印。原則上handler獲得一個日志就必定會根據(jù)級別被統(tǒng)一處理,但是如果handler擁有一個Filter可以對日志進行額外的處理和判斷。例如Filter能夠?qū)碜蕴囟ㄔ吹娜罩具M行攔截or修改甚至修改其日志級別(修改后再進行級別判斷)。

logger和handler都可以安裝filter甚至可以安裝多個filter串聯(lián)起來。

Formatters

Formatters 指定了最終某條記錄打印的格式布局。Formatter會將傳遞來的信息拼接成一條具體的字符串,默認情況下Format只會將信息%(message)s直接打印出來。Format中有一些自帶的LogRecord屬性可以使用,如下表格:

Attribute Format Description
asctime %(asctime)s 將日志的時間構(gòu)造成可讀的形式,默認情況下是‘2016-02-08 12:00:00,123’精確到毫秒
filename %(filename)s 包含path的文件名
funcName %(funcName)s 由哪個function發(fā)出的log
levelname %(levelname)s 日志的最終等級(被filter修改后的)
message %(message)s 日志信息
lineno %(lineno)d 當(dāng)前日志的行號
pathname %(pathname)s 完整路徑
process %(process)s 當(dāng)前進程
thread %(thread)s 當(dāng)前線程

一個Handler只能擁有一個Formatter 因此如果要實現(xiàn)多種格式的輸出只能用多個Handler來實現(xiàn)。

3. logging 配置

簡易配置

首先在 loggers 章節(jié)里說明了一點,我們擁有一個缺省的日志對象root,這個root日志對象的好處是我們直接可以使用logging來進行配置和打日志。例如:

logging.basicConfig(level=logging.INFO,filename='logger.log')
logging.info("info message")

所以這里的簡易配置所指的就是root日志對象,隨拿隨用。每個logger都是單例對象所以配置過一遍之后程序內(nèi)任何地方調(diào)用都可以。我們只需要調(diào)用basicConfig就可以對root日志對象進行簡易的配置,事實上這種方式相當(dāng)有效易用。它使得調(diào)用任何logger時保證至少一定會有一個Handler能夠處理日志。
簡易配置大致可以這么設(shè)置:

logging.basicConfig(level=logging.INFO,
         format='%(asctime)s %(filename)s[line:%(lineno)d] %(levelname)s %(message)s',
         datefmt='[%Y-%m_%d %H:%M:%S]',
         filename='../log/my.log',
         filemode='a')

代碼配置

另一種更加細致地設(shè)置方式是在代碼中配置,但這種設(shè)置方式是使用的最少的方式,畢竟誰也不希望把設(shè)置寫死到代碼里面去。但是這里也稍微介紹一下,雖然用的不多,在必要的時候也可以用一把。(以后補上)

配置文件配置

python中l(wèi)ogging的配置文件是基于ConfigParser的功能。也就是說配置文件的格式也是按照這種方式來編寫。先奉上一個比較一般的配置文件再細說

##############################################
[loggers]
keys=root, log02

[logger_root]
level=INFO
handlers=handler01


[logger_log02]
level=DEBUG
handler=handler02
qualname=log02
##############################################
[handlers]
keys=handler01,handler02

[handler_handler01]
class=FileHandler
level=INFO
formatter=form01
args=('../log/cv_parser_gm_server.log',"a")

[handler_handler02]
class=StreamHandler
level=NOTSET
formatter=form01
args=(sys.stdout,)
##############################################
[formatters]
keys=form01,form02

[formatter_form01]
format=%(asctime)s %(filename)s[line:%(lineno)d] %(levelname)s %(process)d %(message)s
datefmt=[%Y-%m-%d %H:%M:%S]

[formatter_form02]
format=(message)s
##############################################

相信看一遍以后,也找出規(guī)律了,我將幾個大塊用#分了出來。每一個logger或者handler或者formatter都有一個key名字。以logger為例,首先需要在[loggers]配置中加上key名字代表了這個logger。然后用[loggers_xxxx]其中xxxx為key名來具體配置這個logger,在log02中我配置了level和一個handler名,當(dāng)然你可以配置多個hander。根據(jù)這個handler名再去 [handlers]里面去找具體handler的配置,以此類推。
然后在代碼中,這樣加載配置文件即可:

logging.config.fileConfig(log_conf_file)

在handler中有一個class配置,可能有些讀者并不是很懂。其實這個是logging里面原先就寫好的一些handler類,你可以在這里直接調(diào)用。class指向的類相當(dāng)于具體處理的Handler的執(zhí)行者。在logging的文檔中可以知道這里所有的Handler類都是線程安全的,大家可以放心使用。那么問題就來了,如果多進程怎么辦呢。在下一章我主要就是重寫Handler類,來實現(xiàn)在多進程環(huán)境下使用logging。 我們自己重寫或者全部新建一個Handler類,然后將class配置指向自己的Handler類就可以加載自己重寫的Handler了。

4. logging遇到多進程(important)

這部分其實是我寫這篇文章的初衷。python中由于某種歷史原因,多線程的性能基本可以無視。所以一般情況下python要實現(xiàn)并行操作或者并行計算的時候都是使用多進程。但是 python 中l(wèi)ogging 并不支持多進程,所以會遇到不少麻煩。
本次就以 TimedRotatingFileHandler 這個類的問題作為例子。這個Handler本來的作用是:按天切割日志文件。(當(dāng)天的文件是xxxx.log 昨天的文件是xxxx.log.2016-06-01)。這樣的好處是,一來可以按天來查找日志,二來可以讓日志文件不至于非常大, 過期日志也可以按天刪除。
但是問題來了,如果是用多進程來輸出日志,則只有一個進程會切換,其他進程會在原來的文件中繼續(xù)打,還有可能某些進程切換的時候早就有別的進程在新的日志文件里打入東西了,那么他會無情刪掉之,再建立新的日志文件。反正將會很亂很亂,完全沒法開心的玩耍。
所以這里就想了幾個辦法來解決多進程logging問題

原因

在解決之前,我們先看看為什么會導(dǎo)致這樣的原因。
先將 TimedRotatingFileHandler 的源代碼貼上來,這部分是切換時所作的操作:

    def doRollover(self):
        """
        do a rollover; in this case, a date/time stamp is appended to the filename
        when the rollover happens.  However, you want the file to be named for the
        start of the interval, not the current time.  If there is a backup count,
        then we have to get a list of matching filenames, sort them and remove
        the one with the oldest suffix.
        """
        if self.stream:
            self.stream.close()
            self.stream = None
        # get the time that this sequence started at and make it a TimeTuple
        currentTime = int(time.time())
        dstNow = time.localtime(currentTime)[-1]
        t = self.rolloverAt - self.interval
        if self.utc:
            timeTuple = time.gmtime(t)
        else:
            timeTuple = time.localtime(t)
            dstThen = timeTuple[-1]
            if dstNow != dstThen:
                if dstNow:
                    addend = 3600
                else:
                    addend = -3600
                timeTuple = time.localtime(t + addend)
        dfn = self.baseFilename + "." + time.strftime(self.suffix, timeTuple)
        if os.path.exists(dfn):
            os.remove(dfn)
        # Issue 18940: A file may not have been created if delay is True.
        if os.path.exists(self.baseFilename):
            os.rename(self.baseFilename, dfn)
        if self.backupCount > 0:
            for s in self.getFilesToDelete():
                os.remove(s)
        if not self.delay:
            self.stream = self._open()
        newRolloverAt = self.computeRollover(currentTime)
        while newRolloverAt <= currentTime:
            newRolloverAt = newRolloverAt + self.interval
        #If DST changes and midnight or weekly rollover, adjust for this.
        if (self.when == 'MIDNIGHT' or self.when.startswith('W')) and not self.utc:
            dstAtRollover = time.localtime(newRolloverAt)[-1]
            if dstNow != dstAtRollover:
                if not dstNow:  # DST kicks in before next rollover, so we need to deduct an hour
                    addend = -3600
                else:           # DST bows out before next rollover, so we need to add an hour
                    addend = 3600
                newRolloverAt += addend
        self.rolloverAt = newRolloverAt

我們觀察 if os.path.exists(dfn) 這一行開始,這里的邏輯是如果 dfn 這個文件存在,則要先刪除掉它,然后將 baseFilename 這個文件重命名為 dfn 文件。然后再重新打開 baseFilename這個文件開始寫入東西。那么這里的邏輯就很清楚了

  1. 假設(shè)當(dāng)前日志文件名為 current.log 切分后的文件名為 current.log.2016-06-01
  2. 判斷 current.log.2016-06-01 是否存在,如果存在就刪除
  3. 將當(dāng)前的日志文件名 改名為current.log.2016-06-01
  4. 重新打開新文件(我觀察到源代碼中默認是"a" 模式打開,之前據(jù)說是"w")

于是在多進程的情況下,一個進程切換了,其他進程的句柄還在 current.log.2016-06-01 還會繼續(xù)往里面寫東西。又或者一個進程執(zhí)行切換了,會把之前別的進程重命名的 current.log.2016-06-01 文件直接刪除。又或者還有一個情況,當(dāng)一個進程在寫東西,另一個進程已經(jīng)在切換了,會造成不可預(yù)估的情況發(fā)生。還有一種情況兩個進程同時在切文件,第一個進程正在執(zhí)行第3步,第二進程剛執(zhí)行完第2步,然后第一個進程 完成了重命名但還沒有新建一個新的 current.log 第二個進程開始重命名,此時第二個進程將會因為找不到 current 發(fā)生錯誤。如果第一個進程已經(jīng)成功創(chuàng)建了 current.log 第二個進程會將這個空文件另存為 current.log.2016-06-01。那么不僅刪除了日志文件,而且,進程一認為已經(jīng)完成過切分了不會再切,而事實上他的句柄指向的是current.log.2016-06-01。
好了這里看上去很復(fù)雜,實際上就是因為對于文件操作時,沒有對多進程進行一些約束,而導(dǎo)致的問題。
那么如何優(yōu)雅地解決這個問題呢。我提出了兩種方案,當(dāng)然我會在下面提出更多可行的方案供大家嘗試。

解決方案1

先前我們發(fā)現(xiàn) TimedRotatingFileHandler 中邏輯的缺陷。我們只需要稍微修改一下邏輯即可:

  1. 判斷切分后的文件 current.log.2016-06-01 是否存在,如果不存在則進行重命名。(如果存在說明有其他進程切過了,我不用切了,換一下句柄即可)
  2. 以"a"模式 打開 current.log
    發(fā)現(xiàn)修改后就這么簡單~
    talking is cheap show me the code:
class SafeRotatingFileHandler(TimedRotatingFileHandler):
    def __init__(self, filename, when='h', interval=1, backupCount=0, encoding=None, delay=False, utc=False):
        TimedRotatingFileHandler.__init__(self, filename, when, interval, backupCount, encoding, delay, utc)
    """
    Override doRollover
    lines commanded by "##" is changed by cc
    """
    def doRollover(self):
        """
        do a rollover; in this case, a date/time stamp is appended to the filename
        when the rollover happens.  However, you want the file to be named for the
        start of the interval, not the current time.  If there is a backup count,
        then we have to get a list of matching filenames, sort them and remove
        the one with the oldest suffix.
        
        Override,   1. if dfn not exist then do rename
                    2. _open with "a" model
        """
        if self.stream:
            self.stream.close()
            self.stream = None
        # get the time that this sequence started at and make it a TimeTuple
        currentTime = int(time.time())
        dstNow = time.localtime(currentTime)[-1]
        t = self.rolloverAt - self.interval
        if self.utc:
            timeTuple = time.gmtime(t)
        else:
            timeTuple = time.localtime(t)
            dstThen = timeTuple[-1]
            if dstNow != dstThen:
                if dstNow:
                    addend = 3600
                else:
                    addend = -3600
                timeTuple = time.localtime(t + addend)
        dfn = self.baseFilename + "." + time.strftime(self.suffix, timeTuple)
##        if os.path.exists(dfn):
##            os.remove(dfn)
            
        # Issue 18940: A file may not have been created if delay is True.
##        if os.path.exists(self.baseFilename):
        if not os.path.exists(dfn) and os.path.exists(self.baseFilename):
            os.rename(self.baseFilename, dfn)
        if self.backupCount > 0:
            for s in self.getFilesToDelete():
                os.remove(s)
        if not self.delay:
            self.mode = "a"
            self.stream = self._open()
        newRolloverAt = self.computeRollover(currentTime)
        while newRolloverAt <= currentTime:
            newRolloverAt = newRolloverAt + self.interval
        #If DST changes and midnight or weekly rollover, adjust for this.
        if (self.when == 'MIDNIGHT' or self.when.startswith('W')) and not self.utc:
            dstAtRollover = time.localtime(newRolloverAt)[-1]
            if dstNow != dstAtRollover:
                if not dstNow:  # DST kicks in before next rollover, so we need to deduct an hour
                    addend = -3600
                else:           # DST bows out before next rollover, so we need to add an hour
                    addend = 3600
                newRolloverAt += addend
        self.rolloverAt = newRolloverAt

不要以為代碼那么長,其實修改部分就是 "##" 注釋的地方而已,其他都是照抄源代碼。這個類繼承了 TimedRotatingFileHandler 重寫了這個切分的過程。這個解決方案十分優(yōu)雅,改換的地方非常少,也十分有效。但有網(wǎng)友提出,這里有一處地方依然不完美,就是rename的那一步,如果就是這么巧,同時兩個或者多個進程進入了 if 語句,先后開始 rename 那么依然會發(fā)生刪除掉日志的情況。確實這種情況確實會發(fā)生,由于切分文件一天才一次,正好切分的時候同時有兩個Handler在操作,又正好同時走到這里,也是蠻巧的,但是為了完美,可以加上一個文件鎖,if 之后加鎖,得到鎖之后再判斷一次,再進行rename這種方式就完美了。代碼就不貼了,涉及到鎖代碼,影響美觀。

解決方案2

我認為最簡單有效的解決方案。重寫FileHandler類(這個類是所有寫入文件的Handler都需要繼承的TimedRotatingFileHandler 就是繼承的這個類;我們增加一些簡單的判斷和操作就可以。
我們的邏輯是這樣的:

  1. 判斷當(dāng)前時間戳是否與指向的文件名是同一個時間
  2. 如果不是,則切換 指向的文件即可
    結(jié)束,是不是很簡單的邏輯。
    talking is cheap show me the code:
class SafeFileHandler(FileHandler):
    def __init__(self, filename, mode, encoding=None, delay=0):
        """
        Use the specified filename for streamed logging
        """
        if codecs is None:
            encoding = None
        FileHandler.__init__(self, filename, mode, encoding, delay)
        self.mode = mode
        self.encoding = encoding
        self.suffix = "%Y-%m-%d"
        self.suffix_time = ""

    def emit(self, record):
        """
        Emit a record.

        Always check time 
        """
        try:
            if self.check_baseFilename(record):
                self.build_baseFilename()
            FileHandler.emit(self, record)
        except (KeyboardInterrupt, SystemExit):
            raise
        except:
            self.handleError(record)
            
    def check_baseFilename(self, record):
        """
        Determine if builder should occur.
        
        record is not used, as we are just comparing times, 
        but it is needed so the method signatures are the same
        """
        timeTuple = time.localtime()
        
        if self.suffix_time != time.strftime(self.suffix, timeTuple) or not os.path.exists(self.baseFilename+'.'+self.suffix_time):
            return 1
        else:
            return 0
    def build_baseFilename(self):
        """
        do builder; in this case, 
        old time stamp is removed from filename and
        a new time stamp is append to the filename
        """
        if self.stream:
            self.stream.close()
            self.stream = None
        
        # remove old suffix
        if self.suffix_time != "":
            index = self.baseFilename.find("."+self.suffix_time)
            if index == -1:
                index = self.baseFilename.rfind(".")
            self.baseFilename = self.baseFilename[:index]
        
        # add new suffix
        currentTimeTuple = time.localtime()
        self.suffix_time = time.strftime(self.suffix, currentTimeTuple)
        self.baseFilename  = self.baseFilename + "." + self.suffix_time
        
        self.mode = 'a'
        if not self.delay:
            self.stream = self._open()

check_baseFilename 就是執(zhí)行邏輯1判斷;build_baseFilename 就是執(zhí)行邏輯2換句柄。就這么簡單完成了。
這種方案與之前不同的是,當(dāng)前文件就是 current.log.2016-06-01 ,到了明天當(dāng)前文件就是current.log.2016-06-02 沒有重命名的情況,也沒有刪除的情況。十分簡潔優(yōu)雅。也能解決多進程的logging問題。

解決方案其他

當(dāng)然還有其他的解決方案,例如由一個logging進程統(tǒng)一打日志,其他進程將所有的日志內(nèi)容打入logging進程管道由它來打理。還有將日志打入網(wǎng)絡(luò)socket當(dāng)中也是同樣的道理。

5. 參考資料

python logging 官方文檔
林中小燈的切分方案,方案一就是從這兒來的

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,506評論 19 139
  • logging模塊介紹: logging是python內(nèi)置的標準庫模塊,模塊提供不同的日志級別,并可以采用不同的方...
    4ffde5305e8f閱讀 2,988評論 0 2
  • 前言 在自動化測試實踐過程中,必不可少的就是進行日志管理,方便調(diào)試和生產(chǎn)問題追蹤,python提供了logg...
    苦葉子閱讀 928評論 0 0
  • 本文章是我大概三年前,在上家單位使用 Python 工作時結(jié)合官方文檔做的整理。現(xiàn)在 Python 官方文檔聽說已...
    好吃的野菜閱讀 218,008評論 14 232
  • 影帝梁朝偉最近在意大利拍攝電影《歐洲攻略》,和替身的合照難分真假。這個梁朝偉的替身是內(nèi)地演員徐上威,現(xiàn)年43歲,之...
    05f1c57958cf閱讀 219評論 0 0

友情鏈接更多精彩內(nèi)容