Nodejs文件下载三问

1. 如何控制浏览器下载or打开文件?

我们在网站点击文件的链接,有时是在浏览器中直接打开了,有时是下载到本地了,作为服务提供方,我要如何控制响应?

浏览器会根据响应头部来判断如何处理文件。

1.1 基础知识

1.1.1 content-type

在响应中,Content-Type 标头告诉客户端实际返回的内容的内容类型

常见值的有(更多见content-type对照表):

  • text/html html格式
  • text/plain 纯文本格式
  • image/jpeg jpg图片格式
  • application/json JSON数据格式
  • application/pdf pdf格式
  • application/octet-stream 二进制流数据,未知的应用程序文件

1.1.2 content-disposition

在常规的 HTTP 应答中,Content-Disposition 响应标头指示回复的内容该以何种形式展示,是以内联 的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地。

此头部的值有:

  • inline 默认值,表示回复中的消息体会以页面的一部分或者整个页面的形式展示
  • attachment 表示消息体会被下载到本地
  • attachment; filename="filename.jpg" 消息体被下载到本地,下载后的文件名为【filename.jpg】

这两个头信息要如何使用?

1.2 实践

1.2.1 浏览器中打开文件

具体文件类型(浏览器支持预览)+ inline

js 复制代码
const Koa = require('koa');
const app = new Koa();
const fs = require('fs');

app.listen(3000);
console.log('listening on port 3000');

app.use(async (ctx) => {
    const buffer = fs.readFileSync('./头像.jpg');
    ctx.body = buffer;
    console.log(buffer);
    ctx.set('Content-Type', 'image/jpeg');
    ctx.set('Content-Disposition', 'inline');

    /**
        const buffer = fs.readFileSync('./file.txt');
        ctx.body = buffer;
        ctx.set('Content-Type', 'text/plain');
        ctx.set('Content-Disposition', 'inline');
     */
});

1.2.2 在浏览器中下载确定类型的文件

具体文件类型(浏览器支持预览)+ attachment + filename(下载后的文件命名)

js 复制代码
app.use(async (ctx) => {
    const buffer = fs.readFileSync('./头像.jpg');
    ctx.body = buffer;
    ctx.set('Content-Type', 'image/jpeg');
    ctx.set('Content-Disposition', 'attachment;filename="avatar.jpg"');
});

1.2.3 在浏览器中下载未知类型的文件

未知文件 + filename(下载后的文件命名)

js 复制代码
app.use(async (ctx) => {
   const buffer = fs.readFileSync('./头像.jpg');
   ctx.body = buffer;
   ctx.set('Content-Type', 'application/octet-stream');
   ctx.set('Content-Disposition', 'filename="octet.jpg"');
 });

注意 Content-Type: application/octet-stream时,不能在浏览器中打开。

另外在给 body 赋值的时候,koa 会根据 body 值类型自动设置 content-type 。 如果值类型是 Buffer 或者 Stream, 对应值为 application/octet-stream

2. 如何给文件设置缓存?

读取文件需要消耗服务器的内存和CPU的,响应传输文件也要消耗带宽。如果服务端文件无修改,浏览器使用缓存的文件,服务端无需读取文件完整内容,响应时也不用传输文件,此类请求消耗的资源也会减少,那我们应该如何使用缓存?

2.1 基础知识

2.1.1 expires

  • expires 响应头包含时间,即在此时之后,响应过期
  • 无效的日期,比如 0,代表着过去的日期,即该资源已经过期
  • 如果在expires时间内,表示资源未过期
  • 此缓存为强缓存,即如果缓存未过期,则不会发送请求,会直接从浏览器内存缓存或disk cache
  • 如果在cache-control响应头设置了 "max-age" 或者 "s-max-age" 指令,那么 expires 头会被忽略

此请求头是http 1.0版本定义的,有个比较大的缺点是:expires的时间是服务器的时间,而浏览器判断缓存是用的客户端的时间,如果客户端和服务器的时间相差较大,这个判断就不准。

2.1.2 cache-control

