GO语言工程实践课后作业:实现思路、代码以及路径记录 | 青训营

117 阅读9分钟

需求

  • 用户可以浏览话题,获得该话题的title,content和创建时间,以及该话题的所有回帖列表
  • 用户可以在话题下面回帖,通过输入回帖内容后点击“发布回帖”来增加回帖
  • 使用Gin框架和模板
  • 话题和回帖数据用文件存储(分别在data/topic.txt和data/post.txt文件),并分别使用map建立索引

本文所用到的Gin和HTML模板在之前的文章中有介绍 # Gin框架与Web开发 | 青训营

数据结构设计

1. 话题(Topic)

  • id(唯一)
  • title
  • content
  • create_time

2. 回帖(Post)

  • id(唯一)
  • topic_id(外联Topic的id)
  • content
  • create_time

实现思路

1. 文件目录

- project_work1/
  - cmd/
    - main.go
  - internal/
    - handlers/
      - topic_handler.go
    - models/
      - topic.go
      - post.go
    - storage/
      - topic_store.go
      - post_store.go
  - web/
    - templates/
      - index.tmpl
      - topic.tmpl
  - data/
    - topics.txt
    - posts.txt
  • cmd/:这个目录包含主应用程序的代码,主要是 main.go
  • internal/:这个目录包含项目内部的模块,包括处理程序、数据模型和数据存储。
    • handlers/:处理 HTTP 请求的处理程序代码。
    • models/:数据模型的定义。
    • storage/:数据存储的功能代码。
  • web/:这个目录包含用于渲染用户界面的模板文件。
    • templates/:HTML 模板文件。
  • data/:这个目录包含话题和回帖数据的文本文件。
    • topics.txt:话题数据文本文件。
    • posts.txt:回帖数据文本文件。

这个目录结构将项目分为各个模块,有助于组织代码并使其易于维护和扩展。以后可以根据需要进一步调整和扩展这个目录结构,例如添加配置文件、静态文件等。

2. 定义数据结构和存储

internal/models/ 目录下创建 topic.gopost.go 文件来定义话题和回帖的数据结构。

internal/models/topic.go

package models

import "time"

// Topic 表示一个话题的数据结构
type Topic struct {
    ID         int       // 话题的唯一标识符
    Title      string    // 话题的标题
    Content    string    // 话题的内容
    CreateTime time.Time // 话题的创建时间
}

internal/models/post.go

package models

import "time"

// Post 表示一个回帖的数据结构
type Post struct {
    ID         int       // 回帖的唯一标识符
    TopicID    int       // 回帖所属话题的 ID
    Content    string    // 回帖的内容
    CreateTime time.Time // 回帖的创建时间
}

3. 数据的读取、写入和管理

internal/storage/ 目录下创建 topic_store.gopost_store.go 文件。这两个文件负责处理数据的读取、写入和管理,以及提供了一些基本的数据操作接口。通过这两个文件,你的应用程序可以在多次运行之间保留数据,并且在不同的部分之间共享数据。

internal/storage/topic_store.go

package storage

