JavaScript
Node.js
MongoDB
mongoose
Koa

KoaとMongoDB GridFSでちょっと真面目にファイルアップローダー実装

KoaとMongoDBのGridFSを使った、以下の基本的な機能があるファイルのアップローダーの実装をしてみました。
簡易的にmimeTypeがtext image audio videoの4種類のファイルに対応します。

  • ファイルアップロード
  • ファイルアップロード進捗表示
  • アップロードキャンセル(ただしファイル単位)
  • ファイル削除
  • ファイルダウンロード

初めに

今回使用するライブラリは以下の通りです。
* koa
* koa-router
* koa-static
* mongoose
* mongoose-gridfs
* async-busboy

ボディーパーサーにはkoa-bodyparserというのもありますが、koa-bodyparserはmultipart/form-dataに対応していないため、代わりにasync-busboyを使用しました。

サーバー側の処理

gridfsインスタンスの生成

gridfsインスタンスの生成は、mongodb接続が完了した後でないとだめらしいので、connectionプロミスのthen()でインスタンスを生成します。

const MONGODB_URI = 'mongodb://user:password@localhost:27017/test';
let gridfs = null;
let mongoFile = null;
mongoose.connect(MONGODB_URI).then(_ => {
    gridfs = require('mongoose-gridfs')({
        collection: 'myFS',
        model: 'Files',
        mongooseConnection: mongoose.connection
    });
    mongoFile = gridfs.model;
});

ファイルリストの生成

ルートにアクセスすると、アップロード済みのファイルがリスト表示されるようにします。
テンプレートエンジンは使用せず、該当箇所に{list}と書かれたHTMLファイルを読み込み、string.replace()で置換したものを返しています。

router.get('/', async ctx => {
    await Grid.find({}, async (err, gridFiles) => {
        if (err) {
            ctx.throw(500);
            return;
        }
        let body = fs.readFileSync('views/filetest.html', 'utf8');
        ctx.type = 'html';
        ctx.body = body.replace('{list}', gridFiles.map(file => {
            const type = file.contentType.split('/')[0];
            // ファイルタイプに応じてプレビュータグを変更、テキストファイルはiframeで対応
            const tag = { text: 'iframe', image: 'img', audio: 'audio', video: 'video' }[type];
            const attr = ['audio', 'video'].includes(type) ? 'controls controlsList="nodownload"' : '';
            const name = file.filename;
            // なんちゃってSSR
            return `
<div id="f${file.id}" class="item ${type}">
    <${tag} src="/${file.id}" ${attr}></${tag}>
    <div class="name"><span>${name}</span><div class="delete-button" data-id="${file.id}">✖</div></div>
    <a class="download-button" href="/${file.id}" download="${name}"></a>
</div>`;
        }).join('\n'));
    });
});

ファイルダウンロード(メディア再生時シーク対応)

ダウンロードは、ファイルIDをパスで渡すことで行います。
GridFSからストリームでファイルを取得し、ctx.bodyに設定すればファイルを返すことができます。

router.get('/:id', async ctx => {
    const stream = gridfs.storage.createReadStream({
        _id: ctx.params.id,
        root: 'myFS'
    });
    ctx.type = meta.contentType;
    ctx.body = stream;
});

ちょっと注意しなければならないのが、gridfsのインスタンス生成時にcollectionオプションを設定した場合、gridfs.storage.createReadStream()で、rootオプションにcollectionを設定しないとGridFSからファイルが取得できません。
(rootというオプション名からツリー構造的にファイルが登録できるのかと想像しますが、そこまで突っ込んで勉強していません)
あと、単純にダウンロードだけを行うのでしたらこれでもいいのですが、動画や音声ファイルを<audio>や<video>を使って再生する場合でもこのルーティングを通ります。これだと再生においてシークができません。ですのでシークができるようにrangeヘッダーに対応します。

router.get('/:id', async ctx => {
    const fileId = ctx.params.id;
    const meta = await Grid.findOne({ _id: fileId });
    if (!meta) {
        console.warn('meta is null', fileId);
        return;
    }
    const range = ctx.header.range;
    if (range) {
        // rangeヘッダーがあれば該当部分のみを返す
        let [start, end] = range.replace(/bytes=/, '').split('-');
        start = parseInt(start, 10);
        end = end ? parseInt(end, 10) : meta.length - 1;
        const chunkSize = (end - start) + 1;
        ctx.set('Accept-Ranges', `bytes`);
        ctx.set('Content-Range', `bytes ${start}-${end}/${meta.length}`);
        ctx.set('Content-Length', chunkSize);
        ctx.status = 206;
        const stream = gridfs.storage.createReadStream({
            _id: fileId,
            root: 'myFS',
            range: {
                startPos: start,
                endPos: end
            }
        });
        ctx.type = meta.contentType;
        ctx.body = stream;
    } else {
        const stream = gridfs.storage.createReadStream({
            _id: fileId,
            root: 'myFS'
        });
        ctx.type = meta.contentType;
        ctx.body = stream;
    }
});

これで、シークができるようになりました。

ファイルアップロード

送られてきたファイルをリクエストから取得し、GridFSに登録します。また、登録が完了したら、登録された情報(特にfile._id)を返すようにします。

