近期的项目里使用了这样一个项目架构: 前端 -> nodejs -> java
- 前端负责实现业务逻辑的展示和交互
- nodejs 包括维护某些数据和接口转发
- java 负责维护剩下的数据
在 nodejs 的接口转发中拦截一部分接口,再对请求的方法进行区分,请求后台数据后,再进行返回。现有的接口中基本只用到了 get 和 post 两种,但是在文件上传的时候遇到了问题。
node 层使用 eggjs ,一般的 post 的请求直接在 ctx.body 就能拿到请求的参数,但是 /upload 的接口就不行,拿到的 body 是 {} ,下面我们来逐步分析。
js 中的文件
web 中的 blob 、file 和 formdate
一个 blob ( binary large object ) 对象表示一个不可变的, 原始数据的类似文件对象。blob表示的数据不一定是一个javascript原生格式。 file 接口基于blob,继承 blob 功能并将其扩展为支持用户系统上的文件。
前端上传文件的方式无非就是使用:1、表单自动上传;2、使用 ajax 上传。我们可以使用以下代码创建一个 form,并打印出 file
<form method="post" id="uploadform" enctype="multipart/form-data">
<input type="file" id="file" name="file" />
</form>
<button id="submit">submit</button>
<script src="http://www.51sjk.com/Upload/Articles/1/0/268/268609_20210708024023568.js"></script>
<script>
$("#submit").click(function() {
console.log($("#file")[0].files[0])
});
</script>

从 f12 中可以看出 file 原型链上是 blob。
简单地说 blob 可以理解为 web 中的二进制文件。 而 file 是基于 blob 实现的一个类,新增了关于文件有关的一些信息。
formdata对象的作用就类似于 jq 的 serialize() 方法,不过 formdata 是浏览器原生的,且支持二进制文件。 ajax 通过 formdata 这个对象发送表单请求,无论是原生的 xmlhttprequest 、jq 的 ajax 方法、 axios 都是在 data 里直接指定上传 formdata 类型的数据,fetch api 是在 body 里上传。
fordata 数据有两种方式生成,如下 formdata 和 formdata2 的区别,而 formdata2 可以通过传入一个 element 的方式进行初始化,初始化之后依然可以调用 formdata 的 append 方法。
<!doctype html>
<html>
<form method="post" id="uploadform" name="uploadformname" enctype="multipart/form-data">
<input type="file" id="fileimag" name="configfile" />
</form>
<div id="show"></div>
<button id="submit">submit</button>
<script src="http://www.51sjk.com/Upload/Articles/1/0/268/268609_20210708024023568.js"></script>
</html>
<script>
$("#submit").click(function() {
const file = $("#fileimag")[0].files[0];
const formdata = new formdata();
formdata.append("fileimag", file);
console.log(formdata.getall("fileimag"));
const formdata2 = new formdata(document.queryselector("#uploadform"));
// const formdata2 = new formdata(document.forms.nameditem("uploadformname"););
console.log(formdata2.get("configfile"));
});
</script>
console.log() 无法直接打印出 formdata 的数据,可以使用 get(key) 或者 getall(key)
- 如果是使用 new formdata(element) 的创建方式,上面 key 为 <input /> 上的 name 字段。
- 如果是使用 append 添加的数据,get/getall 时 key 为 append 所指定的 key。
node 中的 buffer 、 stream 、fs
buffer 和 stream 是 node 为了让 js 在后端拥有处理二进制文件而出现的数据结构。
通过名字可以看出 buffer 是缓存的意思。存储在内存当中,所以大小有限,buffer 是 c++ 层面分配的,所得内存不在 v8 内。
stream 可以用水流形容数据的流动,在文件 i/o、网络 i/o中数据的传输都可以称之为流。
通过两个 fs 的 api 看出,readfile 不指定字符编码默认返回 buffer 类型,而 createreadstream 将文件转化为一个 stream , nodejs 中的 stream 通过 data 事件能够一点一点地拿到文件内容,直到 end 事件响应为止。
const fs = require("fs");
fs.readfile("./package.json", function(err, buffer) {
if (err) throw err;
console.log("buffer", buffer);
});
function readlines(input, func) {
var remaining = "";
input.on("data", function(data) {
remaining += data;
var index = remaining.indexof("\n");
var last = 0;
while (index > -1) {
var line = remaining.substring(last, index);
last = index + 1;
func(line);
index = remaining.indexof("\n", last);
}
remaining = remaining.substring(last);
});
input.on("end", function() {
if (remaining.length > 0) {
func(remaining);
}
});
}
function func(data) {
console.log("line: " + data);
}
var input = fs.createreadstream("./package.json");
input.setencoding("binary");
readlines(input, func);
fs.readfile() 函数会缓冲整个文件。 为了最小化内存成本,尽可能通过 fs.createreadstream() 进行流式传输。
使用 nodejs 创建 uoload api
http 协议中的文件上传
在 http 的请求头中 content-type 是 multipart/form-data 时,请求的内容如下:
post / http/1.1 content-type: multipart/form-data; boundary=----webkitformboundaryomwe4oxvn0iuf1s4 origin: http://localhost:3000 referer: http://localhost:3000/upload sec-fetch-mode: navigate sec-fetch-user: ?1 upgrade-insecure-requests: 1 user-agent: mozilla/5.0 (macintosh; intel mac os x 10_14_5) applewebkit/537.36 (khtml, like gecko) chrome/76.0.3809.132 safari/537.36 ------webkitformboundaryoqbx9oybhx4sf1yq content-disposition: form-data; name="upload" http://localhost:3000 ------webkitformboundaryomwe4oxvn0iuf1s4 content-disposition: form-data; name="upload"; filename="img_9429.jpg" content-type: image/jpeg ����jfif��c // 文件的二进制数据 …… --------webkitformboundaryomwe4oxvn0iuf1s4--
根据 webkitformboundaryomwe4oxvn0iuf1s4 可以分割出文件的二进制内容
原生 node
使用原生的 node 写一个文件上传的 demo
const http = require("http");
const fs = require("fs");
const util = require("util");
const querystring = require("querystring");
//用http模块创建一个http服务端
http
.createserver(function(req, res) {
if (req.url == "/upload" && req.method.tolowercase() === "get") {
//显示一个用于文件上传的form
res.writehead(200, { "content-type": "text/html" });
res.end(
'<form action="/upload" enctype="multipart/form-data" method="post">' +
'<input type="file" name="upload" multiple="multiple" />' +
'<input type="submit" value="upload" />' +
"</form>"
);
} else if (req.url == "/upload" && req.method.tolowercase() === "post") {
if (req.headers["content-type"].indexof("multipart/form-data") !== -1)
parsefile(req, res);
} else {
res.end("pelease upload img");
}
})
.listen(3000);
function parsefile(req, res) {
req.setencoding("binary");
let body = ""; // 文件数据
let filename = ""; // 文件名
// 边界字符串 ----webkitformboundaryomwe4oxvn0iuf1s4
const boundary = req.headers["content-type"]
.split("; ")[1]
.replace("boundary=", "");
req.on("data", function(chunk) {
body += chunk;
});
req.on("end", function() {
const file = querystring.parse(body, "\r\n", ":");
// 只处理图片文件;
if (file["content-type"].indexof("image") !== -1) {
//获取文件名
var fileinfo = file["content-disposition"].split("; ");
for (value in fileinfo) {
if (fileinfo[value].indexof("filename=") != -1) {
filename = fileinfo[value].substring(10, fileinfo[value].length - 1);
if (filename.indexof("\") != -1) {
filename = filename.substring(filename.lastindexof("\") + 1);
}
console.log("文件名: " + filename);
}
}
// 获取图片类型(如:image/gif 或 image/png))
const entiredata = body.tostring();
const contenttyperegex = /content-type: image/.*/;
contenttype = file["content-type"].substring(1);
//获取文件二进制数据开始位置,即contenttype的结尾
const upperboundary = entiredata.indexof(contenttype) + contenttype.length;
const shorterdata = entiredata.substring(upperboundary);
// 替换开始位置的空格
const binarydataalmost = shorterdata
.replace(/^\s\s*/, "")
.replace(/\s\s*$/, "");
// 去除数据末尾的额外数据,即: "--"+ boundary + "--"
const binarydata = binarydataalmost.substring(
0,
binarydataalmost.indexof("--" + boundary + "--")
);
// console.log("binarydata", binarydata);
const bufferdata = new buffer.from(binarydata, "binary");
console.log("bufferdata", bufferdata);
// fs.writefile(filename, binarydata, "binary", function(err) {
// res.end("sucess");
// });
fs.writefile(filename, bufferdata, function(err) {
res.end("sucess");
});
} else {
res.end("reupload");
}
});
}
通过 req.setencoding("binary"); 拿到图片的二进制数据。可以通过以下两种方式处理二进制数据,写入文件。
fs.writefile(filename, binarydata, "binary", function(err) {
res.end("sucess");
});
fs.writefile(filename, bufferdata, function(err) {
res.end("sucess");
});
koa
在 koa 中使用 koa-body 可以通过 ctx.request.files 拿到上传的 file 对象。下面是例子。
'use strict';
const koa = require('koa');
const app = new koa();
const router = require('koa-router')();
const koabody = require('../index')({multipart:true});
router.post('/users', koabody,
(ctx) => {
console.log(ctx.request.body);
// => post body
ctx.body = json.stringify(ctx.request.body, null, 2);
}
);
router.get('/', (ctx) => {
ctx.set('content-type', 'text/html');
ctx.body = `
<!doctype html>
<html>
<body>
<form action="/" enctype="multipart/form-data" method="post">
<input type="text" name="username" placeholder="username"><br>
<input type="text" name="title" placeholder="tile of film"><br>
<input type="file" name="uploads" multiple="multiple"><br>
<button type="submit">upload</button>
</body>
</html>`;
});
router.post('/', koabody,
(ctx) => {
console.log('fields: ', ctx.request.body);
// => {username: ""} - if empty
console.log('files: ', ctx.request.files);
/* => {uploads: [
{
"size": 748831,
"path": "/tmp/f7777b4269bf6e64518f96248537c0ab.png",
"name": "some-image.png",
"type": "image/png",
"mtime": "2014-06-17t11:08:52.816z"
},
{
"size": 379749,
"path": "/tmp/83b8cf0524529482d2f8b5d0852f49bf.jpeg",
"name": "nodejs_rulz.jpeg",
"type": "image/jpeg",
"mtime": "2014-06-17t11:08:52.830z"
}
]}
*/
ctx.body = json.stringify(ctx.request.body, null, 2);
}
)
app.use(router.routes());
const port = process.env.port || 3333;
app.listen(port);
console.log('koa server with `koa-body` parser start listening to port %s', port);
console.log('curl -i http://localhost:%s/users -d "user=admin"', port);
console.log('curl -i http://localhost:%s/ -f "source=@/path/to/file.png"', port);
我们来看一下 koa-body 的实现
const forms = require('formidable');
function requestbody(opts) {
opts = opts || {};
...
opts.multipart = 'multipart' in opts ? opts.multipart : false;
opts.formidable = 'formidable' in opts ? opts.formidable : {};
...
// @todo: next major version, opts.strict support should be removed
if (opts.strict && opts.parsedmethods) {
throw new error('cannot use strict and parsedmethods options at the same time.')
}
if ('strict' in opts) {
console.warn('deprecated: opts.strict has been deprecated in favor of opts.parsedmethods.')
if (opts.strict) {
opts.parsedmethods = ['post', 'put', 'patch']
} else {
opts.parsedmethods = ['post', 'put', 'patch', 'get', 'head', 'delete']
}
}
opts.parsedmethods = 'parsedmethods' in opts ? opts.parsedmethods : ['post', 'put', 'patch']
opts.parsedmethods = opts.parsedmethods.map(function (method) { return method.touppercase() })
return function (ctx, next) {
var bodypromise;
// only parse the body on specifically chosen methods
if (opts.parsedmethods.includes(ctx.method.touppercase())) {
try {
if (opts.json && ctx.is(jsontypes)) {
bodypromise = buddy.json(ctx, {
encoding: opts.encoding,
limit: opts.jsonlimit,
strict: opts.jsonstrict,
returnrawbody: opts.includeunparsed
});
} else if (opts.multipart && ctx.is('multipart')) {
bodypromise = formy(ctx, opts.formidable);
}
} catch (parsingerror) {
if (typeof opts.onerror === 'function') {
opts.onerror(parsingerror, ctx);
} else {
throw parsingerror;
}
}
}
bodypromise = bodypromise || promise.resolve({});
/**
* check if multipart handling is enabled and that this is a multipart request
*
* @param {object} ctx
* @param {object} opts
* @return {boolean} true if request is multipart and being treated as so
* @api private
*/
function ismultipart(ctx, opts) {
return opts.multipart && ctx.is('multipart');
}
/**
* donable formidable
*
* @param {stream} ctx
* @param {object} opts
* @return {promise}
* @api private
*/
function formy(ctx, opts) {
return new promise(function (resolve, reject) {
var fields = {};
var files = {};
var form = new forms.incomingform(opts);
form.on('end', function () {
return resolve({
fields: fields,
files: files
});
}).on('error', function (err) {
return reject(err);
}).on('field', function (field, value) {
if (fields[field]) {
if (array.isarray(fields[field])) {
fields[field].push(value);
} else {
fields[field] = [fields[field], value];
}
} else {
fields[field] = value;
}
}).on('file', function (field, file) {
if (files[field]) {
if (array.isarray(files[field])) {
files[field].push(file);
} else {
files[field] = [files[field], file];
}
} else {
files[field] = file;
}
});
if (opts.onfilebegin) {
form.on('filebegin', opts.onfilebegin);
}
form.parse(ctx.req);
});
}
代码中删除了影响有关文件上传的相关逻辑
- 首先 multipart 为 true 是开启文件上传的关键。
- 然后
formy函数处理了 http 解析和保存的一系列过程,最终将 files 抛出进行统一处理。代码中依赖了formidable这个库,我们其实也可以直接使用这个库对文件进行处理。(上面的原生 node upload 只是简单地处理了一下) - opts.formidable 是 formidable 的 config 可以设置文件大小,保存的文件路径等等。
eggjs
使用 eggjs 进行文件上传需要现在配置文件中开启
config.multipart = { mode: "file", filesize: "600mb" };
然后通过 ctx.request.files[0] 就能取到文件信息。
文件上传接口的转发
一千个观众眼中有一千个哈姆雷特,通过以上知识点的梳理,我相信你也有了自己得想法。在这里说一下我是怎么处理的。 在 egg 中我使用了 request-promise 去做接口转发,通过查看 api 和 ctx.request.files[0] 拿到的信息,我做了以下处理。
if (method === "post") {
options.body = request.body;
options.json = true;
if (url === uploadeurl) {
delete options.body;
options.formdata = {
// like <input type="text" name="name">
name: "file",
// like <input type="file" name="file">
file: {
value: fs.createreadstream(ctx.request.files[0].filepath),
options: {
filename: ctx.request.files[0].filename,
contenttype: ctx.get("content-type")
}
}
};
}
} else {
options.qs = query;
}
总结
- http 中的文件上传第一步就是设置 content-type 为 multipart/form-data 的 header。
- 区分好 web 端 js 和 node 端处理文件的方式有所不同。
- 有些 npm 模块的 readme 并不是很清晰,可以直接下源码去看 example ,或者直接读源码,就比如上文中没有提到的 koa-body 中 formidable 的用法并未在他的 reademe 中写出,直接看源码会发现更多用法。
- 文中的知识点很多知识稍微提及,可以进一步深入了解与他相关的知识。比如 web 的 filereader 等等。
- 最后如果文中有任何错误请及时指出,有任何问题可以讨论。
参考
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。
月城泪远