koa在請求體的處理方面依賴於通用插件koa-bodyparser或者koa-body,前者比較小巧,內部使用了co-body庫,可以處理一般的x-www-form-urlencoded格式的請求,但不能處理文件上傳;但后者則內置了formidable庫,在應對文件上傳方面得心應手,本文就formidable文件上傳的細節進行了一些分析。
koa-body的一般使用
const Koa = require('koa2')
const koaBody = require('koa-body');
const app = new Koa()
app.use(koaBody({
multipart: true,
formidable: {
// multiples: 接受多文件上傳,默認為true
// uploadDir: os.tmpDir() 文件上傳路徑,默認為系統臨時文件夾
keepExtensions: true // 保留文件原本的擴展名,否則沒有擴展名,默認為false
}
}))
app.use(ctx => {
ctx.body = `Request Body: ${JSON.stringify(ctx.request.body)}, ${JSON.stringify(ctx.request.files)}`;
})
app.listen(3003, () => console.log('server running on port 3003'))
在koaBody的選項中開啟multipart即可,非常簡便,其中的formidable可以指定設置,具體配置見文檔
文件form和普通form的區別
普通form
下面是一個普通form的例子:
<form action="http://localhost:3003" method="POST">
<p>
姓名:<input type="text" name="name">
</p>
<p>
年齡:<input type="number" name="age">
</p>
<p>
<input type="submit" value="提交">
</p>
</form>
在填寫表單點擊提交按鈕時,它的請求報文格式是:
// 省略了一些頭字段
POST http://localhost:3003/ HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded
...
name=MickFone&age=58
Content-Type是application/x-www-form-urlencoded,請求體是標准的form請求體格式,鍵值之間使用=
連接,field之間則使用&
。
文件form
<form action="http://localhost:3003" method="POST" enctype="multipart/form-data">
<p>
姓名:<input type="text" name="name">
</p>
<p>
年齡:<input type="number" name="age">
</p>
<p>
資料:<input type="file" multiple name="materials">
</p>
<p>
<input type="submit" value="提交">
</p>
</form>
它的請求報文格式是:
// 省略了一些頭字段,所有的換行均為\\r\\n
POST http://localhost:3003/ HTTP/1.1
...
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryI58Bh9EERAVK3lE7
...
------WebKitFormBoundaryI58Bh9EERAVK3lE7
Content-Disposition: form-data; name="name"
MickFone
------WebKitFormBoundaryI58Bh9EERAVK3lE7
Content-Disposition: form-data; name="age"
58
------WebKitFormBoundaryI58Bh9EERAVK3lE7
Content-Disposition: form-data; name="materials"; filename="sign.png"
Content-Type: image/png
PNG信息(亂碼)
------WebKitFormBoundaryI58Bh9EERAVK3lE7
Content-Disposition: form-data; name="materials"; filename="文本.txt"
Content-Type: text/plain
兩個黃鸝鳴翠柳,
一行白鷺上青天。
------WebKitFormBoundaryI58Bh9EERAVK3lE7--
上傳文件時的Conent-Type是multipart/form-data,這種情況下就不能再簡單地使用=, &
連接項,因為可能上傳了二進制文件,而且文件上傳需要攜帶它們的描述信息,比如mime類型、文件名、編碼方式這些,所以multipart/form-data類型的表單格式會變得比較復雜。
來看看具體的格式,首先在請求頭中Content-Type不僅指定了mime類型,還指定了一個boundary,以這個作為請求體中每個field之間的界線,每兩個boundary之間的部分都是一個表單項,或者是一個文件。每個部分都有Content-Disposition頭,其中的name屬性指定了文件在form表單中的name;如果是文件,則有額外的filename屬性表示上傳文件名(帶擴展名)和Content-Type頭描述其mime類型。描述信息結束后是一個空行,緊接着的是表單項的值或者文件的內容,最后以一個boundary加上--
作為請求體的結尾。
請求體格式可以大概表示為:
--${boundary}
${描述頭1 Content-Disposition: form-data; ...}
${描述頭2}
..
// 空白行
${內容}
if (end) --${boundary}--
那么koa-body是怎么處理這種上傳格式的呢?koa-body內部使用了formidable,這是個專門解決這類問題的庫,我們來分析一下它的細節處理。
這里需要注意的一點是,Content-Disposition這個頭字段如果出現在響應頭中,則表明瀏覽器應該如何去呈現這個文件,
如:Content-Disposition: inline表明瀏覽器默認應當在頁面中打開它,而 Content-Disposition: attachment 則會使
瀏覽器打開一個“另存為”的對話框保存文件。由此可見其與請求體中這個字段的含義有較大的區別,不能混淆這個頭字段在請求
和響應兩種場景的含義。
formidable對文件上傳的處理
- 首先從請求頭的Content-Type檢測出multipart/form-data類型,並且讀取boundary
- 創建了一個Multiple parser(一個對象模式的Transform流)來用來解析ctx.req,使用boundary初始化它
- Multiple parser開始處理請求體,它內部的實現是一個自動機,通過它提取出文件頭信息(mime、編碼、文件名等)
- 頭信息提取完后,根據文件信息新建File可讀流和一個part工具流,隨着parser的解析觸發part的data事件,將ctx.req暫停,然后往File可讀流中寫入內容,接着恢復ctx.req的流動性
下面是一個簡單版本的實現,只能處理單文件上傳,省略了解析字符串的自動機部分
// middleware.js
const { Transform, Stream } = require('stream')
const fs = require('fs')
const path = require('path')
const dir = path.dirname(process.argv[1])
const LF = 10 // '\n'
const CR = 13 // '\r'
const HYPHEN = 45 // '-'
module.exports = async function middleWare(ctx, next) {
return new Promise((resolve, reject) => {
let part
let File, filepath
// 1. 從Content-Type拿到boundary
const contentType = ctx.headers['content-type']
const boundaryReg = contentType.match(/boundary=(.*)/)
const boundary = Buffer.from('--' + boundaryReg[1])
// 用來收集普通的表單元素
let fields = {}
// 是否碰到文件的標記
let fileFlag = false
// 2. 創建轉換流
const transformer = new Transform({
// 運行在對象模式
objectMode: true,
transform(buffer, encoding, callback) {
let prevIndex = 0
let fieldBegin = 0
let fieldEnd = 0
for (let i = 0, l = buffer.length; i < l; i++) {
const c = buffer[i]
// 檢測到空行
if (!fieldBegin && c === LF && buffer[i-1] == CR && buffer[i-2] === c) {
// 第一部分頭信息,可以使用utf8編碼提取文件信息
let fileInfoBuffer = buffer.slice(prevIndex, i+1)
let fileInfoString = fileInfoBuffer.toString('utf8')
// 這里只簡單地用正則去匹配了文件名
const filenameReg = /name="([^"]+)"(; filename="([^"]+)")?/
if (fileInfoString.match(filenameReg)) {
let name = RegExp.$1
let filename = RegExp.$3
// 3. 獲取文件名
if (filename) {
this.push({ name: 'filename', buffer: Buffer.from(filename) })
fieldBegin = i + 1
fileFlag = true
}
// 獲取表單name屬性
else if (name) {
fieldBegin = i + 1
this.push({ name: 'fieldname', buffer: Buffer.from(name) })
}
}
}
// 簡單地用校驗開頭和結尾匹配的方式判斷表單值或文件的結尾
else if (fieldBegin && c === LF && buffer[i+1] === HYPHEN && buffer[i+2] === HYPHEN) {
let j = i + boundary.length
if (buffer[j] === boundary[boundary.length - 1]) {
fieldEnd = i - 1
let fileBuffer = buffer.slice(fieldBegin, fieldEnd)
this.push({ name: 'fielddata', buffer: fileBuffer })
fieldBegin = fieldEnd = 0
prevIndex = i + 1
}
}
}
callback()
}
})
// 讓轉換流開始流動
let currentFieldName = ''
transformer.on('data', ({ name, buffer }) => {
if (name === 'fieldname') {
currentFieldName = buffer.toString()
}
else if (name === 'filename') {
// 這是個無關緊要的工具流
part = new Stream()
part.readable = true
// 4. 創建待寫入的文件流
filepath = path.resolve(dir, buffer.toString())
File = fs.createWriteStream(filepath)
// 這里只簡單使用了工具流的EventEmitter特征
part.on('data', (chunk) => {
ctx.req.pause()
File.write(chunk, () => {
ctx.req.resume()
})
})
// 寫入后再執行后續操作
part.on('end', () => {
File.end('', async () => {
ctx.fields = fields
ctx.file = filepath
console.log(filepath)
fileFlag = false
resolve()
})
})
}
else if (name === 'fielddata') {
if (fileFlag) {
// 如果是文件,向工具流發送數據
part.emit('data', buffer)
}
// 否則是普通的表單值
else if (currentFieldName) {
let field = fields[currentFieldName]
if (typeof field === 'string') {
fields[currentFieldName] = [field, buffer.toString()]
} else if (Array.isArray(field)) {
field.push(buffer.toString())
} else {
fields[currentFieldName] = buffer.toString()
}
currentFieldName = ''
}
}
})
transformer.on('end', () => {
if (part instanceof Stream) {
part.emit('end')
}
})
ctx.req.on('end', () => {
transformer.end()
})
ctx.req.pipe(transformer)
}).then(next)
}
// app.js
const Koa = require('koa2')
const middleWare = require('./middleware')
const app = new Koa()
app.use(middleWare)
app.use(ctx => {
ctx.body = `Request Fields: ${JSON.stringify(ctx.fields)}, Request Body: ${ctx.file}`;
})
if (!module.parent) {
app.listen(3003, () => console.log('server running on port 3003...'))
}
如果接收這樣的表單:
<form action="http://localhost:3003" method="POST" enctype="multipart/form-data">
<p>
姓名:<input type="text" name="name">
</p>
<p>
年齡:<input type="number" name="age">
</p>
<p>
愛好1:<input type="text" name="hobit">
</p>
<p>
愛好2:<input type="text" name="hobit">
</p>
<p>
資料:<input type="file" multiple name="materials">
</p>
<p>
<input type="submit" value="提交">
</p>
</form>
響應結果為:
Request Fields: {"name":"MickFone","age":"58","hobit":["pingpong","video games"]}, Request Body: ...\upload\instance.png
總結
- multipart/form-data的表單格式與一般的x-www-form-urlencoded有很大區別,在讀取數據上要麻煩一些
- formidable大致使用了四個步驟讀取了multipart/form-data中的普通表單字段和文件,並把文件保存到本地
- 通過了解formidable的基本實現,需要掌握Node.js自定義流的用法