前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >跨越时空的对白——async&await分析

跨越时空的对白——async&await分析

原创
作者头像
Yerik
修改2022-05-08 22:07:40
1.1K3
修改2022-05-08 22:07:40
举报
文章被收录于专栏:烹饪一朵云烹饪一朵云

同步中的异步

在ES6中新增了asgnc...await...的异步解决方案,对于这种方案,有多种操作姿势,比如这样

代码语言:javascript
复制
const asyncReadFile = async function(){
    const f1 = await readFile('/etc/fstab')
    const f2 = await readFile('/etc/shells')
    console.log(f1.toString())
    console.log(f2.toString())
}

或者是这样

代码语言:javascript
复制
async function f(){
    try{
        await new Promise.reject('出错了')
    } catch(e){
        
    }
    return await Promise.resolve('hello yerik')
}

是否能发现这两种使用方式的各自的特点:

  • async...await...异步解决方案支持同步的方式去执行异步操作
  • async...await...异步解决方案支持通过try...catch...进行异常捕获

对于第一点来说还好理解,但第2种说法就很费解了,以至于有一种颠覆以往理解的绝望感,对于js的世界观都已经灰色。对于try...catch...来说,不都是同步执行过程中捕获异常的吗,为何在async...await...中的try...catch...可以捕获异步执行的异常呢?

这个时候就去翻一下阮一峰老师的ES6教程,还以为是我当年看书走眼了,忘了啥,查漏补缺,结果阮老师就这么轻飘飘一句话

es6教程片段.png
es6教程片段.png

┑( ̄Д  ̄)┍

时间和空间上的分离

阮老师,您说的是平行时空么?还是错位空间?

错位空间.png
错位空间.png

我吹过你吹过的晚风

那我们算不算 相拥

我遇到过你发现的error,那我们算不算相拥,反正我读完也是挺郁闷的,阮老师那种在大气层的理解,对于普通人的我还是需要一层层剖析才能理解,那就先按照自己的理解来说吧,大家一起探讨一下,看看有没有道理

我们知道对于nodejs的异步实现都是借助libuv其他线程完成的。正常情况下,当eventloop通知调用栈处理异步回调函数的时候,原调用栈种的函数应该已经执行完了,因此调用函数和异步逻辑是由完全不同的线程执行的,本质上是没有交集的,这个时候可以理解为空间上是隔离的。异步回调被触发执行时,调用函数早已执行结束,因而,回调函数和调用函数的执行在时间上也是隔离的

好了,时空隔离的问题,勉强解释通了,但是async...await...又是怎么打破这种隔离,让其中的try...catch...可以捕获到异步操作中的异常?曾经大胆猜测,async...await...可以强行拉长try...catch...作用域,让调用函数的生命周期可以尽量延长,以至于可以等待直到异步函数执行完成,在此期间如果异步过程出现异常,调用函数就可以捕捉到,然而这个延长函数生命周期并等待异步执行结束,这不就是相当于是在阻塞线程的执行?阻塞执行——这跟JS的非阻塞的特质又是背道而驰的。

至此我总觉得在调用函数和异步逻辑之间存在某种诡异的tunnel,对!说的就是那股风!其可以在主函数和异步函数这两个不同时空互相隔离的生物进行消息传递,比如说在时空A中捕获了时空B里面的异常消息,这样它们就可以相拥❤

怎么想都觉得这个过程离大谱!

try...catch...不能捕获异步异常

try...catch...能捕获到的仅仅是try模块内执行的同步方法的异常(try执行中且不需要异步等待),这时候如果有异常,就会将异常抛到catch中。

捕获异常.png
捕获异常.png

除此之外,try...catch...执行之前的异常,以及try...catch...内的异步方法所产生的异常(例如ajax请求、定时器),都是不会被捕获的!看代码

无法捕获.png
无法捕获.png

这段代码中,setTimeout的回调函数抛出一个错误,并不会在catch中捕获,会导致程序直接报错崩掉。

这说明在jstry...catch...并不是说写上一个就可以高枕无忧。尤其是在异步处理的场景下。

那这个问题是怎么来的呢?

我从网上扒了个动图,可以比较形象的解释这个问题。图中演示了foo,bar,tmp,baz四个函数的执行过程。同步函数的执行在调用栈中转瞬即逝,异步处理需要借助libuv。比如这个setTimeout这个Web API,它独立于主线程中的libuv中别的线程负责执行。执行结束吼,会将对应回调函数放到等待队列中,当调用栈空闲吼会从等待队列中取出回调函数执行

