GO语言工程实践课后作业
前置
笔者环境
- macos 10.15.7
- Golang 1.18
- GoLand 2022.01
读完本文可以获得
- 如何进行项目的需求分析,包括抽象业务实体,绘制ER图,设计项目层级结构等
- 如何使用Golang的gin框架搭建Web服务项目,管理项目依赖
- Golang项目的分层设计思想,包括Controller、Service、Repository等层的责任
- 如何设计查询流程控制和发布流程控制来组织Service层业务逻辑
- 如何设计线程安全的并发程序,使用sync.WaitGroup协调goroutine
- 如何设计全局唯一ID生成器,并在项目中应用
一、 需求分析
进入【第四届字节跳动青训营 - 暑假专场 Q&A】🙋...-青训营社区 (juejin.cn),我们将完成该社区话题页面的本地Web服务功能,不考虑前端实现。
具体功能如下
- 展示话题(标题,文字描述)和回帖列表
- 对话题发布回帖(回帖id生成需要保证不重复、唯一性)
注意事项如下
- 话题和回帖使用文件存储
- 新加回帖追加到本地文件,同时需要更新索引,注意Map的并发安全问题
1.1 需求用例
用户可以浏览话题以及话题的回帖,也可以对话题进行回帖操作,因此可以抽象出两个实体Topic和PostList
1.2 ER图
根据用例图,抽象出实体的属性,话题和帖子直接的联系是1:M
1.3 分层结构
按照上述代码分层结构,抽象出目录结构如下
.
├── controller # 控制层
├── data # 本地存储
├── go.mod
├── go.sum
├── main.go
├── repository # 数据层
└── service # 逻辑层
二、 项目搭建
我们使用 gin Web框架进行开发,使用 go mod 进行依赖管理。
2.1 新建项目
makdir topicPost # 项目目录
cd topicPost
go mod init topicPost # 使用go mod 管理依赖
touch main.go # 创建go的入口文件
建议使用IDE工具进行项目搭建,如(GoLand 或 VSCode)
2.2 测试gin框架
-
在
mian.go文件中添加如下测试代码(官方代码)package main import ( "net/http" "github.com/gin-gonic/gin" ) func main() { r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "pong", }) }) r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080") } -
添加项目依赖
go mod tidy使用
go mod tidy命令将把项目所需要的依赖添加到go.mod -
测试运行
go run main.go项目启动后,在浏览器中输入localhost:8080/ping
2.3 新建目录
在需求分析的分层结构中已经抽象出了项目的目录结构,按照目录结构进行新建
三、 实现思路
3.1 总体
- 先完成用户根据话题Id获取话题及其回帖
- 再完成用户根据话题Id进行回帖操作
3.2 步骤
-
根据ER图,我们先分别构造Topic和Post的本地数据,并存放到data目录下,便于后续请求数据的获取。
-
从data目录里读出Topic和Post的本地数据,并转成JSON格式存储到内存中
-
为Service层提供repository的查询功能
-
为Service层提供repository的回帖功能
-
Service层接收TopicId,并返回话题和回帖列表
-
Service层接收ParentId,并返回新增回帖Id
-
Controller层处理页面信息请求,即获取话题及其回帖列表
-
Controller层处理回帖发布请求
-
进行Router的路由设置
-
运行测试
四、代码开发
4.1 data本地数据
在data目录下新建topic和post两个文件
topic文件数据
{"id":1,"title":"青训营来啦!","content":"哈喽~欢迎你加入青训营大家庭!","create_time":1650437625}
post文件数据
{"id":1855946374730608640,"parent_id":1,"content":"HelloWorld","create_time":1690808012}
4.2 读取data本地数据
当启动项目时,需要将数据全部读入到内存中,(本项目数据小,可以一次性读入到内存),然后再进行后续逻辑的处理。
4.2.1 数据实体
根据ER图抽象出来两个实体:Topic和Post,将两个实体转为struct。
在repository目录下新建topic.go和post.go
repository/topic.go
type Topic struct {
Id int64 `json:"Id"`
Title string `json:"title"`
Content string `json:"Content"`
CreateTime int64 `json:"CreateTime"`
}
repository/post.go
type Post struct {
Id int64 `json:"id"`
ParentId int64 `json:"parent_id"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
4.2.2 元索引
当从文件中读取Topic和Post的数据时,要将它们放到内存中,因此就需要一个数据结构进行存储。
- 通过Topic Id来获取Topic,因此可以使用
map[int64] *Topic进行存储,key是Topic Id,value是Topic的指针。 - 通过Topic Id来获取该话题的回帖,而回帖可能有多个,因此可以使用
map[int64] [] *Post进行存储,key是Topic Id,value是Post指针类型的数组。
定义元索引
在repository目录下新建initData.go文件,用于定义元索引和对其进行初始化(即读取data目录下的两个文件)
var (
topicIndexMap map[int64]*Topic // 存储话题元索引
postIndexMap map[int64][]*Post // 存储回帖列表索引
rwMutex sync.RWMutex // 用于保证新增回帖时Map的线程安全
)
topicIndexMap 和 postIndexMap 这两个 map 可能会被多个 goroutine 同时访问和修改。
在本项目中,不涉及topicIndexMap的修改,因此不需要加锁。
但创建新回帖时,会存在数据竞争的风险,多个 goroutine 并发修改,可能导致数据不一致或者错误。
为了保证线程安全,这里使用了 sync.RWMutex 读写锁。
初始化元索引
处理步骤
- 打开文件
- 解析JSON为Topic结构体
- 构建索引map
// Init函数处理初始化逻辑
func Init(filePath string) error {
var err error
err = initTopicIndexMap(filePath)
err = initPostIndexMap(filePath)
return err
}
// 根据文件初始化话题索引
func initTopicIndexMap(filePath string) error {
open, err := os.Open(filePath + "topic") // 打开文件
defer open.Close() // 函数结束时关闭文件
if err != nil {
return err
}
scanner := bufio.NewScanner(open) // 创建一个扫描器来读取文件内容,将返回值赋给 scanner 变量
topicTmpMap := make(map[int64]*Topic) // 创建一个空的映射类型变量
for scanner.Scan() { // 循环遍历 scanner 扫描器,每次扫一行
text := scanner.Text() // 获取当前扫描到的文本内容
var topic Topic
err := json.Unmarshal([]byte(text), &topic) // 解析文本为JSON,并转为Topic结构体
if err != nil {
return err
}
topicTmpMap[topic.Id] = &topic // 将话题数据存储到映射中
}
topicIndexMap = topicTmpMap // 构建话题元索引
return nil
}
// 根据文件初始化帖子索引
func initPostIndexMap(filePath string) error {
open, err := os.Open(filePath + "post")
defer open.Close()
if err != nil {
return err
}
scanner := bufio.NewScanner(open)
postTmpMap := make(map[int64][]*Post)
for scanner.Scan() {
text := scanner.Text()
var post Post
err := json.Unmarshal([]byte(text), &post)
if err != nil {
return err
}
posts, ok := postTmpMap[post.ParentId]
if !ok { // 表示当前话题还没有回帖列表
postTmpMap[post.ParentId] = []*Post{&post} // 创建一个新的回帖列表并存储到映射中
continue
}
posts = append(posts, &post)
postTmpMap[post.ParentId] = posts // 更新映射中的回帖列表
}
postIndexMap = postTmpMap // 构建回帖列表元索引
return nil
}
4.3 Repository层
Repository层需要为Service层提供数据的查询和更新(修改、增加、删除)操作。
单例Dao
使用单例模式来提供一个线程安全的、易于管理的全局Dao对象,使其可以被高效共享。
全局单例Dao对象也有一些潜在问题
- 效率不高:锁和频繁的同步开销会降低性能。
- 代码侵入:全局实例强制要求依赖注入。
- 扩展性差:一个实例难以应对复杂业务。
- 并发访问:共享实例如果处理过长会阻塞其他请求。
- 代码复用性差:实例捆绑全局状态,不易重用。
因为本项目的操作简单,因此采用全局单例模式
repository/topic.go
repository/topic.go文件提供返回指定TopicId的Topic实体
由于TopicDao只涉及读取Topic数据,不涉及写入,所以不加线程安全锁,并且为每个线程创建实例,无需同步和锁的开销。
package repository
import (
"fmt"
)
// 话题实体
type Topic struct {
Id int64 `json:"Id"`
Title string `json:"title"`
Content string `json:"Content"`
CreateTime int64 `json:"CreateTime"`
}
// 处理Topic数据操作
type TopicDao struct {
// 内部可以添加dao需要的私有字段
}
var topicDao *TopicDao // 全局TopicDao指针
// 获取TopicDao实例
func NewTopicDaoInstance() *TopicDao {
return &TopicDao{}
}
// 通过TopicId查询Topic
func (*TopicDao) QueryTopicById(id int64) (*Topic, error) {
// 查找topic
topic, ok := topicIndexMap[id]
if !ok {
return nil, fmt.Errorf("topic %d not found", id)
}
return topic, nil
}
repository/post.go
repository/pos.got文件提供返回指定parentId的Post列表
因为需要创建新的回帖,所以采用全局单例Dao来保证线程安全
package repository
import (
"encoding/json"
"fmt"
"os"
"sync"
)
// 回帖实体
type Post struct {
Id int64 `json:"id"`
ParentId int64 `json:"parent_id"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
// 处理Post数据操作
type PostDao struct {
// 内部可以添加dao需要的私有字段
}
var (
postDao *PostDao // postDao全局指针
postOnce sync.Once // 确保全局postDao单例
)
// 获取postDao实例
func NewPostDaoInstance() *PostDao {
// 初始化postDao
postOnce.Do(
func() {
postDao = &PostDao{}
})
return postDao
}
// 通过parentId查询回帖数组
func (*PostDao) QueryPostByParentId(parentId int64) ([]*Post, error) {
// 查找 PostFormIndex
// 未找到返回错误
posts, ok := postIndexMap[parentId]
if !ok {
return nil, fmt.Errorf("topic %d has no one post", parentId)
}
return posts, nil
}
// 创建新Post
func (*PostDao) CreatePost(post *Post) error {
// 打开并写入Post到文件
f, err := os.OpenFile("./data/post", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return err
}
defer f.Close() // 函数结束时关闭文件
marshal, _ := json.Marshal(post) // 序列化Post结构体为json格式
// 写入文件,并在每个Post后面加上换行符
if _, err = f.WriteString(string(marshal) + "\n"); err != nil {
return err
}
rwMutex.Lock() // 加上读写锁,保证线程安全
defer rwMutex.Unlock() // 函数结束时释放锁
postList, ok := postIndexMap[post.ParentId] // 从postIndexMap中根据post.ParentId获取对应的回帖列表
if !ok {
postIndexMap[post.ParentId] = []*Post{post} // 如果不存在,就创建一个新的列表并存入map中
}
postList = append(postList, post) // 将新的回帖追加到列表中
postIndexMap[post.ParentId] = postList // 更新回帖列表元索引
return nil
}
4.4 Service层-查询
根据TopicId查询一个页面的信息,包括Topic和Post列表。我们使用查询流程控制来驱动整个流程,实现查询话题及其回帖逻辑。
查询流程控制说明
查询流程控制对象(Query Flow)是一种常见的Service层处理方式,主要原因有
- 将一个查询功能的不同步骤模块化,提高内聚性。
- 方便参数校验、业务处理、结果组装的逻辑分离。
- 可以引入中间状态对象,而不是直接返回结果。
- 易于日志记录和调试。
- 流程控制对象可以重用。
- 查询代码更易读和理解。
流程控制方式主要优点是将逻辑拆分得更清晰,更方便测试和扩展,有助于实现统一的服务接口。
查询流程控制步骤
- 定义了PageInfo结构体,用于存储返回的页面信息。
- 定义了QueryPageInfoFlow结构体,用于封装查询流程的属性和方法。
- 定义了NewQueryPageInfoFlow函数,用于初始化查询流程。
- 定义了Do方法,用于执行查询流程的各个步骤,并处理错误。
- 定义了QueryPageInfo函数,用于创建并执行查询流程,并返回结果。
- 定义了checkParam方法,用于检查参数是否合法。
- 定义了prepareInfo方法,用于准备信息,包括获取主题和回帖列表。这里使用了并发和等待组来提高效率。
- 定义了packPageInfo方法,用于包装结果为PageInfo结构体。
并行处理
话题元索引和回帖列表元索引可以同时根据TopicId进行数据的获取,因此可以并行获取。
本程序中使用了sync.WaitGroup来进行协调协程。
-
wg.Add(2)表示待加入 WaitGroup 的协程数量是2个,因为有两个并发查询 -
defer wg.Done()在每个并发查询中加入,可以在协程结束前将计数-1。 -
wg.Wait(),等待2个协程完成。
代码实现
在service层新建queryPageInfo.go文件
package service
import (
"articlePost.cn/repository"
"errors"
"sync"
)
// 定义返回结果
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
// 查询流程控制
type QueryPageInfoFlow struct {
topicId int64 // 流程属性
topic *repository.Topic // 流程属性
posts []*repository.Post // 流程属性
pageInfo *PageInfo // 结果属性
}
// 初始化流程
func NewQueryPageInfoFlow(topId int64) *QueryPageInfoFlow {
return &QueryPageInfoFlow{
topicId: topId,
}
}
// 执行查询流程
func (f *QueryPageInfoFlow) Do() (*PageInfo, error) {
// 检查参数
if err := f.checkParam(); err != nil {
return nil, err
}
// 准备信息
if err := f.prepareInfo(); err != nil {
return nil, err
}
// 包装结果
if err := f.packPageInfo(); err != nil {
return nil, err
}
// 返回结果
return f.pageInfo, nil
}
// 入口函数,创建流程并运行
func QueryPageInfo(topicId int64) (*PageInfo, error) {
return NewQueryPageInfoFlow(topicId).Do()
}
// 检查参数
func (f *QueryPageInfoFlow) checkParam() error {
if f.topicId <= 0 {
return errors.New("topic id must be larger than 0")
}
return nil
}
// 准备信息
// 该方法通过并发查询获取话题和回帖数据
func (f *QueryPageInfoFlow) prepareInfo() error {
// 获取topic信息
var wg sync.WaitGroup
wg.Add(2)
var topicErr, postErr error
// 启动goroutine并发查询话题
go func() {
defer wg.Done()
topic, err := repository.NewTopicDaoInstance().QueryTopicById(f.topicId) // Repository查询话题
if err != nil {
topicErr = err
return
}
f.topic = topic // 保存结果到flow中
}()
// 并发获取post列表
go func() {
defer wg.Done()
posts, err := repository.NewPostDaoInstance().QueryPostByParentId(f.topicId) // Repository查询回帖
if err != nil {
topicErr = err
return
}
f.posts = posts // 保存结果到flow中
}()
wg.Wait() // 等待两个goroutine结束,确保两个查询完成才继续流程
// 检查错误并返回
if topicErr != nil {
return topicErr
}
if postErr != nil {
return postErr
}
return nil
}
// 包装结果
func (f *QueryPageInfoFlow) packPageInfo() error {
// 封装为返回结构
f.pageInfo = &PageInfo{
Topic: f.topic,
PostList: f.posts,
}
return nil
}
4.5 Service层-发布
根据TopicId新增回帖,使用发布流程控制来驱动整个流程(与查询流程类似),实现新增回帖。
idworker.IdWorker
因为回帖id生成需要保证不重复、唯一性,笔者一开始想使用时间戳,但考虑到并发情况下时间戳可能一样,于是就看了老师的代码,使用Id生成器来生成全局唯一ID。
idworker.IdWorker包中的NextId()方法是线程安全的,它内部使用了锁来保护共享数据,以确保在多线程环境中能够正确地生成唯一的ID。NextId()方法使用了Snowflake算法来生成全局唯一的长整形ID。该算法使用时间戳作为一部分来生成ID,但是它还使用了其他信息来确保生成的ID是全局唯一的。例如,它还使用了机器ID和序列号来确保即使在同一时间戳下,也能够生成不同的ID。
代码实现
在service层新建publishPost.go文件
package service
import (
"articlePost.cn/repository"
"errors"
idworker "github.com/gitstliu/go-id-worker"
"time"
"unicode/utf16"
)
// 发布帖子流程控制
type PublishPostFlow struct {
content string
topicId int64
postId int64
post repository.Post
}
// ID生成器
var idGen *idworker.IdWorker
// 初始化ID生成器
func init() {
idGen = &idworker.IdWorker{}
idGen.InitIdWorker(1, 1)
}
// 发布帖子函数
func PublishPost(topicId int64, content string) (int64, error) {
// 创建流程实例并执行
return NewPublishPostFlow(topicId, content).Do()
}
// 创建发布帖子流程实例
func NewPublishPostFlow(topicId int64, content string) *PublishPostFlow {
return &PublishPostFlow{
content: content,
topicId: topicId,
}
}
// 执行发布流程
func (f *PublishPostFlow) Do() (int64, error) {
// 1. 参数校验
if err := f.checkParam(); err != nil {
return 0, err
}
// 2. 处理帖子信息
if err := f.dealInfo(); err != nil {
return 0, err
}
// 3. 返回生成的帖子ID
return f.postId, nil
}
// 参数校验
func (f *PublishPostFlow) checkParam() error {
if f.topicId <= 0 {
return errors.New("topic id must be larger than 0")
}
if len(utf16.Encode([]rune(f.content))) >= 500 {
return errors.New("content length must be less than 500")
}
return nil
}
// 处理帖子信息
func (f *PublishPostFlow) dealInfo() error {
// 创建帖子结构体
post := &repository.Post{
ParentId: f.topicId,
Content: f.content,
CreateTime: time.Now().Unix(),
}
// 生成ID
id, err := idGen.NextId()
if err != nil {
return err
}
post.Id = id
// repository创建Post
if err := repository.NewPostDaoInstance().CreatePost(post); err != nil {
return err
}
// 保存返回的ID
f.postId = post.Id
return nil
}
4.6 Controller层-查询
Controller层将调用Service层来处理Http请求,并封装响应体返回。
主要步骤是
- 解析请求参数
- 调用Service查询
- 封装响应结果
controller/pageInfo.go
新建controller/pageInfo.go文件
package controller
import (
"articlePost.cn/service"
"strconv"
)
// 全局Response结构信息
type PageData struct {
Code int64 `json:"code"` // 状态码
Msg string `json:"msg"` // 返回信息
Data interface{} `json:"data"` // 返回数据
}
// 处理查询页面请求
func QueryPageInfo(topicIdStr string) *PageData {
// 1.参数解析
topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
// 解析错误返回
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
// 2.调用Service层查询
pageInfo, err := service.QueryPageInfo(topicId)
// 解析错误返回
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
// 3. 封装Response结果
return &PageData{
Code: 0,
Msg: "success",
Data: pageInfo,
}
}
4.7 Controller层-发布
新建controller/post.go文件
package controller
import (
"articlePost.cn/service"
"strconv"
)
// 根据Topic id 新增回帖
func CreatePost(topicIdStr string, content string) *PageData {
// 1.参数解析
topicId, _ := strconv.ParseInt(topicIdStr, 10, 64)
// 2.调用Service层查询
postId, err := service.PublishPost(topicId, content)
// 解析错误返回
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
// 3. 封装Response结果
return &PageData{
Code: 0,
Msg: "success",
Data: map[string]int64{
"post_id": postId,
},
}
}
4.8 Router路由
- 通过构建路由,来调用Controller层,并返回结果。
- 初始化元索引
在main.go文件中修改
package main
import (
"articlePost.cn/controller"
"articlePost.cn/repository"
"github.com/gin-gonic/gin"
"net/http"
"os"
)
func main() {
// 1. 初始化元索引
err := repository.Init("./data/")
if err != nil {
os.Exit(-1)
}
// 2. 创建路由
router := gin.Default()
// 3. 处理查询页面请求
router.GET("/page/get/:id", func(c *gin.Context) {
// 获取参数
topicId := c.Param("id")
// 调用Controller层处理请求
data := controller.QueryPageInfo(topicId)
// 返回响应
c.JSON(http.StatusOK, data)
})
// 4. 处理发布帖子请求
router.POST("/page/post/do", func(c *gin.Context) {
// 获取参数
topicId, _ := c.GetPostForm("topic_id")
content, _ := c.GetPostForm("content")
// 调用Controller层处理请求
data := controller.CreatePost(topicId, content)
// 返回响应
c.JSON(200, data)
})
router.Run(":8080")
}
4.9 运行测试
-
go run main.go运行项目 -
在Postman中查询话题和帖子
http://localhost:8080/page/get/1
-
发送帖子
http://localhost:8080/page/post/do
-
再次查询
http://localhost:8080/page/get/1
五、总结
通过这个项目可以全面了解一个Golang Web服务项目的设计思路,包括需求分析、框架搭建、分层设计、业务组织、并发处理、ID生成等知识点。可以掌握Golang编程的一般模式,为后续工程项目的学习奠定基础。