40.用Gin完成一个简易待办事项管理后端代码(六)代码结构工程化

代码地址:https://gitee.com/lymgoforIT/golang-trick/tree/master/24-gin-learning

一:工程化简介

上一节我们将所有的代码都写到了主文件main.go中,实际工作中则是需要对代码进行工程化管理的。

工程化有哪些优点:

  • 模块化:工程化有助于将项目分成更小、更易于管理的模块。每个模块可以负责一个特定的功能或领域,使得项目结构更清晰,有助于在项目开发过程中降低复杂性。
  • 可维护性:模块化结构使得项目更易于维护。当需要修改某个特定功能时,可以更轻松的定位到相关的模块以及文件进行修改。此外,这种结构有助于减少在项目中引入错误的可能性。
  • 代码重用:将项目划分为模块使得代码更容易重用。可以将通用的功能封装成模块,并在其他项目中重用这些模块,从而减少重复工作。
  • 团队协作:工程化有助于提高团队协作效率。通过将项目划分为模块,团队成员可以更轻松的为特定模块分配任务,并在不影响其他模块的情况下独立工作。
  • 测试和调试:工程化使得项目的测试和调试更容易进行。可以为每个模块编写单元测试,从而确保各个功能模块的正确性。同时,模块化结构也有助于在出现问题时快速定位问题所在。

本案例最终的目录结构如下:

├─config
│      config.go    存放应用配置的结构体
│      config.yaml  存放应用配置
├─constdef
│      const.go     常量定义
├─database
│      db.go    数据库连接代码
├─handlers
│      todo_handlers.go   待办事项增删改查代码
│      user_handlers.go   用户注册登录代码
├─middleware
│      auth.go    jwt认证鉴权中间件
├─model
│      todo_model.go  待办事项数据模型
│      user_model.go  用户数据模型
├─routers
│      router.go     路由注册
└─util    工具包,本案例暂未用到
│
│  main.go          主入口文件

二:各模块具体内容

1、配置文件

config.yaml 存放应用配置,后期如果有新增配置,均可以在这里统一管理

mysql:
  host: 127.0.0.1
  port: 3306
  username: root
  password: root
  db_name: test

# 本案例并没有用到redis,但是实际工作中基本都会有,所以加上,避免配置只演示mysql过于单一
redis:
  host: 127.0.0.1
  port: 6379
  db: 0

config.go 保存从配置文件解析到的配置,并分结构体管理,如mysql和redis定义到两个结构体中,方便管理和维护

注:

  • 本案例使用viper读取配置文件
  • viper定义映射时使用的是mapstructure,而不是json,yaml,尽管我们使用的是yaml文件,tag也必须写为mapstructure
  • 定义大写的Init方法用于初始化配置
  • 动态监听配置文件的变化,实时更新配置文件Cfg变量
package config

import (
	"fmt"
	"github.com/fsnotify/fsnotify"
	"github.com/spf13/viper"
)

var Cfg = new(Config)

type Config struct {
	MySQL *MySQLConfig `mapstructure:"mysql"`
	Redis *RedisConfig `mapstructure:"redis"`
}

type MySQLConfig struct {
	Host     string `mapstructure:"host"`
	Port     string `mapstructure:"port"`
	UserName string `mapstructure:"username"`
	Password string `mapstructure:"password"`
	DBName   string `mapstructure:"db_name"`
}

type RedisConfig struct {
	Host string `mapstructure:"host"`
	Port string `mapstructure:"port"`
	DB   int    `mapstructure:"db"`
}

