1. 需求描述
-
展示话题(标题、文字描述)和回帖列表;
-
暂不考虑前端页面实现,仅仅实现本地web服务;
-
话题和回帖数据用文件存储;
-
支持查询指定话题的帖子数据,支持发布帖子;
-
ID生成不重复,数据传入文件,更新索引,保证并发安全。
2.Gin框架介绍及使用
简介:
Gin 是一个用于构建 Web 应用程序的高性能 Go框架。它被设计成轻量级和高效的,并且具有快速的路由和中间件功能,使得构建 Web 服务器和 API 变得简单而高效。它采用了类似于 Martini 框架的风格,但在性能上比 Martini 更快,使用httprouter,速度提升了40倍,追求极致的性能。 Gin 的特点包括:
- 高性能:Gin 被设计为高性能框架,其路由和中间件的实现非常快速,使得处理请求的速度非常高效。
- 简单易用:Gin 提供了简单而直观的 API,使得编写 Web 应用程序变得简单和易于理解。
- 轻量级:Gin 是一个轻量级的框架,它的代码库非常小巧,并且没有太多不必要的复杂性。
- 路由:Gin 提供了灵活而强大的路由功能,支持不同的 HTTP 方法和路由参数,可以方便地定义 RESTful 风格的 API。
- 中间件:Gin 的中间件支持非常好,开发人员可以方便地定义和使用中间件来处理请求和响应。
- JSON 支持:Gin 内置了对 JSON 的支持,可以方便地将结构体转换为 JSON 或从 JSON 解析数据。
- 错误处理:Gin 提供了统一的错误处理机制,可以方便地处理错误并返回相应的错误信息。
- 热重载:Gin 支持热重载,可以在开发过程中实时修改代码并立即查看效果,无需重启服务器。
使用:
安装:go get -u github.com/gin-gonic/gin
导入:import "github.com/gin-gonic/gin"
示例代码:
go
package main
import (
"gopkg.in/gin-gonic/gin"
"net/http"
)
func main() {
// 创建 Gin 实例
r := gin.Default()
// 定义路由
//无参路由
r.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "Hello, World!")
})
//带参路由
r.GET("/hello/:name", func(c *gin.Context) {
name := c.Param("name")
c.String(http.StatusOK, "Hello, "+name+"!")
})
// 启动服务器,默认监听localhost:8080
r.Run()
}
运行结果:
访问效果:http://localhost:8080/hello/JueJIn
代码解读:
-
package main
: 声明程序的入口文件。 -
导入所需的包:
"gopkg.in/gin-gonic/gin"
: 导入 Gin 框架,用于构建 Web 服务器。"net/http"
: 导入 Go 标准库中的net/http
包,用于处理 HTTP 请求和响应。
-
func main()
: 入口函数。 -
创建 Gin 实例:
r := gin.Default()
创建了一个默认的 Gin 实例,它包含了一些默认的中间件和配置。 -
定义路由:
r.GET("/")
: 定义一个无参路由,处理根路径/
的 GET 请求。当客户端访问根路径时,会执行匿名函数func(c *gin.Context) {...}
,向客户端返回 "Hello, World!" 的响应。r.GET("/hello/:name")
: 定义一个带参路由,处理/hello/:name
路径的 GET 请求。当客户端访问带有参数的路径时,Gin 框架会从 URL 中获取参数值,并将其保存在请求的上下文中。在匿名函数中,通过c.Param("name")
获取到 URL 中的参数值,然后将其与 "Hello, " 拼接起来,并返回给客户端。
-
r.Run()
: 启动服务器,默认监听localhost:8080
地址。一旦服务器启动,它会开始监听来自客户端的请求,并根据定义的路由进行相应的处理和响应。
3.实践项目源码解读:
源码地址:github.com/Moonlight-Z...
项目分层结构:
repository数据层:
- 数据层负责处理与数据存储相关的操作,以及数据库的增删查改;
- 封装了对数据库或其他数据存储的访问,隐藏了数据存储的具体实现细节,为上层提供了统一的数据访问接口;
- 数据层通常包含数据访问对象、数据模型等,用于封装数据操作和数据结构。
service逻辑层:
- 处理核心业务逻辑输出;
- 依赖数据层的数据,进行逻辑处理和计算,结果返回给视图层;
- 与具体的数据存储无关只关注业务逻辑处理。
cotroller视图层:
- 程序与用户交互的接口,负责接收请求和发送响应;
- 包含控制器,接收用户的输入,转发给逻辑层进行处理,再将结果进行处理并返还给用户。
data数据存储文件:
- 数据存储位置,一般项目使用数据库,本项目直接使用普通文件存储,数据以json形式存储。
server.go
- 程序入口,定义路由,处理路由,调用接口响应请求;
- 初始化数据,装入已有数据,运行服务器。
查询功能实现:
数据层:
db_init.go:
- 定义话题(topic)和话题对应帖子(post)的全局字典存储结构,
topicIndexMap map[int64]*Topic
和postIndexMap map[int64][]*Post
,并使用sync.Mutex
锁机制保证两个变量使用的并发安全; - 将data中的话题和帖子数据分别读取并存储到两个字典中便于使用。
topic.go和post.go:
- 分别定义
Topic
和Post
结构体映射对应数据对象,类似于springboot框架中entity文件下的类,go语言没有类的概念,选择使用结构体来映射数据对象; - 定义
TopicDao
和PostDao
两个结构体,用于添加对数据库的操作方法实现对对应数据的增删查改,该项目使用文件存储数据,因此结构体设置为空; - 全局变量
postDao
,topicDao
和sync.Once
类型的变量postOnce
。postDao
和topicDao
用于存储PostDao
和TopicDao
的实例,而postOnce
则用于保证postDao
和topicDao
只会被初始化一次; - 函数
NewPostDaoInstance()
和NewTopicDaoInstance()
用于获取PostDao
和TopicDao
的实例,并保证只初始化一次; - 方法
QueryTopicById(id int64) *Topic
,该方法用于根据给定的id
查询对应的话题数据,通过查询topicIndexMap
中指定键的值,可以得到对应id
的话题数据; - 方法
QueryPostsByParentId(parentId int64) []*Post
,该方法用于根据给定的parentId
查询对应的所有帖子,postIndexMap
的键为parentId
,值为一个包含多个Post
结构体的切片,通过查询postIndexMap
中指定键的值,可以得到对应parentId
下的所有帖子。
逻辑层:
query_page_info.go:
- 定义
PageInfo
结构体,用于表示页面信息,其中包括一个Topic
字段和一个PostList
字段。Topic
表示话题信息,类型为*repository.Topic
,而PostList
表示帖子列表,类型为[]*repository.Post
,即存储指定话题及其帖子的结构; - 定义
QueryPageInfoFlow
结构体,表示查询页面信息的流程; - 定义
QueryPageInfo
函数,用于查询页面信息。它接收一个topicId
参数,然后通过调用NewQueryPageInfoFlow(topicId).Do()
来执行查询,并返回查询结果的PageInfo
数据,NewQueryPageInfoFlow(topId int64) *QueryPageInfoFlow
构造函数,用于创建QueryPageInfoFlow
结构体的实例,初始化了topicId
字段,并返回实例的指针; checkParam()
方法检查参数的合法性。如果topicId
小于等于0,返回错误;prepareInfo()
方法准备页面信息,它启动两个协程(goroutine)并使用sync.WaitGroup
等待两个协程完成。一个goroutine用于通过调用数据层函数QueryTopicById(f.topicId)
获取话题信息,并将结果存储在f.topic
字段中;另一个goroutine用于通过数据层函数QueryPostsByParentId(f.topicId)
获取帖子列表,并将结果存储在f.posts
字段中;packPageInfo()
方法将查询到的topic
和posts
打包成PageInfo
结构体,并存储在f.pageInfo
字段中;Do()
按照特定的流程顺序依次执行了checkParam()
、prepareInfo()
和packPageInfo()
方法,并返回查询到的页面信息。
query_page_info_test.go:
- 测试查询方法
QueryPageInfo
的正确性以及服务器是否正常运行。
视图层:
query_page_info.go:
- 定义了一个名为
PageData
的结构体,用于表示返回给客户端的页面数据。该结构体包含三个字段:Code
表示返回码,Msg
表示返回消息,Data
表示返回的数据; - 定义了一个名为
QueryPageInfo
的函数,用于处理查询页面信息的请求。它接收一个名为topicIdStr
的字符串参数,该参数表示用户请求中的话题ID,将ID转换为int类型后作为参数调用service.QueryPageInfo(topicId)
函数来查询页面信息; service.QueryPageInfo(topicId)
会调用相应的逻辑层QueryPageInfo
方法,获取页面信息,并返回查询结果的PageInfo
数据给用户。
main:
server.go:
- 首先调用
Init("./data/")
函数来初始化数据字典,Init()
函数会调用数据层函数repository.Init(filePath)
来初始化数据,其中filePath
表示数据存储路径; - 定义查询功能 HTTP GET 请求的路由规则:
/community/page/get/:id
,当客户端发送以/community/page/get/
开头的 GET 请求时,路由会将:id
对应的部分提取出来,作为参数传递给路由处理函数,路由处理函数首先从请求参数中获取到话题IDtopicId
,然后调用视图层函数cotroller.QueryPageInfo(topicId)
来处理查询请求并返回数据; - 最后将查询结果以 JSON 格式返回给客户端。
使用示例:
访问效果:http://localhost:8080/community/page/get/1
4.发布帖子功能实现
设计思路:
发布帖子涉及到数据的获取,处理和存储,需要接收前端传入的话题ID和帖子内容,考虑在路由设置时设置两个参数,分别获取话题ID和帖子内容,处理数据并对数据进行合法性判断;
在逻辑层要实现获取获取不重复的帖子ID和帖子发布时间的数据,还需要实现将帖子数据按照json格式保存到data下的post文件中;
在视图层要获取到前端传入的话题ID、帖子内容和当前时间的数据作为参数调用逻辑层函数实现保存帖子数据的逻辑,并且返回成功或其他错误信息到前端。
具体实现:
逻辑层:
新增upload_post.go:
GetMaxPostId()
函数用于获取到当前帖子数据中的最大id,由于文件中的帖子id是递增的,获取到最大id加1作为后续加入帖子的id即可保证id的不可重复,当使用数据库时还是宁愿用主码自增;SavePost()
函数时保存帖子数据的主体函数,处理好帖子ID后,将数据保存在一个Post
结构体中并处理为json格式写入指定路径的post文件,最后再将字典PostIndexMap
的索引进行更新并把保存的帖子数据返回。
go
package service
import (
"encoding/json"
"go-project-example/repository"
"os"
)
// 获取post数据的最大id
func GetMaxPostId() int64 {
var maxId int64
for _, posts := range repository.PostIndexMap {
for _, post := range posts {
if post.Id > maxId {
maxId = post.Id
}
}
}
return maxId
}
// 保存帖子数据到文件
func SavePost(parentId int64, content string, createTime int64) (*repository.Post, error) {
repository.Mutex.Lock()
defer repository.Mutex.Unlock()
// 获取当前帖子数据的最大ID,+1作为新发贴的ID
maxId := GetMaxPostId() + 1
post := &repository.Post{
Id: maxId,
ParentId: parentId,
Content: content,
CreateTime: createTime,
}
// 将帖子数据处理为json格式写入post文件中
postFile, err := os.OpenFile("./data/post", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return nil, err
}
defer postFile.Close()
encoder := json.NewEncoder(postFile)
if err := encoder.Encode(post); err != nil {
return nil, err
}
// 更新post字典索引
posts, ok := repository.PostIndexMap[parentId]
if !ok {
repository.PostIndexMap[parentId] = []*repository.Post{post}
} else {
repository.PostIndexMap[parentId] = append(posts, post)
}
return post, nil
}
视图层:
新增upload_post.go:
- 定义
PostData
结构体用于存储只有帖子内容和话题ID的帖子数据; UploadPost()
函数判断帖子内容和话题ID是否合法,合法情况下调用逻辑层SavePost()
函数处理并保存帖子数据到post文件中,保存完成后接收返回的新增帖子数据,作为发布成功提示内容返回给客户端。
go
package cotroller
import (
"go-project-example/repository"
"go-project-example/service"
"time"
)
type PostData struct {
Content string `json:"content"`
ParentID int64 `json:"parent_id"`
}
func UploadPost(topicId int64, content string) (*PageData, error) {
if topicId <= 0 {
return &PageData{
Code: -1,
Msg: "Topic ID must be larger than 0",
}, nil
}
if content == "" {
return &PageData{
Code: -1,
Msg: "Post content cannot be empty",
}, nil
}
// 获取当前时间作为帖子发布时间
currentTime := time.Now().Unix()
// 保存帖子
post, err := service.SavePost(topicId, content, currentTime)
if err != nil {
return &PageData{
Code: -1,
Msg: "Failed to save post",
}, err
}
// 发布成功时,返回发布的帖子内容
postData := &repository.Post{
Id: post.Id,
ParentId: post.ParentId,
Content: post.Content,
CreateTime: post.CreateTime,
}
// 发布成功
return &PageData{
Code: 0,
Msg: "Post uploaded successfully",
Data: postData,
}, nil
}
main:
server.go新增代码:
- 新增发布帖子的路由规则,要求在路由参数中写入话题ID和帖子内容,路由处理函数初步判断参数的合法性并将话题ID和帖子内容进行处理保存到变量中用于调用视图层函数
UploadPost()
进行发布帖子的逻辑处理和响应获取,并将获取到的响应结果以json格式发回客户端。
go
package main
import (
"go-project-example/cotroller"
"go-project-example/repository"
"gopkg.in/gin-gonic/gin.v1"
"net/url"
"os"
"strconv"
)
func main() {
if err := Init("./data/"); err != nil {
os.Exit(-1)
}
r := gin.Default()
r.GET("/community/page/get/:id", func(c *gin.Context) {
topicId := c.Param("id")
data := cotroller.QueryPageInfo(topicId)
c.JSON(200, data)
})
// 定义路由规则,设置参数获取话题ID和帖子内容
r.GET("/community/page/upload/:id/:content", func(c *gin.Context) {
topicIdStr := c.Param("id")
topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
if err != nil {
c.JSON(400, gin.H{
"code": -1,
"msg": "Invalid topic ID",
})
return
}
// 从URL参数中获取未经URL编码的原始帖子内容
postContent, err := url.QueryUnescape(c.Param("content"))
if err != nil {
c.JSON(400, gin.H{
"code": -1,
"msg": "Invalid post content",
})
return
}
// 调用上传帖子的处理函数
data, err := cotroller.UploadPost(topicId, postContent)
if err != nil {
c.JSON(400, gin.H{
"code": -1,
"msg": "upload failed",
})
return
}
c.JSON(200, data)
})
err := r.Run()
if err != nil {
return
}
}
func Init(filePath string) error {
if err := repository.Init(filePath); err != nil {
return err
}
return nil
}
使用示例:
访问效果:http://localhost:8080/community/page/upload/2/青训营我来啦!
文件内容更改情况:
对查询功能的影响测试:
访问:http://localhost:8080/community/page/get/2
没有影响且能正常输出!
5.总结
- 功能实现虽然很少但是整体框架,整个项目的层次结构很完整,可以扩展为一个完整的web后端项目,其中蕴含的知识也非常丰富,让我更进一步的感受到Go实现后端的方法,对比以前使用的springboot后端虽然有很多不同点,但也有很多相通的地方,包括层次结构的选择上,前后端数据传输上其实都很类似,包括Gin框架定义路由和路由处理函数的逻辑都和springboot的处理方式很像,相信类比这两种框架学习说不定可以更快的掌握一些技术技巧;
- 但是作为Go语言独有的协程并发,仍然还有很多疑点,对其中的原理以及使用上的细节也很模糊,还要深入了解,对并发安全控制的技巧也还一知半解,还有很多技术点需要学习,当然由于学习Go语言还没有多久,语法错误也是很多,这个项目也做了很久,还是需要注重基础语法的使用啊!
- 总之,项目收获了很多,也初步接触了Gin框架,对青训营大项目的框架选择上有了初步判断,能够使用Gin框架搭建简单的服务器了,算是一个好的开始吧,接下来就是继续加油了!
- 文章有错误或不严谨的地方望大佬海涵、批评指正!