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 日
Read by: 366