文件上传与下载
文件上传
简单文件上传
package main
import (
"log"
"os"
"github.com/cybergodev/httpc"
)
func main() {
fileContent, err := os.ReadFile("document.pdf")
if err != nil {
log.Fatal(err)
}
result, err := httpc.Post("https://api.example.com/upload",
httpc.WithFile("file", "document.pdf", fileContent),
)
if err != nil {
log.Fatal(err)
}
log.Printf("上传完成:%d", result.StatusCode()) // 输出示例:上传完成:200(实际状态码取决于服务端)
}Multipart 表单
上传文件的同时附带表单字段:
form := &httpc.FormData{
Fields: map[string]string{
"title": "My Document",
"type": "pdf",
},
Files: map[string]*httpc.FileData{
"file": {
Filename: "report.pdf",
Content: fileContent,
},
},
}
result, err := httpc.Post("https://api.example.com/upload",
httpc.WithFormData(form),
)FileData 还提供 ContentType 字段,用于显式声明每个文件的 MIME 类型(不设置时该 part 默认为 application/octet-stream):
Files: map[string]*httpc.FileData{
"file": {
Filename: "document.pdf",
Content: fileContent,
ContentType: "application/pdf", // 显式声明 MIME 类型
},
},多文件上传
form := &httpc.FormData{
Fields: map[string]string{
"description": "批量上传",
},
Files: map[string]*httpc.FileData{
"file1": {Filename: "doc1.pdf", Content: content1},
"file2": {Filename: "doc2.pdf", Content: content2},
"file3": {Filename: "image.png", Content: content3},
},
}
result, err := httpc.Post(url, httpc.WithFormData(form))二进制上传
data, err := os.ReadFile("data.bin")
if err != nil {
log.Fatal(err)
}
result, err := httpc.Post(url,
httpc.WithBinary(data, "application/octet-stream"),
)
if err != nil {
log.Fatal(err)
}流式上传(大文件)
WithBody 直接接受 io.Reader,无需把整个文件读入内存。Reader 请求体的 Content-Type 不会被自动检测,需显式设置:
file, err := os.Open("large-video.mp4")
if err != nil {
log.Fatal(err)
}
defer file.Close()
result, err := httpc.Post("https://api.example.com/upload",
httpc.WithBody(file),
httpc.WithHeader("Content-Type", "video/mp4"),
)配合 io.Pipe 可实现零拷贝流式上传——生产者 goroutine 边生成边发送,HTTP 传输层并发消费:
pr, pw := io.Pipe()
go func() {
defer pw.Close()
// 分块写入 pw,例如从数据库或生成器逐块产出
_, _ = pw.Write(chunk)
}()
result, err := httpc.Post("https://api.example.com/upload",
httpc.WithBody(pr),
httpc.WithHeader("Content-Type", "application/octet-stream"),
)Reader 请求体绕过大小验证
io.Reader 无法预知数据长度,HTTPC 对其不做大小校验。上传不可信数据时用 io.LimitReader 包裹,或配置 Security.MaxRequestBodySize 全局上限:
result, err := httpc.Post(url,
httpc.WithBody(io.LimitReader(reader, 10<<20)), // 上限 10MB
httpc.WithHeader("Content-Type", "application/octet-stream"),
)文件下载
Download(ctx, url, cfg, options...) 是贯穿包级函数、Client 和 DomainClient 的唯一规范下载入口。
下载始终以流式进行:内部自动附加 WithStreamBody(true),响应体从网络直达磁盘(io.Copy),全程不会把整个文件缓冲进内存;启用校验和时哈希计算也在写入过程中同步完成,不产生第二遍读盘。
下载受 MaxResponseBodySize 约束
流式写盘不等于没有大小上限:下载响应体仍受客户端 Security.MaxResponseBodySize 约束——DefaultConfig 默认 10MB(其他预设:SecureConfig 5MB、PerformanceConfig 50MB、MinimalConfig 1MB)。超出上限时下载以 ErrResponseBodyTooLarge 错误失败,不会在限额处静默截断文件。下载大文件前需先调大该值:
cfg := httpc.DefaultConfig()
cfg.Security.MaxResponseBodySize = 200 << 20 // 200MB,按需调整基本下载
cfg := httpc.DefaultDownloadConfig()
cfg.FilePath = "/tmp/file.zip"
result, err := httpc.Download(context.Background(), "https://example.com/file.zip", cfg)
if err != nil {
log.Fatal(err)
}
fmt.Printf("下载完成: %s\n", httpc.FormatBytes(result.BytesWritten))
fmt.Printf("耗时: %v\n", result.Duration)带进度回调
cfg := httpc.DefaultDownloadConfig()
cfg.FilePath = "/tmp/file.zip"
cfg.Overwrite = true
cfg.ProgressCallback = func(downloaded, total int64, speed float64) {
pct := float64(downloaded) / float64(total) * 100
fmt.Printf("\r下载中: %.1f%% (%s)", pct, httpc.FormatSpeed(speed))
}
result, err := httpc.Download(context.Background(), "https://example.com/file.zip", cfg)
if err != nil {
log.Fatal(err)
}
fmt.Printf("\n下载完成: %s, 平均速度 %s\n",
httpc.FormatBytes(result.BytesWritten),
httpc.FormatSpeed(result.AverageSpeed),
)进度回调的触发规则
回调的参数是 (已下载字节, 总字节, 当前速度),触发节奏由实现保证:
- 最短间隔 200ms:高速网络下不会因高频回调拖慢写盘,低速网络下也能稳定刷新;
- 结束时补一次最终回调:此时
downloaded为总字节数、speed即平均速度,方便 CLI 收尾换行; total的来源:服务器返回的Content-Length;续传场景下服务器对 Range 请求只报告剩余字节数,HTTPC 会自动加上已有文件偏移得到完整大小;total可能为 0 或负数:服务器未返回 Content-Length(chunked 传输)时无法预知总量,回调里要先判total > 0再计算百分比。
带认证与自定义请求头
Download 的可变参数就是普通的 RequestOption,认证头、查询参数、单次超时都可以直接附加:
cfg := httpc.DefaultDownloadConfig()
cfg.FilePath = "/tmp/report.pdf"
cfg.Overwrite = true
result, err := client.Download(context.Background(),
"https://api.example.com/files/report.pdf",
cfg,
httpc.WithBearerToken("my-token"), // 认证
httpc.WithHeader("Accept", "application/pdf"), // 自定义头
httpc.WithTimeout(5*time.Minute), // 单次下载超时预算
)断点续传
cfg := httpc.DefaultDownloadConfig()
cfg.FilePath = "/tmp/large-file.zip"
cfg.ResumeDownload = true
result, err := httpc.Download(context.Background(), url, cfg)
if err != nil {
log.Fatal(err)
}
if result.Resumed {
fmt.Printf("续传完成:从断点恢复\n")
}TIP
断点续传依赖服务端支持 Range 请求头。如果服务端不支持(返回 200 而非 206),将返回错误以保护已下载的部分文件。
续传的完整判定逻辑:
| 服务端响应 | 行为 |
|---|---|
206 Partial Content | 以 O_APPEND 追加写入已有文件,result.Resumed 为 true |
200 OK(不支持 Range) | 返回错误 server does not support range requests,不截断已有部分文件 |
416 Range Not Satisfiable | 返回错误(常见于本地文件已完整、或服务端资源已变更变小) |
| 其他非 2xx 状态码 | 返回 unexpected status code 错误,错误信息附带响应体前 200 字节预览 |
Overwrite 与 ResumeDownload 同时为 true 时
ResumeDownload 优先——已有文件被追加扩展而非替换重写。此外,续传模式下若中途写盘失败,已有字节会被保留(供下一次续传);非续传模式下失败则会删除写了一半的文件,避免留下损坏产物。
校验和验证(SHA-256)
设置 Checksum 后,HTTPC 在写盘的同时对流过的数据计算 SHA-256,下载完成时与期望值比对:
- 比对不区分大小写(期望值内部统一转小写);
- 不匹配 → 删除已下载文件并返回错误,不会留下被污染的产物;
- 成功时
result.ActualChecksum携带实际计算出的哈希,可供记录; - 算法合法性在打开目标文件之前校验——配置错误(如未知的
ChecksumAlgorithm)不会截断磁盘上的既有文件。
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"net/http"
"net/http/httptest"
"github.com/cybergodev/httpc"
)
func main() {
payload := []byte("hello httpc checksum")
// 本地模拟服务器返回固定内容;生产中期望值应来自发布清单等可信渠道
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(payload)
}))
defer server.Close()
sum := sha256.Sum256(payload)
expected := hex.EncodeToString(sum[:])
cfg := httpc.DefaultConfig()
cfg.Security.AllowPrivateIPs = true // 允许连接 127.0.0.1 本地服务器
client, err := httpc.New(cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
dlCfg := httpc.DefaultDownloadConfig()
dlCfg.FilePath = "checksum-demo.txt"
dlCfg.Overwrite = true
dlCfg.Checksum = expected // 期望 SHA-256(hex 编码)
dlCfg.ChecksumAlgorithm = httpc.ChecksumSHA256
result, err := client.Download(context.Background(), server.URL, dlCfg)
if err != nil {
log.Fatal(err) // 校验失败:文件已被删除,错误信息含期望值与实际值
}
fmt.Printf("校验通过:%s\n", result.ActualChecksum) // 输出:校验通过:2f2b7c...(payload 的 SHA-256)
}带上下文控制
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cfg := httpc.DefaultDownloadConfig()
cfg.FilePath = "/tmp/file.zip"
result, err := httpc.Download(ctx, url, cfg)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("下载超时")
}
log.Fatal(err)
}文件冲突与清理语义
下载对目标文件的处理规则一览(配合 ErrFileExists、ErrEmptyFilePath 两个哨兵错误使用 errors.Is 判断):
| 场景 | 行为 |
|---|---|
目标文件已存在,Overwrite/ResumeDownload 均为 false | 返回 ErrFileExists,不动文件 |
| 目标路径是目录 | 返回错误 |
Overwrite = true(非续传) | O_TRUNC 覆盖写 |
ResumeDownload = true 且服务器支持 Range | O_APPEND 追加写 |
| 写入/落盘(sync/close)失败 | 非续传:删除半成品文件;续传:保留已有字节 |
| 校验和不匹配 | 删除已下载文件 |
| 期望的算法不受支持(仅 SHA-256) | 打开文件之前即返回错误,不会截断既有文件 |
| 目标目录不存在 | 自动递归创建(权限 0755),文件权限 0644 |
| 路径为空 | 返回 ErrEmptyFilePath |
错误状态码(非 200/206)时,错误信息会附带响应体前 200 字节的预览便于排查;同时最多排空 1MiB 响应体以尽量让连接回归连接池复用。
Download 与 SaveToFile 如何选择
Result.SaveToFile(path) 把已经在内存里的响应体写到磁盘;Download 则从头到尾流式写盘:
| 方式 | 适用场景 | 说明 |
|---|---|---|
result.SaveToFile(path) | 小到中等响应体(已在内存) | 路径经过与 Download 相同的安全校验;空响应体返回 ErrResponseBodyEmpty |
client.Download(ctx, url, cfg) | 大文件 | 流式写盘、支持进度/续传/校验和,内存占用与文件大小无关 |
// 响应体已在内存:直接落盘
result, err := client.Get("https://example.com/small.json")
if err != nil {
log.Fatal(err)
}
if err := result.SaveToFile("/tmp/small.json"); err != nil {
log.Fatal(err)
}DownloadResult 字段一览
Download 返回的 DownloadResult 除常见的字节数与耗时外,还携带完整的请求/响应元数据:
| 字段 | 类型 | 说明 |
|---|---|---|
FilePath | string | 校验后的绝对保存路径 |
BytesWritten | int64 | 本次写入磁盘的字节数(续传时不含续传前已有偏移) |
Duration | time.Duration | 下载总耗时 |
AverageSpeed | float64 | 平均速度(字节/秒) |
StatusCode | int | 响应状态码(200 或 206) |
ContentLength | int64 | 服务器报告的 Content-Length(续传时为剩余字节数) |
Resumed | bool | 本次是否为断点续传 |
ResponseCookies | []*http.Cookie | 响应返回的 Cookie(DomainClient 会自动捕获进会话) |
ActualChecksum | string | 实际计算出的校验和(仅设置 Checksum 时非空) |
Proto | string | 协议版本(如 HTTP/2.0) |
ResponseHeaders | http.Header | 响应头 |
RequestURL / RequestMethod | string | 实际请求的 URL 与方法 |
RequestHeaders | http.Header | 实际发送的请求头 |
安全防护
文件下载内置多层安全保护,全部在打开目标文件之前完成:
| 保护层 | 说明 |
|---|---|
| 路径验证 | 阻止 UNC 路径(\\server\share、//server)、控制字符、路径遍历(Clean 后以 .. 开头且逃出工作目录即拒绝) |
| 长度限制 | 路径最长 4096 字符,超长直接拒绝 |
| 系统路径保护 | 禁止写入系统目录:Windows 覆盖 C:\Windows\、C:\Program Files\ 及 %SystemRoot% 等环境变量展开位置;Linux/macOS 覆盖 /etc/、/usr/、/bin/、/System/、/Library/ 等 |
| 符号链接检测 | 目标本身是符号链接、或任一父目录解析进系统目录(TOCTOU 防护,递归检查至多 32 层)均拒绝 |
| 文件大小限制 | 受 MaxResponseBodySize 限制 |
目录也会被校验
自动创建父目录(MkdirAll)发生在路径校验之后,因此 FilePath 里的每一级目录都不能借道系统路径或符号链接绕过防护。Result.SaveToFile 复用同一套校验。
域名客户端下载
域名客户端的下载会自动捕获响应 Cookie 到会话:
dc, err := httpc.NewDomainDefault("https://api.example.com")
if err != nil {
log.Fatal(err)
}
defer dc.Close()
dc.SetHeader("Authorization", "Bearer "+token)
cfg := httpc.DefaultDownloadConfig()
cfg.FilePath = "/tmp/report.pdf"
// 下载并自动管理会话(path 相对于 baseURL)
result, err := dc.Download(context.Background(), "/files/report.pdf", cfg)
if err != nil {
log.Fatal(err)
}域名客户端的两个注意事项
- 请求选项会被执行两次(一次用于捕获会话状态,一次用于实际请求)。避免传入带副作用的选项(计数器、随机 nonce 生成器等);确有需要时改用底层
Client直接下载。 - Download 与「包装响应对象的自定义中间件」不兼容:下载路径需要直接访问原始响应流,若自定义中间件把
ResponseMutator替换为包装类型,Download会返回明确错误。内置中间件(Recovery/Logging/Metrics 等)均为透传,不受影响。