Gin 封装原生sql + jwt 实现 web

最近工作之余学了一下 Go 语言, 在此之前是先学了一段时间的 rust, 真的是从入门到放弃, 根本搞不懂, 于是果断转 Go 了, 为啥不继续用 Java 呢, 就是觉得它很啰嗦, 代码量还大, 然后 scala 用的其实不多. 尤其像我这种经常用 Python 搞数据脚本的选手, 应该是不可能喜欢上 Java 的. 学了点 go 后, 觉得它真的很 nice, 简洁, 严谨, 性能强, 风格上特别像 C, 写起来爽的一批.

于是这里弄个 gin 的学习笔记, 后面自己搞一个数据的后台服务, 就用它了.

整体目录

./api/
├── handlers/
│   ├── auth/
│   │   └── views.go
│   ├── user/
│   │   └── views.go
│   └── routers.go
./internal/
├── db/
│   └── db.go
└── middleware/
    └── auth.go
./pkg/
├── jwtt/
│   └── jwt.go
└── utils/
    └── response.go
./test/
└── test.go
./tmp/
└── runner-build
|go.mod
|go.sum
|main.go
  • api 文件夹主要放路由, 还有处理函数 handlers
  • internal 文件夹主要放 数据库封装, 请求响应中间件等
  • Pkg 文件夹主要放 jwt 认证, 工具函数库等
  • 主文件就是 main 了, 本来是也弄类似 cmd 文件夹, 想想算了, 自己玩而已.

项目入口

main.go

package main

import (
	"github.com/gin-gonic/gin"
	"youge.com/api"
	"youge.com/internal/db"
)

func main() {

	// todo 初始化全局配置

	// 初始化数据库连接池
	db.InitDB()

	// 创建 gin 路由实例
	r := gin.Default()

	// 注册中间件 (请求认证, 日志)

	// 注册业务路由
	api.RegisterAllRouters(r)

	// 启动HTTP服务,默认在0.0.0.0:8080启动服务
	r.Run(":8000")
}

主要功能就是:

  • 创建 gin 实例, 并绑定端口为 "8000", 默认是 "8080"
  • 统一初始化全局数据库, 用的 mysql
  • 统一注册业务路由

统一封装 Mysql 查询和执行

因为我是搞数据的原生 sql 型选选手, 自然不可能用 orm 的, 为了方便还是用了 sqlx 库, 方便让查询结果和结构体进行映射, 更加高效处理.

internal/db/db.go

package db

import (
	"context"
	"fmt"
	"regexp"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
)

// 全局 sqlx 连接池
var db *sqlx.DB

func InitDB() error {
	// 硬编码得了
	connStr := "root:admin@tcp(127.0.0.1:3306)/cj"
	var err error

	// 赋值给全局变量 DB, 不能是 ":="
	db, err = sqlx.Connect("mysql", connStr)
	if err != nil {
		panic("数据库连接失败: " + err.Error())
	}

	// 验证连接有效性
	if err = db.Ping(); err != nil {
		panic("数据库心跳检测失败: " + err.Error())
	}

	// 连接池配置
	db.SetMaxOpenConns(50)                  // 最大连接数的 2-3倍
	db.SetMaxIdleConns(20)                  // SetMaxOpenConns 的 1/3
	db.SetConnMaxLifetime(time.Minute * 30) // 小于数据库的 wait_timeout

	return db.Ping()

}

// 危险操作检测
// var dangerCheck = regexp.MustCompile(`(?i)(\b(DROP|ALTER|TRUNCATE|DELETE\s+FROM)\b|--|#|/\*)`)

// 先都放开吧, 开发阶段而已
var dangerCheck = regexp.MustCompile("nb ya")

/* ------------- 核心 API ---------- */
// 查询数据, 自动映射到结构体
func Query(dest interface{}, sql string, args ...interface{}) error {
	if dangerCheck.MatchString(sql) {
		return fmt.Errorf("危险 sql 操作")
	}

	return db.Select(dest, sql, args...)
}

// 带上下文的查询 (web请求)
func QueryContext(ctx context.Context, dest interface{}, sql string, args ...interface{}) error {
	if dangerCheck.MatchString(sql) {
		return fmt.Errorf("危险 sql 操作")
	}
	return db.SelectContext(ctx, dest, sql, args...)
}

