项目git库代码地址
https://gitee.com/debugksir/ksir_go
实现功能
- 项目结构最佳实践 ✅
- 项目配置:viper ✅
- 开发、测试、生产环境区分 ✅
- 日志:zap ✅
- gorm mysql: 关系型数据库 ✅
- 路由以及分组 ✅
- response 统一返回规范封装 ✅
- pagination 分页器封装 ✅
- redis 连接 ✅
- 用户登录系统 ✅
- 密码存储加密 ✅
- 验证码 captcha ✅
- JWT ✅
- 常用中间件(跨域、日志、JWT)✅
- 资源文件管理 minio ✅
- docker配置、CI/CD ✅
项目目录结构规划
前置条件:
go环境搭建传送门
gin项目初始化传送门
项目目录及相应说明如下:
.├── Dockerfile -- server 服务镜像构建配置├── README.md -- 备注说明├── config -- 配置目录│ ├── config.debug.yml -- 开发环境配置│ ├── config.go -- 配置数据结构│ ├── config.release.yml -- 生产环境│ └── config.test.yml -- 测试环境├── controller -- 接口逻辑目录│ ├── tools│ │ ├── pagination -- 分页器│ │ │ └── pagination.go│ │ └── response -- 响应数据封装│ │ └── response.go│ └── v1│ ├── common.go│ ├── entry.go│ └── user.go├── docker-compose.yml├── global -- 全局变量│ └── global.go├── go.mod├── go.sum├── initialize -- 初始化│ ├── db.go -- 数据库初始化│ ├── logger.go -- 日志初始化│ ├── minio.go -- minio文件资源管理初始化│ ├── redis.go -- redis 初始化│ ├── routers.go -- gin服务及路由初始化│ └── viper.go -- 配置加载初始化├── main.go -- 项目入口文件├── middleware -- 中间件│ ├── cross.go -- 跨域│ ├── jwt.go -- 鉴权验证│ └── logger.go -- 日志记录├── model -- 数据库表模型│ └── user.go├── public -- web托管目录│ ├── login.html│ ├── register.html│ └── userInfo.html├── router -- 路由目录│ ├── common.go│ ├── entry.go│ └── user.go├── tmp -- gin项目构建临时目录(自动生成的)│ └── runner-build├── utils -- 工具│ ├── captcha.go│ ├── minio.go│ ├── redisMassageCode.go│ └── utils.go├── wait-for-it-bash.sh -- docker server服务异步bash环境下的启动脚本└── wait-for-it-sh.sh -- docker server服务异步sh环境下的启动脚本

