一.项目起步
我们跟着胡毛毛老师的教程进行编写整个代码,很多是我个人写的过程与思路,仅供参考。 教程https://www.bilibili.com/video/BV1Fb4y14747 GitHub链接https://github.com/mao888/bluebell 该项目旨在一步一步进行实现项目代码,仅作为个人笔记。建议使用postman进行测试,而不是网页进行测试。 由于我并不是学习前端的,所以选择直接将里面的静态文件与html复制进来。
建议使用postman进行测试! 建议使用postman进行测试! 建议使用postman进行测试!
注意这里,这个项目前端还有些许问题,如果遇到问题可以看到这篇文章,用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")
}
接下来我们创建routers文件夹,把路由放入其中。 而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五个模型,分别对应评论,社区,参数,帖子,用户。 我们以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进行数据库的连接,如下图。 并通过运行sql的脚本直接创立五个表
设置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文件,如下图。 如此,我们完成了所有的初始设定,再开始进行功能的编写。 现在我们可以重新将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
}
}
三.注册业务的编写
如胡毛毛老师所画的图标这样。 故我们先建立三个文件夹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,这胡毛毛老师已经帮我们写好了,这里就不多赘述。 其次我们还有数据库的连接问题,我们虽然是分块分步骤进行操作,但最基础的还是得连接数据库。这个时候我们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显示 成功为
其实后面的很多逻辑与功能的实现与注册相差差不多。虽然可能经历过很多中间件,通过不同的协议传输到这里来,思路其实是相同。 我们首先都有个接口,这个接口对准了前端,发送着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原理
我们请求成功时会显示如下
在我们进行下一步开发的时候,我们会发现有个问题。我们用户必须先进行登录才能实现下一步功能。 这我们必须使用中间件了。 中间件是干啥的?就是我们在运行中间突然插出一个东西,跟我们大喊,“此路是我开,此树是我栽,要想执行这些程序你就先得执行我,我就是要插队。”这就是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中可以得到。 可见我们返回出了communityList的数据。 而CommunityDetailHandler,也就是获取Community的详细信息显示,思路也是大差不大,从c.Param获得社区id,然后在mysql进行索引,同时带出introduction, create_time,并进行返回,思路相同就不赘述。 如下如一般的返回
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
}