func Init() (err error) {
	// 方式1:直接指定配置文件路径(相对路径或者绝对路径)
	// 相对路径:相对执行的可执行文件的相对路径
	//viper.SetConfigFile("./conf/config.yaml")
	// 绝对路径:系统中实际的文件路径
	//viper.SetConfigFile("/Users/lym/Desktop/bluebell/conf/config.yaml")

	// 方式2:指定配置文件名和配置文件的位置,viper自行查找可用的配置文件
	// 配置文件名不需要带后缀
	// 配置文件位置可配置多个
	//viper.SetConfigName("config") // 指定配置文件名(不带后缀)
	//viper.AddConfigPath(".") // 指定查找配置文件的路径(这里使用相对路径)
	//viper.AddConfigPath("./conf")      // 指定查找配置文件的路径(这里使用相对路径)

	// 基本上是配合远程配置中心使用的,告诉viper当前的数据使用什么格式去解析
	//viper.SetConfigType("json")

	fmt.Println()
	viper.SetConfigFile("24-gin-learning/class08/config/config.yaml")
	err = viper.ReadInConfig() // 读取配置信息
	if err != nil {
		// 读取配置信息失败
		fmt.Printf("viper.ReadInConfig failed, err:%v\n", err)
		return
	}

	// 把读取到的配置信息反序列化到 Conf 变量中
	if err := viper.Unmarshal(Cfg); err != nil {
		fmt.Printf("viper.Unmarshal failed, err:%v\n", err)
	}

	viper.WatchConfig() // 动态监听配置问题的变化,更新Cfg配置
	viper.OnConfigChange(func(in fsnotify.Event) {
		fmt.Println("配置文件修改了...")
		if err := viper.Unmarshal(Cfg); err != nil {
			fmt.Printf("viper.Unmarshal failed, err:%v\n", err)
		}
	})
	return
}

2、常量管理

本案例中只有一个全局常量,用于给jwt生成的token加盐的SecretKey

package constdef

const SecretKey = "abc123"

3、数据库管理

实际工作中该模块可能会取名dao,其下分更细一点可能还会有mysql、redis、mq等文件夹(包),本案例只有一级目录,并取名为database

db.go:用于初始化mysql连接实例

package database

import (
	"fmt"
	"golang-trick/24-gin-learning/class08/config"
	"golang-trick/24-gin-learning/class08/model"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func InitDB() (*gorm.DB, error) {
	mysqlConfig := config.Cfg.MySQL
	dsn := fmt.Sprintf("%s:%s@(%s:%s)/%s?charset=utf8mb4&parseTime=true&loc=Local", mysqlConfig.UserName, mysqlConfig.Password, mysqlConfig.Host, mysqlConfig.Port, mysqlConfig.DBName)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		panic(any("failed to connect to db"))
	}

	// 建表
	err = db.AutoMigrate(&model.User{}, &model.Todo{})
	if err != nil {
		return nil, err
	}
	return db, nil
}

4、业务处理Handler

用户的注册与登录属于和用户相关的逻辑,故单独用一个文件管理
user_handlers.go

package handlers

import (
	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v4"
	"golang-trick/24-gin-learning/class08/constdef"
	"golang-trick/24-gin-learning/class08/model"
	"golang.org/x/crypto/bcrypt"
	"gorm.io/gorm"
	"net/http"
)

// 用户注册
func SignUp(c *gin.Context, db *gorm.DB) {
	var user model.User

	if err := c.BindJSON(&user); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// 用户名为唯一键,不允许重复,所以根据用户名查询是否已经存在同名的用户
	var existingUser model.User
	err := db.Where("username = ?", user.Username).First(existingUser).Error
	if err != nil && err != gorm.ErrRecordNotFound {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if existingUser.ID != 0 {
		c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在!"})
		return
	}

	// 用户密码加密入库
	password, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	// hash加密后的密码赋值给用户,从而入库
	user.Password = string(password)
	err = db.Create(&user).Error
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusCreated, user)
}

// 用户登录
func SignIn(c *gin.Context, db *gorm.DB) {
	var user model.User

	if err := c.BindJSON(&user); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// 查询用户是否存在,存在则查出用户,然后对比加密的密码是否一致
	var existingUser model.User
	err := db.Where("username = ?", user.Username).First(&existingUser).Error
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if existingUser.ID == 0 {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误!"})
		return
	}

	// 比对密码
	err = bcrypt.CompareHashAndPassword([]byte(existingUser.Password), []byte(user.Password))
	if err != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误!"})
		return
	}

	// 用户名和token都校验通过了,生成token返回
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"id":       existingUser.ID,
		"username": existingUser.Username,
	})
	// 对token进行加盐操作
	signedToken, err := token.SignedString([]byte(constdef.SecretKey))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	// 加盐后的token返回
	c.JSON(http.StatusOK, gin.H{"token": signedToken})
}

待办事项的增删改查则放到todo_handlers.go

package handlers

import (
	"github.com/gin-gonic/gin"
	"golang-trick/24-gin-learning/class08/model"
	"gorm.io/gorm"
	"net/http"
	"strconv"
)

