项目git库代码地址

https://gitee.com/debugksir/ksir_go

实现功能

  • 项目结构最佳实践 ✅
  • 项目配置:viper ✅
  • 开发、测试、生产环境区分 ✅
  • 日志:zap ✅
  • gorm mysql: 关系型数据库 ✅
  • 路由以及分组 ✅
  • response 统一返回规范封装 ✅
  • pagination 分页器封装 ✅
  • redis 连接 ✅
  • 用户登录系统 ✅
  • 密码存储加密 ✅
  • 验证码 captcha ✅
  • JWT ✅
  • 常用中间件(跨域、日志、JWT)✅
  • 资源文件管理 minio ✅
  • docker配置、CI/CD ✅

项目目录结构规划

前置条件:
go环境搭建传送门
gin项目初始化传送门

项目目录及相应说明如下:

  1. .
  2. ├── Dockerfile -- server 服务镜像构建配置
  3. ├── README.md -- 备注说明
  4. ├── config -- 配置目录
  5. ├── config.debug.yml -- 开发环境配置
  6. ├── config.go -- 配置数据结构
  7. ├── config.release.yml -- 生产环境
  8. └── config.test.yml -- 测试环境
  9. ├── controller -- 接口逻辑目录
  10. ├── tools
  11. ├── pagination -- 分页器
  12. └── pagination.go
  13. └── response -- 响应数据封装
  14. └── response.go
  15. └── v1
  16. ├── common.go
  17. ├── entry.go
  18. └── user.go
  19. ├── docker-compose.yml
  20. ├── global -- 全局变量
  21. └── global.go
  22. ├── go.mod
  23. ├── go.sum
  24. ├── initialize -- 初始化
  25. ├── db.go -- 数据库初始化
  26. ├── logger.go -- 日志初始化
  27. ├── minio.go -- minio文件资源管理初始化
  28. ├── redis.go -- redis 初始化
  29. ├── routers.go -- gin服务及路由初始化
  30. └── viper.go -- 配置加载初始化
  31. ├── main.go -- 项目入口文件
  32. ├── middleware -- 中间件
  33. ├── cross.go -- 跨域
  34. ├── jwt.go -- 鉴权验证
  35. └── logger.go -- 日志记录
  36. ├── model -- 数据库表模型
  37. └── user.go
  38. ├── public -- web托管目录
  39. ├── login.html
  40. ├── register.html
  41. └── userInfo.html
  42. ├── router -- 路由目录
  43. ├── common.go
  44. ├── entry.go
  45. └── user.go
  46. ├── tmp -- gin项目构建临时目录(自动生成的)
  47. └── runner-build
  48. ├── utils -- 工具
  49. ├── captcha.go
  50. ├── minio.go
  51. ├── redisMassageCode.go
  52. └── utils.go
  53. ├── wait-for-it-bash.sh -- docker server服务异步bash环境下的启动脚本
  54. └── wait-for-it-sh.sh -- docker server服务异步sh环境下的启动脚本

image.png

常用依赖安装

  • gin框架: github.com/gin-gonic/gin
  • 开发环境热更新: github.com/pilu/fresh
  • 项目配置: github.com/spf13/viper
  • 项目日志: go.uber.org/zap
  • 数据库gorm: gorm.io/gorm
  • mysql驱动: gorm.io/driver/mysql
  • redis: github.com/go-redis/redis/v8
  • 图像验证码: github.com/mojocn/base64Captcha
  • jwt: github.com/dgrijalva/jwt-go
  • 文件改动提醒: github.com/fsnotify/fsnotify
  • 文件资源管理 github.com/minio/minio-go

使用viper库实现项目配置

项目使用参考:https://github.com/spf13/viper

核心逻辑:

  1. package initialize
  2. import (
  3. "fmt"
  4. "ksirGo/global"
  5. "github.com/fsnotify/fsnotify"
  6. "github.com/gin-gonic/gin"
  7. "github.com/spf13/viper"
  8. )
  9. func Viper() *viper.Viper {
  10. v := viper.New()
  11. mode := gin.Mode() // debug test release
  12. // 根据gin启动环境来加载不同的配置文件,启动环境可以通过(export GIN_MODE=release/test/debug)来指定
  13. v.SetConfigFile("config/config." + mode + ".yml")
  14. v.SetConfigType("yaml")
  15. err := v.ReadInConfig()
  16. if err != nil {
  17. panic(fmt.Errorf("配置文件错误: %s", err))
  18. }
  19. v.WatchConfig()
  20. v.OnConfigChange(func(e fsnotify.Event) {
  21. fmt.Println("config file changed:", e.Name)
  22. if err := v.Unmarshal(&global.CONFIG); err != nil {
  23. fmt.Println(err)
  24. }
  25. })
  26. // 将配置文件中的数据映射到我们的全局变量CONFIG中,关于CONFIG以及yaml文件数据如何定义请参考git库代码
  27. if err := v.Unmarshal(&global.CONFIG); err != nil {
  28. fmt.Println(err)
  29. }
  30. // fmt.Println("配置内容:", global.CONFIG)
  31. return v
  32. }

