一文彻底搞懂前端沙箱(代码片段)

让前端飞 让前端飞     2022-11-28     585

关键词:

什么是“沙箱”

沙箱(Sandbox)[1]

也称作:“沙箱/沙盒/沙盘”。沙箱是一种安全机制,为运行中的程序提供隔离环境。通常是作为一些来源不可信、具破坏力或无法判定程序意图的程序提供实验之用。沙箱能够安全的执行不受信任的代码,且不影响外部实际代码影响的独立环境。

有哪些动态执行脚本的场景?

在一些应用中,我们希望给用户提供插入自定义逻辑的能力,比如 Microsoft 的 Office 中的 VBA,比如一些游戏中的 lua 脚本,FireFox 的「油猴脚本」,能够让用户发在可控的范围和权限内发挥想象做一些好玩、有用的事情,扩展了能力,满足用户的个性化需求。

大多数都是一些客户端程序,在一些在线的系统和产品中也常常也有类似的需求,事实上,在线的应用中也有不少提供了自定义脚本的能力,比如 Google Docs 中的 Apps Script,它可以让你使用 JavaScript 做一些非常有用的事情,比如运行代码来响应文档打开事件或单元格更改事件,为公式制作自定义电子表格函数等等。

与运行在「用户电脑中」的客户端应用不同,用户的自定义脚本通常只能影响用户自已,而对于在线的应用或服务来讲,有一些情况就变得更为重要,比如「安全」,用户的「自定义脚本」必须严格受到限制和隔离,即不能影响到宿主程序,也不能影响到其它用户。

另外,有一些牵扯「模板化」的前端框架,如Vue.js、Venom.js等都会用到动态代码执行。

JavaScript中的沙箱实现

零、几个基础知识什么是constructor

JavaScript中constructor属性指向创建当前对象的构造函数,该属性是存在原型里的,且是不可靠的 JavaScript中constructor属性[2]

