代码地址: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")
}