本文为原创, 遵循 CC 4.0 BY-SA 版权协议, 转载需注明出处: https://blog.csdn.net/big_cheng/article/details/121565484.
文中代码属于 public domain (无版权).
为减轻处理压力, 一个较好的方法是程序将下载交由nginx 实际执行, 参考:
https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/
例如:
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment;filename=xxx")
w.Header().Set("X-Accel-Redirect", "/download/xxx")
w.Header().Set("X-Accel-Limit-Rate", "512000") // 500K/s
在nginx conf 里配置下载目录"download" 的位置. 当下游nginx 收到程序如上设置的header 后, 将接手该文件的下载.
程序在设置header 之前可以根据请求参数进行权限检查, 但不便之处是程序收不到nginx 的处理结果(也就不知道实际下载成功否).
在实践中碰到某些浏览器, 在下载时可以自动启用加速功能, 推测其机制:
- 可能是浏览器内部创建多线程来下载同一文件(Range 分块请求)
- 也可能加速模块在云上(作为下载代理, 可具有缓存功能), 在云上多线程下载后 -> 返给浏览器
加速造成一个问题: 我们系统中下载文件需验证已登录且有权限, 而加速线程发出的请求经测试(一般)不带session cookie, 从而导致下载失败(有时失败若干次后偶尔又会带上cookie).
经简单调查, nginx 默认支持文件的分块(Range) 下载, 以更高效下载且容易续传/出错恢复; 而且据闻一些video 控件在源不支持Range 请求时不能seek, 所以简单地禁止Range 请求可能不是个好办法.
目前大多数程序传递登录信息都是通过cookie session, 虽然这不是http 标准定义的方式. RFC 7235 (HTTP Authentication) 5.1 指向的iana 官方网页注册了Basic/Bearer/Digest/OAuth 等鉴权方式, 没有"session".
经测试, 发现未登录时设置"WWW-Authenticate" header 并返回401 (Unauthorized) 并没有效果.
好在我们系统的下载文件的安全等级不高, 所以想到一个办法: 在浏览器首次请求下载时, 生成一个免登录的下载链接并redirect 回去, 经试验后续请求都会使用这个免登录链接, 这样就可以下载了.
免登录链接 = 原链接 & _dsid=xx.
dsid “download session id”, 在生成免登录链接同时在服务端创建对应的下载会话, 后续带相同dsid 参数的请求使用同一个下载会话. 下载会话的作用:
- 控制有效期(例如5分钟未请求则不再允许使用), 减少安全风险
- 绑定目标下载文件(文件标志存储在下载会话里), 防止例如拿到dsid 后修改原链接的部分指向其他文件
由于下载的并发度不高, 使用 GolangHttpSession-1 数据结构 “概述” 部分描述的数据结构.
import (
"container/list"
"crypto/rand"
"fmt"
"sync"
"time"
)
type DownloadSessionManager struct {
mu *sync.Mutex
m map[string]*list.Element // id => {*dlses}
l *list.List // front (active) -> back (inactive).
}
type dlses struct {
id string
expire time.Time // 过期时间
n int // access count, 0 when create
data string
}
const default_dl_duration_min = 5 // 下载会话有效期: 5min
func NewDownloadSessionManager() *DownloadSessionManager {
return &DownloadSessionManager{
mu: new(sync.Mutex),
m: map[string]*list.Element{},
l: new(list.List),
}
}
dlses.data 可用于绑定下载文件. dlses.n 可用于下载计数(因为同一个文件可能有很多Range 下载请求, 但总体只能算一次下载).
创建下载会话:
// Create 新建一个下载会话, 并存储data 数据. 返回会话id. 不能生成id 时panic.
func (dsm *DownloadSessionManager) Create(data string) string {
dsm.mu.Lock()
defer dsm.mu.Unlock()
id := ""
for i := 0; i < 10; i++ { // (试10次)
if s := genDlSesId(); dsm.m[s] == nil {
id = s
break
}
}
if id == "" {
panic("cannot create download sesId")
}
dsm.m[id] = dsm.l.PushFront(&dlses{
id: id,
expire: time.Now().Add(default_dl_duration_min * time.Minute),
n: 0,
data: data,
})
return id
}
func genDlSesId() string {
// 24位大写, 如"AC58F133FD0E7AAA03A1F5D6"
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return fmt.Sprintf("%X", b)
}
获取下载会话 - 这将在收到免登录下载请求时调用以作验证:
// Get 获取指定下载会话, 返回{access count/int/>=0, data/string}. 如果会话
// 不存在或已过期, 返回nil.
func (dsm *DownloadSessionManager) Get(id string) []interface{} {
dsm.mu.Lock()
defer dsm.mu.Unlock()
dsm.gc() // 同步清理
if e := dsm.m[id]; e != nil {
ds := e.Value.(*dlses)
ds.n++
ds.expire = time.Now().Add(default_dl_duration_min * time.Minute)
dsm.l.MoveToFront(e) // => 最活跃
return []interface{}{ds.n - 1, ds.data}
} else {
return nil
}
}
// gc 清理所有过期下载会话 - 调用者需lock.
func (dsm *DownloadSessionManager) gc() {
now := time.Now()
for e := dsm.l.Back(); e != nil; e = dsm.l.Back() {
ds := e.Value.(*dlses)
if now.After(ds.expire) { // 过期
dsm.l.Remove(e)
delete(dsm.m, ds.id)
} else {
break
}
}
}
因为下载的并发度不高, 所以简化直接在Get 时做过期处理.
如前所述, 由于程序转交nginx 下载后无法知道实际的下载结果, 所以无法准确计数. 保守做法可以:
在Get 返回dlses.n == 0 时计数.
本文未分析Range 请求的"Range" 参数.
Stackoverflow 上有一篇使用cookie 侦测实际下载是否开始的文章:
https://stackoverflow.com/questions/1106377/detect-when-browser-receives-file-download
原理是服务端在响应文件内容同时返回一个cookie, 当页面js 能读到这个cookie 时就说明下载已经开始(但是云加速环境是否compatible?). Interesting.