【Go】-基于Gin框架的博客项目

目录

项目分析

项目分层

初始化

用户模块

注册

登录

社区模块

所有社区

指定社区

帖子模块

顺序获取帖子

获取指定帖子

投票模块

发帖

投票

项目开发及部署

开发中使用air

makefile的编写

docker

总结


项目分析

基于Gin框架的IM即时通讯小demo,实现了用户注册,添加好友,创建和加入群聊;好友聊天,群聊天;可以自定义个人信息,群信息;在聊天中可以发送文字、图片、表情、语音。

        项目地址:knoci/GinBlog


项目分层

        config:配置文件

        static:前端静态资源

        docs:swagger

        setting:负责配置文件的读取

        template:html模板

        router:路由层

        controller:控制层,负责服务的转发

        logic:逻辑层,具体功能的实现

        dao:数据层,负责数据库和存储相关

        pkg:第三方库

        models:模板层,结构的定义

        middlewares:中间件

        logger:日志相关


初始化

        main函数如下:

package mainimport ("GinBlog/controller""GinBlog/dao/mysql""GinBlog/dao/redis""GinBlog/logger""GinBlog/pkg/snowflake""GinBlog/router""GinBlog/setting""fmt""os"
)// @title GinBlog项目接口文档
// @version 1.0
// @description Go web博客项目// @contact.name knoci
// @contact.email knoci@foxmail.com// @host 127.0.0.1:8808
// @BasePath /api/v1func main() {// 1.读取配置if len(os.Args) < 2 {fmt.Println("need config file.eg: GinBlog config.yaml")return}config_set := os.Args[1]if lenth := len(config_set); config_set[lenth-4:] == ".exe" {config_set = config_set[0 : lenth-4]}err := setting.Init(config_set)if err != nil {fmt.Printf("load setting failed: %v", err)return}// 2.加载日志err = logger.Init(setting.Conf.LogConfig, setting.Conf.Mode)if err != nil {fmt.Printf("load logger failed: %v", err)return}// 3.配置mysqlerr = mysql.Init(setting.Conf.MySQLConfig)if err != nil {fmt.Printf("init mysql failed: %v", err)return}defer mysql.Close()// 4.配置rediserr = redis.Init(setting.Conf.RedisConfig)if err != nil {fmt.Println("init redis failed: %v", err)return}defer redis.Close()// 5.获取路由并运行服务if err := snowflake.Init(setting.Conf.StartTime, setting.Conf.MachineID); err != nil {fmt.Printf("init snowflake failed: %v", err)return}if err := controller.InitTrans("zh"); err != nil {fmt.Println("init trans failed: %v", err)return}r := router.InitRouter(setting.Conf.Mode)err = r.Run(fmt.Sprintf(":%d", setting.Conf.Port))if err != nil {fmt.Printf("run server failed: %v", err)return}
}

        一开始进来用命令行参数指令读取配置,然后去到setting下的Init()函数初始化配置,setting中还定义了嵌套结构体,用这种方法确保拿到我们每个程序需要的参数。其中WatchConfig()函数和OnConfigChange()来监视config变化实现实时更新。

        需要注意的是vipeReadInConfig()函数要读入参数到结构体,一定要打上mapstructure的tag将map[string]interface{} 类型的数据解码到 Go 的结构体中。

package settingimport ("fmt""github.com/fsnotify/fsnotify""github.com/spf13/viper"
)var Conf = new(AppConfig)type AppConfig struct {Name         string `mapstructure:"name"`Mode         string `mapstructure:"mode"`Version      string `mapstructure:"version"`StartTime    string `mapstructure:"start_time"`MachineID    int64  `mapstructure:"machine_id"`Port         int    `mapstructure:"port"`*LogConfig   `mapstructure:"log"`*MySQLConfig `mapstructure:"mysql"`*RedisConfig `mapstructure:"redis"`
}type LogConfig struct {Level      string `mapstructure:"level"`Filename   string `mapstructure:"filename"`MaxSize    int    `mapstructure:"max_size"`MaxAge     int    `mapstructure:"max_age"`MaxBackups int    `mapstructure:"max_backups"`
}type MySQLConfig struct {User         string `mapstructure:"user"`Host         string `mapstructure:"host"`Password     string `mapstructure:"password"`DbName       string `mapstructure:"dbname"`Port         int    `mapstructure:"port"`MaxOpenConns int    `mapstructure:"max_open_conns"`MaxIdleConns int    `mapstructure:"max_idle_conns"`
}type RedisConfig struct {Host         string `mapstructure:"name"`Password     string `mapstructure:"password"`Port         int    `mapstructure:"port"`DB           int    `mapstructure:"db"`PoolSize     int    `mapstructure:"pool_size"`MinIdleConns int    `mapstructure:"min_idle_conns"`
}func Init(filepath string) (err error) {viper.SetConfigFile(filepath)err = viper.ReadInConfig()if err != nil {fmt.Print("Error loading config...")return}err = viper.Unmarshal(Conf)if err != nil {fmt.Print("Error unmarshalling config...")}viper.WatchConfig()viper.OnConfigChange(func(in fsnotify.Event) {err = viper.Unmarshal(Conf)if err != nil {fmt.Print("Error unmarshalling config while config changing...")return}})return
}

        把LogConfig参数给到logger的Init启动日志,日志这里用了Zap库,创建了全局替换,修改了gin的日志中间件,把日志记录到zap中。

