1. 程式人生 > 程式設計 >深入討論Python 裝飾器 (與Java Aop對比思考)

深入討論Python 裝飾器 (與Java Aop對比思考)

更多Java面試資料(作業系統,網路,zk,mq,redis,java等):github.com/yuhaqiang12…

先說結論 java 註解能實現的功能,python 的裝飾器絕大部分都是可以勝任的,裝飾器更像 Java 中註解加上Aop兩者的組合

python 是一門極簡的語言,語言簡潔學習起來也是相當輕鬆的,但是依然有一些高階技巧,例如裝飾器,協程,併發會讓人感覺困惑,失望與沮喪,本文將重點講解 python裝飾器的使用,使用常用的例子讓我們更直觀的看到裝飾器的強大表達能力,最後也給出了編寫裝飾器常見的工具。

熟悉 java的同學一定熟悉註解的使用,藉助於註解可以定義元資料配置,我們常常有這種感受,"只要加上這個註解,我的元件就會被註冊進去","只要加上這個註解,就會新增事務控制",也會困惑,"為什麼加了這個註解依然沒有生效?", python 沒有提供像Java似的註解,但是提供了相比註解表達能力更加強大的裝飾器。 例如 web框架 Flask 中的route ,errorhandler,及 python 自帶的 property,staticmethod等。 實際上java 註解能實現的功能,python 的裝飾器絕大部分都是可以勝任的,裝飾器更像 Java 中註解加上Aop兩者的組合, 這個結論最後我們會重點討論,先按下不表。現在首先以日誌列印的簡單例子初步講解一下裝飾器的使用

1.0 裝飾器的簡單例子

def log(func):                                      #@1
    def func_dec(*args,**kwargs):                  #@2
        r = func(*args,**kwargs)                   #@3
        print("didiyun execute done:%s" % func.__name__)
        return r

    return func_dec

@log                                                #@4
def test_dec(size,length,ky=None): print "didiyun execute test_dec param:%s,%s,%s" % (size,ky) def test(): test_dec(ky="yuhaiqiang",length=3,size=1) "" 輸出結果可以看到裝飾器的裝飾邏輯已正確被執行 didiyun execute test_dec param:1,3,yuhaiqiang didiyun execute done:test_dec "" @1 定義 log 裝飾器,輸入引數func是需要被裝飾的函式,本例中輸出列印是 test_dec @2 定義一個裝飾函式,引數型別包括變長的位置引數和名字引數,適應被裝飾函式不同的引陣列合,這種寫法可以代表任意引陣列合 @3 執行實際的函式func_dec, 注意處理返回值,不要"吞掉"
被裝飾函式的返回值 @4 在被裝飾函式上新增裝飾器,注意此處不要加(),後面會解釋具體原因,瞭解該原因,就能完全瞭解裝飾器的小九九 複製程式碼

裝飾器可以在函式外層新增額外的功能裝飾原函式, 本例的裝飾器只是在函式外層列印一行日誌,實現的是非常簡單的功能,實際中裝飾器並不是"僅僅列印日誌的雕蟲小技",還能實現其他更有用的功能

2.使用裝飾器巧用檔案鎖

2.1 使用 fcntl 實現檔案鎖

class Lock:
    def __init__(self,filename,block=True):
    #block 引數為 true代表阻塞式獲取。  False為非阻塞,如果獲取不到立刻返回 false
        self.filename = filename
        self.block = block
        self.handle = open(filename,'w')

    def acquire(self):
        if not self.block:
            try:
                fcntl.flock(self.handle,fcntl.LOCK_EX | fcntl.LOCK_NB)
                return True
            except:
                return False
        else:
            fcntl.flock(self.handle,fcntl.LOCK_EX)
            return True
    def release(self):
        fcntl.flock(self.handle,fcntl.LOCK_UN)
        self.handle.close()
複製程式碼

藉助於 fcntl 庫 我們已經實現檔案鎖,感興趣的讀者可以深入瞭解一下 fcntl 庫,下面我們以檔案鎖為例,介紹一下裝飾器很實用很常見的一些功能。

2.2 定義檔案鎖裝飾器

