最近学了学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.githttps://gitee.com/vhukze/go_web_test.git
有一点要注意的是,这里在设计的时候要避免循环依赖问题,毕竟没有spring来帮我们解决循环依赖了。其实循环依赖这种问题本来就是要在设计上避免,而不是代码中去解决它吧