当前位置: 首页 > article >正文

【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) // 此处不会阻塞
	<-quit                                               // 阻塞在此,当接收到上述两种信号时才会往下执行
	zap.L().Info("Shutdown Server ...")
	// 创建一个5秒超时的context
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	// 5秒内优雅关闭服务(将未处理完的请求处理完再关闭服务),超过5秒就超时退出
	if err := srv.Shutdown(ctx); err != nil {
		zap.L().Fatal("Server Shutdown", zap.Error(err))
	}

	zap.L().Info("Server exiting")
}

从 main 函数体出发,bluebell 项目在启动时,先后进行:

  1. 加载配置(通过 viper);
  2. 初始化日志(通过 zap);
  3. 初始化 MySQL 连接;
  4. 初始化 Redis 连接;
  5. 初始化 snowflake 算法;
  6. 初始化 GIN 框架内置的翻译器;
  7. 注册路由;
  8. 启动服务;
  9. 配置优雅关机;

其中,业务代码的实现主要是在注册路由环节 ,其余的环节是为路由注册环节做的准备工作。因此我们首先复盘一下 bluebell 项目在完成路由注册之前,先进行了哪些准备工作。

准备工作:加载配置、初始化日志、初始化数据库等

加载配置

加载配置的语句段是:

if err := settings.Init(); err != nil {
	fmt.Printf("init settings failed, err:%v\n", err)
	return
}

它通过 settings 包下的 Init 函数完成配置初始化。settings 包下仅包含 settings.go 一个文件,其内容为:

package settings

import (
	"fmt"

	"github.com/fsnotify/fsnotify"
	"github.com/spf13/viper"
)

// Conf 全局变量,用来保存程序的所有配置信息
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 {
	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
}

在 settings 当中,我们初始化一个名为 Conf 的全局变量(单例模式),它的类型是 AppConfig。AppConfig 就是一个用于保存 bluebell 项目当中要用到的配置参数的结构体,包括项目名称、模式、版本、启动时间、数据库配置等。通过 mapstructure 来完成 yaml 文件中参数命与结构体参数名的一一映射。

Init 函数对 Conf 对象进行了初始化,通过 viper 来完成。具体来说,viper 通过 SetConfigFile 方法配置要解析的参数文件:

viper.SetConfigFile("./conf/config.yaml")

通过 ReadInConfig 方法读取配置文件:

err = viper.ReadInConfig() // 读取配置信息
if err != nil {
	// 读取配置信息失败
	fmt.Printf("viper.ReadInConfig() failed, err:%v\n", err)
	return
}

再通过 Unmarshal 方法将配置文件解析到传入到结构体当中:

if err := viper.Unmarshal(Conf); err != nil {
	fmt.Printf("viper.Unmarshal failed, err:%v\n", err)
}

config.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: localhost
  port: 3306
  user: "root"
  password: "root"
  dbname: "bluebell"
  max_open_conns: 200
  max_idle_conns: 50
redis:
  host: localhost
  port: 6379
  password: ""
  db: 0
  pool_size: 100

至此我们便完成了全局参数的配置,在后续开发其它 golang web 项目的时候,可以直接重复使用这一套参数配置的脚手架。参数配置的好处在于,我们将一些需要人为手动配置的参数,统一放在了一个 config.yaml 文件当中。当其中一项配置被更改时,我们不需要到代码当中每一个硬编码的地方寻找要更改的参数,而直接修改配置文件当中的参数即可。一个最直观的例子就是数据库地址的更改。

初始化日志

准备工作的第二项是对日志进行初始化,其步骤在 main 当中的体现为:

// 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...")

日志的初始化通过 logger 包下的 Init 函数完成。logger 包下同样只包含 logger.go 一个文件:

package logger

import (
	"bluebell/settings"
	"net"
	"net/http"
	"net/http/httputil"
	"os"
	"runtime/debug"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/natefinch/lumberjack"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)

// Init 初始化Logger
func Init(cfg *settings.LogConfig, mode string) (err error) {
	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()
	}
}

实际上我们需要关注的只有 logger 文件当中的 Init 和 getEncoder 函数。GinLogger 和 GinRecovery 的作用是将 zap 日志集成到 GIN 框架当中,我们将在后面准备 GIN 的时候提到。

Init 函数当中所做的就是对 zap 日志进行了初始化,最后将初始化好的日志对象通过 zap.ReplaceGlobals(lg) 替换为全局的日志对象。在之后的使用当中,通过 zap.L() 即可获取这个全局的日志对象,并对日志进行记录。准备日志时的:

zap.L().Debug("logger init success...")

这条语句就是通过全局的日志对象记录一个 Debug 级别的记录。

初始化 MySQL 连接

语句如下:

if err := mysql.Init(settings.Conf.MySQLConfig); err != nil {
	fmt.Printf("init mysql failed, err:%v\n", err)
	return
}
defer mysql.Close()

与数据库相关的操作统一放到了 dao 层,因此与 mysql 和 redis 初始化相关的代码同样也放在了 dao 层的对应位置。
在这里插入图片描述
初始化 mysql 的操作在 dao/mysql/mysql.go 当中进行。初始化 MySQL 连接要做的就是将全局的数据库对象与对应的 MySQL 数据库进行关联:

var db *sqlx.DB

func Init(cfg *settings.MySQLConfig) (err error) {
	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
}

