用golang搭建springboot风格项目结构 gin+gorm

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 Tair(兼容Redis),内存型 2GB
简介: 最近学了学go语言,想练习一下用go开发web项目,项目结构弄个什么样呢。

最近学了学go语言,想练习一下用go开发web项目,项目结构弄个什么样呢。


去码云上面找了找,找到一个用Go语言搭建的springboot风格的web项目,拿来按自己的习惯改了改,还不错。


文末git地址


先来看一下整体的项目结构



可以看到业务的三层结构和缓存、日志、token、全局异常等。以及一个javaer们最熟悉的application配置文件……



下面说一下整体逻辑


首先肯定是先来搭建一个gomod的go项目,在gomod中引入一些依赖


module go_web_test
go 1.17
require (
  github.com/allegro/bigcache v1.2.1
  github.com/gin-contrib/cors v1.3.1
  github.com/gin-gonic/gin v1.7.7
  github.com/satori/go.uuid v1.2.0
  github.com/sirupsen/logrus v1.8.1
  github.com/spf13/viper v1.9.0
  github.com/stretchr/testify v1.7.0
  gorm.io/driver/mysql v1.2.1
  gorm.io/gorm v1.22.4
)
require (
  github.com/davecgh/go-spew v1.1.1 // indirect
  github.com/fsnotify/fsnotify v1.5.1 // indirect
  github.com/gin-contrib/sse v0.1.0 // indirect
  github.com/go-playground/locales v0.13.0 // indirect
  github.com/go-playground/universal-translator v0.17.0 // indirect
  github.com/go-playground/validator/v10 v10.4.1 // indirect
  github.com/go-sql-driver/mysql v1.6.0 // indirect
  github.com/golang/protobuf v1.5.2 // indirect
  github.com/hashicorp/hcl v1.0.0 // indirect
  github.com/jinzhu/inflection v1.0.0 // indirect
  github.com/jinzhu/now v1.1.3 // indirect
  github.com/json-iterator/go v1.1.11 // indirect
  github.com/leodido/go-urn v1.2.0 // indirect
  github.com/magiconair/properties v1.8.5 // indirect
  github.com/mattn/go-isatty v0.0.12 // indirect
  github.com/mitchellh/mapstructure v1.4.2 // indirect
  github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
  github.com/modern-go/reflect2 v1.0.1 // indirect
  github.com/pelletier/go-toml v1.9.4 // indirect
  github.com/pmezard/go-difflib v1.0.0 // indirect
  github.com/spf13/afero v1.6.0 // indirect
  github.com/spf13/cast v1.4.1 // indirect
  github.com/spf13/jwalterweatherman v1.1.0 // indirect
  github.com/spf13/pflag v1.0.5 // indirect
  github.com/subosito/gotenv v1.2.0 // indirect
  github.com/ugorji/go/codec v1.1.7 // indirect
  golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
  golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf // indirect
  golang.org/x/text v0.3.6 // indirect
  google.golang.org/protobuf v1.27.1 // indirect
  gopkg.in/ini.v1 v1.63.2 // indirect
  gopkg.in/yaml.v2 v2.4.0 // indirect
  gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)


application配置文件里面自定义一些配置内容


server:
  appName: go_web_test
  port: 8888
db:
  dsn: "root:VHUKZE./start@(192.168.1.8:3306)/vhukze?charset=utf8mb4&parseTime=True&loc=Local"
  maxIdleConns: 200
  maxOpenConns: 1000
  connMaxLifetime: 60

然后看启动类,main.go


main方法启动项目,main方法中调用初始化组件的方法,把要用的组件都初始化完成。


下面还有一个自动生成表的方法,可以根据你struct定义的结构来自动生成表结构,就是没每加一个struct就要在这个方法里面加一行生成那个表的代码,在每次启动的时候也是会根据struct的字段来更新表结构的