解释.gif
解释.gif
代码语言:javascript
复制
const foo = ()=>console.log('Start!')
const bar = ()=>setTimeout(()=>console.log('Timeout!'), 0)
const tmp = ()=>Promise.resolve('Promise!').then(res=>console.log(res))
const baz = ()=>console.log('End!')

foo();
bar();
tmp();
baz();

不能捕获的原因

为了讲清楚不能被捕获的原因,我改一下代码,模拟异步过程发生了异常。大家可以把执行逻辑再套回刚才的动图逻辑再看一下,(后面有机会学习怎么做动图哈哈哈)

代码语言:javascript
复制
const bar = ()=> {
    try{
        setTimeout(()=>{
            throw new Error()
        }, 500)
    }catch(e){
        // catch error.. don't work
    }
}

setTimeout的回调在Queue排队等待执行的时候,Call Stack中的bar就已经执行完了,bar的销毁顺便也终止了try...catch...的捕获域。当主进程开始执行throw new Error()的时候,相当于外层是没有任何捕获机制的,该异常会直接抛出给V8进行处理

回调函数无法捕获?

因为大部分遇到无法catch的情况,都发生在回调函数,就认为回调函数不能catch,这个结论是对的吗?

只能说不一定,且看这个例子

代码语言:javascript
复制
// 定义一个 fn,参数是函数。
const fn = (cb: () => void) => {
  cb();
};

function main() {
  try {
    // 传入 callback,fn 执行会调用,并抛出错误。
    fn(() => {
      throw new Error('123');
    })
  } catch(e) {
    console.log('error');
  }
}
main();

结果当然是可以catch的。因为callback执行的时候,跟main还在同一次事件循环中,即一个eventloop tick。所以上下文没有变化,错误是可以catch的。 根本原因还是同步代码,并没有遇到异步任务。

如何捕获?

简单来说就是哪里抛异常就在哪里捕获

代码语言:javascript
复制
const bar = ()=> {
    setTimeout(()=>{
        try{
            throw new Error()
        }catch(e){
            // catch error.. don't work
        }
    }, 500)
}

那这样写代码一点都不会快乐了,要出处小心,时候留意以防哪里没有考虑到异常的场景。

基于Promise的解决方案

所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise提供统一的 API,各种异步操作都可以用同样的方法进行处理。

本质上,这个就是一个状态管理机,同时又提供resolvereject两个开关。resolve负责将状态机的状态调整成Fulfilledreject将状态处理成Rejected

对于Promise来说是如何处理异常的?我们不妨通过改造前面的代码来试试

code1

代码语言:javascript
复制
function bar(){
    new Promise((resolve, reject)=>{
        setTimeout(()=>{
            // 通过throw抛出异常
            throw new Error('err')
        }, 500)
    })
}
function exec(){
    try{
        bar().then(res=>{
            console.log('res', res)
        })
    }catch(err){
        console.log('err has been caught in try-catch block')
    }
}

在这个过程中,尝试抛出全局异常Uncaught Error,然而try...catch...并没有捕获到。造成这个问题的原因还是在于异常抛出的时候,exec已经从执行栈中出栈了,此外,在Promise规范里有说明,在异步执行的过程中,通过throw抛出的异常是无法捕获的,异步异常必须通过reject捕获

code1.png
code1.png

code2

代码语言:javascript
复制
function bar(){
    return new Promise((resolve, reject)=>{
        setTimeout(()=>{
            reject('err')
        }, 500)
    })
}
function exec(){
    try{
        bar().then(res=>{
            console.log('res', res)
        })
    }catch(err){
        console.log('err has been caught in try-catch block')
    }
}

这次通过reject抛出异常,但是try...catch...同样还是没有捕获到异常。原因是reject需要配合Promise.prototype.catch一起使用

code2.png
code2.png

code3

代码语言:javascript
复制
function bar(){
    return new Promise((resolve, reject)=>{
        setTimeout(()=>{
            reject('err')
        }, 500)
    })
}
function exec(){
    try{
        bar().then(res=>{
            console.log('res', res)
        }).catch(err=>{
            // Promise.prototype.catch捕获异常
            console.log('err has been caught in promise catch')
        })
    }catch(err){
        console.log('err has been caught in try-catch block')
    }
}