bluebell 项目中通过 sqlx 对数据库进行操作。首先通过 Connect 方法建立与 MySQL 数据库的连接。数据库的配置参数同样放在了 config.yaml 当中,通过 cfg 传递给 Init。db 是一个 sqlx.DB 类型的指针,它扮演的角色就是一个在全局用于对数据库进行操作的对象。通过 Connect 方法初始化 db 之后,进行一些连接参数的配置,就完成了数据库的初始化。

在初始化数据库时,defer 了一个 Close,用于项目退出时关闭数据库连接。

初始化 Redis 连接

Redis 连接的初始化与 MySQL 类似。

// 4. 初始化Redis连接
if err := redis.Init(settings.Conf.RedisConfig); err != nil {
	fmt.Printf("init redis failed, err:%v\n", err)
	return
}
defer redis.Close()

其配置放在了 dao 层的 redis 目录下:
在这里插入图片描述
bluebell 通过 go-redis 对 bluebell 当中的 Redis 连接进行管理。

与 MySQL 初始化时通过 sqlx 对 db 对象进行初始化以用于全局的数据库操作管理类似,在 Redis 初始化时,同样是建立了一个 redis 的 Client,命名为 client,其作用是支持全局的 Redis 操作交互:

// 声明一个全局的rdb变量
var (
	client *redis.Client
	Nil    = redis.Nil
)

// Init 初始化连接
func Init(cfg *settings.RedisConfig) (err error) {
	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()
}

初始化雪花算法

接下来我们要对 snowflake 算法进行初始化。snowflake 算法的作用就是生成一个全局唯一的 ID 标识,在 bluebell 中用于对新注册的用户进行标记。snowflake 的初始化方法如下:

// 初始化雪花算法
if err := snowflake.Init(settings.Conf.StartTime, settings.Conf.MachineID); err != nil {
	fmt.Printf("init snowflake failed, err:%v\n", err)
	return
}

通过 Init 函数进行初始化,Init 被放在了 pkg/snowflake/snowflake.go 当中:
在这里插入图片描述
在 bluebell 当中并没有重复造轮子,而是通过一个已有的 snowflake 包初始化 snowflake 算法:

package snowflake

import (
	sf "github.com/bwmarrin/snowflake"
	"time"
)

var node *sf.Node

func Init(startTime string, machineID int64) (err error) {
	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()
}

Init 初始化了 node 这个节点,后续在使用 snowflake 的时候,我们通过 GenID 这个导出的方法即可生成 Int64 类型的全局唯一 ID。

初始化 GIN 框架内置的校验器所使用的翻译器

翻译器的作用其实就是将错误信息翻译成中文,便于开发者调试:

// 初始化 gin 框架内置的校验器使用的翻译器
if err := controller.InitTrans("zh"); err != nil {
	fmt.Printf("init validator trans failed, err:%v\n", err)
	return
}

它被放在了 controller 的 validator 当中,validator.go 当中实现了一些参数校验要用到的方法,包括翻译器、去除提示信息当中结构体名称的函数、校验必填字段等:

package controller

import (
	"bluebell/models"
	"fmt"
	"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"
	"github.com/go-playground/validator/v10"
	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 的自定义方法, 从结构体字段中取 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")
	}
}

validator 当中的内容可以被视为脚手架的一部分,用于前后端分离项目当中前后端相互通讯的部分。

注册路由并启动服务

准备工作的最后一步就是注册路由,注册好路由之后就可以启动服务了:

// 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)
	}
}()

注册路由当中包含着具体的业务实现,我们将在下一节进行详述。我们先来回顾一下服务启动部分的实现。

这部分在我看来非常的亲切,因为我刚刚复盘过 Gee 和 GeeRPC 项目。通过创建一个 http.Server 建立服务器,Handler 就是具体的业务处理逻辑,在其中将路由与业务相绑定,传入的实参需要实现 Handler 接口,即必须具有 ServeHTTP 方法。我们在注册路由部分得到的 r 实际上是一个 *gin.Engine 类型的对象,它实现了 Handler 接口。

最后,启动一个 goroutine 通过 ListenAndServe 开启服务即可。

后面的:

// 等待中断信号来优雅地关闭服务器,为关闭服务器操作设置一个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) // 此处不会阻塞
<-quit                                               // 阻塞在此,当接收到上述两种信号时才会往下执行

将会阻塞 main 函数,直到 Terminal 当中有终止进程的信号传来,使得 <-quit 接收到信号,才会进一步执行后续的代码。在此之前,开启服务的 goroutine 不会停止,实现了优雅关机。

路由注册:将具体的业务与路由相绑定

现在我们来仔细研究一下 SetupRouter 函数,它被放在了 router/routes.go 当中,其作用是将路由与业务相绑定,并返回 *gin.Engine 对象,用于传给 http.Server。SetupRouter 的实现如下:

package router

import (
	"bluebell/controller"
	_ "bluebell/docs"
	"bluebell/logger"
	"bluebell/middleware"
	"github.com/gin-contrib/pprof"
	"github.com/gin-gonic/gin"
	"github.com/swaggo/files"
	ginSwagger "github.com/swaggo/gin-swagger"
	"net/http"
)

func SetupRouter(mode string) *gin.Engine {
	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
}

有了 Gee 项目的基础,理解 GIN 就没有那么困难了。GIN 同样通过一个 Engine 对象对中间件、分组、Router 与业务绑定等进行统筹与管理。

