静态文件服务 #

一、静态文件概述 #

静态文件包括CSS、JavaScript、图片、字体等资源文件。

1.1 静态文件目录结构 #

text
project/
├── static/
│   ├── css/
│   │   ├── style.css
│   │   └── bootstrap.min.css
│   ├── js/
│   │   ├── app.js
│   │   └── jquery.min.js
│   ├── images/
│   │   ├── logo.png
│   │   └── banner.jpg
│   └── fonts/
│       └── iconfont.ttf
├── uploads/
│   └── files/
└── views/

二、基本配置 #

2.1 Static方法 #

go
e := echo.New()

e.Static("/static", "static")

访问:

text
/static/css/style.css  →  static/css/style.css
/static/js/app.js      →  static/js/app.js
/static/images/logo.png →  static/images/logo.png

2.2 多个静态目录 #

go
e := echo.New()

e.Static("/static", "static")
e.Static("/uploads", "uploads")
e.Static("/assets", "public/assets")

2.3 静态文件中间件 #

go
e := echo.New()

e.Use(middleware.Static("static"))

2.4 自定义配置 #

go
e := echo.New()

e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
    Root:   "static",
    Browse: true,
}))

三、静态文件配置选项 #

3.1 配置选项 #

选项 说明
Root 静态文件根目录
Skipper 跳过函数
Index 默认索引文件
Browse 是否启用目录浏览
HTML5 HTML5模式
IgnoreBase 忽略基础路径

3.2 目录浏览 #

go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
    Root:   "static",
    Browse: true,
}))

访问 /static/ 会显示目录列表。

3.3 默认索引文件 #

go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
    Root:  "static",
    Index: "index.html",
}))

3.4 HTML5模式 #

go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
    Root:  "static",
    HTML5: true,
}))

所有未匹配的路由返回index.html,适用于SPA应用。

3.5 跳过特定路径 #

go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
    Root: "static",
    Skipper: func(c echo.Context) bool {
        return strings.HasPrefix(c.Path(), "/api")
    },
}))

四、文件服务 #

4.1 单个文件 #

go
e.GET("/robots.txt", func(c echo.Context) error {
    return c.File("static/robots.txt")
})

4.2 文件下载 #

go
e.GET("/download/:filename", func(c echo.Context) error {
    filename := c.Param("filename")
    return c.Attachment("files/"+filename, filename)
})

4.3 内联显示 #

go
e.GET("/view/:filename", func(c echo.Context) error {
    filename := c.Param("filename")
    return c.Inline("files/"+filename, filename)
})

4.4 动态生成文件 #

go
e.GET("/report.csv", func(c echo.Context) error {
    var buf bytes.Buffer
    
    writer := csv.NewWriter(&buf)
    writer.Write([]string{"ID", "Name", "Email"})
    writer.Write([]string{"1", "张三", "zhangsan@example.com"})
    writer.Flush()
    
    c.Response().Header().Set("Content-Disposition", "attachment; filename=report.csv")
    return c.Blob(http.StatusOK, "text/csv", buf.Bytes())
})

五、缓存控制 #

5.1 缓存中间件 #

go
func cacheMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        c.Response().Header().Set("Cache-Control", "public, max-age=31536000")
        return next(c)
    }
}

e.Use(cacheMiddleware)

5.2 静态文件缓存 #

go
staticGroup := e.Group("/static")
staticGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        c.Response().Header().Set("Cache-Control", "public, max-age=31536000")
        c.Response().Header().Set("Expires", time.Now().AddDate(1, 0, 0).Format(http.TimeFormat))
        return next(c)
    }
})
staticGroup.Static("/", "static")

5.3 ETag支持 #

go
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        file := "static" + c.Request().URL.Path
        
        info, err := os.Stat(file)
        if err != nil {
            return next(c)
        }
        
        etag := fmt.Sprintf(`"%x-%x"`, info.ModTime().Unix(), info.Size())
        
        c.Response().Header().Set("ETag", etag)
        
        if c.Request().Header.Get("If-None-Match") == etag {
            return c.NoContent(http.StatusNotModified)
        }
        
        return next(c)
    }
})

六、文件上传目录 #

6.1 上传目录配置 #

go
e.Static("/uploads", "uploads")

6.2 安全限制 #

go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
    Root: "uploads",
    Skipper: func(c echo.Context) bool {
        path := c.Request().URL.Path
        forbidden := []string{".php", ".asp", ".jsp", ".exe"}
        for _, ext := range forbidden {
            if strings.HasSuffix(path, ext) {
                return true
            }
        }
        return false
    },
}))

七、CDN配置 #

7.1 CDN URL #

go
func cdnURL(path string) string {
    cdnBase := os.Getenv("CDN_BASE_URL")
    if cdnBase == "" {
        cdnBase = "/static"
    }
    return cdnBase + path
}

func main() {
    e := echo.New()
    
    e.Renderer = &Template{
        templates: template.Must(template.New("").Funcs(template.FuncMap{
            "cdn": cdnURL,
        }).ParseGlob("views/**/*.html")),
    }
    
    e.Start(":8080")
}

