跳到主要内容

3 篇博文 含有标签「gin」

查看所有标签

· 阅读需 5 分钟

记录一下记账项目后端使用 jwt 方案来鉴权的开发流程

github.com/golang-jwt/jwt/v5

jwt 涉及到加密及解密的过程,将这两个逻辑抽离为两个工具函数

package jwt_helper

import (
"log"
"os"
"time"

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

// 辅助函数,从环境变量中获取加密私钥
func getHmacSecret() []byte {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
log.Fatal("JWT_SECRET is not set")
}
return []byte(secret)
}

// 加密,生成 jwt
func GenerateJWT(user_id uint) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user_id,
// 过期时间
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(),
})
secret := getHmacSecret()

return token.SignedString(secret)
}

// 解密,解析 jwt
func ParseJWT(jwtString string) (*jwt.Token, error) {
secret := getHmacSecret()

return jwt.Parse(jwtString, func(t *jwt.Token) (interface{}, error) {
// 这里直接将结果返回,默认 error 为 nil
// 报错交给中间件即可
return secret, nil
})
}

登录成功后,接口返回 jwt

func (ctrl *SessionController) Create(c *gin.Context) {
// 获取与校验请求体数据..
// 查询验证码是否有效..
// 查询用户(无则创建)..
// 生成并返回 jwt
jwt, err := jwt_helper.GenerateJWT(uint(user.ID))
if err != nil {
log.Print("Generate JWT Error", err)
c.JSON(http.StatusInternalServerError, api.Error{Error: "Failed to generate jwt"})
return
}

res := api.SessionResponse{
Jwt: jwt,
UserID: user.ID,
}
c.JSON(http.StatusOK, res)
}

其他接口的鉴权的过程即读取和解密 jwt 的过程

因为大部分接口都需要鉴权逻辑,可以将鉴权逻辑抽离为中间件

gin 的中间件结构如下:

func Middleware() gin.HandlerFunc {
// 返回一个函数,接收上下文对象的指针
return func(c *gin.Context) {
// ...
// 暴露到上下文中,作为全局变量
c.Set("me", user)
// 中间件是按照注册的顺序执行的
// 移交控制权(下一个中间件或处理函数)
c.Next()
}
}

鉴权逻辑并非单纯返回 true 或 false,我们可以顺便将用户数据直接读取出来,放到 gin 上下文中。这样一来既可以起到鉴权的功能,也减少了 controller 中冗余的用户数据查询逻辑

首先将解析 jwt 及鉴权过程抽离为辅助函数 getMe,接收上下文指针,返回用户指针

package middleware

import (
// ...
)

func getMe(c *gin.Context) (*database.User, error) {
var user database.User

// 获取权限请求头,截取 jwt 字符串
auth := c.GetHeader("Authorization")
if len(auth) < 8 {
return nil, fmt.Errorf("JWT is required")
}
jwtString := auth[7:]

// 解析 jwt,得到 token
t, err := jwt_helper.ParseJWT(jwtString)
if err != nil {
return nil, fmt.Errorf("invalid jwt")
}

// 解析 token 的 claims 部分,将其断言为 MapClaims 类型
claims, ok := t.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("invalid jwt")
}

// 从 claims 中提取用户 ID,并断言其类型为 float64
userID, ok := claims["user_id"].(float64)
if !ok {
return nil, fmt.Errorf("invalid jwt")
}

// 超时校验
exp, ok := claims["exp"].(float64)
if !ok {
return nil, fmt.Errorf("invalid jwt")
}
if float64(time.Now().Unix()) > exp {
return nil, fmt.Errorf("invalid jwt")
}

// 数据库查询用户信息
if tx := database.DB.Find(&user, userID); tx.Error != nil {
return nil, fmt.Errorf("invalid jwt")
}

// 返回 user 地址
return &user, nil
}

中间件接收一个白名单的切片(因为不是全部接口都要鉴权),返回 gin.HandlerFunc 函数