开发、测试、生产环境区分

gin为我们提供了GIN_MODE环境变量,我们可以通过该特性来构建各个环境,开发、测试生产具体用法如下

  1. # 1. 开发环境
  2. fresh # 热更新,默认使用的是export GIN_MODE=debug环境
  3. # 同
  4. export GIN_MODE=debug && fresh
  5. # 2. 测试环境
  6. go build -o server .
  7. export GIN_MODE=test && ./server
  8. # 3. 生产环境
  9. go build -o server .
  10. export GIN_MODE=release && ./server
  11. # 在运行时我们就可以通过mode := gin.Mode() 获取相应的环境了

使用zap库实现日志记录

初始化

  1. func Logger() *zap.Logger {
  2. zapConfig := zap.NewDevelopmentConfig()
  3. zapConfig.OutputPaths = []string{
  4. // 读取配置中的日志目录,根据日期进行归档
  5. fmt.Sprintf("%s/log_%s.log", global.CONFIG.Zap.LogPath, utils.GetNowDateStr()),
  6. "stdout",
  7. }
  8. logger, _ := zapConfig.Build()
  9. // zap.ReplaceGlobals(logger)
  10. return logger
  11. }
  12. // utils.GetNowDateStr 如下
  13. func GetNowDateStr() string {
  14. now := time.Now()
  15. dateStr := fmt.Sprintf("%02d-%02d-%02d", now.Year(), int(now.Month()), now.Day())
  16. return dateStr
  17. }

在中间件中使用

  1. // 记录Bad请求日志
  2. func RequestLogger() gin.HandlerFunc {
  3. return func(c *gin.Context) {
  4. start := time.Now()
  5. // 请求路径
  6. path := c.Request.URL.Path
  7. // 请求参数
  8. query := c.Request.URL.RawQuery
  9. c.Next()
  10. cost := time.Since(start)
  11. // 若response的状态码不是200为异常
  12. if c.Writer.Status() != 200 {
  13. // 记录异常信息
  14. global.LOGGER.Info(path,
  15. zap.Int("status", c.Writer.Status()),
  16. zap.String("method", c.Request.Method),
  17. zap.String("path", path),
  18. zap.String("query", query),
  19. zap.String("ip", c.ClientIP()),
  20. zap.String("user-agent", c.Request.UserAgent()),
  21. zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
  22. zap.Duration("cost", cost),
  23. )
  24. }
  25. }
  26. }
  27. // 记录panic日志
  28. func RecoveryLogger(stack bool) gin.HandlerFunc {
  29. return func(c *gin.Context) {
  30. defer func() {
  31. if err := recover(); err != nil {
  32. var brokenPipe bool
  33. if ne, ok := err.(*net.OpError); ok {
  34. if se, ok := ne.Err.(*os.SyscallError); ok {
  35. if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
  36. brokenPipe = true
  37. }
  38. }
  39. }
  40. httpRequest, _ := httputil.DumpRequest(c.Request, false)
  41. if brokenPipe {
  42. global.LOGGER.Error(c.Request.URL.Path,
  43. zap.Any("error", err),
  44. zap.String("request", string(httpRequest)),
  45. )
  46. // If the connection is dead, we can't write a status to it.
  47. c.Error(err.(error)) // nolint: errcheck
  48. c.Abort()
  49. return
  50. }
  51. if stack {
  52. global.LOGGER.Error("[Recovery from panic]",
  53. zap.Any("error", err),
  54. zap.String("request", string(httpRequest)),
  55. zap.String("stack", string(debug.Stack())),
  56. )
  57. } else {
  58. global.LOGGER.Error("[Recovery from panic]",
  59. zap.Any("error", err),
  60. zap.String("request", string(httpRequest)),
  61. )
  62. }
  63. c.AbortWithStatus(http.StatusInternalServerError)
  64. }
  65. }()
  66. c.Next()
  67. }
  68. }

最终日志文件形如:
image.png

关系型数据库gorm的使用

文档参考:https://gorm.io/zh_CN/docs/index.html

前置条件需要先安装mysql,如使用了docker,可使用以下命令一键安装

  1. docker run -d -p:3306:3306 --name my_mysql -e MYSQL_ROOT_PASSWORD=123456 mysql

