Bottle 中的裝飾器類和描述符應用

PrivateRookie發表於2018-06-27

這篇是很久以前發在知乎上的文章, 現在把它搬運過來

最近在閱讀Python微型Web框架Bottle的原始碼,發現了Bottle中有一個既是裝飾器類又是描述符的有趣實現。剛好這兩個點是Python比較的難理解,又混合在一起,讓程式碼有些晦澀難懂。但理解程式碼之後不由得為Python語言的簡潔優美讚歎。所以把相關知識和想法稍微整理,以供分享。

正文

bottle是Python的一個微型Web框架,所有程式碼都在一個bottle.py檔案中,只依賴標準庫實現,相容Python 2和Python 3,而且最新的穩定版0.12程式碼也只有3700行左右。雖然小,但它實現了Web框架基本功能。這裡就不以過多的筆墨去展示Bottle框架,需要的請訪問其網站了解更多。這裡著重介紹與本文相關的重要物件request。在Bottle裡,request物件代表了當前執行緒處理的請求,客戶端傳送的請求資料如表單資料,請求網站和cookie都可以從request物件中獲得。下面是官方文件中的兩個例子
from bottle import request, route, response, template

# 獲取客戶端cookie以實現登陸時問候使用者功能
@route('/hello')
def hello():
    name = request.cookie.username or 'Guest'
    return template('Hello {{name}}', name=name)

# 獲取形如/forum?id=1&page=5的查詢字串中id和page變數的值
route('/forum')
def display_forum():
    forum_id = request.query.id
    page = request.query.page or '1'
    return template('Forum ID: {{id}} (page {{page}})', id=forum_id, page=page)
那麼Bottle是如何實現的呢?根據WSGI介面規定,所有的HTTP請求資訊都包含在一個名為envrion的dict物件中。所以Bottle要做的就是把HTTP請求資訊從environ解析出來。在深入Request類如何實現之前先要了解下Bottle的FormsDict。FormsDict與字典類相似,但擴充套件了一些功能,比如支援屬性訪問、一對多的鍵值對、WTForms支援等。它在Bottle中被廣泛應用,如上面的示例中cookie和query資料都以FormsDict儲存,所以我們可以用request.query.page的方式獲取相應屬性值。

下面是0.12版Bottle中Request類的部分程式碼,0.12版中Request類繼承了BaseRequest,為了方便閱讀我把程式碼合併在一起,同時還有重要的DictProperty的程式碼。需要說明的是Request類init傳入的environ引數就是WSGI協議中包含HTTP請求資訊的envrion,而query方法中的_parse_qsl函式可以接受形如/forum?id=1&page=5原始查詢字串然後以[(key1, value1), (ke2, value2), ...]的list返回。

class DictProperty(object):
    """ Property that maps to a key in a local dict-like attribute. """

    def __init__(self, attr, key=None, read_only=False):
        self.attr, self.key, self.read_only = attr, key, read_only

    def __call__(self, func):
        functools.update_wrapper(self, func, updated=[])
        self.getter, self.key = func, self.key or func.__name__
        return self

    def __get__(self, obj, cls):
        if obj is None: return self
        key, storage = self.key, getattr(obj, self.attr)
        if key not in storage: storage[key] = self.getter(obj)
        return storage[key]

    def __set__(self, obj, value):
        if self.read_only: raise AttributeError("Read-Only property.")
        getattr(obj, self.attr)[self.key] = value

    def __delete__(self, obj):
        if self.read_only: raise AttributeError("Read-Only property.")
        del getattr(obj, self.attr)[self.key]

class Request:
    def __init__(self, environ=None):
        self.environ {} if environ is None else envrion
        self.envrion['bottle.request'] = self

    @DictProperty('environ', 'bottle.request.query', read_only=True)
    def query(self):
        get = self.environ['bottle.get'] = FormsDict()
        pairs = _parse_qsl(self.environ.get('QUERY_STRING', ''))
        for key, value in pairs:
            get[key] = value
        return get

query方法的邏輯和程式碼都比較簡單,就是從environ中獲取'QUERY_STRING',並用把原始查詢字串解析為一個FormsDict,將這個FormsDict賦值給environ['bottle.request.query']並返回。但這個函式的裝飾器的作用就有些難以理解,裝飾器的實現方式都是"dunder"特殊方法,有些晦澀難懂。如果上來就看這些原始碼可能難以理解程式碼實現的功能。那不如這些放一邊,假設自己要實現這些方法,你會寫出什麼程式碼。
一開始你可能寫出這樣的程式碼。

# version 1
class Request:
    """
    some codes here
    """
    def query(self):
        get = self.environ['bottle.get'] = FormsDict()
        pairs = _parse_qsl(self.environ.get('QUERY_STRING', ''))
        for key, value in pairs:
            get[key] = value
        return get