首先通过 gin.New() 初始化一个 Engine 指针。通过 Use 绑定全局中间件,包括 GinLogger、GinRecovery 和限流中间件 RateLimitMiddleware。前两个中间件的作用是将 zap 日志集成到 GIN 框架当中,第三个中间件通过令牌桶算法实现全局路由访问量的限流。有关令牌桶中间件的使用我在之前的一篇文章当中有所提及:常用的限流策略简介——以漏桶和令牌桶为例。

之后要做的就是将业务函数与 Router 进行绑定了,通过 GET 和 POST 方法来完成。值得注意的是此处通过 ginSwagger 将 Swagger 与 GIN 的路由相绑定,生成 RESTful API。

然后 bluebell 新建了一个前缀为 /api/v1 的分组 v1,v1 下保存的是具体的业务处理逻辑。比如通过 POST 方法可以实现注册和登录等功能,通过 GET 方法可以拉取当前存在的社区以及帖子。

bluebell 还支持创建帖子以及为帖子点赞,但是在创建帖子以及点赞之前,按照 web 应用的逻辑,应该首先进行用户登录,因此在绑定这两项业务之前,bluebell 为 v1 分组加入了 JWT 鉴权中间件。

通过 pprof.Register(r) 注册了与 pprof 性能调试相关的路由。

最后,通过 GIN Engine 的 NoRoute 方法,绑定了当用户访问不存在的路由时的逻辑,即返回 404 状态码。

完成路由注册后,将 *Engine 返回,后续将用于 http.Server 的实例创建。

在 bluebell 项目中,业务处理存在一定的重复,比如有获取社区以及获取帖子等业务,其基本的逻辑是一致的,因此我将挑选几个比较有代表性的业务进行仔细梳理。在梳理业务之前,我们先来仔细研究一下 GIN 所用到的中间件。

将 GIN 与 zap 继承的两个 middleware 我们暂且略过,这两个 middleware 是 GIN + zap 的脚手架。我们仔细研究一下 RateLimitMiddleware 限流中间件以及 JWTAuthMiddleware 鉴权中间件。

Middleware

RateLimitMiddleware:限流中间件,基于令牌桶算法

package middleware

import (
	"github.com/gin-gonic/gin"
	"github.com/juju/ratelimit"
	"net/http"
	"time"
)

func RateLimitMiddleware(fillInterval time.Duration, cap int64) func(c *gin.Context) {
	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()
	}
}

限流中间件的实现非常的简单。bluebell 在实现时,没有重复造轮子,而是使用现有的支持 golang web 项目限流的库 ratelimit 实现这个中间件。

首先,创建一个令牌桶 bucket。之后,返回一个参数为 *gin.Context 的匿名函数。如果取不到令牌,则返回响应,并通过 Abort 来确保后续的业务处理 Handler 不会继续执行。如果可以取到令牌,那么就调用 c.Next() 执行下一个 Handler。

JWTAuthMiddleware:鉴权中间件,用于处理与登录相关的业务

鉴权中间件在需要登录的场景下使用,比如针对帖子发表以及帖子点赞计数等与用户信息高度相关的场景。JWTAuthMiddleware 的实现如下:

package middleware

import (
	"bluebell/controller"
	"bluebell/pkg/jwt"
	"github.com/gin-gonic/gin"
	"strings"
)

// JWTAuthMiddleware 是基于 JWT 的认证中间件
func JWTAuthMiddleware() func(c *gin.Context) {
	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) 获取用户信息
	}
}

它需要使用我们在 /pkg/jwt/jwt.go 当中实现的一些功能,不过我们先关注中间件本身。

正如注释当中所说,鉴权中间件假定 Authorization 信息被放在 Request Header 当中。因此该中间件首先从 Header 读取 Authorization 字段下的值,保存在 authHeader 当中。如果 authHeader 当中,表明当前访问的用户尚未登录,通过 Abort 方法停止后续 Handler 的调用并返回。

否则对 authHeader 进行解析,如果通过空格分割的字段不是两部分,或是第一部分不为 Bearer,同样 Abort。这一步解析成功,才会进一步对 Token 进行解析。通过 jwt 包中的 ParseToken 来完成:

// mySecret 加严
var mySecret = []byte("YGGP")


// MyClaims 自定义声明结构体并内嵌 jwt.StandardClaims
type MyClaims struct {
	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")
}

可以理解为,通过外部的 jwt 包调用 ParseWithClaims,它会根据 mySecret 字段对 tokenString 进行解析,得到的值保存在 MyClaims 类型的 mc 当中。相当于对传入的 Authorization 进行解析,而这个 Authorization 是在用户登录时分配给用户的,Authorization 当中包含着用户等登录信息等个人信息,以及 Authorization 过期时间。在解析完毕后,如果 token 合法,则将 mc 返回,通过 mc 可以解析出用户的 ID 等信息。

bluebell 的 jwt 包中还包含一个 GenToken 函数:

// 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:    "bluebell",                                                                        // 签发人, 可以写 app 的名称
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, c) // jwt.SigningMethodHS256 是加密的算法
	return token.SignedString(mySecret)
}

它用于根据用户 ID 、用户名以及我们指定的加密码、过期时间来生成 Authorization。成功登录的用户应该通过设置 Request Header 当中的 Bearer Authorization 字段来进行鉴权。

中间件总结