Cache-Control 通用消息头字段,被用于在 http 请求和响应中,通过指定指令来实现缓存机制。缓存指令是单向的,这意味着在请求中设置的指令,不一定被包含在响应中。

常用指令如下,更多指令说明见MDN

  1. max-age
  • max-age=<seconds> 设置缓存存储的最大周期,超过这个时间缓存被认为过期 (单位秒)
  • max-age是时间长度,缓存过期时间为响应头中的 date 加上 max-age
  • 此缓存为强缓存
  • max-age=0 则效果同no-cache

在chrome浏览器上【刷新】请求,浏览器发送请求会携带请求头max-age=0,safari也是。

这个只会对主请求增加,派生请求不会。

  1. no-store
    no-store 缓存不应存储有关客户端请求或服务器响应的任何内容,即不使用任何缓存

  2. no-cache
    no-cache 在发布缓存副本之前,强制要求缓存把请求提交给原始服务器进行验证(协商缓存验证)。

开发者调试选择【停用缓存】、浏览器【强制刷新】(mac 中是command+shift+R),主请求、派生请求都会增加此请求头。

2.1.3 last-modified/if-modified-since

  1. last-modified

last-modified 是一个响应首部,其中包含源头服务器认定的资源做出修改的日期及时间。它通常被用作一个验证器来判断接收到的或者存储的资源是否彼此一致。

  1. if-modified-since

if-modified-since 是一个条件式请求首部,服务器只在所请求的资源在给定的日期时间之后对内容进行过修改的情况下才会将资源返回,状态码为 200。

如果请求的资源从那时起未经修改,那么返回一个不带有消息主体的 304 响应,而在 last-modified 首部中会带有上次修改时间。

if-modified-since 只可以用在 GET 或 HEAD 请求中。

  1. 缺点
    此对头信息是http 1.0协议中定义的,主要有两个缺点:
  • 文件的修改时间精确到秒,如果在一秒内修改文件,记录不到
  • 如果只是打开文件,不修改,直接保存,修改时间会被更新

所以只用文件最后修改时间来判断文件是否修改不太准确,http 1.1版本就增加了etag这对请求头来更好的判断文件是否修改。

2.1.4 etag/if-none-match

  1. etag

etag HTTP 响应头是资源的特定版本的标识符。这可以让缓存更高效,并节省带宽,因为如果内容没有改变,Web 服务器不需要发送完整的响应。而如果内容发生了变化,使用 ETag 有助于防止资源的同时更新相互覆盖("空中碰撞")。

如果给定 URL 中的资源更改,则一定 要生成新的 etag 值。比较这些 etag 能快速确定此资源是否变化。

  1. if-none-match

if-none-match 是一个条件式请求首部 对于 GET 和 HEAD 请求方法来说,当且仅当服务器上没有任何资源的 etag 属性值与这个首部中列出的相匹配的时候,服务器端才会返回所请求的资源,响应码为 200。

当验证失败的时候,服务器端必须返回响应码 304(Not Modified,未改变)。需要注意的是,服务器端在生成状态码为 304 的响应的时候,必须同时生成以下会存在于对应的 200 响应中的首部:cache-control、content-location、date、etag、expires 和 vary。

当与 if-modified-since 一同使用的时候,if-none-match 优先级更高(假如服务器支持的话)

2.2 头部字段计算

2.2.1 如何判断 max-age 缓存是否过期?

max-age相比expire的优点是使用了时间长度,不完全依赖本地时间,那么max-age 如何计算资源是否过期?RFC 2616

ini 复制代码
13.2.3 Age Calculations

Summary of age calculation algorithm, when a cache receives a
   response:

      /*
       * age_value
       *      is the value of Age: header received by the cache with this response(响应头中的age, 响应在从源服务器开始的路径上驻留在每个缓存中的累计时间,加上它沿着网络路径传输的时间量)
       * date_value
       *      is the value of the origin server's Date: header (源服务器响应头Date)
       * request_time
       *      is the (local) time when the cache made the request that resulted in this cached response (发起请求的本地时间)
       * response_time
       *      is the (local) time when the cache received the response (收到请求的本地时间)
       * now
       *      is the current (local) time (当前本地时间)
       */

      apparent_age = max(0, response_time - date_value);
      corrected_received_age = max(apparent_age, age_value);
      response_delay = response_time - request_time;
      corrected_initial_age = corrected_received_age + response_delay;
      resident_time = now - response_time;
      current_age   = corrected_initial_age + resident_time;


