文件上傳那點事兒

前言

日常在寫業務的時候經常會用的到的是 GET, POST請求去請求接口,GET 相關的接口會比較容易基本不會出錯,而對於 POST中經常使用的 表單提交,JSON提交也比較容易,可是對於文件上傳呢?你們可能對這個步驟會比較懼怕,由於可能你們對它並非怎麼熟悉,而瀏覽器Network對它也沒有詳細的進行記錄,所以它成爲了咱們心中的一根刺,咱們總是沒法肯定,關於文件上傳究竟是我寫的有問題呢?仍是後端有問題,固然,咱們通常都比較謙虛, 老是會在本身身上找緣由,但是每每實事呢?可能就出在後端身上,多是他接受寫的有問題,致使你換了各類請求庫去嘗試,axiosrequestfetch 等等。那麼咱們如何避免這種狀況呢?咱們自身要對這一塊夠熟悉,才能不以猜的方式去寫代碼。若是你以爲我以上說的你有同感,那麼你閱讀完這篇文章你將收穫自信,你將不會質疑本身,不會以猜的方式去寫代碼。css

本文比較長可能須要花點時間去看,須要有耐心,我採用自頂向下的方式,全部示例會先展示出你熟悉的方式,再一層層往下, 先從請求端是怎麼發送文件的,再到接收端是怎麼解析文件的。前端

前置知識

什麼是 multipart/form-data?

multipart/form-data 最初由 《RFC 1867: Form-based File Upload in HTML》[1]文檔提出。node

Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

因爲文件上傳功能將使許多應用程序受益,所以建議對HTML進行擴展,以容許信息提供者統一表達文件上傳請求,並提供文件上傳響應的MIME兼容表示。ios

總結就是原先的規範不知足啦,我要擴充規範了。git

文件上傳爲何要用 multipart/form-data?

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters.  Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.

1867文檔中也寫了爲何要新增一個類型,而不使用舊有的application/x-www-form-urlencoded:由於此類型不適合用於傳輸大型二進制數據或者包含非ASCII字符的數據。日常咱們使用這個類型都是把表單數據使用url編碼後傳送給後端,二進制文件固然沒辦法一塊兒編碼進去了。因此multipart/form-data就誕生了,專門用於有效的傳輸文件。github

也許你有疑問?那能夠用 application/json嗎?express

其實我認爲,不管你用什麼均可以傳,只不過會要綜合考慮一些因素的話,multipart/form-data更好。例如咱們知道了文件是以二進制的形式存在,application/json 是以文本形式進行傳輸,那麼某種意義上咱們確實能夠將文件轉成例如文本形式的 Base64 形式。可是呢,你轉成這樣的形式,後端也須要按照你這樣傳輸的形式,作特殊的解析。而且文本在傳輸過程當中是相比二進制效率低的,那麼對於咱們動輒幾十M幾百M的文件來講是速度是更慢的。npm

以上爲何文件傳輸要用multipart/form-data 我還能夠舉個例子,例如你在中國,你想要去美洲,咱們的multipart/form-data至關因而選擇飛機,而application/json至關於高鐵,可是呢?中國和美洲之間沒有高鐵啊,你執意要坐高鐵去,你能夠花昂貴的代價(後端額外解析你的文本)造高鐵去美洲,可是你有更加廉價的方式坐飛機(使用multipart/form-data)去美洲(去傳輸文件)。你圖啥?(若是你有錢有時間,抱歉,打擾了,老子給你道歉)json

multipart/form-data規範是什麼?

摘自 《RFC 1867: Form-based File Upload in HTML》[2] 6.Exampleaxios

Content-type: multipart/form-data, boundary=AaB03x
--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--AaB03x--

能夠簡單解釋一些,首先是請求類型,而後是一個 boundary (分割符),這個東西是幹啥的呢?其實看名字就知道,分隔符,當時分割做用,由於可能有多文件多字段,每一個字段文件之間,咱們沒法準確地去判斷這個文件哪裏到哪裏爲截止狀態。所以須要有分隔符來進行劃分。而後再接下來就是聲明內容的描述是 form-data 類型,字段名字是啥,若是是文件的話,得知道文件名是啥,還有這個文件的類型是啥,這個也很好理解,我上傳一個文件,我總得告訴後端,我傳的是個啥,是圖片?仍是一個txt文本?這些信息確定得告訴人家,別人纔好去進行判斷,後面咱們也會講到若是這些沒有聲明的時候,會發生什麼?

