【Bluebell】项目总结:基于 golang 的前后端分离 web 项目实战
文章目录
- Bluebell 项目总结:基于 golang 的前后端分离 web 项目实战
- 项目入口:先从 main 函数入手
- 准备工作:加载配置、初始化日志、初始化数据库等
- 加载配置
- 初始化日志
- 初始化 MySQL 连接
- 初始化 Redis 连接
- 初始化雪花算法
- 初始化 GIN 框架内置的校验器所使用的翻译器
- 注册路由并启动服务
- 路由注册:将具体的业务与路由相绑定
- Middleware
- RateLimitMiddleware:限流中间件,基于令牌桶算法
- JWTAuthMiddleware:鉴权中间件,用于处理与登录相关的业务
- 中间件总结
- 将业务逻辑与路由相绑定:以基本的 MySQL CRUD 为例
- Response 的封装
- 参数的封装
- signup:用户注册业务
- Controller
- Logic
- Dao:MySQL
- login:用户登录业务
- Controller
- Logic
- Dao:MySQL
- community:拉取社区信息
- Controller
- Logic
- Dao
- posts:获取帖子信息
- Controller
- Logic
- Dao
- post:发表帖子
- Controller
- Logic
- Dao:MySQL
- Redis Keys
- Dao:Redis
- 业务逻辑进阶:通过 Redis 当中帖子的分数降序地获取帖子列表
- Controller
- Logic
- Logic:GetPostList2,从所有社区查询帖子并按分数排序
- Logic:GetCommunityPostList2,从特定的社区查询帖子并按分数排序
- Dao:Redis
- Redis:GetpostIDsInOrder
- Redis:GetCommunityPostIDsInOrder
- Redis:GetPostVoteData
- Dao:MySQL
- MySQL:GetPostListByIDs
- MySQL:GetUserByID
- MySQL:GetCommunityDetailByID
- 业务逻辑进阶:基于 Redis 实现帖子点赞并记录
- Controller
- Logic
- Dao
- 工程化:以 Swagger 生成 RESTful 风格接口文档 / pprof 进行性能调优 / Docker 部署为例
- Swagger:生成 RESTful 风格的接口文档
- 第一步:添加注释
- 第二部:生成接口文档数据
- 第三步:引入 gin-swagger 渲染文档数据
- 使用 pprof 进行性能调优
- go tool pprof
- pprof + go-wrk
- 使用 Docker 对 bluebell 项目进行部署
Bluebell 项目总结:基于 golang 的前后端分离 web 项目实战
Bluebell 项目是 q1mi 老师 Golang web 开发进阶课程的实战项目,概括地来说这是一个仿 Reddit 论坛的前后端分离 Web 项目,由于 q1mi 老师的课程是付费的,因此我在学习的过程中并没有通过文章的形式对学习的过程进行记录,感兴趣的同学可以前往平台订阅 q1mi 老师的课程。本篇文章仅用于对 Bluebell 项目进行总结,以梳理 Golang web 开发思路,仅做学习与复习之用。
Bluebell 项目涉及到的技术栈比较完整,基本上可以应对大部分基于 Golang 进行的 Web 应用开发所需要的技术。所涉及的技术包括:
- Golang 的 GIN Web 框架实战;
- 将 zap 日志库集成到 GIN 当中用于日志记录;
- 通过 viper 进行参数配置管理;
- 基于 Controller + Logic + Dao(CLD 分层,有别于 MVC 开发模式)对应用进行分层,搭建 Web 开发的脚手架;
- Golang + MySQL(使用 sqlx 库)进行 CRUD;
- Golang + Redis(使用 go-redis 库)实现帖子的点赞计数;
- 在 GIN 框架当中集成 JWT 鉴权;
- 使用 snowflake 算法生成唯一的用户标识 ID;
- 使用令牌桶算法进行访问限流;
- 使用 Swagger 生成 RESTful 风格的接口文档;
- 通过 Docker 对 bluebell 项目进行部署。
- 使用 pprof 对 golang 程序进行性能分析;
- 使用 go-wrk 对 web 项目进行压力测试;
可以说,bluebell 项目基本上涵盖了所有必要的后端开发技术栈,其不足在于缺少时兴的诸如消息队列中间件、NoSQL 数据库等的实战使用。此外,bluebell 是一个单体 web 应用,不涉及到更高阶的微服务开发部分。通过 bluebell 项目对基于 golang 的 MySQL CRUD 以及 Redis 的使用进行熟悉可以说非常的合适。
下面让我们从 main 函数开始,对 bluebell 这个完整的 golang 后端项目进行回顾。
项目入口:先从 main 函数入手
bluebell 项目的 main 函数如下:
package main import ( "bluebell/controller" "bluebell/dao/mysql" "bluebell/dao/redis" "bluebell/logger" "bluebell/pkg/snowflake" "bluebell/router" "bluebell/settings" "context" "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" "go.uber.org/zap" ) // @title bluebell项目接口文档 // @version 1.0 // @description ... ... ... // @contact.name yggp // @contact.url ... ... ... // @host 127.0.0.1:8081 // @BasePath /api/v1 func main() { // 1. 加载配置 if err := settings.Init(); err != nil { fmt.Printf("init settings failed, err:%v\n", err) return } // 2. 初始化日志 if err := logger.Init(settings.Conf.LogConfig, settings.Conf.Mode); err != nil { fmt.Printf("init logger failed, err:%v\n", err) return } defer zap.L().Sync() zap.L().Debug("logger init success...") // 3. 初始化MySQL连接 if err := mysql.Init(settings.Conf.MySQLConfig); err != nil { fmt.Printf("init mysql failed, err:%v\n", err) return } defer mysql.Close() // 4. 初始化Redis连接 if err := redis.Init(settings.Conf.RedisConfig); err != nil { fmt.Printf("init redis failed, err:%v\n", err) return } defer redis.Close() // 初始化雪花算法 if err := snowflake.Init(settings.Conf.StartTime, settings.Conf.MachineID); err != nil { fmt.Printf("init snowflake failed, err:%v\n", err) return } // 初始化 gin 框架内置的校验器使用的翻译器 if err := controller.InitTrans("zh"); err != nil { fmt.Printf("init validator trans failed, err:%v\n", err) return } // 5. 注册路由 r := router.SetupRouter(settings.Conf.Mode) // 6. 启动服务 (优雅关机) fmt.Println(settings.Conf.Port) srv := &http.Server{ Addr: fmt.Sprintf(":%d", settings.Conf.Port), Handler: r, } go func() { // 开启一个goroutine启动服务 if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) } }() // 等待中断信号来优雅地关闭服务器,为关闭服务器操作设置一个5秒的超时 quit := make(chan os.Signal, 1) // 创建一个接收信号的通道 // kill 默认会发送 syscall.SIGTERM 信号 // kill -2 发送 syscall.SIGINT 信号,我们常用的Ctrl+C就是触发系统SIGINT信号 // kill -9 发送 syscall.SIGKILL 信号,但是不能被捕获,所以不需要添加它 // signal.Notify把收到的 syscall.SIGINT或syscall.SIGTERM 信号转发给quit signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // 此处不会阻塞 zap.L().Fatal("Server Shutdown", zap.Error(err)) } zap.L().Info("Server exiting") } fmt.Printf("init settings failed, err:%v\n", err) return } 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 { Host string `mapstructure:"host"` User string `mapstructure:"user"` 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:"host"` Password string `mapstructure:"password"` Port int `mapstructure:"port"` DB int `mapstructure:"db"` PoolSize int `mapstructure:"pool_size"` } func Init() (err error) { viper.SetConfigFile("./conf/config.yaml") //viper.SetConfigName("config") // 指定配置文件名称(不需要带后缀) //viper.SetConfigType("yaml") // 指定配置文件类型(专用于从远程获取配置信息时指定配置文件类型的) viper.AddConfigPath(".") // 指定查找配置文件的路径(这里使用相对路径) err = viper.ReadInConfig() // 读取配置信息 if err != nil { // 读取配置信息失败 fmt.Printf("viper.ReadInConfig() failed, err:%v\n", err) return } // 把读取到的配置信息反序列化到 Conf 变量中 if err := viper.Unmarshal(Conf); err != nil { fmt.Printf("viper.Unmarshal failed, err:%v\n", err) } viper.WatchConfig() viper.OnConfigChange(func(in fsnotify.Event) { fmt.Println("配置文件修改了...") if err := viper.Unmarshal(Conf); err != nil { fmt.Printf("viper.Unmarshal failed, err:%v\n", err) } }) return } // 读取配置信息失败 fmt.Printf("viper.ReadInConfig() failed, err:%v\n", err) return } fmt.Printf("viper.Unmarshal failed, err:%v\n", err) } fmt.Printf("init logger failed, err:%v\n", err) return } defer zap.L().Sync() zap.L().Debug("logger init success...") writeSyncer := getLogWriter( // cfg 是 Conf 当中保存的有关 logger 的配置 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 } var core zapcore.Core if mode == "dev" { // 开发模式, 日志输出到 Terminal consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) // 在日志库中内置的开发模式 // 日志可以有多种输出 core = zapcore.NewTee( zapcore.NewCore(encoder, writeSyncer, l), zapcore.NewCore(consoleEncoder, zapcore.Lock(os.Stdout), zap.DebugLevel), ) } else { core = zapcore.NewCore(encoder, writeSyncer, l) } lg := zap.New(core, zap.AddCaller()) // 替换zap库中全局的logger zap.ReplaceGlobals(lg) return } 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 return zapcore.NewJSONEncoder(encoderConfig) } func getLogWriter(filename string, maxSize, maxBackup, maxAge int) zapcore.WriteSyncer { lumberJackLogger := &lumberjack.Logger{ Filename: filename, MaxSize: maxSize, MaxBackups: maxBackup, MaxAge: maxAge, } return zapcore.AddSync(lumberJackLogger) } // GinLogger 接收 gin 框架默认的日志 func GinLogger() 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) zap.L().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 recover掉项目可能出现的panic,并使用zap记录相关日志 func GinRecovery(stack bool) gin.HandlerFunc { return func(c *gin.Context) { defer func() { if err := recover(); err != nil { // Check for a broken connection, as it is not really a // condition that warrants a panic stack trace. 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 { zap.L().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 { zap.L().Error("[Recovery from panic]", zap.Any("error", err), zap.String("request", string(httpRequest)), zap.String("stack", string(debug.Stack())), ) } else { zap.L().Error("[Recovery from panic]", zap.Any("error", err), zap.String("request", string(httpRequest)), ) } c.AbortWithStatus(http.StatusInternalServerError) } }() c.Next() } } fmt.Printf("init mysql failed, err:%v\n", err) return } defer mysql.Close() dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DbName, ) // 也可以使用MustConnect连接不成功就panic db, err = sqlx.Connect("mysql", dsn) if err != nil { zap.L().Error("connect DB failed", zap.Error(err)) return } db.SetMaxOpenConns(cfg.MaxOpenConns) db.SetMaxIdleConns(cfg.MaxIdleConns) return } fmt.Printf("init redis failed, err:%v\n", err) return } defer redis.Close() client = redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port, ), Password: cfg.Password, // no password set DB: cfg.DB, // use default DB PoolSize: cfg.PoolSize, }) _, err = client.Ping().Result() return } func Close() { _ = client.Close() } fmt.Printf("init snowflake failed, err:%v\n", err) return } var st time.Time st, err = time.Parse("2006-01-02", startTime) if err != nil { return } sf.Epoch = st.UnixNano() / 1000000 node, err = sf.NewNode(machineID) return } func GenID() int64 { return node.Generate().Int64() } fmt.Printf("init validator trans failed, err:%v\n", err) return } // 修改gin框架中的Validator引擎属性,实现自定制 if v, ok := binding.Validator.Engine().(*validator.Validate); ok { // 注册一个获取 json tag 的自定义方法, 从结构体字段中取 json tag 作为返回提示信息的字段 v.RegisterTagNameFunc(func(fld reflect.StructField) string { name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] if name == "-" { return "" } return name }) v.RegisterStructValidation(SignUpParamsStructLevelValidation, 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 } type SignUpParam struct { Age uint8 `json:"age" binding:"gte=1,lte=130"` Name string `json:"name" binding:"required"` Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required"` RePassword string `json:"re_password" binding:"required,eqfield=Password"` } // 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 } func SignUpParamsStructLevelValidation(sl validator.StructLevel) { su := sl.Current().Interface().(models.ParamSignUp) if su.Password != su.RePassword { sl.ReportError(su.RePassword, "re_password", "RePassword", "eqfield", "password") } } Addr: fmt.Sprintf(":%d", settings.Conf.Port), Handler: r, } go func() { // 开启一个goroutine启动服务 if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) } }() if mode == gin.ReleaseMode { gin.SetMode(gin.ReleaseMode) // gin 设置成发布模式 } r := gin.New() r.Use(logger.GinLogger(), logger.GinRecovery(true), middleware.RateLimitMiddleware(2*time.Second, 1)) r.LoadHTMLFiles("./templates/index.html") r.Static("/static", "./static") r.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) }) r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) v1 := r.Group("/api/v1") // 注册业务路由 v1.POST("/signup", controller.SignUpHandler) // 登录 v1.POST("/login", controller.LoginHandler) v1.GET("/community", controller.CommunityHandler) v1.GET("/community/:id", controller.CommunityDetailHandler) v1.GET("/post/:id", controller.GetPostDetailHandler) v1.GET("/posts/", controller.GetPostListDetailHandler) v1.GET("/posts2/", controller.GetPostListHandler2) v1.Use(middleware.JWTAuthMiddleware()) // 应用 JWT 鉴权中间件 { v1.POST("/post", controller.CreatePostHandler) // 根据时间或分数获取帖子列表 v1.POST("/vote", controller.PostVoteController) } pprof.Register(r) // 注册 pprof 相关路由 r.NoRoute(func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "msg": "404", }) }) return r } bucket := ratelimit.NewBucket(fillInterval, cap) return func(c *gin.Context) { // 如果取不到令牌就返回响应 if bucket.TakeAvailable(1) == 0 { c.String(http.StatusOK, "rate limit...") c.Abort() return } c.Next() } } return func(c *gin.Context) { // 客户端携带 Token 有三种方式: 1. 放在请求头; 2. 放在请求体; 3. 放在 URL // 此处假设 Token 放在 Header 的 Authorization 当中, 并使用 Bearer 开头 // 这里的具体实现需要根据具体的业务情况来定 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 } 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) 获取用户信息 } } UserID int64 `json:"user_id"` Username string `json:"username"` jwt.StandardClaims } // ParseToken 用于解析 JWT func ParseToken(tokenString string) (*MyClaims, error) { // 解析 token var 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 { return mc, nil } return nil, errors.New("invalid token") } // 首先创建一个我们自己的声明 c := MyClaims{ userID, username, jwt.StandardClaims{ ExpiresAt: time.Now().Add(time.Duration(viper.GetInt("auth.jwt_expire")) * time.Hour).Unix(), // 过期时间 Issuer: "bluebell", // 签发人, 可以写 app 的名称 }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, c) // jwt.SigningMethodHS256 是加密的算法 return token.SignedString(mySecret) } Code ResCode `json:"code"` Msg interface{} `json:"msg"` // 定义为空接口的原因是接收到的内容可能比较复杂 Data interface{} `json:"data,omitempty"` // 原因同上 } func ResponseError(c *gin.Context, code ResCode) { c.JSON(http.StatusOK, &ResponseData{ Code: code, Msg: code.Msg(), Data: nil, }) } func ResponseSuccess(c *gin.Context, data interface{}) { c.JSON(http.StatusOK, &ResponseData{ Code: CodeSuccess, Msg: CodeSuccess.Msg(), Data: data, }) } func ResponseErrorWithMsg(c *gin.Context, code ResCode, msg interface{}) { c.JSON(http.StatusOK, &ResponseData{ Code: code, Msg: msg, Data: nil, }) } CodeSuccess: "success", CodeInvalidParam: "请求参数错误", CodeUserExist: "用户名已存在", CodeUserNotExist: "用户名不存在", CodeInvalidPassword: "用户名或密码错误", CodeServerBusy: "服务繁忙", CodeNeedLogin: "需要登录", CodeInvalidToken: "无效的token", } func (c ResCode) Msg() string { msg, ok := codeMsgMap[c] if !ok { msg = codeMsgMap[CodeServerBusy] } return msg } 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 { // UserID 从请求中获取当前的用户 PostID string `json:"post_id" binding:"required"` // 帖子 ID Direction int8 `json:"direction,string" binding:"oneof=1 0 -1"` // 赞成票(1) or 反对票(-1) or 取消投票(0) } // ParamPostList 是获取帖子列表的 query string 参数 type ParamPostList struct { CommunityID int64 `json:"community_id" form:"community_id"` // 可以为空, 如果不传这个参数, 那么就按照所有社区去查询 Page int64 `json:"page" form:"page"` Size int64 `json:"size" form:"size"` Order string `json:"order" form:"order"` } type ParamCommunityPostList struct { *ParamPostList } // 1. 获取参数和参数校验 p := new(models.ParamSignUp) if err := c.ShouldBindJSON(p); err != nil { // 请求参数有误, 直接返回响应, 注意此处只能检查参数类型是否合规, 比如要求一个 string, 传入的是 int, 就会报错 zap.L().Error("SignUp with invalid param", zap.Error(err)) // 判断 err 是不是 validator.ValidationErrors 类型 errs, ok := err.(validator.ValidationErrors) if !ok { // 如果 err 不是 validator.ValidationErrors 类型, 说明在参数校验之前已经出错, 此时没必要翻译 ResponseError(c, CodeInvalidParam) // 使用 ResponseError 对错误响应进一步地封装 return } ResponseErrorWithMsg(c, CodeInvalidParam, removeTopStruct(errs.Translate(trans))) return } // 可以使用 Golang 的 validator 第三方库对参数进行校验, 它原生支持 gin 框架 // 2. 业务处理 if err := logic.SignUp(p); err != nil { zap.L().Error("logic.SignUp failed", zap.Error(err)) if errors.Is(err, mysql.ErrorUserNotExist) { ResponseError(c, CodeUserExist) return } ResponseError(c, CodeServerBusy) return } // 3. 返回响应 ResponseSuccess(c, nil) } // 1. 首先判断用户是否存在, 如果存在则直接返回 if err = mysql.CheckUserExist(p.Username); err != nil { return } // 2. 生成 UID userID := snowflake.GenID() // 构造一个 user 实例 user := &models.User{ UserID: userID, Username: p.Username, Password: p.Password, } // 3. 用户密码加密, 已经在 mysql.user 完成 // 4. 保存数据进入数据库, 设计到 dao 层 return mysql.InsertUser(user) } sqlStr := `select count(user_id) from user where username = ?` var count int if err = db.Get(&count, sqlStr, username); err != nil { return } if count 0 { return ErrorUserExist } return nil } // 对密码进行加密, 因为数据库中不应该存储明文的密码 user.Password = encryptPassword(user.Password) // 执行 SQL 语句入库 sqlStr := `insert into user(user_id, username, password) values(?, ?, ?)` _, err = db.Exec(sqlStr, user.UserID, user.Username, user.Password) return } // encryptPassword 对明文的密码进行加密 func encryptPassword(oPassword string) string { h := md5.New() h.Write([]byte(secret)) return hex.EncodeToString(h.Sum([]byte(oPassword))) } // 1. 获取请求参数, 并进行参数校验 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. 业务逻辑处理 /* 登录需要做什么? 1. 检查用户名是否存在, 如果不存在转到注册页面. 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.ErrorUserNotExist) { ResponseError(c, CodeUserNotExist) return } ResponseError(c, CodeInvalidPassword) return } // 3. 返回响应 //ResponseSuccess(c, token) ResponseSuccess(c, gin.H{ "user_id": fmt.Sprintf("%d", user.UserID), // 如果 ID 的值大于 1 user = &models.User{ Username: p.Username, Password: p.Password, } // mysql.Login 传递的是指针, 可以拿到 UserID if err = mysql.Login(user); err != nil { return nil, err } // 拿到 UserID 之后就可以生成 JWT token, err := jwt.GenToken(user.UserID, user.Username) if err != nil { return } user.Token = token return } oPassword := user.Password // 用户登录的密码 sqlStr := `select user_id, username, password from user where username=?` err = db.Get(user, sqlStr, user.Username) if err == sql.ErrNoRows { // 用户不存在·失败 return ErrorUserNotExist } if err != nil { // 查询数据库失败 return err } // 判断密码是否正确 password := encryptPassword(oPassword) if password != user.Password { // 密码错误·失败 return ErrorInvalidPassword } return } // 查询到所有的社区 (community_id, community_name), 以切片的形式返回 // 没有参数校验和检查, 直接从 Logic 层获取社区列表 data, err := logic.GetCommunityList() if err != nil { // 后端的错误通常不会详细地对外暴露 zap.L().Error("logic.GetCommunityList() failed", zap.Error(err)) ResponseError(c, CodeServerBusy) // 不轻易将服务端的报错暴露给外面 return } ResponseSuccess(c, data) } // 简单来说这个函数的作用就是查找数据库当中所有的 community, 并返回 return mysql.GetCommunityList() } sqlStr := `select community_id, community_name from community` if err = db.Select(&communityList, sqlStr); err != nil { if err == sql.ErrNoRows { // select 语句没有查询到记录, 返回空的分类列表 zap.L().Warn("There is no community in db") err = nil } } return } // GetCommunityDetailByID 根据 id 查询社区详情 func GetCommunityDetailByID(id int64) (community *models.CommunityDetail, err error) { sqlStr := `select community_id, community_name, introduction, create_time from community where community_id = ?` community = new(models.CommunityDetail) if err = db.Get(community, sqlStr, id); err != nil { if err == sql.ErrNoRows { err = ErrorInvalidID } } return community, err } 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,omitempty" db:"introduction"` CreateTime time.Time `json:"create_time" db:"create_time"` } page, size := getPageInfo(c) // 1. 获取数据 data, err := logic.GetPostList(page, size) if err != nil { zap.L().Error("logic.GetPostList() failed", zap.Error(err)) ResponseError(c, CodeServerBusy) return } ResponseSuccess(c, data) // 2. 返回响应 } // 0. 获取分页参数 pageStr := c.Query("page") sizeStr := c.Query("size") var ( page int64 size int64 err error ) page, err = strconv.ParseInt(pageStr, 10, 64) if err != nil { page = 1 } size, err = strconv.ParseInt(sizeStr, 10, 64) if err != nil { size = 10 } return page, size } // 查询 posts, err := mysql.GetPostList(page, size) if err != nil { return } data = make([]*models.ApiPostDetail, 0, len(posts)) for _, 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.GetCommunityDetailByID(post.CommunityID)", zap.Int64("community_id", post.CommunityID), zap.Error(err)) continue } postDetail := &models.ApiPostDetail{ AuthorName: user.Username, Post: post, CommunityDetail: community, } data = append(data, postDetail) } return } ID int64 `json:"id,string" db:"post_id"` AuthorID int64 `json:"author_id" db:"author_id"` CommunityID int64 `json:"community_id" db:"community_id" binding:"required"` Status 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"` // 嵌入社区信息的结构体 } sqlStr := `select post_id, title, content, author_id, community_id, create_time from post ORDER BY create_time DESC limit ?,? ` posts = make([]*models.Post, 0, 2) err = db.Select(&posts, sqlStr, (page-1)*size, size) return } user = new(models.User) sqlStr := `select user_id, username from user where user_id = ?` err = db.Get(user, sqlStr, uid) return } sqlStr := `select community_id, community_name, introduction, create_time from community where community_id = ?` community = new(models.CommunityDetail) if err = db.Get(community, sqlStr, id); err != nil { if err == sql.ErrNoRows { err = ErrorInvalidID } } return community, err } // 1. 获取参数及参数校验 p := new(models.Post) // 涉及到帖子的结构, 保存在 models/post.go 下, 上文已经提到过 if err := c.ShouldBindJSON(p); err != nil { zap.L().Debug("c.ShouldBindJSON(p) error", zap.Any("err", err)) zap.L().Error("Create post with invalid param") ResponseError(c, CodeInvalidParam) return } // 从 c.Context 当中取得当前发起请求的用户的 id userID, err := getCurrentUser(c) if err != nil { ResponseError(c, CodeNeedLogin) return } p.AuthorID = userID // 2. 创建帖子, 交给 logic 层 if err = logic.CreatePost(p); err != nil { zap.L().Error("logic.CreatePost(p) failed", zap.Error(err)) ResponseError(c, CodeServerBusy) return } // 3. 返回响应 ResponseSuccess(c, nil) } uid, ok := c.Get(CtxUserIDKey) if !ok { err = ErrorUserNotLogin return } userID, ok = uid.(int64) if !ok { err = ErrorUserNotLogin return } return } // 1. 生成 post_id, 通过雪花算法 p.ID = snowflake.GenID() // 2. 保存到数据库 && 3. 返回 err = mysql.CreatePost(p) if err != nil { return err } err = redis.CreatePost(p.ID, p.CommunityID) return } 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 } return Prefix + key } pipeline := client.TxPipeline() // 帖子时间 pipeline.ZAdd(getRedisKey(KeyPostTimeZSet), redis.Z{ // getRedisKey(KeyPostTimeZSet) 得到 "bluebell:post:time" Score: float64(time.Now().Unix()), Member: postID, }) // 帖子分数 pipeline.ZAdd(getRedisKey(KeyPostScoreZSet), redis.Z{ // getRedisKey(KeyPostScoreZSet) 得到 "bluebell:post:score" Score: float64(time.Now().Unix()), Member: postID, }) // 把帖子 id 加到社区的 set 当中 cKey := getRedisKey(KeyCommunitySetPF + strconv.Itoa(int(communityID))) pipeline.SAdd(cKey, postID) _, err := pipeline.Exec() return err } // GET 请求参数: /api/v1/posts2?page=1&size=10&order=time (query string 参数) // 获取分页参数 // c.ShouldBind() 根据请求的数据类型动态地获取数据 // 👇 初始化结构体时指定初始函数 p := &models.ParamPostList{ Page: 1, Size: 10, Order: models.OrderTime, } if err := c.ShouldBindQuery(p); err != nil { zap.L().Error("GetPostListHandler2 with invalid params", zap.Error(err)) ResponseError(c, CodeInvalidParam) return } // 获取数据 data, err := logic.GetPostListNew(p) // 更新: 合二为一 if err != nil { zap.L().Error("logic.GetPostList() failed", zap.Error(err)) ResponseError(c, CodeServerBusy) return } ResponseSuccess(c, data) } CommunityID int64 `json:"community_id" form:"community_id"` // 可以为空, 如果不传这个参数, 那么就按照所有社区去查询 Page int64 `json:"page" form:"page"` Size int64 `json:"size" form:"size"` Order string `json:"order" form:"order"` } if p.CommunityID == 0 { data, err = GetPostList2(p) } else { data, err = GetCommunityPostList2(p) } if err != nil { zap.L().Error("GetPostListNew failed", zap.Error(err)) return nil, err } return } // 2. 去 redis 查询 id 列表 ids, err := redis.GetPostIDsInOrder(p) if err != nil { //fmt.Println("IM HERE 1") return } if len(ids) == 0 { // 没有必要继续查询下去 zap.L().Warn("redis.GetPostIDsInOrder(p) returns 0 data") return } // 3. 根据 id 去 mysql 数据库查询帖子的详细信息 // 返回的数据还要按照给定的 id 的顺序返回 posts, err := mysql.GetPostListByIDs(ids) if err != nil { //fmt.Println("IM HERE 2") 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.GetCommunityDetailByID(post.CommunityID)", 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 } // 2. 去 redis 查询 id 列表 ids, err := redis.GetCommunityPostIDsInOrder(p) if err != nil { return } if len(ids) == 0 { // 没有必要继续查询下去 zap.L().Warn("redis.GetPostIDsInOrder(p) returns 0 data") return } // 3. 根据 id 去 mysql 数据库查询帖子的详细信息 // 返回的数据还要按照给定的 id 的顺序返回 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.GetCommunityDetailByID(post.CommunityID)", 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 获取 id // 1. 首先根据用户请求中携带的 order 参数确定要查询的 redis 的 key key := getRedisKey(KeyPostTimeZSet) if p.Order == models.OrderScore { key = getRedisKey(KeyPostScoreZSet) } return getIDsFromKey(key, p.Page, p.Size) } func getIDsFromKey(key string, page, size int64) ([]string, error) { // 2. 确定查询的索引起始点 start := (page - 1) * size end := start + size - 1 // 3. ZREVRANGE 按分数从大到小的顺序查询指定数量的元素 return client.ZRevRange(key, start, end).Result() } orderKey := getRedisKey(KeyPostTimeZSet) if p.Order == models.OrderScore { orderKey = getRedisKey(KeyPostScoreZSet) } // 使用 zinterstore 把分区的帖子 set 与帖子分数的 zset 生成一个新的 zset // 针对新的 zset, 按之前的逻辑取数据 // 从 redis 获取 id // 社区的 key cKey := getRedisKey(KeyCommunitySetPF + strconv.Itoa(int(p.CommunityID))) // 利用缓存 key 减少 zinterstore 的执行次数 key := orderKey + strconv.Itoa(int(p.CommunityID)) if client.Exists(key).Val() 更新分数和投票记录 direction = -1时, 有两种情况: 1. 之前没有投过票, 现在投反对票 -> 更新分数和投票记录 2. 之前投过赞成票, 现在改投反对票 -> 更新分数和投票记录 ========== 投票的限制 ========== 每个帖子自发表之日起, 一个星期之内, 允许用户投票. 超过一个星期就不允许再投票了. 1. 到期之后将 redis 中保存的赞成票数以及反对票数存储到 mysql 表中 (因为这些数据是已经变冷的数据); 2. 到期之后删除那个 KeyPostVotedZSetPrefix */ // VoteForPost 是为帖子投票的函数 func VoteForPost(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)) }
在 Logic 层没有要具体处理的业务逻辑,直接去 Dao 层对 Redis 当中保存的投票数进行修改即可。
Dao
const ( oneWeekInSeconds = 7 * 24 * 3600 scorePerVote = 432 // 每一票值多少分 ) var ( ErrVoteTimeExpired = errors.New("投票时间已过") ErrVoteRepeated = errors.New("不允许重复投票") ) func VoteForPost(userID, postID string, value float64) error { // 1. 判断投票的限制 // 去 redis 取帖子的发布时间 postTime := client.ZScore(getRedisKey(KeyPostTimeZSet), postID).Val() if float64(time.Now().Unix())-postTime > oneWeekInSeconds { fmt.Println(time.Now().Unix(), postTime) return ErrVoteTimeExpired } // 2. 更新分数 // 先查当前用户给当前帖子的投票记录 ov := client.ZScore(getRedisKey(KeyPostVotedZSetPF+postID), userID).Val() // 如果这次投票的值和之前保存的值一致, 则提示不允许投票 if value == ov { return ErrVoteRepeated } var op float64 if value > ov { op = 1 } else { op = -1 } diff := math.Abs(ov - value) // 计算两次投票的差值 // 2 和 3 需要放到同一个事物当中 pipeline := client.TxPipeline() pipeline.ZIncrBy(getRedisKey(KeyPostScoreZSet), op*diff*scorePerVote, postID) // 3. 记录用户为该帖子投过票的数据 if value == 0 { pipeline.ZRem(getRedisKey(KeyPostVotedZSetPF+postID), postID) } else { pipeline.ZAdd(getRedisKey(KeyPostVotedZSetPF+postID), redis.Z{ Score: value, // 当前用户投的是赞成票还是反对票 Member: userID, }) } _, err := pipeline.Exec() return err }
在 Dao 层,首先判断当前投票行为是否在允许的时间范围内,如果已经超过了时间范围,那么返回 ErrVoteTimeExpired。值得注意的是,此处使用了 Redis 的 ZScore 方法,它的作用是从某个 Sorted Set(由 Redis Key 指定)获取 Member(即 postID) 的 Score。
否则,我们先去 Redis 查找当前用户之前是否为帖子投过票,如果之前投过票且投票行为与本次相同,那么返回 ErrVoteRepeated,禁止用户重复投票。同样通过 Redis 的 ZScore 方法来完成,Redis Key 是 getRedisKey(KeyPostVotedZSetPF+postID),即 postID 指定的帖子的投票情况集合,member 是 userID,即查询当前用户之前对该帖子的投票行为。
如果用户没有投过票,或者之前的投票行为与本次不同,那么首先判断出本次的投票行为,保存到 op 当中。
之后计算两次投票的差值。
接下来我们开启一个 Redis 事务记录投票并修改帖子分数。首先修改帖子的分数,通过 ZIncrBy 来完成,它的作用是修改 Sorted Set 中某个 Member 的 Score。然后记录当前用户对帖子投票的情况,如果 value 为 0 表示用户取消投票,使用 ZRem 将用户从 Sorted Set 中移除。否则通过 ZAdd 将用户当前的投票行为加入到 Sorted Set 当中。
值得注意的是在使用 ZAdd 时,如果用户之前已经使用了 ZAdd 添加相同的 Member 到 Sorted Set,那么本次 ZAdd 将会把之前的 Member 和 Score 覆盖掉。
工程化:以 Swagger 生成 RESTful 风格接口文档 / pprof 进行性能调优 / Docker 部署为例
在后端开发的基础上,一个完整的项目应该有撰写良好的文档,供前端及相关开发人员使用,这可以通过 swagger 来帮助我们完成。此外,如何对我们程序的性能进行调优也是重要的,通过 golang 内置的 pprof 可以帮助我们完成。最后,我们希望自己的程序可以被打包并快速部署,这可以通过 Docker 来完成。
接下来我将回顾如何将 Swagger / pprof / Docker 与 bluebell 项目相结合。
Swagger:生成 RESTful 风格的接口文档
这部分引用自 q1mi 老师的技术博客:
Swagger本质上是一种用于描述使用JSON表示的RESTful API的接口描述语言。Swagger与一组开源软件工具一起使用,以设计、构建、记录和使用RESTful Web服务。Swagger包括自动文档,代码生成和测试用例生成。
使用 gin-swagger 可以为我们的代码自动生成接口文档,一般需要三步:
- 按照 swagger 的要求给接口代码添加声明式注释;
- 使用 swag 工具扫描代码,以自动生成 API 接口文档的数据;
- 使用 gin-swagger 渲染在线接口文档页面,并与 GIN 的 router 绑定。
第一步:添加注释
首先在程序入口 main 函数添加项目介绍的相关信息:
// @title bluebell 项目接口文档 // @version 1.0 // @description bluebell 项目接口文档 // @termsOfService http://swagger.io/terms // @contact.name yggp // @license.name Apache 2.0 // @host 127.0.0.1:8081 // @BasePath /api/v1 func main() { // ... ... ... }
接下来在代码中处理请求的接口函数(通常位于 Controller 层)按照如下方式添加注释:
// CreatePostHandler 创建帖子接口 // @Summary 创建帖子接口 // @Description 用户登录后可以创建帖子 // @Tags 帖子相关接口 // @Accept application/json // @Produce application/json // @Param Authorization header string true "Bearer 用户令牌" // @Param object query models.Post false "查询参数" // @Security ApiKeyAuth // @Success 200 // @Router /post [post] func CreatePostHandler(c *gin.Context) { // 1. 获取参数及参数校验 p := new(models.Post) if err := c.ShouldBindJSON(p); err != nil { zap.L().Debug("c.ShouldBindJSON(p) error", zap.Any("err", err)) zap.L().Error("Create post with invalid param") ResponseError(c, CodeInvalidParam) return } // 从 c.Context 当中取得当前发起请求的用户的 id userID, err := getCurrentUser(c) if err != nil { ResponseError(c, CodeNeedLogin) return } p.AuthorID = userID // 2. 创建帖子, 交给 logic 层 if err = logic.CreatePost(p); err != nil { zap.L().Error("logic.CreatePost(p) failed", zap.Error(err)) ResponseError(c, CodeServerBusy) return } // 3. 返回响应 ResponseSuccess(c, nil) } // GetPostListHandler2 升级版帖子列表接口 // @Summary 升级版帖子列表接口 // @Description 可按社区按时间或分数排序查询帖子列表接口 // @Tags 帖子相关接口 // @Accept application/json // @Produce application/json // @Param Authorization header string false "Bearer 用户令牌" // @Param object query models.ParamPostList false "查询参数" // @Security ApiKeyAuth // @Success 200 {object} _ResponsePostList // @Router /posts2 [get] func GetPostListHandler2(c *gin.Context) { // GET 请求参数: /api/v1/posts2?page=1&size=10&order=time (query string 参数) // 获取分页参数 //c.ShouldBind() 根据请求的数据类型动态地获取数据 // 👇 初始化结构体时指定初始函数 p := &models.ParamPostList{ Page: 1, Size: 10, Order: models.OrderTime, } if err := c.ShouldBindQuery(p); err != nil { zap.L().Error("GetPostListHandler2 with invalid params", zap.Error(err)) ResponseError(c, CodeInvalidParam) return } // 获取数据 data, err := logic.GetPostListNew(p) // 更新: 合二为一 if err != nil { zap.L().Error("logic.GetPostList() failed", zap.Error(err)) ResponseError(c, CodeServerBusy) return } ResponseSuccess(c, data) }
第二部:生成接口文档数据
通过 swag 工具生成接口文档,gin-swagger 的安装可参考 q1mi 老师的博客(使用 Windows 的同学需要将 swag 导入到环境变量才能使用 swag init)。
使用 swag init 生成接口文档数据,如果注释格式没问题,项目根目录下会多出一个 doc 文件夹。
第三步:引入 gin-swagger 渲染文档数据
首先需要按照 q1mi 老师的教程引入 gin-swagger,之后在 GIN 中注册路由。
启动项目,访问 localhost:8081/swagger/index.html 我们就可以得到 swagger 文档:
使用 pprof 进行性能调优
在 GIN 框架下可以直接使用:
pprof.Register(r) // 注册 pprof 相关路由
来绑定 pprof 相关路由。
此时,当我们启动项目之后,可以在 /debug/pprof 获得下面的内容:
在这里我们看不出什么门道,想要进行性能调优需要进一步使用 pprof 可视化工具。
go tool pprof
go tool pprof 的用法如下:
go tool pprof [binary] [source]
- binary 是应用的二进制文件,用来解析各种符号;
- source 表示 profile 数据的来源,可以是本地文件,也可以是 http 地址;
获取的 Profiling 数据是动态的,想要获得有效的数据,请保证应用处于较大的负载(比如使用go-wrk对服务接口进行压测的过程中获取 profiling)。否则,如果应用处于空闲状态,得到的结果意义不大。
pprof + go-wrk
现在我们使用 go-wrk 开启压测,然后使用 pprof 进行性能分析。
首先使用 go-wrk 进行压测:
go-wrk -t=8 -c=100 -n=10000 "http://127.0.0.1:8081/api/v1/posts"
然后进行性能分析:
go tool pprof http://127.0.0.1:8081/debug/pprof/profile
在 pprof 的 Terminal 下输入 web 可以得到:
使用 Docker 对 bluebell 项目进行部署
最后一步就是使用 Docker 对 bluebell 项目进行部署。在进行这一步之前,我们需要确保所使用的 mysql 和 redis 都是来自于 docker 的,这样才能够一步成功,因为我们需要在打包 docker 之后与外部 docker 进行交互,如果使用本机的 mysql 或 redis,则 docker 会找不到服务器。
首先编写 Dockerfile:
FROM golang:alpine AS builder # 为我们的镜像设置必要的环境变量 ENV GO111MODULE=on \ GOPROXY=https://goproxy.cn,direct \ CGO_ENABLED=0 \ GOOS=linux \ GOARCH=amd64 # 移动到工作目录:/build WORKDIR /build # 将代码复制到容器中 COPY go.mod . COPY go.sum . RUN go mod download COPY . . # 将我们的代码编译成二进制可执行文件 app RUN go build -o bluebell_app . FROM debian:stretch-slim COPY ./wait-for.sh / COPY ./templates /templates COPY ./static /static COPY ./conf /conf # 从builder镜像中把/dist/app 拷贝到当前目录 COPY --from=builder /build/bluebell_app / RUN set -eux; \ apt get updates; \ apt get install -y \ --no-install-recommends \ netcat; \ chmod 755 wait-for.sh EXPOSE 8081
之后我们构建镜像并指定镜像名为 bluebell:
docker build . -t bluebell_app
我们在启动 docker 的时候需要关联其它容器:
docker run --link=mysql:mysql --link=redis507:redis507 -p 8081:8081 bluebell_app
当然在启动前我们需要修改我们项目的 yaml 配置文件:
name: "web_app" mode: "dev" port: 8081 version: "v0.0.1" start_time: "2020-02-14" machine_id: 1 auth: jwt_expire: 8760 log: level: "debug" filename: "web_app.log" max_size: 200 max_age: 30 max_backups: 7 mysql: host: mysql port: 13306 user: "root" password: "root" dbname: "bluebell" max_open_conns: 200 max_idle_conns: 50 redis: host: redis507 port: 6379 password: "" db: 0 pool_size: 100
将 mysql 和 redis 的 host 修改为 docker 的名称。
在 Terminal 直接运行,即可启动程序。
至此,我们完整地回顾了整个 bluebell 项目。