13.2.4 Expiration Calculations

 The max-age directive takes priority over Expires, so if max-age is
   present in a response, the calculation is simply:

      freshness_lifetime = max_age_value

   Otherwise, if Expires is present in the response, the calculation is:

      freshness_lifetime = expires_value - date_value
The calculation to determine if a response has expired is quite
   simple:

      response_is_fresh = (freshness_lifetime > current_age)

例如:

  • 2023-08-24 08:00:00 发起请求 => request_time
  • 2023-08-24 08:00:02 收到响应 => response_time
  • 响应头中的age=1 => age_value
  • 响应头中的date=2023-08-24 08:00:01 => date_value
  • 当前时间 2023-08-24 08:00:50 => now
  • 响应头中的max-age=60s
ini 复制代码
apparent_age = max(0, response_time - date_value) = max(0, 1) = 1

corrected_received_age = max(apparent_age, age_value) = max(0, 1) = 1

response_delay = response_time - request_time = 2

corrected_initial_age = corrected_received_age + response_delay = 3

resident_time = now - response_time = 48

current_age = corrected_initial_age + resident_time = 51

freshness_lifetime = 60

response_is_fresh = (freshness_lifetime > current_age) = true,则响应未过期

2.2.2 如何计算etag?

etag的计算有需求:

  • 计算速度快
  • 文件内容变化了,etag也会变化
  • 文件内容没变化,etag也不变

nginx 上也能配置etag,它是如何计算的?

ini 复制代码
etag->value.len = ngx_sprintf(etag->value.data, ""%xT-%xO"",
                                  r->headers_out.last_modified_time,
                                  r->headers_out.content_length_n)
                      - etag->value.data;

r->headers_out.etag = etag;

使用了文件的最后修改时间和文件大小,这种方式计算快,文件变更时(大部分场景,除了文件1s内变化了但大小没变),etag的值也会发生变化。这种方案因为使用了last-modified_time,还是存在和last-modified一样的缺点,文件内容没变化,但etag值会发生变化。

node中有个库etag,有两种方式计算:

  1. 同nginx,使用文件修改时间和文件大小
js 复制代码
function stattag (stat) {
  var mtime = stat.mtime.getTime().toString(16)
  var size = stat.size.toString(16)
  return '"' + size + '-' + mtime + '"'
}

这种方式计算速度十分快

ini 复制代码
const fs = require('fs');
const etag = require('etag');

const stats = fs.statSync('./doc.pdf');
const start = Date.now();
console.log(
    '文件大小为 %d, etag为 %s, 耗时 %d ms', 
    stats.size, 
    etag(stats, {weak: false}), 
    Date.now() - start
);
// 文件大小为 14349127, etag为 "daf347-183c1e63d5c", 耗时 0 ms
  1. 使用文件内容计算hash值
    读取文件buffer,计算hash
js 复制代码
function entitytag (entity) {
  if (entity.length === 0) {
    // fast-path empty
    return '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"'
  }

  // compute hash of entity
  var hash = crypto
    .createHash('sha1')
    .update(entity, 'utf8')
    .digest('base64')
    .substring(0, 27)

  // compute length of entity
  var len = typeof entity === 'string'
    ? Buffer.byteLength(entity, 'utf8')
    : entity.length

  return '"' + len.toString(16) + '-' + hash + '"'
}

此方案只有文件内容变更时,etag值才会发生变化,但需要的时间和内存(读取文件buffer)都更多:

js 复制代码
const fs = require('fs');
const etag = require('etag');