使用非常简单

  1. 连接MySQL
  1. func Mysql() *gorm.DB {
  2. m := global.CONFIG.Mysql
  3. if m.DBName == "" {
  4. return nil
  5. }
  6. // fmt.Println("mysql配置:", m.Dsn())
  7. mysqlConfig := mysql.Config{
  8. DSN: m.Dsn(), // DSN data source name
  9. DefaultStringSize: 191, // string 类型字段的默认长度
  10. SkipInitializeWithVersion: false, // 根据版本自动配置
  11. }
  12. if db, err := gorm.Open(mysql.New(mysqlConfig), &gorm.Config{}); err != nil {
  13. fmt.Println("mysql连接失败:", err.Error())
  14. return nil
  15. } else {
  16. sqlDB, _ := db.DB()
  17. sqlDB.SetMaxIdleConns(m.MaxIdleConns)
  18. sqlDB.SetMaxOpenConns(m.MaxOpenConns)
  19. Generator(db)
  20. fmt.Println("mysql连接成功!")
  21. return db
  22. }
  23. }
  1. 创建表模型
  1. package model
  2. import "gorm.io/gorm"
  3. type User struct {
  4. gorm.Model
  5. Phone string `json:"phone" form:"phone" binding:"required" gorm:"comment:'手机号'"`
  6. Password string `json:"password" form:"password" binding:"required" gorm:"comment:'密码'"`
  7. Nickname string `json:"nickname" form:"nickname" gorm:"comment:'昵称'"`
  8. Avatar string `json:"avatar" form:"avatar" gorm:"size:512;comment:'头像'"`
  9. Gender *int `json:"gender" form:"gender" gorm:"comment:'性别1:男;2:女'"`
  10. Age int `json:"age" form:"age" gorm:"comment:'年龄'"`
  11. Name string `json:"name" form:"name" gorm:"comment:'姓名'"`
  12. Email string `json:"email" form:"email" gorm:"comment:'邮箱'"`
  13. }
  1. 迁移到数据库
  1. // 生成数据库表
  2. func Generator(db *gorm.DB) {
  3. db.AutoMigrate(&model.User{})
  4. }

Api路由及分组

  1. 在initialize/routers.go中完成gin实例的创建以及router的初始化
  1. func Routers() *gin.Engine {
  2. // gin.SetMode(gin.ReleaseMode) # 环境断言
  3. Router := gin.Default()
  4. Static := global.CONFIG.Static
  5. StaticFS := global.CONFIG.StaticFS
  6. if Static.RelativePath != "" && Static.Root != "" {
  7. Router.Static(Static.RelativePath, Static.Root) // 静态网站
  8. }
  9. if StaticFS.RelativePath != "" && StaticFS.FS != "" {
  10. Router.StaticFS(StaticFS.RelativePath, http.Dir(StaticFS.FS)) // 虚拟文件系统
  11. }
  12. Router.MaxMultipartMemory = global.CONFIG.System.MaxMultipartMemory // 文件上传最大尺寸
  13. Router.Use(
  14. // middleware.Cross(), // 跨域
  15. middleware.RequestLogger(), // 错误请求日志
  16. middleware.RecoveryLogger(true), // 宕机日志
  17. ) // 添加日志记录中间件
  18. apiGroup := Router.Group("/api")
  19. router.InitRouter(apiGroup)
  20. router.CommonGroup(apiGroup)
  21. router.UserGroup(apiGroup)
  22. return Router
  23. }
  1. 在router文件夹下创建我们的api路由组,形如

基础路由:
image.png

公共路由:
image.png

用户路由:
image.png

统一规范响应数据方法封装

前端期望的接口响应数据如下:

  1. {
  2. code: 0, -- code码,根据不同的业务修改可自行定制,一般而言,0代表正常,400代表异常
  3. data: {...}, -- 响应具体数据
  4. msg: "", -- 消息提示
  5. }

gin封装如下:

这里参考了https://github.com/flipped-aurora/gin-vue-admin

  1. package response
  2. import (
  3. "net/http"
  4. "github.com/gin-gonic/gin"
  5. )
  6. type Response struct {
  7. Code int `json:"code"`
  8. Data interface{} `json:"data"`
  9. Msg string `json:"msg"`
  10. }
  11. const (
  12. ERROR = 400
  13. SUCCESS = 0
  14. )
  15. func Result(code int, data interface{}, msg string, c *gin.Context) {
  16. // 开始时间
  17. c.JSON(http.StatusOK, Response{
  18. code,
  19. data,
  20. msg,
  21. })
  22. }
  23. func Success(c *gin.Context) {
  24. Result(SUCCESS, map[string]interface{}{}, "success!", c)
  25. }
  26. func SuccessWithMessage(c *gin.Context, message string) {
  27. Result(SUCCESS, map[string]interface{}{}, message, c)
  28. }
  29. func SuccessWithData(c *gin.Context, data interface{}) {
  30. Result(SUCCESS, data, "success!", c)
  31. }
  32. func SuccessWithDetailed(c *gin.Context, data interface{}, message string) {
  33. Result(SUCCESS, data, message, c)
  34. }
  35. func Fail(c *gin.Context) {
  36. Result(ERROR, map[string]interface{}{}, "fail!", c)
  37. }
  38. func FailWithMessage(c *gin.Context, message string) {
  39. Result(ERROR, map[string]interface{}{}, message, c)
  40. }
  41. func FailWithDetailed(c *gin.Context, data interface{}, message string) {
  42. Result(ERROR, data, message, c)
  43. }

分页器封装

在分页器中通过页码、单页数据量、总量三个数据即可算出分页器所需要的所有数据,如

总页数: total / size 向上取整
是否还有下一页: total > page * size