func Me(whiteList []string) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// 检测白名单
for _, s := range whiteList {
if has := strings.HasPrefix(path, s); has {
c.Next()
return
}
}
// 调用 jwt 解析逻辑,获取用户
user, err := getMe(c)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{
"error": err.Error(),
})
return
}
// 将 me 放到上下文中,作为「全局变量」
c.Set("me", user)
c.Next()
}
}

中间件使用 r.Use 注册

// 创建路由
r := gin.Default()
// 应用中间件
r.Use(middleware.Me([]string{"/api/v1/session", "/api/v1/validation-codes", "/ping"}))
// 注册路由..

· 阅读需 5 分钟

记录下记账项目后端的 orm 工具 Gorm 的基本使用

1 连接数据库

在 database package 暴露数据库实例 DB。声明 ConnectDB 方法,内部使用 gorm.Open 连接数据库,并给 DB 赋值

package database

var DB *gorm.DB

func ConnectDB() {
if DB != nil {
return
}

// 从环境变量中获取数据库连接字符串
dsn := os.Getenv("DB_DSN")
if dsn == "" {
log.Fatal("DB_DSN is not set")
}

// 连接数据库
database, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

if err != nil {
log.Fatal("Fail to connect database!", err)
}

// 赋值 DB
DB = database
}

2 model 定义

https://gorm.io/zh_CN/docs/models.html

model 就是一个结构体,定义类型,后跟注释形式的字段配置,例如 gorm:"<-:create" json:"createdAt"

type ValidationCode struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"size:255;not null" json:"email"`
Code string `gorm:"size:255;not null" json:"code"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
UpdatedAt time.Time `gorm:"<-:update" json:"updatedAt"`
CreatedAt time.Time `gorm:"<-:create" json:"createdAt"`
UsedAt *time.Time `json:"usedAt"`
}
  • 一对多

一个 user 可以创建多个 tag 和 item 使用外键约束,关联 tag 和 user 的关系即可

type User struct {
gorm.Model
Email string `gorm:"size:255;not null;unique"`
Tags []Tag
Items []Item
}
type Tag struct {
gorm.Model
UserID uint `gorm:"not null;index"`
// ...
// 只要遵守约定 模型名+ID 那么
// GORM 能够自动识别这种外键关系,不需要显式地使用 foreignKey 标签指定。
User User `gorm:"foreignKey:UserID"`
}
  • 多对多

一个 item 可以属于多个 tag,同时一个 tag 下也可以有很多 item

使用连接表来定义多对多的关系,这种关系声明在一个表的一个字段就可以

会自动生成一个连接表

type Tag struct {
gorm.Model
UserID uint `gorm:"not null;index"`
User User `gorm:"foreignKey:UserID"`
}
type Item struct {
gorm.Model
UserID uint `gorm:"not null;index"`
User User `gorm:"foreignKey:UserID"`
// item_tags 多对多连接表的结构通常是由 GORM 自动生成的
// 它会包含两个字段:item_id 和 tag_id 分别作为外键指向 items 表和 tags 表的主键
// 通常,在处理数据库和关联关系时,推荐使用指针类型作为切片的元素类型,因为这可以更好地与ORM工作,并方便处理没有值(nil)的情形。因此推荐将 item 中的 Tags 定义为指向 Tag 的指针的切片
Tags []*Tag `gorm:"many2many:item_tags;"`
}

若要重写外键,可以使用标签foreignKeyreferencesjoinforeignKeyjoinReferences。当然,您不需要使用全部的标签,你可以仅使用其中的一个重写部分的外键、引用,例如

Tags []Tag `gorm:"many2many:item_tags;foreignKey:ID;joinForeignKey:ItemID;References:ID;joinReferences:TagID"`
  • foreignKey:ID 指的是Item模型的ID字段作为连接表(item_tags)的外键。

  • joinForeignKey:ItemID 指的是在连接表中用于指向Item记录的字段名。

  • References:ID 指的是Tag模型中的ID字段作为参照。

  • joinReferences:TagID 指的是在连接表中用于指向Tag记录的字段名。

