项目实践
需求背景
掘金的社区话题的页面功能包括话题详情,回帖列表,支持回帖,点赞, 和回帖回复,本次项目实战以此为需求模型,开发一个该页面交涉及的服务端小功能。
需求描述
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地web服务
- 话题和回帖数据用文件存储
需求用例
用户能够浏览某个话题页面,访问该话题的标题,内容和该标题下的所有回帖。
ER图-Entity Relationship Diagram
话题和帖子是一对多的关系,每一个帖子都有一个话题ID,与对应的话题相关联。
分层结构设计
- 数据层:数据
Model,外部数据的增删改查 - 逻辑层:业务
Entity,处理核心业务逻辑输出 - 视图层:视图
View,处理和外部的交互逻辑
组件工具
- Gin高性能
go web框架 github.com/gin- gonic/… - Go Mod
go mod init命令用于初始化一个新的模块。这个命令会创建一个名为go.mod的文件,该文件包含了模块的路径、依赖项以及其他相关信息。go get用于从远程代码仓库下载并安装 Go 包及其依赖项。它可以自动处理包的依赖关系,确保所有依赖项都被正确下载和安装。go tidy是一个用于管理Go模块依赖关系的命令。它可以确保项目依赖是最新的,并且没有不必要的依赖。
当你运行 go tidy 时,它会做以下几件事情:
- 检查项目的
go.mod文件,确保所有依赖项都被正确记录。 - 检查依赖项的版本,确保它们是最新的。如果有更新的版本可用,
go tidy会更新go.mod文件中的依赖项版本。 - 检查项目中是否有未使用的依赖项。如果有,
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 结构体和相关的方法是为了实现一种流程控制和封装,这种设计在软件开发中有几个好处:
- 封装性:将查询页面信息的逻辑封装在一个结构体中,使得代码更加模块化和可维护。通过这种方式,与查询页面信息相关的所有数据和操作都被组织在一起,便于管理和理解。
- 单一职责原则:每个结构体或方法只负责一个功能或任务。在这个例子中,
queryPageInfoFlow结构体负责查询页面信息的整个流程,包括参数检查、数据准备和结果封装。这种设计使得每个部分的功能更加清晰,易于修改和扩展。 - 可重用性:通过将查询流程封装在一个结构体中,可以在不同的地方重用这个流程,而不需要重复编写相同的代码。这提高了代码的复用性,减少了重复代码。
- 错误处理:在
queryPageInfoFlow中,可以集中处理可能出现的错误,使得错误处理更加一致和可控。例如,在Do方法中,可以对每个步骤返回的错误进行处理,确保错误能够被正确捕获和处理。 - 流程控制:通过结构体中的方法,可以更好地控制查询流程的执行顺序和逻辑。例如,可以在
prepareInfo方法中并行执行多个查询操作,提高查询效率。 - 测试性:封装的流程使得单元测试更加容易。可以针对每个方法编写测试用例,确保每个步骤都能正确执行。
如果不设计 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 提供了并发读写支持,确保多线程操作安全。
实现步骤
- 文件写入:追加模式打开文件,并将帖子信息写入。
- 索引更新:使用
sync.Map存储post.ID到post的映射关系。 - 并发安全:通过
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指令即可
查询功能验证:
追加帖子和id唯一性功能验证:
路径记录
- 开发路径:首先实现
Post结构体,紧接着设计PublishPost和GenerateUniqueID函数,然后实现文件追加和索引更新。 - 测试路径:为了验证功能的正确性,可以使用单元测试对每个功能模块进行测试。例如,测试
GenerateUniqueID是否生成唯一的 ID;测试AppendToFile的文件写入和索引更新功能。 - 调试路径:调试过程中,可以使用日志记录调试信息,以便追踪文件写入和索引存储是否正常执行。
总结
以上是GO语言工程实践中完成的几个课后作业内容。通过此作业加深了对 Go 语言的并发处理、文件操作和安全 Map 使用的理解。在实际工程中,生成唯一ID、文件追加、并发安全的 Map 操作都是常用的技术,实现过程中遇到的问题和解决方法也为后续开发积累了经验。