import (
    "bufio"
    "fmt"
    "github.com/XCmafu/go_project_work1/internal/models"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

var (
    topics     = make(map[int]models.Topic) // 存储话题数据的映射
    topicIDSeq = 1                          // 用于生成唯一的话题 ID
    topicLock  sync.RWMutex                 // 用于保护对 topics 的并发访问
    topicFile  *os.File                     // 用于操作话题数据文件
)

var (
    topicDataFilePath = "data/topics.txt" // 话题数据文件路径
)

// init 初始化话题数据文件并加载已有数据
func init() {
    // 初始化话题数据文件,如果文件不存在则创建
    var err error
    topicFile, err = os.OpenFile(topicDataFilePath, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644)
    if err != nil {
       fmt.Println("Failed to initialize topic data file:", err)
       return
    }

    // 加载已有数据到 topics 中
    loadExistingTopics()
}

// loadExistingTopics 从文件中加载已有话题数据到 topics 映射中
func loadExistingTopics() {
    topicLock.Lock()         // 获取写锁,保护对 topics 的并发访问
    defer topicLock.Unlock() // 函数执行完后释放写锁

    topicFile.Seek(0, 0) // 将文件指针移到文件开头
    scanner := bufio.NewScanner(topicFile)
    // 逐行扫描从 topicFile 文件中读取的内容。
    for scanner.Scan() {
       fields := strings.Split(scanner.Text(), "|") // 将扫描器(scanner)当前行的文本内容按照竖线符号 | 进行分割,并返回一个字符串切片(数组)
       if len(fields) != 4 {
          continue
       }
       topicID, _ := strconv.Atoi(fields[0])
       createTime, _ := time.Parse(time.RFC3339, fields[3])
       topic := models.Topic{
          ID:         topicID,
          Title:      fields[1],
          Content:    fields[2],
          CreateTime: createTime,
       }
       topics[topic.ID] = topic // 将数据加载到 topics
       if topicID >= topicIDSeq {
          topicIDSeq = topicID + 1 // 更新 topicIDSeq
       }
    }
}

// SaveTopic 保存一个新的话题到 topics 中,并将数据写入文件
func SaveTopic(topic models.Topic) {
    topicLock.Lock()         // 获取写锁,保护对 topics 的并发访问
    defer topicLock.Unlock() // 函数执行完后释放写锁

    // 为话题分配一个唯一的 ID,并设置创建时间为当前时间
    topic.ID = topicIDSeq
    topic.CreateTime = time.Now()

    // 将话题存储到 topics 映射中
    topics[topic.ID] = topic
    topicIDSeq++

    // 将话题数据写入文件
    writeTopicToFile(topic)
}

// LoadTopics 从文件中加载所有话题数据并返回一个话题列表
func LoadTopics() []models.Topic {
    topicLock.RLock()         // 获取读锁,保护对 topics 的并发访问
    defer topicLock.RUnlock() // 函数执行完后释放读锁

    var topicList []models.Topic
    topicFile.Seek(0, 0) // 将文件指针移到文件开头
    scanner := bufio.NewScanner(topicFile)
    for scanner.Scan() {
       fields := strings.Split(scanner.Text(), "|")
       if len(fields) != 4 {
          continue
       }
       topicID, _ := strconv.Atoi(fields[0])
       createTime, _ := time.Parse(time.RFC3339, fields[3])
       topicList = append(topicList, models.Topic{
          ID:         topicID,
          Title:      fields[1],
          Content:    fields[2],
          CreateTime: createTime,
       })
    }

    return topicList
}

// LoadTopic 从文件中加载特定话题的详细信息并返回一个话题,如果找不到则返回 nil
func LoadTopic(topicID int) *models.Topic {
    topicLock.RLock()         // 获取读锁,保护对 topics 的并发访问
    defer topicLock.RUnlock() // 函数执行完后释放读锁

    topic := topics[topicID]
    if topic.ID == 0 {
       return nil // 未找到特定话题
    }
    return &topic
}

// writeTopicToFile 将话题数据写入文件
func writeTopicToFile(topic models.Topic) {
    topicString := fmt.Sprintf("%d|%s|%s|%s\n", topic.ID, topic.Title, topic.Content, topic.CreateTime.Format(time.RFC3339))
    _, _ = topicFile.WriteString(topicString)
}

topic_store.go功能介绍:

  1. init() 函数:初始化话题数据文件,如果文件不存在则创建,并调用 loadExistingTopics() 函数加载已有话题数据到内存中的 topics 映射中。
  2. loadExistingTopics() 函数:从话题数据文件中读取已有数据,并将数据解析成话题对象后存储到内存中的 topics 映射中。
  3. SaveTopic() 函数:保存一个新的话题到内存中的 topics 映射,并将数据写入话题数据文件。
  4. LoadTopics() 函数:从话题数据文件中加载所有话题数据,并返回一个话题对象列表。
  5. LoadTopic() 函数:根据话题 ID 从内存中的 topics 映射中加载特定话题的详细信息。
  6. writeTopicToFile() 函数:将话题数据写入话题数据文件。

internal/storage/post_store.go

package storage

import (
    "bufio"
    "fmt"
    "github.com/XCmafu/go_project_work1/internal/models"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

var (
    posts     = make(map[int][]models.Post) // 存储回帖数据的映射
    postIDSeq = 1                           // 用于生成唯一的回帖 ID
    postLock  sync.RWMutex                  // 用于保护对 posts 的并发访问
    postFile  *os.File                      // 用于操作回帖数据文件
)

var (
    postDataFilePath = "data/posts.txt" // 回帖数据文件路径
)

func init() {
    // 初始化回帖数据文件,如果文件不存在则创建
    var err error
    postFile, err = os.OpenFile(postDataFilePath, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644)
    if err != nil {
       fmt.Println("Failed to initialize post data file:", err)
       return
    }

    // 加载已有数据到 posts 中
    loadExistingPosts()
}

func loadExistingPosts() {
    postLock.Lock()         // 获取写锁,保护对 posts 的并发访问
    defer postLock.Unlock() // 函数执行完后释放写锁

    postFile.Seek(0, 0) // 将文件指针移到文件开头
    scanner := bufio.NewScanner(postFile)
    for scanner.Scan() {
       fields := strings.Split(scanner.Text(), "|")
       if len(fields) != 4 {
          continue
       }
       postID, _ := strconv.Atoi(fields[0])
       topicID, _ := strconv.Atoi(fields[1])
       createTime, _ := time.Parse(time.RFC3339, fields[3])
       post := models.Post{
          ID:         postID,
          TopicID:    topicID,
          Content:    fields[2],
          CreateTime: createTime,
       }
       if _, found := posts[topicID]; !found {
          // 如果话题不存在,就创建一个新的切片,并将 post 添加到这个切片中。
          posts[topicID] = []models.Post{post}
       } else {
          // 如果话题存在,将新的 post 追加到话题对应的帖子切片中。
          posts[topicID] = append(posts[topicID], post)
       }
       if postID >= postIDSeq {
          postIDSeq = postID + 1 // 更新 postIDSeq
       }
    }
}

// SavePost 保存一个新的回帖到 posts 中,并将数据写入文件
func SavePost(post models.Post) {
    postLock.Lock()         // 获取写锁,保护对 posts 的并发访问
    defer postLock.Unlock() // 函数执行完后释放写锁

    // 为回帖分配一个唯一的 ID,并设置创建时间为当前时间
    post.ID = postIDSeq
    post.CreateTime = time.Now()

    // 将回帖存储到 posts 映射中,使用 topicID 作为键来分组回帖
    posts[post.TopicID] = append(posts[post.TopicID], post)
    postIDSeq++

    // 将回帖数据写入文件
    writePostToFile(post)
}

// LoadPosts 从文件中加载特定话题的所有回帖数据并返回一个回帖列表
func LoadPosts(topicID int) []models.Post {
    postLock.RLock()         // 获取读W锁,保护对 posts 的并发访问
    defer postLock.RUnlock() // 函数执行完后释放读锁

    return posts[topicID]
}

// writePostToFile 将回帖数据写入文件
func writePostToFile(post models.Post) {
    postString := fmt.Sprintf("%d|%d|%s|%s\n", post.ID, post.TopicID, post.Content, post.CreateTime.Format(time.RFC3339))
    _, _ = postFile.WriteString(postString)
}

post_store.go功能介绍:

  1. init() 函数:初始化回帖数据文件,如果文件不存在则创建,并调用 loadExistingPosts() 函数加载已有回帖数据到内存中的 posts 映射中。
  2. loadExistingPosts() 函数:从回帖数据文件中读取已有数据,并将数据解析成回帖对象后存储到内存中的 posts 映射中。
  3. SavePost() 函数:保存一个新的回帖到内存中的 posts 映射,并将数据写入回帖数据文件。
  4. LoadPosts() 函数:根据话题 ID 从内存中的 posts 映射中加载特定话题的所有回帖数据,并返回一个回帖对象列表。
  5. writePostToFile() 函数:将回帖数据写入回帖数据文件。

4. 处理话题和回帖请求

topic_handler.go 是一个处理话题和回帖请求的文件,它包含了用于处理不同类型请求的处理函数。这些处理函数使用了 gin 框架来处理 HTTP 请求和生成 HTTP 响应。

internal/handlers/topic_handler.go

package handlers

import (
    "github.com/XCmafu/go_project_work1/internal/models"
    "github.com/XCmafu/go_project_work1/internal/storage"
    "github.com/gin-gonic/gin"
    "net/http"
    "strconv"
)

// IndexHandler 处理显示所有话题的请求
func IndexHandler(c *gin.Context) {
    topicList := storage.LoadTopics()
    c.HTML(http.StatusOK, "index.tmpl", gin.H{
       "topics": topicList,
    })
}

// TopicHandler 处理显示特定话题及回帖的请求
func TopicHandler(c *gin.Context) {
    topicID, _ := strconv.Atoi(c.Param("id"))
    topic := storage.LoadTopic(topicID)
    if topic == nil {
       c.String(http.StatusNotFound, "Topic not found")
       return
    }
    postList := storage.LoadPosts(topicID)
    c.HTML(http.StatusOK, "topic.tmpl", gin.H{
       "topic":  topic,
       "posts":  postList,
       "postId": 0, // Set a placeholder value for new post form
    })
}

// PostHandler 处理发布新回帖的请求
func PostHandler(c *gin.Context) {
    topicID, _ := strconv.Atoi(c.PostForm("topic_id"))
    content := c.PostForm("content")
    post := models.Post{
       TopicID: topicID,
       Content: content,
    }
    storage.SavePost(post)
    c.Redirect(http.StatusSeeOther, "/topic/"+strconv.Itoa(topicID))
}

// NewTopicHandler 处理发布新话题的请求
func NewTopicHandler(c *gin.Context) {
    title := c.PostForm("title")
    content := c.PostForm("content")
    topic := models.Topic{
       Title:   title,
       Content: content,
    }
    storage.SaveTopic(topic)
    c.Redirect(http.StatusSeeOther, "/")
}

topic_handler.go功能介绍:

  1. IndexHandler:处理显示所有话题的请求。它调用 storage.LoadTopics() 来加载所有话题数据,然后将数据传递给模板渲染引擎,最终返回一个包含话题列表的 HTML 页面。
  2. TopicHandler:处理显示特定话题及回帖的请求。它首先从请求中获取话题的 ID,然后调用 storage.LoadTopic(topicID) 加载特定话题的详细信息。如果话题存在,它还调用 storage.LoadPosts(topicID) 加载该话题的所有回帖数据。最后,它将话题和回帖数据传递给模板渲染引擎,生成一个包含话题详情和回帖列表的 HTML 页面。
  3. PostHandler:处理发布新回帖的请求。它从 POST 请求中获取话题 ID 和回帖内容,然后创建一个新的回帖对象并调用 storage.SavePost(post) 将回帖保存到数据存储中。之后,它将用户重定向到特定话题页面。
  4. NewTopicHandler:处理发布新话题的请求。它从 POST 请求中获取话题的标题和内容,然后创建一个新的话题对象并调用 storage.SaveTopic(topic) 将话题保存到数据存储中。最后,它将用户重定向回首页。

5. 主函数

main.go是应用程序的入口点,它使用 gin 框架来设置路由和处理请求。

package main

import (
    "github.com/XCmafu/go_project_work1/internal/handlers"
    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()

    r.LoadHTMLGlob("web/templates/*")

    r.GET("/", handlers.IndexHandler)
    r.GET("/topic/:id", handlers.TopicHandler)
    r.POST("/post", handlers.PostHandler)
    r.POST("/topic", handlers.NewTopicHandler)

    r.Run(":8080")
}

6. 模板

模板topic.tmplindex.tmpl将在服务器端渲染,根据传入的数据动态生成最终的 HTML 页面。

index.tmpl

<!DOCTYPE html>
<html>
<head>
    <title>Topics</title>
</head>
<body>
    <h1>帖子列表</h1>
    <ul>
        {{ range .topics }}
            <li><a href="/topic/{{ .ID }}">{{ .Title }}</a></li>
        {{ end }}
    </ul>
    <hr>
    <h2>创建新帖子</h2>
    <form action="/topic" method="post">
        <label for="title">标题:</label>
        <input type="text" name="title" id="title" required><br>
        <label for="content">内容:</label>
        <textarea name="content" id="content" rows="4" required></textarea><br>
        <input type="submit" value="发布帖子">
    </form>
</body>
</html>

topic.tmpl

<!DOCTYPE html>
<html>
<head>
    <title>{{ .topic.Title }}</title>
</head>
<body>
    <h1>帖子标题: {{ .topic.Title }}</h1>
    <p>{{ .topic.Content }}</p>
    <p>{{ .topic.CreateTime }}</p>
    <hr>
    <h2>评论列表</h2>
    <ul>
        {{ range .posts }}
            <li>{{ .Content }}</li>
        {{ end }}
    </ul>
    <hr>
    <h2>发布新的评论</h2>
    <form action="/post" method="post">
        <input type="hidden" name="topic_id" value="{{ .topic.ID }}">
        <label for="content">内容:</label>
        <textarea name="content" id="content" rows="4" required></textarea><br>
        <input type="submit" value="发布评论">
    </form>
    <br>
    <a href="/">返回帖子列表</a>
</body>
</html>

实现结果

上述工程代码已上传 Github

  1. 访问http://127.0.0.1:8080/

image.png

  1. 输入帖子标题和内容,发布帖子

image.png

image.png

  1. 点击帖子

image.png

  1. 输入评论内容,发布评论

image.png

image.png