node转发formdata数据

1,719 阅读3分钟

这是我参与更文挑战的第2天,活动详情查看: 更文挑战

node转发formdata数据

最近补回了千年老坑:企业微信机器人sdk兼容node,解决的过程有点困难,还好都一一解决了..

背景:由于企业微信上传文件的接口使用的是formdata格式,所以就干脆客户端和作为代理的node代码都需要用formdata格式了。但是吧我又不想用其他库,毕竟就是一个小小的请求而已..自己封装一下得了。于是就开始了我的踩坑之路...

背景

客户端:浏览器环境代码

直接用的是formdata,没啥好说的,代码如下:

form.setAttribute("method", "POST");
if(format === "formdata"){
    // 如果要发送formdata类型就直接把页面上的input赋值到form的input中
    form.setAttribute("action", "http://localhost:3000/postFile");
    form.setAttribute("enctype","multipart/form-data")
    let input = document.createElement("input");
    input = msg;
    form.appendChild(input);
    let keyInput = document.createElement("input");
    keyInput.name = "rootKey"
    keyInput.value = this.key
    form.appendChild(keyInput);
}

客户端:node环境代码

需要自己封装formdata了,参考了postman里面的http格式代码,调试了很多次最后得到代码如下(其实是太不对的代码):

const resObj = {
    postData: "",
    headers: {} as any
}
//格式化消息体
if(format === "formdata"){
    //随便来一个key,问题不大
    const boundaryKey = "515897053453198140930459"
    resObj.postData = getFormData(boundaryKey, '1.txt', msg, this.key)
    resObj.headers = {
      hostname'localhost',
      port3000,
      path'/postFile',
      method'POST',
      headers: {
        'Content-Type''multipart/form-data; boundary=--------------------------515897053453198140930459',
        'Content-Length'checkLength(resObj.postData)
      }
    };
}
//封装消息体
export function getFormData(boundaryKey:string, filename: string, msg: string, key: string) {
    const extType = getMimeType(filename)
    return `----------------------------${boundaryKey}\r
Content-Disposition: form-data; name="media"; filename="${filename}"\r
Content-Type: ${extType}\r
\r
${msg}\r
----------------------------${boundaryKey}\r
Content-Disposition: form-data; name="rootKey"\r
\r
${key}\r
----------------------------${boundaryKey}--\r
`
}

function getMimeType(filename: string){
    const mimes = {
        '.png': 'image/png',
        '.gif': 'image/gif',
        '.jpg': 'image/jpeg',
        '.jpeg': 'image/jpeg',
        '.txt': 'text/plain'
    };
    const extname = filename.match(/\.(\w+)$/)
    return extname?mimes[extname[0]]:mimes['.txt'] //默认txt
}

这里边有一些调试了很多次才被我揪出来的坑:

  1. 字符长度Content-Length不能写太长,写短了消息体会被切短,最好老老实实计算。
  2. Content-Type一定要写,里面的boundary不能照抄postman,要写很长的-----。
  3. 这个boundarykey填啥都可以,可以固定不变,不影响请求。
  4. 注意\r\n,一定要注意,不然会导致消息体解析失败,请求直接发不出来。
  5. 注意各个key之间的顺序,不然到node端自己手动解析就很麻烦。

格式化好之后发送请求代码如下:

//发送formdata请求
const {headers, postData} = this.getFormatBody(sendMsg[i], format)
const req = http.request(headers, function(res) {
    res.setEncoding('utf8');
    const resData = [] as any;
    res.on('data', function (chunk) {
      resData.push(chunk);
    });
    res.on('end', function() {
      allRes.push(resData.join(""))
      if(i===msg.length-1){
          resolve(allRes);
      }
    })
});
req.write(postData);

服务端代码

做服务端的时候就有点麻烦了,一开始我是做了一个简单的字符串解析:

const postFile = async (ctx: any) => {
    const postMsg = ctx.request
    delete postMsg.header.host
    // 获取客户端传过来的key值
    const keyReg = /(?<="rootKey"\r\n\r\n)(\w|\W)+(?=\r\n-----)/
    const keyStr = allBodyArr.pop()
    key = keyStr.match(keyReg)[0]
    // 发送rawBody
    const allBody = postMsg.rawBody //要设置bodyParser的拓展格式的form为formdata才能获取到
    // 获取formdata中的分隔符(其实也可以在header中获取)
    const splitKey = allBody.match(/------(\w|\W)+?(?=\n)/)[0]
    // 获取每一部分消息体
    const allBodyArr = allBody.split(splitKey)
    // 第一个是换行符,去掉
    allBodyArr.shift()
    for(let i = 0; i < allBodyArr.length; i++){
        // 拼接formdata body
        let bodyStr = `\n${splitKey}${allBodyArr[i]}${splitKey}\n`
        // 调用企业微信接口,获取文件的id
        let fileInfo = await postAxios(`https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key=${key}&type=file`, 
            postMsg.header, bodyStr)
        if(fileInfo.errcode=="0"){
            // 发送文件
            let res = await postAxios(`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${key}`,
                null, botSendFile(fileInfo["media_id"]))
            if(res.errcode!="0"){
                ctx.body = sendText(0, 200, "发送文件失败")
            }
        }else{
            ctx.body = sendText(0, 200, "获取文件id失败")
        }
        ctx.body = sendText(0, 200, "ok")
    }
}

这时候其实是可以跑起来的,但是多发几个文件就会发现问题:

  1. 文本文件可以正常传递,但是传输文件和图片出现问题,发送到企业微信上的图片,下载下来发现不符合规范。
  2. 而且有些小图片明明符合长度要求,客户端却报错。

找bug

先从请求入手,发现postmsg,node,浏览器三个方法得到的都一样,是错误的文件格式,于是确认时后端代码的问题。其实我对自己封装的东西也没啥自信,怀疑是写多了什么或者不小心弄少了什么字符,然后我拿到无法显示的图片进行保存,右键以文本的形式打开,然后再把原来的图片用文本格式打开,进行对比。发现无法显示的图片是utf-8编码,原来的图片并不是这个编码方式。是bodyparser直接把数据转换成utf-8格式了,查了一下,文档没有说怎么获取buffer格式。

好吧,所以还是要我自己来想办法处理

解决思路

  1. 思路1:保存后转发。网上的博客一搜全都是这种方法,考虑过要不要用,但是还是觉得太累赘。

  2. 思路2:直接转发。

    其实我用网上的formdata库试了一下,是可以直接把读取到的文件用axios进行转发的,想不通为啥,就去看了一下源码,发现人家使用的是流的形式,于是思考怎么用流的方式去做转发...

    1. 想去配置代理直接转发客户端请求,查了半天太麻烦了。
    2. 然后去看pipe啥的能不能做转发,好像没啥用,怎么转发都是依赖数据格式的,最终还是这个数据格式的问题。
    3. 最后又想怎么获取到原生的请求体。

解决问题

  1. 发现ctx.req是node的原生请求,于是用req.on监听得到原生buffer数据。试了一下axios能不能发送,嘿老天不负有心人,居然还真的可以,于是解决问题。
  2. 解决之后发现之前的图片等请求出了问题,发现还是需要bodyparser,于是把转buffer这一过程以中间件的形式写在bodyparser前面,挂到req的属性上。

最终代码如下:

//中间件代码
app.use(async (ctx, next) => {
    //处理formdata格式数据为buffer类型
    if (ctx.request.url === '/postFile') {
        const buffer: any = []
        ctx.req.on('data', (chunk: any) => {
            buffer.push(chunk)
        })
        ctx.req.on('end', (chunk: any) => {
            const bufferRes = Buffer.concat(buffer)
            const keyReg = /(?<="rootKey"\r\n\r\n)(\w|\W)+(?=\r\n-----)/
            const key = bufferRes.toString('utf-8').match(keyReg)[0]
            ctx.myrequest = {
                bufferRes,
                key,
            }
        })
    }
}))
    await next()
}).use(
    bodyParser({
        formLimit: '20mb',
        extendTypes: {
            form: ['multipart/form-data'], // will parse application/x-javascript type body as a JSON string
        },
    }),
)
//发送文件代码
const postFile = async (ctx: any) => {
    const postMsg = ctx.request
    delete postMsg.header.host
    const {bufferRes, key} = ctx.myrequest
    let fileInfo = await postAxios(
        `https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key=${key}&type=file`,
        postMsg.header,
        bufferRes,
    )
    if (fileInfo.errcode == '0') {
        // 发送文件
        let res = await postAxios(
            `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${key}`,
            null,
            botSendFile(fileInfo['media_id']),
        )
        if (res.errcode != '0') {
            ctx.body = sendText(0, 200, '发送文件失败')
        }
    } else {
        ctx.body = sendText(0, 200, '获取文件id失败')
    }
    ctx.body = sendText(0, 200, '发送文件成功')
}

成功解决~

客户端node环境代码问题

post和浏览器搞定了,发现node中自己封装的formdata还是有编码问题,因为我是直接用字符串拼接的

解决:把代码分成头部,消息体,和尾部。

  1. 消息体由于是直接读取的文件,本来就是buffer

  2. 头部和尾部都是字符串,所以都要转为buffer。

    用Buffer.from(head/tail)即可。

  3. 最后用Buffer.concat把三部分进行拼接,用Buffer.byteLength(allBuffer)计算字节长度

代码如下:

const boundaryKey = "515897053453198140930459"

//要用buffer避免编码问题
export function getFormData(filename: string, msg: any, key: string) {
    const extType = getMimeType(filename)
    const head = `----------------------------${boundaryKey}\r
Content-Disposition: form-data; name="media"; filename="${filename}"\r
Content-Type: ${extType}\r
\r\n`
    const tail = `\r
----------------------------${boundaryKey}\r
Content-Disposition: form-data; name="rootKey"\r
\r
${key}\r
----------------------------${boundaryKey}--\r
    `//全都转为buffer
    const headBuffer = Buffer.from(head)
    const tailBuffer = Buffer.from(tail)
    const allBuffer = Buffer.concat([headBuffer, msg, tailBuffer])
    return {
        body: allBuffer,
        head: {
            'Content-Type'`multipart/form-data; boundary=--------------------------${boundaryKey}`,
            'Content-Length'Buffer.byteLength(allBuffer)
        }
    }
}

解决问题~~