router.post('/fileup', async ctx => {
    try {
        // リクエストからファイルを取得
        const { files, fields } = await asyncBusboy(ctx.req);
        const res = [];
        await Promise.all(files.map(async file => {
            await (_ => {
                return new Promise((resolve, reject) => {
                    // GridFSに
                    gridfs.write(
                        {
                            filename: file.filename,
                            contentType: file.mimeType
                        },
                        file,
                        (err, createdFile) => {
                            if (err) {
                                reject(err);
                            } else {
                                // 登録された情報を配列に追加
                                res.push({
                                    id: createdFile._id.toString(), // file._idはObjectId型
                                    type: createdFile.contentType,
                                    name: createdFile.filename
                                });
                                resolve();
                            }
                        }
                    );
                });
            })();
        }));
        // 結果をクライアントに返す
        ctx.body = JSON.stringify(res);
    } catch (err) {
        ctx.throw(500);
    }
});

ファイル削除

送られてきたIDをもとに、unlinkById()で削除します。

router.get('/filedel/:id', async ctx => {
    await (_ => {
        return new Promise(resolve => {
            mongoFile.unlinkById(ctx.params.id, (err, unlinkedFile) => {
                if (err) {
                    ctx.throw(500);
                } else {
                    ctx.body = ctx.params.id;
                }
                resolve();
            });
        });
    })();
});

クライアント側の処理

ファイルアップロード

ファイルのアップロードはD&Dで行えるようにします。また、アップロード中でも、新たにD&Dでアップロードファイルの追加が行えるよう、キューに登録するという形にします。

const uploadQueue = [];

document.ondragover = evt => evt.preventDefault();
document.ondrop = async evt => {
    evt.preventDefault();
    const files = [...evt.dataTransfer.files];
    if (files.length) {
        // アップロードファイルリストを表示        
        uploadList.classList.add('show');
        uploadListTab.classList.add('show');
    }
    files.forEach(file => {
        // キューに登録
        uploadFileQueue.push(file);
        // アップロードファイルリスト(UI)にアイテムを追加
        createUploadItem(file);
    });
    // アップロード数表示の更新
    uploadCount.textContent = uploadFileQueue.length;
    // アップロード中でなければアップロードを開始する
    if (!uploading) {
        uploading = true;
        upload(uploadFileQueue[0]);
    }
}

アップロード処理は、キューからジョブを取得して行います。
Fetch APIを使用したかったのですが、Fetch APIだとアップロードの進捗に対応していないらしく、XHRで行うようにします。
また、アップロードのキャンセルにも対応させます。

function upload(file) {
    // アップロードファイルリストの対象アイテムで、アップロード処理で扱う各エレメントを取得
    let uploadItem = document.getElementById(`upload_${file.uploadId}`);
    let cancelButton = document.getElementById(`uploadCancel_${file.uploadId}`);
    let size = document.getElementById(`uploadFileSize_${file.uploadId}`);
    let progressBar = document.getElementById(`uploadProgress_${file.uploadId}`);

    // スクロールで表示領域外だった場合もあるため、表示領域内になるようスクロール
    uploadItem.scrollIntoView();

    let xhr = new XMLHttpRequest();

    // 次のジョブへ(使用する変数を渡すのが面倒なのでクロージャーで関数定義)
    const next = _ => {
        // アップロードファイルリストから対象アイテムを削除
        uploadItem.remove();
        xhr = null;
        uploadItem = null;
        cancelButton.onclick = null;
        cancelButton = null;
        size = null;
        progressBar = null;

        // キューから終了したジョブを削除
        const index = uploadFileQueue.findIndex(item => item.id === file.id);
        if (index !== -1) {
            uploadFileQueue.splice(index, 1);
        }

        // アップロード数表示の更新
        uploadCount.textContent = uploadFileQueue.length;

        if (uploadFileQueue.length) {
            // キューが存在すれば次のファイルのアップロードを行う
            upload(uploadFileQueue[0]);
        } else {
            // アップロードが完了したら、
            // アップロードファイルリストを非表示にし、
            // アップロード中フラグをクリア
            uploadList.classList.remove('show');
            uploadListTab.classList.remove('show');
            uploading = false;
        }
    }

    cancelButton.onclick = evt => {
        // アップロードキャンセル
        xhr.abort();
        next();
    }

    xhr.upload.onprogress = evt => {
        // アップロードの進捗更新(プログレスバーおよびアップロード済みのサイズ)
        progressBar.style.width = `${(evt.loaded / evt.total) * 100 | 0}%`;
        size.textContent = `${readableFileSize(evt.loaded)}/${readableFileSize(evt.total)}`;
    };

    xhr.onload = evt => {
        // 完了したら、レスポンスでGridFSに登録されたファイル情報が返ってくるので、
        // それをもとにファイルリストのアイテムを作成する
        createFileListItem(xhr.response[0]);
        next();
    };

    // ファイルをアップロード
    xhr.open('POST', `/fileup`);
    xhr.responseType = 'json';
    const fd = new FormData();
    fd.append('file', file, file.name);
    xhr.send(fd);
}

エラーログ

サーバー側なのですが、以下のようにエラーログが吐き出されます。このエラーが吐き出されないようにするためにはどうすればいいのか調べてみましたが、わかりませんでした。
エラー.png

最後に

今回実装したソースをGitHubに上げました。
ソース
fileuploader.gif