Appearance
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.htmltemplates/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.icomain.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 应用的高危区域,必须做以下防护:
- 限制大小:通过
r.MaxMultipartMemory和 Nginx 层限制 - 检查扩展名:白名单方式
- 检查 MIME 类型:不能只看扩展名
- 重命名:不要使用用户原始文件名
- 存储隔离:上传目录不执行脚本(配置 Nginx 不解析 PHP/Go)
- 病毒扫描:生产环境建议接入 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 在模板渲染和文件处理方面的核心能力:
- 模板渲染:
LoadHTMLGlob/LoadHTMLFiles加载模板,c.HTML渲染,支持变量、条件、循环。 - 自定义模板函数:通过
SetFuncMap注册,扩展模板能力。 - 静态文件:
Static/StaticFS/StaticFile三种方式。 - 文件上传:
c.FormFile单文件、c.MultipartForm多文件、c.SaveUploadedFile保存。 - 上传安全:大小限制、扩展名白名单、MIME 校验、重命名、路径校验。
- 文件下载:
c.File内联、c.FileAttachment下载、c.Data字节流。 - 综合实战:一个完整的文件管理系统。
下一篇我们将进入数据库的世界,学习 GORM 与 Gin 的结合。