Skip to content

06-模板渲染与文件上传

虽然现在前后端分离是主流,但服务端模板渲染在后台管理系统、邮件模板、快速原型开发等场景下依然非常实用。文件上传则是 Web 开发的必备能力。本篇将系统讲解 Gin 的 HTML 模板渲染、静态文件服务以及文件上传/下载。

HTML 模板渲染:LoadHTMLGlob、LoadHTMLFiles

Gin 使用 Go 标准库 html/template 进行模板渲染。Gin 引擎提供了两个方法加载模板:

方法说明
r.LoadHTMLGlob(pattern)按通配符加载,如 templates/*
r.LoadHTMLFiles(files...)显式指定多个文件路径

渲染使用 c.HTML(code, name, data)

项目结构

text
project/
├── main.go
└── templates/
    ├── index.html
    └── user.html

templates/index.html

html
<!DOCTYPE html>
<html>
<head><title>首页</title></head>
<body>
  <h1>欢迎,{{.name}}</h1>
  <p>当前时间:{{.now}}</p>
</body>
</html>

main.go

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
	"time"
)

func main() {
	r := gin.Default()

	// 加载 templates 目录下所有 .html
	r.LoadHTMLGlob("templates/*")

	r.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", gin.H{
			"name": "Gin 学习者",
			"now":  time.Now().Format("2006-01-02 15:04:05"),
		})
	})

	r.Run(":8080")
}

使用 LoadHTMLFiles

go
r.LoadHTMLFiles("templates/index.html", "templates/user.html")

多级目录模板

如果模板分布在子目录中,例如 templates/admin/index.html,可以使用 ** 通配:

go
r.LoadHTMLGlob("templates/**/*")

但要注意不同目录下的同名文件会冲突,建议给文件起不同的名字。

模板分隔符

默认分隔符是 \{\{ \}\},可通过 r.Delims("{[{", "}]}") 修改,常用于与前端框架(如 Vue)的 \{\{ \}\} 区分。

模板变量与条件渲染

Go 模板用 \{\{.FieldName\}\} 输出变量,. 代表当前上下文对象。

templates/user.html

html
<!DOCTYPE html>
<html>
<head><title>用户信息</title></head>
<body>
  <h1>用户信息</h1>
  <p>姓名:{{.Name}}</p>
  <p>年龄:{{.Age}}</p>
  <p>邮箱:{{.Email}}</p>

  {{if .IsVip}}
    <p style="color:gold">⭐ VIP 会员</p>
  {{else}}
    <p>普通用户</p>
  {{end}}

  {{if gt .Age 18}}
    <p>已成年</p>
  {{else}}
    <p>未成年</p>
  {{end}}
</body>
</html>

main.go

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

type User struct {
	Name  string
	Age   int
	Email string
	IsVip bool
}

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	r.GET("/user", func(c *gin.Context) {
		u := User{
			Name:  "Alice",
			Age:   20,
			Email: "alice@example.com",
			IsVip: true,
		}
		c.HTML(http.StatusOK, "user.html", u)
	})

	r.Run(":8080")
}

常用条件语法

语法含义
\{\{if .Field\}\}...\{\{end\}\}if 判断
\{\{if .Field\}\}...\{\{else\}\}...\{\{end\}\}if-else
\{\{if eq .A .B\}\}相等
\{\{if not .Field\}\}取反
\{\{if and .A .B\}\}
\{\{if or .A .B\}\}
\{\{if gt .A .B\}\}大于
\{\{if lt .A .B\}\}小于

模板循环

使用 \{\{range\}\} 遍历切片、数组或 map。

templates/list.html