封装内容如下:

  1. package pagination
  2. import "gorm.io/gorm"
  3. func Pagination(page *int, size *int) func(db *gorm.DB) *gorm.DB {
  4. return func(db *gorm.DB) *gorm.DB {
  5. if *page < 1 {
  6. *page = 1
  7. }
  8. switch {
  9. case *size > 100:
  10. *size = 100
  11. case *size < 1:
  12. *size = 10
  13. }
  14. return db.Limit(*size).Offset((*page * *size) - *size)
  15. }
  16. }
  17. func Total(db *gorm.DB) int64 {
  18. var total int64
  19. db.Limit(-1).Offset(-1).Count(&total)
  20. return total
  21. }

使用如下:

  1. type userListParamsType struct {
  2. Page int `form:"page" json:"page"` // 不传默认为1
  3. Size int `form:"size" json:"size"` // 不传默认为10
  4. }
  5. func UserList(c *gin.Context) {
  6. var params userListParamsType
  7. if paramsErr := c.ShouldBindQuery(&params); paramsErr != nil {
  8. response.FailWithMessage(c, paramsErr.Error())
  9. return
  10. }
  11. var list []model.User
  12. var total int64
  13. // 通过gorm.DB中的方法Scopes进行分页数据约束,在调用Scopes前,我们可以任意设置查询条件
  14. query := global.DB.Order("created_at desc").Scopes(pagination.Pagination(&params.Page, &params.Size)).Find(&list)
  15. if query.Error != nil {
  16. response.FailWithMessage(c, query.Error.Error())
  17. } else {
  18. // 通过DB查询实例获取分页总数
  19. total = pagination.Total(query)
  20. response.SuccessWithData(c, gin.H{
  21. "list": list,
  22. "total": total,
  23. "page": params.Page,
  24. "size": params.Size,
  25. })
  26. }
  27. }

连接redis

前置条件需要先安装redis,如使用了docker,可使用一下命令一键安装

  1. docker run -d -p:6379:6379 --name my_redis redis

使用流程:

  1. 初始化

    1. func Redis() *redis.Client {
    2. redisConfig := global.CONFIG.Redis
    3. if !redisConfig.Enable {
    4. return nil
    5. }
    6. rdb := redis.NewClient(&redis.Options{
    7. Addr: fmt.Sprintf("%s:%d", redisConfig.Host, redisConfig.Port),
    8. Password: redisConfig.Password,
    9. DB: redisConfig.DB,
    10. })
    11. if pong, err := rdb.Ping(context.Background()).Result(); err != nil {
    12. global.LOGGER.Error("redis connect ping failed, err:", zap.Error(err))
    13. return nil
    14. } else {
    15. fmt.Println("redis连接成功! pong:", pong)
    16. return rdb
    17. }
    18. }
  2. 使用 ```go func generateCode(length int) string { rand.Seed(time.Now().UnixNano()) var code string for i := 0; i < length; i++ {

    1. code += strconv.Itoa(rand.Intn(10))

    } return code }

func CreateMessageCode(key string) string { var code = generateCode(6) global.REDIS.Set(ctx, key, code, 60*time.Second) return code }

func VerifyMessageCode(key string, value string) bool { if target, err := global.REDIS.Get(ctx, key).Result(); err != nil { return false } else { return target == value } }

  1. <a name="jSZEA"></a>
  2. ## 使用captcha库实现登录图形验证码验证
  3. 通过github.com/mojocn/base64Captcha库很容易实现图形验证码功能,同时还支持语音、中文、数学计算、字符串、数字类型的验证方式,最终以base64格式生成图形验证码以及一个ID,用户需要把图形中正确的验证字符串以及ID提交给后端,后端通过这两个数据再进行校验。
  4. 使用流程。
  5. 1. 实现生成验证码方法
  6. ```go
  7. type ConfigType struct {
  8. Id string
  9. VerifyValue string
  10. // digit chinese math string audio
  11. CaptchaType string
  12. DriverAudio *base64Captcha.DriverAudio
  13. DriverString *base64Captcha.DriverString
  14. DriverChinese *base64Captcha.DriverChinese
  15. DriverMath *base64Captcha.DriverMath
  16. DriverDigit *base64Captcha.DriverDigit
  17. }
  18. var store = base64Captcha.DefaultMemStore
  19. func GenerateCaptcha(confg ConfigType) (id string, b64s string, err error) {
  20. var driver base64Captcha.Driver
  21. //create base64 encoding captcha
  22. switch confg.CaptchaType {
  23. case "digit":
  24. driver = confg.DriverDigit
  25. case "string":
  26. driver = confg.DriverString.ConvertFonts()
  27. case "math":
  28. driver = confg.DriverMath.ConvertFonts()
  29. case "chinese":
  30. driver = confg.DriverChinese.ConvertFonts()
  31. case "audio":
  32. driver = confg.DriverAudio
  33. default:
  34. driver = confg.DriverDigit
  35. }
  36. c := base64Captcha.NewCaptcha(driver, store)
  37. return c.Generate()
  38. }
  1. 创建获取验证码接口
  1. // 获取图片验证码接口
  2. func GetCaptcha(c *gin.Context) {
  3. captchaConfig := global.CONFIG.Captcha
  4. var config = utils.ConfigType{
  5. CaptchaType: "digit", // digit chinese math string audio
  6. DriverDigit: &base64Captcha.DriverDigit{
  7. Width: captchaConfig.ImgWidth,
  8. Height: captchaConfig.ImgHeight,
  9. Length: captchaConfig.Length,
  10. },
  11. }
  12. if id, b64s, err := utils.GenerateCaptcha(config); err != nil {
  13. response.FailWithMessage(c, err.Error())
  14. } else {
  15. response.SuccessWithData(c, gin.H{
  16. "id": id,
  17. "b64s": b64s,
  18. })
  19. }
  20. }
  1. 使用与校验
  1. func LoginByPassword(c *gin.Context) {
  2. var params loginParamsType
  3. if err := c.ShouldBindJSON(&params); err != nil {
  4. // response.FailWithMessage(c, err.Error())
  5. response.FailWithMessage(c, "请检查信息是否填写完整!")
  6. } else {
  7. // 校验验证码
  8. if !utils.VerifyCaptcha(utils.ConfigType{
  9. Id: params.CaptchaID,
  10. VerifyValue: params.CaptchaValue,
  11. }) {
  12. response.FailWithMessage(c, "请检查验证码是否正确!")
  13. } else {
  14. user := FindUser(params.Phone)
  15. if user.Password != utils.GeneratePassword(params.Password) {
  16. response.FailWithMessage(c, "请检查账号和密码是否填写正确!")
  17. } else {
  18. if token, tokenErr := middleware.GenerateToken(uint64(user.ID)); err != nil {
  19. response.FailWithMessage(c, tokenErr.Error())
  20. } else {
  21. response.SuccessWithDetailed(c, gin.H{
  22. "data": user,
  23. "token": token,
  24. }, "登录成功!")
  25. }
  26. }
  27. }
  28. }
  29. }
  30. // utils.VerifyCaptcha
  31. func VerifyCaptcha(params ConfigType) bool {
  32. return store.Verify(params.Id, params.VerifyValue, true)
  33. }

