需求
- 用户可以浏览话题,获得该话题的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.go 和 post.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.go 和 post_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功能介绍:
init()函数:初始化话题数据文件,如果文件不存在则创建,并调用loadExistingTopics()函数加载已有话题数据到内存中的topics映射中。loadExistingTopics()函数:从话题数据文件中读取已有数据,并将数据解析成话题对象后存储到内存中的topics映射中。SaveTopic()函数:保存一个新的话题到内存中的topics映射,并将数据写入话题数据文件。LoadTopics()函数:从话题数据文件中加载所有话题数据,并返回一个话题对象列表。LoadTopic()函数:根据话题 ID 从内存中的topics映射中加载特定话题的详细信息。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功能介绍:
init()函数:初始化回帖数据文件,如果文件不存在则创建,并调用loadExistingPosts()函数加载已有回帖数据到内存中的posts映射中。loadExistingPosts()函数:从回帖数据文件中读取已有数据,并将数据解析成回帖对象后存储到内存中的posts映射中。SavePost()函数:保存一个新的回帖到内存中的posts映射,并将数据写入回帖数据文件。LoadPosts()函数:根据话题 ID 从内存中的posts映射中加载特定话题的所有回帖数据,并返回一个回帖对象列表。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功能介绍:
IndexHandler:处理显示所有话题的请求。它调用storage.LoadTopics()来加载所有话题数据,然后将数据传递给模板渲染引擎,最终返回一个包含话题列表的 HTML 页面。TopicHandler:处理显示特定话题及回帖的请求。它首先从请求中获取话题的 ID,然后调用storage.LoadTopic(topicID)加载特定话题的详细信息。如果话题存在,它还调用storage.LoadPosts(topicID)加载该话题的所有回帖数据。最后,它将话题和回帖数据传递给模板渲染引擎,生成一个包含话题详情和回帖列表的 HTML 页面。PostHandler:处理发布新回帖的请求。它从 POST 请求中获取话题 ID 和回帖内容,然后创建一个新的回帖对象并调用storage.SavePost(post)将回帖保存到数据存储中。之后,它将用户重定向到特定话题页面。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.tmpl和index.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
- 输入帖子标题和内容,发布帖子
- 点击帖子
- 输入评论内容,发布评论