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 类型定义文件。这个 article
是Article
的实例,而Article
实际上是 mongoose 动态构造出来的一个类(应该是通过了某种元编程技术)。在类型定义中是这么反映的:
interface Model<T extends Document> extends NodeJS.EventEmitter, ModelProperties { new(doc?: Object): T; /* other things */ }
而 article 就是那个<T extends Document>
,而 Document
的save
方法是这么写的:
/** * 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 日