def file_lock(lock_name,block=True):                               #@1 
    def wrapper(func):
        def f(*args,**kwargs):
            name = lock_name
            lock = Lock(name,block)
            acquire = lock.acquire()
            if not acquire:
                print("failed to acquire lock:%s,now ignore" % name)
                return                                              #@2
            print("acquire process lock:%s" % name)
            try:
                return func(*args,**kwargs)
            finally:
                lock.release()                                      #@3
                print("release process lock:%s" % name)
        return f
    return wrapper
    
@file_lock(name="/var/local/file",block=True)                      #@4
def get_length():
    pass
get_length()

輸出                                                                 #@5
acquire process lock:/var/local/file
execute test_dec param:1
release process lock:/var/local/file
複製程式碼
  1. 定義 file_lock 裝飾器,接受兩個引數, lock_name:鎖路徑, block: 是否阻塞式的獲取
  2. 該處在獲取鎖失敗時,僅僅返回了 None, 呼叫方無法 明確知道None 是 get_length 的返回值還是獲取鎖失敗,實際上應該丟擲一個異常,交給 上游呼叫方去處理 獲取鎖操作失敗的異常
  3. try finally 保證鎖一定可以被釋放
  4. 在使用檔案鎖的時候,還需要提供鎖的值,能否提供預設值呢?只對該例項方法加鎖,讀者可以考慮一下如何實現。此外細心地讀者能比較出來,file_lock裝飾器,添加了括號(),但是兩者的區別可不僅僅因為一個有引數,一個無參,後一節解釋下裝飾器的語法糖本質
  5. 實際在專案中使用時,經常會遇到檔案鎖的問題, 在專案除錯階段, 由於經常需要手動終止強殺程式, 這樣會導致檔案鎖沒有被正確清理,讀者可以考慮 將檔案鎖指定在一個固定的目錄, 每次程式啟動時,檢測是否有同路徑程式,如果沒有, 可以清理該目錄,如果存在同路徑程式,說明現在有併發執行,不清理該目錄。 如果沒有清理功能可能會導致永遠無法獲取到鎖。 如果不實際使用以上程式碼實現檔案鎖,可以 忽略該問題,不影響理解裝飾器。 感興趣的讀者可以試試,希望能提出更好的檔案鎖方案

使用檔案鎖之後,呼叫該方法必須先獲取到鎖,否則只能先阻塞。 因此實際的處理方法不需要處理同步邏輯, 只需要一行裝飾器, 就額外擴充套件了同步功能, 通過異常控制,還能保證檔案鎖一定可以被釋放, 避免檔案鎖洩露, 通過裝飾器我們還可以實現很多其他有用的功能 ,但是檔案鎖裝飾器的實現已經相比日誌裝飾器複雜了, 仔細觀察, 它已經 嵌套了 三層函式,後續我們會優化這個問題。

2.3 解釋1.0 的疑問,何時使用裝飾器需要新增 括號()

在1.0 日誌裝飾器的例子我們留下了一個疑問,為什麼 log 裝飾器不需要加() ,而檔案鎖裝飾器的使用卻加了()

回到1.0 的裝飾器實現,假如我們不使用@ log 的方式,使用如下方式呢?能不能實現相同的邏輯?

 @log
 def foo():
     pass
 
 foo() # 相當於 log(foo)(),log(func) 返回裝飾函式,最後的括號代表執行
 
 foo = log(foo) # 就是裝飾器語法糖幫我們做的
複製程式碼

log 方法接受的引數是 func , 自然當手動顯式 呼叫 log 裝飾 foo 函式時, 絲毫不影響實現裝飾的功能. 但是顯得我們很囉嗦很蠢, 幸福的是 python 提供了裝飾器的語法糖, 使用該語法糖就好像我們手動執行裝飾一樣。 但是如果我們加上括號代表什麼意思呢? @log() 的寫法, 不就相當於呼叫了 log 函式,但是又不給其傳參? 實際 python 直譯器也是這麼"抗議" 我們的。

但是為什麼檔案鎖又加上了括號呢?答案是,裝飾器有時候需要一些額外的引數,例如 Flask 中我們常用的 route,我們需要告訴 Flask, 如何 將url對映到具體的 handler, 自然需要告訴 route, 需要繫結的 url 是什麼, 和 spring 的@RequestMapping作用類似