至此我们回顾了一下 bluebell 当中所使用到的限流中间件和鉴权中间件,给我最大的感受是,对于这些常用的 web 开发工具,我们是不需要造轮子的,比如用于限流的令牌桶以及用于鉴权的 jwt。重点仍然是如何将中间件应用到具体的业务场景当中。

将业务逻辑与路由相绑定:以基本的 MySQL CRUD 为例

接下来我们以注册、登录以及拉取社区详情、拉取帖子详情等业务功能为例,对 bluebell 项目的 CLD 分层架构、golang + sqlx + redis 操作数据库的内容进行回顾。

在开始之前,先简单介绍一下 bluebell 项目中用到的 CLD 分层架构。C 指的是 Controller 层,在这一层要进行的是参数获取以及参数校验;L 指的是 Logic 层,在这一层处理具体的业务逻辑;D 指的是 Dao 层,在这一层直接和数据库进行交互。

Response 的封装

一个 web 应用无非就是接收到一个 HTTP Request 之后,根据相应的 Method 以及业务逻辑,进行处理,并返回 Response。bluebell 项目对 Response 进行了封装,为了后续项目复盘的推进,此处首先对 Response 的封装进行回顾。

Response 封装保存在 controller 包当中的 response.go 文件下,原因在于 Response 的回发在 Controller 这一层完成。Response 分为三种情况,分别是Error、Success 以及 ErrorWithMsg:

type ResponseData struct {
	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,
	})
}

回发的状态码定义在 controller 包的 code.go 文件当中:

package controller

type ResCode int64

const (
	CodeSuccess ResCode = 1000 + iota
	CodeInvalidParam
	CodeUserExist
	CodeUserNotExist
	CodeInvalidPassword
	CodeServerBusy

	CodeInvalidToken
	CodeNeedLogin
)

var codeMsgMap = map[ResCode]string{
	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
}

参数的封装

bluebell 项目将业务要用到的参数保存在了 models 包下,同样是为了项目复盘的推进,此处先总结一系列后续要用到的参数模型。

/models/params.go 当中,保存了一些与业务相关的参数,比如用户注册、登录的参数,帖子投票的参数等:

package models

const (
	OrderTime  = "time"
	OrderScore = "score"
)

// 定义请求参数的结构体

type 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 {
	// 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
}

signup:用户注册业务

将用户注册业务与对应的路由相绑定,使用 POST 方法:

// 注册业务路由
v1.POST("/signup", controller.SignUpHandler)
Controller

我们首先来到 Controller 层,研究一下 SignUpHandler 的实现:

// controller 获取参数并进行参数校验
func SignUpHandler(c *gin.Context) {
	// 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)
}

在用户通过访问注册绑定的路由发起注册业务的 Request 时,我们与前端约定通过 JSON 结构传递数据,因此 bluebell 服务器将会接收到一个保存了用户注册信息的 JSON 数据流。

我们首先通过 new 创建一个 ParamSignUp 类型的指针 p,通过 c.ShouldBindJSON 将数据流当中的 JSON 结构与 p 进行绑定。之后进行一系列参数校验的工作,如果参数校验无误,就将参数指针 p 传递给 logic 层,进行具体的业务处理。如果业务处理无误,就通过 ResponseSuccess 返回响应。

Logic

SignUp 函数放在 Logic 层当中,用于处理用户登录的业务逻辑,其接受的参数是 ParamSignUp 类型的指针:

func SignUp(p *models.ParamSignUp) (err error) {
	// 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)
}

首先会去 dao 层,通过 mysql 查看当前用户是否存在,如果不存在,则继续执行注册逻辑。

由于我们需要为每一个用户分配一个全局唯一的 ID,因此我们使用 snowflake 的 GenID 通过雪花算法生成全局唯一 ID。之后就可以构造一个 User 实例了,User 结构同样保存在 models 包当中。最后将构造好的 user 插入到数据库当中,即可完成用户的注册。

Dao:MySQL

注册业务的 Logic 层有两处设计到与 Dao 层进行交互的部分,分别是验证用户是否存在(查),以及插入用户信息到数据库(增)。

我们首先来回顾一下 CheckUserExist:

// CheckUserExist 判断用户是否存在
func CheckUserExist(username string) (err error) {
	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
}

首先构造一条 SQL 语句 sqlStr,通过使用全局数据库对象 db 的 Get 方法执行语句,并将查询结果保存到 count 当中。如果 count 大于 0,表示当前用户已存在。

再来回顾 InsertUser:

// InsertUser 向数据库中插入一条新的用户记录
func InsertUser(user *models.User) (err error) {
	// 对密码进行加密, 因为数据库中不应该存储明文的密码
	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)))
}

插入用户数据时,首先需要对密码进行加密,然后构造 SQL 的插入语句,通过调用 db 的 Exec 方法完成插入。

login:用户登录业务

实现了用户注册业务之后,用户登录业务的实现就非常简单了,在复盘之前,我们可以想象,用户的登录将会复用用户注册时实现的方法。

// 登录
v1.POST("/login", controller.LoginHandler)
Controller

Controller 层的实现如下:

func LoginHandler(c *gin.Context) {
	// 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 << 53 - 1 (js number 类型的最大值, 而 int64 的最大值是 1 << 63 - 1)
		"user_name": user.Username,
		"token":     user.Token,
	})
}

参数解析成功后,通过 Logic 层进行登录的业务处理。此处分为两种情况,如果用户不存在,则返回“用户不存在”的状态码,反之用户存在的话,则应该构造响应结构体并返回,包括返回 userID、username 以及 Authorization Token,Token 用于用户 JWT 鉴权。

