需求背景
最近在工作上, 遇到了一個比較特殊的需求:
為了安全, 設計一個函式或者裝飾器, 然後使用者在 “定義/呼叫” 函式時, 只能訪問到我們允許的內建變數和全域性變數。
通過例子來這解釋下上面的需求:
1 2 3 4 5 6 7 8 9 10 |
a = 123 def func(): print a print id(a) func() # 輸出 123 32081168 |
函式功能簡單明瞭, 對於結果, 大家應該也不會有太大的異議:func分別是取得全域性名稱空間中a的值和使用內建名稱空間中的函式id獲取了a的地址. 熟悉Python的童鞋, 對於LEGB肯定也是不陌生的,也正是因為LEGB才讓函式func輸出正確的結果. 但是這個只是一個常規例子, 只是用來拋磚引玉而已. 我們真正想要討論的是下面的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# 裝飾函式 def wrap(f): # 呼叫使用者傳入的函式 f() a = 123 # 使用者自定義函式 def func(): import os print os.listdir('.') wrap(func) # 輸出 ['1.yml', '2.py', '2.txt', '2.yml', 'ftp', 'ftp.rar', 'test', 'tmp', '__init__.py'] |
潛在危險因素
在上面的例子可以看出, 如果在func中, 引入別的模組, 然後再執行模組中的方法, 也是可行的! 而且這還是一個非常方便的功能! 但是除了方便, 更多的是一種潛在的危險.在日常使用, 或許我們不會考慮這些, 但是如果在模組與模組之間的協同作用時, 特別是多人蔘與的情況下, 這種危險的因素, 就不得不讓我們認真對待!
或許有很多同學會覺得這些擔憂是過多的, 是沒必要的, 但是請思考一種場景: 我們有個主模組, 暫時稱為main.py, 它允許使用者動態載入模組, 也就是說只要使用者將對應的模組放到對應的目錄, 然後利用訊息機制去通知main.py, 告訴它應該載入新模組了, 並且執行新模組裡面的b函式, 那在這種情況下, main.py肯定不能直接傻傻的就去執行, 因為我們不能相信每個使用者都是誠實善良的, 也不能相信每個使用者編寫的模組或者函式是符合我們的行為標準規範. 所以我們得有些措施去防範這些事情, 我們能做的大概也就下面幾種方式:
- 在使用者通知
main.py
時有新模組加入並且要求執行函式時, 先對模組的程式碼做檢查, 不符合標準或者帶有危險程式碼的拒絕載入. - 控制好
內建命名空間
和全域性名稱空間
, 使其只能用允許使用的內容
在方案1, 其實也是我們最容易想到的方法, 但是這個方法的成本還是比較高, 因為我們需要將可能出現的錯誤程式碼或者關鍵詞,全部寫成一套規則, 而且這套規則還很大可能會誤傷, 不過也可能業界已經有類似的成熟的方案, 只是我還沒接觸到而已.
所以我們只能用方案2的方法, 這種方法在我們看來, 是成本比較低的, 也比較容易控制的, 因為這就和防火牆一樣, 我們只放行我們允許的事物.
具體實現
實現方案2最大的問題就是, 如何控制內建名稱空間 和全域性名稱空間
我們第一個想法肯定就是覆蓋它們, 因為我們都知道不管是內建名稱空間還是全域性名稱空間, 都是通過字典的形式在維護:
1 2 3 4 5 6 7 8 9 |
print globals() print globals()['__builtins__'].__dict__ # 輸出 # 全域性名稱空間 {'__builtins__': <module '__builtin__' (built-in)>, '__name__': '__main__', '__file__': 'D:/Python_project/ftp/2.py', '__doc__': None, '__package__': None} #內建名稱空間 {'bytearray': <type 'bytearray'>, 'IndexError': <type 'excep.....(省略過多部分)..} |
注: globals函式 是用來列印當前全域性名稱空間的函式, 同樣, 也能通過修改這個函式返回的字典對應的key, 實現全域性名稱空間的修改.例如:
1 2 3 4 5 6 7 8 9 10 |
s = globals() print s s['a'] = 3 print s print a # 輸出 {'__builtins__': <module '__builtin__' (built-in)>, '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 's': {...}, '__name__': '__main__', '__doc__': None} {'a': 3, '__builtins__': <module '__builtin__' (built-in)>, '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 's': {...}, '__name__': '__main__', '__doc__': None} 3 |
可以看出, 我們並沒有定義變數a, 只是在globals的返回值上面增加了key-value, 就變相實現了我們定義的操作, 這其實也能用於很多希望能夠動態賦值的需求場景! 比如說, 我不確定有多少個變數, 希望通過一個變數名列表, 動態生成這些變數, 在這種情況下, 就能參考這種方法, 不過還是希望謹慎使用, 因為修改了這個, 就是就修改了全域性名稱空間.
好了, 迴歸到本文, 我們已經知道通過globals函式能夠代表全域性名稱空間, 但是為什麼內建名稱空間要用globals()[‘__builtins__’].__dict__來表示? 其實這個和python自身的機制有關, 因為模組在編譯和初始化的過程中, 內建名稱空間就是以這種形式,寄放在全域性名稱空間:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
static void initmain(void) { PyObject *m, *d; m = PyImport_AddModule("__main__"); if (m == NULL) Py_FatalError("can't create __main__ module"); d = PyModule_GetDict(m); if (PyDict_GetItemString(d, "__builtins__") == NULL) { PyObject *bimod = PyImport_ImportModule("__builtin__"); if (bimod == NULL || PyDict_SetItemString(d, "__builtins__", bimod) != 0) Py_FatalError("can't add __builtins__ to __main__"); Py_XDECREF(bimod); } } |
從上面程式碼可以看出, 在初始化__main__時, 會有一個獲取__builtins__的動作, 如果這個結果是NULL, 那麼就會用之前初始化好的__builtin__去存進去, 這些程式碼具體可以看Pythonrun.c, 在這不詳細展開了.
既然內建名稱空間(__builtins__)和全域性名稱空間(globals())都已經找到對應物件了, 那我們下一步就應該是想法將這兩個空間替換成我們想要的.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
# coding: utf8 # 修改全域性名稱空間 test_var = 123 # 測試變數 tmp = globals().keys() print globals() print test_var for i in tmp: del globals()[i] print globals() print test_var print id(2) # 輸出 {'tmp': ['__builtins__', '__file__', '__package__', 'test_var', '__name__', '__doc__'], '__builtins__': <module '__builtin__' (built-in)>, '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 'test_var': 123, '__name__': '__main__', '__doc__': None} 123 {'tmp': ['__builtins__', '__file__', '__package__', 'test_var', '__name__', '__doc__'], 'i': '__doc__'} Traceback (most recent call last): File "D:/Python_project/ftp/2.py", line 10, in <module> print test_var NameError: name 'test_var' is not defined |
在上面的輸出可以看到, 在刪除前後, 通過print globals()可以看到全域性名稱空間確實已經被修改了, 因為test_var已經無法列印了, 觸發了NameError, 這樣的話, 就有辦法能夠限制全域性命令空間了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
# 虛擬碼 # 裝飾函式 def wrap(f): # 呼叫使用者傳入的函式 .... 修改全域性名稱空間 f() .... 還原全域性名稱空間 a = 123 # 使用者自定義函式 def func(): import os print os.listdir('.') wrap(func) |
為什麼我只寫虛擬碼, 因為我發現這個功能實現起來是非常蛋疼! 原因就是, 在實現之前, 我們必須要解決幾個問題:
- 全域性名稱空間對應了一個字典, 所以如果我們想要修改, 只能從修改這個字典本身, 於是先清空再定義成我們約束的, 呼叫完之後, 又得反過來恢復, 這些操作是十分之蛋疼.
- 涉及到共享的問題, 如果這個使用者函式處理很久, 而且是多執行緒的, 那麼整個模組都會變得很不穩定, 甚至稱為”汙染”
那就先撇開不講, 講講內建名稱空間, 剛才我們已經找到了能代表內建名稱空間的物件, 很幸運的是, 這個是”真的能夠摸得到”的, 那我們試下直接就賦值個空字典, 看會怎樣:
1 2 3 4 5 6 7 8 9 10 11 12 |
s = globals() print s['__builtins__'] # __builtins__檢查是否存在 s['__builtins__'] = {} print s['__builtins__'] # __builtins__檢查是否存在 print id(3) # 試下內建函式能否使用 print globals() # 輸出 <module '__builtin__' (built-in)> {} 32602360 {'__builtins__': {}, '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 's': {...}, '__name__': '__main__', '__doc__': None} |
結果有點尷尬, 似乎沒啥用, 但是其實這個__builtins__只是一個表現, 真正的內建名稱空間是在它所指向的字典物件, 也就是: globals()[‘__builtins__’].__dict__!
1 2 3 4 |
print globals()['__builtins__'].__dict__ # 輸出 {'bytearray': <type 'bytearray'>, 'IndexError': <type 'exceptions.IndexError'>....} # 省略 |
所以我們真正要覆蓋的, 是這個字典才對, 所以上面的程式碼要改成:
1 2 3 4 5 6 7 8 9 |
s = globals() s['__builtins__'].__dict__ = {} # 覆蓋真正的內建名稱空間 print s['__builtins__'].__dict__ # __builtins__檢查是否存在 # 輸出 Traceback (most recent call last): File "D:/Python_project/ftp/2.py", line 3, in <module> s['__builtins__'].__dict__ = {} TypeError: readonly attribute |
失敗了…原來這個內建名稱空間是隻讀的, 所以我們上面的方法都失敗了..那難道真的沒法解決了嗎? 一般這樣問, 通常都有解決方案滴~
完美方案
這個解決方法, 需要一個庫的幫忙~, 那就是inspect庫, 這個庫是幹嘛呢? 簡單來說就是用來自省. 它提供四種用處:
- 對是否是模組,框架,函式等進行型別檢查。
- 獲取原始碼
- 獲取類或函式的引數的資訊
- 解析堆疊
在這裡, 我們需要用到第二個功能, 其餘的功能, 感興趣的童鞋可以去谷歌學習哦, 也可以參考: https://my.oschina.net/taisha…
除了inspect, 我們還需要用到exec, 這也是一大殺器, 可以先參考這個學習下: http://www.mojidong.com/pytho…
方法大致的過程就是以下幾步:
- 根據使用者傳入的func物件, 利用inspect取出對應的原始碼
- 通過exec利用原始碼並且傳入全域性名稱空間, 重新編譯
程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
# coding: utf8 import inspect # 裝飾函式 def wrap(f): # 呼叫使用者傳入的函式 source = inspect.getsource(f) # 獲取原始碼 exec('%s \n%s()' % (source, f.func_name), {'a': 'this is inspect', '__builtins__': {}}) # 重新編譯, 並且重新構造全域性名稱空間 a = 123 # 使用者自定義函式 def func(): print a import os print os.listdir('.') wrap(func) # 輸出 this is inspect Traceback (most recent call last): File "D:/Python_project/ftp/2.py", line 19, in <module> wrap(func) File "D:/Python_project/ftp/2.py", line 8, in wrap exec('%s \nfunc()' % source, {'a': 'this is inspect', '__builtins__': {}}) File "<string>", line 6, in <module> File "<string>", line 3, in func ImportError: __import__ not found |
雖然上面報錯了, 但那不就我們求之不得結果嗎? 我們可以正確的輸出a的值this is inspe, 而且當func想import時, 直接報錯! 這樣就能滿足我們的變態慾望了~ 嘿嘿!,
關於程式碼執行原理, 其實在關鍵部位的程式碼, 都已經加了註釋, 可能在exec那部分會比較迷惑, 但其實大家將對應的變數代入字串就能懂了, 替換之後, 其實也就是函式的定義+執行, 可以通過print ‘%s \n%s()’ % (source, f.func_name)幫助理解.而後面的字典, 也就是我們一直很糾結的全域性名稱空間, 其中內建名稱空間也被人為定義了, 所以能夠達到我們想要的效果了!
這種只是一種拋磚引玉, 讓有類似場景需求的童鞋, 有個參考的方向, 也歡迎分享你們實現的方案, 嘿嘿!