项目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 initialize
import (
"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.RawQuery
c.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 bool
if 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: errcheck
c.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.Mysql
if m.DBName == "" {
return nil
}
// fmt.Println("mysql配置:", m.Dsn())
mysqlConfig := mysql.Config{
DSN: m.Dsn(), // DSN data source name
DefaultStringSize: 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 model
import "gorm.io/gorm"
type User struct {
gorm.Model
Phone 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.Static
StaticFS := global.CONFIG.StaticFS
if 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 response
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Data interface{} `json:"data"`
Msg string `json:"msg"`
}
const (
ERROR = 400
SUCCESS = 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 pagination
import "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 = 100
case *size < 1:
*size = 10
}
return db.Limit(*size).Offset((*page * *size) - *size)
}
}
func Total(db *gorm.DB) int64 {
var total int64
db.Limit(-1).Offset(-1).Count(&total)
return total
}
使用如下:
type userListParamsType struct {
Page int `form:"page" json:"page"` // 不传默认为1
Size int `form:"size" json:"size"` // 不传默认为10
}
func UserList(c *gin.Context) {
var params userListParamsType
if paramsErr := c.ShouldBindQuery(¶ms); paramsErr != nil {
response.FailWithMessage(c, paramsErr.Error())
return
}
var list []model.User
var 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.Redis
if !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. 实现生成验证码方法
```go
type ConfigType struct {
Id string
VerifyValue string
// digit chinese math string audio
CaptchaType string
DriverAudio *base64Captcha.DriverAudio
DriverString *base64Captcha.DriverString
DriverChinese *base64Captcha.DriverChinese
DriverMath *base64Captcha.DriverMath
DriverDigit *base64Captcha.DriverDigit
}
var store = base64Captcha.DefaultMemStore
func GenerateCaptcha(confg ConfigType) (id string, b64s string, err error) {
var driver base64Captcha.Driver
//create base64 encoding captcha
switch confg.CaptchaType {
case "digit":
driver = confg.DriverDigit
case "string":
driver = confg.DriverString.ConvertFonts()
case "math":
driver = confg.DriverMath.ConvertFonts()
case "chinese":
driver = confg.DriverChinese.ConvertFonts()
case "audio":
driver = confg.DriverAudio
default:
driver = confg.DriverDigit
}
c := base64Captcha.NewCaptcha(driver, store)
return c.Generate()
}
- 创建获取验证码接口
// 获取图片验证码接口
func GetCaptcha(c *gin.Context) {
captchaConfig := global.CONFIG.Captcha
var config = utils.ConfigType{
CaptchaType: "digit", // digit chinese math string audio
DriverDigit: &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 loginParamsType
if 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.VerifyCaptcha
func 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 []byte
res = 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 registerParamsType
if err := c.ShouldBindJSON(¶ms); err != nil {
response.FailWithMessage(c, "请检查信息是否填写完整!"+err.Error())
} else {
var exist_user model.User
rows := 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 loginParamsType
if 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 loginParamsType
if 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.User
query := 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: true
endpoint: "127.0.0.1:9090"
deploy-host: "127.0.0.1"
deploy-port: 9090
access-key-id: "admin"
secret-access-key: "12345678"
secure: false # useSSL
- minIO初始化
func MinIO() *minio.Client {
minioInfo := global.CONFIG.MinIO
if !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.InitBucket
func 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.User
query := 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 = *imageUrl
db := 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 builder
WORKDIR /server
COPY . .
RUN go env -w GO111MODULE=on
RUN go env -w GOPROXY=https://goproxy.cn,direct
RUN go env -w CGO_ENABLED=0
RUN go mod tidy
RUN go build -o server .
FROM alpine:latest
LABEL MAINTAINER debugksir<402351681@qq.com>
WORKDIR /server
COPY --from=builder /server ./
EXPOSE 8080
ENV GIN_MODE=$GIN_MODE
RUN ["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: default
config:
- subnet: '172.1.0.0/16'
services:
mysql:
image: mysql
container_name: ksir_go_mysql
restart: always
ports:
- "3308:3306"
environment:
MYSQL_DATABASE: 'ksir_go'
MYSQL_ROOT_PASSWORD: 'ksir_go_admin'
volumes:
- /home/mysql:/var/lib/mysql
platform: linux/x86_64
networks:
network:
ipv4_address: 172.1.0.12
redis:
image: redis
container_name: ksir_go_redis
restart: always
ports:
- '6380:6379'
networks:
network:
ipv4_address: 172.1.0.13
minio:
image: minio/minio
container_name: ksir_go_minio
restart: always
environment:
MINIO_ROOT_USER: 'admin'
MINIO_ROOT_PASSWORD: '12345678'
volumes:
- /home/minio_data:/data
- /home/minio_config:/root/.minio
command: server /data --console-address ":9000" --address ":9090"
ports:
- '9000:9000'
- '9090:9090'
networks:
network:
ipv4_address: 172.1.0.14
server:
build:
context: .
environment:
GIN_MODE: $GIN_MODE # release or test or debug
container_name: ksir_go_server
restart: always
ports:
- '8082:8080'
depends_on:
- mysql
- redis
- minio
volumes:
- /home/ksir_go/logs:/home/logs
networks:
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