GO语言工程实践课后作业 | 豆包MarsCode AI刷题

130 阅读10分钟

项目实践

需求背景

掘金的社区话题的页面功能包括话题详情,回帖列表,支持回帖,点赞, 和回帖回复,本次项目实战以此为需求模型,开发一个该页面交涉及的服务端小功能。

需求描述

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

需求用例

用户能够浏览某个话题页面,访问该话题的标题,内容和该标题下的所有回帖。

ER图-Entity Relationship Diagram

话题和帖子是一对多的关系,每一个帖子都有一个话题ID,与对应的话题相关联。 image.png

分层结构设计

  1. 数据层:数据Model,外部数据的增删改查
  2. 逻辑层:业务Entity,处理核心业务逻辑输出
  3. 视图层:视图View,处理和外部的交互逻辑

组件工具

  1. Gin高性能go web框架 github.com/gin- gonic/…
  2. Go Mod
  • go mod init 命令用于初始化一个新的模块。这个命令会创建一个名为 go.mod 的文件,该文件包含了模块的路径、依赖项以及其他相关信息。
  • go get 用于从远程代码仓库下载并安装 Go 包及其依赖项。它可以自动处理包的依赖关系,确保所有依赖项都被正确下载和安装。
  • go tidy 是一个用于管理Go模块依赖关系的命令。它可以确保项目依赖是最新的,并且没有不必要的依赖。

当你运行 go tidy 时,它会做以下几件事情:

  1. 检查项目的 go.mod 文件,确保所有依赖项都被正确记录。
  2. 检查依赖项的版本,确保它们是最新的。如果有更新的版本可用,go tidy 会更新 go.mod 文件中的依赖项版本。
  3. 检查项目中是否有未使用的依赖项。如果有,go tidy 会从 go.mod 文件中移除这些依赖项。

设计思路与代码实现

数据存储基于文件(JSON格式),以持久化主题和帖子信息。整个后端结构分为三层:数据层、逻辑层和视图层。

1.数据模型

Topci结构

  • Id:int64-唯一标识符。
  • Title:string-主题的标题。
  • Content:字符串-描述或主要内容。
  • CreateTime:int64-创建时间作为Unix时间戳。

Post结构

  • Id:int64-唯一标识符。
  • ParentId:int64-引用主题。
  • Content:字符串-发布内容。
  • CreateTime:int64-创建时间作为Unix时间戳。
// topic.go
type Topic struct {
	Id         int64  `json:"id"`
	Title      string `json:"title"`
	Content    string `json:"content"`
	CreateTime int64  `json:"create_time"`
}

// post.go
type Post struct {
	Id         int64  `json:"id"`
	ParentId   int64  `json:"parent_id"`
	Content    string `json:"content"`
	CreateTime int64  `json:"create_time"`
}

2.应用层

2.1数据层

处理所有文件操作,将Topic和Post结构序列化和反序列化为JSON文件。

  • 文件结构:
    • 话题存储在 /data/topic
    • 帖子存储在 /data/post

数据访问接口

这里DAO对象是一个空对象,用到了单例模式,使用sync.Once来保证只被创建一次。同时加了错误处理机制,包括解析JSON失败,Map查询失败等。

type PostDao struct {
}

var postDao *PostDao
var postOnce sync.Once

func NewPostDaoInstance() *PostDao {
	postOnce.Do(func() {
		postDao = &PostDao{}
	})
	return postDao
}

func (*PostDao) QueryPostsByParentId(parentId int64) ([]*Post, error) {
	posts, ok := postIndexMap[parentId]
	if !ok {
		return nil, fmt.Errorf("parent id %d not found", parentId)
	}
	return posts, nil
}

话题和帖子的获取,这里采用了建立索引的方式来提高性能。

var (
	topicIndexMap sync.Map // 使用并发安全的 sync.Map
	postIndexMap  sync.Map // 使用并发安全的 sync.Map
)

func InitTopicIndexMap(filePath string) error {
	open, err := os.Open(filePath + "topic")
	if err != nil {
		return err
	}
	scanner := bufio.NewScanner(open)
	topicTmpMap := make(map[int64]*Topic)
	for scanner.Scan() {
		line := scanner.Text()
		var topic Topic
		if err := json.Unmarshal([]byte(line), &topic); err != nil {
			return err
		}
		topicTmpMap[topic.Id] = &topic
	}
	// 使用并发安全的map
	for k, v := range topicTmpMap {
		topicIndexMap.Store(k, v)
	}
	return nil
}