當裝飾器加上引數之後, 驚訝的發現裝飾器更像是三層函式了....., 可理解性已經極差了, 但是一旦理解之後我們會發現三層函式是原因的

不妨這樣理解, 當裝飾器沒有引數, 就像log 裝飾器,該裝飾器接受引數為 func, 我們稱其為"兩層"裝飾器 ,以上我們已經分析了它的的原理, foo = log(foo). 裝飾器的@標記等於告訴 python直譯器, "你把@下一行的函式,作為引數傳給該裝飾器,然後把返回值賦值給該函式", 相當於執行 foo = log(foo) 當我們呼叫 foo() 相當於是呼叫 log(foo)()。

對於帶引數的裝飾器file_lock(name,block), 我們分成兩個階段理解,回顧一下 file_lock 的三層函式實現,我們在第二層定義了一個 wrapper 函式,該函式接受了一個 func 引數,隨後我們在 file_lock 的最後將其return, 我們可以這樣認為

@file_lock(name="/var/local/test",block=True)
def test()
 pass
 
wrapper = file_lock(name,block) #第一階段
test = wrapper(test)    #第二階段
複製程式碼

第一階段執行了最外層的函式file_lock, 返回了 wrapper。 第二階段,使用 wrapper 裝飾 test, 第二階段我們已經熟悉理解了。 實際上, 只是第一階段是多執行的。 由於我們多給它加了一個括號, python 直譯器自然會去執行該函式, 該函式返回另一個裝飾函式, 這樣就到了第二階段。

python 直譯器希望我們這樣去理解,否則三層函式的寫法很讓人崩潰。後續我們繼續探索裝飾器,能不能實現相同的功能,但是能擺脫編寫三層函式的噩夢。

以上分析了帶引數和不帶引數的裝飾器的區別,及如何在心裡去理解與接受這種寫法, python 通過語法糖, 函式之上的裝飾器定義 代替蠢笨的手動裝飾呼叫。 我們可以實現複雜的裝飾器 但卻能提供極其 優雅的使用方式給呼叫方, 還是 讓人鼓舞的,事實上, python的框架中大量的使用了裝飾器。也說明瞭裝飾器的強大與優雅。

3.python 裝飾器方法的執行時機與順序

python 是解釋執行的語言,我們做一個小實驗,以上例子先定義 log 裝飾器,而後再使用 log裝飾器,如果置換一下順序

@log("say some thing")
def test_dec(size, length,ky=None):
    print "execute test_dec param:%s,ky)

def log(info=None):
    def wrapper(func):
        def func_dec(*args,**kwargs):
            r = func(*args,**kwargs) 
            print("execute done:%s" % func.__name__)
            return r
        return func_dec
    return wrapper
複製程式碼

毫無疑問,這樣會報語法錯誤, python是 從python檔案從上到下執行解釋執行, 只有已經定義log, 才能使用它。 在python 中,函式是一等公民, 函式也是物件,在定義函式時,也就是在宣告一個函式物件

def foo():
    pass
複製程式碼

def foo()就是在宣告一個函式物件, foo 即是該函式物件的引用, 我們可以額外定義該物件的屬性, 通過dir(foo) 檢視一下該函式物件有哪些屬性,其實是和類例項物件沒有區別的

以上提過 test 被 log裝飾 後, test() 等同於log(test)() , python 裝飾器解釋執行完 @log def test(),等同於test=log(test) 此時 test引用的函式物件是 log裝飾後的函式物件

@log
def test():
    pass
test=log(test)
複製程式碼

3.2 裝飾器的執行順序

實際開發中我們經常會遇到使用多個裝飾器,如果讀者理解了3.0,及以上的函式物件的概念,其實應能能猜出來裝飾器的裝飾順序,自然是從上往下執行的 foo = a(b(c(foo))) 但是實際的程式碼執行順序是 c->b->a

@a
@b
@c
def foo()
    pass
複製程式碼

以上我們使用日誌裝飾器和檔案鎖裝飾器介紹了裝飾器的使用,並且討論了帶引數及不帶引數裝飾器的區別。其中"三層函式"的定義方式可讀性非常差,在下一節將重點討論如何使用類實現裝飾器,簡化三層裝飾器的邏輯,減少相似程式碼的編寫