用户密码存储加密策略

为了提高用户账号安全性,密码肯定不能明文存储,我们可以通过md5加盐进行不可恢复的加密。

使用流程:

  1. 在使用md5加密之前,我们需要先设置盐用于加密,这个密钥尽可能设置复杂一点,可前往一下网站获取,获取到后,可在配置文件中进行配置。

  2. 实现加密方法封装

  1. func md5plus(str string, salt string, iteration int) string {
  2. b := []byte(str)
  3. s := []byte(salt)
  4. h := md5.New()
  5. h.Write(s)
  6. h.Write(b)
  7. var res []byte
  8. res = h.Sum(nil)
  9. for i := 0; i < iteration-1; i++ {
  10. h.Reset()
  11. h.Write(res)
  12. res = h.Sum(nil)
  13. }
  14. return hex.EncodeToString(res)
  15. }
  16. func GeneratePassword(target string) string {
  17. // 对密码进行撒盐以及3次加密
  18. return md5plus(target, global.CONFIG.System.Salt, 3)
  19. }
  1. 注册加密密码
  1. func Register(c *gin.Context) {
  2. var params registerParamsType
  3. if err := c.ShouldBindJSON(&params); err != nil {
  4. response.FailWithMessage(c, "请检查信息是否填写完整!"+err.Error())
  5. } else {
  6. var exist_user model.User
  7. rows := global.DB.Where("phone = ?", params.Phone).Find(&exist_user)
  8. if rows.RowsAffected > 0 {
  9. response.FailWithMessage(c, "当前用户已存在!")
  10. } else if len(params.Password) < 6 {
  11. response.FailWithMessage(c, "密码长度不少于6!")
  12. } else {
  13. // 验证码校验
  14. if utils.VerifyMessageCode(fmt.Sprintf("register-%s", params.Phone), params.Code) {
  15. var user = model.User{
  16. Phone: params.Phone,
  17. Password: utils.GeneratePassword(params.Password), // 看这里,关键点
  18. }
  19. global.DB.Create(&user)
  20. if global.DB.Error != nil {
  21. response.FailWithMessage(c, global.DB.Error.Error())
  22. } else {
  23. response.SuccessWithMessage(c, "注册成功!")
  24. }
  25. } else {
  26. response.FailWithMessage(c, "验证码错误!")
  27. }
  28. }
  29. }
  30. }
  1. 密码登录匹配密码
  1. func LoginByPassword(c *gin.Context) {
  2. var params loginParamsType
  3. if err := c.ShouldBindJSON(&params); err != nil {
  4. // response.FailWithMessage(c, err.Error())
  5. response.FailWithMessage(c, "请检查信息是否填写完整!")
  6. } else {
  7. // 校验验证码
  8. if !utils.VerifyCaptcha(utils.ConfigType{
  9. Id: params.CaptchaID,
  10. VerifyValue: params.CaptchaValue,
  11. }) {
  12. response.FailWithMessage(c, "请检查验证码是否正确!")
  13. } else {
  14. user := FindUser(params.Phone)
  15. if user.Password != utils.GeneratePassword(params.Password) { // 看这里, 关键点
  16. response.FailWithMessage(c, "请检查账号和密码是否填写正确!")
  17. } else {
  18. if token, tokenErr := middleware.GenerateToken(uint64(user.ID)); err != nil {
  19. response.FailWithMessage(c, tokenErr.Error())
  20. } else {
  21. response.SuccessWithDetailed(c, gin.H{
  22. "data": user,
  23. "token": token,
  24. }, "登录成功!")
  25. }
  26. }
  27. }
  28. }
  29. }

