文件上傳接口的轉發(node)

場景

近期的項目裏使用了這樣一個項目架構: 前端 -> nodejs -> javajavascript

  • 前端負責實現業務邏輯的展現和交互
  • nodejs 包括維護某些數據和接口轉發
  • java 負責維護剩下的數據

在 nodejs 的接口轉發中攔截一部分接口,再對請求的方法進行區分,請求後臺數據後,再進行返回。現有的接口中基本只用到了 get 和 post 兩種,可是在文件上傳的時候遇到了問題。css

node 層使用 eggjs ,通常的 post 的請求直接在 ctx.body 就能拿到請求的參數,可是 /upload 的接口就不行,拿到的 body 是 {} ,下面咱們來逐步分析。html

js 中的文件

web 中的 Blob 、File 和 FormData

一個 Blob ( Binary Large Object ) 對象表示一個不可變的, 原始數據的相似文件對象。Blob表示的數據不必定是一個JavaScript原生格式。 File 接口基於Blob,繼承 Blob 功能並將其擴展爲支持用戶系統上的文件。前端

前端上傳文件的方式無非就是使用:一、表單自動上傳;二、使用 ajax 上傳。咱們能夠使用如下代碼建立一個 Form,並打印出 filejava

<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 實現的一個類,新增了關於文件有關的一些信息。node

FormData 對象的做用就相似於 Jq 的 serialize() 方法,不過 FormData 是瀏覽器原生的,且支持二進制文件。jquery

ajax 經過 FormData 這個對象發送表單請求,不管是原生的 XMLHttpRequest 、jq 的 ajax 方法、 axios 都是在 data 裏直接指定上傳 formData 類型的數據,fetch api 是在 body 裏上傳。ios

FormData 添加數據有有兩種,以下 formData 和 formData2 的區別,而 formData2 能夠經過傳入一個 element 的方式進行初始化,初始化以後依然能夠調用 formData 的 append 方法。git

<!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)github

  • 若是是使用 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 直接使用這個庫對文件進行處理。(上面的原生 node upload 只是簡單地處理了一下 img 圖片)
  • opts.formidable 是 formidable 的 config ,能夠設置文件大小,保存的文件路徑等等。

eggjs

使用 eggjs 進行文件上傳須要先在配置文件中開啓

config.multipart = { mode: "file", fileSize: "600mb" };
複製代碼

而後經過 ctx.request.files[0] 就能取到文件信息。

文件上傳接口的轉發

一千個觀衆眼中有一千個哈姆雷特 經過以上知識點的梳理,我相信你也有了本身的想法,下面在這裏說一下我是怎麼處理的。

在 egg 中我使用了 request-promise 去作接口轉發,經過查看 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 等等。
  • 最後若是文中有任何錯誤請及時指出,有任何問題能夠討論。

參考

houbean.github.io/2017/02/22/…

www.npmjs.com/package/for…

github.com/dlau/koa-bo…

相關文章
相關標籤/搜索