好了講完了這些前置知識,咱們接下來要進入咱們的主題了。面對 File, formData,Blob,Base64,ArrayBuffer 到底怎麼作?還有文件上傳不只僅是前端的事。服務端也能夠文件上傳(例如咱們利用某雲,把靜態資源上傳到 OSS 對象存儲)。服務端和客戶端也有各類類型,Buffer,Stream,Base64....頭禿,怎麼搞?不急,就是由於上傳文件不僅僅是前端的事,因此我將如下上傳文件的一方稱爲請求端,接受文件一方稱爲接收方。我會以請求端各類上傳方式,接收端是怎麼解析咱們的文件以及咱們最終的殺手鐗調試工具 -wireshark 來進行講解。如下是講解的大綱,咱們先從瀏覽器端上傳文件,再到服務端上傳文件,而後咱們再來解析文件是如何被解析的。

請求端

前端

File

首先咱們先寫下最簡單的一個表單提交方式。

<form action="http://localhost:7787/files" method="POST">
 <input name="file" type="file" id="file">
 <input type="submit" value="提交">
</form>

咱們選擇文件後上傳,發現後端返回了文件不存在。

不用着急,熟悉的同窗可能立馬知道是啥緣由了。噓,知道了也聽我慢慢叨叨。

咱們打開控制檯,因爲表單提交會進行網頁跳轉,所以咱們勾選preserve log 來進行日誌追蹤。

咱們能夠發現其實 FormDatafile 字段顯示的是文件名,並無將真正的內容進行傳輸。再看請求頭。

發現是請求頭和預期不符,也印證了 application/x-www-form-urlencoded 沒法進行文件上傳。

咱們加上請求頭,再次請求。

<form action="http://localhost:7787/files" enctype="multipart/form-data" method="POST">
 <input name="file" type="file" id="file">
 <input type="submit" value="提交">
</form>

發現文件上傳成功,簡單的表單上傳就是像以上同樣簡單。可是你得熟記文件上傳的格式以及類型。

FormData

formData 的方式我隨便寫了如下幾種方式。

<input type="file" id="file">
<button id="submit">上傳</button>
<script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
submit.onclick = () => {
 const file = document.getElementById('file').files[0];
 var form = new FormData();
 form.append('file', file);
 
 // type 1
 axios.post('http://localhost:7787/files', form).then(res => {
 console.log(res.data);
 })
 // type 2
 fetch('http://localhost:7787/files', {
 method: 'POST',
 body: form
 }).then(res => res.json()).tehn(res => {console.log(res)});
 // type3
 var xhr = new XMLHttpRequest();
 xhr.open('POST', 'http://localhost:7787/files', true);
 xhr.onload = function () {
 console.log(xhr.responseText);
 };
 xhr.send(form);
}
</script>

以上幾種方式都是能夠的。可是呢,請求庫這麼多,我隨便在 npm 上一搜就有幾百個請求相關的庫。

所以,掌握請求庫的寫法並非咱們的目標,目標只有一個仍是掌握文件上傳的請求頭和請求內容。

Blob

Blob 對象表示一個不可變、原始數據的類文件對象。Blob 表示的不必定是JavaScript原生格式的數據。File[3] 接口基於Blob,繼承了 blob 的功能並將其擴展使其支持用戶系統上的文件。

所以若是咱們遇到 Blob 方式的文件上方式不用懼怕,能夠用如下兩種方式:

1.直接使用 blob 上傳

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
 
const form = new FormData();
form.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', form);

2.使用 File 對象,再進行一次包裝(File 兼容性可能會差一些  https://caniuse.com/#search=File

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
 
const file = new File([blob], '1.json');
form.append('file', file);
axios.post('http://localhost:7787/files', form)

ArrayBuffer

ArrayBuffer 對象用來表示通用的、固定長度的原始二進制數據緩衝區。

雖然它用的比較少,可是他是最貼近文件流的方式了。

在瀏覽器中,他每一個字節以十進制的方式存在。我提早準備了一張圖片。

const bufferArrary = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,1,3,0,0,0,37,219,86,202,0,0,0,6,80,76,84,69,0,0,255,128,128,128,76,108,191,213,0,0,0,9,112,72,89,115,0,0,14,196,0,0,14,196,1,149,43,14,27,0,0,0,10,73,68,65,84,8,153,99,96,0,0,0,2,0,1,244,113,100,166,0,0,0,0,73,69,78,68,174,66,96,130];
const array = Uint8Array.from(bufferArrary);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form)