使用jwt库实现接口鉴权

文档参看:https://github.com/golang-jwt/jwt

简单说明,用户在登录成功的时候,后端通过用户的一些必要信息生成一个token下发给用户,用户将这个token存放在cookie或者localstorage中做持久化存储,在所有需要鉴权的接口中,通过在header中携带这个token信息,后端在接收到时,对其进行是否有效校验,从而完成用户身份认证。

使用流程。

  1. 我们需要生成一个Signingkey,用于加密解密使用,这个密钥尽可能设置复杂一点,可前往一下网站获取。

  2. 安装依赖,添加生成token方法

  1. type AuthClaim struct {
  2. UserId uint64 `json:"userId"`
  3. jwt.StandardClaims
  4. }
  5. var secret = []byte(global.CONFIG.JWT.SigningKey)
  6. const TokenExpireDuration = 24 * time.Hour // 设置过期实践
  7. func GenerateToken(userId uint64) (string, error) {
  8. c := AuthClaim{
  9. UserId: userId,
  10. StandardClaims: jwt.StandardClaims{
  11. ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
  12. Issuer: "ksir_go",
  13. },
  14. }
  15. token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
  16. // 结合设置的密钥,增强安全性
  17. return token.SignedString(secret)
  18. }
  1. 实现jwt中间件
  1. func ParseToken(tokenStr string) (*AuthClaim, error) {
  2. token, err := jwt.ParseWithClaims(tokenStr, &AuthClaim{}, func(tk *jwt.Token) (interface{}, error) {
  3. return secret, nil
  4. })
  5. if err != nil {
  6. return nil, err
  7. }
  8. if claim, ok := token.Claims.(*AuthClaim); ok && token.Valid {
  9. return claim, nil
  10. }
  11. return nil, errors.New("invalid token ")
  12. }
  13. func JWTAuthMiddleware() gin.HandlerFunc {
  14. return func(c *gin.Context) {
  15. token := c.Request.Header.Get("token")
  16. if token == "" {
  17. c.JSON(http.StatusForbidden, "empty token")
  18. c.Abort()
  19. return
  20. }
  21. claim, err := ParseToken(token)
  22. if err != nil {
  23. c.JSON(http.StatusForbidden, "Invalid token")
  24. c.Abort()
  25. return
  26. }
  27. c.Set("userId", claim.UserId)
  28. c.Next()
  29. }
  30. }
  1. 登录时生成并下发token
  1. func LoginByPassword(c *gin.Context) {
  2. var params loginParamsType
  3. if err := c.ShouldBindJSON(&params); err != nil {
  4. // response.FailWithMessage(c, err.Error())
  5. response.FailWithMessage(c, "请检查信息是否填写完整!")
  6. } else {
  7. // 校验验证码
  8. if !utils.VerifyCaptcha(utils.ConfigType{
  9. Id: params.CaptchaID,
  10. VerifyValue: params.CaptchaValue,
  11. }) {
  12. response.FailWithMessage(c, "请检查验证码是否正确!")
  13. } else {
  14. user := FindUser(params.Phone)
  15. if user.Password != utils.GeneratePassword(params.Password) {
  16. response.FailWithMessage(c, "请检查账号和密码是否填写正确!")
  17. } else {
  18. // 看这里, 关键点
  19. if token, tokenErr := middleware.GenerateToken(uint64(user.ID)); err != nil {
  20. response.FailWithMessage(c, tokenErr.Error())
  21. } else {
  22. response.SuccessWithDetailed(c, gin.H{
  23. "data": user,
  24. "token": token,
  25. }, "登录成功!")
  26. }
  27. }
  28. }
  29. }
  30. }
  1. 在需要鉴权的接口处,添加中间件
  1. func UserGroup(Router *gin.RouterGroup) {
  2. var userGroup = Router.Group("/user")
  3. userGroup.POST("/register", v1.Register)
  4. userGroup.POST("/loginByPassword", v1.LoginByPassword)
  5. userGroup.GET("/userList", v1.UserList)
  6. // 看这里, 关键点
  7. userGroup.GET("/userInfo", middleware.JWTAuthMiddleware(), v1.UserInfo)
  8. userGroup.POST("/avatarUpload", middleware.JWTAuthMiddleware(), v1.AvatarUpload)
  9. }
  1. 在接口中使用鉴权信息
  1. func UserInfo(c *gin.Context) {
  2. // 看这里, 关键点
  3. if userId, exists := c.Get("userId"); exists {
  4. var user model.User
  5. query := global.DB.Where("id = ?", userId).Find(&user)
  6. if query.Error != nil {
  7. response.FailWithMessage(c, query.Error.Error())
  8. } else {
  9. response.SuccessWithData(c, gin.H{
  10. "data": user.Phone,
  11. })
  12. }
  13. }
  14. }

