起因
在开发一个备份七牛文件到本地的工具过程中,使用到了阿里开源的 Egg.js 框架,在此过程中遇到了一些利用 ES6 Generator 函数以及 Promise 进行流程控制和 NodeJS 流相关的问题,总结过后分享一下。
项目的需求如下:
下载:利用七牛’资源列举’接口获取文件 key 名,得到可下载的外链,进行下载并保存到本地;
上传:开放 Web 端多文件上传接口,接受 HTML5 input[type=”file”] 形式的文件上传,上传到业务服务器后保存本地一份,再由服务器直传到七牛云服务器
利用 Generator Function 和 Promise 解决多层异步回调
在 Generator Function、Async Function 和 Promise 大行其道的今天,在网上搜索相关名词,大部分都会搜到如何利用 Promise 改写回调函数
这类文章,然而有时候我们会碰到回调函数写法和Promise、Generator Function 写法并存的情况,比如七牛的Node.js服务端 SDK,就是回调函数写法的,而 Egg.js 基于 Koa 1.x 版本,大量使用 Generator Function,这里就会有一些坑。
首先介绍 Egg.js 约定的部分目录如下
egg目录结构约定
app/router.js 用于配置 URL 路由规则
app/controller/** 用于解析用户的输入,处理后返回相应的结果
app/service/** 用于编写业务逻辑层
controller 是直接和 router 相关的,故controller职责主要是解析请求,调用service获取数据并返回给客户端。
service层主要做操作数据库、上传文件等业务逻辑操作
假设我们要从七牛服务器获取文件信息,有如下接口:
// bucketManager 是构造的一个七牛资源管理对象
bucketManager.listPrefix(args, options, callback){}
// 很显然七牛的资源列举对象是callback写法的,而在Egg里,我们一般把获取资源写作一个service,再在controller里调用
// 于是第一反应这么写
// app/controller/backup.js
* save(options) {
const result = yield this.ctx.service.qiniuOperation.listFiles(options);
}
// app/service/qiniu_Operation.js
* listFiles (options) {
bucketManager.listPrefix(args, options, (err, respBody, respInfo) => {
if (err) throw err;
if (respInfo.statusCode === 200) {
// 异步数据库操作
yield this.ctx.service.backup.databaseOperation();
}
});
}
当我们这么写的时候,很快会提示运行时错误Unexpected strict mode reserved word yield
。
究其原因是 yield 是不能被用在一个非generator函数里的,上面代码中包裹yield 的环境是一个回调函数(匿名函数),故yield是不能使用的。于是就遇到一个问题,如何在callback写法的sdk中使用generator函数进行异步流程控制。
由于对generator的不熟悉,这个问题查了很久都没有答案,直到在CNode的精华区看到一个帖子,第七部分讲解 app/service 的例子给了我很大启发,例子是这样的
module.exports = app=>(class BaiduService extends app.Service {
constructor(ctx) {
super(ctx);
this.config = this.app.config;
}
* getBaiduHomePage() {
let data = yield new Promise((resolve, reject)=> {
require('request').get('http://www.baidu.com', function (err, res, data) {
if (err) return reject(err);
return resolve(data);
})
});
return data;
}
});
我们可以yield一个generator function,还可以yield一个Promise。上面代码中我的思维停留在在每一个异步的回调函数中处理下一步的操作,而例子中则巧妙的应用Promise,在回调函数获取到数据后利用resolve将控制交还到controller,controller无需关心service发生了什么,只需要yield service提供的函数即可获取到数据。于是改写代码如下:
// app/controller/backup.js
const result = yield this.ctx.service.qiniuOperation.listFiles(options);
// app/service/qiniu_Operation.js
* listFiles(options) {
const files = yield new Promise((resolve, reject) => {
bucketManager.listPrefix(args, options, (err, respBody, respInfo) => {
if (err) throw err;
if (respInfo.statusCode === 200) {
return resolve(respBody);
} else {
return reject();
}
});
});
return files;
}
上传文件
在项目开发中首先做了下载到本地功能,再去做的上传功能。
在实现文件下载到本地时,一开始没有认真看Egg文档中HTTP Client一节,比较笨的使用了NodeJS原生的http模块的request方法来下载云端文件。获取到文件buffer之后,采用fs.appendFile将buffer保存到本地文件。
再做上传功能时,有个需求是在上传前保存一份备份到本地。查阅Egg文档,对于单文件提供了getFileStream*()方法,对于多文件上传提供了multipart插件。通过log两种方法的返回值,他们都返回一个FileSreanm对象。
这里由于惯性思维,我选择了直接读取FileStream对象中的buffer,发现stream._readableState.buffer是一个长度为1的数组。便直接复用下载文件的fs.appendFile那部分代码,将buffer直接保存为文件。
然而后来有个需求是要求限制上传文件大小为4mb,在测试的时候才发现别说4mb,超过60多k的文件就传不上去了,一次请求服务端最多能收到64k左右的数据,由于HTTP Client的30000ms timeout时间的限制,还会导致30s后服务进程退出。类似这个issue.
这让我发现我对NodeJS里流的概念理解的太过浅薄了,上传时传来的FileStream对象是一个Readable Stream,Node文档告诉我们通过stream._readableState.buffer可以获取到缓存数据,这个数据的大小是由highWaterMark选项指定的,在没有被持续读的时候,stream是暂停的,没有被消费掉,这会导致浏览器卡死,并导致http timeout的问题。所以通过直接读取buffer下载文件,在文件超过一定大小时,就行不通了,这在思路上就是有问题的。
在参考了egg-example中关于上传的例子之后, 改为创建一个可写流来接收上传传输来的FileStream,并通过pipe()方法让流持续被写入。代码如下:
// app/controller/backup.js
* webMultiUpload () {
const parts = this.ctx.multipart();
while((part = yield parts)) {
yield this.ctx.service.backup.saveToLocal(keyName, part);
}
}
// app/service/backup.js
* saveToLocal (keyName, fileStream) {
return new Promise((resolve, reject) => {
mkdirp(dir, (err) => {
resolve(fileStream);
});
})
.then((fileStream) => {
const ws = fs.createWriteStream(keyName);
fileStream.pipe(ws);
})
.catch(err => {
console.log(err);
});
}