这次,异常成功地通过Promise.prototype.catch捕获到了,现在我们完全可以确定,在Promise中,异常的捕获跟try...catch...没有什么关系。

code3.png
code3.png

code4

至此我们已然通过try...catch...捕获异常的测试,那如果采用async...await...的方式呢?

代码语言:javascript
复制
function bar(){
    return new Promise((resolve, reject)=>{
        setTimeout(()=>{
            reject('err')
        }, 500)
    })
}
async function exec(){
    // trycatch 捕获异常
    try{
        await bar()
    }catch(err){
        console.log("err has been caught in try-catch block")
    }
}

惊讶的发现,通过这样的方式,我们终于通过try...catch...捕捉到了异常!对于code3和code4来说,我们的差异在于采用了async...await...,而这,到底是什么原理来实现的呢?至此,问题的根源我们已经模拟出来了,接下来是剖析

code4.png
code4.png

小结

Promise必须为以下三种状态之一:等待态Pending、执行态Fulfilled和拒绝态Rejected。一旦Promiseresolvereject,不能再迁移至其他任何状态(即状态immutable

小结.png
小结.png

基本过程:

  • 初始化Promise状态pending
  • 立即执行Promise中传入的fn函数,将Promise内部resolvereject函数作为参数传递给fn,按事件机制时机处理
  • 执行then(..)注册回调处理数组(then方法可被同一个promise调用多次)
  • Promise里的关键是要保证,then方法传入的参数onFulfilledonRejected,必须在then方法被调用的那一轮事件循环之后的新执行栈中执行。

对于Promise来说,本质上也是基于回调的,只要是基于回调,那就同样无法摆脱try...catch...不能捕获异步异常的事实。不过在Promise规范中有一套自己的异常处理逻辑,尽管这并不能打破时空上的隔离,但由于其将异步的异常逻辑封装在回调逻辑中,当Promise的状态发生改变时,将错误或异常以回调的形式呈现出来

虽然Promise的出现很大程度改变了编程的习惯,不过嘛,这个机制还是有问题的,毕竟其运行的过程非常依赖内部状态的控制,我们知道Promise的状态控制是非常依赖resolvereject,这就意味着,我们必须很清楚明白异常会出现在哪里,然后异常出现的地方需要通过reject的方式将Promise的状态调整成Rejected,也就说,我们需要很明确代码要在什么地方执行reject

异常本无形,它的出现不一定可控,在工程实践的过程中又是大肠包小肠,层层套娃,Promise可以处理我们已经明确的异常,那么那些不明确的又需要怎么处理呢?为了从本质上处理这个问题,async...await...由此而生

async&await今生

啰啰嗦嗦说了这么多,铺垫了async...await...的诞生背景——为了解决异常跨越时空的问题,这部分则是解释async...await...实现的原理,是的,就是那股风的来源,风起之处——Generator

Generator

Generator函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。

协程

传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做协程coroutine,意思是多个线程互相协作,完成异步任务。

对于协程来说其有点像函数,又有点像线程。它的运行流程大致如下

  • 协程A开始执行。
  • 协程A执行到一半,进入暂停,任务挂起,执行权转移到协程B。
  • (一段时间后)协程B交还执行权。
  • 协程A恢复执行。

上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。

举例来说,读取文件的协程写法如下。

代码语言:javascript
复制
function* asyncJob() {
  // ...其他代码
  var f = yield readFile(fileA);
  // ...其他代码
}

上面代码的函数asyncJob是一个协程,它的奥妙就在其中的yield命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。

协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它如果去除yield命令,这种写法非常跟同步操作相比,不要说相似,简直一模一样。

协程的 Generator 函数实现

Generator函数是协程在ES6的实现,最大特点就是可以交出函数的执行权,即暂停执行。

整个Generator函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。Generator函数的执行方法如下。

代码语言:javascript
复制
function* gen(x) {
  var y = yield x + 2;
  return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面代码中,调用Generator函数,会返回一个内部指针(即遍历器)g。这是Generator函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g的next方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield语句,上例是执行到x + 2为止。

换言之,next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示Generator函数是否执行完毕,即是否还有下一个阶段。

异常捕获

Generator函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:

  • 函数体内外的数据交换
  • 错误处理机制。

注意观察代码中的两个next的不同

代码语言:javascript
复制
function* gen(x){
  var y = yield x + 2;
  return y;
}
var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }

next返回值的value属性,是Generator函数向外输出数据

next方法还可以接受参数,向Generator函数体内输入数据。

上面代码中,第一个next方法的value属性,返回表达式x + 2的值3。第二个next方法带有参数2,这个参数可以传入Generator函数,作为上个阶段异步任务的返回结果,被函数体内的变量y接收。因此,这一步的value属性,返回的就是2(变量y的值)。

Generator函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

代码语言:javascript
复制
function* gen(x){
  try {
    var y = yield x + 2;
  } catch (e){
    console.log(e);
  }
  return y;
}
var g = gen(1);
g.next();
g.throw('出错了');
// 出错了

上面代码的最后一行,Generator 函数体外,使用指针对象的throw方法抛出的错误,可以被函数体内的try...catch代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。

异步任务的封装

下面看看如何使用Generator函数,执行一个真实的异步任务。

代码语言:javascript
复制
var fetch = require('node-fetch');
function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}

上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从JSON格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了yield命令。

执行这段代码的方法如下。

代码语言:javascript
复制
var g = gen();
var result = g.next();
result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

上面代码中,首先执行Generator函数,获取遍历器对象,然后使用next方法(第二行),执行异步任务的第一阶段。由于Fetch模块返回的是一个Promise对象,因此要用then方法调用下一个next方法。

可以看到,虽然Generator函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。

co模块

co 模块是著名程序员TJ Holowaychuk于 2013 年 6 月发布的一个小工具,用于Generator 函数的自动执行。

代码语言:javascript
复制
var gen = function* () {
  var f1 = yield readFile('/etc/fstab');
  var f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

co模块可以让你不用编写Generator函数的执行器。

代码语言:javascript
复制
var co = require('co');
co(gen);

上面代码中,Generator 函数只要传入co函数,就会自动执行。

co函数返回一个Promise对象,因此可以用then方法添加回调函数。

代码语言:txt
复制
co(gen).then(function (){
  console.log('Generator 函数执行完成');
});

上面代码中,等到Generator函数执行结束,就会输出一行提示。

异步实现

先回答了异步实现的前置条件——基于协程,之后我们再来看看异步的关键词async

ES2017标准引入了async函数,使得异步操作变得更加方便。

async函数是什么?一句话,它就是Generator函数的语法糖。

前文有一个Generator函数,依次读取两个文件。

代码语言:javascript
复制
const fs = require('fs');
const readFile = function (fileName) {
  return new Promise(function (resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) return reject(error);
      resolve(data);
    });
  });
};
const gen = function* () {
  const f1 = yield readFile('/etc/fstab');
  const f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

上面代码的函数gen可以写成async函数,就是下面这样。

代码语言:javascript
复制
const asyncReadFile = async function () {
  const f1 = await readFile('/etc/fstab');
  const f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

一比较就会发现,async函数就是将Generator函数的星号(*)替换成async,将yield替换成await,仅此而已。

async函数对Generator函数的改进,体现在以下四点。

  • 内置执行器。asyncReadFile();上面的代码调用了asyncReadFile函数,然后它就会自动执行,输出最后结果。这完全不像Generator函数,需要调用next方法,或者用co模块,才能真正执行,得到最后结果。
    • Generator函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。
  • 更好的语义。
    • asyncawait,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
  • 更广的适用性。
    • co模块约定,yield命令后面只能是Thunk函数或Promise对象,而async函数的await命令后面,可以是Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。
  • 返回值是Promise
    • async函数的返回值是Promise对象,这比Generator函数的返回值是Iterator对象方便多了。你可以用then方法指定下一步的操作。

进一步说,async函数完全可以看作多个异步操作,包装成的一个Promise对象,而await命令就是内部then命令的语法糖。

async实现原理

本质上是将Generator函数和自动执行器,包装在一个函数里。

代码语言:javascript
复制
async function fn(args) {
  // ...
}
// 等同于
function fn(args) {
  return spawn(function* () {
    // ...
  });
}

所有的async函数都可以写成上面的第二种形式,其中的spawn函数就是自动执行器。

下面给出spawn函数的实现,基本就是前文自动执行器的翻版。

代码语言:txt
复制
function spawn(genF) {
  return new Promise(function(resolve, reject) {
    const gen = genF();
    function step(nextF) {
      let next;
      try {
        next = nextF();
      } catch(e) {
        return reject(e);
      }
      if(next.done) {
        return resolve(next.value);
      }
      Promise.resolve(next.value).then(function(v) {
        step(function() { return gen.next(v); });
      }, function(e) {
        step(function() { return gen.throw(e); });
      });
    }
    step(function() { return gen.next(undefined); });
  });
}

await分析

根据语法规格,await命令只能出现在async函数内部,否则都会报错。

代码语言:javascript
复制
// 报错
const data = await fetch('https://api.github.com');

上面代码中,await命令独立使用,没有放在async函数里面,就会报错。

目前,有一个语法提案,允许在模块的顶层独立使用await命令,使得上面那行代码不会报错了。这个提案的目的,是借用await解决模块异步加载的问题。

代码语言:javascript
复制
// awaiting.js
let output;
async function main() {
  const dynamic = await import(someMission);
  const data = await fetch(url);
  output = someProcess(dynamic.default, data);
}
main();
export { output };

上面代码中,awaiting.js除了输出output,还默认输出一个Promise对象(async 函数立即执行后,返回一个Promise对象),从这个对象判断异步操作是否结束。

下面是加载这个模块的新的写法。

代码语言:javascript
复制
// usage.js
import promise, { output } from "./awaiting.js";
function outputPlusValue(value) { return output + value }
promise.then(() => {
  console.log(outputPlusValue(100));
  setTimeout(() => console.log(outputPlusValue(100), 1000);
});

上面代码中,将awaiting.js对象的输出,放在promise.then()里面,这样就能保证异步操作完成以后,才去读取output

这种写法比较麻烦,等于要求模块的使用者遵守一个额外的使用协议,按照特殊的方法使用这个模块。一旦你忘了要用Promise加载,只使用正常的加载方法,依赖这个模块的代码就可能出错。而且,如果上面的usage.js又有对外的输出,等于这个依赖链的所有模块都要使用Promise加载。

顶层的await命令,就是为了解决这个问题。它保证只有异步操作完成,模块才会输出值。

代码语言:javascript
复制
// awaiting.js
const dynamic = import(someMission);
const data = fetch(url);
export const output = someProcess((await dynamic).default, await data);

上面代码中,两个异步操作在输出的时候,都加上了await命令。只有等到异步操作完成,这个模块才会输出值。

加载这个模块的写法如下。

代码语言:javascript
复制
// usage.js
import { output } from "./awaiting.js";
function outputPlusValue(value) { return output + value }
console.log(outputPlusValue(100));
setTimeout(() => console.log(outputPlusValue(100), 1000);

上面代码的写法,与普通的模块加载完全一样。也就是说,模块的使用者完全不用关心,依赖模块的内部有没有异步操作,正常加载即可。

这时,模块的加载会等待依赖模块(上例是awaiting.js)的异步操作完成,才执行后面的代码,有点像暂停在那里。所以,它总是会得到正确的output,不会因为加载时机的不同,而得到不一样的值。

小结

协程的引入具备了挂起自己和被重新唤醒的能力。可以想象一下,协程在被中断吼,是需要某种机制来保存当前执行的上下文。在空间上,协程初始化创建的时候为其分配的栈一定的栈空间,用来保存执行过程中的一些关键信息,当函数被唤醒后,通过栈内保存的信息恢复"案发现场"。

总结

至此,前面code4中的案例就解释通了,await的时候exec函数被挂起,等bar函数中的异步操作执行结束后,exec函数被恢复。此时恢复的还有try...catch...。这个时候可以发现Promise的状态已经通过reject触发,由于没有Promise.prototype.catch,所以这个时候Promise会把异常向外抛出,正好被try...catch...捕捉到,这个时候,确实如前文所猜测,在async...await...try...catch...就是守株待兔,并且最后还真的等到了!

总结.png
总结.png

Sync invoke an async function and await its returned awaitable object.

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 同步中的异步
  • 时间和空间上的分离
  • try...catch...不能捕获异步异常
    • 不能捕获的原因
      • 回调函数无法捕获?
        • 如何捕获?
          • code1
          • code2
          • code3
          • code4
      • 基于Promise的解决方案
        • 小结
        • async&await今生
          • Generator
            • 协程
            • 协程的 Generator 函数实现
            • 异常捕获
            • 异步任务的封装
            • co模块
          • 异步实现
            • async实现原理
          • await分析
          • 小结
          • 总结
          相关产品与服务
          容器服务
          腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档