// 创建待办事项
func CreateTodo(c *gin.Context, db *gorm.DB) {
	var todo model.Todo
	if err := c.BindJSON(&todo); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	todo.UserId = c.GetInt("userID")
	db.Create(&todo)
	c.JSON(http.StatusOK, todo)
}

// 查询待办事项
func GetTodos(c *gin.Context, db *gorm.DB) {
	var todos []model.Todo
	db.Where("user_id = ?", c.GetInt("userID")).Find(&todos)
	c.JSON(http.StatusOK, todos)
}

// 更新待办事项
func UpdateTodo(c *gin.Context, db *gorm.DB) {
	id, err := strconv.Atoi(c.Param("id")) // 从路径参数中取出需要更新的待办事项id
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	var updateTodo model.Todo
	if err := c.BindJSON(&updateTodo); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// 更新操作需要指明model,从而知道是哪个表
	db.Model(&model.Todo{}).Where("id = ? and user_id = ?", id, c.GetInt("userID")).Updates(updateTodo)
	c.JSON(http.StatusOK, updateTodo)
}

// 删除待办事项
func DeleteTodo(c *gin.Context, db *gorm.DB) {
	id, err := strconv.Atoi(c.Param("id")) // 从路径参数中取出需要更新的待办事项id
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	var todo model.Todo
	// 根据待办事项id和用户id查询指定帖子是否存在,存在才删除
	db.Where("id = ? and user_id = ?", id, c.GetInt("userID")).First(&todo)
	if todo.ID == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
		return
	}

	db.Delete(&todo)

	c.JSON(http.StatusOK, gin.H{"message": "Todo deleted"})
}

5、模型(model)管理

实际工作中可能会有很多的表,对应很多的model,故可以一个model对应一个文件

user_model.go

package model

import "gorm.io/gorm"

type User struct {
	gorm.Model
	Username string `json:"username" gorm:"unique"` // 唯一键
	Password string `json:"password"`
}

todo_model.go

package model

import "gorm.io/gorm"

// 待办事项结构体
type Todo struct {
	gorm.Model
	Title  string `json:"title"`
	Status string `json:"status"`
	UserId int    `json:"user_id"`
}

6、中间件

auth.go

package middleware

import (
	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v4"
	"golang-trick/24-gin-learning/class08/constdef"
	"net/http"
)

// 用于操作待办事项前的鉴权中间件
func AuthenticationMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		tokenString := c.GetHeader("token")

		if tokenString == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "权限校验未通过,请检查token是否传递"})
			c.Abort()
			return
		}

		// 解析token,拿到token中的用户名和id等信息
		token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
			return []byte(constdef.SecretKey), nil
		})
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
			c.Abort()
			return
		}

		// token无效或者过期了
		if !token.Valid {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token"})
			c.Abort()
			return
		}

		claims := token.Claims.(jwt.MapClaims)
		// 将用户id设置在context上下文中,断言为float64后转为int
		c.Set("userID", int(claims["id"].(float64)))
		c.Next() // 执行下一个handler
	}
}

7、路由

当服务路由非常多时,路由也应该根据场景放到不同路由组,然后放到不同的路由文件中,这样管理更为清晰

router.go

package routers

import (
	"github.com/gin-gonic/gin"
	"golang-trick/24-gin-learning/class08/handlers"
	"golang-trick/24-gin-learning/class08/middleware"
	"gorm.io/gorm"
	"net/http"
)

// SetupRouter 路由
func SetupRouter(db *gorm.DB) *gin.Engine {

	r := gin.Default()

	r.POST("/signUp", func(c *gin.Context) {
		handlers.SignUp(c, db)
	})

	r.POST("/signIn", func(c *gin.Context) {
		handlers.SignIn(c, db)
	})

	authorized := r.Group("/")
	// 作者操作待办事项前需要先鉴权,所以对该路由分组使用鉴权中间件
	authorized.Use(middleware.AuthenticationMiddleware())
	// 用大括号将该路由组下各路由包起来,方便管理(后期会介绍如果进行结构化管理)
	{
		authorized.POST("/todos", func(c *gin.Context) {
			handlers.CreateTodo(c, db)
		})

		authorized.GET("/todos", func(c *gin.Context) {
			handlers.GetTodos(c, db)
		})

		authorized.PUT("/todos/:id", func(c *gin.Context) {
			handlers.UpdateTodo(c, db)
		})

		authorized.DELETE("/todos/:id", func(c *gin.Context) {
			handlers.DeleteTodo(c, db)
		})

		// 退出登录,之所以写在该路由组下,是因为该路由组添加了鉴权中间件,退出登录前需要先鉴权是已经登录的状态
		// 这里退出登录只是简单模拟,打印输出而已,实际退出登录,应该对token等进行销毁
		authorized.POST("/signOut", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
		})
	}

	r.NoRoute(func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"msg": "404",
		})
	})
	return r
}

