前言
最近想往全干发展, 一直在看Node相关的东西, 刚好有点个人需求, 就动手撸了个玩具
玩具基于 react + express
其中有个场景是这样, 前端页面需要同时提交 表单, 图片, 视频.
自然而然的就想到了FormData.
但express本身不支持formdata类型
于是乎搜了一圈, 发现大家都推荐 multer
找到multer文档一看... 需要提前定义好下载路径
这不是我想要的...
我理想状态是在request上通过键名直接拿到buffer, 由我自己决定后续操作
...此时陷入僵局
一番思考, 忽然联想到前几天看到的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)
}
- 这玩意干啥的?
浏览器原生提供的API, 用于把字符串转化为ArrayBuffer - 为什么需要它?
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]
}
}
- 这玩意..?
把数字转化为一个数组, 其每一项表示为一个8bit的数字, 从高位到低位排列 - 为什么..?
喜闻乐见的数学环节, 还记得上面说的吗?
每个单元最大数字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)
}
}
- 这...?
由num2ByteArr得到的 常规数组 向一个固定长度的 常规数组 填充
把元数组内容依次填充到低位
最后转化为Uint8Array - 为...?
比如我在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)
}
}
- 这..?
跟上面哪个同理, 只不过这个是用字符串来填充 - 为..?
因为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
}
- 这..?
合并多个Uint8Array - 为..?
虽然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()
}
}