Go写博客后端笔记

209 阅读12分钟

一.项目起步

们跟着胡毛毛老师的教程进行编写整个代码,很多是我个人写的过程与思路,仅供参考。 教程https://www.bilibili.com/video/BV1Fb4y14747 GitHub链接https://github.com/mao888/bluebell 该项目旨在一步一步进行实现项目代码,仅作为个人笔记。建议使用postman进行测试,而不是网页进行测试。 由于我并不是学习前端的,所以选择直接将里面的静态文件与html复制进来

建议使用postman进行测试! 建议使用postman进行测试! 建议使用postman进行测试!

image.png 注意这里,这个项目前端还有些许问题,如果遇到问题可以看到这篇文章,用postman测试就好了www.yuque.com/u28879420/e… 先在main.go中写代码,将代码跑通,展示出我们现在的页面,再说实现功能的事。

package main
​
import (
    "net/http""github.com/gin-gonic/gin"
)
​
func main() {
    r := gin.New()
    r.LoadHTMLFiles("templates/index.html") // 加载html
    r.Static("/static", "./static")         // 加载静态文件
    r.GET("/", func(context *gin.Context) {
        context.HTML(http.StatusOK, "index.html", nil)
    })
    r.Run(":8081")
}
​

image.png

接下来我们创建routers文件夹,把路由放入其中。 image.png 而routers.go中是

package routers
​
import (
    "net/http""github.com/gin-gonic/gin"
)
​
func SetupRouter() *gin.Engine {
    r := gin.New()
    r.LoadHTMLFiles("templates/index.html") // 加载html
    r.Static("/static", "./static")         // 加载静态文件
    r.GET("/", func(context *gin.Context) {
        context.HTML(http.StatusOK, "index.html", nil)
    })
​
    return r
}

main.go中是

package main
​
import (
    "bluebell/routers"
    "fmt"
)
​
func main() {
    // 注册路由
    r := routers.SetupRouter()
    err := r.Run(":8081")
    if err != nil {
        fmt.Printf("run server failed, err:%v\n", err)
        return
    }
}

即我们就完成了路由的初始化。

注意到我们现在与事例代码项目的区别了吗,我们没有setting。我们没有进行模型的设计,还没有进行中间件的编写,更没有进行功能的实现。我们现在只是简单的把路由导入,把界面跑起来。

二.模型构建以及setting的编写

我们在models写入五个模型,comment,community,params,post,user五个模型,分别对应评论,社区,参数,帖子,用户。 image.png 我们以comment模型为例

package models
​
import "time"type Comment struct {
    PostID     uint64    `db:"question_id" json:"question_id"`
    ParentID   uint64    `db:"parent_id" json:"parent_id"`
    CommentID  uint64    `db:"comment_id" json:"comment_id"`
    AuthorID   uint64    `db:"author_id" json:"author_id"`
    Content    string    `db:"content" json:"content"`
    CreateTime time.Time `db:"create_time" json:"create_time"`
}
​

我们在这comment模型中可以看到PostID,ParentID,CommentID,AuthorID,Content,CreateTime几个参数。模型的各个参数根本上是为了用户而服务的,是程序员自己设计的。我们这几个参数会在数据库,网页中各个地方进行展示。

如上图有一个create_tables.sql,这是胡毛毛老师为我们写的sql脚本,方便我们直接进行表的创立。 个人建议直接使用goland进行数据库的连接,如下图。image.png 并通过运行sql的脚本直接创立五个表 image.png 设置settings文件

package settings
​
import (
    "fmt""github.com/fsnotify/fsnotify"
    "github.com/spf13/viper"
)
​
var Conf = new(AppConfig)
​
type AppConfig struct {
    Mode         string `mapstructure:"mode"`
    Port         int    `mapstructure:"port"`
    Name         string `mapstructure:"name"`
    Version      string `mapstructure:"version"`
    StartTime    string `mapstructure:"start_time"`
    MachineID    int    `mapstructure:"machine_id"`
    *LogConfig   `mapstructure:"log"`
    *MySQLConfig `mapstructure:"mysql"`
    *RedisConfig `mapstructure:"redis"`
}
​
type MySQLConfig struct {
    Host         string `mapstructure:"host"`
    User         string `mapstructure:"user"`
    Password     string `mapstructure:"password"`
    DB           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"`
    MinIdleConns int    `mapstructure:"min_idle_conns"`
}
​
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"`
}
​
func Init() error {
    viper.SetConfigFile("./conf/config.yaml")
​
    viper.WatchConfig()
    viper.OnConfigChange(func(in fsnotify.Event) {
        fmt.Println("夭寿啦~配置文件被人修改啦...")
        viper.Unmarshal(&Conf)
    })
​
    err := viper.ReadInConfig()
    if err != nil {
        panic(fmt.Errorf("ReadInConfig failed, err: %v", err))
    }
    if err := viper.Unmarshal(&Conf); err != nil {
        panic(fmt.Errorf("unmarshal to Conf failed, err:%v", err))
    }
    return err
}