html
<!DOCTYPE html>
<html>
<head><title>用户列表</title></head>
<body>
  <h1>用户列表(共 {{len .Users}} 人)</h1>
  <ul>
  {{range $i, $u := .Users}}
    <li>{{add $i 1}}. {{$u.Name}} - {{$u.Age}}岁 - {{$u.Email}}</li>
  {{else}}
    <li>暂无数据</li>
  {{end}}
  </ul>

  <h2>成绩表</h2>
  <table border="1">
    <tr><th>科目</th><th>分数</th></tr>
    {{range $subject, $score := .Scores}}
    <tr><td>{{$subject}}</td><td>{{$score}}</td></tr>
    {{end}}
  </table>
</body>
</html>

注意 \{\{add\}\}\{\{len\}\} 是模板内置函数,add 实际是自定义函数,下面会讲。

main.go

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

type User struct {
	Name  string
	Age   int
	Email string
}

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	r.GET("/users", func(c *gin.Context) {
		users := []User{
			{Name: "Alice", Age: 20, Email: "alice@x.com"},
			{Name: "Bob", Age: 25, Email: "bob@x.com"},
			{Name: "Carol", Age: 30, Email: "carol@x.com"},
		}
		scores := map[string]int{
			"Math":    95,
			"English": 88,
			"Go":      100,
		}
		c.HTML(http.StatusOK, "list.html", gin.H{
			"Users":  users,
			"Scores": scores,
		})
	})

	r.Run(":8080")
}

模板函数:自定义模板函数

模板内置函数有限,可以通过 template.FuncMap 注册自定义函数,并在 LoadHTMLGlob 之前调用 r.SetFuncMap

go
package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"html/template"
	"net/http"
	"strings"
	"time"
)

func main() {
	r := gin.Default()

	// 自定义模板函数
	r.SetFuncMap(template.FuncMap{
		// 加法
		"add": func(a, b int) int { return a + b },
		// 字符串转大写
		"upper": strings.ToUpper,
		// 格式化时间
		"formatTime": func(t time.Time) string {
			return t.Format("2006-01-02 15:04:05")
		},
		// 金额分转元
		"fenToYuan": func(fen int) string {
			return fmt.Sprintf("%.2f", float64(fen)/100)
		},
	})

	r.LoadHTMLGlob("templates/*")

	r.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", gin.H{
			"name":    "alice",
			"now":     time.Now(),
			"balance": 12345,
		})
	})

	r.Run(":8080")
}

templates/index.html

html
<!DOCTYPE html>
<html><body>
  <p>姓名:{{upper .name}}</p>
  <p>时间:{{formatTime .now}}</p>
  <p>余额:¥{{fenToYuan .balance}}</p>
</body></html>

提示:SetFuncMap 必须在 LoadHTMLGlob 之前调用,否则不会生效。

静态文件服务:Static、StaticFS、StaticFile

Web 应用通常需要服务静态资源(CSS、JS、图片)。Gin 提供三种方法:

方法说明
r.Static(relativePath, root)把某个 URL 前缀映射到目录
r.StaticFS(relativePath, http.FileSystem)http.FileSystem 提供更细控制(如目录列表)
r.StaticFile(relativePath, filepath)把单个 URL 映射到单个文件

项目结构

text
project/
├── main.go
├── templates/
│   └── index.html
└── assets/
    ├── css/
    │   └── style.css
    ├── js/
    │   └── app.js
    └── favicon.ico

main.go

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	// 1. Static:把 /assets 映射到 ./assets 目录
	r.Static("/assets", "./assets")

	// 2. StaticFile:单文件映射
	r.StaticFile("/favicon.ico", "./assets/favicon.ico")

	// 3. StaticFS:可以打开目录浏览(开发环境方便,生产环境慎用)
	// r.StaticFS("/browse", http.Dir("./assets"))

	r.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", gin.H{})
	})

	r.Run(":8080")
}

templates/index.html

html
<!DOCTYPE html>
<html>
<head>
  <link rel="icon" href="/favicon.ico">
  <link rel="stylesheet" href="/assets/css/style.css">
  <script src="/assets/js/app.js"></script>
</head>
<body>
  <h1>静态资源示例</h1>
  <button onclick="sayHi()">点我</button>
</body>
</html>

