(中文) 一个不正确的 TypeScript 类型定义引发的血案

Извините, этот техт доступен только в “中文” и “Американский Английский”. For the sake of viewer convenience, the content is shown below in this site default language. You may click one of the links to switch the site language to another available language.

Mongoose 的 TypeScript 类型定义并不完全正确!

昨天晚上在 bgs 研究了(或者说调试了)XGS 写的 nodejs 服务器项目。他在用 koa 作为服务器,mongodb 作为数据存储,写一个不知道什么项目(似乎名为「写作者平台」/ «Write’s Platform»)。反正当时还处于很初级的阶段,只有两个路由,一个 get,一个 post,后者是用来向 mongodb 提交一篇文章的。在测试过程中,他发现了一个问题——他在保存文章的回调中更改了响应的内容(如下),但是实际测试得到的响应内容却是 koa 默认的 OK。

router.post('/path', (ctx, next) => {
    const article = new Article( /* something */ );
    article.save((err, article) => {
      if (err){
        ctx.body = err;
      } else {
        ctx.body = "success";
      }
    });
})

起初我发现,他的代码中,router.post('/path', (ctx, next) => { /* body */ });/* body */这段中并没有返回 next(),这应该会导致异步的回调没有被阻塞。

接着我想,可能又是 koa 的版本搞混了什么的,因为 koa v2,koa router 什么的版本管理都很混乱,长期处于虽然大家都想使用最新版,但是默认的却一直是旧版的情况。后来又出现了 koa 是新版,router 是旧版的情况,曾经我为别人解决过这个问题。

但是这次却不是。并且把返回 promise 切换为使用 async/await之后(如下),情况依然没有好转。

router.post('/path', (ctx, next) => {
    const article = new Article( /* something */ );
    return article.save((err, saved) => {
      if (err){
        ctx.body = err;
      } else {
        console.log(saved);
        ctx.body = "success";
      }
      // or `return next();` here.
    }).then((thing) => {
        console.log(thing);
        return next();
    });
})

(过程中我们发现这个传给回调的 thing居然是undefined,这是个伏笔)

渐渐地我被各种函数的签名(比如究竟应该返回一个什么,是 next() 还是一个 promise,而 next 的类型又是什么?)搞得烦躁起来。于是我提议使用 TypeScript 来辅助检查。

我们开始怀疑 article.save这个函数。因为在 koa 官方(仅有)的、涉及到等待异步动作的 router 代码样例里,完全类似的代码是可以工作的:

router.get(
  '/users/:id',
  function (ctx, next) {
    return User.findOne(ctx.params.id).then(function(user) {
      ctx.user = user;
      return next();

    });
  },
  function (ctx) {
    console.log(ctx.user);
    // => { id: 17, name: "Alex" }
  }
);

于是我们着重检查了 mongoose 的 TypeScript 类型定义文件。这个 articleArticle的实例,而Article实际上是 mongoose 动态构造出来的一个类(应该是通过了某种元编程技术)。在类型定义中是这么反映的:

interface Model<T extends Document> extends NodeJS.EventEmitter, ModelProperties {
    new(doc?: Object): T;
    /* other things */
}

而 article 就是那个<T extends Document>,而 Documentsave方法是这么写的:

    /**
     * Saves this document.
     * @param options options optional options
     * @param options.safe overrides schema's safe option
     * @param options.validateBeforeSave set to false to save without validating.
     * @param fn optional callback
     */
    save(options?: SaveOptions, fn?: (err: any, product: this, numAffected: number) => void): Promise<this>;
    save(fn?: (err: any, product: this, numAffected: number) => void): Promise<this>;

(取自 npm install @types/mongoose的结果,不知为何与 DefinitelyTyped 上的有所不同。)看到这,特别是Promise<this>,我就确信了,这个函数没有问题。可是上面返回的thing又确乎是一个undefined,难道是我们的使用姿势有问题?

万般无奈之中,TypeScript 也不靠谱之际,我们只能诉诸源码了。鉴于 mongoose 的代码风格,直接搜索 .prototype.save就能找到。它实际上是在这里,但是看到这里让人更崩溃了:

Model.prototype.save = function(options, fn) {
  if (typeof options === 'function') {
    fn = options;
    options = undefined;
  }

  if (!options) {
    options = {};
  }

  if (fn) {
    fn = this.constructor.$wrapCallback(fn);
  }

  return this.$__save(options, fn);
};

它返回的是this.$__save的结果,但这个函数并没有返回值!日志显示,这里的this就是这个article实例(一个Document)。然而线索到这里似乎断了。怎么能凭空从一个没有返回值的函数返回一个 promise 呢?

我们又想到了一招:查看调用栈。但是 node 的调试器在这时却不好使了。我们只能用原始粗暴的console.trace手动搞出调用栈。虽然没有直接的线索,这个调用栈给了我们一些提示——save并不是直接调用的,而是经过了某种hook的封装。

这时,我想到了直接console.log(article.save),它居然是一个wrappedPointCut出来的结果!再在代码里搜索wrappedPointCut就非常接近真相了。关键的代码在这里

model.prototype[pointCut] = (function(_newName) {
      return function wrappedPointCut() {
        var Promise = PromiseProvider.get();

        var _this = this;
        /* something omitted for simplicity */
        var promise = new Promise.ES6(function(resolve, reject) {
          args.push(function(error) {
            if (error) {
              /* something omitted for simplicity */
              reject(error);
              return;
            }
            $results = Array.prototype.slice.call(arguments, 1);
            resolve.apply(promise, $results);
          });
          _this[_newName].apply(_this, args);
        });
        if (fn) {
          /* something omitted for simplicity */
          return promise.then(
            function() {
              process.nextTick(function() {
                fn.apply(null, [null].concat($results));
              });
            },
            function(error) {
              process.nextTick(function() {
                fn(error);
              });
            });
        }
        return promise;
      };
    })(newName);

注意到如果没有fn,那么会在第 33 行返回第 7 行定义的 promise,如果有fn,就会直接在第 21 行返回接续之前的 promise 的另一个 promise,这个接续(.then())里面利用了fn处理了结果,却没有返回。也就是说,这时候的返回值是Promise<undefined>而不是Promise<this>——我们被这样的 TypeScript 类型定义坑惨了!如果它一开始就告诉我们会这样,那该多好呀,就像这样:

    save(options: SaveOptions, fn: (err: any, product: this, numAffected: number) => void): Promise<undefined>;
    save(fn: (err: any, product: this, numAffected: number) => void): Promise<undefined>;
    save(options?: SaveOptions): Promise<this>;
    save(): Promise<this>;

2017 年 5 月 4 日

freetiger18 :