mapstructure 使用结构体中字段的名称做映射,例如结构体中有一个 Title 字段, mapstructure 解码时会在 map[string]interface{}中查找键名title,并且字段不区分大小写。 如图,settings文件是关于app,mysql,redis,logger等应用设置统一处理的 其中我们再使用viper库进行统一管理,导入yaml文件,如下图。 image.png 如此,我们完成了所有的初始设定,再开始进行功能的编写。 现在我们可以重新将settings文件的输入导入进我们的main中,我们此时应修改routers和main文件,将settings里的参数加入进去,此时我们的main如下

package main
​
import (
    "bluebell/routers"
    "bluebell/settings"
    "fmt"
)
​
func main() {
    // 加载配置
    if err := settings.Init(); err != nil {
        fmt.Printf("load config failed, err:%v\n", err)
        return
    }
    // 注册路由
    r := routers.SetupRouter(settings.Conf.Mode)
    err := r.Run(fmt.Sprintf(":%d", settings.Conf.Port))
    if err != nil {
        fmt.Printf("run server failed, err:%v\n", err)
        return
    }
}

三.注册业务的编写

如胡毛毛老师所画的图标这样。 image.png 故我们先建立三个文件夹controller,logic,dao。让我们分块去实现我们的功能。 controller为控制模块,负责对功能实现的控制。 而logic负责功能的逻辑实现。 而dao负责数据库的操作与对接。

首先我们日常先构建一个路由组

    v1 := r.Group("api/v1")

再在routers里使用POST请求写入注册方法

    v1.POST("/signup", controller.SignUpHandler)

由于我们所有的功能都是分块完成的,所以我们所写的反馈函数是controller.SignupHandler。 然后我们打开controller文件夹中的user.go,我们进行控制函数的编写

func SignUpHandler(c *gin.Context) {
	// 1.获取请求参数 2.校验数据有效性
	var fo *models.RegisterForm
	if err := c.ShouldBindJSON(&fo); err != nil {
		// 请求参数有误,直接返回响应
		zap.L().Error("SiginUp with invalid param", zap.Error(err))
		// 判断err是不是 validator.ValidationErrors类型的errors
		errs, ok := err.(validator.ValidationErrors)
		if !ok {
			// 非validator.ValidationErrors类型错误直接返回
			ResponseError(c, CodeInvalidParams) // 请求参数错误
			return
		}
		// validator.ValidationErrors类型错误则进行翻译
		ResponseErrorWithMsg(c, CodeInvalidParams, removeTopStruct(errs.Translate(trans)))
		return // 翻译错误
	}

	// 3.业务处理——注册用户
	if err := logic.SignUp(fo); err != nil {
		zap.L().Error("logic.signup failed", zap.Error(err))
		if errors.Is(err, mysql.ErrorUserExit) {
			ResponseError(c, CodeUserExist)
			return
		}
		ResponseError(c, CodeServerBusy)
		return

		if err != nil {
			zap.L().Error("mysql.Register() failed", zap.Error(err))
			ResponseError(c, CodeServerBusy)
			return
		}
	}
	ResponseSuccess(c, nil)
}

如图,我们的控制函数基本上只是控制返回参数,其实胡毛毛老师加入了mysql的验证工作,设定了mysql请求失败的情况。而很多Response函数是提前写好的,分块方便调用,而很多code如CodeServerBusy也是提前设置的,方便我们使用。 如图,响应函数很规范的将数据展现出来

type ResponseData struct {
	Code    MyCode      `json:"code"`
	Message string      `json:"message"`
	Data    interface{} `json:"data,omitempty"` // omitempty当data为空时,不展示这个字段
}

func ResponseError(ctx *gin.Context, c MyCode) {
	rd := &ResponseData{
		Code:    c,
		Message: c.Msg(),
		Data:    nil,
	}
	ctx.JSON(http.StatusOK, rd)
}

func ResponseErrorWithMsg(ctx *gin.Context, code MyCode, data interface{}) {
	rd := &ResponseData{
		Code:    code,
		Message: code.Msg(),
		Data:    nil,
	}
	ctx.JSON(http.StatusOK, rd)
}