package main
import (
  "fmt"
  logger "github.com/sirupsen/logrus"
  "go_web_test/biz/controller"
  "go_web_test/biz/dao"
  "go_web_test/config/cache"
  "go_web_test/config/db"
  "go_web_test/config/gin"
  "go_web_test/config/http"
  "go_web_test/config/log"
  "go_web_test/config/token"
  vc "go_web_test/config/viper"
  _ "net/url"
)
func main() {
  initComponents()
}
// 初始化服务所有组件
func initComponents() {
  // 初始化日志
  log.InitLogConfig()
  logger.Info("===================================================================================")
  logger.Info("Starting Application")
  // 读取本地配置文件
  vc.InitLocalConfigFile()
  // 初始化url配置
  //url.InitUrlConfig()
  // 初始化Mysql
  db.InitDbConfig()
  // 自动生成表
  autoMigrate()
  // 初始化缓存
  cache.InitBigCacheConfig()
  // 初始化Redis
  //redis.InitRedisConfig()
  // 初始化HttpClient连接池
  //http.InitHttpClientConfig()
  // 初始化token
  token.InitTokenConfig()
  // 初始化Gin
  router := gin.InitGinConfig()
  // 注册Api
  // 用户api
  controller.UserApi(router)
  // 启动Gin
  gin.RunGin(router)
}
// 自动生成表
func autoMigrate() {
  err := db.DB.AutoMigrate(dao.User{})
  if err != nil {
  _ = fmt.Errorf("自动生成user表失败")
  panic(err)
  }
}


下面来说一下初始化组件中的每个组件内容


初始化日志配置,就是配一下输出日志的格式和输入到文件什么的,下面那个LoggerAccess方法是定义了一个gin的中间件,用来输出请求的日志信息,其实格式跟gin默认的日志输出格式差不多


package log
import (
  "fmt"
  "github.com/gin-gonic/gin"
  log "github.com/sirupsen/logrus"
  "go_web_test/config/log/lumberjack"
  "go_web_test/utils"
  "io"
  "os"
  "path"
  "time"
)
func InitLogConfig() {
  // 设置日志输出路径和名称
  logFilePath := path.Join("../log/", "go_web_test.log")
  // 日志输出滚动设置
  fileOut := &lumberjack.Logger{
  Filename:   logFilePath, // 日志文件位置
  MaxSize:    100,         // 单文件最大容量,单位是MB
  MaxBackups: 500,         // 最大保留过期文件个数
  MaxAge:     15,          // 保留过期文件的最大时间间隔,单位是天
  LocalTime:  true,        // 启用当地时区计时
  }
  // 文件和控制台日志输出
  writers := []io.Writer{
  fileOut,
  os.Stdout,
  }
  fileAndStdoutWriter := io.MultiWriter(writers...)
  log.SetOutput(fileAndStdoutWriter)
  // 设置日志格式为Text格式
  log.SetFormatter(&log.TextFormatter{
  DisableColors:   false,
  FullTimestamp:   true,
  TimestampFormat: "2006-01-02 15:04:05",
  })
  // 设置日志级别为Info以上
  log.SetLevel(log.InfoLevel)
}
// LoggerAccess 入口日志打印
func LoggerAccess(c *gin.Context) {
  // 开始时间
  startTime := time.Now()
  // 处理请求
  c.Next()
  // 请求方式
  reqMethod := c.Request.Method
  // 请求路由
  reqUri := c.Request.RequestURI
  // 状态码
  statusCode := c.Writer.Status()
  // 服务器IP
  serverIP := utils.GetLocalIP()
  // 客户端IP
  clientIP := c.ClientIP()
  // 结束时间
  endTime := time.Now()
  // 执行时间
  latencyTime := fmt.Sprintf("%6v", endTime.Sub(startTime))
  //日志格式
  log.WithFields(log.Fields{
  "server-ip": serverIP,
  "duration":  latencyTime,
  "status":    statusCode,
  "method":    reqMethod,
  "uri":       reqUri,
  "client-ip": clientIP,
  }).Info("Api accessing")
}


读取本地配置文件,就是读取我们的application.yaml配置文件的内容,这里用的是viper这个工具,这里读取完之后,在其他代码里面就可以直接用viper来获取配置文件的内容了


package viper
import (
  "fmt"
  log "github.com/sirupsen/logrus"
  "github.com/spf13/viper"
)
const fileName = "application"
// InitLocalConfigFile 加载本地配置文件
func InitLocalConfigFile() {
  log.Info("初始化本地配置文件……")
  viper.SetConfigName(fileName)
  viper.SetConfigType("yaml")
  viper.AddConfigPath("./")
  err := viper.ReadInConfig()
  if err != nil {
  panic(fmt.Errorf("读取配置文件失败: %s \n", err))
  }
  log.Info("本地配置文件初始化完成……")
}