這樣確實實現瞭解析查詢字串的功能,但每次在呼叫這個方法時都需要對原始查詢字串解析一次,實際上在處理某特請求時,查詢字串是不會改變的,所以我們只需要解析一次並把它儲存起來,下次使用時直接返回就好了。另外此時的query方法還是一個普通方法,必須使用這樣的方法來呼叫它

# 獲取id
request.query().id
# 獲取page
request.query().page

query後面的小括號讓語句顯得不那麼協調,其實就是我覺得它醜。要是也能和官方文件中的示例實現以屬性訪問的方式獲取相應的資料就好了。所以程式碼還得改改。

# query method version 2
class Request:
    """
    some codes here
    """
    @property
    def query(self):
        if 'bootle.get.query' not in self.environ:
            get = self.environ['bottle.get'] = FormsDict()
            pairs = _parse_qsl(self.environ.get('QUERY_STRING', ''))
            for key, value in pairs:
                get[key] = value
        return self.environ['bottle.get.query']

第二版改變的程式碼就兩處,一個是使用property裝飾器,實現了request.query的訪問方式;另一個就是在query函式體中增加了判斷'bottle.get.query'是否在environ中的判斷語句,實現了只解析一次的要求。第二版幾乎滿足了所有要求,它表現得就像Bottle中真正的query方法一樣。但它還是有些缺陷。
首先,Request類並不只有query一個方法,如果要編寫完整的Request類就會發現,有很多方法的程式碼與query相似,都是從environ中解析出需要的資料,而且都只需要解析一次,儲存起來,第二次或以後訪問時返回儲存的資料就好了。所以可以考慮將屬性管理的程式碼從方法體內抽象出來,正好Python中的描述符可以實現這樣的功能。另外如果使用Bottle的開發者在寫程式碼時不小心嘗試進行request.query = some_data的賦值時,將會丟擲如下錯誤。

>>> AttributeError: can't set attribute

我們確實希望屬性是隻讀的,在對其賦值時應該丟擲錯誤,但這樣的報錯資訊並沒有提供太多有用的資訊,導致調bug時一頭霧水,找不到方向。我們更希望丟擲如

>>> AttributeError: Read-only property

這樣明確的錯誤資訊。
所以第三版的程式碼可以這樣寫

# query method version 3
class Descriptor:
    def __init__(self, attr, key, getter, read_only=False):
        self.attr = attr
        self.key = key
        self.getter = getter
        self.read_only = read_only

    def __set__(self, obj, value):
        if self.read_only:
                raise AttributeError('Read only property.')
        getattr(obj, self.attr)[self.key] = value

    def __get__(self, obj, cls):
        if obj is None:
            return self
        key, storage = self.key, getattr(obj, self.attr)
        if key not in storage:
            storage[key] = self.getter(obj)
        return storage[key]

    def __delete__(self, obj):
        if self.read_only:
            raise AttributeError('Read only property.')
        del getattr(obj, self.attr)[self.key]

class Reqeust:
    """
    some codes
    """
    def query(self):
        get = self.environ['bottle.get'] = FormsDict()
        pairs = _parse_qsl(self.environ.get('QUERY_STRING', ''))
        for key, value in pairs:
            get[key] = value
        return get  
    query = Descriptor('environ', 'bottle.get.query', query, read_only=True)

第三版的程式碼沒有使用property裝飾器,而是使用了描述符這個技巧。如果你之前沒有見到過描述符,在這裡限於篇幅只能做個簡單的介紹,但描述符涉及知識點眾多,如果有不清楚之處可以看看《流暢的Python》第20章屬性描述符,裡面有非常詳細的介紹。

簡單來說,描述符是對多個屬性運用相同存取邏輯的一種方式,如Bottle框架裡我們需要對很多屬性都進行判斷某個鍵是否在environ中,如果在則返回,如果不在,需要解析一次這樣的存取邏輯。而描述符需要實現特定協議,包括set,get, __delete_方法,分別對應設定,讀取和刪除屬性的方法。他麼的引數也比較特殊,如get__方法的三個引數self, obj, cls分別對應描述符例項的引用,對第三版的程式碼來說就是Descriptor('environ', 'bottle.get.query', query, read_only=True)建立的例項的引用;obj則對應將某個屬性託管給描述的例項物件的引用,對應的應該為request物件;而cls則為Request類的引用。在呼叫request.query時編譯器會自動傳入這些引數。如果以Request.query的方式呼叫,那麼obj引數的傳入值為None,這時候通常的處理是返回描述符例項。

在Descriptor中get方法的程式碼最多,也比較難理解,但如果記住其引數的意義也沒那麼難。下面以query的實現為例,我新增一些註釋來幫助理解

key, storage = self.key, getattr(obj, self.attr)
# key='bottle.get.query'
# storage = environ 即包含HTTP請求的資訊的environ