這裏須要注意的是 new Blob([typedArray.buffer], {type: 'xxx'}),第一個參數是由一個數組包裹。裏面是 typedArray 類型的 buffer。

Base64

const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEUAAP+AgIBMbL/VAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==';
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
 byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const array = Uint8Array.from(byteNumbers);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form);

關於 base64 的轉化和原理能夠看這兩篇 base64 原理[4] 和

原來瀏覽器原生支持JS Base64編碼解碼[5]

小結

對於瀏覽器端的文件上傳,能夠歸結出一個套路,全部東西核心思路就是構造出 File 對象。而後觀察請求 Content-Type,再看請求體是否有信息缺失。而以上這些二進制數據類型的轉化能夠看如下表。

圖片來源 (https://shanyue.tech/post/bin...[6])

服務端

講完了瀏覽器端,如今咱們來說服務器端,和瀏覽器不一樣的是,服務端上傳有兩個難點。

1.瀏覽器沒有原生 formData,也不會想瀏覽器同樣幫咱們轉成二進制形式。

2.服務端沒有可視化的 Network 調試器。

Buffer

Request

首先咱們經過最簡單的示例來進行演示,而後一步一步深刻。相信文檔能夠查看 https://github.com/request/re...

// request-error.js
const fs = require('fs');
const path = require('path');
const request = require('request');
const stream = fs.readFileSync(path.join(__dirname, '../1.png'));
request.post({
 url: 'http://localhost:7787/files',
 formData: {
 file: stream,
 }
}, (err, res, body) => {
 console.log(body);
})

發現報了一個錯誤,正像上面所說,瀏覽器端報錯,能夠用NetWork。那麼服務端怎麼辦?這個時候咱們拿出咱們的利器 -- wireshark

咱們打開 wireshark (若是沒有或者不會的能夠查看教程 https://blog.csdn.net/u013613...

設置配置 tcp.port == 7787,這個是咱們後端的端口。

運行上述文件 node request-error.js

咱們來找到咱們發送的這條http的請求報文。中間那堆亂七八糟的就是咱們的文件內容。

POST /files HTTP/1.1
host: localhost:7787
content-type: multipart/form-data; boundary=--------------------------437240798074408070374415
content-length: 305
Connection: close
----------------------------437240798074408070374415
Content-Disposition: form-data; name="file"
Content-Type: application/octet-stream
.PNG
.
...
IHDR.............%.V.....PLTE......Ll.....    pHYs..........+.....
IDAT..c`.......qd.....IEND.B`.
----------------------------437240798074408070374415--

能夠看到上述報文。發現咱們的內容請求頭 Content-Type: application/octet-stream有錯誤,咱們上傳的是圖片請求頭應該是image/png,而且也少了 filename="1.png"

咱們來思考一下,咱們剛纔用的是fs.readFileSync(path.join(__dirname, '../1.png')) 這個函數返回的是 BufferBuffer是什麼樣的呢?就是下面的形式,不會包含任何文件相關的信息,只有二進制流。

<Buffer 01 02>

因此我想到的是,須要指定文件名以及文件格式,幸虧 request 也給咱們提供了這個選項。

key: {
 value:  fs.createReadStream('/dev/urandom'),
 options: {
 filename: 'topsecret.jpg',
 contentType: 'image/jpeg'
 }
}

能夠指定options,所以正確的代碼應該以下(省略不重要的代碼)

...
request.post({
 url: 'http://localhost:7787/files',
 formData: {
 file: {
 value: stream,
 options: {
 filename: '1.png'
 }
 },
 }
});

咱們經過抓包能夠進行分析到,文件上傳的要點仍是規範,大部分的問題,均可以經過規範模板來進行排查,是否構造出了規範的樣子。

Form-data

咱們再深刻一些,來看看 request 的源碼, 他是怎麼實現Node端的數據傳輸的。

打開源碼咱們很容易地就能夠找到關於 formData 這塊相關的內容 https://github.com/request/re...

就是利用form-data,咱們先來看看 formData 的方式。

const path = require('path');
const FormData = require('form-data');
const fs = require('fs');
const http = require('http');
const form = new FormData();
form.append('file', fs.readFileSync(path.join(__dirname, '../1.png')), {
 filename: '1.png',
 contentType: 'image/jpeg',
});
const request = http.request({
 method: 'post',
 host: 'localhost',
 port: '7787',
 path: '/files',
 headers: form.getHeaders()
});
form.pipe(request);
request.on('response', function(res) {
 console.log(res.statusCode);
});

原生 Node

看完formData,可能感受這個封裝仍是過高層了,因而我打算對照規範手動來構造multipart/form-data請求方式來進行講解。咱們再來回顧一下規範。

Content-type: multipart/form-data, boundary=AaB03x
--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--AaB03x--

我模擬上方,我用原生 Node 寫出了一個multipart/form-data 請求的方式。

主要分爲4個部分
* 構造請求header
* 構造內容header
* 寫入內容
* 寫入結束分隔符
const path = require('path');
const fs = require('fs');
const http = require('http');
// 定義一個分隔符,要確保惟一性
const boundaryKey = '-------------------------461591080941622511336662';
const request = http.request({
 method: 'post',
 host: 'localhost',
 port: '7787',
 path: '/files',
 headers: {
 'Content-Type': 'multipart/form-data; boundary=' + boundaryKey, // 在請求頭上加上分隔符
 'Connection': 'keep-alive'
 }
});
// 寫入內容頭部
request.write(
 `--${boundaryKey}rnContent-Disposition: form-data; name="file"; filename="1.png"rnContent-Type: image/jpegrnrn`
);
// 寫入內容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
 // 寫入尾部
 request.end('rn--' + boundaryKey + '--' + 'rn');
});
request.on('response', function(res) {
 console.log(res.statusCode);
});

