基于NodeJs的Express及Webuploader实现大文件分片上传与合并(一)

基于NodeJs进行前端分段上传实现,我选择比较通用的Express框架及Webuploader上传组件实现。

我在之前项目中设计实现的大文件上传方案,尤其注重上传文件的快速本地化(局域网服务器),之后通过中心控制服务器进行分段任务的传输与合并。该方案能够很好的解决有限时间大文件上传的需求,但在前端这块儿还是一次性上传到本地(局域网服务器),试着想进行分段上传优化。

之前项目的设计方案中局域网服务器部署的是NodeJs,之所以在局域网服务器部署NodeJs服务支持文件上传本地化,是因为要保持局域网服务器的逻辑功能简单(只负责接收文件,获取分段任务,发送分段文件信息),不需要复杂的逻辑,而且需要跟前端配合,考虑到实现成本、团队的技术人员分配情况(前端居多)及后期维护,因此NodeJs服务最为合适。

这里提供一些学习上手资料:

- Nodejs入门教程——了解上手Nodejs,知道他能做什么,如何上手做

- Nodejs官网——官方网站,提供多平台下载资源,Document,API等(英文)

- Nodejs中英文文档——结合入门教程,上手更快(英文一时间困难的,中英文文档配套着看)

- Express框架官网——教你如何快速搭建基于Nodejs的Web开发框架(英文,不过还好,也有中文的)

- Express中文开发文档——结合官网英文,上手更快

- 阮一峰老师的Express参考教程——阮一峰老师,大牛的分享,值得学习

- WebUploader前端上传组件——传说中的大文件分片并发上传,百度WebFE开发的,不过GitHub上已经好久没有更新了,有兴趣的可以fork跟进研究

好了,进入正题,上手开发实现文件分片上传与合并,并支持下载的简单服务。

1、安装Nodejs

官网下载Nodejs,并安装,步骤略,如遇问题,自行Google/Baidu。

2、安装Express框架

访问Express框架官网,按照步骤安装即可。

3、根据中间件写一个简单的路由访问页面(源码见我的GitHub)

大致的目录作用说明(详细请自行深入学习Express):

- 入口文件是app.js。

- routes目录的作用是路由及控制器的作用,相当于MVC的Router和Controller。

- views目录相当于MVC中的视图,里面存放视图中的layout和页面view

注:MVC中的Model,可自行建立目录,因为Express作为后端服务,符合CommonJS的规范,因此可以自己建立模块,加到module.exports中,在Controller中调用require引入即可。

熟悉了目录以后,就可以上手试着写一个简单的访问上传页面,这里得熟悉一下jade的写法,上手看一些基本的就好,用到的时候再多翻翻。

我写了一个简单的前端layout模板,在views/front/layout/front.jade,block content是模板引用具体的view的一个写法。

doctype html
html
    head
        meta(charset='utf-8')
        - var defaultTitle = 'MyApp';
        title= title ? [title, defaultTitle].join(' | ') : defaultTitle
        link(rel='stylesheet' href='/css/reset.min.css')
        script(src='/js/jquery-1.8.3.min.js')
    body
        block content
我又写了一个上传的页面,也很简单,在views/front/upload.jade。
extends ../layout/front

block content
    link(rel="stylesheet" type="text/css" href="webuploader/webuploader.css")
    link(rel="stylesheet" type="text/css" href="css/front/upload.css")


    #uploader.wu-example
        .dndArea
            .file-select
                #picker 选择文件
                p 或将文件拖到这里
        // 用来存放文件信息
        #thelist.uploader-list
        button#ctlBtn.btn.btn-default 开始上传


    script(src="webuploader/webuploader.js")
    script(src='js/md5.min.js')
    script(src='js/front/upload.js')
之后,在Controller routes目录中写一个简单的页面控制器,我的路径在routes/frontpage.js。
var express = require('express');
var router = express.Router();
 
router.get('/upload', function (req, res, next) {
    res.render('front/upload', {title: 'Upload'});
});
 
module.exports = router;
最后,在入口文件app.js中引入路由模块。
var routes = require('./routes/frontpage');
接下来,启动服务,访问一下页面。
cd myapp
DEBUG=myapp:* npm start
在浏览器访问http://127.0.0.1:6888/upload。代码中的默认端口是6888,这个可以根据需要在bin/www文件中更改。
4、开始调用Webuploader实现文件上传

根据Webuploader的demo,简单写一个文件上传的js,然后写一个post Controller即可。

我写的js在public/js/front/upload.js,简单的文件上传示例在routes/frontpage.js的form相关下面的route.post方法,这里把post过来的方法贴一下,比较经典,同时支持传递一些参数。

var multer = require('multer');
var fs = require('fs');
 
// form相关
router.get('/form', function (req, res, next) {
    res.render('form', {'title': 'Form'});
}).post('/form_process', multer({dest: __dirname + '/../../upload/'}).array('file'), function (req, res, next) {
    var responseJson = {
        first_name: req.body.first_name,
        last_name: req.body.last_name,
        origin_file: req.files[0]// 上传的文件信息
    };
 
    var src_path = req.files[0].path;
    var des_path = req.files[0].destination + req.files[0].originalname;
 
    fs.rename(src_path, des_path, function (err) {
        if (err) {
            throw err;
        }
 
        fs.stat(des_path, function (err, stat) {
            if (err) {
                throw err;
            }
 
            responseJson['upload_file'] = stat;
            console.log(responseJson);
 
            res.json(responseJson);
        });
    });
 
    console.log(responseJson);
});


注意,现在开始才是真正的大文件分片上传实现,前面的都是铺垫哦(没有铺垫也没法继续的)。
1)首先,配合Webuploader,开启该插件的分片上传相关选项,其中server是上传分片文件请求的服务器地址,其他配置暂没有列出。

{
        // 文件接收服务端。
        server: '/upload_chunks',
 
        // 分片上传配置
        chunked: true,// 开起分片上传。
        chunkSize: Math.pow(1024, 2),// 单位字节(Byte)
        threads: 1,//上传并发数
 
        // 不压缩image, 默认如果是jpeg,文件上传前会压缩一把再上传!
        resize: false
}
2)设计转存文件分片的控制器。
控制器层实现的思路比较简单,就是转存上传上来的分片文件,从上传的临时目录移动到指定的目录下。这里重点是分片文件目录的设计,我在每个文件发送分片之前创建了该文件的一个唯一标识组guid(只在每个文件开始之前生成,而不是每个文件的每个分片文件都生成),根据这个唯一的guid,就可以找到对应的文件,那么据此标识,可以将所有该文件的分片文件都放在以该标识为基础计算的目录名称下面,这样在之后的合并文件中就可以方便的找到某个文件的所有分片。具体代码见frontpage.js中的post /upload_chunks。

    // 上传分片
    router.post('/upload_chunks', multer({dest: tmpDir}).array('file'), function (req, res, next) {
        var src_path = req.files[0].path;// 原始片段在临时目录下的路径
        var des_dir = [chunkDir, req.body.guid].join('/');
        var des_path = (req.body.chunk) ? [des_dir, req.body.chunk].join('/') : des_dir;
 
        // 如果没有des_dir目录,则创建des_dir
        if (!fs.existsSync(des_dir)) {
            fs.mkdirSync(des_dir);
        }
 
        // 移动分片文件到
        try {
            child_process.exec(['mv', src_path, des_path].join(' '), function (err, stdout, stderr) {
                if (err) {
                    console.log(err);
 
                    return res.json({'status': 0, 'msg': '移动分片文件错误!'});
                }
 
                return res.json({'status': 1, 'msg': req.body.guid + '_' + req.body.chunk + '上传成功!'});
            });
        } catch (e) {
            console.log(e);
 
            return res.json({'status': 0, 'msg': '移动分片文件错误!'});
        }
 
    });
此处移动分片文件采用fork子进程方式实现,也可以调用fs其他方法实现。
3)设计所有文件分片上传成功后合并分片文件及回调方法。

合并分片文件的思路是:在所有分片上传成功后,发起合并分片post请求,其中重点是生成文件访问url,包括签名、路径、名称等。

以下是Webuploader在所有分片上传成功后发起merge分片请求js:

uploader.on('uploadSuccess', function (file) {
        console.log(file);
 
        // 如果是分片上传,文件上传成功后执行分片合并并返回Get文件的url
        if (uploader.options.chunked) {
            $.post('/merge_chunks', {
                'hash': md5([file.id, file.name, file.size, file.type, file['__hash']].join('')),
                'name': file.name,
                'size': file.size
            }, function (data) {
                if (data.status) {
                    $('#' + file.id).find('p.state').text('已上传');
                    $('#' + file.id).find('.progress-bar').css({
                        'background-image': 'url(' + data.url + ')',
                        'background-size': 'cover',
                        'background-repeat': 'no-repeat'
                    });
                } else {
                    $('#' + file.id).find('p.state').text('上传错误!');
                }
            }, 'json');
        }
    });
后端合并分片的实现见frontpage.js中的post /merge_chunks:
    // 合并分片
    router.post('/merge_chunks', function (req, res, next) {
        var src_dir = [chunkDir, req.body.hash + '/'].join('/');
 
        // 目标目录
        var time = new Date();
        var path = md5([
            time.getFullYear(),
            time.getMonth() + 1 <= 9 ? '0' + (time.getMonth() + 1) : time.getMonth() + 1,
            time.getDate <= 9 ? '0' + time.getDate() : time.getDate()
        ].join(''));// 文件目录名
 
        // 如果没有des_dir目录,则创建des_dir
        var des_dir = [uploadDir, path].join('/');
        if (!fs.existsSync(des_dir)) {
            fs.mkdirSync(des_dir);
        }
 
        // 文件名+扩展名
        var name = decodeURIComponent(req.body.name);
 
        // 文件的实际名称和路径
        var fileName = md5([path, name, req.body.size, new Date().getTime(), 99999 * Math.random()].join(fileSalt));
 
        // 文件签名
        var sig = md5([path, name, fileName, req.body.size].join(fileSalt));
 
        // 文件的实际路径
        var des_path = [des_dir, fileName].join('/');
 
        try {
            var files = fs.readdirSync(src_dir);
 
            if (files.length == 0) {
                return res.json({'status': 0, 'url': '', 'msg': '分片文件不存在!'});
            }
 
            if (files.length > 1) {
                files.sort(function (x, y) {
                    return x - y;
                });
            }
 
            for (var i = 0, len = files.length; i < len; i++) {
                fs.appendFileSync(des_path, fs.readFileSync(src_dir + files[i]));
            }
 
            // 删除分片文件夹
            child_process.exec(['rm', '-rf', src_dir].join(' '));
 
            return res.json({
                'status': 1,
                'url': [
                    'http://127.0.0.1:6888',
                    'file', fileName,
                    'path', path,
                    'name', encodeURIComponent(name),
                    'sig', sig
                ].join('/')
            });
        } catch (e) {
            // 删除分片文件夹
            child_process.exec(['rm', '-rf', src_dir].join(' '));
 
            return res.json({'status': 0, 'url': ''});
        }
    });
此处我们生成的url形式为:http://IP:PORT/file/程序生成的唯一文件名/path/存储相对目录名(按天分)/name/经过urlencode原始文件名/sig/文件签名。
注意,该url形式要与第4步的获取文件接口一致,获取文件接口实现见第4步。

4)实现获取文件接口,用户获取合并后的文件(显示或下载)。

获取接口涉及的重点是文件签名验证,详见frontpage.js中的get /file/:file/path/:path/name/:name/sig/:sig。

    // 文件获取
    router.get('/file/:file/path/:path/name/:name/sig/:sig', function (req, res, next) {
        try {
            var name = decodeURIComponent(req.params.name);
            var stat = fs.statSync([uploadDir, req.params.path, req.params.file].join('/'));
            var sig = md5([req.params.path, name, req.params.file, stat.size].join(fileSalt));
 
            // 签名验证
            if (sig != req.params.sig) {
                return res.json({status: 0, msg: '签名错误!'});
            }
 
            res.download([uploadDir, req.params.path, req.params.file].join('/'), name, function (err) {
                if (err) {
                    return res.send('下载错误!');
                }
            });
        } catch (e) {
            return res.json({status: 0, msg: '未知错误!'});
        }
    });


至此,文件分片上传的核心功能实现完成,可以根据自己的设计,对前端进行改进,也可增加上传后删除按钮等。
注:本文的实现思路仅供参考实践,有不正确的地方,还望给位指正交流,非常感谢!

你可能感兴趣的:(基于NodeJs的Express及Webuploader实现大文件分片上传与合并(一))