常用依赖安装
- 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
核心逻辑:
package initializeimport ("fmt""ksirGo/global""github.com/fsnotify/fsnotify""github.com/gin-gonic/gin""github.com/spf13/viper")func Viper() *viper.Viper {v := viper.New()mode := gin.Mode() // debug test release// 根据gin启动环境来加载不同的配置文件,启动环境可以通过(export GIN_MODE=release/test/debug)来指定v.SetConfigFile("config/config." + mode + ".yml")v.SetConfigType("yaml")err := v.ReadInConfig()if err != nil {panic(fmt.Errorf("配置文件错误: %s", err))}v.WatchConfig()v.OnConfigChange(func(e fsnotify.Event) {fmt.Println("config file changed:", e.Name)if err := v.Unmarshal(&global.CONFIG); err != nil {fmt.Println(err)}})// 将配置文件中的数据映射到我们的全局变量CONFIG中,关于CONFIG以及yaml文件数据如何定义请参考git库代码if err := v.Unmarshal(&global.CONFIG); err != nil {fmt.Println(err)}// fmt.Println("配置内容:", global.CONFIG)return v}
开发、测试、生产环境区分
gin为我们提供了GIN_MODE环境变量,我们可以通过该特性来构建各个环境,开发、测试生产具体用法如下
# 1. 开发环境fresh # 热更新,默认使用的是export GIN_MODE=debug环境# 同export GIN_MODE=debug && fresh# 2. 测试环境go build -o server .export GIN_MODE=test && ./server# 3. 生产环境go build -o server .export GIN_MODE=release && ./server# 在运行时我们就可以通过mode := gin.Mode() 获取相应的环境了
使用zap库实现日志记录
初始化
func Logger() *zap.Logger {zapConfig := zap.NewDevelopmentConfig()zapConfig.OutputPaths = []string{// 读取配置中的日志目录,根据日期进行归档fmt.Sprintf("%s/log_%s.log", global.CONFIG.Zap.LogPath, utils.GetNowDateStr()),"stdout",}logger, _ := zapConfig.Build()// zap.ReplaceGlobals(logger)return logger}// utils.GetNowDateStr 如下func GetNowDateStr() string {now := time.Now()dateStr := fmt.Sprintf("%02d-%02d-%02d", now.Year(), int(now.Month()), now.Day())return dateStr}
在中间件中使用
// 记录Bad请求日志func RequestLogger() gin.HandlerFunc {return func(c *gin.Context) {start := time.Now()// 请求路径path := c.Request.URL.Path// 请求参数query := c.Request.URL.RawQueryc.Next()cost := time.Since(start)// 若response的状态码不是200为异常if c.Writer.Status() != 200 {// 记录异常信息global.LOGGER.Info(path,zap.Int("status", c.Writer.Status()),zap.String("method", c.Request.Method),zap.String("path", path),zap.String("query", query),zap.String("ip", c.ClientIP()),zap.String("user-agent", c.Request.UserAgent()),zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),zap.Duration("cost", cost),)}}}// 记录panic日志func RecoveryLogger(stack bool) gin.HandlerFunc {return func(c *gin.Context) {defer func() {if err := recover(); err != nil {var brokenPipe boolif ne, ok := err.(*net.OpError); ok {if se, ok := ne.Err.(*os.SyscallError); ok {if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {brokenPipe = true}}}httpRequest, _ := httputil.DumpRequest(c.Request, false)if brokenPipe {global.LOGGER.Error(c.Request.URL.Path,zap.Any("error", err),zap.String("request", string(httpRequest)),)// If the connection is dead, we can't write a status to it.c.Error(err.(error)) // nolint: errcheckc.Abort()return}if stack {global.LOGGER.Error("[Recovery from panic]",zap.Any("error", err),zap.String("request", string(httpRequest)),zap.String("stack", string(debug.Stack())),)} else {global.LOGGER.Error("[Recovery from panic]",zap.Any("error", err),zap.String("request", string(httpRequest)),)}c.AbortWithStatus(http.StatusInternalServerError)}}()c.Next()}}
最终日志文件形如:
关系型数据库gorm的使用
文档参考:https://gorm.io/zh_CN/docs/index.html
前置条件需要先安装mysql,如使用了docker,可使用以下命令一键安装
docker run -d -p:3306:3306 --name my_mysql -e MYSQL_ROOT_PASSWORD=123456 mysql
使用非常简单
- 连接MySQL
func Mysql() *gorm.DB {m := global.CONFIG.Mysqlif m.DBName == "" {return nil}// fmt.Println("mysql配置:", m.Dsn())mysqlConfig := mysql.Config{DSN: m.Dsn(), // DSN data source nameDefaultStringSize: 191, // string 类型字段的默认长度SkipInitializeWithVersion: false, // 根据版本自动配置}if db, err := gorm.Open(mysql.New(mysqlConfig), &gorm.Config{}); err != nil {fmt.Println("mysql连接失败:", err.Error())return nil} else {sqlDB, _ := db.DB()sqlDB.SetMaxIdleConns(m.MaxIdleConns)sqlDB.SetMaxOpenConns(m.MaxOpenConns)Generator(db)fmt.Println("mysql连接成功!")return db}}
- 创建表模型
package modelimport "gorm.io/gorm"type User struct {gorm.ModelPhone string `json:"phone" form:"phone" binding:"required" gorm:"comment:'手机号'"`Password string `json:"password" form:"password" binding:"required" gorm:"comment:'密码'"`Nickname string `json:"nickname" form:"nickname" gorm:"comment:'昵称'"`Avatar string `json:"avatar" form:"avatar" gorm:"size:512;comment:'头像'"`Gender *int `json:"gender" form:"gender" gorm:"comment:'性别1:男;2:女'"`Age int `json:"age" form:"age" gorm:"comment:'年龄'"`Name string `json:"name" form:"name" gorm:"comment:'姓名'"`Email string `json:"email" form:"email" gorm:"comment:'邮箱'"`}
- 迁移到数据库
// 生成数据库表func Generator(db *gorm.DB) {db.AutoMigrate(&model.User{})}
Api路由及分组
- 在initialize/routers.go中完成gin实例的创建以及router的初始化
func Routers() *gin.Engine {// gin.SetMode(gin.ReleaseMode) # 环境断言Router := gin.Default()Static := global.CONFIG.StaticStaticFS := global.CONFIG.StaticFSif Static.RelativePath != "" && Static.Root != "" {Router.Static(Static.RelativePath, Static.Root) // 静态网站}if StaticFS.RelativePath != "" && StaticFS.FS != "" {Router.StaticFS(StaticFS.RelativePath, http.Dir(StaticFS.FS)) // 虚拟文件系统}Router.MaxMultipartMemory = global.CONFIG.System.MaxMultipartMemory // 文件上传最大尺寸Router.Use(// middleware.Cross(), // 跨域middleware.RequestLogger(), // 错误请求日志middleware.RecoveryLogger(true), // 宕机日志) // 添加日志记录中间件apiGroup := Router.Group("/api")router.InitRouter(apiGroup)router.CommonGroup(apiGroup)router.UserGroup(apiGroup)return Router}
- 在router文件夹下创建我们的api路由组,形如
基础路由:
公共路由:
用户路由:
统一规范响应数据方法封装
前端期望的接口响应数据如下:
{code: 0, -- code码,根据不同的业务修改可自行定制,一般而言,0代表正常,400代表异常data: {...}, -- 响应具体数据msg: "", -- 消息提示}
gin封装如下:
package responseimport ("net/http""github.com/gin-gonic/gin")type Response struct {Code int `json:"code"`Data interface{} `json:"data"`Msg string `json:"msg"`}const (ERROR = 400SUCCESS = 0)func Result(code int, data interface{}, msg string, c *gin.Context) {// 开始时间c.JSON(http.StatusOK, Response{code,data,msg,})}func Success(c *gin.Context) {Result(SUCCESS, map[string]interface{}{}, "success!", c)}func SuccessWithMessage(c *gin.Context, message string) {Result(SUCCESS, map[string]interface{}{}, message, c)}func SuccessWithData(c *gin.Context, data interface{}) {Result(SUCCESS, data, "success!", c)}func SuccessWithDetailed(c *gin.Context, data interface{}, message string) {Result(SUCCESS, data, message, c)}func Fail(c *gin.Context) {Result(ERROR, map[string]interface{}{}, "fail!", c)}func FailWithMessage(c *gin.Context, message string) {Result(ERROR, map[string]interface{}{}, message, c)}func FailWithDetailed(c *gin.Context, data interface{}, message string) {Result(ERROR, data, message, c)}
分页器封装
在分页器中通过页码、单页数据量、总量三个数据即可算出分页器所需要的所有数据,如
总页数: total / size 向上取整
是否还有下一页: total > page * size
封装内容如下:
package paginationimport "gorm.io/gorm"func Pagination(page *int, size *int) func(db *gorm.DB) *gorm.DB {return func(db *gorm.DB) *gorm.DB {if *page < 1 {*page = 1}switch {case *size > 100:*size = 100case *size < 1:*size = 10}return db.Limit(*size).Offset((*page * *size) - *size)}}func Total(db *gorm.DB) int64 {var total int64db.Limit(-1).Offset(-1).Count(&total)return total}
使用如下:
type userListParamsType struct {Page int `form:"page" json:"page"` // 不传默认为1Size int `form:"size" json:"size"` // 不传默认为10}func UserList(c *gin.Context) {var params userListParamsTypeif paramsErr := c.ShouldBindQuery(¶ms); paramsErr != nil {response.FailWithMessage(c, paramsErr.Error())return}var list []model.Uservar total int64// 通过gorm.DB中的方法Scopes进行分页数据约束,在调用Scopes前,我们可以任意设置查询条件query := global.DB.Order("created_at desc").Scopes(pagination.Pagination(¶ms.Page, ¶ms.Size)).Find(&list)if query.Error != nil {response.FailWithMessage(c, query.Error.Error())} else {// 通过DB查询实例获取分页总数total = pagination.Total(query)response.SuccessWithData(c, gin.H{"list": list,"total": total,"page": params.Page,"size": params.Size,})}}
连接redis
前置条件需要先安装redis,如使用了docker,可使用一下命令一键安装
docker run -d -p:6379:6379 --name my_redis redis
使用流程:
初始化
func Redis() *redis.Client {redisConfig := global.CONFIG.Redisif !redisConfig.Enable {return nil}rdb := redis.NewClient(&redis.Options{Addr: fmt.Sprintf("%s:%d", redisConfig.Host, redisConfig.Port),Password: redisConfig.Password,DB: redisConfig.DB,})if pong, err := rdb.Ping(context.Background()).Result(); err != nil {global.LOGGER.Error("redis connect ping failed, err:", zap.Error(err))return nil} else {fmt.Println("redis连接成功! pong:", pong)return rdb}}
使用 ```go func generateCode(length int) string { rand.Seed(time.Now().UnixNano()) var code string for i := 0; i < length; i++ {
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 } }
<a name="jSZEA"></a>## 使用captcha库实现登录图形验证码验证通过github.com/mojocn/base64Captcha库很容易实现图形验证码功能,同时还支持语音、中文、数学计算、字符串、数字类型的验证方式,最终以base64格式生成图形验证码以及一个ID,用户需要把图形中正确的验证字符串以及ID提交给后端,后端通过这两个数据再进行校验。使用流程。1. 实现生成验证码方法```gotype ConfigType struct {Id stringVerifyValue string// digit chinese math string audioCaptchaType stringDriverAudio *base64Captcha.DriverAudioDriverString *base64Captcha.DriverStringDriverChinese *base64Captcha.DriverChineseDriverMath *base64Captcha.DriverMathDriverDigit *base64Captcha.DriverDigit}var store = base64Captcha.DefaultMemStorefunc GenerateCaptcha(confg ConfigType) (id string, b64s string, err error) {var driver base64Captcha.Driver//create base64 encoding captchaswitch confg.CaptchaType {case "digit":driver = confg.DriverDigitcase "string":driver = confg.DriverString.ConvertFonts()case "math":driver = confg.DriverMath.ConvertFonts()case "chinese":driver = confg.DriverChinese.ConvertFonts()case "audio":driver = confg.DriverAudiodefault:driver = confg.DriverDigit}c := base64Captcha.NewCaptcha(driver, store)return c.Generate()}
- 创建获取验证码接口
// 获取图片验证码接口func GetCaptcha(c *gin.Context) {captchaConfig := global.CONFIG.Captchavar config = utils.ConfigType{CaptchaType: "digit", // digit chinese math string audioDriverDigit: &base64Captcha.DriverDigit{Width: captchaConfig.ImgWidth,Height: captchaConfig.ImgHeight,Length: captchaConfig.Length,},}if id, b64s, err := utils.GenerateCaptcha(config); err != nil {response.FailWithMessage(c, err.Error())} else {response.SuccessWithData(c, gin.H{"id": id,"b64s": b64s,})}}
- 使用与校验
func LoginByPassword(c *gin.Context) {var params loginParamsTypeif err := c.ShouldBindJSON(¶ms); err != nil {// response.FailWithMessage(c, err.Error())response.FailWithMessage(c, "请检查信息是否填写完整!")} else {// 校验验证码if !utils.VerifyCaptcha(utils.ConfigType{Id: params.CaptchaID,VerifyValue: params.CaptchaValue,}) {response.FailWithMessage(c, "请检查验证码是否正确!")} else {user := FindUser(params.Phone)if user.Password != utils.GeneratePassword(params.Password) {response.FailWithMessage(c, "请检查账号和密码是否填写正确!")} else {if token, tokenErr := middleware.GenerateToken(uint64(user.ID)); err != nil {response.FailWithMessage(c, tokenErr.Error())} else {response.SuccessWithDetailed(c, gin.H{"data": user,"token": token,}, "登录成功!")}}}}}// utils.VerifyCaptchafunc VerifyCaptcha(params ConfigType) bool {return store.Verify(params.Id, params.VerifyValue, true)}
用户密码存储加密策略
为了提高用户账号安全性,密码肯定不能明文存储,我们可以通过md5加盐进行不可恢复的加密。
使用流程:
在使用md5加密之前,我们需要先设置盐用于加密,这个密钥尽可能设置复杂一点,可前往一下网站获取,获取到后,可在配置文件中进行配置。
实现加密方法封装
func md5plus(str string, salt string, iteration int) string {b := []byte(str)s := []byte(salt)h := md5.New()h.Write(s)h.Write(b)var res []byteres = h.Sum(nil)for i := 0; i < iteration-1; i++ {h.Reset()h.Write(res)res = h.Sum(nil)}return hex.EncodeToString(res)}func GeneratePassword(target string) string {// 对密码进行撒盐以及3次加密return md5plus(target, global.CONFIG.System.Salt, 3)}
- 注册加密密码
func Register(c *gin.Context) {var params registerParamsTypeif err := c.ShouldBindJSON(¶ms); err != nil {response.FailWithMessage(c, "请检查信息是否填写完整!"+err.Error())} else {var exist_user model.Userrows := global.DB.Where("phone = ?", params.Phone).Find(&exist_user)if rows.RowsAffected > 0 {response.FailWithMessage(c, "当前用户已存在!")} else if len(params.Password) < 6 {response.FailWithMessage(c, "密码长度不少于6!")} else {// 验证码校验if utils.VerifyMessageCode(fmt.Sprintf("register-%s", params.Phone), params.Code) {var user = model.User{Phone: params.Phone,Password: utils.GeneratePassword(params.Password), // 看这里,关键点}global.DB.Create(&user)if global.DB.Error != nil {response.FailWithMessage(c, global.DB.Error.Error())} else {response.SuccessWithMessage(c, "注册成功!")}} else {response.FailWithMessage(c, "验证码错误!")}}}}
- 密码登录匹配密码
func LoginByPassword(c *gin.Context) {var params loginParamsTypeif err := c.ShouldBindJSON(¶ms); err != nil {// response.FailWithMessage(c, err.Error())response.FailWithMessage(c, "请检查信息是否填写完整!")} else {// 校验验证码if !utils.VerifyCaptcha(utils.ConfigType{Id: params.CaptchaID,VerifyValue: params.CaptchaValue,}) {response.FailWithMessage(c, "请检查验证码是否正确!")} else {user := FindUser(params.Phone)if user.Password != utils.GeneratePassword(params.Password) { // 看这里, 关键点response.FailWithMessage(c, "请检查账号和密码是否填写正确!")} else {if token, tokenErr := middleware.GenerateToken(uint64(user.ID)); err != nil {response.FailWithMessage(c, tokenErr.Error())} else {response.SuccessWithDetailed(c, gin.H{"data": user,"token": token,}, "登录成功!")}}}}}
使用jwt库实现接口鉴权
文档参看:https://github.com/golang-jwt/jwt
简单说明,用户在登录成功的时候,后端通过用户的一些必要信息生成一个token下发给用户,用户将这个token存放在cookie或者localstorage中做持久化存储,在所有需要鉴权的接口中,通过在header中携带这个token信息,后端在接收到时,对其进行是否有效校验,从而完成用户身份认证。
使用流程。
我们需要生成一个Signingkey,用于加密解密使用,这个密钥尽可能设置复杂一点,可前往一下网站获取。
安装依赖,添加生成token方法
type AuthClaim struct {UserId uint64 `json:"userId"`jwt.StandardClaims}var secret = []byte(global.CONFIG.JWT.SigningKey)const TokenExpireDuration = 24 * time.Hour // 设置过期实践func GenerateToken(userId uint64) (string, error) {c := AuthClaim{UserId: userId,StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),Issuer: "ksir_go",},}token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)// 结合设置的密钥,增强安全性return token.SignedString(secret)}
- 实现jwt中间件
func ParseToken(tokenStr string) (*AuthClaim, error) {token, err := jwt.ParseWithClaims(tokenStr, &AuthClaim{}, func(tk *jwt.Token) (interface{}, error) {return secret, nil})if err != nil {return nil, err}if claim, ok := token.Claims.(*AuthClaim); ok && token.Valid {return claim, nil}return nil, errors.New("invalid token ")}func JWTAuthMiddleware() gin.HandlerFunc {return func(c *gin.Context) {token := c.Request.Header.Get("token")if token == "" {c.JSON(http.StatusForbidden, "empty token")c.Abort()return}claim, err := ParseToken(token)if err != nil {c.JSON(http.StatusForbidden, "Invalid token")c.Abort()return}c.Set("userId", claim.UserId)c.Next()}}
- 登录时生成并下发token
func LoginByPassword(c *gin.Context) {var params loginParamsTypeif err := c.ShouldBindJSON(¶ms); err != nil {// response.FailWithMessage(c, err.Error())response.FailWithMessage(c, "请检查信息是否填写完整!")} else {// 校验验证码if !utils.VerifyCaptcha(utils.ConfigType{Id: params.CaptchaID,VerifyValue: params.CaptchaValue,}) {response.FailWithMessage(c, "请检查验证码是否正确!")} else {user := FindUser(params.Phone)if user.Password != utils.GeneratePassword(params.Password) {response.FailWithMessage(c, "请检查账号和密码是否填写正确!")} else {// 看这里, 关键点if token, tokenErr := middleware.GenerateToken(uint64(user.ID)); err != nil {response.FailWithMessage(c, tokenErr.Error())} else {response.SuccessWithDetailed(c, gin.H{"data": user,"token": token,}, "登录成功!")}}}}}
- 在需要鉴权的接口处,添加中间件
func UserGroup(Router *gin.RouterGroup) {var userGroup = Router.Group("/user")userGroup.POST("/register", v1.Register)userGroup.POST("/loginByPassword", v1.LoginByPassword)userGroup.GET("/userList", v1.UserList)// 看这里, 关键点userGroup.GET("/userInfo", middleware.JWTAuthMiddleware(), v1.UserInfo)userGroup.POST("/avatarUpload", middleware.JWTAuthMiddleware(), v1.AvatarUpload)}
- 在接口中使用鉴权信息
func UserInfo(c *gin.Context) {// 看这里, 关键点if userId, exists := c.Get("userId"); exists {var user model.Userquery := global.DB.Where("id = ?", userId).Find(&user)if query.Error != nil {response.FailWithMessage(c, query.Error.Error())} else {response.SuccessWithData(c, gin.H{"data": user.Phone,})}}}
使用minIO实现文件资源管理
市面上已经有很成熟的服务了,比如阿里云oss、腾讯云cos、七牛等等,如项目经费充足,使用这些服务很合适,如节省成本或者需要进行更高的可定制化操作,可以使用minIO自己实现文件资源管理。
文档参考:http://docs.minio.org.cn/docs/
使用流程:
- 安装minIO,可以按照官方文档进行安装,这里通过docker进行快速安装
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"
- 在配置文件中配置minIO信息
minio:enable: trueendpoint: "127.0.0.1:9090"deploy-host: "127.0.0.1"deploy-port: 9090access-key-id: "admin"secret-access-key: "12345678"secure: false # useSSL
- minIO初始化
func MinIO() *minio.Client {minioInfo := global.CONFIG.MinIOif !minioInfo.Enable {return nil}minioClient, err := minio.New(minioInfo.Endpoint, &minio.Options{Creds: credentials.NewStaticV4(minioInfo.AccessKeyID, minioInfo.SecretAccessKey, ""),Secure: minioInfo.Secure,})if err != nil {log.Fatalln("minio初始化失败:", err)return nil} else {return minioClient}}
- 创建存储桶
// 在入口处调用初始化方法,成功以后创建存储桶global.MINIO = initialize.MinIO()if global.MINIO != nil {utils.InitBucket()}// utils.InitBucketfunc InitBucket() {// 创建头像存储桶CreateMinioBucket("avatar") // 头像存储桶global.MINIO.SetBucketPolicy(ctx, "avatar", policy.BucketPolicyReadWrite) // 设置存储桶为公开}
- 使用存储桶
// 头像上传func AvatarUpload(c *gin.Context) {if userId, exists := c.Get("userId"); exists {var user model.Userquery := global.DB.Where("id = ?", userId).Find(&user)if query.Error != nil {response.FailWithMessage(c, "用户不存在!")} else {file, _ := c.FormFile("file")// 看这里, 关键点if imageUrl, _, err := utils.UploadFileAvatar(file); err != nil {response.FailWithMessage(c, err.Error())} else {user.Avatar = *imageUrldb := global.DB.Save(&user)if db.Error != nil {response.FailWithMessage(c, db.Error.Error())} else {response.SuccessWithData(c, gin.H{"data": user,})}}}}}// utils.UploadFileAvatar// 获取文件后缀名func GetFileType(filePath string) (filetype string, filename string) {fileNameWithSuffix := path.Base(filePath)fileType := path.Ext(fileNameWithSuffix)fileNameOnly := strings.TrimSuffix(fileNameWithSuffix, fileType)return fileType, fileNameOnly}func UploadFileAvatar(file *multipart.FileHeader) (*string, *minio.UploadInfo, error) {reader, readerErr := file.Open()if readerErr != nil {return nil, nil, readerErr}filetype, _ := GetFileType(file.Filename)// 使用uuid防止命名重复而造成覆盖generate_file_name := uuid.New().String() + filetype// 规则: http://[minio域名]/[存储桶]/[文件名]image_url := fmt.Sprintf("http://%s:%d/%s/%s", global.CONFIG.MinIO.DeployHost, global.CONFIG.MinIO.DeployPort, "avatar", generate_file_name)uploadInfo, err := global.MINIO.PutObject(ctx, "avatar", generate_file_name, reader, file.Size, minio.PutObjectOptions{ContentType: "application/octet-stream"})return &image_url, &uploadInfo, err}
- 为了构造的头像图片地址能够永久访问,我们需要前往minIO控制台,将avatar存储桶配置为公开。
我们在docker中配置的minio控制台端口为9000, 访问localhost:9000,登录配置的账号密码即可管理我们的minio了。


使用docker、docker-compose实现所有服务部署配置
文档参考:https://docs.docker.com/get-started/overview/
至此我们的项目使用了mysql、redis、minio、以及gin服务自身的打包,如果这些服务挨个去搭建、加上每个环境的配置信息,难免是一件相当繁琐的事情,因此我们在这里使用docker-compose容器编排我们的服务,使整个应用可以一键部署。
使用流程:
- gin服务构建镜像配置文件Dockerfile
值得注意的是,虽然我们使用容器编排让server在mysql之后启动,但其实际是在mysql running状态后启动,我们需要mysql处于ready状态后才启动,否则server就会报错连不上mysql,所以文件根目录下的wait-for-it-sh.sh就派上用场了,由于我们server服务用的是alpine系统镜像,该系统支持的是sh命令,如你的镜像支持的是bash命令,可使用wait-for-it-bash.sh脚本。
FROM golang:alpine as builderWORKDIR /serverCOPY . .RUN go env -w GO111MODULE=onRUN go env -w GOPROXY=https://goproxy.cn,directRUN go env -w CGO_ENABLED=0RUN go mod tidyRUN go build -o server .FROM alpine:latestLABEL MAINTAINER debugksir<402351681@qq.com>WORKDIR /serverCOPY --from=builder /server ./EXPOSE 8080ENV GIN_MODE=$GIN_MODERUN ["chmod","+x","./wait-for-it-sh.sh"]# ENTRYPOINT ./server# 虽然加上了depends-on:mysql,但仅仅保证mysql处于running状态,我们需要在ready状态之后启动server,所以在这里加上了wait-for-it-sh.sh做处理ENTRYPOINT ["./wait-for-it-sh.sh", "172.1.0.12:3306", "--", "./server"]
- 使用docker-compose编排我们的服务
值得注意的是我们这里通过environment指令,将环境GIN_MODE传递给了server容器,而在server容器中,我们又将这个变量作为启动的环境。
environment:GIN_MODE: $GIN_MODE # release or test or debug
docker-compose.yml:
version: "3"networks:network:ipam:driver: defaultconfig:- subnet: '172.1.0.0/16'services:mysql:image: mysqlcontainer_name: ksir_go_mysqlrestart: alwaysports:- "3308:3306"environment:MYSQL_DATABASE: 'ksir_go'MYSQL_ROOT_PASSWORD: 'ksir_go_admin'volumes:- /home/mysql:/var/lib/mysqlplatform: linux/x86_64networks:network:ipv4_address: 172.1.0.12redis:image: rediscontainer_name: ksir_go_redisrestart: alwaysports:- '6380:6379'networks:network:ipv4_address: 172.1.0.13minio:image: minio/miniocontainer_name: ksir_go_miniorestart: alwaysenvironment:MINIO_ROOT_USER: 'admin'MINIO_ROOT_PASSWORD: '12345678'volumes:- /home/minio_data:/data- /home/minio_config:/root/.miniocommand: server /data --console-address ":9000" --address ":9090"ports:- '9000:9000'- '9090:9090'networks:network:ipv4_address: 172.1.0.14server:build:context: .environment:GIN_MODE: $GIN_MODE # release or test or debugcontainer_name: ksir_go_serverrestart: alwaysports:- '8082:8080'depends_on:- mysql- redis- miniovolumes:- /home/ksir_go/logs:/home/logsnetworks:network:ipv4_address: 172.1.0.11
至此我们的docker就配置好了,根据我们的使用环境来启动项目即可。
# 依赖docker环境,先确保安装docker、docker-compose# 测试环境(使用的是config/config.test.yml配置)export GIN_MODE=test && docker-compose up -d --build# 生产环境(使用的是config/config.release.yml配置)export GIN_MODE=release && docker-compose up -d --build
部署
前置条件:
- 服务器
- 安装好了docker及docker-compose
- 克隆代码
git clone https://gitee.com/debugksir/ksir_go
- 一键部署(以测试环境为例)
export GIN_MODE=test && docker-compose up -d --build
使用docker ps查看启动的容器状态。
开放启动的端口包括gin服务启动端口、minio控制台以及接口端口(如需远程访问mysql、redis可以一并开放)
浏览器访问验证。
项目实现了三个页面供测试
- /public/register.html
- /public/login.html
- /public/userInfo.html