# 判斷envrion中是否包含key來決定是否需要解析
if key not in storage:
    storage[key] = self.getter(obj)
    # self.getter(obj)就是呼叫了原來的query方法,不過要傳入一個Request例項,也就是obj
return storage[key]

set, delete程式碼比較簡單,在這裡我們把只讀屬性在賦值和刪除時丟擲的錯誤定製為AttributeError('Read only property.'),方便除錯。
透過使用描述符這個有些難懂的方法,我們可以在Request的方法中專心於編寫如何解析的程式碼,不用擔心屬性的存取邏輯。和在每個方法中都使用if判斷相比高到不知道哪裡去。但美中不足的是,這樣讓我們的方法程式碼後面拖著一個“小尾巴”,即

query = Descriptor('envrion', 'bottle.get.query', query, read_only=True)

怎麼去掉這個這個“小尾巴“呢?回顧之前的程式碼幾乎都是對query之類的方法進行修飾,所以可以嘗試使用裝飾器,畢竟裝飾器就是對某個函式進行修飾的,而且我們應該使用引數化的裝飾器,這樣才能將envrion等引數傳遞給裝飾器。如果要實現引數化裝飾器就需要一個裝飾器工廠函式,也就是說裝飾器的程式碼裡需要巢狀至少3個函式體,寫起來有寫繞,程式碼可閱讀性也有差。更大的問題來自如何將描述符與裝飾器結合起來,因為Descriptor是一個類而不是方法。

解決辦法其實挺簡單的。如果知道Python中函式也是物件,實現了call方法的物件可以表現得像函式一樣。所以我們可以修改Descirptor的程式碼,實現call方法,讓它的例項成為callable物件就可以把它用作裝飾器;而要傳入的引數可以以例項屬性儲存起來,透過self.attribute的形式訪問,而不是像使用工廠函式實現引數化裝飾器時透過閉包來實現引數的訪問獲取。這時候再來看看Bottle裡的DictProperty程式碼

class DictProperty(object):
    """ Property that maps to a key in a local dict-like attribute. """

    def __init__(self, attr, key=None, read_only=False):
        self.attr, self.key, self.read_only = attr, key, read_only

    def __call__(self, func):
        functools.update_wrapper(self, func, updated=[])
        self.getter, self.key = func, self.key or func.__name__
        return self

    def __get__(self, obj, cls):
        if obj is None: return self
        key, storage = self.key, getattr(obj, self.attr)
        if key not in storage: storage[key] = self.getter(obj)
        return storage[key]

    def __set__(self, obj, value):
        if self.read_only: raise AttributeError("Read-Only property.")
        getattr(obj, self.attr)[self.key] = value

    def __delete__(self, obj):
        if self.read_only: raise AttributeError("Read-Only property.")
        del getattr(obj, self.attr)[self.key]

其實就是一個有描述符作用的裝飾器類,它的使用方法很簡單:

@DictProperty('environ', 'bottle.get.query', read_only=True)
def query(self):
    """ some codes """

拆開會更好理解點:

property = DictProperty('environ', 'bottle.get.query', read_only=True)
@property
def query(self):
    """ some codes """  

再把@實現的語法糖拆開:

def query(self):
    """ some codes """

property = DictProperty('environ', 'bottle.get.query', read_only=True)
query = property(query) # @實現的語法糖

再修改以下程式碼形式:

def query(self):
    """ some codes """

query = DictProperty('environ', 'bottle.get.query', read_only=True)(query)

是不是和第三版的實現方式:

def query(self):
    """ some codes """

query = Descriptor('environ', 'bottle.get.query', query, read_only=True)

非常相似。
但我們可以使用裝飾器把方法體後面那個不和諧的賦值語句”小尾巴“去掉,將屬性存取管理抽象出來,而且只需要使用一行非常簡便的裝飾器把這個功能新增到某個方法上。這也許就是Python的美之一吧。

寫在後面

DictProperty涉及知識遠不止文中涉及的那麼簡單,如果你還是不清楚DictProperty的實現功能,建議閱讀《流暢的Python》第7章和第22章,對裝飾器和描述符有詳細的描述,另外《Python Cookbook》第三版第9章超程式設計有關於引數化裝飾器和裝飾器類的敘述和示例。如果你對Bottle為什麼要實現這樣的功能感到困惑,建議閱讀Bottle的文件和WSGI相關的文章。
其實前一陣再閱讀Bottle原始碼時就想寫一篇文章,但奈何許久不寫東西文筆生疏加上醫院實習期間又比較忙,一直推到現在才終於磕磕絆絆地把我閱讀的Bottle原始碼的一些感悟寫出來,希望對喜歡Python的各位有些幫助把。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
多少事,從來急。天地轉,光陰迫。

相關文章