function test() 
const obj = new test();
console.log(obj.hasOwnProperty(\'constructor\')); // false
console.log(obj.__proto__.hasOwnProperty(\'constructor\')); // true
console.log(obj.__proto__ === test.prototype); // true
console.log(test.prototype.hasOwnProperty(\'constructor\')); // true

/** constructor是不可靠的 */
function Foo() 
Foo.prototype = ;
const foo = new Foo();
console.log(foo.constructor === Object);  // true,可以看出不是Foo了

constructor也是一种用于创建和初始化class[3]创建的对象的特殊方法 Class构造方法[4]

几个典型的constructor:

(async function())().constructor === Promise

// 浏览器环境下
this.constructor.constructor === Function
window.constructor.constructor === Function

// node环境下
this.constructor.constructor === Function
global.constructor.constructor === Function
JS Proxy getPrototypeOf()

handler.getPrototypeOf()是一个代理方法,当读取代理对象的原型时,该方法就会被调用。语法:

const p = new Proxy(obj, 
  getPrototypeOf(target)  // target 被代理的目标对象。
  ...
  
);

当 getPrototypeOf 方法被调用时,this 指向的是它所属的处理器对象,getPrototypeOf 方法的返回值必须是一个对象或者 null。

在 JavaScript 中,有下面这五种操作(方法/属性/运算符)可以触发 JS 引擎读取一个对象的原型,也就是可以触发 getPrototypeOf() 代理方法的运行:

Object.getPrototypeOf()[5]Reflect.getPrototypeOf()[6]proto[7]Object.prototype.isPrototypeOf()[8]instanceof[9]

如果遇到了下面两种情况,JS 引擎会抛出 TypeError[10] 异常:

getPrototypeOf() 方法返回的不是对象也不是 null。目标对象是不可扩展的,且 getPrototypeOf() 方法返回的原型不是目标对象本身的原型

基本用法:

const obj = ;
const proto = ;
const handler = 
    getPrototypeOf(target) 
        console.log(target === obj);   // true
        console.log(this === handler); // true
        return proto;
    
;

var p = new Proxy(obj, handler); // obj是被代理的对象,也就是handler.getPrototypeOf的target参数
console.log(Object.getPrototypeOf(p) === proto);    // true


5 种触发 getPrototypeOf 代理方法的方式:

const obj = ;
const p = new Proxy(obj, 
    getPrototypeOf(target) 
        return Array.prototype;
    
);

console.log(
    Object.getPrototypeOf(p) === Array.prototype,  // true
    Reflect.getPrototypeOf(p) === Array.prototype, // true
    p.__proto__ === Array.prototype,               // true
    Array.prototype.isPrototypeOf(p),              // true
    p instanceof Array                             // true
);


两种异常的情况:

// getPrototypeOf() 方法返回的不是对象也不是 null
const obj = ;
const p = new Proxy(obj, 
    getPrototypeOf(target) 
        return "foo";
    
);
Object.getPrototypeOf(p); // TypeError: "foo" is not an object or null

// 目标对象是不可扩展的,且 getPrototypeOf() 方法返回的原型不是目标对象本身的原型
const obj = Object.preventExtensions(); // obj不可扩展
const p = new Proxy(obj, 
    getPrototypeOf(target) 
        return ;
    
);
Object.getPrototypeOf(p); // TypeError: expected same prototype value

// 如果对上面的代码做如下的改造就没问题
const obj = Object.preventExtensions(); // obj不可扩展
const p = new Proxy(obj, 
    getPrototypeOf(target)  // target就是上面的obj
        return obj.__proto__; // 返回的是目标对象本身的原型
    
);
Object.getPrototypeOf(p); // 不报错
一、跟浏览器宿主环境一致的沙箱实现构建闭包环境

我们知道在 JavaScript 中的作用域(scope)只有全局作用域(global scope)、函数作用域(function scope)以及从 ES6 开始才有的块级作用域(block scope)。如果要将一段代码中的变量、函数等的定义隔离出来,受限于 JavaScript 对作用域的控制,只能将这段代码封装到一个 Function 中,通过使用 function scope 来达到作用域隔离的目的。也因为需要这种使用函数来达到作用域隔离的目的方式,于是就有 IIFE(立即调用函数表达式),这是一个被称为“自执行匿名函数”的设计模式。

(function foo()
    const a = 1;
    console.log(a);
 )();// 无法从外部访问变量 
 
 console.log(a) // 抛出错误:"Uncaught ReferenceError: a is not defined"

当函数变成立即执行的函数表达式时,表达式中的变量不能从外部访问,它拥有独立的词法作用域。不仅避免了外界访问 IIFE 中的变量,而且又不会污染全局作用域,弥补了 JavaScript 在 scope 方面的缺陷。一般常见于写插件和类库时,如 JQuery 当中的沙箱模式

(function (window) 
    var jQuery = function (selector, context) 
        return new jQuery.fn.init(selector, context);
    
    jQuery.fn = jQuery.prototype = function () 
        //原型上的方法,即所有jQuery对象都可以共享的方法和属性
    
    jQuery.fn.init.prototype = jQuery.fn;
    window.jQeury = window.$ = jQuery; //如果需要在外界暴露一些属性或者方法,可以将这些属性和方法加到window全局对象上去
)(window);

当将 IIFE 分配给一个变量,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。

const result = (function () 
    const name = "张三";
    return name;
)();

console.log(result); // "张三"
原生浏览器对象的模拟

模拟原生浏览器对象的目的是为了防止闭包环境,操作原生对象,篡改污染原生环境,完成模拟浏览器对象之前我们需要先关注几个不常用的 API。

eval

eval 函数可将字符串转换为代码执行,并返回一个或多个值:

const b = eval("(name:\'张三\')");
console.log(b.name);

由于 eval 执行的代码可以访问闭包和全局范围,因此就导致了代码注入的安全问题,因为代码内部可以沿着作用域链往上找,篡改全局变量,这是我们不希望的。

console.log(eval( this.window === window )); // true 

补充几个点:

性能&安全问题,一般不建议在实际业务代码中引入eval辅助异步编程框架的windjs大量采用eval的写法来辅助编程,引发争议 专访 Wind.js 作者老赵(上):缘由、思路及发展[11]浏览器环境下,(0, eval)()比eval()的性能要好「目前已经不是了」(0, eval)(‘this’)[12]

const times = 1000;
const time1 = \'直接引用\';
const time2 = \'间接引用\';

let times1 = times;
console.time(time1);
while(times1--) 
    eval(`199 + 200`);

console.timeEnd(time1);

let times2 = times;
console.time(time2);
while(times2--) 
    (0, eval)(`199 + 200`);

console.timeEnd(time2);

new Function

Function构造函数创建一个新的 Function 对象。直接调用这个构造函数可用于动态创建函数。

new Function ([arg1[, arg2[, ...argN]],] functionBody) 

arg1, arg2, ... argN 被函数使用的参数的名称必须是合法命名的。参数名称是一个有效的 JavaScript 标识符的字符串,或者一个用逗号分隔的有效字符串的列表,例如“×”,“theValue”,或“a,b”。

补充几个点:

new Function()性能一般比eval要好,很多用到这块的前端框架都是用new Function()实现的,比如:Vue.js打开浏览器控制台后,new Function()的性能要慢一倍以上

functionBody
一个含有包括函数定义的 JavaScript 语句的字符串。

const sum = new Function(\'a\', \'b\', \'return a + b\'); 
console.log(sum(1, 2));//3 

同样也会遇到和 eval 类似的的安全问题和相对较小的性能问题。

let a = 1;

function sandbox() 
    let a = 2;
    return new Function(\'return a;\'); // 这里的 a 指向最上面全局作用域内的 1


const f = sandbox();
console.log(f());

与 eval 不同的是 Function 创建的函数只能在全局作用域中运行,它无法访问局部闭包变量,它们总是被创建于全局环境,因此在运行时它们只能访问全局变量和自己的局部变量,不能访问它们被 Function 构造器创建时所在的作用域的变量。new Function()是 eval()更好替代方案。它具有卓越的性能和安全性,但仍没有解决访问全局的问题。

with

with 是 JavaScript 中一个关键字,扩展一个语句的作用域链。它允许半沙盒执行。那什么叫半沙盒?语句将某个对象添加到作用域链的顶部,如果在沙盒中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值。如果沒有同名的属性,则将拋出 ReferenceError。

// 严格模式下以下代码运行会有问题

function sandbox(o) 
    with (o)
        //a=5; 
        c=2;
        d=3;
        console.log(a,b,c,d); // 0,1,2,3 //每个变量首先被认为是一个局部变量,如果局部变量与 obj 对象的某个属性同名,则这个局部变量会指向 obj 对象属性。
    


const f = 
    a:0,
    b:1

sandbox(f); 
      
console.log(f);
console.log(c,d); // 2,3 c、d被泄露到window对象上

究其原理,with在内部使用in运算符。对于块内的每个变量访问,它都在沙盒条件下计算变量。如果条件是 true,它将从沙盒中检索变量。否则,就在全局范围内查找变量。但是 with 语句使程序在查找变量值时,都是先在指定的对象中查找。所以对于那些本来不是这个对象的属性的变量,查找起来会很慢,对于有性能要求的程序不适合(JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符)。with 也会导致数据泄漏(在非严格模式下,会自动在全局作用域创建一个全局变量)

in 运算符

in 运算符能够检测左侧操作数是否为右侧操作数的成员。其中,左侧操作数是一个字符串,或者可以转换为字符串的表达式,右侧操作数是一个对象或数组。

const o =   
    a : 1,  
    b : function() 
;
console.log("a" in o);  //true
console.log("b" in o);  //true
console.log("c" in o);  //false
console.log("valueOf" in o);  //返回true,继承Object的原型方法
console.log("constructor" in o);  //返回true,继承Object的原型属性

with + new Function

配合 with 用法可以稍微限制沙盒作用域,先从当前的 with 提供对象查找,但是如果查找不到依然还能从更上面的作用域获取,污染或篡改全局环境。

function sandbox (src) 
    src = \'with (sandbox) \' + src + \'\';
    return new Function(\'sandbox\', src);


const str = `
    let a = 1; 
    window.name="张三"; 
    console.log(a); // 打印:1
`
;

sandbox(str)();

console.log(window.name);//\'张三\'

可以看到,基于上面的方案都多多少少存在一些安全问题:

eval 是全局对象的一个函数属性,执行的代码拥有着和应用中其它正常代码一样的的权限,它能访问「执行上下文」中的局部变量,也能访问所有「全局变量」,在这个场景下,它是一个非常危险的函数使用 Function 构造器生成的函数,并不会在创建它的上下文中创建闭包,一般在全局作用域中被创建。当运行函数的时候,只能访问自己的本地变量和全局变量,不能访问 Function 构造器被调用生成的上下文的作用域with 一样的问题,它首先会在传入的对象中查找对应的变量,如果找不到就会往更上层的全局作用域去查找,所以也避免不了污染或篡改全局环境

那有没有更安全一些的沙箱环境实现呢?

基于 Proxy 实现的沙箱(ProxySandbox)

ES6 Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,属于一种“元编程”(meta programming)

function evalute(code,sandbox) 
  sandbox = sandbox || Object.create(null);
  const fn = new Function(\'sandbox\', `with(sandbox)return ($code)`);
  const proxy = new Proxy(sandbox, 
    has(target, key) 
      // 让动态执行的代码认为属性已存在
      return true; 
    
  );
  return fn(proxy);

evalute(\'1+2\') // 3
evalute(\'console.log(1)\') // Cannot read property \'log\' of undefined

我们知道无论 eval 还是 function,执行时都会把作用域一层一层向上查找,如果找不到会一直到 global,那么利用 Proxy 的原理就是,让执行了代码在 sandobx 中找的到,以达到「防逃逸」的目的。

我们前面提到with在内部使用in运算符来计算变量,如果条件是 true,它将从沙盒中检索变量。理想状态下没有问题,但也总有些特例独行的存在,比如 Symbol.unscopables。

Symbol 对象的 Symbol.unscopables 属性,指向一个对象。该对象指定了使用 with 关键字时,哪些属性会被 with 环境排除。

Array.prototype[Symbol.unscopables]
// //   copyWithin: true,//   entries: true,//   fill: true,//   find: true,//   findIndex: true,//   keys: true// Object.keys(Array.prototype[Symbol.unscopables])
// [\'copyWithin\', \'entries\', \'fill\', \'find\', \'findIndex\', \'keys\']

上面代码说明,数组有 6 个属性,会被 with 命令排除。


由此我们的代码还需要修改如下:

function sandbox(code) 
    code = \'with (sandbox) \' + code + \'\'
    const fn = new Function(\'sandbox\', code)

    return function (sandbox) 
        const sandboxProxy = new Proxy(sandbox, 
            has(target, key) 
                return true
            ,
            get(target, key) 
                if (key === Symbol.unscopables) return undefined
                return target[key]
            
        )
        return fn(sandboxProxy)
    

const test = 
    a: 1,
    log()
        console.log(\'11111\')
    

const code = \'log(); console.log(a)\' // 1111,TypeError: Cannot read property \'log\' of undefinedsandbox(code)(test)

Symbol.unscopables 定义对象的不可作用属性。Unscopeable 属性永远不会从 with 语句中的沙箱对象中检索,而是直接从闭包或全局范围中检索。

快照沙箱(SnapshotSandbox)

快照沙箱实现来说比较简单,主要用于不支持 Proxy 的低版本浏览器,原理是基于diff来实现的,在子应用激活或者卸载时分别去通过快照的形式记录或还原状态来实现沙箱,snapshotSandbox 会污染全局 window。
我们看下 qiankun[13] 的 snapshotSandbox 的源码,这里为了帮助理解做部分精简及注释。

function iter(obj, callbackFn) 
    for (const prop in obj) 
        if (obj.hasOwnProperty(prop)) 
            callbackFn(prop);
        
    


/**
 * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
 */

class SnapshotSandbox 
    constructor(name) 
        this.name = name;
        this.proxy = window;
        this.type = \'Snapshot\';
        this.sandboxRunning = true;
        this.windowSnapshot = ;
        this.modifyPropsMap = ;
        this.active();
    
    //激活
    active() 
        // 记录当前快照
        this.windowSnapshot = ;
        iter(window, (prop) => 
            this.windowSnapshot[prop] = window[prop];
        );

        // 恢复之前的变更
        Object.keys(this.modifyPropsMap).forEach((p) => 
            window[p] = this.modifyPropsMap[p];
        );

        this.sandboxRunning = true;
    
    //还原
    inactive() 
        this.modifyPropsMap = ;

        iter(window, (prop) => 
            if (window[prop] !== this.windowSnapshot[prop]) 
                // 记录变更,恢复环境
                this.modifyPropsMap[prop] = window[prop];
              
                window[prop] = this.windowSnapshot[prop];
            
        );
        this.sandboxRunning = false;
    

let sandbox = new SnapshotSandbox();
//test
((window) => 
    window.name = \'张三\'
    window.age = 18
    console.log(window.name, window.age) //    张三,18
    sandbox.inactive() //    还原
    console.log(window.name, window.age) //    undefined,undefined
    sandbox.active() //    激活
    console.log(window.name, window.age) //    张三,18
)(sandbox.proxy);

legacySandBox

qiankun 框架 singular 模式下的 proxy 沙箱实现,为了便于理解,这里做了部分代码的精简和注释。

//legacySandBox
const callableFnCacheMap = new WeakMap();

function isCallable(fn) 
  if (callableFnCacheMap.has(fn)) 
    return true;
  
  const naughtySafari = typeof document.all === \'function\' && typeof document.all === \'undefined\';
  const callable = naughtySafari ? typeof fn === \'function\' && typeof fn !== \'undefined\' : typeof fn ===
    \'function\';
  if (callable) 
    callableFnCacheMap.set(fn, callable);
  
  return callable;
;

function isPropConfigurable(target, prop) 
  const descriptor = Object.getOwnPropertyDescriptor(target, prop);
  return descriptor ? descriptor.configurable : true;


function setWindowProp(prop, value, toDelete) 
  if (value === undefined && toDelete) 
    delete window[prop];
   else if (isPropConfigurable(window, prop) && typeof prop !== \'symbol\') 
    Object.defineProperty(window, prop, 
      writable: true,
      configurable: true
&nbs

一文彻底搞懂zookeeper(代码片段)

本文是基于CentOS7.9系统环境,进行Zookeeper的学习和使用1.Zookeeper简介1.1什么是ZookeeperZookeeper是一个开源的分布式的,为分布式应用提供协调服务的Apache项目。本质上,就是文件系统+通知机制1.2Zookeeper工作机制Zookeepe... 查看详情

一文彻底搞懂slam技术(代码片段)

什么是SLAM?SLAM (simultaneouslocalizationandmapping),也称为CML(ConcurrentMappingandLocalization),即时定位与地图构建,或并发建图与定位。问题可以描述为:将一个机器人放入未知环境中的未知位置,是否有办法让机器人一边逐步描... 查看详情

一文彻底搞懂slam技术(代码片段)

什么是SLAM?SLAM (simultaneouslocalizationandmapping),也称为CML(ConcurrentMappingandLocalization),即时定位与地图构建,或并发建图与定位。问题可以描述为:将一个机器人放入未知环境中的未知位置,是否有办法让机器人一边逐步描... 查看详情

一文彻底搞懂docker中的namespace(代码片段)

什么是namespacenamespace是对全局系统资源的一种封装隔离。这样可以让不同namespace的进程拥有独立的全局系统资源。这样改变一个namespace的系统资源只会影响当前namespace中的进程,对其它namespace中的资源没有影响。以前Linux也... 查看详情

一文彻底搞懂kafka(代码片段)

Kafka的学习和使用本文是基于CentOS7.9系统环境,进行Kafka的学习和使用一、Kafka的简介1.1Kafka基本概念(1)什么是KafkaKafka是一个分布式的基于发布/订阅模式的消息队列,主要应用于大数据实时处理领域(2)消息队列点对点模式... 查看详情

一文彻底搞懂hbase(代码片段)

本文是基于CentOS7.9系统环境,进行HBase的学习和使用一、HBase的简介1.1HBase基本概念HBase是一种分布式、可扩展、支持海量数据存储的NoSQL数据库,可以解决HDFS随机写的问题1.2HBase数据模型逻辑上,HBase的数据模型同关系... 查看详情

一文彻底搞懂hbase(代码片段)

本文是基于CentOS7.9系统环境,进行HBase的学习和使用一、HBase的简介1.1HBase基本概念HBase是一种分布式、可扩展、支持海量数据存储的NoSQL数据库,可以解决HDFS随机写的问题1.2HBase数据模型逻辑上,HBase的数据模型同关系... 查看详情

一文彻底搞懂leveldb架构(代码片段)

leveldbleveldb是一个写性能十分优秀的存储引擎,是典型的LSM-tree的实现。LSM的核心思想是为了换取最大的写性能而放弃掉部分读性能。那么,为什么leveldb写性能高?简单来说它就是尽量减少随机写的次数。leveldb首先将... 查看详情

一文彻底搞懂leveldb架构(代码片段)

leveldbleveldb是一个写性能十分优秀的存储引擎,是典型的LSM-tree的实现。LSM的核心思想是为了换取最大的写性能而放弃掉部分读性能。那么,为什么leveldb写性能高?简单来说它就是尽量减少随机写的次数。leveldb首先将... 查看详情

一文彻底搞懂leveldb架构(代码片段)

leveldbleveldb是一个写性能十分优秀的存储引擎,是典型的LSM-tree的实现。LSM的核心思想是为了换取最大的写性能而放弃掉部分读性能。那么,为什么leveldb写性能高?简单来说它就是尽量减少随机写的次数。leveldb首先将... 查看详情

一文彻底搞懂cookiesessiontoken到底是什么(代码片段)

点击上方关注“终端研发部”设为“星标”,和你一起掌握更多数据库知识责编:架构君 | 来源:不学无数的程序员链接:https://my.oschina.net/u/4030990/blog/3136476上一篇好文:MySQL数据库的优化,你知道有哪... 查看详情

字符串匹配,一文彻底搞懂(代码片段)

1暴力破解法在主串A中查找模式串B的出现位置,其中如果A的长度是n,B的长度是m,则n>m。当我们暴力匹配时,在主串A中匹配起始位置分别是0、1、2….n-m且长度为m的n-m+1个子串。暴力匹配对应代码是:#inc... 查看详情

mybatis缓存专题-一文彻底搞懂mybatis二级缓存(代码片段)

文章目录1.二级缓存概念2.二级缓存使用2.1.配置二级缓存2.2.分开关2.3.实体类实现序列化接口2.4.测试方法3.cache有一些可选的属性3.1.type3.2.eviction3.3.flushInterval3.4.size3.5readOnly3.6blocking4.MyBatis的缓存机制整体设计以及二级缓存的工作... 查看详情

一文让你彻底搞懂多线程(代码片段)

如果对什么是线程、什么是进程仍存有疑惑,请先Google之,因为这两个概念不在本文的范围之内。用多线程只有一个目的,那就是更好的利用cpu的资源,因为所有的多线程代码都可以用单线程来实现。说这个话其... 查看详情

一文带你彻底搞懂docker中的cgroup(代码片段)

前言进程在系统中使用CPU、内存、磁盘等计算资源或者存储资源还是比较随心所欲的,我们希望对进程资源利用进行限制,对进程资源的使用进行追踪。这就让cgroup的出现成为了可能,它用来统一将进程进行分组࿰... 查看详情

一文带你彻底搞懂springboot-rabbitmq(代码片段)

一、环境搭建采用maven多module模式,共计创建三个子modulecommon:通用实体信息rabbitmq-publisher:消息发布者,基于SpringBootrabbitmq-subscriber:消息订阅者,基于SpringBoot在消息发布者和订阅者两个项目中加入rabbitm... 查看详情

mybatis缓存专题-一文彻底搞懂mybatis一级缓存(代码片段)

文章目录1.缓存的概念1.1.什么是缓存1.2.为什么使用缓存1.3.什么样的数据能使用缓存,什么样的数据不能使用2.什么是一级缓存3.什么情况下会命中一级缓存4.Mybatis的一级缓存机制详解5.MyBatis关闭一级缓存6.Mybatis的一级缓存机... 查看详情

mybatis缓存专题-一文彻底搞懂mybatis一级缓存(代码片段)

文章目录1.缓存的概念1.1.什么是缓存1.2.为什么使用缓存1.3.什么样的数据能使用缓存,什么样的数据不能使用2.什么是一级缓存3.什么情况下会命中一级缓存4.Mybatis的一级缓存机制详解5.MyBatis关闭一级缓存6.Mybatis的一级缓存机... 查看详情