背景
在一个古老的系统中,有这样一段代码:
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/2904274/globals-and-locals-in-python-exec/2906198