使用minIO实现文件资源管理

市面上已经有很成熟的服务了,比如阿里云oss、腾讯云cos、七牛等等,如项目经费充足,使用这些服务很合适,如节省成本或者需要进行更高的可定制化操作,可以使用minIO自己实现文件资源管理。

文档参考:http://docs.minio.org.cn/docs/

使用流程:

  1. 安装minIO,可以按照官方文档进行安装,这里通过docker进行快速安装
  1. docker run -d -p 9090:9090 -p 9000:9000 --name my_minio -e "MINIO_ROOT_USER=admin" -e "MINIO_ROOT_PASSWORD=12345678" -v /Users/apple/workspace/docker_volumes/minio/data:/data -v /Users/apple/workspace/docker_volumes/minio/config:/root/.minio minio/minio server /data --console-address ":9000" --address ":9090"
  1. 在配置文件中配置minIO信息
  1. minio:
  2. enable: true
  3. endpoint: "127.0.0.1:9090"
  4. deploy-host: "127.0.0.1"
  5. deploy-port: 9090
  6. access-key-id: "admin"
  7. secret-access-key: "12345678"
  8. secure: false # useSSL
  1. minIO初始化
  1. func MinIO() *minio.Client {
  2. minioInfo := global.CONFIG.MinIO
  3. if !minioInfo.Enable {
  4. return nil
  5. }
  6. minioClient, err := minio.New(minioInfo.Endpoint, &minio.Options{
  7. Creds: credentials.NewStaticV4(minioInfo.AccessKeyID, minioInfo.SecretAccessKey, ""),
  8. Secure: minioInfo.Secure,
  9. })
  10. if err != nil {
  11. log.Fatalln("minio初始化失败:", err)
  12. return nil
  13. } else {
  14. return minioClient
  15. }
  16. }
  1. 创建存储桶
  1. // 在入口处调用初始化方法,成功以后创建存储桶
  2. global.MINIO = initialize.MinIO()
  3. if global.MINIO != nil {
  4. utils.InitBucket()
  5. }
  6. // utils.InitBucket
  7. func InitBucket() {
  8. // 创建头像存储桶
  9. CreateMinioBucket("avatar") // 头像存储桶
  10. global.MINIO.SetBucketPolicy(ctx, "avatar", policy.BucketPolicyReadWrite) // 设置存储桶为公开
  11. }
  1. 使用存储桶
  1. // 头像上传
  2. func AvatarUpload(c *gin.Context) {
  3. if userId, exists := c.Get("userId"); exists {
  4. var user model.User
  5. query := global.DB.Where("id = ?", userId).Find(&user)
  6. if query.Error != nil {
  7. response.FailWithMessage(c, "用户不存在!")
  8. } else {
  9. file, _ := c.FormFile("file")
  10. // 看这里, 关键点
  11. if imageUrl, _, err := utils.UploadFileAvatar(file); err != nil {
  12. response.FailWithMessage(c, err.Error())
  13. } else {
  14. user.Avatar = *imageUrl
  15. db := global.DB.Save(&user)
  16. if db.Error != nil {
  17. response.FailWithMessage(c, db.Error.Error())
  18. } else {
  19. response.SuccessWithData(c, gin.H{
  20. "data": user,
  21. })
  22. }
  23. }
  24. }
  25. }
  26. }
  27. // utils.UploadFileAvatar
  28. // 获取文件后缀名
  29. func GetFileType(filePath string) (filetype string, filename string) {
  30. fileNameWithSuffix := path.Base(filePath)
  31. fileType := path.Ext(fileNameWithSuffix)
  32. fileNameOnly := strings.TrimSuffix(fileNameWithSuffix, fileType)
  33. return fileType, fileNameOnly
  34. }
  35. func UploadFileAvatar(file *multipart.FileHeader) (*string, *minio.UploadInfo, error) {
  36. reader, readerErr := file.Open()
  37. if readerErr != nil {
  38. return nil, nil, readerErr
  39. }
  40. filetype, _ := GetFileType(file.Filename)
  41. // 使用uuid防止命名重复而造成覆盖
  42. generate_file_name := uuid.New().String() + filetype
  43. // 规则: http://[minio域名]/[存储桶]/[文件名]
  44. image_url := fmt.Sprintf("http://%s:%d/%s/%s", global.CONFIG.MinIO.DeployHost, global.CONFIG.MinIO.DeployPort, "avatar", generate_file_name)
  45. uploadInfo, err := global.MINIO.PutObject(ctx, "avatar", generate_file_name, reader, file.Size, minio.PutObjectOptions{ContentType: "application/octet-stream"})
  46. return &image_url, &uploadInfo, err
  47. }
  1. 为了构造的头像图片地址能够永久访问,我们需要前往minIO控制台,将avatar存储桶配置为公开。

我们在docker中配置的minio控制台端口为9000, 访问localhost:9000,登录配置的账号密码即可管理我们的minio了。

image.png

image.png

使用docker、docker-compose实现所有服务部署配置

文档参考:https://docs.docker.com/get-started/overview/