访问 http://localhost:8080/assets/css/style.css 就能直接拿到 CSS 文件。

文件上传基础:单文件上传 c.FormFile

文件上传走 multipart/form-data 协议。Gin 用 c.FormFile("fieldname") 拿到上传的文件对象 *multipart.FileHeader

go
package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"net/http"
)

func main() {
	r := gin.Default()
	// 默认内存上限 32MB,超过会写入磁盘临时文件
	r.MaxMultipartMemory = 8 << 20 // 8 MB

	r.LoadHTMLGlob("templates/*")

	r.GET("/upload", func(c *gin.Context) {
		c.HTML(http.StatusOK, "upload.html", nil)
	})

	r.POST("/upload", func(c *gin.Context) {
		file, err := c.FormFile("file")
		if err != nil {
			c.String(http.StatusBadRequest, "获取文件失败: %v", err)
			return
		}

		fmt.Printf("文件名: %s\n", file.Filename)
		fmt.Printf("大小: %d bytes\n", file.Size)
		fmt.Printf("MIME: %s\n", file.Header.Get("Content-Type"))

		c.String(http.StatusOK, "上传成功: %s (%d bytes)",
			file.Filename, file.Size)
	})

	r.Run(":8080")
}

templates/upload.html

html
<!DOCTYPE html>
<html>
<head><title>上传</title></head>
<body>
  <h1>上传单个文件</h1>
  <form action="/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="file" />
    <button type="submit">上传</button>
  </form>
</body>
</html>

文件保存:c.SaveUploadedFile

拿到 *multipart.FileHeader 后,调用 c.SaveUploadedFile(file, dstPath) 就能保存到本地。

go
package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"net/http"
	"os"
	"path/filepath"
	"time"
)

func main() {
	r := gin.Default()
	r.MaxMultipartMemory = 8 << 20

	// 上传目录
	os.MkdirAll("uploads", 0755)

	r.POST("/upload", func(c *gin.Context) {
		file, err := c.FormFile("file")
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		// 用时间戳重命名,避免覆盖
		ext := filepath.Ext(file.Filename)
		newName := fmt.Sprintf("%d%s", time.Now().UnixMilli(), ext)
		dst := filepath.Join("uploads", newName)

		if err := c.SaveUploadedFile(file, dst); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}

		c.JSON(http.StatusOK, gin.H{
			"original": file.Filename,
			"saved":    newName,
			"size":     file.Size,
			"path":     dst,
		})
	})

	r.Run(":8080")
}

多文件上传:MultipartForm

多文件上传有两种方式:

方式一:多次调用 c.FormFile

表单中用同一个字段名提交多个文件:

html
<input type="file" name="files" multiple />
go
r.POST("/upload-multi", func(c *gin.Context) {
	form, err := c.MultipartForm()
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	files := form.File["files"]

	results := make([]gin.H, 0, len(files))
	for _, file := range files {
		dst := filepath.Join("uploads", file.Filename)
		if err := c.SaveUploadedFile(file, dst); err != nil {
			results = append(results, gin.H{
				"name":  file.Filename,
				"error": err.Error(),
			})
			continue
		}
		results = append(results, gin.H{
			"name": file.Filename,
			"size": file.Size,
		})
	}
	c.JSON(http.StatusOK, gin.H{"files": results})
})

方式二:不同字段名分别获取

go
r.POST("/upload-fields", func(c *gin.Context) {
	avatar, _ := c.FormFile("avatar")
	idCard, _ := c.FormFile("id_card")
	// 分别处理
})

文件上传安全:限制大小、检查类型

文件上传是 Web 应用的高危区域,必须做以下防护:

  1. 限制大小:通过 r.MaxMultipartMemory 和 Nginx 层限制
  2. 检查扩展名:白名单方式
  3. 检查 MIME 类型:不能只看扩展名
  4. 重命名:不要使用用户原始文件名
  5. 存储隔离:上传目录不执行脚本(配置 Nginx 不解析 PHP/Go)
  6. 病毒扫描:生产环境建议接入 ClamAV 等