func ResponseSuccess(ctx *gin.Context, data interface{}) {
	rd := &ResponseData{
		Code:    CodeSuccess,
		Message: CodeSuccess.Msg(),
		Data:    data,
	}
	ctx.JSON(http.StatusOK, rd)
}

这个时候我们才发现我们的基建工作并没有完成。 比如说我们在生成id的时候需要使用到雪花算法,在进行验证的时候需要使用jwt,这胡毛毛老师已经帮我们写好了,这里就不多赘述。 image.png 其次我们还有数据库的连接问题,我们虽然是分块分步骤进行操作,但最基础的还是得连接数据库。这个时候我们settings里储存的参数很整齐划一的放入我们的dsn参数中。 注意我们这里使用的是sqlx而非我们之前使用的gorm,sqlx效率更高。

var db *sqlx.DB

// Init 初始化MySQL连接
func Init(cfg *settings.MySQLConfig) (err error) {
	// "user:password@tcp(host:port)/dbname"
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=Local", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DB)
	db, err = sqlx.Connect("mysql", dsn)
	if err != nil {
		return
	}
	db.SetMaxOpenConns(cfg.MaxOpenConns)
	db.SetMaxIdleConns(cfg.MaxIdleConns)
	return
}

// Close 关闭MySQL连接
func Close() {
	_ = db.Close()
}

与此同时我们也要写好error_code,和controller一样把错误统一化处理。

我们重新回到控制函数,我们发现我们代码实现的过程除了返回,还有一个专门的logic函数,这就是代码的逻辑实现。

func SignUp(p *models.RegisterForm) (error error) {
	// 1、判断用户存不存在
	err := mysql.CheckUserExist(p.UserName)
	if err != nil {
		// 数据库查询出错
		return err
	}

	// 2、生成UID
	userId, err := snowflake.GetID()
	if err != nil {
		return mysql.ErrorGenIDFailed
	}
	// 构造一个User实例
	u := models.User{
		UserID:   userId,
		UserName: p.UserName,
		Password: p.Password,
	}
	// 3、保存进数据库
	return mysql.InsertUser(u)
}

我们在logic/user.go中写入到吗,这就是注册函数的逻辑,首先是判断用户是否存在,再用雪花算法生成UID,创建实例并写入数据库。 既然实现逻辑我们接下来要实现数据库的功能。实现CheckUserExist与InsertUser函数。

const secret = "123456"

func encryptPassword(data []byte) (result string) {
	h := md5.New()
	h.Write([]byte(secret))
	return hex.EncodeToString(h.Sum(data))
}

func CheckUserExist(username string) (error error) {
	sqlstr := `select count(user_id) from user where username = ?`
	var count int
	if err := db.Get(&count, sqlstr, username); err != nil {
		return err
	}
	if count > 0 {
		return errors.New("用户已存在")
	}
	return
}

func InsertUser(user models.User) (error error) {
	// 对密码进行加密
	user.Password = encryptPassword([]byte(user.Password))
	// 执行SQL语句入库
	sqlstr := `insert into user(user_id,username,password) values(?,?,?)`
	_, err := db.Exec(sqlstr, user.UserID, user.UserName, user.Password)
	return err
}

CheckUserExist就是检测其在数据库中是否存在,我们使用sql语句进行处理。 而InsertUser进行了md5加密,从实例中将密码拉出来进行加密生成新的密码,再放回password,再插入数据库里面。 我们在main函数中进行mysql和雪花算法的初始化

	if err := mysql.Init(settings.Conf.MySQLConfig); err != nil {
		fmt.Printf("init mysql failed, err:%v\n", err)
		return
	}
	defer mysql.Close() // 程序退出关闭数据库连接
	// 雪花算法生成分布式ID
	if err := snowflake.Init(1); err != nil {
		fmt.Printf("init snowflake failed, err:%v\n", err)
		return
	}

我们整体的signup功能就实现了,我们现在进行运行 当我们参数出现错误时,postman显示 image.png 成功为 image.png

其实后面的很多逻辑与功能的实现与注册相差差不多。虽然可能经历过很多中间件,通过不同的协议传输到这里来,思路其实是相同。 我们首先都有个接口,这个接口对准了前端,发送着request。 我们这个项目的思路是将一个功能的实现分三步走。 第一步就是将返回与响应写明白,什么时候会响应什么,前端需要怎样的数据格式,如何接受或发送数据,给他一个外部框架。 第二步就是进行逻辑的实现,比如注册功能中我们首先想的是这个用户在不在我们的数据库中,如果不是返回什么。还需要使用雪花算法实现用户唯一的uuid,并一同存入数据库。 而第三步就是对数据库的CRUD,我们在注册时会对数据库进行多番操作,比如说查询数据库有无当前正注册用户,对密码进行加密在放入数据库,种种行为。 我们理清这个框架和思路能更好的规范我们的代码风格,提升我们写代码的思路。

四.登陆业务的编写以及鉴权中间件的开发

根据我们的实现登录业务 我们首先要将路由给加入

	v1.POST("/login", controller.LoginHandler)

然后我们再在user的控制函数中进行撰写。 我们看到controller层代码

func LoginHandler(c *gin.Context) {
	// 1、获取请求参数及参数校验
	//var u *models.User
	var u *models.LoginForm
	if err := c.ShouldBindJSON(&u); err != nil {
		// 请求参数有误,直接返回响应
		zap.L().Error("Login with invalid param", zap.Error(err))
		// 判断err是不是 validator.ValidationErrors类型的errors
		errs, ok := err.(validator.ValidationErrors)
		if !ok {
			// 非validator.ValidationErrors类型错误直接返回
			ResponseError(c, CodeInvalidParams) // 请求参数错误
			return
		}
		// validator.ValidationErrors类型错误则进行翻译
		ResponseErrorWithMsg(c, CodeInvalidParams, removeTopStruct(errs.Translate(trans)))
		return
	}
	// 2、业务逻辑处理——登录
	user, err := logic.Login(u)
	if err != nil {
		zap.L().Error("logic.Login failed", zap.String("username", u.UserName), zap.Error(err))
		if errors.Is(err, mysql.ErrorUserNotExit) {
			ResponseError(c, CodeUserNotExist)
			return
		}
		ResponseError(c, CodeInvalidParams)
		return
	}
	// 3、返回响应
	ResponseSuccess(c, gin.H{
		"user_id":       fmt.Sprintf("%d", user.UserID), //js识别的最大值:id值大于1<<53-1  int64: i<<63-1
		"user_name":     user.UserName,
		"access_token":  user.AccessToken,
		"refresh_token": user.RefreshToken,
	})
}

着证明了我们在上一章末尾的思考。 当你确定好你的写代码方式之后,你应该按照你的既定统筹规划进行一步一步的运行。 首先就是检验参数是否符合我们传入数据地规范,接下来将参数u传入logic函数进行处理,最后在进行错误检测和成功返回。 注意这里返回的格式,和signup中地nil不同,我们返回了一个gin.H的接口,因为我们要输出和返回数据。 再让我们打开logic层

func Login(p *models.LoginForm) (user *models.User, error error) {
	user = &models.User{
		UserName: p.UserName,
		Password: p.Password,
	}
	if err := mysql.Login(user); err != nil {
		return nil, err
	}
	// 生成JWT
	//return jwt.GenToken(user.UserID,user.UserName)
	atoken, rtoken, err := jwt.GenToken(user.UserID, user.UserName)
	if err != nil {
		return
	}
	user.AccessToken = atoken
	user.RefreshToken = rtoken
	return
}

在这了我们访问了mysql.Login,我们就不展示mysql中的代码了,其主要功能是去在数据库里查询该id是否存在,如果存在就与其生成的密码比较,如果错误就返回密码错误,正确就返回空。 主要是我们在这里使用了jwt,jwt是我们现在所编写的包之一,使用jwt生成atoken和rtoken,绑定在用户上。

具体的jwt实现与原理请看相关视频和文章,jwt是我们在登录时常用的验证手段,要确切记得。 胡毛毛老师讲其jwt思路 jwt-go原理

我们请求成功时会显示如下 image.png

在我们进行下一步开发的时候,我们会发现有个问题。我们用户必须先进行登录才能实现下一步功能。 这我们必须使用中间件了。 中间件是干啥的?就是我们在运行中间突然插出一个东西,跟我们大喊,“此路是我开,此树是我栽,要想执行这些程序你就先得执行我,我就是要插队。”这就是middleware中间件。

v1.Use(middlewares.JWTAuthMiddleware())	

五.社区与帖子功能

1.community功能

思路和之前一样,先加路由

		v1.GET("/community", controller.CommunityHandler)           // 获取分类社区列表

再在controller里创建community.go的文件用于实现社区功能 编写函数CommunityHandler,因为我们只需要展示帖子,所以很简单就交给逻辑层进行实现,取回我们的communityList。

func CommunityHandler(c *gin.Context) {
	// 查询到所有的社区(community_id,community_name)以列表的形式返回
	communityList, err := logic.GetCommunityList()
	if err != nil {
		zap.L().Error("logic.GetCommunityList() failed", zap.Error(err))
		ResponseError(c, CodeServerBusy) // 不轻易把服务端报错暴露给外面
		return
	}
	ResponseSuccess(c, communityList)
}