Logic

Logic 层的实现如下:

func Login(p *models.ParamLogin) (user *models.User, err error) {
	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
}

业务逻辑还是比较直观的。也就是分为两种情况,如果用户不存在,则返回不存在的错误信息,否则为当前登录用户根据 userID 和 username 生成 token,并返回。

Dao:MySQL

Dao 层的实现如下:

func Login(user *models.User) (err error) {
	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
}

首先通过 username 从数据库查找加密的密码,再对当前传入的密码加密以比对密码是否匹配。

community:拉取社区信息

方才我们复盘的注册和登录业务使用的 Method 都是 POST,现在我们来回顾两个使用 GET 方法的路由。首先我们回顾拉取社区信息的 Router 及其业务函数。

v1.GET("/community", controller.CommunityHandler)
Controller

Controller 层的实现如下:

func CommunityHandler(c *gin.Context) {
	// 查询到所有的社区 (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)
}

由于获取社区信息使用的是 GET 方法,因此不需要进行参数校验,直接从 Logic 层拉取信息即可。

Logic

Logic 层的实现如下:

func GetCommunityList() ([]*models.Community, error) {
	// 简单来说这个函数的作用就是查找数据库当中所有的 community, 并返回
	return mysql.GetCommunityList()
}

同样,我们在 Logic 层不需要进行业务处理,唯一的业务就是从 Dao 层获取数据。

Dao

我们在 Dao 层回顾两种获取社区数据的方法,分别是获取所有社区的信息以及根据社区的 ID 获取信息:

func GetCommunityList() (communityList []*models.Community, err error) {
	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
}

与 Community 相关的参数保存在了 models 当中的 community.go 下:

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,omitempty" db:"introduction"`
	CreateTime   time.Time `json:"create_time" db:"create_time"`
}

Community 结构包含 ID 和 Name,而 CommunityDetail 结构进一步囊括了 Introduction 和 CreateTime。

Dao 层在做的实际上就是 SQL 语句的执行,没有复杂难以理解的点。

posts:获取帖子信息

与 community 类似,posts 要做的就是获取帖子的信息:

v1.GET("/posts/", controller.GetPostListDetailHandler)

由于一个帖子包含社区信息、帖子信息、帖子详细内容、帖子作者等属性,因此获取帖子信息的实现比获取社区列表要更加复杂。此处我们首先复盘最基础的“获取帖子信息”的方法,之后我们复盘更加复杂的基于点赞数排序获取帖子信息的方法。

Controller

其 Controller 层下的实现如下:

func GetPostListDetailHandler(c *gin.Context) {
	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. 返回响应
}

这里的 getPageInfo 函数是用于获取当前页的配置信息的函数。需要配置 PageInfo 的原因是,前端页面一次可能显示不下所有帖子的信息,因此需要分页。getPageInfo 的实现如下:

func getPageInfo(c *gin.Context) (int64, int64) {
	// 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
}

首先通过 c.Query 从 URL 中解析 page 和 size 参数,解析得到的参数是 string 类型的,需要通过 strconv.ParseInt 将 string 转为 Int64,最后返回 page 和 size 参数,传递给 Logic 层。

Logic

Logic 层的实现如下:

// GetPostList 用于获取帖子列表
func GetPostList(page, size int64) (data []*models.ApiPostDetail, err error) {
	// 查询
	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
}

首先,通过 Dao 层的 GetPostList 获取 posts 列表。

之后,构造一个 ApiPostDetail 指针类型的 list,用于保存具体的 post 信息。ApiPostDetail 结构保存在 models 的 post.go 下:

package models

import "time"

// 结构体中内存对齐的概念: 类型相同的成员尽可能放在一起
type Post struct {
	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"` // 嵌入社区信息的结构体
}

可以看到,ApiPostDetail 包含了非常复杂的帖子信息,包含作者姓名、帖子投票数、帖子详细信息以及社区详细信息等细节。

回到 Logic 层的实现,我们根据 Dao 层 GetPostList 得到的帖子列表,通过 for loop 对每一个帖子遍历,得到帖子的细节。

具体来说,分别通过 Dao 层的 GetUserByID 和 GetCommunityDetailByID 获取该帖子的作者信息以及社区信息,最后构造一个 ApiPostDetail 对象并追加到 data 列表当中。

Dao

posts 的 Logic 层中有三处涉及到与 Dao 的交互。

首先是 GetPostList,它的作用是基于帖子的创建时间返回帖子列表:

// GetPostList 查询帖子列表数据, 应该从新到旧得到结果
func GetPostList(page, size int64) (posts []*models.Post, err error) {
	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
}

此处的 page 和 size 是分页参数。

然后是 GetUserByID:

// 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
}

它做的就是根据 user_id 查找用户名。

最后是 GetCommunityDetailByID:

// 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 查找社区详情。

post:发表帖子

下面我们回顾一个更加复杂的例子,即 bluebell 的帖子发表功能。为什么说它复杂,因为在访问这个路由之前需要先通过 JWT 鉴权中间件对用户进行鉴权,只有登录之后的用户才能够发表帖子。其路由注册方法如下:

v1.POST("/post", controller.CreatePostHandler)

在进入到 controller 之前,我们先根据之前已经复习过的内容,思考一下 post 需要完成哪些业务逻辑。

