文件的故事

March 24, 2021

前言

项目中遇到的文件下载,上传基本上最常见的事情了。大概半年前,需要实现某表单的查询下载功能,查询还好,只要后端返回数据,我负责展示就好了,但是下载要如何实现呢?用axios的GET请求返回的数据,不忍直视,根本就下载不了。一顿百度谷歌之后,哦,原来这么简单,只要一个window.location.href=url就搞定了,是不是很简单~

文件下载

后来的文件下载我都统统用这种方式,只是下载提示不够明显,后来改为window.open打开个新的tag页,然后自动关闭,明显点下载。好像到这里就已经很完美,一切交给浏览器解决。

直到开始node.js中间层搭建。中间层的功能负责连接前后端,接口还是由后端提供的,具体可以参考淘宝前后端分离实践,以及天猪大神的egg - JSConf China 2016。node.js端用的是egg.js,小公司用它还是很顺心的。平常的API接口还好,只用转发到对应的后端接口就好了,但是文件下载怎么办?难不成我也要在node.js里面写个window.open?滑稽可笑。

这个时候又回到了请求上面,客户端自然还是用window.open,而nodejs端获得文件流再返回给客户端,这样window.open才能用。于是在本地测试了一下,用fs.readFile以及fs.createReadStream的方式都可以返回给客户端,但是下载文件类型却是没有的,几经波折后才发现没有设置Content-dispositionContent-Disposition消息头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地,用上koa2的attachment(fileName)方法更是简单,那这个不就迎刃而解了? 这里测试的只是本地的文件流,那后端接口上的文件流呢?这里还是用ctx.curl方法来请求获取数据,只是需要注意的是curl的设置响应数据格式,不是之前的RESTful的API接口-json交流方式了,而是文件流,所以默认不设置dataType就好了。代码如下:

async download() {
  const { ctx, app } = this;
  let data = {};
  try {
    data = await ctx.curl(url, {
      method: 'GET',
      headers: {},
      timeout: 8000,
    });
  } catch(e) {
    data = {
      status: 404,
      data: {msg:'服务访问出错'}
    }
  }    
  ctx.status = data.status || 404;
  ctx.set(data.headers || {});
  ctx.body = data.data;
}

客户端的文件下载

之前在客户端表格导出不是用window.location.href=url就是用window.open(url),这个方法感觉很土,当然还有更土的就是用a标签,动态修改里面的href,<a href="url" target="_blank">导出</a>一般这种才是最常见的吧,可惜url需要一直修改。那有没有正常的用请求接口的方式来下载文件呢? 答案是有的,

fetch('/download').then((response) => { 
  return response.blob().then((blob) => {
    var a = document.createElement('a');
    var url = window.URL.createObjectURL(blob);
    var filename = response.headers.get('Content-Disposition');
    a.href = url;
    a.download = filename;
    a.click();
    window.URL.revokeObjectURL(url);       
  })
});

这个好像那里看到过,不就是创建了个a标签,再点击下载嘛。。。。还不如直接用a标签方便的多!!而且这里还用到了HTML5的download属性,还有blob对象,实在是麻烦。另外如果接口返回的不是文本流,而是json的话,就不用blob,直接用返回的url作为href,来click就好了。 这么看来用接口的形式来下载文件似乎很笨吧,当然从另外一个角度讲,请求/download接口后可以用js控制很多东西,比如客户端权限认证,而不是一股脑丢给浏览器。

文件上传

文件上传也是软肋,毕竟多年来一直没有接触过。。。。以前知道的范围领域也就是input标签可以设置type属性为file,这样就能上传文件了。后来在项目中还真的遇到文件上传的,但是这个时候已经有各种组件了,上来直接是饿了吗element的组件库,又或则是Ant Design的组件,样式又漂亮,根本不需要自己去开发嘛。。。。但是这样真的好吗,之前忙一直没有时间去看,直到了用上了node.js中间层,需要自己来做中转维护。

客户端实现

想想只是用<input type="file" />要如何实现上传呢,明明这都没有和后端联系上。。。。于是乎只能从饿了吗的代码里面找起来,其实饿了吗和ant design的实现大同小异,只是语言不同罢了。代码如下:

let upLoad = (ev) => {
  let files = Array.prototype.slice.call(ev.target.files);
  let rawFile = files[0];
  const formData = new FormData();
  const xhr = new XMLHttpRequest();

  formData.append('file', rawFile);
  if (xhr.upload) {
    xhr.upload.onprogress = function progress(e) {
      if (e.total > 0) {
        e.percent = e.loaded / e.total * 100;
      }
    };
  }
  xhr.onload = function onload() {
    if (xhr.status < 200 || xhr.status >= 300) {
      return console.log('wrong');
    }

    // onSuccess(getBody(xhr));
  }
  xhr.open('post', '/action?_csrf=VNPzCPKhQRs4eYhoCjFQgwQh', true);  
  xhr.send(formData);
}

上传的文件到哪里了?ev.target.files里面就是上传的文件数组,获取到上传文件的对象,再添加到FormData里面。FormData又是何物?带着一脸懵逼又去一顿百度谷歌,FormData是用XMLHttpRequest发送请求的键/值对,当然这也意味着是表单传输multipart/form-data的形式。如果你想要传入参数,只需要formData.append(key, value)就可以了。上面代码中自然是用formData.append('file', rawFile),紧接着用了xhr.openxhr.send方法,开眼界了,原来xhr.send里面可以带参数的。。。。文件的键名是file。 可以看出上面的处理方式直接用的是XMLHttpRequest 2.0,那为什么不用fetch呢?fetch不应该是未来趋势吗?想来这里有兼容问题,另外一点fetch上传文件好像没有进度条一说,只是Response.body有getReader方法用于读取原始字节流,如此来解决进度条问题Fetch进阶指南,以及XMLHttpRequestabort方法取消对象,也是fetch不能媲美的。

node.js端实现

node.js端的实现就曲折多了,为了获得上传的文件,用了官方的示例里面的方法ctx.getFileStream(),获得了文件流之后,再在官网介绍的httpclient里面有示例,用到了苏大神的formstream模块,生成可以被httpclient消费的stream对象,如下:

const fileStream = await ctx.getFileStream();
const form = new FormStream();
form.stream('file', fileStream, fileStream.filename);
data = await ctx.curl(url, {
  method: 'POST',
  headers: form.headers({
      Cookie: 'cookieHere',
  }),
  // contentType: 'multipart/form-data',
  stream: form,
  dataType: 'json',
}) 

看着简单,但是刚开始弄的时候却一脸懵逼,不知道如何是好,尤其是添加cookie的时候,由于没有用到form.headers,文件上传一直有问题,没有依据rfc1867, multipart/form-data是必须的,同时最重要的是分隔符!!boundary=这个在headers中是一定要加上的。 看了苏大神的FormStream里面,才发现原来这是模拟浏览器文件上传的动作,添加leading再添加stream/buffer,是个不错的npm模块,值得学习。 知道FormStream了,那ctx.getFileStream()又是如何获得stream对象呢,一开始以为是egg中ctx自带的方法,后来查了api指南才知道是egg-multipart模块引入的。但是egg-multipart核心部分其实是基于Busboy模块的。Busboy是个好东西,其安装量也是杠杠的。Busboy是用来解析node.js里接受到的form-data请求,这里egg-multipart用到的代码大致如下;

busboy.on('file', onFile)
function onFile(fieldname, file, filename, encoding, mimetype) {
  if (checkFile) {
    var err = checkFile(fieldname, file, filename, encoding, mimetype)
    if (err) {
      // make sure request stream's data has been read
      var blackHoleStream = new BlackHoleStream()
      file.pipe(blackHoleStream)
      return onError(err)
    }
  }

  // opinionated, but 5 arguments is ridiculous
  file.fieldname = fieldname
  file.filename = filename
  file.transferEncoding = file.encoding = encoding
  file.mimeType = file.mime = mimetype
  ch(file)
}
request.pipe(busboy)

通过pipe,busboy会触发file事件,同时传入file参数,也就是一个可读流ReadableStream.call(this, opts)。对于这个可读流,可以直接file.pipe(fs.createWriteStream(saveTo))将文件保存到本地磁盘,也可以再度转手如ctx.getFileStream()。关于busboy模块还是自己多玩玩比较好。

总结

文件下载上传对于大多数JSer可能都不陌生,但是于我却是刚刚开始,犹如打开了新技能,同时也知道了postman里面的文件上传key值是file,所以想梳理api,总结一下文件相关部分。