然后logic的函数,直接调用dao层中的函数。

func GetCommunityList() ([]*models.Community, error) {
	// 查数据库 查找到所有的community 并返回
	return mysql.GetCommunityList()
}

从dao层中我们可以看到,本质上是通过sqlx去索引community_id和community_name。 然后再进行返回得出我们的communityList并展示到前端。

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

我们使用postman进行验证,get请求下输入127.0.0.1:8081/api/v1/community,且在Authorization中输入Bearer token,该token为access_token,127.0.0.1:8081/api/v1/login中可以得到。 image.png 可见我们返回出了communityList的数据。 而CommunityDetailHandler,也就是获取Community的详细信息显示,思路也是大差不大,从c.Param获得社区id,然后在mysql进行索引,同时带出introduction, create_time,并进行返回,思路相同就不赘述。 如下如一般的返回 image.png

2.post功能

关于帖子post的编写思路,和community功能类似。 我们先想想我们需要实现的功能,有帖子的创建,帖子的列表获取,帖子的细节获取。 首先就是帖子的创建,我们延续我们三步走的原则,首先就是获取参数及其检验,第二部就是logic层的返回,第三步就是返回响应。

func CreatePostHandler(c *gin.Context) {
	// 1、获取参数及校验参数
	var post models.Post
	if err := c.ShouldBindJSON(&post); err != nil { // validator --> binding tag
		zap.L().Debug("c.ShouldBindJSON(post) err", zap.Any("err", err))
		zap.L().Error("create post with invalid parm")
		ResponseErrorWithMsg(c, CodeInvalidParams, err.Error())
		return
	}
	userID, err := getCurrentUserID(c)
	if err != nil {
		zap.L().Error("GetCurrentUserID() failed", zap.Error(err))
		ResponseError(c, CodeNotLogin)
		return
	}
	post.AuthorId = userID
	// 2、创建帖子
	err = logic.CreatePost(&post)
	if err != nil {
		zap.L().Error("logic.CreatePost failed", zap.Error(err))
		ResponseError(c, CodeServerBusy)
		return
	}
	// 3、返回响应
	ResponseSuccess(c, nil)
}

接下来就是logic层的操作。 我们很经典的使用雪花算法生成postid,创建帖子并保存到数据库。 而我们使用redis用以储存vote信息的,在本篇笔记中不会涉及太多。

func CreatePost(post *models.Post) (err error) {
	// 1、 生成post_id(生成帖子ID)
	postID, err := snowflake.GetID()
	if err != nil {
		zap.L().Error("snowflake.GetID() failed", zap.Error(err))
		return
	}
	post.PostID = postID
	// 2、创建帖子 保存到数据库
	if err := mysql.CreatePost(post); err != nil {
		zap.L().Error("mysql.CreatePost(&post) failed", zap.Error(err))
		return err
	}
	community, err := mysql.GetCommunityNameByID(fmt.Sprint(post.CommunityID))
	if err != nil {
		zap.L().Error("mysql.GetCommunityNameByID failed", zap.Error(err))
		return err
	}
	// redis存储帖子信息
	if err := redis.CreatePost(
		post.PostID,
		post.AuthorId,
		post.Title,
		TruncateByWords(post.Content, 120),
		community.CommunityID); err != nil {
		zap.L().Error("redis.CreatePost failed", zap.Error(err))
		return err
	}
	return

}

而我们使用GetPostList是不用redis的,可以直接使用MySQL进行post列表的获取,如同我们在community功能实现所做的事。只是多加了几个错误判断。

func GetPostList(page, size int64) (data []*models.ApiPostDetail, err error) {
    postList, err := mysql.GetPostList(page, size)
    if err != nil {
        fmt.Println(err)
        return
    }
    data = make([]*models.ApiPostDetail, 0, len(postList)) // data 初始化
    for _, post := range postList {
        // 根据作者id查询作者信息
        user, err := mysql.GetUserByID(post.AuthorId)
        if err != nil {
            zap.L().Error("mysql.GetUserByID() failed",
                zap.Uint64("postID", post.AuthorId),
                zap.Error(err))
            continue
        }
        // 根据社区id查询社区详细信息
        community, err := mysql.GetCommunityByID(post.CommunityID)
        if err != nil {
            zap.L().Error("mysql.GetCommunityByID() failed",
                zap.Uint64("community_id", post.CommunityID),
                zap.Error(err))
            continue
        }
        // 接口数据拼接
        postdetail := &models.ApiPostDetail{
            Post:            post,
            CommunityDetail: community,
            AuthorName:      user.UserName,
        }
        data = append(data, postdetail)
    }
    return
}