7.2 模板中使用 #

html
<link rel="stylesheet" href="{{cdn "/css/style.css"}}">
<script src="{{cdn "/js/app.js"}}"></script>
<img src="{{cdn "/images/logo.png"}}" alt="Logo">

八、完整示例 #

go
package main

import (
    "bytes"
    "encoding/csv"
    "fmt"
    "html/template"
    "io"
    "net/http"
    "os"
    "strings"
    "time"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

type Template struct {
    templates *template.Template
}

func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    return t.templates.ExecuteTemplate(w, name, data)
}

func main() {
    e := echo.New()
    
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    
    e.Static("/static", "static")
    e.Static("/uploads", "uploads")
    
    e.Use(staticCacheMiddleware)
    
    funcMap := template.FuncMap{
        "cdn": func(path string) string {
            return "/static" + path
        },
        "now": time.Now,
    }
    
    t := &Template{
        templates: template.Must(template.New("").Funcs(funcMap).ParseGlob("views/**/*.html")),
    }
    e.Renderer = t
    
    e.GET("/", home)
    e.GET("/download/:filename", downloadFile)
    e.GET("/report.csv", downloadReport)
    e.POST("/upload", uploadFile)
    
    e.Logger.Fatal(e.Start(":8080"))
}

func staticCacheMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        if strings.HasPrefix(c.Path(), "/static") || strings.HasPrefix(c.Path(), "/uploads") {
            c.Response().Header().Set("Cache-Control", "public, max-age=31536000")
            c.Response().Header().Set("Expires", time.Now().AddDate(1, 0, 0).Format(http.TimeFormat))
        }
        return next(c)
    }
}

func home(c echo.Context) error {
    return c.Render(http.StatusOK, "pages/home.html", map[string]interface{}{
        "Title": "首页",
    })
}

func downloadFile(c echo.Context) error {
    filename := c.Param("filename")
    
    ext := strings.ToLower(filename[strings.LastIndex(filename, "."):])
    forbidden := []string{".php", ".asp", ".jsp", ".exe", ".sh"}
    for _, f := range forbidden {
        if ext == f {
            return echo.NewHTTPError(http.StatusForbidden, "不允许下载此类型文件")
        }
    }
    
    filepath := "uploads/" + filename
    
    if _, err := os.Stat(filepath); os.IsNotExist(err) {
        return echo.NewHTTPError(http.StatusNotFound, "文件不存在")
    }
    
    return c.Attachment(filepath, filename)
}

func downloadReport(c echo.Context) error {
    var buf bytes.Buffer
    
    writer := csv.NewWriter(&buf)
    writer.Write([]string{"ID", "Name", "Email", "Created"})
    writer.Write([]string{"1", "张三", "zhangsan@example.com", "2024-01-01"})
    writer.Write([]string{"2", "李四", "lisi@example.com", "2024-01-02"})
    writer.Flush()
    
    c.Response().Header().Set("Content-Disposition", "attachment; filename=report.csv")
    c.Response().Header().Set("Content-Type", "text/csv; charset=utf-8")
    
    return c.Blob(http.StatusOK, "text/csv", buf.Bytes())
}

func uploadFile(c echo.Context) error {
    file, err := c.FormFile("file")
    if err != nil {
        return err
    }
    
    src, err := file.Open()
    if err != nil {
        return err
    }
    defer src.Close()
    
    os.MkdirAll("uploads", 0755)
    
    dst, err := os.Create("uploads/" + file.Filename)
    if err != nil {
        return err
    }
    defer dst.Close()
    
    if _, err = io.Copy(dst, src); err != nil {
        return err
    }
    
    return c.JSON(http.StatusOK, map[string]string{
        "filename": file.Filename,
        "url":      "/uploads/" + file.Filename,
        "message":  "上传成功",
    })
}

views/pages/home.html

html
<!DOCTYPE html>
<html>
<head>
    <title>{{.Title}}</title>
    <link rel="stylesheet" href="{{cdn "/css/style.css"}}">
</head>
<body>
    <h1>Welcome!</h1>
    
    <img src="{{cdn "/images/logo.png"}}" alt="Logo">
    
    <form action="/upload" method="POST" enctype="multipart/form-data">
        <input type="file" name="file">
        <button type="submit">Upload</button>
    </form>
    
    <a href="/download/report.pdf">Download Report</a>
    <a href="/report.csv">Download CSV</a>
    
    <script src="{{cdn "/js/app.js"}}"></script>
</body>
</html>

九、总结 #

静态文件服务要点:

方法 说明
Static() 静态目录
File() 单个文件
Attachment() 文件下载
Inline() 内联显示
Blob() 二进制响应

配置选项:

选项 说明
Root 根目录
Browse 目录浏览
Index 索引文件
HTML5 SPA模式

准备好学习数据库集成了吗?让我们进入下一章!

最后更新:2026-03-28