至此我们的项目使用了mysql、redis、minio、以及gin服务自身的打包,如果这些服务挨个去搭建、加上每个环境的配置信息,难免是一件相当繁琐的事情,因此我们在这里使用docker-compose容器编排我们的服务,使整个应用可以一键部署。

使用流程:

  1. gin服务构建镜像配置文件Dockerfile

值得注意的是,虽然我们使用容器编排让server在mysql之后启动,但其实际是在mysql running状态后启动,我们需要mysql处于ready状态后才启动,否则server就会报错连不上mysql,所以文件根目录下的wait-for-it-sh.sh就派上用场了,由于我们server服务用的是alpine系统镜像,该系统支持的是sh命令,如你的镜像支持的是bash命令,可使用wait-for-it-bash.sh脚本。

  1. FROM golang:alpine as builder
  2. WORKDIR /server
  3. COPY . .
  4. RUN go env -w GO111MODULE=on
  5. RUN go env -w GOPROXY=https://goproxy.cn,direct
  6. RUN go env -w CGO_ENABLED=0
  7. RUN go mod tidy
  8. RUN go build -o server .
  9. FROM alpine:latest
  10. LABEL MAINTAINER debugksir<402351681@qq.com>
  11. WORKDIR /server
  12. COPY --from=builder /server ./
  13. EXPOSE 8080
  14. ENV GIN_MODE=$GIN_MODE
  15. RUN ["chmod","+x","./wait-for-it-sh.sh"]
  16. # ENTRYPOINT ./server
  17. # 虽然加上了depends-on:mysql,但仅仅保证mysql处于running状态,我们需要在ready状态之后启动server,所以在这里加上了wait-for-it-sh.sh做处理
  18. ENTRYPOINT ["./wait-for-it-sh.sh", "172.1.0.12:3306", "--", "./server"]
  1. 使用docker-compose编排我们的服务

值得注意的是我们这里通过environment指令,将环境GIN_MODE传递给了server容器,而在server容器中,我们又将这个变量作为启动的环境。

  1. environment:
  2. GIN_MODE: $GIN_MODE # release or test or debug

docker-compose.yml:

  1. version: "3"
  2. networks:
  3. network:
  4. ipam:
  5. driver: default
  6. config:
  7. - subnet: '172.1.0.0/16'
  8. services:
  9. mysql:
  10. image: mysql
  11. container_name: ksir_go_mysql
  12. restart: always
  13. ports:
  14. - "3308:3306"
  15. environment:
  16. MYSQL_DATABASE: 'ksir_go'
  17. MYSQL_ROOT_PASSWORD: 'ksir_go_admin'
  18. volumes:
  19. - /home/mysql:/var/lib/mysql
  20. platform: linux/x86_64
  21. networks:
  22. network:
  23. ipv4_address: 172.1.0.12
  24. redis:
  25. image: redis
  26. container_name: ksir_go_redis
  27. restart: always
  28. ports:
  29. - '6380:6379'
  30. networks:
  31. network:
  32. ipv4_address: 172.1.0.13
  33. minio:
  34. image: minio/minio
  35. container_name: ksir_go_minio
  36. restart: always
  37. environment:
  38. MINIO_ROOT_USER: 'admin'
  39. MINIO_ROOT_PASSWORD: '12345678'
  40. volumes:
  41. - /home/minio_data:/data
  42. - /home/minio_config:/root/.minio
  43. command: server /data --console-address ":9000" --address ":9090"
  44. ports:
  45. - '9000:9000'
  46. - '9090:9090'
  47. networks:
  48. network:
  49. ipv4_address: 172.1.0.14
  50. server:
  51. build:
  52. context: .
  53. environment:
  54. GIN_MODE: $GIN_MODE # release or test or debug
  55. container_name: ksir_go_server
  56. restart: always
  57. ports:
  58. - '8082:8080'
  59. depends_on:
  60. - mysql
  61. - redis
  62. - minio
  63. volumes:
  64. - /home/ksir_go/logs:/home/logs
  65. networks:
  66. network:
  67. ipv4_address: 172.1.0.11

至此我们的docker就配置好了,根据我们的使用环境来启动项目即可。

  1. # 依赖docker环境,先确保安装docker、docker-compose
  2. # 测试环境(使用的是config/config.test.yml配置)
  3. export GIN_MODE=test && docker-compose up -d --build
  4. # 生产环境(使用的是config/config.release.yml配置)
  5. export GIN_MODE=release && docker-compose up -d --build

部署

前置条件:

  • 服务器
  • 安装好了docker及docker-compose
  1. 克隆代码
  1. git clone https://gitee.com/debugksir/ksir_go
  1. 一键部署(以测试环境为例)
  1. export GIN_MODE=test && docker-compose up -d --build
  1. 使用docker ps查看启动的容器状态。

  2. 开放启动的端口包括gin服务启动端口、minio控制台以及接口端口(如需远程访问mysql、redis可以一并开放)

  3. 浏览器访问验证。

项目实现了三个页面供测试

  • /public/register.html
  • /public/login.html
  • /public/userInfo.html

image.png

login.gif

QQ20220301-152810-HD.gif