免责声明:

  1. 我不是专业的网站开发。
  2. 这些行为虽然会令人意外,但是仔细想想又确实合理。
  3. 这篇文章的主要目的是缅怀我花费在这件事情上的N个小时。

我有一个网页服务,用户可以登录并管理存储在该服务中的数据。其中一个功能允许用户下载他们的数据:基本上,用户可以点击网站上的一个按钮,然后会弹出一个新页面,这个页面的URL将返回用户所需的数据,然后剩下由浏览器接管。非常常见。

你大概可以想象,这个实现相当简单,唯一可能不那么直接的是:后端是一个 REST API 服务,前端通过为每个请求的 Authorization 头设置为用户令牌来进行身份验证。然而,对于下载的 GET 请求,浏览器不支持为新页面中打开的 URL 设置请求头。所以我最终的做法是:一个 POST 请求用于创建下载令牌,接收所有参数和身份验证。另一个下载的 GET 请求,下载令牌将会包含在 URL 中。下载令牌的有效时间很短,因此实现可以非常简单粗暴。我知道还有其他方法,但我觉得它们要么有些过度设计,要么有点奇怪。不管怎样,这种方法运行可行,且在生产环境中运行得不错。

那为什么会有这篇文章

呃,有天我开始为这服务添加一个新功能,其实跟之前的很相似,也是用户下载一些文件,只是这次服务器需要进行一些复杂的计算来生成提供给用户下载的文件。计算量倒是也没有很大,坏的情况下需要几秒,不足以造成 HTTP 超时之类的。不知道你怎么想🤔,至少我没有预料到会有坑。事实上这个方法在本地运行的不错(著名Flag),但是当我将其部署到生产环境时……

一号坑

我注意到每次用户点击下载按钮时,后端会收到两个相同的 GET 请求,因此服务器会进行两次耗时计算,糟糕。 WHY?原来浏览器(我使用的是Chrome)有这么一个优化:如果一个 GET 请求在几秒内没有收到响应,它会开始怀疑该请求是否有问题,并尝试重新发送一个请求。这不会影响正确性,因为 GET 请求应该是幂等的。我尝试了一下让下载令牌只有效一次(幂等性再见),但是这没用,在发出第二个请求后,浏览器会关闭第一个请求。也许我可以让服务器先响应一些内容,然后再生成其余的部分,但我觉得这也有点奇怪,也没有那么方便实现。

二号坑

接下来我尝试了:将第二个 GET 请求的复杂计算移到第一个 POST 请求中,这样创建令牌可能需要几秒钟,服务器上维护一个从下载令牌到一些二进制数据的映射。这里就触发了第二个坑,这时浏览器会将"打开新页面"部分视为弹窗并阻止(有一个小的UI组件提示用户有被阻止的弹窗,用户也可以手动解除,但这不太用户友好)。简单来说,浏览器有一套非常复杂的规则来决定什么样的弹出窗口是允许的,什么是不允许的。例如,当用户与页面有互动时(如点击一个按钮),则可可以打开一个新页面,但是稍后再打开则不行。

下一步?

我 认为 希望这些是全部的坑了,计划下一步尝试:

  • 复杂计算放在 POST 请求中,在请求期间前端会显示一个加载提示,并在数据完全准备好后显示一个新的下载按钮。
  • 或者:复杂计算留在 GET 请求中,但是 GET 请求只会在第一次执行这个运行(配合一个锁避免冲突),并在下载令牌有效期间缓存这个结果。