初始化mysql,根据配置文件中的URL来连接数据库


package db
import (
  "fmt"
  log "github.com/sirupsen/logrus"
  "github.com/spf13/viper"
  "gorm.io/driver/mysql"
  "gorm.io/gorm"
  "gorm.io/gorm/schema"
  "time"
)
var DB *gorm.DB
// InitDbConfig 初始化Db
func InitDbConfig() {
  log.Info("初始化数据库 Mysql")
  var err error
  dsn := viper.GetString("db.dsn")
  maxIdleConns := viper.GetInt("db.maxIdleConns")
  maxOpenConns := viper.GetInt("db.maxOpenConns")
  connMaxLifetime := viper.GetInt("db.connMaxLifetime")
  if DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
  QueryFields: true,
  NamingStrategy: schema.NamingStrategy{
    TablePrefix:   "",   // 表名前缀
    SingularTable: true, // 使用单数表名
  },
  }); err != nil {
  panic(fmt.Errorf("初始化数据库失败: %s \n", err))
  }
  sqlDB, err := DB.DB()
  if sqlDB != nil {
  sqlDB.SetMaxIdleConns(maxIdleConns)                                    // 空闲连接数
  sqlDB.SetMaxOpenConns(maxOpenConns)                                    // 最大连接数
  sqlDB.SetConnMaxLifetime(time.Second * time.Duration(connMaxLifetime)) // 单位:秒
  }
  log.Info("Mysql: 数据库初始化完成")
}


初始化缓存,没有redis的时候可以用这个,我这里没有弄redis,先把redis的内容注释了。


package cache
import (
  "fmt"
  "github.com/allegro/bigcache"
  log "github.com/sirupsen/logrus"
  "go_web_test/utils"
  "math"
  "time"
)
var BigCache *Cache
// Cache 缓存
type Cache struct {
  BigCache *bigcache.BigCache // 本地缓存
}
// Get 根据key从缓存中获取对象
func (c Cache) Get(key string) (value interface{}, err error) {
  valueBytes, err := c.BigCache.Get(key)
  if err != nil {
  return nil, err
  }
  value = utils.Deserialize(valueBytes)
  return value, nil
}
// Set 根据key,value将目标对象存入缓存中
func (c Cache) Set(key string, value interface{}) {
  valueBytes := utils.Serialize(value)
  err := c.BigCache.Set(key, valueBytes)
  if err != nil {
  panic(err)
  }
}

// InitBigCacheConfig 初始化BigCache

func InitBigCacheConfig() {

log.Info("初始化缓存…… BigCache")

config := bigcache.Config{

 Shards:           1024,                      // 存储的条目数量,值必须是2的幂

 LifeWindow:       math.MaxInt16 * time.Hour, // 超时后条目被处理

 CleanWindow:      2 * time.Minute,           // 处理超时条目的时间范围

 MaxEntrySize:     500,                       // 条目最大尺寸,以字节为单位

 HardMaxCacheSize: 0,                         // 设置缓存最大值,以MB为单位,超过了不在分配内存。0表示无限制分配

}
  bigCache, err := bigcache.NewBigCache(config)
  if err != nil {
  panic(fmt.Errorf("初始化BigCache: %s \n", err))
  }
  BigCache = &Cache{
  BigCache: bigCache,
  }
  log.Info("BigCache: 初始化完成")
}


初始化token配置,就是token验证的配置,可以配置需要忽略的请求路径

package token
var TokenCfg *TokenConfig // token配置
type TokenConfig struct {
  IgnorePaths []string
}
// AddIgnorePath 增加token不校验路径
func (config *TokenConfig) AddIgnorePath(ignorePath string) *TokenConfig {
  config.IgnorePaths = append(config.IgnorePaths, ignorePath)
  return config
}
// TokenIgnorePath token不校验路径集
func (config *TokenConfig) TokenIgnorePath() {
  config.AddIgnorePath("/token/*").
  AddIgnorePath("/ping").AddIgnorePath("/user/*")
}
// InitTokenConfig 初始化token配置
func InitTokenConfig() {
  TokenCfg = &TokenConfig{
  IgnorePaths: make([]string, 0),
  }
  TokenCfg.TokenIgnorePath()
}