4. 裝飾器類的設計

在本節中,我們重點優化三層裝飾器的編寫,除此之外,筆者在實際開發中還發現了其他常見的需求, 例如

  1. 暫存裝飾器的引數。 期望通過被裝飾函式找到裝飾器引數, 筆者在自動化測試中就使用裝飾器定義測試 case, 需要在 case 中配置元資料資訊,在實際的執行引擎部分,訪問該元資料資訊。就是將元資料資訊放到函式物件中
  2. 註冊被裝飾函式物件。 例如某些 web 框架,註冊 handler, 需要在裝飾器中實現某些註冊邏輯

從以上三點出發,可以看到裝飾器的邏輯有某些通用的部分,然而以上裝飾器的例子都是通過函式實現的, 但函式在內部狀態, 繼承等方面明顯不如類,所以我們嘗試使用類實現裝飾器。並嘗試實現一個通用的裝飾基類

4.1 思路

python 提供了很多奇異方法,所謂的奇異方法是指,只要你實現了這個方法,就可以使用 python 的某些工具方法,例如實現__ len__ 方法,可以使用 len() 獲取長度,實現__ iter__ 可以使用 iter方法返回一個迭代器,其他方法 還有 "__eq__","__ne__", "__next__", 等。 其中當實現__ call__ 方法時, 類可以被當做一個函式使用,例如以下示例

class FuncClass(object):
    def __call__(self):
        print("didiyun")

>>>F = FuncClass()
>>>F()
didiyun
複製程式碼

是否也可以使用 python 的這個特性實現裝飾器呢?答案是可以的,讓我們來實現一個裝飾基類, 解決以上的痛點

class BaseDecorator(object):
    def __call__(self, *_,**kwargs):                           #@1
        return self.do_call(**kwargs)                           #@2

    def do_call(self,*_,**decorator_kwargs):
        def wrapper(func):
            wrapper.__explained = False

            @wraps(func)                                        #@3
            def _wrap(*args,**kwargs):
                if not wrapper.__explained:                     #@4
                    self._add_dict(func,decorator_kwargs)
                    wrapper.__explained = True

                return self.invoke(func,*args,**kwargs)       #@5

            self._add_dict(_wrap,decorator_kwargs)             
            _wrap = self.wrapper(_wrap)                         #@6
            return _wrap

        return wrapper

    def wrapper(self,wrapper):
        return wrapper

    def _add_dict(self,func,decorator_kwargs):
        for k,v in decorator_kwargs.items():
            func.__dict__[k] = v

    def invoke(self,**kwargs):
        return func(*args,**kwargs)

BaseDecorator實現的並不是具體的某個裝飾器邏輯,它可以作為裝飾器類的基類,以上我們曾分析編寫裝飾器通用的需求已經痛點。以下先具體講解這個類的實現,而後在討論如何使用
1. __call__ 函式簽名,*_ 代表忽略變長的位置引數,只接受命名引數。實際的裝飾器中,一般都是使用命名引數.程式碼可讀性高
2. __call__ 本身的實現邏輯委託給了 do_call 方法,主要是考慮, BaseDecorator 作為裝飾基類,需要提供某些工具方法及可擴充套件方法,但是__ call__ 方法本身無法被繼承,所以我們退而求次,將工具方法封裝在自定義方法中,子類還是需要重新
實現__ call__, 並呼叫 do_call 方法, do_call 方法的簽名和__ call__ 相同
3. functools提供了 wraps 裝飾器, 以上我們分析過python是使用裝飾後的函式物件替換之前的函式物件達到裝飾的效果, 可能有人會有疑問,如果 之前的函式物件有一些自定義屬性呢? 裝飾後的新函式會不會丟掉,答案是肯定的, 我們可以訪問之前的函式物件,給其設定屬性,
 這些屬性會被儲存在 物件的__ dict__ 字典中, 而wraps 裝飾器會把原函式的__ dict__拷貝到新的裝飾後的函式物件中, 因此 wraps 裝飾後,就不會丟掉原有的屬性, 而不使用則一定會丟掉。 感興趣的讀者可以點開 wraps 裝飾器,看一下具體實現邏輯
