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。
登录后参与评论。