Egg.js開發七牛雲備份專案總結

rbe發表於2019-02-16

起因

在開發一個備份七牛檔案到本地的工具過程中,使用到了阿里開源的 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/** 用於編寫業務邏輯層

  1. controller 是直接和 router 相關的,故controller職責主要是解析請求,呼叫service獲取資料並返回給客戶端。

  2. service層主要做運算元據庫、上傳檔案等業務邏輯操作

  3. 非同步操作繼承於Koa 1.x的 Generator 函式寫法

假設我們要從七牛伺服器獲取檔案資訊,有如下介面:

// 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);
    });
}

相關文章