4. 在本節開始,我們提出裝飾器的通用需求,其中之一是需要將裝飾器的引數存放到被裝飾的函式中,_add_dict方法便是將裝飾器引數設定到原函式以及裝飾後的函式中
5. invoke 負責實現具體的裝飾邏輯,例如日誌裝飾器僅僅是列印日誌,那麼該方法實現就是列印日誌,以及呼叫原函式。  檔案鎖裝飾器,則需要先獲取鎖後在執行原函式,具體的裝飾邏輯在該方法中實現, 具體的裝飾器子類應該重寫該方法。下一節我們繼承該BaseDecorator重寫以上的日誌及檔案鎖裝飾器
6. invoke 方法是裝飾函式呼叫時被觸發的, 而 wrapper 方法只會被觸發一次,當 python 直譯器執行到@log時,會執行該裝飾器的wrapper 方法。相當於,函式被定義的時候,執行了 wrapper方法,在該方法內可以實現某些註冊功能。將函式和某些鍵值對映起來放到字典中,例如 web 框架的 url和handler對映
關係的註冊
複製程式碼

BaseDecorator 抽出來了 invoke,wrapper 目的是讓子類裝飾器可以在這兩個維度上擴充套件,分別實現裝飾,及某些註冊邏輯,在下一節我們嘗試重寫日誌及檔案鎖裝飾器,更直觀的感受BaseDeceator 給我們帶來的便利

4.2 重寫日誌及檔案鎖裝飾器

class _log(BaseDec):
    def invoke(self,**kwargs):    #@1
        print("execute done:%s,%s" % (func.__name__,func.desc) ) #@2
        return func(*args,**kwargs)

    def __call__(self,desc):
        return self.do_call(desc=desc)
log = _log()                                  #@2 

1. invoke方法中包括原函式以及原函式的輸入引數,該輸入引數不是裝飾器的引數資訊
2. 通過 func 可以訪問到裝飾器中定義的 desc 引數資訊
3. 建立裝飾器例項, 便可以像之前一樣使用 @log,需要注意的是,該裝飾類變成單例, 在定義裝飾邏輯的時候,不要輕易在 self 中儲存變數
複製程式碼

通過重寫日誌裝飾器, 可以看到已經擺脫了三層函式的噩夢, 成功的分離了裝飾器的基本程式碼,以及裝飾邏輯程式碼,我們可以更加聚焦於裝飾邏輯的核心程式碼編寫,同時可以通過原函式訪問裝飾器中輸入的引數,例如可以訪問到日誌裝飾器的 desc 以下我們再重寫檔案鎖裝飾器

class _file_lock(BaseDec):
    def invoke(self, func,**kwargs):
        name = func.name                                        #@1 
        lk = Lock(name,True)
        acquire = lk.acquire()
        if not acquire:
            print("failed to acquire lock:%s,now ignore" % name)
            return

        print("acquire process lock:%s" % name)
        try:
            return func(*args,**kwargs)
        finally:
            lk.release()
            print("release process lock:%s" % name)

    def __call__(self,name,block=True):
        return self.do_call(name=name,block=block)             #@2
        
file_lock = _file_lock()                                        #@3

1. 可以通過 func 訪問到裝飾器中定義的 name 引數
2. 把引數傳給 do_call 委託執行
3. 建立檔案鎖例項,其他位置就可以使用@file_lock了
複製程式碼

使用新的裝飾基類後, 編寫新的裝飾器子類,是非常輕鬆方便的事情, 不需要再躡手躡腳的定義複雜的三層函式, 不需要重複的設定裝飾器引數, 如果我們在專案中大量使用裝飾器, 不妨使用裝飾基類, 統一常見的功能需求。裝飾器的更多用法還需要讀者去發掘,但是熟悉 java 的同學 一定熟悉 aop 的理念, 筆者深受 java 折磨多年, 對 aop也幾分偏愛, 在我看來, python 的裝飾器是 java 中的註解加 aop 的結合。下一節我們橫向對比一下 java 註解與 python 裝飾器的相似點, 論證文章開頭我們留下的一個論點

5.對比 Java 的註解

