
背景
在一個古老的系統(tǒng)中,有這樣一段代碼:
scope = dict(globals(), **locals())
exec(
"""
global_a = 123
def func_a():
print(global_a)
"""
, scope)
exec("func_a()", scope)
第一段用戶代碼定義了函數(shù),第二段用戶代碼執(zhí)行函數(shù)(不要問為什么這么做,因為用戶永遠是正確的)。第一個代碼段執(zhí)行后,func_a和global_a都會被加入作用域scope,由于第二個代碼段也使用同一個scope,所以第二個代碼段調(diào)用func_a是可以正確輸出123的。
但是使用exec執(zhí)行用戶代碼畢竟不優(yōu)雅,也很危險,于是把exec函數(shù)封裝在了一個Python沙箱環(huán)境中(簡單理解就是另一個Python服務(wù),將code和scope傳給這個服務(wù)后,服務(wù)會在沙箱環(huán)境調(diào)用exec(code,scope)執(zhí)行代碼),相當于每一次對exec調(diào)用都替換成了對沙箱服務(wù)的RPC請求。
于是代碼變成了這個樣子:
scope = dict(globals(), **locals())
scope = call_sandbox(
"""
global_a = 123
def func_a():
print(global_a)
"""
, scope)
call_sandbox("func_a()", scope)
作用域跨服務(wù)傳遞問題
由于多次RPC調(diào)用需要使用同一個作用域,所以沙箱服務(wù)返回了新的scope,以保證下次調(diào)用時作用域不會丟失。但是執(zhí)行代碼會發(fā)現(xiàn)第二次call_sandbox調(diào)用時候,會返回錯誤:
global name 'global_a' is not defined
首先懷疑第一次調(diào)用后scope沒有更新,但是如果scope沒有更新,應(yīng)該會報找不到func_a才對,這個報錯說明,第二次調(diào)用時候,作用域里的func_a是存在的,但是func_a找不到變量global_a。通過輸出第二次call_sandbox前的scope,會發(fā)現(xiàn)global_a和func_a都是存在的:
print(scope.keys())
# ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__file__', '__cached__',
# '__builtins__', 'global_a', 'func_a']
call_sandbox("func_a()", scope)
證明在第二次call_sandbox時,scope被正確的傳入了,沒有報找不到func_a也印證了這個結(jié)論。在func_a里獲取并輸出一下globals()和locals():
def func_a():
inner_scope = dict(globals(), **locals()
print(inner_scope.keys())
# ['__builtins__']
可以看到在func_a外作用域是正常的,但是func_a內(nèi)的作用域就只有builtins了,相當于作用域被清空了。猜測是函數(shù)的caller指向的是沙箱環(huán)境內(nèi)的作用域,當scope回傳回來后,caller沒有更新,所以在函數(shù)內(nèi)找不到函數(shù)外的作用域,查看一下Python函數(shù)的魔術(shù)方法:

發(fā)現(xiàn)有一個globals變量,指向的就是所在作用域,相當于函數(shù)的caller,通過如下代碼驗證調(diào)用沙箱服務(wù)后的scope里的func_a的globals是否和當前作用域的一樣:
scope["func_a"].__globals__ == globals() # False
確實不一樣,接下來試試把scope["func_a"].globals置為globals(),應(yīng)該就可以跑通了。
優(yōu)化作用域更新邏輯
到這里問題的根源已經(jīng)搞清了:
- 第一個exec語句和第二個exec語句分別在Python服務(wù)A和B中執(zhí)行,第一個exec語句中定義的func_a所在的作用域是服務(wù)A(func_a.globals == A)
- 在scope回傳到服務(wù)B后,global_a和func_a被拷貝到了服務(wù)B所在作用域,但是func_a.globals還是指向服務(wù)A的作用域,所以出現(xiàn)可以調(diào)用到func_a但在func_a里找不到global_a
- 將func_a.globals置為B,就可以使代碼在服務(wù)B正確執(zhí)行
如文檔所述,函數(shù)globals是一個只讀變量,所以不能直接賦值,需要通過拷貝函數(shù)的方式實現(xiàn),定義一個拷貝函數(shù)的方法:
import copy
import types
import functools
def copy_func(f, globals=None, module=None):
if globals is None:
globals = f.__globals__
g = types.FunctionType(f.__code__, globals, name=f.__name__,
argdefs=f.__defaults__, closure=f.__closure__)
g = functools.update_wrapper(g, f)
if module is not None:
g.__module__ = module
return g
更新調(diào)用沙箱后回傳的scope,如果scope中的value是一個function,就通過復制的方式更新它的globals為scope:
scope = dict(globals(), **locals())
scope = call_sandbox(
"""
global_a = 123
def func_a():
print(global_a)
"""
, scope)
for k, v in scope:
if isinstance(v, types.FunctionType):
scope[k] = copy_func(v, scope, __name__)
call_sandbox("func_a()", scope)
重新運行,兩個call_sandbox都可以正常執(zhí)行,問題解決。
參考文檔
https://docs.python.org/3/reference/datamodel.html
https://stackoverflow.com/questions/2904274/globals-and-locals-in-python-exec/2906198