首页 / 资讯中心 / 文章详情

拒绝eval陷阱:用AST白名单构建Python安全表达式求值器

拒绝eval陷阱:用AST白名单构建Python安全表达式求值器 ★ FEATURED ARTICLE
写这篇文章的念头来源于我自己做过的一个小项目。当时要做个内部用的数据筛选工具产品同学提了个需求让运营能自己填条件表达式比如price * quantity 1000 and status paid系统拿到之后按条件筛数据。第一版我很自然写了eval()功能倒是秒切但某天做安全自查盯着那行代码越想越不对劲——一个能执行任意Python代码的函数暴露给非技术用户简直是在生产环境埋雷。于是才有了后面用抽象语法树AST重写求值器的过程也就是这篇文章想聊透的东西。无论你是做低代码平台、规则引擎、报表系统还是只是想在脚本里少冒险只要写过或想过用eval处理用户输入的表达式这篇文章应该能帮你省不少排查时间。我会把为什么要避开eval、AST 是怎么工作的、白名单校验怎么设计、两条求值路线的取舍以及实际落地时的边界问题都过一遍。1. 为什么运行字符串代码是危险动作eval的安全红线1.1 eval看起来很方便需求起点先回到那个让我后怕的场景。运营同学要填的表达式本质上是一个字符串比如expression price * quantity 1000 and status paid如果用eval来求值代码特别短result eval(expression)前提是price、quantity、status这些变量必须在当前作用域里可访问。通常的做法是构造一个字典传进去scope {price: 12.5, quantity: 80, status: paid} result eval(expression, {__builtins__: {}}, scope)这样看似把__builtins__禁掉了还能用吗可以跑但问题是——你以为的沙箱在Python里根本经不起推敲。1.2 从eval口中逃出去的数据代码执行的三条路径先说最简单也最直白的一条Python在求值eval的时候是把整个字符串作为Python源代码片段来编译执行。只要表达式里能访问到任何对象就可以沿着属性链一路摸到系统层。举个例子正常用户会输入price * quantity但恶意用户可能输入形如__import__(os).system(...)的表达式。如果__builtins__没关干净这一句可以直接调用系统命令就算把__builtins__关成空字典攻击者还可以借助字符串对象的类通过的__class__拿到类型对象再沿__bases__回溯到object接着用__subclasses__()找到某个能读写文件或执行命令的类。整个过程不需要任何导入语句纯粹靠对象→类→基类→所有子类这条属性链就够了。这种技术在安全圈有个很形象的说法Python对象属性穿越。第二条路径是资源耗尽型攻击。不需要真的执行系统命令输入一个10 ** 10 ** 10这样的表达式Python在求值时会尝试计算一个巨大无比的大整数CPU和内存瞬间被打满。这类攻击在规则引擎场景里特别容易被忽略因为大家的目光都盯着能不能执行命令没人想到一个数学表达式也能把服务拖垮。第三条路径是信息探测与越权。open(/etc/passwd).read()、__import__(os).listdir(.)等表达式可以让没有权限的人读到不该读的东西。哪怕只是读出文件列表对某些敏感系统来说已经是事故了。问题的根源在于eval把表达式求值和代码执行这两件事完全合并了。它没有一个中间层可以让你检查这句代码到底在干什么你只能看到一个字符串。而字符串这种形态是无法可靠防御的——同一个意图可以被编码成无数种字符串变体你每堵一个洞攻击者换一种写法又进来了。这就是为什么后来我下定决定不再用黑名单式过滤而是借助AST从结构上做限制。2. 抽象语法树AST把表达式变成一棵可以审阅的树2.1 ast.parse与modeeval从字符串到结构数据Python标准库里有个模块叫ast它的作用是把源代码字符串解析成一棵结构化的树。树上的每个节点代表源代码里的一个语法要素比如数字、变量名、运算符、函数调用等等。我们不需要直接去操作这棵树但可以看它。先看一个最简单的例子import ast code 1 2 * 3 tree ast.parse(code, modeeval) print(ast.dump(tree, indent2))输出大概是这样的结构Expression( bodyBinOp( leftConstant(value1), opAdd(), rightBinOp( leftConstant(value2), opMult(), rightConstant(value3)))注意modeeval这个参数它表示我们只解析一个表达式而不是一段完整的语句。对于这个应用场景来说这一点很重要我们的用户输入应该是一个表达式而不是一段代码。如果允许完整的语句模式那就可以写x 1; print(...)之类的东西攻击面又扩大了。AST看起来抽象其实可以用一棵树来想象。根节点是Expression下面挂着一个BinOp二元运算节点它有三个子节点左边的操作数、中间的运算符、右边的操作数。BinOp的右边又是一个BinOp因为乘法的优先级更高。整棵树的叶子节点是数字常量或者变量名内部节点是各种运算符和语法结构。2.2 常见的AST节点清单认识自己要审核的对象在做安全求值之前需要先知道我们可能遇到哪些节点类型。我挑几个最常见的列一下这同时也是后面白名单设计的基础节点类型语法示例说明Constant123、abc、True字面量常量数字、字符串、布尔值都算Nameprice、status变量名对应负载数据里的字段BinOpa b、a * b / c二元算术运算内部还有op子节点UnaryOp-x、not x一元运算负号、逻辑非等Comparea b、x y比较运算可能连着多个比较符BoolOpa and b、a or b布尔逻辑运算IfExpa if cond else b三元条件表达式Callfoo(a, b)函数调用注意它的func子节点可以是一个属性访问链Attributeobj.attr属性访问如os.systemSubscripta[0]、d[key]下标访问如lst[0]Lambdalambda x: x 1匿名函数定义ListComp[x for x in y]列表推导式不看这棵树的时候price * quantity 1000就是一串字符。当你把它解析成AST它变成了一种结构化数据我们就可以像审阅代码一样逐个节点检查甚至可以直接在遍历过程中完成求值。2.3 审阅和执行是两回事AST赋予的能力AST最大的价值是把审阅和执行拆开了。用eval的时候字符串一步到位直接执行中间没有审查机会。而拿到AST之后我们至少有三个选择第一先审查再执行。完整遍历AST判断每个节点的类型是否在白名单里全部通过之后再交给compile和eval去执行。这是比较经典的做法。第二不执行自己翻译。完全不用Python的执行机制而是自己写一个解释器逐节点遍历AST用自己的代码把加法乘法比较这些操作算出来。这种方法更慢但安全边界最清晰因为你根本没让Python虚拟机去解释恶意代码只是把AST当数据在遍历。第三审查和翻译混合。在遍历求值的过程中对每个节点做白名单判断遇到不在允许列表中的节点立即抛异常。我在实际项目里采用的是混合方案自己写遍历逻辑逐节点校验逐节点计算。后面会详细展开。先记住一个结论只要我们能在执行之前看到每个原子操作安全策略就能从禁掉所有恶意变成只开放已知善意后者才是真正可行的。3. 安全求值的核心思路白名单节点校验3.1 黑名单永远漏白名单默认拒绝两条路线的对比很多人第一次听到AST方案第一反应是那我做个黑名单把__import__、open、os、system这些敏感词全过滤掉不就行了这话听上去合理实操中却处处是坑。字符串过滤的黑名单是枚举坏人但坏人永远在变着花样出现。拿__import__来说过滤了它攻击者可以用getattr(__builtins__, __imp ort__)这种拼接写法绕过去过滤了__builtins__还有globals()、vars()、dir()可以拿到运行时信息就算你把所有内置函数都禁了还能通过对象属性链路找到os。你每堵一个洞都要写一条规则规则越来越多误杀也越来越多而且攻击面根本堵不完。白名单的思路完全不同默认拒绝所有节点只有我明确允许的类型才放行。比如我只允许数字、变量名、算术运算符、比较运算符、逻辑运算符、条件表达式那么就算攻击者费尽心思构造了一长串字符串只要最终的AST里出现了Attribute属性访问或者Call函数调用就直接拒绝——根本不需要关心恶意字符串具体长什么样。黑名单是列出所有不能做的事白名单是列出所有能做的事。在安全领域白名单是唯一可靠的方向。原因很简单可做的事情是有限的不可做的事情是无限的。3.2 我允许哪些节点求值器的宪法以我那个数据筛选工具为例业务需要的表达式能力其实很窄四则运算和取余 - * / %比较运算 !布尔运算and or not三元条件a if cond else b取负、括号改变优先级少量纯函数abs、min、max、round、len、sum对应的AST白名单如下允许的节点ast.Expression根节点本身ast.Constant数字、字符串、布尔值、Noneast.Name变量名注意ID必须来自用户数据字段不能是任意的ast.BinOp二元运算ast.UnaryOp一元运算ast.Compare比较运算ast.BoolOp布尔逻辑ast.IfExp三元表达式允许的运算符节点作为op子节点ast.Add、ast.Sub、ast.Mult、ast.Div、ast.Mod、ast.FloorDivast.Eq、ast.NotEq、ast.Lt、ast.LtE、ast.Gt、ast.GtEast.And、ast.Or、ast.Not一律拒绝的节点ast.Attribute任何点出来的属性访问比如__class__、os.systemast.Subscript下标访问比如__subclasses__()[0]ast.Call默认拒绝所有函数调用但会单独开放白名单函数见第5章ast.Lambda、ast.ListComp、ast.DictComp、ast.SetComp、ast.GeneratorExpast.Import、ast.ImportFrom导入语句有个容易忽略的点Callback和Attribute往往是一起出现的。如果放行了Call但把Attribute禁了那攻击者用os.system(...)这条路就断了但如果不小心放行了Attribute再放行Call就等于把Python完全敞开了。所以我把它们放在不同的层级分别控制等你真的需要开放自定义函数时只开放白名单函数的Call而Attribute依旧保持拒绝。这里补一句自己的经验写白名单校验时不要试图允许大多数拒绝少数而是从空集开始一点点加。第一次跑你的回归测试你会遇到一堆被拒的合理表达式再逐个添加允许项。这个过程看起来麻烦但每加一个节点你都能想清楚我为什么要允许它而不是糊里糊涂把整片树林放进来。4. 两条求值路线受限exec与自建解释器4.1 路线一校验后用compile和受限命名空间这是网上最多人推荐的方案先用AST校验一遍确认没有危险节点然后把编译后的代码交给eval执行。import ast ALLOWED_NODES ( ast.Expression, ast.Constant, ast.Name, ast.BinOp, ast.UnaryOp, ast.Compare, ast.BoolOp, ast.IfExp, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod, ast.FloorDiv, ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.And, ast.Or, ast.Not, ) class NodeChecker(ast.NodeVisitor): def visit(self, node): if not isinstance(node, ALLOWED_NODES): raise ValueError(f禁止的AST节点: {type(node).__name__}) self.generic_visit(node) def safe_eval(expr, env): tree ast.parse(expr, modeeval) NodeChecker().visit(tree) code compile(tree, safe, eval) return eval(code, {__builtins__: {}}, env)这段代码确实能挡掉绝大多数不需要的场景。它的思路是校验器先行执行器后行。但有个隐藏风险需要拎出来说eval内部运行的是完整的CPython字节码解释器AST校验只是在执行前设了一道关卡。万一校验器有漏洞哪怕漏掉一种节点后面就全面失守。而且当你需要开放Call节点时eval的执行路径里依然可以调用到一些危险内置函数除非你把__builtins__完全清空并且显式地把白名单函数放进环境里。清空__builtins__之后普通表达式其实已经很难执行系统命令了。可要是你开放了Call攻击者仍然可以利用已知函数返回对象→再沿属性链访问的方式来增强能力。属性穿越的链条有时不是一步的比如你允许调用getattr那它就能间接拿到任意属性。因此只要你用了eval就无法真正做到只解释我想要的语义只能做到在入口处挡住大部分人。4.2 路线二遍历AST手写求值器只解释白名单内的节点我自己最终走的是第二条路不调用eval而是自己写一个小的解释器遍历AST节点用自己的代码逐节点计算结果。核心思想是我不让Python虚拟机去解释用户的表达式我只是把AST当作一棵普通的数据树来遍历。简化版实现如下class SafeEvaluator: def __init__(self, funcsNone): self.funcs funcs or {} self.node_count 0 self.max_nodes 1000 self.max_depth 50 def evaluate(self, expr, env): tree ast.parse(expr, modeeval) self.node_count 0 self._check_depth(tree, 0) return self._eval(tree.body, env) def _check_depth(self, node, depth): if depth self.max_depth: raise ValueError(表达式嵌套过深) for child in ast.iter_child_nodes(node): self._check_depth(child, depth 1) def _eval(self, node, env): self.node_count 1 if self.node_count self.max_nodes: raise ValueError(表达式节点数超限) if isinstance(node, ast.Constant): return node.value if isinstance(node, ast.Name): if node.id not in env: raise NameError(f未知变量: {node.id}) return env[node.id] if isinstance(node, ast.BinOp): left self._eval(node.left, env) right self._eval(node.right, env) return self._apply_bin_op(node.op, left, right) if isinstance(node, ast.UnaryOp): operand self._eval(node.operand, env) return self._apply_unary_op(node.op, operand) if isinstance(node, ast.Compare): left self._eval(node.left, env) for op, comparator in zip(node.ops, node.comparators): right self._eval(comparator, env) if not self._apply_compare(op, left, right): return False left right return True if isinstance(node, ast.BoolOp): values [self._eval(value, env) for value in node.values] if isinstance(node.op, ast.And): return all(values) if isinstance(node.op, ast.Or): return any(values) if isinstance(node, ast.IfExp): condition self._eval(node.test, env) if condition: return self._eval(node.body, env) else: return self._eval(node.orelse, env) if isinstance(node, ast.Call): func node.func if not isinstance(func, ast.Name): raise ValueError(仅支持直接调用的白名单函数) if func.id not in self.funcs: raise ValueError(f函数未授权: {func.id}) args [self._eval(arg, env) for arg in node.args] return self.funcs[func.id](*args) raise ValueError(f禁止的AST节点: {type(node).__name__}) def _apply_bin_op(self, op, left, right): if isinstance(op, ast.Add): return left right if isinstance(op, ast.Sub): return left - right if isinstance(op, ast.Mult): return left * right if isinstance(op, ast.Div): return left / right if isinstance(op, ast.Mod): return left % right if isinstance(op, ast.FloorDiv): return left // right raise ValueError(f禁止的二元运算符: {type(op).__name__}) def _apply_unary_op(self, op, operand): if isinstance(op, ast.UAdd): return operand if isinstance(op, ast.USub): return -operand if isinstance(op, ast.Not): return not operand raise ValueError(f禁止的一元运算符: {type(op).__name__}) def _apply_compare(self, op, left, right): if isinstance(op, ast.Eq): return left right if isinstance(op, ast.NotEq): return left ! right if isinstance(op, ast.Lt): return left right if isinstance(op, ast.LtE): return left right if isinstance(op, ast.Gt): return left right if isinstance(op, ast.GtE): return left right raise ValueError(f禁止的比较运算符: {type(op).__name__})这段代码核心的优点在于它只认识_eval里写死的节点类型。出现任何不在if isinstance分支里的节点直接走最后的raise ValueError。没有白名单概念就是默认拒绝一切。用eval的时候安全靠的是执行前的校验用自建求值器的时候安全靠的是解释器根本不认识其他节点。这个方案不是没有缺点。性能上纯Python逐节点解释肯定比原生字节码慢但架不住表达式本身都很短一个表达式也就几十个节点实际耗时可以忽略。可维护性上每次要支持新语法就得往_eval里加分支比直接写一行正则或eval复杂。但换来的是每次加分支都在做代码审查心智负担非常清楚。4.3 对比什么时候选哪种维度路线一校验eval路线二自建求值器安全边界依赖校验器正确性执行路径是完整CPython只解释白名单内节点天然隔离实现成本较低AST校验写完就可用较高所有运算逻辑都要自己写性能快接近原生执行慢一些但表达式场景足够扩展能力开放函数后风险高需要额外防御开放函数仍可控只解释明确节点调试难度出问题时需要同时查校验器和执行器出问题大概率在_eval一个函数里简单给个选型建议如果你只是在内部工具里处理可信度较高但不完全可信的输入且表达式能力要求比较多路线一就已经能挡住绝大多数攻击代码量也小。如果你做的是面向外部用户的低代码平台、规则引擎或者对安全性极其敏感那就选路线二虽然开发量大但安全边界是你自己维护的出了问题你能讲清楚哪些表达式是绝对执行不了的。5. 完整实现与边界打磨让求值器真正可用5.1 从数学表达式到筛选条件能力裁剪清单一个只支持四则运算的求值器放在真实业务里远远不够用。实际项目中至少需要这样几类能力变量取值Name节点要在env字典里查找字段。需要注意大小写敏感、字段不存在时给一个明确的NameError而不是悄悄返回None。不然用户拼错字段名筛选结果就是一堆空数据排查起来一头雾水。字符串字面量比较场景经常会写status paid这种条件所以要允许字符串常量。要注意ast.Constant会同时覆盖数字、字符串、布尔值、None所以Constant节点直接返回的值本身没什么风险。真正要注意的是比较运算时如果类型不匹配Python会抛TypeError这在筛选场景里最好转换成条件不成立而不是直接报错不然一条脏数据能让整个流程挂掉。白名单函数我建议单独维护一个FUNCS字典只放纯函数也就是传入参数返回结果不碰全局状态的函数FUNCS { abs: abs, min: min, max: max, round: round, len: len, sum: sum, }注意round、sum这类函数有默认参数或关键字参数上面代码里的_eval目前只处理了位置参数args。如果业务需要用到round(3.14159, 2)这种带两个位置参数的写法没问题但如果想写sum(xs, start0)这种关键字参数还得支持keywords字段。这个细节很容易踩坑建议一开始就决定好要么支持完整参数语法要么明确禁止关键字参数。我的做法是禁止因为用户没必要写那么复杂报错信息告诉他关键字参数不受支持就够了。再补充一个容易出问题的点函数返回值。如果业务逻辑需要函数返回值参与比较比如len(items) 5那items必须作为环境里的一个列表传进去。如果业务不允许用户看到完整数据那你在构建env时就要控制好能传什么、不能传什么。这个边界属于业务数据授权求值器是没有办法替你决定的。5.2 防资源耗尽表达式长度、AST节点数、运算强度当求值器面向不可信输入时除了执行不该执行的代码还要防让服务跑死。这里有几个非常实际的防护维度表达式长度限制。最简单的一层不允许超过200个字符。这个长度对正常业务表达式来说已经很宽裕了而对绝大多数攻击型输入来说一上来就被拦掉。AST节点数限制。上面代码里的self.max_nodes 1000就是在做这件事。攻击者可能用1111...造一个几千层的链解析成AST之后不断让求值器递归。设一个节点数上限超过就抛异常成本极低效果立竿见影。递归深度限制。节点数和深度是两个维度。一个右递归的表达式1(1(1...))层数极深但节点数可能没到上限这时候递归调用栈可能已经爆了。所以我在_check_depth里限制嵌套深度不超过50层正常表达式到20层都算很深了。运算强度限制。有些恶意的操作不需要执行系统命令但计算量巨大。比如10 ** 999999999虽然最终结果是个巨大的整数Python也能表示但CPU时间不可接受。最直接的办法是限制乘方运算的指数大小或者在_apply_bin_op里给Pow加一个指数过大就拒的规则又或者像上面代码一样干脆不开放Pow节点。我的建议是初期直接不开放乘方等真的需要时再加限制。执行总时长限制。更稳妥的兜底手段是把整个求值包在一个耗时上限内执行。可以用信号或者多进程做硬超时但表达式的耗时普遍在微秒级单线程函数内部塞一个线程定时器就够用。我的做法是解析校验求值整个链路用time.monotonic()记录开始时间超过200毫秒直接抛超时异常。这个兜底可以防止将来加新特性时不小心引入某个计算量爆炸的节点。5.3 回归测试把合法与恶意表达式都写进用例写完求值器之后最重要的一步是建一套回归用例。你会发现安全类需求特别依赖回归测试——今天把某个节点加进允许列表明天可能就会破坏一条安全承诺没有测试挡着根本发现不了。我习惯把用例分成三组。第一组是合法业务表达式# 合法用例 assert ev(price * quantity 1000, {price: 12.5, quantity: 80, status: paid}) is True assert ev((price 1) * 2 30, {price: 12.5}) is True assert ev(status paid and amount 500, {status: paid, amount: 880}) is True assert ev(len(items) 2 and sum(counts) 100, {items: [a, b], counts: [50, 60]}) is True assert ev(VIP if amount 1000 else Normal, {amount: 1500}) VIP第二组是应该抛异常的恶意表达式# 非法用例 with pytest.raises(Exception): ev(__import__(os).system(echo pwned), {}) with pytest.raises(Exception): ev(open(secret.txt).read(), {}) with pytest.raises(Exception): ev(().__class__.__bases__[0].__subclasses__(), {}) with pytest.raises(Exception): ev([x for x in range(100)], {}) with pytest.raises(Exception): ev(lambda x: x 1, {}) with pytest.raises(Exception): ev(globals(), {})第三组是资源耗尽型用例建议用随机化的方式多构造几个变体不要只写死一个字符串。安全测试最忌讳只测我知道的恶意输入攻击者并不会按你的用例来。这里补充一个实操体会恶意用例不要只测试报不报错还要测试到底在哪一层被拦下来。比如__import__(os).system(echo x)被拦是因为Attribute节点被禁止了而open(x)被拦是因为Call节点里函数名不在白名单。把测试断言细到这个程度以后代码重构换了一种拦截方式你也能立刻知道安全边界有没有发生变化。6. 比AST更远一步从表达式求值到规则引擎的扩展6.1 扩张场景把求值器当条件引擎用一个能安全求值的工具价值绝不仅限于算数。我在实际项目里把它扩展成了一个小的条件引擎给每一条记录绑定一个环境字典求值器只负责返回布尔值外层代码根据布尔值决定保留还是过滤这条数据。假设有一批订单数据orders [ {id: A001, amount: 2300, status: paid, count: 3}, {id: A002, amount: 800, status: pending, count: 1}, ] def filter_orders(expr): ev SafeEvaluator(FUNCS) return [ order for order in orders if ev.evaluate(expr, order) ]运营同学就可以写amount 1000 and status paid来筛出订单。这个模式能直接套到工单筛选、告警规则、动态报表等场景。关键点是外层代码是可信的求值器只处理不可信的表达式串两者之间的边界就是env里放了哪些字段、FUNCS里注册了哪些函数。你控制好这两个入口往业务上扩展就非常安全。再往外扩一层可以把函数白名单做得更业务化。比如在FUNCS里加days_between(date1, date2)、in_week(day)让非技术用户也能写近7天内的数据这种规则。每一步扩展都要做三件事实现函数、加到白名单、补测试用例。这样做虽然慢但安全边界始终清晰。6.2 替换方案对比自写AST vs 第三方语法解析器很多读者会问既然自己写AST求值器要维护这么多分支不如直接上第三方语法解析库比如pyparsing、lark这是个好问题我给自己的参考判断是第三方词法/语法解析库适合表达式语法越来越复杂的情况。比如需要支持中缀、前缀、后缀混合语法或者需要自定义语法规则这时候用lark定义一套你自己的表达式语法让语义尽量简单是长期维护收益更高的选择。而且语法解析库天然会生成自己的AST你可以严格按自己定义的语法来解释安全边界比解析Python语法再过滤更清晰。但它的缺点也很明显学习成本高语法文件本身也是一个需要仔细审查的代码而且很多解析库生成的树节点比Python原生AST抽象得多你需要重新映射回业务语义。对于用户输入Python风格表达式这个场景Python标准库的ast反而是最自然的中间表示不需要引入额外依赖。我的取舍标准是如果表达式能力保持在运算比较逻辑少量函数这个级别标准库ast完全够用维护成本低社区资料多。如果你开始需要字段名用中文别名“语法做前缀式简写”“支持中括号数组字面量”这类自定义语法那才值得换lark或pyparsing。过早引入解析生成器只会让你同时维护两套复杂系统。最后分享一个自己踩过的经验教训。有一个线上小工具当初图省事直接eval(expression, {__builtins__: {}}, data)上线了功能正常了几个月没人发现问题。直到有一天某位内部用户为了调试方便提交了一个带__builtins__恢复代码的表达式虽然没造成实质破坏但那次之后我彻底决定重写。印象最深的是安全改造最难的不是写代码而是说服自己当前看起来没问题不代表边界就是安全的。eval是这么一种东西它能正常运行的时候恰恰是最危险的信号——因为你永远不知道下一次输入会是什么。用AST重写之后我才真正睡得踏实不是因为我堵住了所有已知漏洞而是因为从结构上我们不认识的代码根本不可能被执行。如果你手头也有类似的字符串求值场景我的建议很简单先别急着上AST先用十分钟想想你允许用户输入什么、环境里放了什么数据、开放了哪些能力。把这三个问题想清楚AST方案写起来其实很快。反之这三个问题含糊任何技术方案都救不了你。
阅读完成 · 觉得有帮助?
咨询建站