go
package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"net/http"
	"path/filepath"
	"strings"
	"time"
)

// 允许的扩展名
var allowedExts = map[string]bool{
	".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
}

// 允许的 MIME 类型
var allowedMIMEs = map[string]bool{
	"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true,
}

// 最大文件大小:5MB
const maxFileSize = 5 << 20

func main() {
	r := gin.Default()
	r.MaxMultipartMemory = maxFileSize

	r.POST("/upload-safe", func(c *gin.Context) {
		file, err := c.FormFile("file")
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "文件获取失败"})
			return
		}

		// 1. 大小检查
		if file.Size > maxFileSize {
			c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 5MB"})
			return
		}

		// 2. 扩展名白名单
		ext := strings.ToLower(filepath.Ext(file.Filename))
		if !allowedExts[ext] {
			c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的扩展名: " + ext})
			return
		}

		// 3. MIME 检查
		mime := file.Header.Get("Content-Type")
		if !allowedMIMEs[mime] {
			c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的 MIME: " + mime})
			return
		}

		// 4. 重命名(避免路径遍历和覆盖)
		newName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
		dst := filepath.Join("uploads", newName)

		if err := c.SaveUploadedFile(file, dst); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
			return
		}

		c.JSON(http.StatusOK, gin.H{
			"original": file.Filename,
			"saved":    newName,
			"size":     file.Size,
			"mime":     mime,
		})
	})

	r.Run(":8080")
}

进一步可以打开文件读取前几个字节,根据"魔数"判断真实类型,例如 JPEG 以 FF D8 FF 开头,PNG 以 89 50 4E 47 开头。这能挡住伪造扩展名的攻击。

文件下载:c.File、c.FileAttachment、c.Data

方法说明
c.File(path)直接返回文件,浏览器内联显示(如图片)
c.FileAttachment(path, name)返回文件,并指定下载文件名(触发下载)
c.Data(code, contentType, data)返回字节数据
go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
	"path/filepath"
)

func main() {
	r := gin.Default()

	// 1. c.File:内联显示
	r.GET("/view/:name", func(c *gin.Context) {
		name := c.Param("name")
		// 务必校验,避免路径遍历攻击
		if filepath.Base(name) != name {
			c.String(http.StatusBadRequest, "invalid filename")
			return
		}
		c.File(filepath.Join("uploads", name))
	})

	// 2. c.FileAttachment:触发下载
	r.GET("/download/:name", func(c *gin.Context) {
		name := c.Param("name")
		if filepath.Base(name) != name {
			c.String(http.StatusBadRequest, "invalid filename")
			return
		}
		// 第二个参数是下载时显示的文件名
		c.FileAttachment(filepath.Join("uploads", name), "downloaded-"+name)
	})

	// 3. c.Data:返回字节数据(适合动态生成 CSV、PDF 等)
	r.GET("/report.csv", func(c *gin.Context) {
		csv := "name,age\nAlice,20\nBob,25\n"
		c.Data(http.StatusOK, "text/csv; charset=utf-8", []byte(csv))
	})

	r.Run(":8080")
}

大文件下载

对于大文件(如视频),建议用 http.ServeContent 支持断点续传(Range 请求):

go
r.GET("/video/:name", func(c *gin.Context) {
	name := c.Param("name")
	http.ServeFile(c.Writer, c.Request, filepath.Join("uploads", name))
})

完整的文件管理系统示例

把上面的知识点串起来,做一个迷你文件管理系统:

  • 上传文件(带类型/大小检查)
  • 列出已上传文件
  • 下载文件
  • 删除文件
go
package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"
)

var allowedExts = map[string]bool{
	".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".pdf": true,
	".txt": true, ".zip": true,
}

const maxFileSize = 10 << 20 // 10MB
const uploadDir = "./uploads"

