这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记
这篇文章用来记录一下实现作业的过程:
需求描述
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地web服务
- 话题和回帖数据用文件存储
需求中使用到两个实体,话题(Topic)和回帖(Post)
erDiagram
Tipic ||--o{ Post : reply
分层结构
整体分为三层,数据层(repository)、逻辑层(service)、视图层(controller)
- 数据层需要实现与数据文件的交互,包括增删改查等操作。
- 逻辑层提供核心业务逻辑,计算打包业务实体
- 视图层处理和外部的交互逻辑
所以可以初步规划项目的目录结构如下:
其中repository、service、controller分别对应上述的三个层次,data目录用于存放数据文件,tool目录存放自己写的一些工具函数,test目录存放一些测试函数。最后的server.go是执行路由处理。
数据层实现
需求只要实现展示话题和回帖功能,可以定义两个结构体,分别对应前面的两个实体:Post、Topic。
它们的属性定义如下:
type Post struct {
Id int64 `json:"id"`
User_id int64 `json:"user_id"`
Parent_id int `json:"parent_id"`
Content string `json:"content"`
Create_time int64 `json:"create_time"`
}
type Topic struct {
Id int64 `json:"id"`
User_id int64 `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Create_time int64 `json:"create_time"`
}
在topic.go中实现对Topic的操作,在post.go文件中实现对Post的操作。
由于topic.go和post.go的实现思路是类似的,因此接下来会以topic的实现进行讲解,post的实现就不再赘述了。
TopicDAO
先从topic.go开始实现,代码如下:
type Topic struct {
Id int64 `json:"id"`
User_id int64 `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Create_time int64 `json:"create_time"`
}
type TopicDao struct {
}
var (
topicDao *TopicDao
topicOnce sync.Once
)
func NewTopicDaoInstance() *TopicDao {
topicOnce.Do(
func() {
topicDao = &TopicDao{}
})
return topicDao
}
上面代码的主要思路是定义了一个DAO,由DAO的方法来执行具体的数据操作。其中实例化TopicDao的函数中使用了sync.Once的能力,使得TopicDao在程序中只会实例化一次,后续的调用会直接返回之前的topicDao。这就是设计模式里所谓的单例模式,可以有效减少多余实例化的开销。
现在我们已经有了一个TopicDao,但是还没有给它写查询方法。按前面的需求来说,这里可以直接写一个操作数据文件的方法,但是每一次查询或者创建Topic都要进行一次文件的IO操作,开销会非常大。因此可以提前先把数据文件的数据先读入内容,TopicDao则直接操作内存中的数据。
内存读入数据初始化
我们新建一个db_init.go文件,在这里进行内存读入数据文件的初始化工作,代码如下:
var topicIndexMap map[int64]*Topic
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() {
text := scanner.Text()
var topic Topic
if err := json.Unmarshal([]byte(text), &topic); err != nil {
return err
}
topicTmpMap[topic.Id] = &topic
fmt.Println(topicTmpMap[topic.Id])
}
topicIndexMap = topicTmpMap
return nil
}
分析上面的代码,我们使用一个map数据结构来保存我们的数据,其中用Topic.Id来作为我们数据的Key, Topic的内容作为Value,这样就使用map数据结构完成了一个简单的数据索引。我们可以很容易地通过Id获得该Id的Topic内容。
为TopicDAO实现查询操作
现在我们回到topic.go文件,为TopicDAO写一个通过ID查询Topic内容的函数,代码很简单,如下:
func (*TopicDao) QueryTopicById(id int64) (*Topic, error) {
return topicIndexMap[id], nil
}
因为我们在db_init.go中已经把数据文件的内容读入内存,保存在topicIndexMap中,所以这里只要简单的返回topicIndexMap[id]即可。
逻辑层实现
逻辑层具体的流程可以概况如下:
参数校验-->准备数据-->组装实体
分析需求,我们在逻辑层需要提供给视图层一个页面实体,其中包括了Topic、Post列表。所以我们可以先定义一个PageInfo结构体:
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
为了方便处理,我们再定义一个查询页面信息的处理流对象:
type QueryPageInfoFlow struct {
pageInfo *PageInfo
topicId int64
topic *repository.Topic
posts []*repository.Post
}
这些定义都在query_page_info.go文件里
接下来就可以开始我们逻辑层的具体实现了。
参数校验
对于从视图层传来的参数,在真实业务中我们必须要严格进行校验,对后端来说,前端是不可信任的,因为前端发来的数据非常容易被篡改。不过在这个实践项目中,重点不在此,我们可以简单地进行处理就好。代码如下:
func (f *QueryPageInfoFlow) checkParam() error {
if f.topicId <= 0 {
return errors.New("topic id must be larger than 0")
}
return nil
}
准备数据
在这个阶段,我们需要根据ID来从数据层获得Topic和Post。值得注意的是,查询Topic和查询Post之间并无联系,因此这里我们可以使用并发操作来获得较高的查询效率。
代码如下:
func (f *QueryPageInfoFlow) prepareInfo() error {
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
}()
go func() {
defer wg.Done()
posts, err := repository.NewPostDaoInstance().QueryPostById(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
}
上述代码中,我使用了sync.WaitGroup提供的机制来确保两个协程执行完毕
组装实体
这一步中,我们把从数据层中获取到的数据包装好,代码如下:
func (f *QueryPageInfoFlow) packPageInfo() error {
f.pageInfo = &PageInfo{
PostList: f.posts,
Topic: f.topic,
}
return nil
}
流程整合
由于参数校验-->准备数据-->组装实体的这一整套流程是固定的,我们可以把上面这流程整合到一个DO函数里,这样方便上层的调用,具体如下:
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 QueryPageInfo(topicId int64) (*PageInfo, error) {
queryPageInfoFlow := &QueryPageInfoFlow{
topicId: topicId,
}
pageInfo, err := queryPageInfoFlow.Do()
return pageInfo, err
}
上一层可以通过这个接口来获得查询页面的处理流,而无需关心其他,实现了对上层的透明。
视图层实现
在视图层,我们要做的是构建业务错误码,构建View对象等操作,其中页面View对象的定义如下:
type PageData struct {
Code int64 `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
注意到上面的Data是一个空的interface类型,一句话简单理解interface类型,就是:interface类型的变量能够存储任何实现该interface的对象类型。这里Data是空interface,意味着任意的对象都实现了它,因此Data实际可以存储任意类型的对象。
接下来我们只要把从逻辑层取得的业务对象包装到页面对象中即可,具体代码如下:
func QueryPageInfo(topicIdStr string) *PageData {
topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
if err != nil {
return &PageData{
Code: 400,
Msg: err.Error(),
}
}
pageInfo, err := service.QueryPageInfo(topicId)
if err != nil {
return &PageData{
Code: 400,
Msg: err.Error(),
}
}
return &PageData{
Code: 200,
Msg: "success",
Data: pageInfo,
}
}
Router
至此,我们的三个层次就全部实现了,但是别急,现在我们还缺少一个Router来为我们分发路由。 我们在server.go中,使用GIN框架来为我们实现接收web请求并分发路由的工作,具体代码如下:
func main() {
defer tool.NewIdInstance().SaveId()
if err := Init("./data/"); err != nil {
fmt.Println(err.Error())
os.Exit(-1)
}
r := gin.Default()
r.GET("/community/page/get/:id", func(c *gin.Context) {
topicId := c.Param("id")
data := controller.QueryPageInfo(topicId)
c.JSON(200, data)
})
r.POST("/community/page/post", func(c *gin.Context) {
buf := make([]byte, 1024)
n, _ := c.Request.Body.Read(buf)
var page controller.Page
json.Unmarshal(buf[0:n], &page)
err := controller.CreatePageInfo(&page)
if err != nil {
fmt.Println(err.Error())
}
resp := map[string]string{"msg": "ok"}
c.JSON(200, resp)
})
err := r.Run()
if err != nil {
return
}
}
可以看到代码是写在main函数里的,通过GIN提供的机制,接收请求,取出参数,并把相应的请求分发到对应的函数去执行。
另外在开头,可以看到我们有一个Init函数。还记得我们在数据层中实现的索引吗?这里的Init函数的功能就是初始化我们的数据索引,具体代码如下:
func Init(filePath string) error {
err := repository.InitPostIndexMap(filePath)
if err != nil {
return err
}
err = repository.InitTopicIndexMap(filePath)
if err != nil {
return err
}
return nil
}
测试
topic数据
post数据
在浏览器中进行了简单的测试:
总结
这次我们实现了一个简单的通过ID查询话题和回帖的后端服务,数据的持久化为了简单起见并没有使用数据库来保存,而是用文件来实现。通过这个实践项目,我们可以一窥利用Go开发后端服务的一般流程:需求分析,架构确定,数据层、逻辑层、视图层的实现,路由分发等等。
这个实践项目后面还有一些追加需求作为课后作业,这个我可能会在下一篇文章中再继续记录。
如文中有任何错漏之处,还望指出