至此,已經實現服務端上傳文件的方式。

Stream、Base64

因爲這兩塊就是和Buffer的轉化,比較簡單,我就再也不重複描述了。能夠做爲留給你們的做業,感興趣的能夠給我這個示例代碼倉庫貢獻這兩個示例。

// base64 to buffer
const b64string = /* whatever */;
const buf = Buffer.from(b64string, 'base64');
// stream to buffer
function streamToBuffer(stream) { 
 return new Promise((resolve, reject) => {
 const buffers = [];
 stream.on('error', reject);
 stream.on('data', (data) => buffers.push(data))
 stream.on('end', () => resolve(Buffer.concat(buffers))
 });
}

小結

因爲服務端沒有像瀏覽器那樣 formData 的原生對象,所以服務端核心思路爲構造出文件上傳的格式(header,filename等),而後寫入 buffer 。而後千萬別忘了用 wireshark進行驗證。

接收端

這一部分是針對 Node 端進行講解,對於那些 koa-body 等用慣了的同窗,可能同樣不太清楚整個過程發生了什麼?可能惟一比較清楚的是 ctx.request.files ??? 若是ctx.request.files 不存在,就會懵逼了,可能也不太清楚它到底作了什麼,文件流又是怎麼解析的。

我仍是要說到規範...請求端是按照規範來構造請求..那麼咱們接收端天然是按照規範來解析請求了。

Koa-body

const koaBody = require('koa-body');
app.use(koaBody({ multipart: true }));

咱們來看看最經常使用的 koa-body,它的使用方式很是簡單,短短几行,就能讓咱們享受到文件上傳的簡單與快樂(其餘源碼庫同樣的思路去尋找問題的本源) 能夠帶着一個問題去閱讀,爲何用了它就能解析出文件?

尋求問題的本源,咱們固然要打開 koa-body的源碼,koa-body 源碼不多隻有211行,https://github.com/dlau/koa-b... 很容易地發現它實際上是用了一個叫作formidable的庫來解析files 的。而且把解析好的files 對象賦值到了 ctx.req.files。(因此說你們不要一味死記 ctx.request.files, 注意查看文檔,由於今天用 koa-bodyctx.request.files 明天換個庫可能就是 ctx.request.body 了)

所以看完koa-body咱們得出的結論是,koa-body的核心方法是formidable

Formidable

那麼讓咱們繼續深刻,來看看formidable作了什麼,咱們首先來看它的目錄結構。

.
├── lib
│   ├── file.js
│   ├── incoming_form.js
│   ├── index.js
│   ├── json_parser.js
│   ├── multipart_parser.js
│   ├── octet_parser.js
│   └── querystring_parser.js

看到這個目錄,咱們大體能夠梳理出這樣的關係。

index.js
|
incoming_form.js
|
type
?
|
1.json_parser
2.multipart_parser
3.octet_parser
4.querystring_parser

因爲源碼分析比較枯燥。所以我只摘錄比較重要的片斷。因爲咱們是分析文件上傳,因此咱們只須要關心 multipart_parser 這個文件。

https://github.com/node-formi...

...
MultipartParser.prototype.write = function(buffer) {
 console.log(buffer);
 var self = this,
 i = 0,
 len = buffer.length,
 prevIndex = this.index,
 index = this.index,
 state = this.state,
...

咱們將它的 buffer 打印看看.

<Buffer 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 36 36 ... >
144
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 00 01 00 00 00 01 01 03 00 00 00 25 db 56 ca 00 00 00 06 50 4c 54 45 00 00 ff 80 80 80 4c 6c bf ... >
106
<Buffer 0d 0a 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 ... >

咱們來看wireshark 抓到的包

我用紅色進行了分割標記,對應的就是formidable所分割的片斷 ,因此說這個包主要是將大段的 buffer 進行分割,而後循環處理。

這裏我還能夠補充一下,可能你對以上表很是陌生。左側是二進制流,每1個表明1個字節,1字節=8位,上面的 2d 其實就是16進制的表示形式,用二進制表示就是 0010 1101,右側是ascii 碼用來可視化,可是 assii 分可顯和非可顯示。有部分是沒法可視的。好比你所看到文件中有須要小點,就是不可見字符。

你能夠對照,ascii表對照表[7]來看。

我來總結一下formidable對於文件的處理流程。

原生 Node

好了,咱們已經知道了文件處理的流程,那麼咱們本身來寫一個吧。

const fs = require('fs');
const http = require('http');
const querystring = require('querystring');
const server = http.createServer((req, res) => {
    if (req.url === "/files" && req.method.toLowerCase() === "post") {
        parseFile(req, res)
    }
})
function parseFile(req, res) {
 req.setEncoding("binary");
 let body = "";
 let fileName = "";
 // 邊界字符
 let boundary = req.headers['content-type']
                   .split('; ')[1]
                   .replace("boundary=", "")
 
 req.on("data", function(chunk) {
    body += chunk;
 });
 req.on("end", function() {
     // 按照分解符切分
     const list = body.split(boundary);
     let contentType = '';
     let fileName = '';
     for (let i = 0; i < list.length; i++) {
        if (list[i].includes('Content-Disposition')) {
            const data = list[i].split('rn');
            for (let j = 0; j < data.length; j++) {
                // 從頭部拆分出名字和類型
                if (data[j].includes('Content-Disposition')) {
                    const info = data[j].split(':')[1].split(';');
                    fileName = info[info.length - 1].split('=')[1].replace(/"/g, '');
                    console.log(fileName);
                }
                if (data[j].includes('Content-Type')) {
                    contentType = data[j];
                    console.log(data[j].split(':')[1]);
                }
            }
        }
    }
    // 去除前面的請求頭
    const start = body.toString().indexOf(contentType) + contentType.length + 4; // 有多rnrn
    const startBinary = body.toString().substring(start);
    const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多rn
    // 去除後面的分隔符
    const binary = startBinary.substring(0, end);
    const bufferData = Buffer.from(binary, "binary");
    fs.writeFile(fileName, bufferData, function(err) {
        res.end("sucess");
    });
 });
}
server.listen(7787)

總結

相信有了以上的介紹,你再也不對文件上傳有所害怕, 對文件上傳整個過程都會比較清晰了,還不懂。。。。找我。

再次回顧下咱們的重點:

請求端出問題,瀏覽器端打開 network 查看格式是否正確(請求頭,請求體), 若是數據不夠詳細,打開wireshark,對照咱們的規範標準,看下格式(請求頭,請求體)。

接收端出問題,狀況一就是請求端缺乏信息,參考上面請求端出問題的狀況,狀況二請求體內容錯誤,若是說請求體內容是請求端本身構造的,那麼須要檢查請求體是不是正確的二進制流(例如上面的blob構造的時候,我一開始少了一個[],致使內容主體錯誤)。

其實講這麼多就兩個字:  規範,全部的生態都是圍繞它而展開的。

相關文章
相關標籤/搜索