假定现在用户已经登录,即 HTTP Request 被成功鉴权,我们进入到 Controller 层的参数获取与参数校验,首先获取与帖子发表相关的参数,比如帖子标题、帖子内容、帖子所属社区、作者信息等。获取参数之后,我们进入 Logic 层,处理具体的逻辑,实际上也就是将这个帖子插入到数据库当中,这就涉及到与 Dao 层当中数据库插入的交互。

经过简单的分析,现在我们来深入源码复盘一下 post 路由所绑定的业务。

Controller

Controller 的实现如下:

// CreatePostHandler 是创建帖子的处理函数
func CreatePostHandler(c *gin.Context) {
	// 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)
}

此处值得研究的是 getCurrentUser 函数的实现,不难看出它应该是根据当前 context 获取用户 userID 的方法,在回顾这个函数之前,我推测它应该是根据 Authorization Token 解码出所保存的 token。

getCurrentUser 函数的实现如下:

// getCurrentUser 获取当前登录的用户 ID
func getCurrentUser(c *gin.Context) (userID int64, err error) {
	uid, ok := c.Get(CtxUserIDKey)
	if !ok {
		err = ErrorUserNotLogin
		return
	}
	userID, ok = uid.(int64)
	if !ok {
		err = ErrorUserNotLogin
		return
	}
	return
}

它通过 gin.Context 的 Get 方法获取 CtxUserIDKey。CtxUserIDKey 在鉴权中间件中也被使用,用于在对 Authorization Token 进行解码之后将 userID 保存到 Context 当中。看来我们的推测无误。

接下来我们进入到 Logic 层,研究一下如何将 post 插入到数据库当中。

Logic

Logic 层当中的实现如下:

func CreatePost(p *models.Post) (err error) {
	// 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
}

我们之前的推测少了一步,与 UserID 类似,每一个帖子也有自己全局唯一的 ID,因此在与 Dao 层交互之前,需要先通过 snowflake 算法生成一个 UserID,将其加入到 Post 结构当中。

值得注意的是,在创建帖子时,不仅应该把帖子插入到 MySQL 当中,还要把帖子插入到 Redis 当中,原因是我们后续需要根据帖子的点赞数来对帖子进行排序。

Dao:MySQL

首先来处理将帖子插入到 MySQL 当中的逻辑:

// CreatePost 创建帖子
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
}

非常简单,就是通过 sqlx 将参数传入到 SQL 语句中。

Redis Keys

在深入如何将 Post 信息插入到 Redis 当中之前,我们首先来回顾一下 bluebell 项目 Redis key 的设计。key 的设计保存在 /dao/redis/keys.go 当中:

package redis

// redis 的 key
// 使用冒号分隔: redis key 尽量使用命名空间的方式区分不同的 key, 方便查询和拆分

const (
	Prefix             = "bluebell:"   // 项目 key 前缀
	KeyPostTimeZSet    = "post:time"   // zset; 帖子及发帖时间为分数
	KeyPostScoreZSet   = "post:score"  // zset; 帖子及投票的分数
	KeyPostVotedZSetPF = "post:voted:" // zset; 记录用户及投票的类型; 参数是 post_id
	// set 和 zset 可以通过 ZINTERSCORE 操作取得交集
	KeyCommunitySetPF = "community:" // set; 保存每个分区下帖子的 id
)

// getRedisKey 加上前缀
func getRedisKey(key string) string {
	return Prefix + key
}

Dao:Redis

我们重点来看一下如何将帖子插入到 Redis 当中。实际上,在 Logic 层插入 Redis 的语句中,只传递了两个参数,分别是 PostID 和 CommunityID。

将帖子信息插入到 Redis 当中的实现如下:

func CreatePost(postID, communityID int64) error {
	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
}

首先开启了一个 Redis 事务,用于原子性地执行 CreatePost 当中的 Redis 语句,确保一旦成功则全部成功,有一条失败则全部失败。

通过 ZAdd 方法将 postID 加入到 bluebell:post:time 这个 Sorted Set 当中,在第一次创建帖子时,由于这个有序集合还没有创建,Redis 会帮助我们自动创建有序集合,后续插入到有序集合当中的记录会根据键的值(帖子创建的时间)自动排序。

然后通过 ZAdd 方法将 postID 的初始分数加入到 bluebell:post:score 这个 Sorted Set 当中。初始的分数仍然与时间相关联,这与直觉相符,越新的帖子分数应该越高,使得新的帖子尽可能靠前显示。

最后通过 SAdd 将 postID 插入到 community:$CommunityID$ 当中,它是一个 Set,不需要有序,如果没有创建,则 Redis 会帮助我们创建,用于记录每一个 CommunityID 下有哪些帖子(通过 PostID 关联 Post)。

业务逻辑进阶:通过 Redis 当中帖子的分数降序地获取帖子列表

bluebell 这个项目的亮点就是基于 Redis 对帖子的分数进行排序。我们刚才已经回顾了如何基于帖子的创建时间降序地(越新发表的帖子排在越前)获取帖子列表,现在我们研究一个更有难度的场景,那就是根据帖子的创建时间降序地获取帖子列表。

这部分逻辑将于 posts2 路由相绑定:

v1.GET("/posts2/", controller.GetPostListHandler2)

Controller

Controller 层的实现如下:

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)
}

ParamPostList 保存在 models 当中的 params.go 文件下,它的定义是:

const (
	OrderTime  = "time"
	OrderScore = "score"
)


// 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"`
}

