GO语言工程实践课后作业:实现思路、代码以及路径记录 | 豆包MarsCode AI 刷题
拉取代码:git clone github.com/Moonlight-Z…
切换到 v0 分支:git checkout v0
社区话题页面:
- 展示话题(标题、文字描述)和回帖列表。
- 暂不考虑前端页面实现,仅仅实现一个本地 web 服务。
- 话题和回帖数据用文件存储。
作业:
- 支持发布帖子。
- 本地 Id 生成需要保证不重复、唯一性。
- Append 文件,更新索引,注意 Map 的并发安全问题。
实现思路
-
弄清数据之间的依赖关系。
- 观察数据文件
/data/topic以及/data/post - 找到程序入口,查看已有路由
/community/page/get/:id。- controller: 将参数转为正确的类型,调用 service 层的方法,并将结果返回给前端。
- service: 校验数据,调用 model 层的方法,根据 id 查询 topic 以及 post( id 对应 parentId)
- repository: 实现数据模型,代码中用 map 存储数据。
- 由上面可知。post 依赖于 topic,post 中的 parentId 对应 topic 中的 id。
- 观察数据文件
-
分析业务需求
发布帖子:需要内容(content)以及主题id(topicId)。然后返回一个 postId,若 postId = 0,则表示发布失败;否则表示发布成功。
实现细节
repository
postId 不可重复,需要生成唯一的 Id。(id > 0,<= 0 的均为无效 id)
- 使用一个 int64 类型的数字
newPostId,记录 postId 的最大值。需要生成 postId 时,自增即可。(模拟数据库中的auto_increment功能。由于数据库中,可以自己设置 id,故添加了一个postIdSet配合生成 postId。即若生成的 postId 已经存在于postIdSet中,则生成失败,但仍会自增。) - 为避免并发问题。
postIdSet使用锁来操作,newPostId使用原子操作。 相关代码如下:
var (
postIndexMapMutex sync.RWMutex
postIndexMap map[int64][]*Post // parentId(topicId) -> posts
postIdSetMutex sync.RWMutex
postIdSet map[int64]struct{} // postId -> interface{}
newPostId int64 // atomic 操作
)
// 读取文件时,更新 newPostId
func initPostIndexMap(filePath string) error {
open, err := os.Open(filePath + "post")
if err != nil {
return err
}
scanner := bufio.NewScanner(open)
postTmpMap := make(map[int64][]*Post)
postIdSet = make(map[int64]struct{})
newPostId = 0
for scanner.Scan() {
text := scanner.Text()
var post Post
if err := json.Unmarshal([]byte(text), &post); err != nil {
return err
}
posts, ok := postTmpMap[post.ParentId]
if !ok {
postTmpMap[post.ParentId] = []*Post{&post}
continue
}
posts = append(posts, &post)
// 更新 newPostId
postIdSet[post.Id] = struct{}{}
if newPostId < post.Id {
newPostId = post.Id
}
postTmpMap[post.ParentId] = posts
}
// 更新 newPostId
newPostId++
postIndexMap = postTmpMap
return nil
}
func getNewPostId() int64 {
postId := atomic.LoadInt64(&newPostId)
atomic.AddInt64(&newPostId, 1)
postIdSetMutex.RLock()
_, ok := postIdSet[postId]
postIdSetMutex.RUnlock()
if ok {
return -1
}
postIdSetMutex.Lock()
postIdSet[postId] = struct{}{}
postIdSetMutex.Unlock()
return postId
}
post 追加文件
- 发布帖子时,需要往
postIndexMap中添加记录。若只有并发读,则不需要加锁。现在加入写操作,则需要加锁保证并发安全。(由于topicIndexMap只有读操作,所以不加锁) 代码如下:
// 加上读写锁
func (*PostDao) QueryPostsByParentId(parentId int64) []*Post {
postIndexMapMutex.RLock()
defer postIndexMapMutex.RUnlock()
return postIndexMap[parentId]
}
// 创建 post
func (*PostDao) CreatePost(post *Post) error {
post.Id = getNewPostId()
// 获取到 id 就可以添加数据到 文件 以及 内存
var err error
// 持久化到文件
postFileMutex.Lock()
err = appendJsonData(postFile, post)
postFileMutex.Unlock()
if err != nil {
return fmt.Errorf("持久化数据到文件中失败:%w", err)
}
// 持久化文件中才算添加成功
// 内存
postIndexMapMutex.Lock()
posts := postIndexMap[post.ParentId]
if posts == nil {
postIndexMap[post.ParentId] = []*Post{post}
} else {
postIndexMap[post.ParentId] = append(postIndexMap[post.ParentId], post)
}
postIndexMapMutex.Unlock()
return nil
}
post 的持久化
上面的代码中也提到了。若不加持久化,文件中则没有我们的新增数据(/data/post 文件末尾需要加个换行。否则,第一条新增数据会直接追加到文件末尾,导致一行存在两条json数据,项目再次启动读取时,会出错)
简单起见,直接在 Init 阶段,打开文件,获取 *os.File。使用锁机制,保证并发安全(可以改 channel)。
var (
// 持久化数据
postFileMutex sync.Mutex
postFile *os.File
)
func Init(filePath string) error {
var err error
if err = initTopicIndexMap(filePath); err != nil {
return err
}
if err = initPostIndexMap(filePath); err != nil {
return err
}
postFile, err = os.OpenFile(fmt.Sprintf("%s/post", filePath), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("open post file error: %w", err)
}
return nil
}
func appendJsonData(file *os.File, data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal json data error: %w", err)
}
if _, err = file.WriteString(string(jsonData) + "\n"); err != nil {
return fmt.Errorf("write json data error: %w", err)
}
return nil
}
service
发布帖子时,service 获取到两个参数 topicId 和 content。首先检查参数合法性。 topicId > 0,content 加了一个长度限制 <= 500 (与 main 分支上的长度限制一致)。然后检查已有数据中是否存储 id 为 topicId 的 topic。若不存在,则返回错误。
检查无误后,创建帖子,然后调用 repository 的 CreatePost 方法,将帖子数据持久化到文件中。调用成功,则返回 postId。
func PublishPost(topicId int64, content string) (int64, error) {
// 1. 校验参数
var err error
if err = checkParams(topicId, content); err != nil {
return 0, err
}
// 2. 创建帖子
post := &repository.Post{
ParentId: topicId,
Content: content,
CreateTime: time.Now().Unix(),
}
if err = repository.NewPostDaoInstance().CreatePost(post); err != nil {
return 0, err
}
return post.Id, nil
}
controller
controller 接收前端请求,从请求中获取数据,并将参数转为正确的类型。然后调用 service 层的 PublishPost 方法,并将结果返回给前端。 响应体的格式如下
{
"code": 0,
"msg": "success",
"data": {
"postId": 123
}
}
代码如下:
func PublishPost(topicIdStr, content string) *PageData {
topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
//
postId, err := service.PublishPost(topicId, content)
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
return &PageData{
Code: 0,
Msg: "success",
Data: gin.H{
"post_id": postId,
},
}
}
测试
service 层的测试函数
package service
import (
"testing"
"github.com/stretchr/testify/assert"
)
// 发布失败
func TestPublishPostError(t *testing.T) {
postId, err := PublishPost(1000, "test post")
assert.Error(t, err)
assert.Equal(t, int64(0), postId)
}
// 发布成功
func TestPublishPostSuccess(t *testing.T) {
postId, err := PublishPost(1, "test post")
assert.NoError(t, err)
assert.NotEqual(t, 0, postId)
}
终端测试
启动服务:go run server.go 发送 http 请求:
- 发布失败:
- curl -X POST -d "topic_id=1000&content=test post" http://localhost:8080/community/post/publish
- 响应:{"code":-1,"msg":"topicId 1000 not exist","data":null}
- 发布成功:
- curl -X POST -d "topic_id=1&content=test post" http://localhost:8080/community/post/publish
- 响应:{"code":0,"msg":"success","data":{"post_id":14}}