package loggerimport ("GinBlog/setting""net""net/http""net/http/httputil""os""runtime/debug""strings""time""github.com/gin-gonic/gin""github.com/natefinch/lumberjack" // lumberjack 是一个简单的日志文件滚动库"go.uber.org/zap"                 // zap 是一个快速的、结构化的、可靠的 Go 日志库"go.uber.org/zap/zapcore"         // zap 的核心包
)// lg 是一个全局的 zap.Logger 实例
var lg *zap.Logger// Init 初始化日志配置
func Init(cfg *setting.LogConfig, mode string) (err error) {// 获取日志写入器writeSyncer := getLogWriter(cfg.Filename, cfg.MaxSize, cfg.MaxBackups, cfg.MaxAge)// 获取编码器encoder := getEncoder()// 解析日志级别var l = new(zapcore.Level)err = l.UnmarshalText([]byte(cfg.Level))if err != nil {return // 如果解析失败,返回错误}// 创建 zap 核心对象var core zapcore.Coreif mode == "dev" {// 如果是开发模式,日志同时输出到终端和文件consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())core = zapcore.NewTee( // Tee 表示同时写入多个 Writer//zapcore.NewCore(encoder, writeSyncer, l),zapcore.NewCore(consoleEncoder, zapcore.Lock(os.Stdout), zapcore.DebugLevel),)} else {// 生产模式,只输出到文件core = zapcore.NewCore(encoder, writeSyncer, l)}// 创建 zap 日志对象lg = zap.New(core, zap.AddCaller()) // 添加调用者信息// 替换全局日志对象zap.ReplaceGlobals(lg)// 记录初始化日志zap.L().Info("init logger success")return
}// getEncoder 创建并配置日志编码器
func getEncoder() zapcore.Encoder {// 使用生产环境的编码器配置encoderConfig := zap.NewProductionEncoderConfig()// 设置时间编码器encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder// 设置时间字段名称encoderConfig.TimeKey = "time"// 设置日志级别编码器encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder// 设置持续时间编码器encoderConfig.EncodeDuration = zapcore.SecondsDurationEncoder// 设置调用者编码器encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder// 创建 JSON 编码器return zapcore.NewJSONEncoder(encoderConfig)
}// getLogWriter 创建并配置日志写入器
func getLogWriter(filename string, maxSize, maxBackup, maxAge int) zapcore.WriteSyncer {// 使用 lumberjack 作为日志文件的写入器lumberJackLogger := &lumberjack.Logger{Filename:   filename,  // 日志文件路径MaxSize:    maxSize,   // 文件最大大小MaxBackups: maxBackup, // 最多备份文件数量MaxAge:     maxAge,    // 文件最长保存天数}// 将 lumberjack 写入器包装为 zap 写入器return zapcore.AddSync(lumberJackLogger)
}// GinLogger 是一个 Gin 中间件,用于记录 HTTP 请求日志
func GinLogger() gin.HandlerFunc {return func(c *gin.Context) {// 记录请求开始时间start := time.Now()// 获取请求路径和查询字符串path := c.Request.URL.Pathquery := c.Request.URL.RawQuery// 继续处理请求c.Next()// 计算请求处理时间cost := time.Since(start)// 记录日志lg.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),)}
}// GinRecovery 是一个 Gin 中间件,用于捕获和记录 panic
func GinRecovery(stack bool) gin.HandlerFunc {return func(c *gin.Context) {// 使用 defer 延迟执行 panic 恢复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 {// 如果是连接断开,记录错误日志lg.Error(c.Request.URL.Path,zap.Any("error", err),zap.String("request", string(httpRequest)),)// 如果连接已断开,记录错误并终止请求c.Error(err.(error)) // nolint: errcheckc.Abort()return}// 如果需要记录 stack traceif stack {lg.Error("[Recovery from panic]",zap.Any("error", err),zap.String("request", string(httpRequest)),zap.String("stack", string(debug.Stack())),)} else {lg.Error("[Recovery from panic]",zap.Any("error", err),zap.String("request", string(httpRequest)),)}// 设置 HTTP 状态码并终止请求c.AbortWithStatus(http.StatusInternalServerError)}}()// 继续处理请求c.Next()}
}

        然后是配置mysql,用的是sqlx库,这里封装一个Close()函数允许外部关闭。

package mysqlimport ("GinBlog/setting""fmt"_ "github.com/go-sql-driver/mysql""github.com/jmoiron/sqlx"
)var db *sqlx.DB// Init 初始化MySQL连接
func Init(cfg *setting.MySQLConfig) (err error) {// "user:password@tcp(host:port)/dbname"fmt.Println(cfg.Host)dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=Local", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DbName)db, err = sqlx.Connect("mysql", dsn)if err != nil {return}db.SetMaxOpenConns(cfg.MaxOpenConns)db.SetMaxIdleConns(cfg.MaxIdleConns)return
}// Close 关闭MySQL连接
func Close() {_ = db.Close()
}

        接着是redis,用的是go-redis。

package redisimport ("GinBlog/setting""fmt""github.com/go-redis/redis/v8"
)var (client *redis.ClientNil    = redis.Nil
)func Init(cfg *setting.RedisConfig) (err error) {client = redis.NewClient(&redis.Options{Addr:         fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),Password:     cfg.Password, // no password setDB:           cfg.DB,       // use default DBPoolSize:     cfg.PoolSize,MinIdleConns: cfg.MinIdleConns,})ctx := client.Context()_, err = client.Ping(ctx).Result()if err != nil {return err}return nil
}func Close() {_ = client.Close()
}

        之后用pkg的snowflake库,初始化了一个node,GenID()接收node用雪花算法生个一个id。

package snowflakeimport ("time"sf "github.com/bwmarrin/snowflake"
)var node *sf.Nodefunc Init(startTime string, machineID int64) (err error) {var st time.Timest, err = time.Parse("2006-01-02", startTime)if err != nil {return}sf.Epoch = st.UnixNano() / 1000000node, err = sf.NewNode(machineID)return
}
func GenID() int64 {return node.Generate().Int64()
}

        还进行了一下validator的设置,这是一个参数校验的库。

package controllerimport ("GinBlog/models""fmt""github.com/go-playground/validator/v10""reflect""strings""github.com/gin-gonic/gin/binding""github.com/go-playground/locales/en""github.com/go-playground/locales/zh"ut "github.com/go-playground/universal-translator"enTranslations "github.com/go-playground/validator/v10/translations/en"zhTranslations "github.com/go-playground/validator/v10/translations/zh"
)// 定义一个全局翻译器T
var trans ut.Translator// InitTrans 初始化翻译器
func InitTrans(locale string) (err error) {// 修改gin框架中的Validator引擎属性,实现自定制if v, ok := binding.Validator.Engine().(*validator.Validate); ok {// 注册一个获取json tag的自定义方法v.RegisterTagNameFunc(func(fld reflect.StructField) string {name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]if name == "-" {return ""}return name})// 为SignUpParam注册自定义校验方法v.RegisterStructValidation(SignUpParamStructLevelValidation, models.ParamSignUp{})zhT := zh.New() // 中文翻译器enT := en.New() // 英文翻译器// 第一个参数是备用(fallback)的语言环境// 后面的参数是应该支持的语言环境(支持多个)// uni := ut.New(zhT, zhT) 也是可以的uni := ut.New(enT, zhT, enT)// locale 通常取决于 http 请求头的 'Accept-Language'var ok bool// 也可以使用 uni.FindTranslator(...) 传入多个locale进行查找trans, ok = uni.GetTranslator(locale)if !ok {return fmt.Errorf("uni.GetTranslator(%s) failed", locale)}// 注册翻译器switch locale {case "en":err = enTranslations.RegisterDefaultTranslations(v, trans)case "zh":err = zhTranslations.RegisterDefaultTranslations(v, trans)default:err = enTranslations.RegisterDefaultTranslations(v, trans)}return}return
}// removeTopStruct 去除提示信息中的结构体名称
func removeTopStruct(fields map[string]string) map[string]string {res := map[string]string{}for field, err := range fields {res[field[strings.Index(field, ".")+1:]] = err}return res
}// SignUpParamStructLevelValidation 自定义SignUpParam结构体校验函数
func SignUpParamStructLevelValidation(sl validator.StructLevel) {su := sl.Current().Interface().(models.ParamSignUp)if su.Password != su.RePassword {// 输出错误提示信息,最后一个参数就是传递的paramsl.ReportError(su.RePassword, "re_password", "RePassword", "eqfield", "password")}
}

        终于设置完配置了,然后通过InitRouter()去注册路由了。

package routerimport ("GinBlog/controller""GinBlog/docs""GinBlog/logger""GinBlog/middlewares""github.com/gin-gonic/gin"swaggerFiles "github.com/swaggo/files"ginSwagger "github.com/swaggo/gin-swagger""net/http"
)func InitRouter(mode string) (r *gin.Engine) {if mode == gin.ReleaseMode {gin.SetMode(gin.ReleaseMode) // gin设置成发布模式}r = gin.New()//r.Use(logger.GinLogger(), logger.GinRecovery(true), middlewares.RateLimitMiddleware(1*time.Second, 1))r.Use(logger.GinLogger(), logger.GinRecovery(true))// 注册swagger api相关路由docs.SwaggerInfo.BasePath = ""r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))r.LoadHTMLFiles("./templates/index.html")r.Static("/static", "./static")r.GET("/", func(c *gin.Context) {c.HTML(http.StatusOK, "index.html", nil)})r.GET("/ping", func(c *gin.Context) {c.String(http.StatusOK, "pong")})v1 := r.Group("/api/v1")// 注册v1.POST("/signup", controller.SignUpHandler)// 登录v1.POST("/login", controller.LoginHandler)// 根据时间或分数获取帖子列表v1.GET("/posts2", controller.GetPostListHandler2)//v1.GET("/posts", controller.GetPostListHandler)v1.GET("/community", controller.CommunityHandler)v1.GET("/community/:id", controller.CommunityDetailHandler)v1.GET("/post/:id", controller.GetPostDetailHandler)v1.Use(middlewares.JWTAuthMiddleware()) // 应用JWT认证中间件{v1.POST("/post", controller.CreatePostHandler)// 投票v1.POST("/vote", controller.PostVoteController)}return
}

用户模块

        用到的参数模板,即models下的params.go如下:

package modelstype ParamSignUp struct {Username   string `json:"username" binding:"required"`Password   string `json:"password" binding:"required"`RePassword string `json:"re_password" binding:"required,eqfield=Password"`
}type ParamLogin struct {Username string `json:"username" binding:"required"`Password string `json:"password" binding:"required"`
}type ParamVoteData struct {PostID string `json:"post_id" binding:"required"`// 赞成1反对-1取消投票0,validator的oneof限定Direction int8 `json:"direction,string" binding:"oneof=1 0 -1" `
}type ParamPostList struct {CommunityID int64  `json:"community_id" form:"community_id"`   // 可以为空Page int64 `form:"page"`Size int64 `form:"size"`Order string `form:"order"`
}type ParamCommunityPostList struct {}

         返回状态的封装,在controller的response.go:

package controllerimport ("net/http""github.com/gin-gonic/gin"
)/*{"code":"msg":"data":}
*/
type ResCode int64const (CodeSuccess ResCode = 1000 + iotaCodeInvalidParamCodeUserExistCodeUserNotExistCodeInvalidPasswordCodeServerBusyCodeNeedLoginCodeInvalidToken
)var getMsg = map[ResCode]string{CodeSuccess:         "success",CodeInvalidParam:    "请求参数错误",CodeUserExist:       "用户已存在",CodeUserNotExist:    "用户不存在",CodeInvalidPassword: "用户名或密码错误",CodeServerBusy:      "服务繁忙",CodeNeedLogin:       "需要登录",CodeInvalidToken:    "无效的token",
}type Response struct {Code ResCode     `json:"code"`Msg  interface{} `json:"msg"`Data interface{} `json:"data,omitempty"` // 为空忽略
}func ResponseSuccess(c *gin.Context, data interface{}) {rd := &Response{Code: CodeSuccess,Msg:  getMsg[CodeSuccess],Data: data,}c.JSON(http.StatusOK, rd)
}func ResponseError(c *gin.Context, code ResCode) {rd := &Response{Code: code,Msg:  getMsg[code],Data: nil,}c.JSON(http.StatusOK, rd)
}func ResponseErrorWithMsg(c *gin.Context, code ResCode, msg interface{}) {rd := &Response{Code: code,Msg:  msg,Data: nil,}c.JSON(http.StatusOK, rd)
}

注册

        ShouldBindJSON解析Post传来的JSON自动绑定到参数结构体上,在controller层验证一下参数是否正确,然后转到逻辑层处理具体注册逻辑

func SignUpHandler(c *gin.Context) {// 参数校验var p = new(models.ParamSignUp)if err := c.ShouldBindJSON(p); err != nil {zap.L().Error("SignUp with invalid param", zap.Error(err))// 判断err是不是validator.ValidationErrors类型fmt.Println(p.Password)fmt.Println(p.RePassword)errs, ok := err.(validator.ValidationErrors)if !ok {// 非validator.ValidationErrors类型错误直接返回ResponseError(c, CodeInvalidParam)return}ResponseErrorWithMsg(c, CodeInvalidParam, removeTopStruct(errs.Translate(trans)))return}// 业务处理if err := logic.SignUp(p); err != nil {zap.L().Error("logic.SignUp failed", zap.Error(err))if errors.Is(err, mysql.ErrUserExist) {ResponseError(c, CodeUserExist)}ResponseError(c, CodeServerBusy)return}// 返回响应ResponseSuccess(c, nil)
}

        在logic的SignUp中,判断是否合法,然后存入数据库。

func SignUp(p *models.ParamSignUp) (err error) {// 合法性判断if err := mysql.CheckUserExist(p.Username); err != nil {return err}// 保存到数据库user := models.User{UserID:   snowflake.GenID(),Username: p.Username,Password: p.Password,}err = mysql.InsertUser(&user)if err != nil {return err}return nil
}

        数据库相关的函数封装在dao的mysql,这里密码用到了md5加密。

package mysqlimport ("GinBlog/models""crypto/md5""database/sql""encoding/hex"
)const salt = "knoci1337"func InsertUser(user *models.User) (err error) {// 密码加密user.Password = encryptPassword(user.Password)sqlStr := `insert into user(user_id, username, password) value(?,?,?)`_, err = db.Exec(sqlStr, user.UserID, user.Username, user.Password)if err != nil {return}return nil
}func CheckUserExist(username string) (err error) {sqlStr := `select count(user_id) from user where username = ?`var count intif err = db.Get(&count, sqlStr, username); err != nil {return err}if count > 0 {return ErrUserExist}return nil
}func encryptPassword(password string) string {h := md5.New()h.Write([]byte(salt))return hex.EncodeToString(h.Sum([]byte(password)))
}func Login(user *models.User) (err error) {oPassword := user.PasswordsqlStr := `select user_id, username , password from user where username = ?`err = db.Get(user, sqlStr, user.Username)if err == sql.ErrNoRows {return ErrUserNotExist}if err != nil {// 查询数据库失败return err}password := encryptPassword(oPassword)if password != user.Password {return ErrInvalidPassword}return
}// GetUserById 根据id获取用户信息
func GetUserById(uid int64) (user *models.User, err error) {user = new(models.User)sqlStr := `select user_id, username from user where user_id = ?`err = db.Get(user, sqlStr, uid)return
}

登录

        controller层

func LoginHandler(c *gin.Context) {// 参数校验p := new(models.ParamLogin)if err := c.ShouldBindJSON(p); err != nil {zap.L().Error("Login with invalid param", zap.Error(err))errs, ok := err.(validator.ValidationErrors)if !ok {ResponseError(c, CodeInvalidParam)return}ResponseErrorWithMsg(c, CodeInvalidParam, removeTopStruct(errs.Translate(trans)))return}// 2.业务逻辑处理user, err := logic.Login(p)if err != nil {zap.L().Error("logic.Login failed", zap.String("username", p.Username), zap.Error(err))if errors.Is(err, mysql.ErrUserNotExist) {ResponseError(c, CodeUserNotExist)return}ResponseError(c, CodeInvalidPassword)return}// 3.返回响应ResponseSuccess(c, gin.H{"user_id":   fmt.Sprintf("%d", user.UserID), // id值大于1<<53-1  int64类型的最大值是1<<63-1"user_name": user.Username,"token":     user.Token,})
}

        logic层,登录成功后生成jwt的token。

func Login(p *models.ParamLogin) (*models.User,error) {user := &models.User{Username: p.Username,Password: p.Password,}// 传递的是指针,就能拿到user.UserIDif err := mysql.Login(user); err != nil {return nil, err}// 生成JWTtoken, err := jwt.GenToken(user.UserID, user.Username)if err != nil {return nil, err}user.Token = tokenreturn user, err
}

        jwt的生成以及解析函数封装,在pkg目录jwt的jwt.go:

package jwtimport ("errors""time""github.com/spf13/viper""github.com/dgrijalva/jwt-go"
)var mySecret = []byte("人生不过一场梦")// MyClaims 自定义声明结构体并内嵌jwt.StandardClaims
// jwt包自带的jwt.StandardClaims只包含了官方字段
// 我们这里需要额外记录一个username字段,所以要自定义结构体
// 如果想要保存更多信息,都可以添加到这个结构体中
type MyClaims struct {UserID   int64  `json:"user_id"`Username string `json:"username"`jwt.StandardClaims
}// GenToken 生成JWT
func GenToken(userID int64, username string) (string, error) {// 创建一个我们自己的声明的数据c := MyClaims{userID,"username", // 自定义字段jwt.StandardClaims{ExpiresAt: time.Now().Add(time.Duration(viper.GetInt("auth.jwt_expire")) * time.Hour).Unix(), // 过期时间Issuer: "GinBlog", // 签发人},}// 使用指定的签名方法创建签名对象token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)// 使用指定的secret签名并获得完整的编码后的字符串tokenreturn token.SignedString(mySecret)
}// ParseToken 解析JWT
func ParseToken(tokenString string) (*MyClaims, error) {// 解析tokenvar mc = new(MyClaims)token, err := jwt.ParseWithClaims(tokenString, mc, func(token *jwt.Token) (i interface{}, err error) {return mySecret, nil})if err != nil {return nil, err}if token.Valid { // 校验tokenreturn mc, nil}return nil, errors.New("invalid token")
}

社区模块

        models的模板如下:

package modelsimport "time"type Community struct {ID   int64  `json:"id" db:"community_id"`Name string `json:"name" db:"community_name"`
}type CommunityDetail struct {ID   int64  `json:"id" db:"community_id"`Name         string    `json:"name" db:"community_name"`Introduction string    `json:"introduction" db:"introduction"`CreateTime   time.Time `json:"create_time" db:"create_time"`
}

所有社区

        v1.GET "/community"是获取所有社区,CommunityHandler转到逻辑层

func CommunityHandler(c *gin.Context) {//查询到所有communitycommunities, err := logic.GetCommunityList()if err != nil {zap.L().Error("logic.GetCommunityList() failed", zap.Error(err))ResponseError(c, CodeServerBusy)return}ResponseSuccess(c, communities)
}

        logic层转到数据库,在mysql的community.go

func GetCommunityList() (data []*models.Community, err error) {//查找到所有的community并且返回data, err = mysql.GetCommunityList()if err != nil {zap.L().Error("logic.GetCommunityList() failed", zap.Error(err))return nil, err}return data, err
}

        数据库里用sql语句直接查询

func GetCommunityList() (data []*models.Community, err error) {sqlStr := "select community_id, community_name from community"if err := db.Select(&data, sqlStr); err != nil {if err == sql.ErrNoRows {zap.L().Warn("there is no community in db")err = nil}}return
}

指定社区

        Get请求Query一个id查询单个社区,和查询所有大差不差。

func CommunityDetailHandler(c *gin.Context) {// 获取社区idcommunityID := c.Param("id")id, err := strconv.ParseInt(communityID, 10, 64)if err != nil {ResponseError(c, CodeInvalidParam)return}data , err := logic.GetCommunityDetail(id)if err != nil {zap.L().Error("logic.GetCommunityDetail() failed", zap.Error(err))ResponseError(c, CodeServerBusy)return}ResponseSuccess(c, data)
}
func GetCommunityDetail(id int64) (*models.CommunityDetail, error) {return mysql.GetCommunityDetailByID(id)
}
func GetCommunityDetailByID(id int64) (detail *models.CommunityDetail, err error) {detail = new(models.CommunityDetail)sqlStr := "select community_id, community_name, introduction, create_time from community where community_id = ?"if err := db.Get(detail, sqlStr, id); err != nil {if err == sql.ErrNoRows {err = ErrInvalidID}}fmt.Println("%v", detail)return detail, err
}

帖子模块

        models的模板如下:

package modelsimport "time"const (OderTime  = "time"OderScore = "score"
)type Post struct {ID          int64     `json:"id,string" db:"post_id"`                            // 帖子idAuthorID    int64     `json:"author_id" db:"author_id"`                          // 作者idCommunityID int64     `json:"community_id" db:"community_id" binding:"required"` // 社区idStatus      int32     `json:"status" db:"status"`                                // 帖子状态Title       string    `json:"title" db:"title" binding:"required"`               // 帖子标题Content     string    `json:"content" db:"content" binding:"required"`           // 帖子内容CreateTime  time.Time `json:"create_time" db:"create_time"`                      // 帖子创建时间
}// ApiPostDetail 帖子详情接口的结构体
type ApiPostDetail struct {AuthorName       string             `json:"author_name"` // 作者VoteNum          int64              `json:"vote_num"`    // 投票数*Post                               // 嵌入帖子结构体*CommunityDetail `json:"community"` // 嵌入社区信息
}

顺序获取帖子

        post2实现按时间或分数顺序获取帖子,这里默认按时间

func GetPostListHandler2(c *gin.Context) {// 获取参数p := &models.ParamPostList{Page:  1,Size:  10,Order: models.OderTime,}if err := c.ShouldBindQuery(p); err != nil {zap.L().Error("GetPostListHandler2 with invalid param", zap.Error(err))return}// 获取帖子列表data, err := logic.GetPostListNew(p)if err != nil {zap.L().Error("logic GetPostList() failed", zap.Error(err))return}// 去redis查询id列表// 根据id去数据库查询帖子详情信息ResponseSuccess(c, data)
}

        转到logic中,由一个GetPostListNew()函数实现判断和分发

func GetPostListNew(p *models.ParamPostList) (data []*models.ApiPostDetail, err error) {// 根据请求参数的不同,执行不同的逻辑。if p.CommunityID == 0 {// 查所有data, err = GetPostList2(p)} else {// 根据社区id查询data, err = GetCommunityPostList(p)}if err != nil {zap.L().Error("GetPostListNew failed", zap.Error(err))return nil, err}return
}

        返回的是一个ApiPostDetail类型的切片,包含了坐着,票数,帖子信息,社区信息。

func GetPostList2(p *models.ParamPostList) (data []*models.ApiPostDetail, err error) {ids, err := redis.GetPostIDsInOrder(p)if err != nil {return}if len(ids) == 0 {zap.L().Warn("redis.GetPostIDsInOrder(p) return 0 data")return}posts, err := mysql.GetPostListByIDs(ids)if err != nil {return}//提前查好每篇帖子的投票数voteData, err := redis.GetPostVoteData(ids)if err != nil {return}for idx, post := range posts {// 根据作者id查询作者信息user, err := mysql.GetUserById(post.AuthorID)if err != nil {zap.L().Error("mysql.GetUserById(post.AuthorID) failed",zap.Int64("author_id", post.AuthorID),zap.Error(err))continue}// 根据社区id查询社区详细信息community, err := mysql.GetCommunityDetailByID(post.CommunityID)if err != nil {zap.L().Error("mysql.GetUserById(post.AuthorID) failed",zap.Int64("community_id", post.CommunityID),zap.Error(err))continue}postDetail := &models.ApiPostDetail{AuthorName: user.Username,VoteNum:         voteData[idx],Post:            post,CommunityDetail: community,}data = append(data, postDetail)}return
}func GetCommunityPostList(p *models.ParamPostList) (data []*models.ApiPostDetail, err error) {// 2. 去redis查询id列表ids, err := redis.GetCommunityPostIDsInOrder(p)if err != nil {return}if len(ids) == 0 {zap.L().Warn("redis.GetPostIDsInOrder(p) return 0 data")return}zap.L().Debug("GetCommunityPostIDsInOrder", zap.Any("ids", ids))// 3. 根据id去MySQL数据库查询帖子详细信息// 返回的数据还要按照我给定的id的顺序返回posts, err := mysql.GetPostListByIDs(ids)if err != nil {return}zap.L().Debug("GetPostList2", zap.Any("posts", posts))// 提前查询好每篇帖子的投票数voteData, err := redis.GetPostVoteData(ids)if err != nil {return}// 将帖子的作者及分区信息查询出来填充到帖子中for idx, post := range posts {// 根据作者id查询作者信息user, err := mysql.GetUserById(post.AuthorID)if err != nil {zap.L().Error("mysql.GetUserById(post.AuthorID) failed",zap.Int64("author_id", post.AuthorID),zap.Error(err))continue}// 根据社区id查询社区详细信息community, err := mysql.GetCommunityDetailByID(post.CommunityID)if err != nil {zap.L().Error("mysql.GetUserById(post.AuthorID) failed",zap.Int64("community_id", post.CommunityID),zap.Error(err))continue}postDetail := &models.ApiPostDetail{AuthorName:      user.Username,VoteNum:         voteData[idx],Post:            post,CommunityDetail: community,}data = append(data, postDetail)}return
}

        通过redis目录下post.go的GetPostIDsInOrder()获取按时间排序好的帖子id,GetCommunityPostIDsInOrder()按社区获取帖子id,用字符串切片返回。

        GetCommunityPostIDsInOrder()用ZInterStore命令将社区的帖子集合和排序的有序集合进行交集计算,并将结果存储在key中。这里使用"MAX"作为聚合函数,意味着取两个有序集合中的最大分数。

        GetPostVoteData()函数获取每篇帖子投票顺序,使用了pipeline事务确保一致性,ZCount限定范围在1到999票,cmders是Exec结果的切片,遍历用.(*redis.IntCmd)类型断言其是整数Int型结果并获取其Val添加到data切片。

        getRedisKey()是在key.go自己封装的方法,获取key值,getIDsFromKey()是按照page和size分页获取分区的帖子。

func GetCommunityPostIDsInOrder(p *models.ParamPostList) ([]string, error) {orderKey := getRedisKey(KeyPostTimeZSet)if p.Order == models.OderScore  {orderKey = getRedisKey(KeyPostScoreZSet)}// 使用 zinterstore 把分区的帖子set与帖子分数的 zset 生成一个新的zset// 针对新的zset 按之前的逻辑取数据// 社区的keycKey := getRedisKey(KeyCommunitySetPF + strconv.Itoa(int(p.CommunityID)))// 利用缓存key减少zinterstore执行的次数key := orderKey + strconv.Itoa(int(p.CommunityID))ctx := context.Background()if client.Exists(ctx, key).Val() < 1 {// 不存在,需要计算pipeline := client.Pipeline()pipeline.ZInterStore(ctx, key, &redis.ZStore{Keys:    []string{cKey, orderKey},Aggregate: "MAX",}) // zinterstore 计算pipeline.Expire(ctx, key, 60*time.Second) // 设置超时时间_, err := pipeline.Exec(ctx)if err != nil {return nil, err}}// 存在的话就直接根据key查询idsreturn getIDsFormKey(key, p.Page, p.Size)
}func GetPostIDsInOrder(p *models.ParamPostList) ([]string, error) {// 获取排序顺序key := getRedisKey(KeyPostTimeZSet)if p.Order == models.OderScore {key = getRedisKey(KeyPostScoreZSet)}return getIDsFormKey(key, p.Page, p.Size)
}func GetPostVoteData(ids []string) (data []int64, err error){ctx := context.TODO()data = make([]int64, 0, len(ids))// 使用pipeline一次发送剁掉指令减少RTTpipeline := client.Pipeline()for _, id := range ids {key := getRedisKey(KeyPostVotedZSetPF+id)pipeline.ZCount(ctx, key, "1", "1")}cmders, err := pipeline.Exec(ctx)if err != nil {return nil, err}for _, cmder := range cmders {v := cmder.(*redis.IntCmd).Val()data = append(data, v)}return
}func getIDsFormKey(key string, page, size int64) ([]string, error) {// 确定所有起点start := (page -1) * sizeend := start + size -1// 查询,按分数从大到小ctx := context.TODO()return  client.ZRevRange(ctx, key, start, end).Result()
}
package redis// redis key注意使用命名空间方式方便查询和拆分
const (Prefix = "ginblog:"KeyPostTimeZSet = "post:time" // Zset 发帖时间为分数KeyPostScoreZSet = "post:score" // Zset 帖子评分为分数KeyPostVotedZSetPF = "post:voted" // zset 记录用户及投票类型,参数是post idKeyCommunitySetPF = "community:" // set 保存每个分区下帖子的id
)func getRedisKey(key string) string {return Prefix + key
}

        在mysql中,也用了许多方法,目标都是为了获取帖子的相关信息。

// GetPostList 查询帖子列表函数
func GetPostList(page, size int64) (posts []*models.Post, err error) {sqlStr := `select post_id, title, content, author_id, community_id, create_timefrom postORDER BY create_timeDESClimit ?,?`posts = make([]*models.Post, 0, 2) // 不要写成make([]*models.Post, 2)err = db.Select(&posts, sqlStr, (page-1)*size, size)return
}// GetPostListByIDs 根据给定的id列表查询帖子数据
func GetPostListByIDs(ids []string) (postList []*models.Post, err error) {sqlStr := "select post_id, title, content, author_id, community_id, create_time from post where post_id in (?) order by FIND_IN_SET(post_id, ?)"// sqlx.In批量生成query, args, err := sqlx.In(sqlStr, ids, strings.Join(ids, ","))if err != nil {return nil, err}err = db.Select(&postList, query, args...) // 一定要加...return
}// GetUserById 根据id获取用户信息
func GetUserById(uid int64) (user *models.User, err error) {user = new(models.User)sqlStr := `select user_id, username from user where user_id = ?`err = db.Get(user, sqlStr, uid)return
}func GetCommunityDetailByID(id int64) (detail *models.CommunityDetail, err error) {detail = new(models.CommunityDetail)sqlStr := "select community_id, community_name, introduction, create_time from community where community_id = ?"if err := db.Get(detail, sqlStr, id); err != nil {if err == sql.ErrNoRows {err = ErrInvalidID}}fmt.Println("%v", detail)return detail, err
}

获取指定帖子

        Get的Query获取id参数,然后查询,逻辑比较简单

func GetPostDetailHandler(c *gin.Context) {// 1. 获取参数(从URL中获取帖子的id)pidStr := c.Param("id")pid, err := strconv.ParseInt(pidStr, 10, 64)if err != nil {zap.L().Error("get post detail with invalid param", zap.Error(err))ResponseError(c, CodeInvalidParam)return}// 2. 根据id取出帖子数据(查数据库)data, err := logic.GetPostById(pid)if err != nil {zap.L().Error("logic.GetPostById(pid) failed", zap.Error(err))ResponseError(c, CodeServerBusy)return}// 3. 返回响应ResponseSuccess(c, data)
}

        logic层 :

func GetPostById(pid int64) (data *models.ApiPostDetail, err error) {// 查询并组合我们接口想用的数据post, err := mysql.GetPostById(pid)if err != nil {zap.L().Error("mysql.GetPostById(pid) failed",zap.Int64("pid", pid),zap.Error(err))return}// 根据作者id查询作者信息user, err := mysql.GetUserById(post.AuthorID)if err != nil {zap.L().Error("mysql.GetUserById(post.AuthorID) failed",zap.Int64("author_id", post.AuthorID),zap.Error(err))return}// 根据社区id查询社区详细信息community, err := mysql.GetCommunityDetailByID(post.CommunityID)if err != nil {zap.L().Error("mysql.GetUserById(post.AuthorID) failed",zap.Int64("community_id", post.CommunityID),zap.Error(err))return}// 接口数据拼接data = &models.ApiPostDetail{AuthorName:      user.Username,Post:            post,CommunityDetail: community,}return
}

        用到的数据库查询方法 :

func GetPostById(pid int64) (post *models.Post, err error) {post = new(models.Post)sqlStr := `selectpost_id, title, content, author_id, community_id, create_timefrom postwhere post_id = ?`err = db.Get(post, sqlStr, pid)return
}func GetUserById(uid int64) (user *models.User, err error) {user = new(models.User)sqlStr := `select user_id, username from user where user_id = ?`err = db.Get(user, sqlStr, uid)return
}func GetCommunityDetailByID(id int64) (detail *models.CommunityDetail, err error) {detail = new(models.CommunityDetail)sqlStr := "select community_id, community_name, introduction, create_time from community where community_id = ?"if err := db.Get(detail, sqlStr, id); err != nil {if err == sql.ErrNoRows {err = ErrInvalidID}}fmt.Println("%v", detail)return detail, err
}

投票模块

        在这里,我们引入了鉴权中间件,因为没有登录是不能发帖和投票的

v1.Use(middlewares.JWTAuthMiddleware()) // 应用JWT认证中间件{v1.POST("/post", controller.CreatePostHandler)// 投票v1.POST("/vote", controller.PostVoteController)}

        具体实现在middlewares的auth.go中,这里我们规定把token放在头部 Authorization 中,并且以 Bearer 开头,解析成功后存入gin.Context的上下文中

package middlewaresimport ("GinBlog/controller""GinBlog/pkg/jwt""strings""github.com/gin-gonic/gin"
)// JWTAuthMiddleware 基于JWT的认证中间件
func JWTAuthMiddleware() func(c *gin.Context) {return func(c *gin.Context) {// 客户端携带Token有三种方式 1.放在请求头 2.放在请求体 3.放在URI// 这里假设Token放在Header的Authorization中,并使用Bearer开头// Authorization: Bearer xxxxxxx.xxx.xxx  / X-TOKEN: xxx.xxx.xx// 这里的具体实现方式要依据你的实际业务情况决定authHeader := c.Request.Header.Get("Authorization")if authHeader == "" {controller.ResponseError(c, controller.CodeNeedLogin)c.Abort()return}// 按空格分割parts := strings.SplitN(authHeader, " ", 2)if !(len(parts) == 2 && parts[0] == "Bearer") {controller.ResponseError(c, controller.CodeInvalidToken)c.Abort()return}// parts[1]是获取到的tokenString,我们使用之前定义好的解析JWT的函数来解析它mc, err := jwt.ParseToken(parts[1])if err != nil {controller.ResponseError(c, controller.CodeInvalidToken)c.Abort()return}// 将当前请求的userID信息保存到请求的上下文c上c.Set(controller.CtxUserIDKey, mc.UserID)c.Next() // 后续的处理请求的函数中 可以用过c.Get(CtxUserIDKey) 来获取当前请求的用户信息}
}

发帖

func CreatePostHandler(c *gin.Context) {// 参数校验 ShouldBindJSON()p := new(models.Post)if err := c.ShouldBindJSON(p); err != nil {zap.L().Error("create post with invalid param")ResponseError(c, CodeInvalidParam)return}// 从c中取到发帖子的iduserID, err := GetCurrenUser(c)if err != nil {ResponseError(c, CodeNeedLogin)return}p.AuthorID = userID// 创建帖子if err := logic.CreatePost(p); err != nil {zap.L().Error("logic.CreatePost failed", zap.Error(err))ResponseError(c, CodeServerBusy)return}// 返回响应ResponseSuccess(c, nil)
}

        logic层用雪花算法生成帖子id

func CreatePost(p *models.Post) error {// 生成post idp.ID = snowflake.GenID()err := mysql.CreatePost(p)if err != nil {return err}err = redis.CreatePost(p.ID, p.CommunityID)return err
}

        数据库mysql和redis都要存,redis中的TxPipeline与 Pipeline 类似,但确保操作的原子性。它将命令包装在 MULTI 和 EXEC 命令中,确保所有命令要么全部执行,要么全部不执行

// Mysql
func CreatePost(p *models.Post) (err error) {sqlStr := `insert into post(post_id, title, content, author_id, community_id) values (?, ?, ?, ?, ?)`_, err = db.Exec(sqlStr, p.ID, p.Title, p.Content, p.AuthorID, p.CommunityID)return err
}
//Redis
func CreatePost(postID, communityID int64) error {pipeline := client.TxPipeline()ctx := context.Background()// 帖子时间pipeline.ZAdd(ctx, getRedisKey(KeyPostTimeZSet), &redis.Z{Score:  float64(time.Now().Unix()),Member: postID,})// 帖子分数pipeline.ZAdd(ctx, getRedisKey(KeyPostScoreZSet), &redis.Z{Score:  float64(time.Now().Unix()),Member: postID,})// 更新:把帖子id加到社区的setcKey := getRedisKey(KeyCommunitySetPF + strconv.Itoa(int(communityID)))pipeline.SAdd(ctx, cKey, postID)_, err := pipeline.Exec(ctx)return err
}

投票

type ParamVoteData struct {PostID string `json:"post_id" binding:"required"`// 赞成1反对-1取消投票0,validator的oneof限定Direction int8 `json:"direction,string" binding:"oneof=1 0 -1" `
}

        获取投票的类型,然后获取当前都票的用户,转到logic处理

func PostVoteController(c *gin.Context) {// 获取参数p := new(models.ParamVoteData)if err := c.ShouldBindJSON(p); err != nil {errs, ok := err.(validator.ValidationErrors) // 类型断言if !ok {ResponseError(c, CodeInvalidParam)return}errData := removeTopStruct(errs.Translate(trans))ResponseErrorWithMsg(c, CodeInvalidParam, errData)return}// 逻辑处理userID, err := GetCurrenUser(c)if err != nil {ResponseError(c, CodeNeedLogin)return}if err := logic.PostVote(userID, p); err != nil {zap.L().Error("logic.PostVote() failed", zap.Error(err))ResponseError(c, CodeServerBusy)return}ResponseSuccess(c, nil)
}

        这里转到redis进行,投票的底层实现就是对排序分数的修改。日志不打也可以。

func PostVote(userID int64, p *models.ParamVoteData) error{zap.L().Debug("VoteForPost",zap.Int64("userID", userID),zap.String("postID", p.PostID),zap.Int8("direction", p.Direction))return redis.VoteForPost(strconv.Itoa(int(userID)), p.PostID, float64(p.Direction))
}

        首先检查帖子的投票是否已经超过一周的有效期限,如果超时,则返回错误。如果投票有效,函数会创建一个 Redis 事务管道。接着,根据用户投票的值,函数会执行不同的操作:

  1. 如果用户投票值为0,表示用户要取消对帖子的投票,函数会从特定有序集合中移除该用户的ID。
  2. 如果用户投票值非0,函数会检查用户是否已经对该帖子投过票,并且比较新旧投票值:
    • 如果新旧投票值相同,表示投票重复,函数返回错误。
    • 如果投票值不同,函数计算两者的差值,并根据差值更新帖子的得分(增加或减少),同时更新用户对该帖子的投票记录。

        最后,函数执行事务管道中的所有命令,并处理可能出现的错误。这个过程确保了投票操作的原子性,即所有更新要么同时成功,要么同时失败,从而保证了数据的一致性。

func VoteForPost(userID, postID string, value float64) error {// 判断投票情况ctx := context.Background()postTime := client.ZScore(ctx, getRedisKey(KeyPostTimeZSet), postID).Val()if float64(time.Now().Unix()) - postTime > oneWeek {return ErrVoteTimeExpire}// 更新分数pipeline := client.TxPipeline()// 记录用户投票if value == 0 {pipeline.ZRem(ctx, getRedisKey(KeyPostVotedZSetPF+postID), userID)} else {oldVal := client.ZScore(ctx, getRedisKey(KeyPostVotedZSetPF+postID), userID).Val() // 查询投票记录var op float64if value == oldVal {return ErrVoteRepeat}if value > oldVal {op = 1} else {op = -1}diff := math.Abs(oldVal - value)pipeline.ZIncrBy(ctx, getRedisKey(KeyPostScoreZSet), op*diff*scorePerVote, postID)pipeline.ZAdd(ctx, getRedisKey(KeyPostVotedZSetPF+postID), &redis.Z{Score: value,Member: userID,})}_, err := pipeline.Exec(ctx)return err
}

项目开发及部署

开发中使用air

        air可以让gin框架在开发中运行,对应修改自动重启。.air.conf如下:

# [Air](https://github.com/cosmtrek/air) TOML 格式的配置文件# 工作目录
# 使用 . 或绝对路径,请注意 `tmp_dir` 目录必须在 `root` 目录下
root = "."
tmp_dir = "tmp"[build]
# 只需要写你平常编译使用的shell命令。你也可以使用 `make`
# Windows平台示例: cmd = "go build -o ./tmp/main.exe ."
cmd = "go build -o ./tmp/main.exe ."
# 由`cmd`命令得到的二进制文件名
# Windows平台示例:bin = "tmp/main.exe"
bin = "tmp/main.exe"
# 自定义执行程序的命令,可以添加额外的编译标识例如添加 GIN_MODE=release
full_bin = "tmp/main.exe config/config.yaml"
# Windows平台示例:full_bin = "./tmp/main.exe"
# Linux平台示例:full_bin = "APP_ENV=dev APP_USER=air ./tmp/main.exe"
#full_bin = "./tmp/main.exe"
# 监听以下文件扩展名的文件.
include_ext = ["go", "tpl", "tmpl", "html"]
# 忽略这些文件扩展名或目录
exclude_dir = ["assets", "tmp", "vendor", "frontend/node_modules"]
# 监听以下指定目录的文件
include_dir = []
# 排除以下文件
exclude_file = []
# 如果文件更改过于频繁,则没有必要在每次更改时都触发构建。可以设置触发构建的延迟时间
delay = 1000 # ms
# 发生构建错误时,停止运行旧的二进制文件。
stop_on_error = true
# air的日志文件名,该日志文件放置在你的`tmp_dir`中
log = "air_errors.log"[log]
# 显示日志时间
time = true[color]
# 自定义每个部分显示的颜色。如果找不到颜色,使用原始的应用程序日志。
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"[misc]
# 退出时删除tmp目录
clean_on_exit = true

makefile的编写

        makefile用于在linux部署,可能存在错误(因为项目并未在linux下测试过)

.PHONY: all build run gotool clean helpBINARY="GinBlog"all: gotool buildbuild:CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ./bin/${BINARY}run:@go run ./main.go conf/config.yamlgotool:go fmt ./go vet ./clean:@if [ -f ${BINARY} ] ; then rm ${BINARY} ; fihelp:@echo "make - 格式化 Go 代码, 并编译生成二进制文件"@echo "make build - 编译 Go 代码, 生成二进制文件"@echo "make run - 直接运行 Go 代码"@echo "make clean - 移除二进制文件和 vim swap files"@echo "make gotool - 运行 Go 工具 'fmt' and 'vet'"

docker

        dockerfile生成image

FROM golang:alpine AS builder# 为我们的镜像设置必要的环境变量
ENV GO111MODULE=on \GOPROXY=https://goproxy.cn,direct \CGO_ENABLED=0 \GOOS=linux \GOARCH=amd64# 移动到工作目录:/build
WORKDIR /build# 将代码复制到容器中
COPY . .# 下载依赖信息
RUN go mod download# 将我们的代码编译成二进制可执行文件 bubble
RUN go build -o ginblog .###################
# 接下来创建一个小镜像
###################
FROM debian:stretch-slim# 从builder镜像中把脚本拷贝到当前目录
COPY ./wait-for.sh /# 从builder镜像中把静态文件拷贝到当前目录
COPY ./templates /templates
COPY ./static /static# 从builder镜像中把配置文件拷贝到当前目录
COPY ./config /config# 从builder镜像中把/dist/app 拷贝到当前目录
COPY --from=builder /build/ginblog /EXPOSE 8808RUN echo "" > /etc/apt/sources.list; \echo "deb http://mirrors.aliyun.com/debian buster main" >> /etc/apt/sources.list ; \echo "deb http://mirrors.aliyun.com/debian-security buster/updates main" >> /etc/apt/sources.list ; \echo "deb http://mirrors.aliyun.com/debian buster-updates main" >> /etc/apt/sources.list ; \set -eux; \apt-get update; \apt-get install -y \--no-install-recommends \netcat; \chmod 755 wait-for.sh## 需要运行的命令
#ENTRYPOINT ["/ginblog", "config/config.yaml"]

        docker-compose启动

# yaml 配置
services:mysql:image: "oilrmutp57/mysql5.7:1.1"ports:- "3306:3306"command: "--default-authentication-plugin=mysql_native_password --init-file /data/application/init.sql"environment:MYSQL_ROOT_PASSWORD: "123"MYSQL_DATABASE: "ginblog"MYSQL_PASSWORD: "123"volumes:- ./init.sql:/data/application/init.sqlredis:image: "redis:7.4.1"ports:- "6379:6379"ginblog:build: .command: sh -c "./wait-for.sh mysql:3306 redis:6379 -- ./ginblog ./config/config.yaml"depends_on:- mysql- redisports:- "8808:8808"

        wait-for.sh

#!/bin/bashTIMEOUT=30
QUIET=0ADDRS=()echoerr() {if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi
}usage() {exitcode="$1"cat << USAGE >&2
client:$cmdname host:port [host:port] [host:port] [-t timeout] [-- command args]-q | --quiet                        Do not output any status messages-t TIMEOUT | --timeout=timeout      Timeout in seconds, zero for no timeout-- COMMAND ARGS                     Execute command with args after the test finishes
USAGEexit "$exitcode"
}wait_for() {results=()for addr in ${ADDRS[@]}doHOST=$(printf "%s\n" "$addr"| cut -d : -f 1)PORT=$(printf "%s\n" "$addr"| cut -d : -f 2)result=1for i in `seq $TIMEOUT` ; donc -z "$HOST" "$PORT" > /dev/null 2>&1result=$?if [ $result -ne 0 ] ; thensleep 1continuefibreakdoneresults=(${results[@]} $result)donenum=${#results[@]}for result in ${results[@]}doif [ $result -eq 0 ] ; thennum=`expr $num - 1`fidoneif [ $num -eq 0 ] ; thenif [ $# -gt 0 ] ; thenexec "$@"fiexit 0fiecho "Operation timed out" >&2exit 1
}while [ $# -gt 0 ]
docase "$1" in*:* )ADDRS=(${ADDRS[@]} $1)shift 1;;-q | --quiet)QUIET=1shift 1;;-t)TIMEOUT="$2"if [ "$TIMEOUT" = "" ]; then break; fishift 2;;--timeout=*)TIMEOUT="${1#*=}"shift 1;;--)shiftbreak;;--help)usage 0;;*)echoerr "Unknown argument: $1"usage 1;;esac
doneif [ "${#ADDRS[@]}" -eq 0 ]; thenechoerr "Error: you need to provide a host and port to test."usage 2
fiwait_for "$@"

总结

        这是Gin框架的Blog小demo,基本实现了登录注册,社区,发帖,投票功能,一些拓展的功能没有实现,但是其核心对于入门Gin来说,是不错的练习。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/883922.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

邮件系统SSL加密传输,保护你的电子邮件免受网络威胁

在互联网的浪潮中&#xff0c;企业数字化转型的步伐不断加快。企业邮箱作为数字化应用的重要组成部分&#xff0c;已成为员工沟通、协同工作和企业管理的关键工具。但是在公共网络安全性普遍较弱的背景下&#xff0c;黑客容易侵入企业网络&#xff0c;监控流量&#xff0c;截获…

跨平台开发支付组件,实现支付宝支付

效果图&#xff1a; custom-payment &#xff1a; 在生成预付订单之后页面中需要弹出一个弹层&#xff0c;弹层中展示的内容为支付方式&#xff08;渠道&#xff09;&#xff0c;由用户选择一种支付方式进行支付。 该弹层组件是以扩展组件 uni-popup 为核心的&#xff0c;关于…

usb学习笔记

1 学习链接 https://zhuanlan.zhihu.com/p/683251257https://zhuanlan.zhihu.com/p/683251257控制传输固定使用端点0 &#xff0c;枚举过程使用大量的控制传输&#xff0c;可参考后文中枚举过程的实际报文。控制传输为了保证配置数据的传输的有效性&#xff0c;使用了指令再确…

uniapp一键打包

1.先安装python环境&#xff0c; 2.复制这几个文件到uniapp项目里面 3.修改自己证书路径&#xff0c;配置文件路径什么的 4.在文件夹页面双击buildController.py或者cmd直接输入buildController.py 5.python报错&#xff0c;哪个依赖缺少安装哪个依赖 6.执行不动的话&…

基于Python的B站视频数据分析与可视化

基于Python的B站视频数据分析与可视化 爬取视频、UP主信息、视频评论 功能列表 关键词搜索指定帖子ID爬取指定UP主的主页爬取支持评论爬取生成评论词云图支持数据存在数据库支持可视化 部分效果演示 爬取的UP主信息 关键词搜索爬取 指定UP主的主页爬取 指定为黑马的了 爬取视…

图文并茂教你如何发布自己的NPM包(GitHub Packages npm 包发布)

前情提要 发布包到npm也好&#xff0c;到github packages仓库也好&#xff0c;都是一样的道理&#xff0c;只是仓库地址不一样而已&#xff0c;本文是将npm包发布到了GitHub Packages~ GitHub Packages 简介 GitHub Packages 是一种软件包托管服务&#xff0c;和npm类似&…

WPS设置下拉选项,下拉菜单如何添加

在物料参数工作表输入内容 然后选中要设置下拉选项的单元格 点击数据-》下拉列表 然后选中物料参数的A列就行了

小程序弹窗滑动穿透问题

<!-- page-meta 只能是页面内的第一个节点 --> <page-meta page-style"{{ show ? overflow: hidden; : }}" />

无人机避障——2D栅格地图pgm格式文件路径规划代码详解

代码和测试效果请看上一篇博客&#xff1a; 无人机避障——使用三维PCD点云生成的2D栅格地图PGM做路径规划-CSDN博客 更换模型文件.dae&#xff1a; 部分模型文件可以从这里下载&#xff1a; https://github.com/ethz-asl/rotors_simulator/wiki 将原先代码中的car.dae文件…

iPhone当U盘使用的方法 - iTunes共享文件夹无法复制到电脑怎么办 - 如何100%写入读出

效果图 从iPhone复制文件夹到windows电脑 步骤windows 打开iTunes通过USB连接iPhone和电脑手机允许授权iTunes中点击手机图标&#xff0c;进入到点击左边“文件共享”&#xff0c;在右边随便选择一个App&#xff08;随意...&#xff09;写入U盘&#xff1a;拖动电脑的文件&am…

python 爬虫抓取百度热搜

实现思路&#xff1a; 第1步、在百度热搜页获取热搜元素 元素类名为category-wrap_iQLoo 即我们只需要获取类名category-wrap_为前缀的元素 第2步、编写python脚本实现爬虫 import requests from bs4 import BeautifulSoupurl https://top.baidu.com/board?tabrealtime he…

【保姆级教程】Linux服务器本地部署Trilium+Notes笔记结合内网穿透远程在线协作

文章目录 前言1. 安装docker与docker-compose2. 启动容器运行镜像3. 本地访问测试4.安装内网穿透5. 创建公网地址6. 创建固定公网地址 前言 今天和大家分享一款在G站获得了26K的强大的开源在线协作笔记软件&#xff0c;Trilium Notes的中文版如何在Linux环境使用docker本地部署…

整合 flatten-maven-plugin 插件:解决子模块单独打包失败问题

整合 flatten-maven-plugin 插件&#xff1a;解决子模块单独打包失败问题 解决问题 我们来解决 Maven 多模块工程中&#xff0c;如果在父 pom 中定义了统一版本号 revision &#xff0c;单独对某个子模块执行 clean package 打包失败的问题。 [ERROR] Failed to execute goa…

PLC是如何扫描程序的?各位电气人都了解吗?

学习PLC必须要深刻理解PLC的扫描过程和执行原理&#xff0c;才能可靠无误的编写程序。通俗的讲PLC程序是从上往下&#xff0c;从左往右顺序循环扫描执行&#xff0c;它需要三个过程才真正输出实现外部动作。 第一步&#xff0c;先把外接的开关信号状态批量刷新到I输入映像区。 …

基于BLE的商业综合体室内定位导航系统的设计与实现

在大型商业综合体中&#xff0c;消费者常常因复杂的布局而感到困惑&#xff0c;如何高效、精准地引导顾客到达目标位置&#xff0c;成为提升购物体验的关键。BLE技术凭借其低功耗、高稳定性和广泛的设备兼容性&#xff0c;成为构建室内定位导航系统的理想选择。本文将详细探讨商…

CSS浮雕效果

效果图&#xff1a; HTML源码&#xff1a; <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>Documen…

音视频入门基础:FLV专题(18)——Audio Tag简介

一、引言 根据《video_file_format_spec_v10_1.pdf》第75页&#xff0c;如果某个Tag的Tag header中的TagType值为8&#xff0c;表示该Tag为Audio Tag&#xff1a; 这时StreamID之后紧接着的就是AudioTagHeader&#xff0c;也就是说这时Tag header之后的就是AudioTagHeader&…

《掌控Linux:全面解析用户与组管理的奥秘》

目录 引言 用户与组管理 一、理解用户账户和组 二、Linux用户账户及其类型 三、超级用户权限 &#xff08;一&#xff09;Ubuntu的sudo命令 1、使用su命令临时改变用户身份 2、sudo命令用于切换用户身份执行 四、用户配置文件 &#xff08;一&#xff09;用户账户配置…

exp:CVE-2024-2961将phpfilter任意文件读取提升为远程代码执行(RCE)

该exp来自于https://raw.githubusercontent.com/ambionics/cnext-exploits/main/cnext-exploit.py在原基础上添加了一个小改动&#xff0c;使其更加通用 修改后的exp顶部资源失效则https://www.123865.com/s/kN7jVv-uccLd 之前的命令行参数为 使用方式是python exp.py url com…

玄机-应急响应- Linux入侵排查

一、web目录存在木马&#xff0c;请找到木马的密码提交 到web目录进行搜索 find ./ type f -name "*.php" | xargs grep "eval(" 发现有三个可疑文件 1.php看到密码 1 flag{1} 二、服务器疑似存在不死马&#xff0c;请找到不死马的密码提交 被md5加密的…