Golang私房菜Python私房菜

Python跨服务传递作用域的坑

2021-11-06  本文已影响0人  simpleapples

背景

在一个古老的系统中,有这样一段代码:

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(),应该就可以跑通了。

优化作用域更新逻辑

到这里问题的根源已经搞清了:

如文档所述,函数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

上一篇下一篇

猜你喜欢

热点阅读