踩坑自定義Conten-Type

前言

最近想往全乾發展, 一直在看Node相關的東西, 恰好有點我的需求, 就動手擼了個玩具javascript

玩具基於 react + express前端

其中有個場景是這樣, 前端頁面須要同時提交 表單, 圖片, 視頻.java

天然而然的就想到了FormData.node

但express自己不支持formdata類型react

因而乎搜了一圈, 發現你們都推薦 multerexpress

找到multer文檔一看... 須要提早定義好下載路徑json

這不是我想要的...後端

我理想狀態是在request上經過鍵名直接拿到buffer, 由我本身決定後續操做數組

...此時陷入僵局promise

一番思考, 突然聯想到前幾天看到的RTMP協議規範, 忽然蹦出了個一個想法, 不如本身造一個相似的編碼格式?

通過一波艱苦的嘗試後, 終於折騰出來了...

解決方案

前端部分:

構造一個相似FormData的對象, 能夠經過append添加鍵值對, remove刪除, get指定的鍵值

最終發送時, 傳輸一個序列化的二進制流

後端部分:

構造一個解析器, 解析前端傳輸的數據, 把解析後的數據, 掛在express的request對象上

結構:

我設想中前端最終傳輸的結構是這樣

+--------------------------------------+

| header | chunk0 | chunk1 | ... | end |

+--------------------------------------+

一個固定長度的頭尾, 用於驗證數據的完整性

每個鍵值對包裝爲一個 chunk

其中每個 chunk 的格式爲這樣

+----------------------------+

| type | key | length | body |

+----------------------------+

固定長度的幀頭, 包含4個部分

其中 type 爲數據類型的定義, key 爲鍵值名, length 爲值長度, body 爲值內容

最終定義以下:

header 固定長度和內容 字符串 'mpd', 3字節

end 固定長度和內容 字符串 'end', 3字節

chunk頭部: 固定 20 字節, 其中

type 固定長度 1字節, 其內容爲數字, 0 表示常規JSON字符串, 1表示二進制大文件

key 固定長度 15字節, 其內容爲字符串, 表示該數據內容的鍵名

length 固定長度 4字節, 其內容爲數字, 表示數據內容長度

chunk尾部

body 可變長度, 其內容爲數據, 由服務端根據type類型解析

一點思考

我有個糾結好久的地方, 由於固定了chunk中 key 的長度, 以UTF8編碼爲例, 每一個鍵名就只有15個單字符串長度, 但感受也夠用了...

length固定4字節, 能夠描述4個G的內容偏移量, 我感受是夠了

...

構思完成, 開始動手

而後發現...想法很豐滿... 但操做起來, 踩了無數坑...真的是想砍本身幾刀, 爲何非要跟本身過不去?

實現過程

先不急着帖完整代碼

先給老哥們看看工具函數...

  • str2buffer
const str2buffer = str => {
    const encoder = new TextEncoder()
    return encoder.encode(str)
}
複製代碼
  1. 這玩意幹啥的?
    瀏覽器原生提供的API, 用於把字符串轉化爲ArrayBuffer
  2. 爲何須要它?
    Node中的Buffer對象, 能夠類比瀏覽器中的Uint8Array, 能夠理解成爲8bit爲一個單元組成的數組
    <Buffer 12 0a 4d> 張這個樣子
    因此每一個最小單位能表示的數字範圍爲0-255
    而字符串若是以通用的UTF8編碼, 是可變長度, 若是碰到漢字, 就須要3個8bit, 好比你直接new Uint8Array(['中']) 就會溢出 ,而這個API能夠直接完成這個轉換(我感受像是個冷門API, 之前也沒怎麼見過, 不知道低版本瀏覽器支不支持)
  • num2ByteArr
const num2ByteArr = (num) => {
    const rest = Math.floor(num / 256)
    const last = num % 256
    if (rest >= 256) {
        return [...num2ByteArr(rest), last]
    } else if (rest > 0) {
        return [rest, last]
    } else {
        return [last]
    }
}
複製代碼
  1. 這玩意..?
    把數字轉化爲一個數組, 其每一項表示爲一個8bit的數字, 從高位到低位排列
  2. 爲何..?
    喜聞樂見的數學環節, 還記得上面說的嗎?
    每一個單元最大數字255, 若是我要在chunk中表示數字, 就至關於256進制
    好比我定義了這段Buffer是一個數字 <Buffer 01 02 03>
    那麼它轉化爲10進制就是 1 * 256^2 + 2 * 256^1 + 3 * 256^0
  • numFilledLow