// 执行sql, 返回影响行数
func Exec(sql string, args ...interface{}) (int64, error) {
	if dangerCheck.MatchString(sql) {
		return 0, fmt.Errorf("危险 sql 操作")
	}

	result, err := db.Exec(sql, args...)
	if err != nil {
		return 0, fmt.Errorf("数据库执行失败: %w", err)
	}
	return result.RowsAffected()
}

// 事务封装, 自动回滚
func Transaction(fn func(*sqlx.Tx) error) error {
	tx, err := db.Beginx()
	if err != nil {
		return err
	}

	defer func() {
		if p := recover(); p != nil {
			tx.Rollback()
			panic(p)
		}
	}()

	if err := fn(tx); err != nil {
		tx.Rollback()
		return err
	}
	return tx.Commit()
}

这个没啥讲的, 就是用的硬编码, 不想搞配置文件取读取环境变量啥的, 直接干. 然后整体上也是用 deepseek 生成的, 简单改了一下能用就好了

  • 连接数据库直接搞硬编码, 对外返回 db 的全局对象
  • 封装 Query() 处理 select 语句, 底层是 db.Select(), 然后用参数化, 返回一个结构体
  • 封装 Exec() 处理执行类语句, 如 update, delete, create 等, 返回影响的行数
  • Sql 关键字检测, 如 注释 "--, #" , 删除 "drop, truncate" 等危险动作

统一路由管理

路由是统一在一个文件, 直观看到所有, 但处理方法是按模块区分的

api/routers.go

package api

import (
	"github.com/gin-gonic/gin"
	"youge.com/api/handlers/auth"
	"youge.com/api/handlers/user"
	"youge.com/internal/middleware"
)

// 统一注册入口
func RegisterAllRouters(r *gin.Engine) {
	// 登录认证模块
	authGroup := r.Group("/api/auth")
	{
		// auth.POST("/register", Register)
		authGroup.POST("/login", auth.Login)
	}

	// 用户管理模块
	userGroup := r.Group("/api/user")
	// 需要 token 认证的哦
	userGroup.Use(middleware.JWT())
  
	{
		userGroup.GET("/:id", user.GetUserDetail)
		userGroup.POST("/add", user.CreateUser)
		userGroup.POST("/delete", user.DeleteUser)
		userGroup.POST("/test", user.Test)
	}

}

  • RegisterAllRouters(r *gin.Engine) 用来注册所有路由
  • 用了路由组 r.Goup() 来进行分业务模块,这里演示了2个
  • auth 模块给用户注册, 登录验证, 然后办法 jwt 令牌
  • user 模块用了中间件, 要求校验 jwt 令牌
  • 每个路由的处理函数, 都按模块分包处理了

统一封装请求响应

其实就成功响应和失败响应, 统一返回给前端 json 数据格式

pkg/utils/response.go

package utils

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

// 标准成功响应 (包含空数据, )
func Success(c *gin.Context, data interface{}) {
	// 处理空数据
	if data == nil {
		data = gin.H{}
	}

	c.JSON(200, gin.H{
		"code": 200,
		"msg":  "success",
		"data": data,
	})
}

// 分页数据响应 (包含税)
func PageSuccess(c *gin.Context, data interface{}, total int) {
	c.JSON(200, gin.H{
		"code": 200,
		"msg":  "success",
		"data": data,
		"meta": gin.H{
			"total": total,
		},
	})
}

// 错误响应, 强制要传状态码和错误信息描述字符串
func Error(c *gin.Context, args ...interface{}) {
	// 默认值
	code, msg := 500, "服务器开小差啦~"

	// 严格校验参数
	if len(args) == 2 {
		if statusCode, ok := args[0].(int); ok {
			code = statusCode
		}
		if message, ok := args[1].(string); ok {
			msg = message
		}
	} else {
		// 参数错误时, 强制使用默认参数
		code = 500
		msg = "服务器开小差啦~"
	}

	c.JSON(code, gin.H{
		"code": code,
		"msg":  msg,
	})

}