之所以對比 java 註解,主要是筆者想從 java 的某些用法得到某些借鑑與參考, 以便於我們應用到 python 中,通過兩種語言的對比可以讓我們更深刻的理解語言設計者新增該特性的初衷,以便更好的使用該特性。 更重要的是,讓我們面對不同語言的異同 有更大的包容性, 站在欣賞的角度去對比思考,對於我們快速掌握新的語言十分有益。本節絕不是 為了爭吵兩種語言的優劣, 更不想挑起語言的戰爭

裝飾器和註解最直觀的相似點可能就是@艾特符號了, python 使用相同的符號 對於 java 程式設計師是一種"關照"。 因為 java 程式設計師對於註解有一種特殊的迷戀, 第三方框架就是使用眼花繚亂的註解 幫助 java 程式設計師實現一個個神奇的功能。而裝飾器也是可以勝任的

java 的註解本身只是一種元資料配置,在沒有註解之前, 如果實現相同的元資料配置只能依賴於 xml 配置, 有了註解之後,我們可以把元資料配置和程式碼放到一起,這樣更加直觀, 也更便於修改,至於某些人說 xml配置 可以省卻編譯打包, 其實在筆者經歷的專案中,不論是改程式碼還是改配置都是需要重新走釋出流程, 嚴禁直接修改配置重啟程式(除極特殊情況)。

註解和註解直譯器是密不可分的,定義註解之後,首先就應該想到如何定義直譯器,讀取註解上的元資料配置,使用該元資料配置做什麼。

最常見的是使用方式是使用註解註冊某些元件,開啟某項功能,例如 spring 中使用 Component註冊 bean,使用 RequestMapping 註冊 web url 對映, junit 使用 Test 註冊測試 Case, Spring boot 中使用 EnableXXX 開啟某些擴充套件功能等等,註解直譯器首先需要獲取到 Class 物件使用反射獲取到註解中的元資料配置,然後實現"註冊", "開關"邏輯。 以上在我們實現的直譯器基類中,我們也實現了類似的功能,我們把裝飾器的引數存放到具體的函式物件中, 實際等同於註解的元資料配置, 讀者也可以擴充套件, 新增一個標記, 標記該函式物件確實被某裝飾器裝飾過。 這樣便能像 java 一樣輕鬆的實現某些註冊或者開關功能。

除此之外,註解作為元資料配置,可以作為 aop 的切面,這也是註解被廣泛使用的原因, 註解可以配置在類,屬性,方法之上, "註冊" 功能一般是配置在類上, 如果使用註解切面,需要將註解配置在方法之上。以下列出使用註解 aop 可以實現的功能

   1. 異常攔截 在使用該註解的函式切面上,將異常攔截住,可以做一些通用的功能,例如異常上報,異常兜底,異常忽略等
   2. 許可權控制, 日誌記錄。 可以控制註解方法的切面的使用者訪問許可權,也可以記錄使用者操作
   3. 自動重試, 非同步處理。如果我們希望非同步呼叫某方法,或者某些需要異常重試的方法,可以使用註解定義切面, 新增非同步或重試處理
複製程式碼

註解,提供了非常靈活的切面定義方式,以上三種只是常見的使用方式,當註解定義了切面, aop 會替換被代理的類, 新增某些代理邏輯, 拋開底層實現原理, 實際上aop這種機制和 python 的裝飾器區別並不是很大, 設計模式中裝飾器和代理模式本身就非常相似, 以上註解可以實現的功能, python 的裝飾器都是可以一一實現的。在函式被定義的時刻裝飾器就已經生效了, 而 aop也是通過編譯期或者執行期在實際呼叫之前代理。 python 的裝飾器本身也是一個函式,它通過語法糖的方式,幫我們實現了裝飾,而 靜態型別的java 選擇了動態修改位元組碼,編譯器織入等更加複雜的技術實現了類似的功能。 不同的底層實現, 並不能影響在使用方式及場景上互相借鑑。 所以筆者還是認為 裝飾器更像 java 註解+ aop 的組合。 這樣對比 對於java 程式可能更容易理解,更好的使用裝飾器。

更多Java面試資料(作業系統,java等):github.com/yuhaqiang12…