2.2逻辑层
对DAO提供的接口进一步封装,以给视图层提供更加高级的访问接口。
这里设计 queryPageInfoFlow 结构体和相关的方法是为了实现一种流程控制和封装,这种设计在软件开发中有几个好处:

  1. 封装性:将查询页面信息的逻辑封装在一个结构体中,使得代码更加模块化和可维护。通过这种方式,与查询页面信息相关的所有数据和操作都被组织在一起,便于管理和理解。
  2. 单一职责原则:每个结构体或方法只负责一个功能或任务。在这个例子中,queryPageInfoFlow 结构体负责查询页面信息的整个流程,包括参数检查、数据准备和结果封装。这种设计使得每个部分的功能更加清晰,易于修改和扩展。
  3. 可重用性:通过将查询流程封装在一个结构体中,可以在不同的地方重用这个流程,而不需要重复编写相同的代码。这提高了代码的复用性,减少了重复代码。
  4. 错误处理:在 queryPageInfoFlow 中,可以集中处理可能出现的错误,使得错误处理更加一致和可控。例如,在 Do 方法中,可以对每个步骤返回的错误进行处理,确保错误能够被正确捕获和处理。
  5. 流程控制:通过结构体中的方法,可以更好地控制查询流程的执行顺序和逻辑。例如,可以在 prepareInfo 方法中并行执行多个查询操作,提高查询效率。
  6. 测试性:封装的流程使得单元测试更加容易。可以针对每个方法编写测试用例,确保每个步骤都能正确执行。

如果不设计 queryPageInfoFlow,而是将所有逻辑直接写在 QueryPageInfo 函数中,虽然也能实现功能,但代码的可读性、可维护性和可测试性都会受到影响。封装成结构体和方法可以使得代码更加清晰、模块化,并且易于扩展和维护。

这里还使用了go协程来并行获取话题和帖子信息,提高速度。

type PageInfo struct {
	Topic    *repository.Topic
	PostList []*repository.Post
}

func QueryPageInfo(topicId int64) (*PageInfo, error) {
	return NewQueryPageInfoFlow(topicId).Do()
}

func NewQueryPageInfoFlow(topId int64) *QueryPageInfoFlow {
	return &QueryPageInfoFlow{
		topicId: topId,
	}
}

type QueryPageInfoFlow struct {
	topicId  int64
	pageInfo *PageInfo
	topic *repository.Topic
	posts []*repository.Post
}

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 (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
	go func() {
		defer wg.Done()
		topic, err := repository.NewTopicDaoInstance().QueryTopicById(f.topicId)
		if err != nil {
			topicErr = err
			return
		}
		f.topic = topic
	}()
	//获取post列表
	go func() {
		defer wg.Done()
		posts, err := repository.NewPostDaoInstance().QueryPostsByParentId(f.topicId)
		if err != nil {
			postErr = err
			return
		}
		f.posts = posts
	}()
	wg.Wait()
	if topicErr != nil {
		return topicErr
	}
	if postErr != nil {
		return postErr
	}

	return nil
}

func (f *QueryPageInfoFlow) packPageInfo() error {
	//post list
	postList := make([]*repository.Post, 0)
	for _, post := range f.posts {
		postList = append(postList, post)
	}
	f.pageInfo = &PageInfo{
		Topic:    f.topic,
		PostList: postList,
	}
	return nil
}

2.3视图层
调用逻辑层的服务,并提供接口给Server。

type PageData struct {
	Code int64       `json:"code"`
	Msg  string      `json:"msg"`
	Data interface{} `json:"data"`
}
func QueryPageInfo(topicIdStr string) *PageData {
	//参数转换
	topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
	if err != nil {
		return &PageData{
			Code: -1,
			Msg:  err.Error(),
		}
	}
	//获取service层结果
	pageInfo, err := service.QueryPageInfo(topicId)
	if err != nil {
		return &PageData{
			Code: -1,
			Msg:  err.Error(),
		}
	}
	return &PageData{
		Code: 0,
		Msg:  "success",
		Data: pageInfo,
	}
}

