Python跨服務傳遞作用域的坑 背景 作用域跨服務傳遞問題 優化作用域更新邏輯 參考文檔

背景

在一個古老的系統中,有這樣一段代碼:

scope = dict(globals(), **locals())
exec(
"""
global_a = 123
def func_a():
    print(global_a)
"""
, scope)
exec("func_a()", scope)

第一段用戶代碼定義了函數,第二段用戶代碼執行函數(不要問爲什麼這麼做,因爲用戶永遠是正確的)。第一個代碼段執行後,func_a和global_a都會被加入作用域scope,由於第二個代碼段也使用同一個scope,所以第二個代碼段調用func_a是可以正確輸出123的。

但是使用exec執行用戶代碼畢竟不優雅,也很危險,於是把exec函數封裝在了一個Python沙箱環境中(簡單理解就是另一個Python服務,將code和scope傳給這個服務後,服務會在沙箱環境調用exec(code,scope)執行代碼),相當於每一次對exec調用都替換成了對沙箱服務的RPC請求。

於是代碼變成了這個樣子:

scope = dict(globals(), **locals())
scope = call_sandbox(
"""
global_a = 123
def func_a():
    print(global_a)
"""
, scope)
call_sandbox("func_a()", scope)

作用域跨服務傳遞問題

由於多次RPC調用需要使用同一個作用域,所以沙箱服務返回了新的scope,以保證下次調用時作用域不會丟失。但是執行代碼會發現第二次call_sandbox調用時候,會返回錯誤:

global name 'global_a' is not defined

首先懷疑第一次調用後scope沒有更新,但是如果scope沒有更新,應該會報找不到func_a纔對,這個報錯說明,第二次調用時候,作用域裏的func_a是存在的,但是func_a找不到變量global_a。通過輸出第二次call_sandbox前的scope,會發現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也印證了這個結論。在func_a裏獲取並輸出一下globals()和locals():

def func_a():
    inner_scope = dict(globals(), **locals()
    print(inner_scope.keys())
    # ['__builtins__']

可以看到在func_a外作用域是正常的,但是func_a內的作用域就只有builtins了,相當於作用域被清空了。猜測是函數的caller指向的是沙箱環境內的作用域,當scope回傳回來後,caller沒有更新,所以在函數內找不到函數外的作用域,查看一下Python函數的魔術方法:

發現有一個globals變量,指向的就是所在作用域,相當於函數的caller,通過如下代碼驗證調用沙箱服務後的scope裏的func_a的globals是否和當前作用域的一樣:

scope["func_a"].__globals__ == globals()  # False

確實不一樣,接下來試試把scope["func_a"].globals置爲globals(),應該就可以跑通了。

優化作用域更新邏輯

到這裏問題的根源已經搞清了:

  • 第一個exec語句和第二個exec語句分別在Python服務A和B中執行,第一個exec語句中定義的func_a所在的作用域是服務A(func_a.globals == A)
  • 在scope回傳到服務B後,global_a和func_a被拷貝到了服務B所在作用域,但是func_a.globals還是指向服務A的作用域,所以出現可以調用到func_a但在func_a裏找不到global_a
  • 將func_a.globals置爲B,就可以使代碼在服務B正確執行

如文檔所述,函數globals是一個只讀變量,所以不能直接賦值,需要通過拷貝函數的方式實現,定義一個拷貝函數的方法:

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

更新調用沙箱後回傳的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都可以正常執行,問題解決。

參考文檔

https://docs.python.org/3/reference/datamodel.html

https://stackoverflow.com/questions/49076566/override-globals-in-function-imported-from-another-module

https://stackoverflow.com/questions/2904274/globals-and-locals-in-python-exec/2906198

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章