初始化gin,拿到router。可以看到这里拿到router之后use了四个中间件,日志中间件、全局异常处理中间件,token验证中间件,跨域处理中间件。跨域中间件是用的gin相关库里面的,token验证中间件在后面。然后在RunGin方法中用指定的端口启动了gin


package gin
import (
  "fmt"
  "github.com/gin-contrib/cors"
  "github.com/gin-gonic/gin"
  logger "github.com/sirupsen/logrus"
  "github.com/spf13/viper"
  "go_web_test/config/log"
  "go_web_test/config/token"
  err "go_web_test/exception"
)
// InitGinConfig 初始化Gin
func InitGinConfig() *gin.Engine {
  logger.Info("初始化 gin……")
  gin.SetMode(gin.ReleaseMode)
  router := gin.Default()
  // 入口日志打印
  router.Use(log.LoggerAccess)
  // 统一异常处理
  router.Use(err.ErrHandle)
  // 跨域处理
  router.Use(cors.Default())
  // token校验
  router.Use(token.TokenVerify)
  // 健康检测
  router.GET("/ping", func(c *gin.Context) {
  c.JSON(200, gin.H{
    "message": "pong",
  })
  })
  logger.Info("Gin: 初始化完成……")
  return router
}
// RunGin 启动Gin
func RunGin(router *gin.Engine) {
  port := viper.GetString("server.port")
  logger.Info(fmt.Sprintf("Service started on port(s): %s", port))
  _ = router.Run(":" + port)
}


token验证的中间件,先判断一下当前请求路径需不需要验证,验证失败就抛出一个token验证失败的异常,这个时候全局异常处理里面就可以捕获到处理并返回错误信息


package token
import (
  "github.com/gin-gonic/gin"
  "go_web_test/biz/dto"
  "go_web_test/config/cache"
  "strings"
)
func TokenVerify(c *gin.Context) {
  request := c.Request
  // 过滤不用token校验的url
  if noTokenVerify(TokenCfg.IgnorePaths, request.RequestURI) {
  return
  }
  // 获取token
  tokenStr := request.Header.Get("token")
  if len(tokenStr) == 0 {
  panic(NewTokenError(dto.Unauthorized, dto.GetResultMsg(dto.Unauthorized)))
  }
  if _, err := cache.BigCache.Get(tokenStr); err != nil {
  panic(NewTokenError(dto.Unauthorized, dto.GetResultMsg(dto.Unauthorized)))
  }
  c.Next()
}
// noTokenVerify 判断url是否不需要token校验
func noTokenVerify(ignorePaths []string, path string) bool {
  // 查询缓存
  if noVerify, err := cache.BigCache.Get(path); err == nil {
  return noVerify.(bool)
  }
  // 匹配url
  for _, ignorePath := range ignorePaths {
  // 路径尾通配符*过滤
  if strings.LastIndex(ignorePath, "*") == len(ignorePath)-1 {
    ignorePath = strings.Split(ignorePath, "*")[0]
    if endIndex := strings.LastIndex(path, "/"); strings.Compare(path[0:endIndex+1], ignorePath) == 0 {
    // 添加缓存
    cache.BigCache.Set(path, true)
    return true
    }
    // 无通配符*过滤
  } else if strings.Compare(path, ignorePath) == 0 {
    // 添加缓存
    cache.BigCache.Set(path, true)
    return true
  }
  }
  return false
}

全局异常


package exception
import (
  "github.com/gin-gonic/gin"
  log "github.com/sirupsen/logrus"
  "go_web_test/biz/dto"
  "go_web_test/config/token"
  "net/http"
  "runtime/debug"
)
// ErrHandle 统一异常处理
func ErrHandle(c *gin.Context) {
  defer func() {
  if r := recover(); r != nil {
    apiErr, isApiErr := r.(*ApiError)
    tokenErr, isTokenErr := r.(*token.TokenError)
    if isApiErr {
    // 打印错误堆栈信息
    log.WithField("ErrMsg", apiErr.Error()).Error("PanicHandler handled apiError: ")
    // 封装通用json返回
    c.JSON(http.StatusInternalServerError, apiErr)
    } else if isTokenErr {
    // 打印错误堆栈信息
    log.WithField("ErrMsg", tokenErr.Error()).Error("PanicHandler handled tokenError: ")
    // 封装通用json返回
    c.JSON(http.StatusUnauthorized, tokenErr)
    } else {
    // 打印错误堆栈信息
    err := r.(error)
    log.WithField("ErrMsg", err.Error()).Error("PanicHandler handled ordinaryError: ")
    debug.PrintStack()
    // 封装通用json返回
    c.JSON(http.StatusInternalServerError, NewApiError(dto.InternalServerError, dto.GetResultMsg(dto.InternalServerError)))
    }
    c.Abort()
  }
  }()
  c.Next()
}