相当于如下 sql

CREATE TABLE `items` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL,
`user_id` BIGINT,
`created_at` DATETIME,
`updated_at` DATETIME,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
CREATE TABLE `item_tags` (
`item_id` BIGINT,
`tag_id` BIGINT,
PRIMARY KEY (`item_id`, `tag_id`),
FOREIGN KEY (`item_id`) REFERENCES `items`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON DELETE CASCADE
);

3 同步数据库

使用 cobra 封装为一个指令

func Migrate() {
if DB != nil {
// 迁移数据库
err := DB.AutoMigrate(&User{}, &Item{}, &Tag{}, &ValidationCode{})
if err != nil {
log.Fatal("Fail to migrate database!", err)
}
// 创建测试用户
DB.Save(&User{
Email: "test@test.com",
})
}
}

4 清空数据库

主要是关闭外键检查,方便测试

func TruncateTables(t *testing.T, tables []string) {
// 禁用外键检查
err := DB.Exec("SET FOREIGN_KEY_CHECKS=0;").Error
if err != nil {
if t != nil {
t.Fatalf("Failed to disable foreign key checks: %v", err)
} else {
log.Fatalf("Failed to disable foreign key checks: %v", err)
}
}

// 清空所有给定的表
for _, table := range tables {
if err = DB.Exec("TRUNCATE TABLE " + table + ";").Error; err != nil {
if t != nil {
t.Fatalf("Failed to truncate table %s: %v", table, err)
} else {
log.Fatalf("Failed to truncate table %s: %v", table, err)
}
}
}

// 重新启用外键检查
err = DB.Exec("SET FOREIGN_KEY_CHECKS=1;").Error
if err != nil {
if t != nil {
t.Fatalf("Failed to enable foreign key checks: %v", err)
} else {
log.Fatalf("Failed to enable foreign key checks: %v", err)
}
}
}

· 阅读需 6 分钟

记账项目部署

将项目前端、后端、数据库这三个服务打包的镜像,push 到 docker hub,再利用 docker compose 统一拉取新版本镜像进行服务端部署

后端

源码:https://github.com/GSemir0418/account-app-gin

镜像:https://hub.docker.com/r/gsemir/account-app-backend

  • 流程