const numFilledLow = (raw, len) => {
    if (raw.length < len) {
        const offset = len - raw.length
        const filled = new Array(offset).fill(0).concat(raw)
        return new Uint8Array(filled)
    } else {
        return new Uint8Array(raw)
    }
}
複製代碼
  1. 這...?
    由num2ByteArr獲得的 常規數組 向一個固定長度的 常規數組 填充
    把元數組內容依次填充到低位
    最後轉化爲Uint8Array
  2. 爲...?
    好比我在chunk中定義了4字節長度來表示數字
    如今假設我須要表示的數字是1234
    那麼我但願獲得的最終結果是這樣
    <Buffer 00 00 04 D2>
    可是我在num2ByteArr中獲得的結果是這樣
    [4, 210]
    而Uint8Array在定義後長度就固定了, 不可改變
    那麼我就不能直接由num2ByteArr生成buffer
    須要構造一個我須要長度的Uint8Array, 而後由num2ByteArr產生的結果來向低位填充
  • strFilledLow
const strFilledLow = (raw, len) => {
    if (raw.length < len) {
        const offset = len - raw.length
        const filled = new Uint8Array(offset)
        const res = new Uint8Array(len)
        res.set(filled)
        res.set(raw, offset)
        return res
    } else {
        return new Uint8Array(raw)
    }
}
複製代碼
  1. 這..?
    跟上面哪一個同理, 只不過這個是用字符串來填充
  2. 爲..?
    由於TextEncoder最後編碼出來的是Uint8Array, Uint8Array長度不可變, 因此有些細節上變化
  • concatBuffer
const concatBuffer = (...arrs) => {
    let totalLen = 0
    for (let arr of arrs) {
        totalLen += arr.length
    }
    const res = new Uint8Array(totalLen)
    let offset = 0
    for (let arr of arrs) {
        res.set(arr, offset)
        offset += arr.length
    }
    return res
}
複製代碼
  1. 這..?
    合併多個Uint8Array
  2. 爲..?
    雖然Uint8Array 也是Array, 可是 長度不可變 , 因此並無push, concat這些方法須要本身操做
  • 其餘
const isNumber = v => typeof v === 'number' && v !== NaN
const isString = v => typeof v === 'string'
const isFile = v => v instanceof File
複製代碼

這3個就不說了吧

我把這套方案的類名定爲 MultipleData

發送時調用 實例的 vaules 方法, 會把數據拼接好

代碼以下

import {
    str2buffer,
    num2ByteArr,
    numFilledLow,
    strFilledLow,
    concatBuffer,
    isNumber,
    isString,
    isFile,
} from './untils'

class MultipleData {
    constructor() {
        this.header = str2buffer('mpd')
        this.end = str2buffer('end')
        this.store = {}
    }

    append(key, value) {
        if (!(isNumber(key) || isString(key))) {
            throw new Error('key must be a number or string')
        }

        if (isFile(value)) {
            const _value = await value.arrayBuffer() */
            this.store[key] = new MultipleDataChunk(key, value, 1)
        } else {
            this.store[key] = new MultipleDataChunk(key, value, 0)
        }
    }

    remove(key) {
        delete this.store[key]
    }

    get(key) {
        return this.store[key]
    }

    async values() {
        const chunks = Object.values(this.store)
        const buffers = []
        for (let i = 0; i < chunks.length; i++) {
            const chunkBuffer = await chunks[i].buffer()
            buffers.push(chunkBuffer)
        }
        /**
         * finally buffer like this
         * [header | chunk0 | chunk1 | ... | end] 
         */
        return concatBuffer(this.header, ...buffers, this.end)
    }
}

class MultipleDataChunk {
    constructor(key, value, type) {
        /**
         *  allow number & string , but force to string
         */
        this._key = key.toString()
        if (this._key.length > 15) {
            throw new Error('key must less than 15 char')
        }
        this._type = type
        this._value = value
        
    }

    async buffer() {
        /**
          * if type = 0, call JSON.stringify
          * if type = 1, convert to Uint8Array directly
          */
        let value;
        if (this._type === 0) {
            const jsonStr = JSON.stringify({ [this._key]: this._value })
            value = str2buffer(jsonStr)
        } else {
            const filebuffer = await this._value.arrayBuffer()
            value = new Uint8Array(filebuffer)
        }

        /**
         * structure like this
         * [type | key | length] 
         * [body]
         * type Number 1byte
         * key 15char 15byte
         * length Number 4byte
         */

        const header = new Uint8Array(20)
        const buffer_key = str2buffer(this._key)
        const buffer_length = num2ByteArr(value.length)
        header[0] = this._type
        //header.set(this._type, 0)
        header.set(strFilledLow(buffer_key, 15), 1)
        header.set(numFilledLow(buffer_length, 4), 16)
        return concatBuffer(header, value)
    }

    valueOf() {
        return this._value
    }
}


export default MultipleData
複製代碼

其中還有個小細節

由於我忽然發現 File對象竟然有了個叫 arrayBuffer 的方法

直接調用這個方法返回一個promise, 其resolve的值是這個文件轉化後的ArrayBuff

不用再多一步FileReader了, 舒服

固然也由於這個緣由, 發送數據時得包一層async 或者 Promise


你覺得完了?

後端解析也是坑啊...

一樣, 先看看工具函數

const buffer2num = buf => {
    return Array.prototype.map.call(buf, (i, index, arr) => i * Math.pow(256, arr.length - 1 - index)).reduce((prev, cur) => prev + cur)
}
複製代碼

是否是想打人?

(全世界最好的FP, 不接受反駁)

先別急着動手

他是幹啥的

還記得上面那個例子嗎, 把數字轉化成表示byte的數組, 而後填充

最後拿到的這個玩意 <Buffer 00 00 04 D2>

服務器接收到了這玩意要還原成數字啊...

最開始, 想固然的就 buffer.map(把每一位還原成 n * 256 ^p).reduce(求和)

而後發現不對

仔細排查才發現, node的Buffer對象map返回的每一項依然是 buffer(輸入 輸出 類型統一, 還真是嚴謹的FP)

因此須要想寫鏈式就得調用原生數組的map

最終Buffer解析器的代碼

const {
    buffer2num
} = require('./untils')

class MultipleDataBuffer {
    constructor(buf) {
        this.header = buf.slice(0, 3).toString()
        if (this.header !== 'mpd') {
            throw new Error ('error header')
        }
        let offset = 3
        const res= []
        while (offset < buf.length - 3) {
            const nextHeader = new MultipleDataFrameHeader(buf.slice(offset, offset + 20))
            const nextBody = buf.slice(offset + 20, offset + 20 + nextHeader.bodyLength)
            let nextData;
            if (nextHeader.type === 0) {
                nextData = JSON.parse(nextBody)
            } else {
                nextData = {
                    [nextHeader.key] : nextBody
                }
            }
            res.push(nextData)
            offset = offset + 20 + nextHeader.bodyLength
        }
        this.data = Object.assign({}, ...res)
        this.end = buf.slice(-3).toString()
        if (this.end !== 'end') {
            throw new Error ('error end')
        }
    }
}

class MultipleDataFrameHeader {
    
    constructor(buf) {
        if (buf.length != 20) {
            throw new Error('error frame header length')
        }
        this.type = buffer2num(buf.slice(0, 1))
        this.key = buf.slice(1, 16).filter(i => i != 0).toString()
        this.bodyLength = buffer2num(buf.slice(16, 20))
    }
}

module.exports = MultipleDataBuffer
複製代碼

express中間件(固然, 這個Content-Type, 能夠隨便寫, 只要不跟規範裏的重複就行, 固然你先後端傳的時候得統一)

const isMultipleData = (req, res, next) => {
    const ctype = req.get('Content-Type')
    if (req.method === 'POST' && ctype === 'custom/multipledata') {
        const tempBuffer = [];
        req.on('data', chunk => {
            tempBuffer.push(chunk)
        })
        req.on('end', () => {
            const totalBuffer = Buffer.concat(tempBuffer)
            req.mpd = new MultipleDataBuffer(totalBuffer)
            next()
        })
    } else {
        next()
    }
}
複製代碼

舒口氣..真的完了...

相關文章
相關標籤/搜索