最后就是注册api了,每写一个controller就要在这里注册一下,然后启动gin。


说了一堆配置相关的,来看一下业务的三层结构吧


controller层


使用struct定义一个UserHandler,在注册api的方法中给service赋值,这里可以看到service也是有一个接口和实现类的


package controller
import (
  "github.com/gin-gonic/gin"
  "go_web_test/biz/dto"
  "go_web_test/biz/service"
  "net/http"
  "strconv"
)
type UserHandler struct {
  userService service.UserService
}
func UserApi(router *gin.Engine) {
  userHandler := UserHandler{
  userService: &service.UserServiceImpl{},
  }
  userGroup := router.Group("user/")
  {
  userGroup.GET("/:id", userHandler.user)
  }
}
// 根据ID查询用户
func (userHandler UserHandler) user(c *gin.Context) {
  userIdStr := c.Param("id")
  userId, _ := strconv.Atoi(userIdStr)
  user := userHandler.userService.User(userId)
  c.JSON(http.StatusOK, dto.Ok(user))
}


service层


先定义一个接口,然后一个实现,go语言中的实现没有关键字,遵循duck-typeing的原则,只要像,那就是它的实现。在idea中是会有左边的跳转上下箭头的。同样在service里面也有一个dao层的引用


package service
import "go_web_test/biz/dao"
type UserService interface {
  User(userId int) *dao.User
}
type UserServiceImpl struct {
}
func (UserServiceImpl) User(userId int) *dao.User {
  user := &dao.User{}
  user.SelectById(userId)
  return user
}


dao层


这个里面有对应数据库表的结构以及这个结构所属的方法

package dao
import "go_web_test/config/db"
type User struct {
  Id   int    `json:"id" gorm:"primary_key"`
  Name string `json:"name" gorm:"size:50"`
}
func (user *User) SelectById(userId int) {
  db.DB.First(&user, userId)
}

gitee地址:

https://gitee.com/vhukze/go_web_test.git

https://gitee.com/vhukze/go_web_test.git


有一点要注意的是,这里在设计的时候要避免循环依赖问题,毕竟没有spring来帮我们解决循环依赖了。其实循环依赖这种问题本来就是要在设计上避免,而不是代码中去解决它吧


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore     ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库 ECS 实例和一台目标数据库 RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
NoSQL 前端开发 Java
基于springboot的学生成绩在线管理系统(部署文档+数据库表结构文档)。Javaee项目,springboot项目.
基于springboot的学生成绩在线管理系统(部署文档+数据库表结构文档)。Javaee项目,springboot项目.
|
3月前
|
Go 数据库
golang编程语言操作GORM快速上手篇
使用Go语言的GORM库进行数据库操作的教程,涵盖了GORM的基本概念、基本使用、关联查询以及多对多关系处理等内容。
48 1
|
4月前
|
关系型数据库 API Go
[golang]在Gin框架中使用JWT鉴权
[golang]在Gin框架中使用JWT鉴权
110 0
|
4月前
|
网络协议 Go
[golang]gin框架接收websocket通信
[golang]gin框架接收websocket通信
114 0
|
7月前
|
关系型数据库 MySQL Go
golang使用gorm操作mysql1
golang使用gorm操作mysql1
golang使用gorm操作mysql1
|
7月前
|
Go
golang使用gorm操作mysql3,数据查询
golang使用gorm操作mysql3,数据查询
|
7月前
|
JSON 前端开发 Java
golang使用gorm操作mysql2
golang使用gorm操作mysql2
|
7月前
|
前端开发 Java 应用服务中间件
初始SpringBoot:详解特性和结构
初始SpringBoot:详解特性和结构
|
7月前
|
XML JSON 人工智能
探索Gin框架:Golang Gin框架请求参数的获取
探索Gin框架:Golang Gin框架请求参数的获取
|
7月前
|
存储 中间件 Go
探索Gin框架:快速构建高性能的Golang Web应用
探索Gin框架:快速构建高性能的Golang Web应用