Python: 受限制的 "函數調用"

需求背景

最近在工做上, 遇到了一個比較特殊的需求:python

爲了安全, 設計一個函數或者裝飾器, 而後用戶在 "定義/調用" 函數時, 只能訪問到咱們容許的內置變量和全局變量

經過例子來這解釋下上面的需求:segmentfault

a = 123
def func():
    print  a
    print id(a)

func()   

# 輸出
123
32081168

函數功能簡單明瞭, 對於結果, 你們應該也不會有太大的異議:func分別是取得全局命名空間a的值和使用內置命名空間中的函數id獲取了a的地址. 熟悉Python的童鞋, 對於LEGB確定也是不陌生的,也正是由於LEGB才讓函數func輸出正確的結果. 可是這個只是一個常規例子, 只是用來拋磚引玉而已. 咱們真正想要討論的是下面的例子:安全

# 裝飾函數
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確定不能直接傻傻的就去執行, 由於咱們不能相信每一個用戶都是誠實善良的, 也不能相信每一個用戶編寫的模塊或者函數是符合咱們的行爲標準規範. 因此咱們得有些措施去防範這些事情, 咱們能作的大概也就下面幾種方式:框架

1.在用戶通知`main.py`時有新模塊加入而且要求執行函數時, 先對模塊的代碼作檢查, 不符合標準或者帶有危險代碼的拒絕加載.
2.控制好`內置命名空間`和`全局命名空間`, 使其只能用容許使用的內容

在方案1, 其實也是咱們最容易想到的方法, 可是這個方法的成本仍是比較高, 由於咱們須要將可能出現的錯誤代碼或者關鍵詞,所有寫成一套規則, 並且這套規則還很大可能會誤傷, 不過也可能業界已經有相似的成熟的方案, 只是我還沒接觸到而已.
因此咱們只能用方案2的方法, 這種方法在咱們看來, 是成本比較低的, 也比較容易控制的, 由於這就和防火牆同樣, 咱們只放行咱們容許的事物.函數

具體實現

實現方案2最大的問題就是, 如何控制內置命名空間全局命名空間
咱們第一個想法確定就是覆蓋它們, 由於咱們都知道不論是內置命名空間仍是全局命名空間, 都是經過字典的形式在維護:學習

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, 實現全局命名空間的修改.例如:測試

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, 就變相實現了咱們定義的操做, 這其實也能用於不少但願可以動態賦值的需求場景! 好比說, 我不肯定有多少個變量, 但願經過一個變量名列表, 動態生成這些變量, 在這種狀況下, 就能參考這種方法, 不過仍是但願謹慎使用, 由於修改了這個, 就是就修改了全局命名空間.ui

好了, 迴歸到本文, 咱們已經知道經過globals函數可以表明全局命名空間, 可是爲何內置命名空間要用globals()['__builtins__'].__dict__來表示? 其實這個和python自身的機制有關, 由於模塊在編譯和初始化的過程當中, 內置命名空間就是以這種形式,寄放在全局命名空間:this

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())都已經找到對應對象了, 那咱們下一步就應該是想法將這兩個空間替換成咱們想要的.

# 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, 這樣的話, 就有辦法可以限制全局命令空間了:

# 僞代碼

# 裝飾函數
def wrap(f):
    # 調用用戶傳入的函數
    .... 修改全局命名空間
    f()
    .... 還原全局命名空間

a = 123

# 用戶自定義函數
def func():
    import os
    print os.listdir('.')

wrap(func)

爲何我只寫僞代碼, 由於我發現這個功能實現起來是很是蛋疼! 緣由就是, 在實現以前, 咱們必需要解決幾個問題:

1.全局命名空間對應了一個字典, 因此若是咱們想要修改, 只能從修改這個字典自己, 因而先清空再定義成咱們約束的, 調用完以後, 又得反過來恢復, 這些操做是十分之蛋疼.
2.涉及到共享的問題, 若是這個用戶函數處理好久, 並且是多線程的, 那麼整個模塊都會變得很不穩定, 甚至稱爲"污染"

那就先撇開不講, 講講內置命名空間, 剛纔咱們已經找到了能表明內置命名空間的對象, 很幸運的是, 這個是"真的可以摸獲得"的, 那咱們試下直接就賦值個空字典, 看會怎樣:

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__!

print globals()['__builtins__'].__dict__

# 輸出
{'bytearray': <type 'bytearray'>, 'IndexError': <type 'exceptions.IndexError'>....} # 省略

因此咱們真正要覆蓋的, 是這個字典纔對, 因此上面的代碼要改爲:

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庫, 這個庫是幹嗎呢? 簡單來講就是用來自省. 它提供四種用處:

1.對是不是模塊,框架,函數等進行類型檢查。
2.獲取源碼
3.獲取類或函數的參數的信息
4.解析堆棧

在這裏, 咱們須要用到第二個功能, 其他的功能, 感興趣的童鞋能夠去谷歌學習哦, 也能夠參考: https://my.oschina.net/taisha...
除了inspect, 咱們還須要用到exec, 這也是一大殺器, 能夠先參考這個學習下: http://www.mojidong.com/pytho...

方法大體的過程就是如下幾步:

1.根據用戶傳入的func對象, 利用inspect取出對應的源碼
2.經過exec利用源碼而且傳入全局命名空間, 從新編譯

代碼:

# 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, 並且當funcimport時, 直接報錯! 這樣就能知足咱們的變態慾望了~ 嘿嘿!,

關於代碼運行原理, 其實在關鍵部位的代碼, 都已經加了註釋, 可能在exec那部分會比較迷惑, 但其實你們將對應的變量代入字符串就能懂了, 替換以後, 其實也就是函數的定義+執行, 能夠經過print '%s \n%s()' % (source, f.func_name)幫助理解.然後面的字典, 也就是咱們一直很糾結的全局命名空間, 其中內置命名空間也被人爲定義了, 因此可以達到咱們想要的效果了!

這種只是一種拋磚引玉, 讓有相似場景需求的童鞋, 有個參考的方向, 也歡迎分享大家實現的方案, 嘿嘿!

歡迎各位大神指點交流,轉載請註明來源: https://segmentfault.com/a/11...

相關文章
相關標籤/搜索