8、主入口文件main.go

主文件主要做一些初始化和加载工作,以及最后的服务启动。其实从这里也可以看到,主文件main.go其实应该是很简洁的。

  • 这里读取配置文件必须放到最前面,因为后面的数据库初始化需要用到配置文件
  • 配置文件加载或者初始化DB失败后,可以直接panic,因为这些失败了,服务启动也没有意义了

main.go

package main

import (
	"golang-trick/24-gin-learning/class08/config"
	"golang-trick/24-gin-learning/class08/database"
	"golang-trick/24-gin-learning/class08/routers"
)

func main() {
	// 初始化配置文件
	if err := config.Init(); err != nil {
		panic(any("init config failed"))
		return
	}

	// 初始化DB
	db, err := database.InitDB()
	if err != nil {
		panic(any("create table err"))
	}

	// 注册路由
	r := routers.SetupRouter(db)

	// 启动服务
	r.Run(":8080")
}

以下是基于 go gin 实现单表增删改查的后端代码示例: ```go package main import ( "database/sql" "fmt" "log" "net/http" "strconv" "github.com/gin-gonic/gin" _ "github.com/go-sql-driver/mysql" ) // 定义数据结构 type User struct { ID int `json:"id"` Name string `json:"name"` Age int `json:"age"` } func main() { // 连接数据库 db, err := sql.Open("mysql", "user:password@tcp(localhost:3306)/database") if err != nil { log.Fatal(err) } defer db.Close() // 创建 gin 实例 r := gin.Default() // 查询数据 r.GET("/users/:id", func(c *gin.Context) { id := c.Param("id") user := User{} err := db.QueryRow("SELECT id, name, age FROM users WHERE id=?", id).Scan(&user.ID, &user.Name, &user.Age) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } c.JSON(http.StatusOK, user) }) // 查询所有数据 r.GET("/users", func(c *gin.Context) { rows, err := db.Query("SELECT id, name, age FROM users") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } defer rows.Close() users := []User{} for rows.Next() { user := User{} err := rows.Scan(&user.ID, &user.Name, &user.Age) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } users = append(users, user) } c.JSON(http.StatusOK, users) }) // 添加数据 r.POST("/users", func(c *gin.Context) { var user User if err := c.ShouldBindJSON(&user); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } result, err := db.Exec("INSERT INTO users(name, age) VALUES(?, ?)", user.Name, user.Age) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } id, err := result.LastInsertId() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } user.ID = int(id) c.JSON(http.StatusOK, user) }) // 更新数据 r.PUT("/users/:id", func(c *gin.Context) { id := c.Param("id") var user User if err := c.ShouldBindJSON(&user); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } _, err := db.Exec("UPDATE users SET name=?, age=? WHERE id=?", user.Name, user.Age, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } user.ID, _ = strconv.Atoi(id) c.JSON(http.StatusOK, user) }) // 删除数据 r.DELETE("/users/:id", func(c *gin.Context) { id := c.Param("id") _, err := db.Exec("DELETE FROM users WHERE id=?", id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) return } c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("User %s deleted", id)}) }) // 启动服务器 if err := r.Run(":8080"); err != nil { log.Fatal(err) } } ``` 上述代码使用了 gin 框架处理 HTTP 请求,并使用数据库查询和操作数据。其中,查询数据使用了 `QueryRow` 和 `Query` 函数,添加、更新和删除数据使用了 `Exec` 函数。在添加数据和更新数据的处理函数中,使用了 `ShouldBindJSON` 函数将 JSON 数据绑定到 `User` 结构体上。同时,使用了 HTTP 状态码和 JSON 格式响应客户端请求。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值