近期的项目里使用了这样一个项目架构: 前端 -> 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=\"https://cdn.bootcss.com/jquery/3.4.1/jquery.min.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=\"https://cdn.bootcss.com/jquery/3.4.1/jquery.min.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 等等。
最后如果文中有任何错误请及时指出,有任何问题可以讨论。
参考
https://www.freexyz.cn/article/170637.htm
https://www.npmjs.com/package/formidable
https://github.com/dlau/koa-body
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。
暂无评论内容