踩坑自定义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()
    }
}
复制代码

舒口气..真的完了...

相关文章
相关标签/搜索