Controller 首先初始化了一个 ParamPostList 类型的指针,之后我们根据 GET 方法传入的 URL 进行进一步的参数绑定,如果 URL 没有传入参数,那么就使用默认值。

然后我们就去 Logic 层获取 PostList。

Logic

Logic 层的实现如下:

// GetPostListNew 将两个查询逻辑合二为一变为一个函数
func GetPostListNew(p *models.ParamPostList) (data []*models.ApiPostDetail, err error) {
	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
}

如何 CommunityID 为 0,那么默认从所有 Community 按分数排序对帖子进行查询,否则根据指定的 CommunityID 对帖子进行查询。

Logic:GetPostList2,从所有社区查询帖子并按分数排序

GetPostList2 的实现同样在 Logic 层,其实现为:

func GetPostList2(p *models.ParamPostList) (data []*models.ApiPostDetail, err error) {
	// 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
}

GetPostList2 的逻辑如下:

首先在 Redis 当中根据分数对帖子列表进行查询。

查询到 PostID 的有序列表之后,再通过 PostID 去 MySQL 对具体的帖子进行查询。

每一个帖子具有其各自点赞数,在 Redis 中根据 PostID 对点赞数进行查询,它也是帖子属性的一部分。

最后对每一个帖子进行遍历,从中查找帖子的作者信息(通过 AuthorID 查找 UserName)和社区信息(通过 CommunityID 查找 CommunityDetail)。

通过查找到的信息构造一个代表帖子具体信息的 ApiPostDetail 对象,即完成了一个帖子的查询,将其 append 到返回最终查询结果的列表当中即可。

至此我们完成了 GetPostList2 的逻辑分析,其中有三次与 MySQL 的 Dao 层交互,一次与 Redis 的 Dao 层交互,我们稍后再来研究 Dao 层。

Logic:GetCommunityPostList2,从特定的社区查询帖子并按分数排序

GetCommunityPostList2 的实现如下:

func GetCommunityPostList2(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) 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
}

其在实现上其实和从所有社区获取帖子是一样的,只不过需要根据特定的 CommunityID 从 Redis 获取排序的 PostID 列表。

Dao:Redis

按照顺序,我们先来研究 Logic 层中与 Redis 的 Dao 交互。

Redis:GetpostIDsInOrder

第一种情况就是获取所有社区中帖子按分数排序的 PostID 列表:

func GetPostIDsInOrder(p *models.ParamPostList) ([]string, error) {
	// 从 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()
}

很直观,就是先定位到具体的 Sorted Set,再根据分数得到 PostID 列表。

Redis:GetCommunityPostIDsInOrder

GetCommunityPostIDsInOrder 相较于直接从所有的社区获取按分数排序的帖子而言更加的复杂:

// GetCommunityPostIDsInOrder 按社区根据 ids 查找数据
func GetCommunityPostIDsInOrder(p *models.ParamPostList) ([]string, error) {
	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() < 1 {
		// 不存在, 则需要计算
		pipeline := client.Pipeline()
		pipeline.ZInterStore(key, redis.ZStore{
			Aggregate: "MAX",
		}, cKey, orderKey) // zinterstore 计算
		pipeline.Expire(key, 60*time.Second) // 设置超时时间
		_, err := pipeline.Exec()
		if err != nil {
			return nil, err
		}
	}
	// 存在的话就直接根据 key 查询 ids
	return getIDsFromKey(key, p.Page, p.Size)
}

其中,orderKey 是根据排序方式(帖子创建时间/帖子分数)生成的 Redis 键。cKey 是帖子的社区集合键。key 是一个缓存键,用于存储按社区以及排序方式生成的临时有序集合,创建这个键的目的是减少 ZInterStore 的执行次数。

接下来的 if 条件句判断 key 是否存在于 Redis 当中。如果不存在则需要重新开始计算,即执行 if 函数体。ZInterStore 的作用是将社区帖子集合 cKey 和排序帖子集合 orderKey 进行交集运算,并将结果存储到 key 当中。Aggregate: "MAX" 的作用是在进行交集运算时,如果两个集合中存在相同的元素,那么取分数较大的那一个。最后设置 key 的过期时间为 60 秒,避免缓存数据长时间占用内存。

最后通过 getIDsFromKey 通过 ZRevRange 获取帖子 IDs 列表。

Redis:GetPostVoteData
// GetPostVoteData 根据 ids 查询每篇帖子的投赞成票的数据
func GetPostVoteData(ids []string) (data []int64, err error) {
	// 使用 pipeline 一次发送多条命令, 减少 RTT
	pipeline := client.Pipeline()

	for _, id := range ids {
		key := getRedisKey(KeyPostVotedZSetPF + id)
		pipeline.ZCount(key, "1", "1")
	}

	cmders, err := pipeline.Exec()
	if err != nil {
		return nil, err
	}

	data = make([]int64, 0, len(cmders))
	for _, cmder := range cmders {
		v := cmder.(*redis.IntCmd).Val()
		data = append(data, v)
	}
	return
}

GetPostVoteData 这个函数的作用是从 Redis 中根据帖子列表查找每一个帖子的赞成票数。通过 Redis 的 ZCount 方法来完成。ZCount 的三个参数分别是 Sorted Set 的 Key,要查询的最小值和最大值区间。一个例子如下:
在这里插入图片描述
最后将 Redis 查询的结果保存在 Go 的 int64 slice 当中返回即可。

