分类: nodejs

  • 【视频】Node.js 开发 RAR 解压缩命令行工具

    【视频】Node.js 开发 RAR 解压缩命令行工具

    拖来拖去,终于把 使用 node.js 开发命令行工具 workshop 的视频剪出来了,前几天上传到 B 站,访问量很一般,所以在自己的博客再捞一下。

    这次视频主要面向新手,主要呈现从 0 到 1 实现命令行工具的做法,希望观众无论基础如何,都能在看完视频之后,掌握封装仓库、实现命令行工具的做法。内容大约是:

    1. 不同系统下安装 node.js
    2. 创建命令行工具项目
    3. package.json 结构介绍
    4. 介绍 unrar-promise
    5. 介绍 yargs 实现命令行接口
    6. 开发功能
    7. 发布到 npm

    完成的项目放在 GitHub:meathill/unrar: a simple script to unarchive rar files (github.com) 非常简单,大家可以参考。

    有任何问题、建议均欢迎留言讨论。新的一年,我会努力多做视频、多做好视频,希望大家支持我。

  • MongoDB 里实现多表联查

    MongoDB 里实现多表联查

    前些天遇到一个需求,不复杂,用 SQL 表现的话,大约如此:

    SELECT *
    FROM db1 LEFT JOIN db2 ON db1.a = db2.b
    WHERE db1.userId='$me' AND db2.status=1

    没想到搜了半天,我厂的代码仓库里没有这种用法,各种教程也多半只针对合并查询(即只筛选 db1,没有 db2 的条件)。所以最后只好读文档+代码尝试,终于找到答案,记录一下。

    1. 我们用 mongoose 作为连接库
    2. 联查需要用 $lookup
    3. 如果声明外键的时候用 ObjectId,就很简单:
    // 假设下面两个表 db1 和 db2
    export const Db1Schema = new mongoose.Schema(
      {
        userId: { type: String, index: true },
        couponId: { type: ObjectId, ref: Db2Schema },
      },
      { versionKey: false, timestamps: true }
    );
    export const Db2Schema = new mongoose.Schema(
      {
        status: { type: Boolean, default: 0 },
      },
      { versionKey: false, timestamps: true }
    );
    
    // 那么只要
    db1Model.aggregate([
      {
        $lookup: {
          from: 'db2', // 目标表
          localField: 'couponId', // 本地字段
          foreignField: '_id', // 对应的目标字段
          as: 'source',
      },
      {
        $match: [ /* 各种条件 */ ],
      },
    ]);

    但是我们没有用 ObjectId,而是用 string 作为外键,所以无法直接用上面的联查。必须在 pipeline 里手动转换、联合。此时,当前表(db1)的字段不能直接使用,要配合 let,然后加上 $$ 前缀;连表(db2)直接加 $ 前缀即可。

    最终代码如下:

    // 每次必有的条件,当前表的字段用 `$$`,连表的字段用 `$`
    const filter = [{ $eq: ['$$userId', userId] }, { $eq: ['$isDeleted', false] }];
    if (status === Expired) {
      dateOp = '$lte';
    } else if (status === Normal) {
      dateOp = '$gte';
      filter.push({ $in: ['$$status', [Normal, Shared]] });
    } else {
      dateOp = '$gte';
      filter.push({ $eq: ['$$status', status] });
    }
    const results = await myModel.aggregate([
      {
        $lookup: {
          from: 'coupons',
          // 当前表字段必须 `let` 之后才能用
          let: { couponId: '$couponId', userId: '$userId', status: '$status' },
          // 在 pipeline 里完成筛选
          pipeline: [
            {
              $match: {
                $expr: {
                  // `$toString` 是内建方法,可以把 `ObjectId` 转换成 `string`
                  $and: [{ $eq: [{ $toString: '$_id' }, '$$couponId'] }, ...filter, { [dateOp]: ['$endAt', new Date()] }],
                },
              },
            },
            // 只要某些字段,在这里筛选
            {
              $project: couponFields,
            },
          ],
          as: 'source',
        },
      },
      {
        // 这种筛选相当 LEFT JOIN,所以需要去掉没有连表内容的结果
        $match: {
          source: { $ne: [] },
        },
      },
      {
        // 为了一次查表出结果,要转换一下输出格式
        $facet: {
          results: [{ $skip: size * (page - 1) }, { $limit: size }],
          count: [
            {
              $count: 'count',
            },
          ],
        },
      },
    ]);

    同事告诉我,这样做的效率不一定高。我觉得,考虑到实际场景,他说的可能没错,不过,早晚要迈出这样的一步。而且,未来我们也应该慢慢把外键改成 ObjectId 类型。

  • node.js 里 ESM 与 CommonJS 的区别

    node.js 里 ESM 与 CommonJS 的区别

    可能大部分同学并不会直接用 node.js 开发 Web 后端程序,但是作为现代化前端,我们日常的各种开发都严重依赖开发脚手架,也即 node.js 环境下的各种工具链。目前已经有一些仓库逐步迁移到 ESM,所以了解一下 node.js 里 ESM 和 CommonJS 的区别也很有必要。

    我还是老习惯,后文列举出的差异并非文档记录,而是我在实操中遇到的大坑小坑,希望记下来能节省将来的时间和大家的时间。

    0. 设计原则

    我们可以把 CommonJS 理解成按需加载,需要什么就加载什么,加载进来就执行。所以可以动态加载、条件加载、循环加载。

    ESM 倾向于静态加载,方便解析依赖,优化运行效率。所以起初不能条件加载或者循环加载。不过后面考虑到实际需求,还是开放了 import() 做动态加载。

    这个设计原则可以导出后面的诸多不同。

    1. package.json

    package.json 里添加 type: 'module' 可以开启本项目的 ESM。不写或者 type: 'commonjs' 则继续使用 CommonJS。

    如果没有此配置,虽然我们代码中写的 好像是 ESM,但其实都会被 Babel 或其它什么工具转译成 CommonJS,最终运行的并不是 ESM。这点一定要注意。

    2. __dirname

    ESM 不再支持用 __dirname__filename 获取正在执行的 JS 文件在系统中的路径。作为替代方案,可以使用 import.meta.url 获取当前文件的 URL,不过返回结果是 file:// 协议,如果要继续使用 __dirname 可以这样:

    import {dirname} from 'path';
    import { fileURLToPath } from 'url';
    
    function getDirname(url) {
      const __filename = fileURLToPath(url);
      return dirname(__filename);
    }

    3. 解构

    使用 CommonJS 时,我们可以直接对导出的对象进行解构,比如:

    // lib.js
    module.exports = {
      foo: 'bar',
    };
    
    // index.js
    const {foo} = require('./lib');

    这样的用法在 ESM 中不可行。ESM 解构只能针对使用 export foo = 'bar' 这样主动暴露出的属性。对于一般对象的解构,我们只能写成:

    // lib.js
    export default {
      foo: 'bar',
    }
    
    // index.js
    import lib from './lib';
    
    const {foo} = lib;

    4. .mjs 文件与 .cjs 文件

    我们知道,node.js 加载模块时可以省去文件扩展名,比如 require('./foo'),不需要写最后的 .js.json,node.js 会自动去目录里查找对应的文件。

    node.js 会根据 type 的不同使用不同默认策略加载 js,我们也可以使用特定扩展名要求 node.js 在加载时使用特定模块类型。比如,我们使用 ESM 时,node.js 会把加载进来的 JS 都当 ESM 处理,如果这些 JS 还在使用 CommonJS 加载其它 JS,就会报错(前面说了,ESM 里不支持 require)。此时,我们可以把目标文件的扩展名写为 .cjs,node.js 就会当它是 CommonJS 来处理了。

    此功能在使用第三方库的时候很有用。比如 Postcss,它会加载项目里的配置文件,但它只支持 CommonJS,这时,如果执行时因没有 require 报错,就可以把配置文件的扩展名改成 .cjs

    5. 顶层 await

    开启 ESM 之后,可以使用顶层 await,省去一个异步函数。

    (这是促使我使用 ESM 的主要原因)

    6. importrequire

    ESM 中,我们可以使用 import 导入 CommonJS 模块和 ESM 模块;但是 CommonJS 的 require 只能用来导入 CommonJS 模块。如果要在 CommonJS 中导入 ESM 模块,需要使用 import() 然后异步处理。

    自然,ESM 里不能用 require

    7. 其它区别

    这些区别我在实际开发中没有遇到,大家自己阅读吧:Modules: ECMAScript modules | Node.js v17.8.0 Documentation (nodejs.org)

    (更多…)
  • 捕获 promisify  `child_process.exec` 的错误

    捕获 promisify `child_process.exec` 的错误

    这个东西文档里没写清楚,所以写篇博客记一下。

    在 Node.js 里,我们可以使用 child_process 下的命令分裂出一个进程,执行其他的命令。这些命令包括 execexecFilespawn

    我比较常使用 execspawn。前者用起来比较方便,只要传入完整的命令和参数,很接近日常命令行体验;后者传参要麻烦一些,不过可以实时获取输出,包括 stdoutstderr,比较方便给用户及时反馈。

    下面贴一下文档里的例子,spawn 的使用将来有机会再说。

    const { exec } = require('child_process');
    exec('cat *.js missing_file | wc -l', (error, stdout, stderr) => {
      if (error) {
        console.error(`exec error: ${error}`);
        return;
      }
      console.log(`stdout: ${stdout}`);
      console.error(`stderr: ${stderr}`);
    });

    Node.js 8 之后,我们可以用 util.promisify() 命令将 exec 转换为 Promise 风格的方法,即不再需要 callback 函数,而是返回 Promise 实例,方便我们链式调用或者使用 Async function。

    此时,它的样子是这样的:

    const util = require('util');
    const exec = util.promisify(require('child_process').exec);
    
    async function lsExample() {
      const { stdout, stderr } = await exec('ls');
      console.log('stdout:', stdout);
      console.error('stderr:', stderr);
    }
    lsExample();

    官方文档没解释清楚错误处理,经过我的尝试,是这样的:

    1. 命令发生错误,或者被意外中断都会引发错误
    2. 如果不出错,就会正确返回 stdoutstderr
    3. 否则,错误实例里会包含 stdoutstderrcode
    4. 1~127 是各种错误编码,128+ 则是 signal + 127 的结果,通常来说是受控于我们的操作。比如使用 FFmpeg 录屏的时候,结束录制就要 Ctrl+C,此时就会得到值为 128 的 code。所以此时很可能不是真的失败。
    const util = require('util');
    const exec = util.promisify(require('child_process').exec);
    
    (async () => {
      let code, stdout, stderr;
      try {
        ({stdout, stderr} = await exec('ls'));
      } catch (e) {
        ({code, stdout, stderr} = e);
      }
    
      if (code && code > 127) {
        // 确实失败了
      }
      console.log('stdout:', stdout);
      console.log('stderr:', stderr);
    })();
  • node.js 里使用 fifo

    node.js 里使用 fifo

    0. 需求

    前两天 Showman 遇到一个需求:

    1. 我们需要在服务器端录制视频
    2. 录制视频的过程主要由 node.js 控制,借助 puppeteer 操作浏览器
    3. 但是也会需要执行一些 shell 命令,此时为安全考虑,我们会启动一个封闭的临时环境给用户执行
    4. 这些封闭环境是用户进程间共用的,不会随时启动随时销毁
    5. 所以 node.js 就需要在其它环境里执行一些操作,返回内容,等待执行完毕后再继续下面的

    于是我的同事就让我用 fifo。

    1. 什么是 fifo

    我以前没有用过 fifo,所以搜索了一下。

    FIFO 特殊文件(同具名管道)与管道类似,只是可以用访问文件系统的方式来访问它。它可以被多个进程同时打开和读写。当进程通过 FIFO 交换数据时,内核将直接在内部交换数据,而不会写入到文件系统中。因此,FIFO 特殊文件在文件系统中没有内容;文件系统的入口(即文件)只是作为引用方式,让各进程能够使用文件名来访问管道。

    原文:https://man7.org/linux/man-pages/man7/fifo.7.html

    管道大家应该都知道,把 A 进程的输出直接输入到 B 进程里,加快处理速度。fifo 与管道的差别就是 fifo 可以通过文件路径直接访问,用起来更简单。

    2. 在命令行里使用 fifo

    创建 fifo,使用 mkfifo 命令:

    mkfifo xxx.fifo

    写入内容到 fifo:

    echo "something" > xxx.fifo

    读取 fifo:

    cat xxx.fifo

    因为 fifo 是管道,内容直接走内核,所以实际上硬盘上不会存储任何内容。如果我们在写入之后再 cat fifo,就不会得到任何内容。

    3. 在 node.js 里使用 fifo

    在 node.js 里使用 fifo 需要用 fs.opennet.Socket。因为我需要在执行完毕后继续下一步,所以进行了 Promise 封装:

    try {
      // 为避免执行时间过长导致进程超时,不断输出些内容
      const interval = setInterval(() => {
        log('termlang is processing...');
      }, 3E4);
      await new Promise((resolve, reject) => {
        // 打开名为 $basename-$lineno.sp.fifo 的管道
        open('./$basename-$lineno.sp.fifo', constants.O_RDONLY | constants.O_NONBLOCK, (err, fd) => {
          if (err) {
            clearInterval(interval);
            reject(err);
          }
          const pipe = new Socket({fd});
          pipe.on('data', data => {
            data = data.toString();
            // 以输出内容包括 finished 或 errored 为结束标记
            if (/(finished|errored)/.test(data)) {
              resolve(data);
            }
          });
        });
      });
      clearInterval(interval);
    } catch (err) {
      log(err);
    }

    4. 总结

    作为半路出家的前端,我对系统、对 Linux 一直缺乏了解。所以类似管道这种东西,我一直也不太熟悉,这次算学会了一个新技能,记录分享一下。

  • 使用 Node.js 驱动 FFMPEG  录屏

    使用 Node.js 驱动 FFMPEG 录屏

    FFMPEG 功能非常强大,不仅能转换视频格式、压缩视频、添加字幕等,还能录制屏幕内容。使用 FFMPEG 录屏的方法很简单:

    # Linux
    ffmpeg -video_size 1024x768 -framerate 25 -f x11grab -i :0.0+100,200 output.mp4 
    
    # macOS
    ffmpeg -f avfoundation -list_devices true -i "" 
    
    # Windows
    ffmpeg -f gdigrab -framerate 30 -offset_x 10 -offset_y 20 -video_size 640x480 -show_region 1 -i desktop output.mkv 

    更详细的介绍可以参考官方文档,这里不做太多摘抄。

    使用 Node.js child_process 可以调用外部程序执行操作,详细的用法可以参考官方文档。大概来说,分为:

    1. exec/execFile 调用程序,当执行完成,一次性获得结果
    2. spawn 调用程序,过程中流式获得输出

    我的录屏对时间点有非常高的要求,力求准确。所以我只能选择 spawn,然后通过检查日志输出得到准确的录制开始时间。

    所以,我的代码大概如此:

    class FfmpegRecorder {
      // 其它函数略去
      startRecording(args) {
        this.isFileReady = new Promise((resolve, reject) => {
          this._isFileReadyResolve = resolve;
          this._isFileReadyReject = reject;
        });
        this.recording = spawn(this.ffmpeg, args);
        
        this.recording.stdout.on('data', this.onStdData);
        this.recording.stderr.on('data', this.onStdErr);
      }
      stopRecording() {
        this.recording.kill('SIGINT');
        const path = resolve(process.cwd(), 'a.mp4');
        return path;
      }
    
      onStdData(data) {
        data = data.toString();
        this.stdout += data;
        if (this.stdout.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
          this._isFileReadyResolve();
          this._isFileReadyReject = null;
        }
      }
      onStdErr(data) {
        data = data.toString();
        this.stderr += data;
        if (this.stderr.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
          this._isFileReadyResolve();
          this._isFileReadyReject = null;
        }
      }
    }

    根据我在命令行里直接跑 ffmpeg 的结果,它会先初始化,然后开始录屏。录屏开始时,会输出 Output #0, mp4, to xxx.mp4 这样的日志,所以我就会反复检查 stdoutstderr,直到关键日志出现,然后告诉外面的程序开始录制了。

    这里比较奇怪的是,日志输出应该是正常的,走 stdout 通道,结果只能从 stderr 通道获取。我为防万一,两边都留下了同样的代码。可能我对 Linux 理解不够,将来再研究一下为什么会这样吧。

    上面的代码忽略了 onErroronExit 部分,有兴趣的同学请等我开源(公司代码)。

    在 Linux,stopRecording(),即 kill('SIGINT')(相当于按下 Ctrl+C)之后,FFMPEG 会终止录屏,并且生成可以播放的视频文件。但是在 Windows,只会留下一个无法播放的文件。

    通过观察命令行直接运行 ffmpeg 的结果和 node.js 保存的结果,我发现缺失了结束录制后处理视频文件的部分。实际上 FFMPEG 录屏时只会记录视频内容,录制结束后才会生成影片的meta 信息,而播放器必须读取后者才可以正常播放。所以我猜测在 Windows 下 kill('SIGINT') 会直接彻底杀死进程,而不仅仅是发送一个信号给 FFMPEG,并让它完成后续的工作。

    做出判断后,我尝试用按下 q 的方式通知 FFMPEG 停止工作,并等待 5s。果然成功,于是我构建了新类,继承前面的 FfmpegRecorder,大概代码如下:

    const endTag = /kb\/s:\d+.\d{2}/m;
    
    class WindowsRecorder extend FfmpegRecorder {
      stopRecording() {
        // 向 child process 输入 `q`
        this.recording.stdin.setEncoding('utf8');
        this.recording.stdin.write('q');
        const p = new Promise((resolve, reject) => {
          this._stopResolve = resolve;
          this._stopReject = reject;
        });
        // 设置一个超时保护 15s
        setTimeout(() => {
          this._stopReject();
        }, 15E3);
        return p;
      }
      onStdData(data) {
        super.onStdData(data);
        if (this._stopResolve && endTag.test(this.stdout)) {
          const path = resolve(process.cwd(), 'a.mp4');
          this._stopResolve(path);
        }
      }
      onStdErr(data) {
        super.onStdErr(data);
        if (this._stopResolve && endTag.test(this.stderr)) {
          const path = resolve(process.cwd(), 'a.mp4');
          this._stopResolve(path);
        }
      }
    }

    为了功能完善,我没有选择等待固定的时间,而是继续检查日志,直到发现 endTag 标记。另外,我也留下了 15s 超时保护,避免某些暂时没遇到的问题破坏功能。

    至此,功能基本稳定完成。

  • 解决“[ERR_PACKAGE_PATH_NOT_EXPORTED]: No “exports” main resolved”

    解决“[ERR_PACKAGE_PATH_NOT_EXPORTED]: No “exports” main resolved”

    周末例行升级系统,今天打开项目,npm run dev,就报这个错误。检查代码,没变化,依赖也没变化。因为错误位置在 main.js,尝试给它加上 exports,无果。

    Google 之,发现一个非常新的 issue:https://github.com/babel/babel/issues/11216,3天前,来自 @babel/babel 仓库,多半是了。

    点进去一看,原来 node.js 从 13.10.1 之后,对 package.json 里的 exports 属性解读出现问题,继而导致 Babel 抛出错误。最简单的解决方法就是升级 Babel 到 7.8.4。

    升级后问题解决。

  • Node.js 里使用 Promise 的小技巧

    Node.js 里使用 Promise 的小技巧

    Node.js 8 的时候,引入了 util.promisify() 方法,可以把 node-like 的回调函数改造成返回 Promise 实例的方法,我当时还写了篇博文《Node.js 8 中的 util.promisify》小记。

    所以我现在写 Node.js 基本都是这种风格:

    const fs = require('fs');
    const {promisify} = require('util');
    
    const readFile = promisify(fs.readFile);
    const writeFile = promisify(fs.writeFile);

    刚才在推上看到两位大佬聊起这个话题,发现可以这么搞:

    去查了一下 Node.js 的文档,发现这是 v10 新增的 API,升级之后即可使用。

    我比较喜欢这么做:

    const {promises: {readFile, writeFile}} = require('fs');
    
    (async () => {
      let content = await readFile('1.txt', 'utf8');
      content = doSthToContent(content);
      await writeFile('2.txt', content, 'utf8');
      console.log('ok');
    })();
  • Promise 改造 child_process.exec

    Promise 改造 child_process.exec

    child_process 是 Node.js 的一个内建模块,用于分裂出(spawn)一个子进程,执行一些特定操作。.exec() 是它的方法,接受一个参数,即要执行的 shell 命令,然后通过回调返回结果。.exec().spawn() 的不同之处在于,前者重在返回结果,后者则重在返回内容。所以当你需要执行一个命令,你并不关心执行过程中发生了什么,只要看到结果就好,那么就用 .exec();反之,假如执行过程中产生的信息对你特别有价值,你并不是特别在意结果,就应该用 .spawn()

    另外,我之前在《Node.js 8 中的 util.promisify》中介绍过,Node.js 8 引入了一个新函数,位于 util 模块,叫做 promisify(),用于将回调风格的 Node.js 函数改造成 Promise 规范的函数。

    OK,背景知识介绍结束。近期开发中,我需要执行一个命令,并且取得它的 stdoutstderrexit code,使用 promisify() 之后发现没有 exit code,于是只好重新写了一下,代码如下:

    import {exec as BaseExec} from 'util';
    
    function exec(command, options) {
      return new Promise((resolve, reject) => {
        let result = {};
        const cp = baseExec(command, options, (err, stdout, stderr) => {
          if (err) {
            err.stdout = stdout;
            err.stderr = stderr;
            reject(err);
            return;
          }
    
          result.stdout = stdout;
          result.stderr = stderr;
          if ('code' in result) {
            resolve(result);
          }
        });
    
        cp.on('exit', (code, signal) => {
          result.code = code;
          result.signal = signal;
          if ('stdout' in result) {
            resolve(result);
          }
        });
      });
    }
    

    希望对大家有用。

    新键盘到了,FC660C,静电容,试用一下,效果还不错。略硬,段落感不强,声音不大。

  • Node.js 8 中的 util.promisify

    Node.js 8 中的 util.promisify

    Node.js 8 于上个月月底正式发布,带来了很多新特性。其中比较值得注意的,便有 util.promisify() 这个方法。

    如果你已经很熟悉 Promise,请继续往下看。如果你还不熟悉 Promise,可以先跳过去看下下章:Promise 介绍

    util.promisify()

    虽然 Promise 已经普及,但是 Node.js 里仍然有大量依赖回调的异步函数,如果我们把每个函数都封装一遍,那真是齁麻烦齁麻烦的,比齁还麻烦。

    所以 Node.js 8 就提供了 util.promisify() 这个方法,方便我们把原来的异步回调方法改成支持 Promise 的方法,接下来,想继续 .then().then().then() 搞队列,还是 await 就看实际需要了。

    我们看下范例,让读取目录文件状态的 fs.stat 支持 Promise:

    const util = require('util');
    const fs = require('fs');
    
    const stat = util.promisify(fs.stat);
    stat('.')
      .then((stats) => {
        // Do something with `stats`
      })
      .catch((error) => {
        // Handle the error.
      });
    

    怎么样,很简单吧?按照文档的说法,只要符合 Node.js 的回调风格,所有函数都可以这样转换。也就是说,只要满足下面两个条件,无论是不是原生方法,都可以:

    1. 最后一个参数是回调函数
    2. 回调函数的参数为 (err, result),前面是可能的错误,后面是正常的结果

    结合 Await/Async 使用

    同样是上面的例子,如果想要结合 Await/Async,可以这样使用:

    const util = require('util');
    const fs = require('fs');
    
    const stat = util.promisify(fs.stat);
    async function readStats(dir) {
      try {
        let stats = await stat(dir);
        // Do something with `stats`
      } catch (err) { // Handle the error.
        console.log(err);
      }
    }
    readStats('.');
    

    自定义 Promise 化处理函数

    那如果现有的使用回调的函数不符合这个风格,还能用 util.promisify() 么?答案也是肯定的。我们只要给函数增加一个属性 util.promisify.custom,指定一个函数作为 Promise 化处理函数,即可。请看下面的代码:

    const util = require('util');
    
    // 这就是要处理的使用回调的函数
    function doSomething(foo, callback) { 
      // ...
    }
    
    // 给它增加一个方法,用来在 Promise 化时调用
    doSomething[util.promisify.custom] = function(foo) { 
      // 自定义生成 Promise 的逻辑
      return getPromiseSomehow(); 
    };
    
    const promisified = util.promisify(doSomething);
    console.log(promisified === doSomething[util.promisify.custom]);
    // prints 'true'
    

    如此一来,任何时候我们对目标函数 doSomething 进行 Promise 化处理,都会得到之前定义的函数。运行它,就会按照我们设计的特定逻辑返回 Promise 实例。

    我们就可以升级以前所有的异步回调函数了。

    (更多…)