python logging 日志模塊以及多進程日志
本篇文章主要對 Python logging 的介紹加深理解。更主要是 討論在多進程環境下如何使用logging 來輸出日志, 如何安全地切分日志文件。
1. logging日志模塊介紹
python的logging模塊提供了靈活的標準模塊,使得任何Python程序都可以使用這個第三方模塊來實現日志記錄。python logging 官方文檔
logging框架中主要由四個部分組成:
Loggers: 可供程序直接調用的接口
Handlers: 決定將日志記錄分配至正確的目的地
Filters: 提供更細粒度的日志是否輸出的判斷
Formatters: 制定最終記錄打印的格式布局
2. logging的組成
loggers
loggers 就是程序可以直接調用的一個日志接口,可以直接向logger寫入日志信息。logger并不是直接實例化使用的,而是通過logging.getLogger(name)來獲取對象,事實上logger對象是單例模式,logging是多線程安全的,也就是無論程序中哪里需要打日志獲取到的logger對象都是同一個。但是不幸的是logger并不支持多進程,這個在后面的章節再解釋,并給出一些解決方案。
【注意】loggers對象是有父子關系的,當沒有父logger對象時它的父對象是root,當擁有父對象時父子關系會被修正。舉個例子logging.getLogger("abc.xyz")會創建兩個logger對象,一個是abc父對象,一個是xyz子對象,同時abc沒有父對象所以它的父對象是root。但是實際上abc是一個占位對象(虛的日志對象),可以沒有handler來處理日志。但是root不是占位對象,如果某一個日志對象打日志時,它的父對象會同時收到日志,所以有些使用者發現創建了一個logger對象時會打兩遍日志,就是因為他創建的logger打了一遍日志,同時root對象也打了一遍日志。
每個logger都有一個日志的級別。logging中定義了如下級別
當一個logger收到日志信息后先判斷是否符合level,如果決定要處理就將信息傳遞給Handlers進行處理。
Handlers
Handlers 將logger發過來的信息進行準確地分配,送往正確的地方。舉個栗子,送往控制臺或者文件或者both或者其他地方(進程管道之類的)。它決定了每個日志的行為,是之后需要配置的重點區域。
每個Handler同樣有一個日志級別,一個logger可以擁有多個handler也就是說logger可以根據不同的日志級別將日志傳遞給不同的handler。當然也可以相同的級別傳遞給多個handlers這就根據需求來靈活的設置了。
Filters
Filters 提供了更細粒度的判斷,來決定日志是否需要打印。原則上handler獲得一個日志就必定會根據級別被統一處理,但是如果handler擁有一個Filter可以對日志進行額外的處理和判斷。例如Filter能夠對來自特定源的日志進行攔截or修改甚至修改其日志級別(修改后再進行級別判斷)。
logger和handler都可以安裝filter甚至可以安裝多個filter串聯起來。
Formatters
Formatters 指定了最終某條記錄打印的格式布局。Formatter會將傳遞來的信息拼接成一條具體的字符串,默認情況下Format只會將信息%(message)s直接打印出來。Format中有一些自帶的LogRecord屬性可以使用,如下表格:
一個Handler只能擁有一個Formatter 因此如果要實現多種格式的輸出只能用多個Handler來實現。
3. logging 配置
簡易配置
首先在 loggers 章節里說明了一點,我們擁有一個缺省的日志對象root,這個root日志對象的好處是我們直接可以使用logging來進行配置和打日志。例如:
logging.basicConfig(level=logging.INFO,filename='logger.log')
logging.info("info message")
所以這里的簡易配置所指的就是root日志對象,隨拿隨用。每個logger都是單例對象所以配置過一遍之后程序內任何地方調用都可以。我們只需要調用basicConfig就可以對root日志對象進行簡易的配置,事實上這種方式相當有效易用。它使得調用任何logger時保證至少一定會有一個Handler能夠處理日志。
簡易配置大致可以這么設置:
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')
代碼配置
另一種更加細致地設置方式是在代碼中配置,但這種設置方式是使用的最少的方式,畢竟誰也不希望把設置寫死到代碼里面去。但是這里也稍微介紹一下,雖然用的不多,在必要的時候也可以用一把。(以后補上)
配置文件配置
python中logging的配置文件是基于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
##############################################
相信看一遍以后,也找出規律了,我將幾個大塊用#分了出來。每一個logger或者handler或者formatter都有一個key名字。以logger為例,首先需要在[loggers]配置中加上key名字代表了這個logger。然后用[loggers_xxxx]其中xxxx為key名來具體配置這個logger,在log02中我配置了level和一個handler名,當然你可以配置多個hander。根據這個handler名再去 [handlers]里面去找具體handler的配置,以此類推。
然后在代碼中,這樣加載配置文件即可:
logging.config.fileConfig(log_conf_file)
在handler中有一個class配置,可能有些讀者并不是很懂。其實這個是logging里面原先就寫好的一些handler類,你可以在這里直接調用。class指向的類相當于具體處理的Handler的執行者。在logging的文檔中可以知道這里所有的Handler類都是線程安全的,大家可以放心使用。那么問題就來了,如果多進程怎么辦呢。在下一章我主要就是重寫Handler類,來實現在多進程環境下使用logging。 我們自己重寫或者全部新建一個Handler類,然后將class配置指向自己的Handler類就可以加載自己重寫的Handler了。
4. logging遇到多進程(important)
這部分其實是我寫這篇文章的初衷。python中由于某種歷史原因,多線程的性能基本可以無視。所以一般情況下python要實現并行操作或者并行計算的時候都是使用多進程。但是 python 中logging 并不支持多進程,所以會遇到不少麻煩。
本次就以 TimedRotatingFileHandler 這個類的問題作為例子。這個Handler本來的作用是:按天切割日志文件。(當天的文件是xxxx.log 昨天的文件是xxxx.log.2016-06-01)。這樣的好處是,一來可以按天來查找日志,二來可以讓日志文件不至于非常大, 過期日志也可以按天刪除。
但是問題來了,如果是用多進程來輸出日志,則只有一個進程會切換,其他進程會在原來的文件中繼續打,還有可能某些進程切換的時候早就有別的進程在新的日志文件里打入東西了,那么他會無情刪掉之,再建立新的日志文件。反正將會很亂很亂,完全沒法開心的玩耍。
所以這里就想了幾個辦法來解決多進程logging問題
原因
在解決之前,我們先看看為什么會導致這樣的原因。
先將 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?notself.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這個文件開始寫入東西。那么這里的邏輯就很清楚了
假設當前日志文件名為 current.log 切分后的文件名為 current.log.2016-06-01
判斷 current.log.2016-06-01 是否存在,如果存在就刪除
將當前的日志文件名 改名為current.log.2016-06-01
重新打開新文件(我觀察到源代碼中默認是”a” 模式打開,之前據說是”w”)
于是在多進程的情況下,一個進程切換了,其他進程的句柄還在 current.log.2016-06-01 還會繼續往里面寫東西。又或者一個進程執行切換了,會把之前別的進程重命名的 current.log.2016-06-01 文件直接刪除。又或者還有一個情況,當一個進程在寫東西,另一個進程已經在切換了,會造成不可預估的情況發生。還有一種情況兩個進程同時在切文件,第一個進程正在執行第3步,第二進程剛執行完第2步,然后第一個進程 完成了重命名但還沒有新建一個新的 current.log 第二個進程開始重命名,此時第二個進程將會因為找不到 current 發生錯誤。如果第一個進程已經成功創建了 current.log 第二個進程會將這個空文件另存為 current.log.2016-06-01。那么不僅刪除了日志文件,而且,進程一認為已經完成過切分了不會再切,而事實上他的句柄指向的是current.log.2016-06-01。
好了這里看上去很復雜,實際上就是因為對于文件操作時,沒有對多進程進行一些約束,而導致的問題。
那么如何優雅地解決這個問題呢。我提出了兩種方案,當然我會在下面提出更多可行的方案供大家嘗試。
解決方案1
先前我們發現 TimedRotatingFileHandler 中邏輯的缺陷。我們只需要稍微修改一下邏輯即可:
判斷切分后的文件 current.log.2016-06-01 是否存在,如果不存在則進行重命名。(如果存在說明有其他進程切過了,我不用切了,換一下句柄即可)
以”a”模式 打開 current.log
發現修改后就這么簡單~
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 重寫了這個切分的過程。這個解決方案十分優雅,改換的地方非常少,也十分有效。但有網友提出,這里有一處地方依然不完美,就是rename的那一步,如果就是這么巧,同時兩個或者多個進程進入了 if 語句,先后開始 rename 那么依然會發生刪除掉日志的情況。確實這種情況確實會發生,由于切分文件一天才一次,正好切分的時候同時有兩個Handler在操作,又正好同時走到這里,也是蠻巧的,但是為了完美,可以加上一個文件鎖,if 之后加鎖,得到鎖之后再判斷一次,再進行rename這種方式就完美了。代碼就不貼了,涉及到鎖代碼,影響美觀。
解決方案2
我認為最簡單有效的解決方案。重寫FileHandler類(這個類是所有寫入文件的Handler都需要繼承的TimedRotatingFileHandler 就是繼承的這個類;我們增加一些簡單的判斷和操作就可以。
我們的邏輯是這樣的:
判斷當前時間戳是否與指向的文件名是同一個時間
如果不是,則切換 指向的文件即可
結束,是不是很簡單的邏輯。
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?notos.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 就是執行邏輯1判斷;build_baseFilename 就是執行邏輯2換句柄。就這么簡單完成了。
這種方案與之前不同的是,當前文件就是 current.log.2016-06-01 ,到了明天當前文件就是current.log.2016-06-02 沒有重命名的情況,也沒有刪除的情況。十分簡潔優雅。也能解決多進程的logging問題。
解決方案其他
當然還有其他的解決方案,例如由一個logging進程統一打日志,其他進程將所有的日志內容打入logging進程管道由它來打理。還有將日志打入網絡socket當中也是同樣的道理。
5. 參考資料
python logging 官方文檔
林中小燈的切分方案
Logging Python
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。