3.3.4 Gin 渲染与模板

3.3.4 Gin 渲染与模板 #

Gin 框架提供了多种渲染方式和模板引擎支持,能够满足不同类型应用的需求。本节将深入探讨 Gin 的渲染机制、HTML 模板引擎、静态文件服务以及响应格式化技术。

多种渲染方式 #

JSON 渲染 #

JSON 是现代 Web API 最常用的数据交换格式:

package main

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

// 数据结构定义
type User struct {
    ID        uint      `json:"id"`
    Username  string    `json:"username"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
    Profile   *Profile  `json:"profile,omitempty"`
}

type Profile struct {
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
    Avatar    string `json:"avatar"`
    Bio       string `json:"bio"`
}

func setupJSONRendering() *gin.Engine {
    r := gin.Default()

    // 1. 基础 JSON 响应
    r.GET("/users/:id", func(c *gin.Context) {
        user := User{
            ID:        1,
            Username:  "john_doe",
            Email:     "[email protected]",
            CreatedAt: time.Now(),
        }

        c.JSON(http.StatusOK, user)
    })

    // 2. 使用 gin.H 快速构建 JSON
    r.GET("/status", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "status":    "ok",
            "timestamp": time.Now().Unix(),
            "version":   "1.0.0",
        })
    })

    // 3. 条件性 JSON 字段
    r.GET("/users/:id/profile", func(c *gin.Context) {
        includePrivate := c.Query("include_private") == "true"

        user := User{
            ID:       1,
            Username: "john_doe",
            Email:    "[email protected]",
        }

        if includePrivate {
            user.Profile = &Profile{
                FirstName: "John",
                LastName:  "Doe",
                Avatar:    "/avatars/john.jpg",
                Bio:       "Software Developer",
            }
        }

        c.JSON(200, user)
    })

    // 4. 自定义 JSON 编码
    r.GET("/custom-json", func(c *gin.Context) {
        data := map[string]interface{}{
            "message": "Hello, 世界",
            "unicode": "支持中文",
            "html":    "<script>alert('xss')</script>",
        }

        // 禁用 HTML 转义
        c.PureJSON(200, data)
    })

    // 5. 安全的 JSON 响应(防止 JSON 劫持)
    r.GET("/secure-json", func(c *gin.Context) {
        data := []string{"item1", "item2", "item3"}

        // 添加 ")]}',\n" 前缀防止 JSON 劫持
        c.SecureJSON(200, data)
    })

    // 6. JSONP 支持
    r.GET("/jsonp", func(c *gin.Context) {
        data := gin.H{
            "message": "JSONP response",
            "data":    []int{1, 2, 3, 4, 5},
        }

        // 支持 JSONP 回调
        c.JSONP(200, data)
    })

    return r
}

XML 和其他格式渲染 #

import (
    "encoding/xml"
)

// XML 数据结构
type XMLUser struct {
    XMLName   xml.Name `xml:"user"`
    ID        uint     `xml:"id,attr"`
    Username  string   `xml:"username"`
    Email     string   `xml:"email"`
    CreatedAt string   `xml:"created_at"`
}

func setupMultiFormatRendering() *gin.Engine {
    r := gin.Default()

    // 1. XML 渲染
    r.GET("/users/:id.xml", func(c *gin.Context) {
        user := XMLUser{
            ID:        1,
            Username:  "john_doe",
            Email:     "[email protected]",
            CreatedAt: time.Now().Format(time.RFC3339),
        }

        c.XML(200, user)
    })

    // 2. YAML 渲染
    r.GET("/config.yaml", func(c *gin.Context) {
        config := gin.H{
            "database": gin.H{
                "host":     "localhost",
                "port":     5432,
                "username": "admin",
                "password": "secret",
            },
            "redis": gin.H{
                "host": "localhost",
                "port": 6379,
            },
        }

        c.YAML(200, config)
    })

    // 3. Protocol Buffers 渲染
    r.GET("/users/:id.proto", func(c *gin.Context) {
        // 假设有 protobuf 定义的结构
        data := []int{1, 2, 3, 4, 5}
        c.ProtoBuf(200, &data)
    })

    // 4. 纯文本渲染
    r.GET("/plain", func(c *gin.Context) {
        c.String(200, "Hello, %s! Current time: %s", "World", time.Now().Format(time.RFC3339))
    })

    // 5. 重定向
    r.GET("/redirect", func(c *gin.Context) {
        c.Redirect(http.StatusMovedPermanently, "https://example.com")
    })

    // 6. 文件下载
    r.GET("/download/:filename", func(c *gin.Context) {
        filename := c.Param("filename")
        c.FileAttachment("./uploads/"+filename, filename)
    })

    // 7. 数据流响应
    r.GET("/stream", func(c *gin.Context) {
        c.Stream(func(w io.Writer) bool {
            for i := 0; i < 10; i++ {
                fmt.Fprintf(w, "data: Message %d\n\n", i)
                time.Sleep(time.Second)
            }
            return false
        })
    })

    return r
}

内容协商 #

根据客户端请求的 Accept 头自动选择响应格式:

func setupContentNegotiation() *gin.Engine {
    r := gin.Default()

    r.GET("/users/:id", func(c *gin.Context) {
        user := User{
            ID:        1,
            Username:  "john_doe",
            Email:     "[email protected]",
            CreatedAt: time.Now(),
        }

        // 根据 Accept 头选择响应格式
        accept := c.GetHeader("Accept")

        switch {
        case strings.Contains(accept, "application/xml"):
            xmlUser := XMLUser{
                ID:        user.ID,
                Username:  user.Username,
                Email:     user.Email,
                CreatedAt: user.CreatedAt.Format(time.RFC3339),
            }
            c.XML(200, xmlUser)
        case strings.Contains(accept, "application/yaml"):
            c.YAML(200, user)
        case strings.Contains(accept, "text/plain"):
            c.String(200, "User: %s (%s)", user.Username, user.Email)
        default:
            c.JSON(200, user)
        }
    })

    return r
}

HTML 模板引擎 #

基础模板使用 #

func setupHTMLTemplates() *gin.Engine {
    r := gin.Default()

    // 1. 加载模板文件
    r.LoadHTMLGlob("templates/*")

    // 2. 或者加载特定模板
    // r.LoadHTMLFiles("templates/index.html", "templates/user.html")

    // 3. 渲染模板
    r.GET("/", func(c *gin.Context) {
        c.HTML(200, "index.html", gin.H{
            "title":   "Welcome",
            "message": "Hello, Gin Templates!",
            "users": []User{
                {ID: 1, Username: "alice", Email: "[email protected]"},
                {ID: 2, Username: "bob", Email: "[email protected]"},
            },
        })
    })

    return r
}

模板文件示例 #

创建模板文件 templates/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{.title}}</title>
    <style>
      body {
        font-family: Arial, sans-serif;
        margin: 40px;
      }
      .user {
        border: 1px solid #ddd;
        padding: 10px;
        margin: 10px 0;
      }
      .header {
        color: #333;
      }
    </style>
  </head>
  <body>
    <h1 class="header">{{.title}}</h1>
    <p>{{.message}}</p>

    {{if .users}}
    <h2>Users</h2>
    {{range .users}}
    <div class="user">
      <h3>{{.Username}}</h3>
      <p>Email: {{.Email}}</p>
      <p>ID: {{.ID}}</p>
    </div>
    {{end}} {{else}}
    <p>No users found.</p>
    {{end}}

    <!-- 包含其他模板 -->
    {{template "footer.html" .}}
  </body>
</html>

创建 templates/footer.html

<footer
  style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee;"
>
  <p>&copy; 2024 Gin Application. All rights reserved.</p>
  <p>Generated at: {{.timestamp}}</p>
</footer>

高级模板功能 #

import (
    "html/template"
    "strings"
    "time"
)

func setupAdvancedTemplates() *gin.Engine {
    r := gin.Default()

    // 1. 自定义模板函数
    r.SetFuncMap(template.FuncMap{
        "upper": strings.ToUpper,
        "lower": strings.ToLower,
        "formatDate": func(t time.Time) string {
            return t.Format("2006-01-02 15:04:05")
        },
        "add": func(a, b int) int {
            return a + b
        },
        "multiply": func(a, b int) int {
            return a * b
        },
        "truncate": func(s string, length int) string {
            if len(s) <= length {
                return s
            }
            return s[:length] + "..."
        },
        "safe": func(s string) template.HTML {
            return template.HTML(s)
        },
    })

    // 2. 加载嵌套模板
    r.LoadHTMLGlob("templates/**/*")

    // 3. 使用自定义函数的模板
    r.GET("/advanced", func(c *gin.Context) {
        c.HTML(200, "advanced.html", gin.H{
            "title":     "Advanced Template",
            "username":  "john_doe",
            "content":   "<p>This is <strong>HTML</strong> content</p>",
            "createdAt": time.Now(),
            "count":     42,
            "longText":  "This is a very long text that needs to be truncated for display purposes.",
        })
    })

    return r
}

创建 templates/advanced.html

<!DOCTYPE html>
<html>
  <head>
    <title>{{.title}}</title>
  </head>
  <body>
    <h1>{{upper .title}}</h1>

    <!-- 使用自定义函数 -->
    <p>Username: {{upper .username}}</p>
    <p>Created: {{formatDate .createdAt}}</p>
    <p>Count + 10 = {{add .count 10}}</p>
    <p>Count * 2 = {{multiply .count 2}}</p>

    <!-- 截断长文本 -->
    <p>{{truncate .longText 50}}</p>

    <!-- 安全渲染 HTML -->
    <div>{{safe .content}}</div>

    <!-- 条件渲染 -->
    {{if gt .count 40}}
    <p>Count is greater than 40</p>
    {{else}}
    <p>Count is 40 or less</p>
    {{end}}
  </body>
</html>

模板继承 #

创建基础模板 templates/layouts/base.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{block "title" .}}Default Title{{end}}</title>
    <link rel="stylesheet" href="/static/css/style.css" />
    {{block "head" .}}{{end}}
  </head>
  <body>
    <header>
      <nav>
        <a href="/">Home</a>
        <a href="/users">Users</a>
        <a href="/about">About</a>
      </nav>
    </header>

    <main>
      {{block "content" .}}
      <p>Default content</p>
      {{end}}
    </main>

    <footer>
      <p>&copy; 2024 My Application</p>
    </footer>

    {{block "scripts" .}}{{end}}
  </body>
</html>

创建继承模板 templates/users/list.html

{{template "layouts/base.html" .}} {{define "title"}}User List{{end}} {{define
"head"}}
<style>
  .user-card {
    border: 1px solid #ddd;
    padding: 15px;
    margin: 10px 0;
  }
</style>
{{end}} {{define "content"}}
<h1>Users</h1>
{{range .users}}
<div class="user-card">
  <h3>{{.Username}}</h3>
  <p>{{.Email}}</p>
  <a href="/users/{{.ID}}">View Details</a>
</div>
{{end}} {{end}} {{define "scripts"}}
<script>
  console.log("User list page loaded");
</script>
{{end}}

静态文件服务 #

基础静态文件服务 #

func setupStaticFiles() *gin.Engine {
    r := gin.Default()

    // 1. 基础静态文件服务
    r.Static("/static", "./static")

    // 2. 静态文件服务(带路径前缀)
    r.StaticFS("/assets", http.Dir("./assets"))

    // 3. 单个文件服务
    r.StaticFile("/favicon.ico", "./static/favicon.ico")

    // 4. 嵌入式静态文件(Go 1.16+)
    // r.StaticFS("/embedded", http.FS(embeddedFiles))

    return r
}

高级静态文件配置 #

import (
    "net/http"
    "path/filepath"
    "strings"
)

func setupAdvancedStaticFiles() *gin.Engine {
    r := gin.Default()

    // 1. 自定义静态文件中间件
    r.Use(staticFileMiddleware("./static", "/static"))

    // 2. 带缓存的静态文件服务
    r.Use(staticWithCache("./public", "/public", 24*time.Hour))

    // 3. 压缩静态文件服务
    r.Use(compressedStatic("./assets", "/assets"))

    return r
}

// 自定义静态文件中间件
func staticFileMiddleware(root, prefix string) gin.HandlerFunc {
    fileServer := http.FileServer(http.Dir(root))

    return func(c *gin.Context) {
        if strings.HasPrefix(c.Request.URL.Path, prefix) {
            // 移除前缀
            c.Request.URL.Path = strings.TrimPrefix(c.Request.URL.Path, prefix)

            // 安全检查,防止目录遍历攻击
            if strings.Contains(c.Request.URL.Path, "..") {
                c.AbortWithStatus(404)
                return
            }

            fileServer.ServeHTTP(c.Writer, c.Request)
            c.Abort()
        }
    }
}

// 带缓存的静态文件服务
func staticWithCache(root, prefix string, maxAge time.Duration) gin.HandlerFunc {
    fileServer := http.FileServer(http.Dir(root))

    return func(c *gin.Context) {
        if strings.HasPrefix(c.Request.URL.Path, prefix) {
            // 设置缓存头
            c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", int(maxAge.Seconds())))
            c.Header("Expires", time.Now().Add(maxAge).Format(http.TimeFormat))

            // 检查文件是否存在
            filePath := filepath.Join(root, strings.TrimPrefix(c.Request.URL.Path, prefix))
            if _, err := os.Stat(filePath); os.IsNotExist(err) {
                c.AbortWithStatus(404)
                return
            }

            c.Request.URL.Path = strings.TrimPrefix(c.Request.URL.Path, prefix)
            fileServer.ServeHTTP(c.Writer, c.Request)
            c.Abort()
        }
    }
}

// 压缩静态文件服务
func compressedStatic(root, prefix string) gin.HandlerFunc {
    return func(c *gin.Context) {
        if strings.HasPrefix(c.Request.URL.Path, prefix) {
            // 检查客户端是否支持 gzip
            if strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") {
                gzipPath := filepath.Join(root, strings.TrimPrefix(c.Request.URL.Path, prefix)+".gz")
                if _, err := os.Stat(gzipPath); err == nil {
                    c.Header("Content-Encoding", "gzip")
                    c.Header("Content-Type", getContentType(c.Request.URL.Path))
                    c.File(gzipPath)
                    c.Abort()
                    return
                }
            }

            // 提供原始文件
            originalPath := filepath.Join(root, strings.TrimPrefix(c.Request.URL.Path, prefix))
            c.File(originalPath)
            c.Abort()
        }
    }
}

func getContentType(path string) string {
    ext := filepath.Ext(path)
    switch ext {
    case ".css":
        return "text/css"
    case ".js":
        return "application/javascript"
    case ".html":
        return "text/html"
    case ".json":
        return "application/json"
    case ".png":
        return "image/png"
    case ".jpg", ".jpeg":
        return "image/jpeg"
    case ".gif":
        return "image/gif"
    case ".svg":
        return "image/svg+xml"
    default:
        return "application/octet-stream"
    }
}

响应格式化 #

统一响应格式 #

// 响应包装器
type ResponseWrapper struct {
    Success   bool        `json:"success"`
    Data      interface{} `json:"data,omitempty"`
    Message   string      `json:"message,omitempty"`
    Error     *ErrorInfo  `json:"error,omitempty"`
    Meta      *MetaInfo   `json:"meta,omitempty"`
    Timestamp int64       `json:"timestamp"`
}

type ErrorInfo struct {
    Code    string      `json:"code"`
    Message string      `json:"message"`
    Details interface{} `json:"details,omitempty"`
}

type MetaInfo struct {
    RequestID  string      `json:"request_id,omitempty"`
    Version    string      `json:"version,omitempty"`
    Pagination *Pagination `json:"pagination,omitempty"`
}

type Pagination struct {
    Page       int `json:"page"`
    Size       int `json:"size"`
    Total      int `json:"total"`
    TotalPages int `json:"total_pages"`
}

// 响应辅助函数
func SuccessResponse(c *gin.Context, data interface{}, message ...string) {
    response := ResponseWrapper{
        Success:   true,
        Data:      data,
        Timestamp: time.Now().Unix(),
    }

    if len(message) > 0 {
        response.Message = message[0]
    }

    if requestID, exists := c.Get("request_id"); exists {
        response.Meta = &MetaInfo{
            RequestID: requestID.(string),
            Version:   "v1",
        }
    }

    c.JSON(200, response)
}

func SuccessWithPagination(c *gin.Context, data interface{}, pagination Pagination) {
    response := ResponseWrapper{
        Success:   true,
        Data:      data,
        Timestamp: time.Now().Unix(),
        Meta: &MetaInfo{
            Version:    "v1",
            Pagination: &pagination,
        },
    }

    if requestID, exists := c.Get("request_id"); exists {
        response.Meta.RequestID = requestID.(string)
    }

    c.JSON(200, response)
}

func ErrorResponse(c *gin.Context, statusCode int, code, message string, details interface{}) {
    response := ResponseWrapper{
        Success: false,
        Error: &ErrorInfo{
            Code:    code,
            Message: message,
            Details: details,
        },
        Timestamp: time.Now().Unix(),
    }

    if requestID, exists := c.Get("request_id"); exists {
        response.Meta = &MetaInfo{
            RequestID: requestID.(string),
            Version:   "v1",
        }
    }

    c.JSON(statusCode, response)
}

完整的渲染示例应用 #

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

    // 设置模板函数
    r.SetFuncMap(template.FuncMap{
        "formatDate": func(t time.Time) string {
            return t.Format("2006-01-02 15:04:05")
        },
        "upper": strings.ToUpper,
    })

    // 加载模板
    r.LoadHTMLGlob("templates/**/*")

    // 静态文件服务
    r.Static("/static", "./static")
    r.StaticFile("/favicon.ico", "./static/favicon.ico")

    // API 路由
    api := r.Group("/api/v1")
    {
        api.GET("/users", func(c *gin.Context) {
            users := []User{
                {ID: 1, Username: "alice", Email: "[email protected]", CreatedAt: time.Now()},
                {ID: 2, Username: "bob", Email: "[email protected]", CreatedAt: time.Now()},
            }

            SuccessResponse(c, users, "Users retrieved successfully")
        })

        api.GET("/users/:id", func(c *gin.Context) {
            id := c.Param("id")
            if id == "1" {
                user := User{
                    ID:        1,
                    Username:  "alice",
                    Email:     "[email protected]",
                    CreatedAt: time.Now(),
                }
                SuccessResponse(c, user)
            } else {
                ErrorResponse(c, 404, "USER_NOT_FOUND", "User not found", nil)
            }
        })
    }

    // Web 页面路由
    r.GET("/", func(c *gin.Context) {
        c.HTML(200, "index.html", gin.H{
            "title":     "Home Page",
            "message":   "Welcome to Gin Application",
            "timestamp": time.Now(),
        })
    })

    r.GET("/users", func(c *gin.Context) {
        users := []User{
            {ID: 1, Username: "alice", Email: "[email protected]", CreatedAt: time.Now()},
            {ID: 2, Username: "bob", Email: "[email protected]", CreatedAt: time.Now()},
        }

        c.HTML(200, "users/list.html", gin.H{
            "title": "User List",
            "users": users,
        })
    })

    r.Run(":8080")
}

通过本节的学习,你已经全面掌握了 Gin 框架的渲染与模板功能。这些技术能够帮助你构建既能提供 API 服务又能渲染 Web 页面的全栈应用。合理使用这些渲染方式可以大大提高开发效率和用户体验。

至此,我们已经完成了 Gin 框架核心功能的全面学习,包括路由与参数绑定、中间件机制、错误处理与验证以及渲染与模板。这些知识为你使用 Gin 开发高质量的 Web 应用奠定了坚实的基础。