// 快捷方法示例
func BadRequest(c *gin.Context, msg string) {
	Error(c, 400, msg)
}

func NotFound(c *gin.Context, msg string) {
	Error(c, 404, msg)
}

  • Success(c, data) 请求成功响应, 会自动给前端 200, 然后返回数据
  • Error(c, data string) 请求失败响应, 人工传失败码, 字符串错误, 隐藏真正的err
  • 封装常用的 BadRequest(c, msg) 和 NotFound(c, msg) 方法

统一封装 jwt

分成 2个文件, 一个用于生成令牌, 一个用户解析令牌. 整体上也是问 deepseek 的, 就是说这个有了 AI, 学习任何一门编程技术就是, 一会儿就好.

pkg/jwtt/jwt.go

package jwtt

import (
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type Claims struct {
	UserID               int    `json:"uid"`
	Username             string `json:"uname"`
	jwt.RegisteredClaims        // 嵌入标准声明
}

var (
	// 设置秘钥和令牌有效期
	// 签名方法改为: wt.SigningMethodHS256, 秘钥长度至少 32字节
	SecretKey   = []byte("chenjieyougehuoya12345678910")
	tokenExpire = 2 * time.Hour
)

// 生成 jwt 令牌
func GenerateToken(userID int, username string) (string, error) {
	// 初始化声明
	claims := Claims{
		UserID:   userID,
		Username: username,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenExpire)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Issuer:    "youge.com",
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(SecretKey)
}

// 解析令牌
func ParseToken(tokenString string) (*Claims, error) {
	token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		return SecretKey, nil
	})

	if claims, ok := token.Claims.(*Claims); ok && token.Valid {
		return claims, nil
	}
	return nil, err
}

  • 包名就 jwtt 的原因是怕和引用的 jwt 包名字冲突了, 不太好管理

  • Token 生成就 3步: 加密内容 + 私钥 + 加密算法;

  • 推荐 jwt.SigningMethodHS256算法, 然后要将 time 搞进去, 后面用来判断令牌的有效期等

然后是解析令牌哈,

internal/middleware/auth.go

package middleware

import (
	"errors"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"youge.com/pkg/jwtt"
	"youge.com/pkg/utils"
)

func JWT() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 校验是否有令牌
		tokenString := c.GetHeader("Authorization")
		if tokenString == "" {
			utils.Error(c, 401, "请提供访问令牌")
			c.Abort()
			return
		}

		// 提取 Bearer 后的 token
		if len(tokenString) > 7 && tokenString[:7] == "Bearer " {
			tokenString = tokenString[7:]
		}

		// 校验 token
		claims, err := jwtt.ParseToken(tokenString)
		if err != nil {
			handleJWError(c, err)
			c.Abort()
			return
		}

		// 用户信息存入上下文
		c.Set("uid", claims.UserID)
		c.Set("username", claims.Username)
		c.Next()
	}
}

func handleJWError(c *gin.Context, err error) {
	switch {
	case errors.Is(err, jwt.ErrTokenExpired):
		utils.Error(c, 401, "令牌已过期")
	case errors.Is(err, jwt.ErrTokenInvalidId), errors.Is(err, jwt.ErrTokenMalformed):
		utils.Error(c, 401, "无效令牌")

	case errors.Is(err, jwt.ErrTokenNotValidYet):
		utils.Error(c, 401, "令牌尚未生效")

	default:
		utils.Error(c, 500, "令牌解析失败")
	}
}

  • 提取前端传过来的请求头必须包含 Authorization 字段
  • 要求 token 的形式是 Bearer ...... 的字符串
  • 然后解析和校验令牌, 是否有效, 是否过期, 是否被篡改等
  • 没有问题的话, 就存到上下文, 然后 c.Next() 放行

接口处理函数分模块

就类似 MVC 中的 Controller , 用来处理路由的信息, 包括校验前端请求参数, 从数据库获取数据, 处理逻辑, 然后返回给前端 json 等中间操作, 是 核心逻辑的体现

这里仅演示 2 个模块, 一个需要 token 的 和不需要 token 的, 后面还是看自己业务进行随便拓展就好

api/handlers/auth/views.go

package auth

