GO语言工程实践课后作业:实现思路、代码以及路径记录 | 豆包MarsCode AI 刷题

141 阅读5分钟

GO语言工程实践课后作业:实现思路、代码以及路径记录 | 豆包MarsCode AI 刷题

拉取代码:git clone github.com/Moonlight-Z…

切换到 v0 分支:git checkout v0

社区话题页面:

  • 展示话题(标题、文字描述)和回帖列表。
  • 暂不考虑前端页面实现,仅仅实现一个本地 web 服务。
  • 话题和回帖数据用文件存储。

作业:

  • 支持发布帖子。
  • 本地 Id 生成需要保证不重复、唯一性。
  • Append 文件,更新索引,注意 Map 的并发安全问题。

实现思路

  1. 弄清数据之间的依赖关系。

    • 观察数据文件 /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。
  2. 分析业务需求

    发布帖子:需要内容(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 请求:

post_file.png