Go 标准库写一个带 ETag / Range / gzip 的 HTTP 文件服务

Go 标准库的 net/http + http.FileServer 五行就能起一个静态文件服务,
但缺生产里几个关键能力:ETag、Range(断点续传 / 视频拖动)、压缩、
正确的 Cache-Control。下面写一个完整版。

五行起步

package main

import "net/http"

func main() {
    http.Handle("/", http.FileServer(http.Dir("./public")))
    http.ListenAndServe(":8080", nil)
}

这就行了——http.FileServer 已经支持 Range、Last-Modified、
If-Modified-Since、自动 Content-Type 推断。

但 ETag / 压缩 / 自定义 Cache-Control 要自己加。

完整版

package main

import (
    "compress/gzip"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "log"
    "mime"
    "net/http"
    "os"
    "path/filepath"
    "strings"
    "time"
)

const root = "./public"

func main() {
    h := http.HandlerFunc(serve)
    log.Fatal(http.ListenAndServe(":8080", h))
}

func serve(w http.ResponseWriter, r *http.Request) {
    // 1. 安全:阻止 ../ 跳出 root
    clean := filepath.Clean(r.URL.Path)
    if strings.HasPrefix(clean, "..") {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }
    full := filepath.Join(root, clean)

    // 2. 默认页:目录请求映射到 index.html
    info, err := os.Stat(full)
    if err != nil {
        http.NotFound(w, r)
        return
    }
    if info.IsDir() {
        full = filepath.Join(full, "index.html")
        info, err = os.Stat(full)
        if err != nil {
            http.NotFound(w, r)
            return
        }
    }

    // 3. ETag(用 mtime + size 计算,足够稳)
    etag := computeETag(info)
    if match := r.Header.Get("If-None-Match"); match == etag {
        w.WriteHeader(http.StatusNotModified)
        return
    }
    w.Header().Set("ETag", etag)

    // 4. Cache-Control
    if isAsset(full) {
        // 带 hash 的资源:长期缓存
        w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
    } else {
        w.Header().Set("Cache-Control", "public, max-age=300")
    }

    // 5. Content-Type
    ct := mime.TypeByExtension(filepath.Ext(full))
    if ct != "" {
        w.Header().Set("Content-Type", ct)
    }

    // 6. 真正发文件 —— ServeFile 已包含 Range 支持
    if acceptsGzip(r) && isCompressible(full) {
        w.Header().Set("Content-Encoding", "gzip")
        w.Header().Add("Vary", "Accept-Encoding")
        // 注意:开 gzip 后 Range 不再好用(gzip stream 不能任意定位)
        // 静态文件如果要支持 Range(音视频),别 gzip 它
        gz := gzip.NewWriter(w)
        defer gz.Close()
        f, _ := os.Open(full)
        defer f.Close()
        io.Copy(gz, f)
        return
    }

    http.ServeFile(w, r, full)
}

func computeETag(info os.FileInfo) string {
    h := sha256.New()
    h.Write([]byte(info.Name()))
    h.Write([]byte(info.ModTime().UTC().Format(time.RFC3339Nano)))
    h.Write([]byte(string(rune(info.Size()))))
    return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
}

func acceptsGzip(r *http.Request) bool {
    return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
}

func isCompressible(p string) bool {
    switch strings.ToLower(filepath.Ext(p)) {
    case ".html", ".css", ".js", ".json", ".svg", ".txt", ".xml":
        return true
    }
    return false
}

func isAsset(p string) bool {
    // 文件名含 8+ 位 hex 视为带 hash 的资源
    base := filepath.Base(p)
    for _, part := range strings.Split(base, ".") {
        if len(part) >= 8 && isHex(part) {
            return true
        }
    }
    return false
}

func isHex(s string) bool {
    for _, c := range s {
        if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
            return false
        }
    }
    return true
}

Range / 视频拖动

http.ServeFile 已经处理 Range 请求(206 Partial Content)。
不需要自己写。

校验:

curl -I -H 'Range: bytes=0-100' http://localhost:8080/video.mp4
# HTTP/1.1 206 Partial Content
# Content-Range: bytes 0-100/1234567
# Content-Length: 101

如果你不用 ServeFile 而自己 io.Copy,Range 就失效了。

ETag 的边界

我用 mtime + size 算 ETag —— 简单但有边界:

  • 同内容不同文件(不同 mtime)会返回不同 ETag
  • 改完没改大小且 mtime 精度不够(FAT32 只到 2 秒)会被认为没变

更稳的方案:第一次访问时算文件 SHA256,写到 sidecar 文件 foo.css.etag
作为缓存。代价是写文件 / 维护成本。

测试

# ETag
ETAG=$(curl -sI http://localhost:8080/main.css | grep -i etag | cut -d' ' -f2 | tr -d '\r\n')
curl -I -H "If-None-Match: $ETAG" http://localhost:8080/main.css
# 应该返回 304

# gzip
curl -I -H 'Accept-Encoding: gzip' http://localhost:8080/main.css
# Content-Encoding: gzip

为什么不用 nginx

nginx 这事做得更好(C 写的 + sendfile + zero-copy)。这个 Go 实现的
价值是:

  • 单二进制可执行,跨平台
  • 嵌入到现有 Go 后端里
  • 配合 embed.FS 直接把静态资源打包到二进制(go embed)
//go:embed public
var staticFS embed.FS

http.Handle("/", http.FileServer(http.FS(staticFS)))

整个网站打包成一个 myapp 二进制丢服务器跑。CD 流程极简。

踩过的坑

  • 没做 filepath.Clean + 阻止 .. → 经典目录穿越漏洞,攻击者请求
    /../../../etc/passwd 把文件读走。
  • http.ServeFile 在 path 含编码的 %2E%2E 时可能仍接受(早期 Go 版本
    有 CVE)。升级到 Go 1.22+ 已修。
  • 写自己的 io.Copy 而不用 ServeFile:失去 Range、失去 Last-Modified
    比较、效率也差。能用 ServeFile 就用。
  • 跨平台 mtime 精度差异:Windows 是 100ns、macOS 是 1ns、Linux 通常 ns。
    Build 时给 docker COPY 文件 mtime 都被改为 build 时间,结果所有文件
    ETag 一样。CI 里用 touch -d 还原 mtime 或换 SHA-based ETag。
精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。