import (
	"errors"
	"log"

	"github.com/gin-gonic/gin"
	"youge.com/pkg/jwtt"
	"youge.com/pkg/utils"
)

// 模拟用户数据结构
type User2 struct {
	ID       int
	Username string
	Password string // 实际应该是 哈希值
}

// 测试数据用, 实际要从数据库校验用户账密
var user = User2{
	ID:       1,
	Username: "admin",
	Password: "admin",
}

func authenticateUser(username, password string) (*User2, error) {
	if user.Username == username && user.Password == password {
		return &user, nil
	}
	return nil, errors.New("用户不存在")
}

type LoginRequest struct {
	Username string `json:"username" binding:"required"`
	Password string `json:"password" binding:"required"`
}

func Login(c *gin.Context) {
	// 接收前端传 json
	var req LoginRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		utils.Error(c, 400, "参数格式错误呀")
		return
	}

	// 验证用户(示例)
	user, err := authenticateUser(req.Username, req.Password)
	if err != nil {
		utils.Error(c, 401, "用户名或密码错误")
		return
	}

	// 生成令牌
	token, err := jwtt.GenerateToken(user.ID, user.Username)
	if err != nil {
		log.Println(err)
		utils.Error(c, 500, "令牌生成失败")
		return
	}

	utils.Success(c, gin.H{
		"token": token,
	})
}

  • 登录接口, 要求前端发 POST 请求, 并传一个 json, 包含 username, password
  • 校验账密如果都是 admin 的话 (测试), 就生成 jwt 的 token 返回给前端
  • 实际中要从数据库查, 且密码必须要加密处理

然后再来一个用户模块, 主要演示这个模块是需要检验 token 的, 在前面已经写了.

	// 用户管理模块
	userGroup := r.Group("/api/user")
	// 需要 token 认证的哦
	userGroup.Use(middleware.JWT())
  
	{
		userGroup.GET("/:id", user.GetUserDetail)
		userGroup.POST("/add", user.CreateUser)
		userGroup.POST("/delete", user.DeleteUser)
		userGroup.POST("/test", user.Test)
	}

api/handlers/user/views.go

package user

import (
	"github.com/gin-gonic/gin"
	"youge.com/internal/db"
	"youge.com/pkg/utils"
)

// 用户模块路由组

type User struct {
	Name string `json:"name"`
	Age  int    `son:"age"`
}


// 获取用户信息
// /api/user/:id
func GetUserDetail(c *gin.Context) {
	// id := c.Param("id")
	var users []User
	err := db.Query(&users, "select name, age from test;")
	if err != nil {
		c.JSON(500, gin.H{"error": err.Error()})
	}
	// 结构体转 json
	c.JSON(200, users)
}

// 新增用户
func CreateUser(c *gin.Context) {
	sql := `
		insert into test(name, age) values 
		("zs", 25), ("cjj", 18);
		`
	rows, err := db.Exec(sql)
	if err != nil {
		utils.BadRequest(c, "新增用户失败")
	}

	utils.Success(c, rows)
}

// 删除某个用户
func DeleteUser(c *gin.Context) {
	// 从请求体获取 id
	idStr := c.PostForm("id")
	if idStr == "" {
		utils.BadRequest(c, "请求参数不足!")
		return
	}

	// 将 id 转为 int
	// id, err := strconv.Atoi(idStr)
	// if err != nil {
	// 	utils.BadRequest(c, "请求参数类型错误!")
	// }

	// 删除用户相关
	log.Println(idStr)
	_, err := db.Exec("delete from test where name = ?", idStr)
	if err != nil {
		utils.Error(c)
		// 真实错误信息打印在控制台
		log.Println(err)
		return
	}

	utils.Success(c, "删除的用户id 是: "+idStr)
}

就是 CRUD 没啥技术难度, 注意是体验一下过程就好, 获取前端的路径参数呀, 请求参数呀, 表单呀, json 呀这些东西, 不说了, 随便搞.

整体上内容就差不多啦, 感觉这个 go 还是很好用滴, 这个 gin 来搞一下简单的数据 web 后台就很好,

因为它就是快!

posted @ 2025-04-15 23:54  致于数据科学家的小陈  阅读(53)  评论(0)    收藏  举报