Dao:MySQL

我们再来研究 Logic 层与 Dao 层中 MySQL 的交互。我们先来捋顺一下“获取帖子列表”当中有哪些操作需要与 MySQL 进行交互。首先是通过 PostIDs 获取帖子的具体信息,然后是根据帖子的作者 ID AuthorID 获取作者的姓名,最后是通过帖子的社区 ID CommunityID 获取社区的详细信息。

MySQL:GetPostListByIDs
// 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, ?)
	`
	query, args, err := sqlx.In(sqlStr, ids, strings.Join(ids, ","))
	if err != nil {
		return nil, err
	}

	query = db.Rebind(query)
	err = db.Select(&postList, query, args...)
	return
}

sqlx.In 用于处理子句中的动态参数,query 即为参数绑定后的 SQL 语句,args 是参数列表。

MySQL:GetUserByID
// 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
}

通过 userID 查找 user 的信息。

MySQL:GetCommunityDetailByID
// 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
}

根据 communityID 查找 community 的信息。

我们可以注意到,再查找用户信息和社区信息时,执行 SQL 语句时使用的是 sqlx 的 Get 方法,而执行 SQL 语句查找帖子列表时使用的是 sqlx 的 Select 方法。Get 方法用于查询单条记录,而 Select 可以查找多条记录并将结果记录到 slice 当中。

业务逻辑进阶:基于 Redis 实现帖子点赞并记录

方才我们已经回顾了如何通过 Redis 获取根据帖子创建时间或帖子点赞数排序的帖子列表,现在我们来回顾一下如何实现基于用户为帖子点赞的行为来改变帖子分数的逻辑。

为帖子点赞的业务逻辑绑定在 vote 路由下,使用 POST 方法:

v1.POST("/vote", controller.PostVoteController)

Controller

首先我们来回顾一下 Controller 层的实现,Controller 层要做的事情就是参数获取和参数校验,由于使用 POST 方法,那么必然在接收到的 HTTP Request 当中携带着参数。这个参数应该与帖子点赞相关,因此它应该包含 PostID、UserID 以及点赞的行为(up / down)。

简单分析 Controller 的思路之后,我们来看一下 Controller 的实现:

func PostVoteController(c *gin.Context) {
	// 1. 参数校验
	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 := getCurrentUser(c)
	if err != nil {
		ResponseError(c, CodeNeedLogin)
		return
	}
	// 实现投票的业务逻辑
	if err = logic.VoteForPost(userID, p); err != nil {
		zap.L().Error("logic.VoteForPost() failed", zap.Error(err))
		ResponseError(c, CodeServerBusy)
		return
	}
	ResponseSuccess(c, nil)
}

首先,记录与帖子点赞相关的参数被保存在 ParamVoteData 下:

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)
}

之所以没有 UserID 的原因是因为 UserID 可以通过这个 HTTP Request 携带的 Authorization 恢复出来。即通过 getCurrentUser 函数获得。我们刚才已经说过,鉴权中间件将 userID 保存到了本次 HTTP 请求的 Context 当中,因此可以通过 getCurrentUser 从 Context 中获取 userID,此处不再重复。

获取 userID、PostID 以及 Direction 之后,就可以去 Logic 层处理具体的逻辑了。

Logic

/*
	本项目使用简化版的投票分数
	用户投一票, 就加 432 分, 86400 / 200 即需要 200 张赞成票才可以给帖子续一天, 这个例子来自于 《Redis 实战》

	投票的几种情况:
		direction = 1时, 有两种情况:
	1. 之前没有投过票, 现在投赞成票 -> 更新分数和投票记录
	2. 之前投了反对票, 现在反投赞成票 -> 更新分数和投票记录

		direction = 0时, 有两种情况:
	1. 之前投过赞成票, 现在要取消投票 -> 更新分数和投票记录
	2. 之前投过反对票, 现在要取消投票 -> 更新分数和投票记录

		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 可以为我们的代码自动生成接口文档,一般需要三步:

  1. 按照 swagger 的要求给接口代码添加声明式注释;
  2. 使用 swag 工具扫描代码,以自动生成 API 接口文档的数据;
  3. 使用 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 项目。


http://www.kler.cn/a/595464.html

相关文章:

  • ue5蓝图项目转换为c++项目 遇到的问题
  • VectorBT:Python量化交易策略开发与回测评估详解
  • 如何缓解大语言模型推理中的“幻觉”(Hallucination)?
  • deepSpeed多机多卡训练服务器之间,和服务器内两个GPU是怎么通信
  • 识别并脱敏上传到deepseek/chatgpt的文本文件中的身份证/手机号
  • 单片机自学总结
  • 架构设计之自定义延迟双删缓存注解(上)
  • 【C++基础】Lambda 函数 基础知识讲解学习及难点解析
  • vscode连接本地mysql数据库
  • 解决python配置文件类configparser.ConfigParser,插入、读取数据,自动转为小写的问题
  • LLM之向量数据库Chroma milvus FAISS
  • SOFAStack-00-sofa 技术栈概览
  • ip2region与express最佳实践
  • Linux 文件系统的日志模式与性能影响
  • RC6在线加密工具
  • PaddleSpeech-语音处理-安装【超简洁步骤】
  • 关于 Redis 缓存一致
  • 北京南文观点:AI掘金术激活算法中的“沉默用户”
  • python爬虫解析器bs4,xpath,pquery
  • 【如何打包docker大镜像】