容器共需两个基础镜像,一个用来构建golang:1.21-alpine3.20 AS builder),一个用来运行项目(alpine:latest

构建时先复制依赖文件,安装依赖,之后再将源码复制到容器进行构建

构建产物及环境变量文件复制到运行时的镜像中,启动项目即可

  • 区别环境变量

在构建运行时镜像配置中,添加容器环境变量;启动项目之前将环境变量文件复制到运行时容器中

# ...
FROM alpine:latest
ENV GIN_ENV prod
ENV GIN_MODE release
# ...
WORKDIR /app
COPY --from=builder /app/main /app/main
COPY --from=builder /app/.env.prod /app/.env.prod
# ...

然后代码中通过 GIN_ENV 区别加载的文件,注意是运行时才会区别环境而不是构建时

// 获取环境变量 GIN_ENV,默认值为 "dev"
env := os.Getenv("GIN_ENV")
if env == "" {
env = "dev"
}

// 确定要加载的 .env 文件
envFile := filepath.Join(basepath, "..", ".env."+env)
// 加载环境文件
err := godotenv.Load(envFile)
  • 数据库连接要配置容器名而不是 localhost 或 ip
# env
DB_DSN=username:password@tcp(mysql-container-name:3306)/db_name?charset=utf8mb4&parseTime=True&loc=Local
  • 记得配置 go 代理,加速依赖包
FROM golang:1.21-alpine3.20 AS builder
WORKDIR /app
ENV GOPROXY=https://goproxy.cn,direct
# ...
  • docker build 报错
failed commit on ref "layer-sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1": "layer-sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1" failed size validation: 0 != 32: failed precondition

解决方法:使用 docker pull 手动拉取镜像

  • 先同步数据库,再启动项目
CMD ["sh", "-c", "./main db migrate:create && ./main server"]
  • 镜像构建脚本
#!/bin/bash
# push.sh

VERSION=$1
if [ -z "$VERSION" ]; then
echo "Error: No version specified."
exit 1
fi

docker build -t account-app-backend:$VERSION .
docker tag account-app-backend:$VERSION gsemir/account-app-backend:$VERSION
docker push gsemir/account-app-backend:$VERSION

先构建镜像,再打标签,最后 push 到 dockerhub。在执行脚本时必须传递版本号 sh push.sh 0.1.0

前端

源码:https://github.com/GSemir0418/account-app-vite

镜像:https://hub.docker.com/r/gsemir/account-app-frontend

  • 流程

与后端差不多,需要两个基础镜像,一个用来构建(node:20-alpine3.20 AS build),一个用来运行(nginx:alpine

构建时同样先复制 package.json 与 package-lock.json 到镜像,安装依赖,之后再打包

启动时使用 nginx 开启服务即可

  • 跨域

使用 nignx 反向代理浏览器发出的 api 接口请求

location /api/ {
proxy_pass http://account-app-backend:8080/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

此时前端的 axios 的 baseURL 要使用本地的 origin,即 window.location.origin,不能写死

写死的话 nginx 根本不会匹配到对应的 url 并转发,只能靠后端改 CORS 响应头

  • Vite 多环境配置

根目录定义 .env.development.env.production 等多环境环境变量文件

页面中使用 import.meta.env.XXX_XXX 来访问环境变量

注意 Vite 会在构建时替换对应的环境变量的值,因此在 build 项目前要声明 NODE_ENV 环境变量

  • NODE_ENV 与依赖

这里还存在一个问题,就是如果设置了 NODE_ENV=production 环境变量,npm 在安装依赖时会忽略掉 devDependencies

但是通常情况下,构建阶段应包含所有开发依赖。所以这里我们把设置 NODE_ENV 的步骤放到安装依赖之后、打包项目之前

  • npm ci 替换 npm install

npm ci 会严格安装 lock 文件安装依赖,忽略对 package.json 中版本范围的解析等过程,且安装依赖前会删除现有的 node_modules 目录

总的来说,npm ci 更适合 CICD 的流程中,更严格、更、更干净地安装依赖

数据库

数据库使用官方的 mysql:latest 镜像,使用 docker run 命令直接启动也可以

docker run -d \
--name account-app-mysql \
--network network1 \
-e MYSQL_ROOT_PASSWORD=xxx \
-e MYSQL_DATABASE=account_app_db \
-e MYSQL_USER=xxx \
-e MYSQL_PASSWORD=xxx \
-p 3307:3306 \
-v mysql-data:/var/lib/mysql \
mysql:latest

但是后面会集成到 docker compose 中

docker-compose

源码:https://github.com/GSemir0418/account-app-deploy

Docker compose 的配置实际上就是整合多个容器服务的 docker run 命令

  • network

注意配置网络环境,保证三个服务在同一网络环境下

services:
frontend:
image: gsemir/account-app-frontend:0.1.3
networks:
- account-app-network

backend:
image: gsemir/account-app-backend:0.1.0
networks:
- account-app-network

db:
image: mysql:latest
networks:
- account-app-network

networks:
account-app-network:
driver: bridge
  • 端口

注意端口映射,ports: 70:80 表示宿主环境的(外部)的 70 端口对应容器内部环境的 80 端口,即服务启动(暴露)的端口

  • 环境变量与持久化

会自动读取根目录中的 .env 文件中定义的数据库连接所需的环境变量

services:
db:
image: mysql:latest
container_name: account-app-mysql
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql

volumes:
mysql-data: