Midway 外部版启动过程分析

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: [Midway](https://midwayjs.org/) 是一个 Egg.js 的拓展框架,他提供了更多 ts 以及依赖注入方面的支持。今天我们来看一下 Midway 的启动过程。 ## Index * before start * midway-bin: CLI startup * midway: cluster startup * midway-web: Applica

Midway 是一个 Egg.js 的拓展框架,他提供了更多 ts 以及依赖注入方面的支持。今天我们来看一下 Midway 的启动过程。

Index

  • before start
  • midway-bin: CLI startup
  • midway: cluster startup
  • midway-web: Application/Agent startup
  • example for the flow
  • conclusion

Before Start

midway 的代码所在地是 https://github.com/midwayjs/midway 下。是一个汇总的 mono 仓库。你可以方便的在这一个 git 仓库里找到 midway 的全部拓展代码。不过一些原本 egg 的代码依旧是需要去到 https://github.com/eggjs/ 下阅读。

为了帮助我们l了解整个 midway 的启动,你可以使用 midway-init 这个手脚架工具来初始化一个空的项目,具体步骤是:

# 安装手脚架工具
npm install -g midway-init

# 初始化项目
mkdir midway-test
cd midway-test
midway-init .

或者可以直接下载使用 midway-example 中的空项目, link, 随后执行:

# 安装依赖
npm i

## 启动测试项目
npm run dev

当你看到 midway started on http://127.0.0.1:7001 的字样时,就意味着空项目已经启动好。

有了 example 之后,我们就通过这个项目以及 node_modules 中完整的 midway 和 egg 依赖来研究整个启动的过程。

midway-bin: CLI startup

midway-bin 主要是面向 CLI 命令行的启动处理。当你开始通过 npm run dev 来启动 midway 的时候,就已经是通过 NODE_ENV=local midway-bin dev --ts 的方式调用了 midway-bin 来启动 midway 应用。

当你在执行 npm scripts 时,npm 会帮你在 node_modules 中查找通过 package.json 中的 bin 属性配置好的可执行命令。而 midway-bin 这个命令是在 node_modules/midway-bin/package.json 中的 bin 字段定义的:

{
  "name": "midway-bin",
  // ...
  "bin": {
    "midway-bin": "bin/midway-bin.js",
    "mocha": "bin/mocha.js"
  },
}

也就是说 midway-bin 这个命令其实调用的是 node_modules/midway-bin/bin/midway-bin.js 这个脚本来执行的。这里就是启动命令的一个入口。打开这个文件会发现如下代码:

#!/usr/bin/env node

'use strict';

const Command = require('../').MidwayBin;
new Command().start();

根据这个代码的语音,我们可以知道,这里是有一个 Command 类会自动解析命令传入的参数和环境变量(如 dev 和 --ts 这样的命令和 flag),继续去看 '../' 的内容:

// node_modules/midway-bin/index.js

'use strict';

// 继承 egg-bin 的 Command 类
const Command = require('egg-bin/lib/command');

class MidwayBin extends Command {
  constructor(rawArgv) {
    // 调用父类的初始化
    super(rawArgv);

    // 设置单纯执行 midway-bin 时返回的命令提示
    this.usage = 'Usage: egg-bin [command] [options]';

    // 加载用户在 midway-bin/lib/cmd 下定义的命令
    this.load(path.join(__dirname, 'lib/cmd'));
  }
}

exports.MidwayBin = MidwayBin;
// ...

// dev 命令的逻辑
exports.DevCommand = require('./lib/cmd/dev');

// ...

发现这里导出了刚刚 new Command().start(); 的 Command 类(MidwayBin),并且在这个类的 constructor 中加载用户在 midway-bin 下定义的命令。按照面向对象的逻辑,我们只需要关心 midway-bin 下的 dev 命令实现(当然如果你感兴趣也可以顺着继承链去看 egg-bin -> common-bin 的构造函数内的初始化过程)。

我们来到 midway-bin/lib/cmd/dev.js

'use strict';

class DevCommand extends require('egg-bin/lib/cmd/dev') {
  constructor(rawArgv) {
    // 调用父类的初始化
    super(rawArgv);

    // 设置执行 midway-bin dev 时返回的命令提示
    this.usage = 'Usage: midway-bin dev [dir] [options]';

    // 设置默认参数 (端口) 为 7001
    this.defaultPort = process.env.PORT || 7001;
  }

  * run(context) {
    // 设置默认的 midway 进程启动参数 (Arguments Vector)
    context.argv.framework = 'midway';
    
    // 运行父类 egg-bin 的 dev 启动
    yield super.run(context);
  }
}

module.exports = DevCommand;

通过代码注释,我们可以知道通过 midway-bin dev 启动时,与原本的 egg-bin 启动一个项目唯一的区别就是 midway-bin 设置了一下默认端口,以及启动的框架参数为 'midway'。最后还是调用的 egg-bin 内的 dev 命令的 run 方法来走的。

其中 egg-bin 的启动逻辑,简单来说就两步:

  • ① 整理启动参数

    • 解析 CLI flag:如 --port=2333 --cluster=8 等 flag 解析
    • 解析环境变量:如 NODE_ENV=local
    • 获取当前目录(process.cwd())用作默认的项目 baseDir
    • 通过当前目录读取 package.json 信息(如 egg 字段)
    • 判断 typescript 环境等设置需要 require 的模块参数
  • ② 根据启动参数创建 egg(这里是midway) 进程

    • 此处直接调用 common-bin/lib/helper 下的 #forkNode 方法。这个方法是一个通用的传递参数启动子进程的方法。通过 egg-bin 下 dev 的构造函数中拼装的 start-cluster 脚本来启动子进程。

综上,具体情况是:

  1. npm run dev
  2. NODE_ENV=local midway-bin dev --ts
  3. midway-bin/bin/midway-bin.js
  4. midway-bin/index.js
  5. 父类初始化 egg-bin -> common-bin
  6. 调用 midway-bin 重写的 dev 命令
  7. midway-bin/lib/cmd/dev.js 设置 port 和 framework 参数
  8. egg-bin/lib/cmd/dev.js 整理启动参数
  9. egg-bin -> common-bin #forkNode
  10. egg-bin/lib/start-cluster.js (在子进程中 require application 并 start)

到这里完成 midway-bin 的全部工作。

midway: cluster startup

midway 这个在启动流程和所做的事情等同于 egg-cluster 这个包。主要是区别处理 Application 和 Agent 启动之前的逻辑,然后分别启动这两个部分。

在进入 midway 模块前,我们需要接着上方 midway-bin 的最后一步,来看一下 start-cluster 脚本:

#!/usr/bin/env node

'use strict';

const debug = require('debug')('egg-bin:start-cluster');
const options = JSON.parse(process.argv[2]);
debug('start cluster options: %j', options);
require(options.framework).startCluster(options);

其中的 options.framework 就是前文提到过的在 midway-bin/lib/cmd/dev.js 中设置写死的参数,也就是 'midway',所以这里实际上调用的就是 node_modules/midway/dist/index.js 中的 startCluster 方法,注意在 midway 的 package.json 中配置了 main: 'dist/index', 所以 require ('midway') 拿到的是 midway/dist/index。不过 midway 这个库是用 ts 写的,所以我们直接来看 ts 代码:

// ...
// export * 导出各项定义:'injection', 'midway-core', 'midway-web', 'egg'

const Master = require('../cluster/master');

// ...

/**
 * 应用启动的方法
 */
export function startCluster(options, callback) {
  // options 就是 midway-bin 过程中整理的启动一个 midway 所需的所有参数
  new Master(options).ready(callback);
}

接下来我们来看这个 new Master 的逻辑:

const EggMaster = require('egg-cluster/lib/master');
const path = require('path');
const formatOptions = require('./utils').formatOptions;

class Master extends EggMaster {

  constructor(options) {
    // TypeScript 默认支持的参数判断
    options = formatOptions(options);
    super(options);

    // 输出 egg 格式的版本日志
    this.log('[master] egg version %s, egg-core version %s',
      require('egg/package').version,
      require('egg-core/package').version);
  }

  // 设置 Agent 的 fork 入口
  getAgentWorkerFile() {
    return path.join(__dirname, 'agent_worker.js');
  }

  // 设置 Application 的 fork 入口
  getAppWorkerFile() {
    return path.join(__dirname, 'app_worker.js');
  }
}

module.exports = Master;

此处继承了 egg-cluster 的 master 类(管理 app 和 agent 启动),在构造函数的过程中加上了 TypeScript 的支持参数,然后重写了 getAgentWorkerFilegetAppWorkerFile,让 egg-cluster 在通过子进程 fork 启动 app 和 agent 的时候分别通过 midway/cluster/agent_work.jsmidway/cluster/app_worker.js 这两个本地的脚本入口启动。

// midway/cluster/app_worker.js

'use strict';

const utils = require('./utils');
const options = JSON.parse(process.argv[2]);
utils.registerTypescriptEnvironment(options);
require('egg-cluster/lib/app_worker');

app_worker 为例,实际上这两个 midway 下的入口只做了一件事,就是根据 TypeScript 支持检查的参数来决定是否默认帮用户注册 TypeScript 的运行环境。之后就继续走 egg-cluster 下原本的 app_worker 的入口逻辑。

而打开 egg-clsuter/lib/app_worker.js,这个启动脚本:

'use strict';

// 检查 options 中是否有 require 字段
// 有的话 for 循环挨个 require
// ...

// ...
// 初始化 logger

// 此处 options.framework 就是 'midway'
const Application = require(options.framework).Application;

// 启动 Application
const app = new Application(options);

// ...
// 初始化好之后 callback
app.ready(startServer);

// 超时检查处理
// ...

// Application 初始化好之后做一些检查或者监听
function startServer(err) {
  // 看启动是否
    // 报错
    // 超时
    // 监听 on('error') 处理
  // ...
}

// 如果出现异常,则优化 exit 进程
gracefulExit({
  logger: consoleLogger,
  label: 'app_worker',
  beforeExit: () => app.close(),
});

看起来内容很多,但实际上我们需要关系的只有 2 句,一个是 require 获取 Application,另外一个是 new Application

其中 require 获取 Application,这里面的 options.framework 就是 'midway',所以约等于 require('midway').Application,我们可以找到 node_modules/midway/src/index.ts 看到开头有这么一段:

export * from 'injection';
export * from 'midway-core';
export * from 'midway-web';
export {
  Context,
  IContextLocals,
  EggEnvType,
  IEggPluginItem,
  EggPlugin,
  PowerPartial,
  EggAppConfig,
  FileStream,
  IApplicationLocals,
  EggApplication,
  EggAppInfo,
  EggHttpClient,
  EggContextHttpClient,
  Request,
  Response,
  ContextView,
  LoggerLevel,
  Router,
} from 'egg';

// ...

也就是说 require('midway').Application 其实拿到的是从 'midway-web' 中 export 出来的 Application。也就说从这里就进入了 midway-web 的逻辑。

midway-web: Application/Agent startup

midway-web/src/index.ts 里面 export 了很多内容,其实 Application 和 Agent 也在这里 export 出来。

export {AgentWorkerLoader, AppWorkerLoader} from './loader/loader';
export {Application, Agent} from './midway';
export {BaseController} from './baseController';
export * from './decorators';
export {MidwayWebLoader} from './loader/webLoader';
export * from './constants';
export {loading} from './loading';
接着我们就可以到 midwa-web/src/midway.ts 中来看 Application 的代码(agent 类似所以省略),简单看一下代码,随后会有专门的讲解:

import { Agent, Application } from 'egg';
import { AgentWorkerLoader, AppWorkerLoader } from './loader/loader';
// ...

class MidwayApplication extends (Application as {
  new(...x)
}) {

  // 使用 midway-web 下的 loader 来加载各项资源
  get [Symbol.for('egg#loader')]() {
    return AppWorkerLoader;
  }

  // ...

  /*
   * 通过 midway-web 自定义的 loader 来获取当前的目录
   * 这个可以解决代码编写在 src/ 目录下的执行问题
   */
  get baseDir(): string {
    return this.loader.baseDir;
  }
  get appDir(): string {
    return this.loader.appDir;
  }

  /*
   * 通过 midway-web 自定义的 loader 加载出
   * midway 自定义的 plugin 上下文, application 上下文
   */
  getPluginContext() {
    return (this.loader as AppWorkerLoader).pluginContext;
  }
  getApplicationContext() {
    return (this.loader as AppWorkerLoader).applicationContext;
  }
  generateController(controllerMapping: string) {
    return (this.loader as AppWorkerLoader).generateController(controllerMapping);
  }
  get applicationContext() {
    return this.loader.applicationContext;
  }
  get pluginContext() {
    return this.loader.pluginContext;
  }

}

class MidwayAgent extends (Agent as {
  new(...x)
}) {
  // 省略...
}

export {
  MidwayApplication as Application,
  MidwayAgent as Agent
};

主要的来说,MidwayApplication 所做的事情是继承 egg 的 Application,然后替换了原本 egg 的 loader,使用 midway 自己的 loader。

被继承的 egg 的 Application 中,默认的一些初始化结束后,就会走到 midway-web 的 loader 中开始加载各种资源:

// midway-web/src/loader/loader.ts
import {MidwayWebLoader} from './webLoader';
// ...

const APP_NAME = 'app';

export class AppWorkerLoader extends MidwayWebLoader {

  /**
   * intercept plugin when it set value to app
   */
  loadCustomApp() {
    this.interceptLoadCustomApplication(APP_NAME);
  }

  /**
   * Load all directories in convention
   */
  load() {
    // app > plugin > core
    this.loadApplicationExtend();
    this.loadRequestExtend();
    this.loadResponseExtend();
    this.loadContextExtend();
    this.loadHelperExtend();

    this.loadApplicationContext();
    // app > plugin
    this.loadCustomApp();
    // app > plugin
    this.loadService();
    // app > plugin > core
    this.loadMiddleware();

    this.app.beforeStart(async () => {
      await this.refreshContext();
      // get controller
      await this.loadController();
      // app
      this.loadRouter(); // 依赖 controller
    });
  }

}

export class AgentWorkerLoader extends MidwayWebLoader {
  // ...
}

midway-web/loader/loader.ts 也是 midway 拓展 egg 的一个核心文件。在 AppWorkerLoader 重写的 load 方法中,按照顺序加载了 extend、egg 的 service、midway 的各种 IoC 容器、middleware 以及 Midway 的 controller/router 等等。

loadXXXExtend

加载 Application、Request 等拓展,复用 egg-core 的逻辑。多继承 AppWorkerLoader -> MidwayWebLoader -> MidwayLoader -> EggLoader -> egg-core/lib/loader/mixin/extend()。

loadApplicationContext

loadApplicationContext 方法继承关系:AppWorkerLoader -> MidwayWebLoader -> MidwayLoader。

其中被 load 的 ApplicationContext 就是 IoC 容器的一个具体实例,用于存储应用级的,单例的对象。另外ApplicationContext 是 MidwayContainer 的实例,MidwayContainer 继承自 injection (依赖注入库)

在这一步中,如果用户没有配置关闭自扫描的话,会扫描用户 src 目录下的所有代码。如果发现 @controller, @router 类型的装饰修饰的 class 等都会被预先加载定义到 IoC 容器中。

loadCustomApp

通过 this.interceptLoadCustomApplication 设置 this.app 的 setter,让用户引入的插件要往 this.app挂插件的时候直接挂在 IoC 容器上。

loadService

复用 egg 的 service 逻辑。

loadMiddleware

复用 egg 的 middleware 加载逻辑。

refreshContext

刷新 applicationContext 和 pluginContext 等 IoC 容器。并且从 applicationContext 上取到预先解析的 controllerIds。然后循环通过 applicationContext.getAsync(id) 挨个获取 controller 的实例。

loadController

挨个 controller 获取通过 @controller('/user') 等装饰器的方式注册的一些控制器信息来初始化各个 controller,然后对多个 controller 进行排序最后直接传递给 app.use。

loadRouter

src/router.ts 文件加载,直接复用 egg 的逻辑。

example 代码对照流程

看完了主流程之后,我们在回过头看看看一开始使用 midway-init 脚手架生成的空项目。其中的 src 目录结构如下:

src
├── app
│   ├── controller
│   │   ├── home.ts
│   │   └── user.ts
│   └── public
│       └── README.md
├── config
│   ├── config.default.ts
│   └── plugin.ts
├── interface.ts
└── lib
    └── service
        └── user.ts

在 midway 启动的时候,midway 自定义的 loader 会返回一个基于 src (测试环境) 或者 dist (生产环境) 的 baseDir 目录。同时在 Application 中初始化到 load 阶段中的 loadApplicationContext 时。

// midway-core/src/loader.ts
// ...

export class MidwayLoader extends EggLoader {

  protected pluginLoaded = false;
  applicationContext;
  pluginContext;
  baseDir;
  appDir;
  options;

  constructor(options: MidwayLoaderOptions) {
    super(options);
    this.pluginContext = new MidwayContainer();
  }

  // ...

  protected loadApplicationContext() {
    // 从 src/config/config.default.ts 获取配置
    const containerConfig = this.config.container || this.app.options.container || {};

    // 实例化 ApplicationContext 
    this.applicationContext = new MidwayContainer(this.baseDir);
    // 实例化 requestContext
    const requestContext = new MidwayRequestContainer(this.applicationContext);

    // 注册一些实例到 applicationContext 上
    // ...

    // 如果没有关闭自扫描 (autoLoad) 则进行自扫描
    if (!containerConfig.disableAutoLoad) {
      // 判断默认扫的目录, 默认 'src/'
      const defaultLoadDir = this.isTsMode ? [this.baseDir] : ['app', 'lib'];
      // 按照扫描 export 出来的 class 统计到上下文
      this.applicationContext.load({
        loadDir: (containerConfig.loadDir || defaultLoadDir).map(dir => {
          return this.buildLoadDir(dir);
        }),
        pattern: containerConfig.pattern,
        ignore: containerConfig.ignore
      });
    }

    // 注册 config, plugin, logger 的 handler for container
    // ...
  }

  // ...
}

在 this.applicationContext.load 过程中,会有 globby 获取到 controller 下的 home.ts、user.ts 以及 lib/service/user.ts 等文件,取其 export 的 class 存在 applicationContext 中。另外还有 ControllerPaser 来解析识别文件是否是 controller,是的话就会 push 到 applicationContext.controllerIds 数组中。

所以用户在 src/app/controller/home.ts 中的代码:

import { controller, get, provide } from 'midway';

@provide()
@controller('/')
export class HomeController {

  @get('/')
  async index(ctx) {
    ctx.body = `Welcome to midwayjs!`;
  }
}

就是此时被扫到 HomeController 这个定义已经存储在 applicationContext 中,controllerIds 中也存储了该 controller。

随后在 this.refreshContext() 的过程中,执行了 this.preloadControllerFromXml() 即预加载 controller:

// midway-web/src/loader/webLoader.ts

export class MidwayWebLoader extends MidwayLoader {
  // ...
  async preloadControllerFromXml() {
    // 获取控制器 id 数组
    const ids = this.applicationContext.controllersIds;
    // for 循环遍历 id
    if (Array.isArray(ids) && ids.length > 0) {
      for (const id of ids) {
        // 异步获取 controller 实例
        const controllers = await this.applicationContext.getAsync(id);
        const app = this.app;
        if (Array.isArray(controllers.list)) {
          controllers.list.forEach(c => {
            // 初始化 egg 的 router
            const newRouter = new router_1.EggRouter({
              sensitive: true,
            }, app);

            // 将 controller 方法 expose 到具体的 router
            c.expose(newRouter);

            // 绑定对应 controller 的 router 到 app
            app.use(newRouter.middleware());
          });
        }
      }
    }
  }
  // ...
}

随后回到 midway-web/src/loader.ts 中,继续执行下一步 this.loadController():

// midway-web/src/loader/webLoader.ts

export class MidwayWebLoader extends MidwayLoader {
  // ...
  async loadController(opt: { directory? } = {}): Promise<void> {
    // 设置 controller 所在的基础目录
    const appDir = path.join(this.options.baseDir, 'app/controller');
    // 加载目录下的所有文件
    const results = loading(this.getFileExtension('**/*'), {
      loadDirs: opt.directory || appDir,
      call: false,
    });

    // 遍历每个文件
    for (const exports of results) {
      /* 如果是 export default class */
      if (is.class(exports)) {
        await this.preInitController(exports);
      } else {
      /* 如果是 export 多个 class */
        for (const m in exports) {
          const module = exports[m];
          if (is.class(module)) {
            await this.preInitController(module);
          }
        }
      }
    }

    // must sort by priority
    // 按照优先级排序

    // 调用 egg 的 controller 加载
    super.loadController(opt);
  }

  /**
   * 获取使用 @controller 装饰器注解的信息
   * 根据提取的信息来注册 router
   */
  private async preInitController(module): Promise<void> {
    // ...
  }
  // ...
}

完成以上步骤就可以通过 curl localhost:7001/ 来调用该 controller:

import { controller, get, provide } from 'midway';

@provide()
@controller('/')
export class HomeController {

  @get('/')
  async index(ctx) {
    ctx.body = `Welcome to midwayjs!`;
  }
}

HomeController 的 index 方法。返回收到 'Welcom to midwayjs!'

小结

midway-bin 作为继承自 egg-bin 的 midway 手脚架,在启动的过程中, 主要是设置了 midway 的默认端口和框架名。

midway 这个模块主要是作为启动入口并在 app 和 agent 启动的时候注册 typescript 环境,同时将 midway-web,egg,injection 等多个模块的定义在此统一导出。

最后的 midway-web 部分,在继承的 Application 和 Agent 类上并没有做太多的改动,主要是指定了替换了 egg 原本的 loadder,使用 midway 自己提供的 loader。并在 loader 的过程中添加很多 midway 特有的 feature,如 applicationContext、pluginContext、requestContext 等 IoC 容器。

而用户自己的 home.ts 这个路由,则是在自扫描阶段被解析到 controller 并且暂存 class 定义。随后在加载 controller 的环节中,通过自扫描的数据来反向(可以不需要使用曾经 Egg.js 中 router.js 这样的定义文件声明)查找到 controller 和 router,并初始化好使用 app.use 装载,从而是的 '/' 可以被请求。

本例中,主要是一个 Hello world 式的 example 的启动流程,更多 midway 的优秀用例会在后面慢慢补充希望大家支持。

目录
相关文章
|
5月前
|
JSON Go C++
开发与运维C++问题之在iLogtail新架构中在C++主程序中新增插件的概念如何解决
开发与运维C++问题之在iLogtail新架构中在C++主程序中新增插件的概念如何解决
51 1
|
4月前
|
开发工具 Android开发
Android项目架构设计问题之外部客户方便地设置回调如何解决
Android项目架构设计问题之外部客户方便地设置回调如何解决
35 0
|
5月前
|
移动开发 小程序 JavaScript
跨端技术问题之转Web运行时的“框架”模块主要负责什么功能
跨端技术问题之转Web运行时的“框架”模块主要负责什么功能
|
7月前
|
中间件
NetCore通过中间件判断接口是否存在 AllowAnonymousAttribute 特性
特性来判断一个接口是否被标记为允许匿名访问。以下是一个简单的中间件示例,用于在请求管道中检查接口是否被。.NET Core中,可以通过检查接口上的。在应用程序中使用此中间件,将其添加到。
102 0
|
7月前
|
前端开发 NoSQL 测试技术
Crossbar 后端开发调试混乱解决方案
Crossbar 后端开发调试混乱解决方案
62 0
|
存储 架构师 算法
架构设计的本质:系统与子系统、模块与组件、框架与架构
在软件研发这个领域,程序员的终极目标都是想成为一名合格的架构师。然而梦想很美好,但现实却很曲折。
架构设计的本质:系统与子系统、模块与组件、框架与架构
|
BI 数据处理 Scala
报表统计_执行框架_旧模块改造 | 学习笔记
快速学习报表统计_执行框架_旧模块改造
123 0
报表统计_执行框架_旧模块改造 | 学习笔记
|
存储 设计模式 前端开发
浅谈SpringMVC五大组件以及对执行原理的分析。
Spring MVC是包含在spring中的一个基于MVC设计思想的Web应用程序框架,目的是简化开发工作,提高开发效率。
浅谈SpringMVC五大组件以及对执行原理的分析。
“无处不在” 的系统核心服务 —— ActivityManagerService 启动流程解析
“无处不在” 的系统核心服务 —— ActivityManagerService 启动流程解析
|
JSON 资源调度 安全
Chainlink是如何实现“万能插头”的--外部适配器的开发和应用
在以太坊原生语言solidity中调用API可以将链下数据传输至链上智能合约应用。世界各地的开发者可以利用Chainlink的去中心化区块链预言机将链下真实世界的数据和事件接入区块链环境。Chainlink内置的核心适配器可以轻松配置并验证来自任何开放API的数据。
465 0