data interface{} json:"data"定义了一个名为Data的字段,其类型为interface{},并且使用 json` 标签来指定在 JSON 序列化和反序列化时该字段的名称。

在 Go 语言中,interface{} 是一种空接口类型,它可以表示任何类型的值。这意味着 Data 字段可以存储任何类型的数据,包括结构体、切片、映射、整数、字符串等。

json:"data" 标签告诉 Go 的标准库 encoding/json 在将结构体序列化为 JSON 格式时,应该将 Data 字段映射到名为 "data" 的 JSON 键。同样,在从 JSON 反序列化到结构体时,"data" 键的值将被填充到 Data 字段中。

这种灵活性使得 Data 字段可以适应不同的返回数据结构,这在处理动态数据或未知数据结构时非常有用。例如,在 QueryPageInfo 函数中,Data 字段可以用来存储从 service.QueryPageInfo(topicId) 返回的 pageInfo 数据,而不需要知道 pageInfo 的具体类型

2.4 Server
Server使用视图层提供的API来访问数据,使用了RESTful的gin框架来提供web服务。

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)
	})
	err := r.Run()
	if err != nil {
		return
	}
}

func Init(filePath string) error {
	if err := repository.Init(filePath); err != nil {
		return err
	}
	return nil
}

课后作业

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

发布帖子功能与查询帖子不同,需要发送POST请求,也就需要在Server中绑定一个POST方法,同时还需要底层提供添加帖子的API以供调用。

实现思路

1. 支持发布帖子

支持用户发布内容的基本流程包括:

  • 接口设计:实现一个 CreatePost 函数,将接收到的内容保存到文件中,并更新内存索引。
  • 存储持久化:保存至本地文件便于后续访问。
func (*PostDao) CreatePost(post *Post) error {
	post.Id = postDao.GeneratePostId(post.ParentId)
	// 将 post 结构体写入文件
	err := AppendToFile(post)
	if err != nil {
		return err
	}
	// 使用 LoadOrStore 方法来更新或插入值
	posts, _ := postIndexMap.LoadOrStore(post.ParentId, []*Post{})
	postIndexMap.Store(post.ParentId, append(posts.([]*Post), post))
	return nil
}
2. 本地唯一 ID 生成

生成唯一 ID 是为了保证每条发布的帖子拥有独立标识。本地 ID 生成的方案是维护一个全局最大PostId,每次创建帖子时,先自增然后返回该值,并通过锁保证并发访问。

3. 文件追加和索引更新

为了实现持久化保存,需要将每条帖子追加到文件中。同时,为提高读取效率,使用并发安全的 Map 存储索引。Go 的 sync.Map 提供了并发读写支持,确保多线程操作安全。

实现步骤
  1. 文件写入:追加模式打开文件,并将帖子信息写入。
  2. 索引更新:使用 sync.Map 存储 post.IDpost 的映射关系。
  3. 并发安全:通过 sync.Map 或者 sync.Mutex 实现线程安全的索引更新。
func AppendToFile(post *Post) error {
	// 实现将 post 结构体内容写入文件的逻辑
	file, err := os.OpenFile("./data/post", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return err
	}
	defer file.Close()

	// 将 post 结构体转换为 JSON 格式
	postJSON, err := json.Marshal(post)
	if err != nil {
		return err
	}

	// 将 JSON 字符串写入文件
	_, err = fmt.Fprintln(file, string(postJSON))
	return err
}

运行效果:

首先运行起server.go,然后在另一个命令行输入curl指令即可

查询功能验证: image.png

追加帖子和id唯一性功能验证:

image.png

路径记录

  • 开发路径:首先实现 Post 结构体,紧接着设计 PublishPostGenerateUniqueID 函数,然后实现文件追加和索引更新。
  • 测试路径:为了验证功能的正确性,可以使用单元测试对每个功能模块进行测试。例如,测试 GenerateUniqueID 是否生成唯一的 ID;测试 AppendToFile 的文件写入和索引更新功能。
  • 调试路径:调试过程中,可以使用日志记录调试信息,以便追踪文件写入和索引存储是否正常执行。

总结

以上是GO语言工程实践中完成的几个课后作业内容。通过此作业加深了对 Go 语言的并发处理、文件操作和安全 Map 使用的理解。在实际工程中,生成唯一ID、文件追加、并发安全的 Map 操作都是常用的技术,实现过程中遇到的问题和解决方法也为后续开发积累了经验。