const buffer = fs.readFileSync('./doc.pdf');
console.log(
    '文件大小为 %d, etag为 %s, 耗时 %d ms', 
    buffer.length, 
    etag(buffer, {weak: false}), 
    Date.now() - start
);
// 文件大小为 14349127, etag为 "daf347-I6YY4umgGLXmfZnqMB3lSpfjv1g", 耗时 21 ms

这两种方法各有利弊,可根据情况来使用。

2.3 实践

2.2.1 缓存未过期,不发送请求

js 复制代码
const Koa = require('koa');
const app = new Koa();
const fs = require('fs');


app.listen(3000);
console.log('listening on port 3000');

app.use(async (ctx) => {
    const buffer = fs.readFileSync('./头像.jpg');
    ctx.body = buffer;
    ctx.set('Content-Type', 'image/jpeg');
    ctx.set('Cache-Control', 'public,max-age=400');
});

2.2.2 发送请求,文件改动/文件未改动

js 复制代码
const Koa = require('koa');
const app = new Koa();
const fs = require('fs');
const etag = require('etag');


app.listen(3000);
console.log('listening on port 3000');


app.use(async (ctx) => {
    const requestEtag = ctx.get('If-None-Match');
    const stats = fs.statSync('./头像.jpg');
    const fileEtag = etag(stats, {weak: false});
    if(requestEtag && fileEtag === requestEtag) {
        ctx.status = 304;
        return;
    }

    const buffer = fs.readFileSync('./头像.jpg');
    ctx.body = buffer;
    ctx.set('Content-Type', 'image/jpeg');
    ctx.set('Cache-Control', 'no-cache');
    ctx.set('Etag', fileEtag);
});

3. Transfer-Encoding chunked 是什么?

在了解此字段前先了解下content-length。

  1. content-length

content-length 是一个实体消息首部,用来指明发送给接收方的消息主体的大小,即用十进制数字表示的八位元组的数目。

js 复制代码
const Koa = require('koa');
const app = new Koa();

app.listen(3000);
console.log('listening on port 3000');

app.use(async (ctx) => {
    const body = '明天,你好!';
    ctx.body = body;
    ctx.set('content-length', Buffer.byteLength(body));
});

但这个要求在开始响应的时候就能知道响应体的长度,那那些不能怎么处理?

  1. tranfer-encoding

chunked数据以一系列分块的形式进行发送。 content-length 首部在这种情况下不被发送。在每一个分块的开头需要添加当前分块的长度,以十六进制的形式表示,后面紧跟着 '\r\n' ,之后是分块本身,后面也是'\r\n' 。终止块是一个常规的分块,不同之处在于其长度为 0

js 复制代码
const Koa = require('koa');
const app = new Koa();
const fs = require('fs');

app.listen(3000);
console.log('listening on port 3000');


app.use(async (ctx) => {
    const stream = fs.createReadStream('./doc.pdf');
    ctx.body = stream;
    ctx.set('Content-Type', 'application/pdf');
    ctx.set('Content-Disposition', 'inline');
});

参考文章

相关推荐
信徒_15 分钟前
go 语言中的线程池
开发语言·后端·golang
Pandaconda15 分钟前
【Golang 面试题】每日 3 题(六十五)
开发语言·经验分享·笔记·后端·面试·golang·go
至暗时刻darkest16 分钟前
go 查看版本
开发语言·后端·golang
Violet51533 分钟前
ECMAScript规范解读——this的判定
javascript
知识分享小能手1 小时前
Html5学习教程,从入门到精通,HTML5 简介语法知识点及案例代码(1)
开发语言·前端·javascript·学习·前端框架·html·html5
IT、木易1 小时前
大白话React第二章深入理解阶段
前端·javascript·react.js
muxue1781 小时前
go:运行第一个go语言程序
开发语言·后端·golang
米饭好好吃.1 小时前
【Go】Go wire 依赖注入
开发语言·后端·golang
闲猫1 小时前
go 接口interface func (m Market) getName() string {
开发语言·后端·golang
Good Note1 小时前
Golang的静态强类型、编译型、并发型
java·数据库·redis·后端·mysql·面试·golang