func main() {
	r := gin.Default()
	r.MaxMultipartMemory = maxFileSize
	os.MkdirAll(uploadDir, 0755)

	r.LoadHTMLGlob("templates/*")

	// 首页:上传表单 + 文件列表
	r.GET("/", func(c *gin.Context) {
		entries, _ := os.ReadDir(uploadDir)
		files := make([]gin.H, 0, len(entries))
		for _, e := range entries {
			if e.IsDir() {
				continue
			}
			info, _ := e.Info()
			files = append(files, gin.H{
				"name": e.Name(),
				"size": info.Size(),
				"time": info.ModTime().Format("2006-01-02 15:04:05"),
			})
		}
		c.HTML(http.StatusOK, "files.html", gin.H{"files": files})
	})

	// 上传
	r.POST("/upload", func(c *gin.Context) {
		file, err := c.FormFile("file")
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		if file.Size > maxFileSize {
			c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 10MB"})
			return
		}
		ext := strings.ToLower(filepath.Ext(file.Filename))
		if !allowedExts[ext] {
			c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的扩展名"})
			return
		}
		newName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
		dst := filepath.Join(uploadDir, newName)
		if err := c.SaveUploadedFile(file, dst); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.Redirect(http.StatusFound, "/")
	})

	// 下载
	r.GET("/download/:name", func(c *gin.Context) {
		name := c.Param("name")
		if filepath.Base(name) != name {
			c.String(http.StatusBadRequest, "invalid filename")
			return
		}
		c.FileAttachment(filepath.Join(uploadDir, name), name)
	})

	// 删除
	r.POST("/delete/:name", func(c *gin.Context) {
		name := c.Param("name")
		if filepath.Base(name) != name {
			c.String(http.StatusBadRequest, "invalid filename")
			return
		}
		os.Remove(filepath.Join(uploadDir, name))
		c.Redirect(http.StatusFound, "/")
	})

	r.Run(":8080")
}

templates/files.html

html
<!DOCTYPE html>
<html>
<head>
  <title>文件管理</title>
  <style>
    body { font-family: sans-serif; max-width: 800px; margin: 40px auto; }
    table { width: 100%; border-collapse: collapse; margin-top: 20px; }
    th, td { padding: 8px; border: 1px solid #ddd; text-align: left; }
    .actions form { display: inline; }
  </style>
</head>
<body>
  <h1>迷你文件管理</h1>

  <form action="/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="file" required />
    <button type="submit">上传</button>
    <small>支持 jpg/png/gif/pdf/txt/zip,最大 10MB</small>
  </form>

  <table>
    <tr><th>文件名</th><th>大小</th><th>上传时间</th><th>操作</th></tr>
    {{range .files}}
    <tr>
      <td>{{.name}}</td>
      <td>{{.size}} B</td>
      <td>{{.time}}</td>
      <td class="actions">
        <a href="/download/{{.name}}">下载</a>
        <form action="/delete/{{.name}}" method="post"
              onsubmit="return confirm('确定删除?')">
          <button type="submit">删除</button>
        </form>
      </td>
    </tr>
    {{else}}
    <tr><td colspan="4">暂无文件</td></tr>
    {{end}}
  </table>
</body>
</html>

小结

本篇覆盖了 Gin 在模板渲染和文件处理方面的核心能力:

  1. 模板渲染LoadHTMLGlob / LoadHTMLFiles 加载模板,c.HTML 渲染,支持变量、条件、循环。
  2. 自定义模板函数:通过 SetFuncMap 注册,扩展模板能力。
  3. 静态文件Static / StaticFS / StaticFile 三种方式。
  4. 文件上传c.FormFile 单文件、c.MultipartForm 多文件、c.SaveUploadedFile 保存。
  5. 上传安全:大小限制、扩展名白名单、MIME 校验、重命名、路径校验。
  6. 文件下载c.File 内联、c.FileAttachment 下载、c.Data 字节流。
  7. 综合实战:一个完整的文件管理系统。

下一篇我们将进入数据库的世界,学习 GORM 与 Gin 的结合。