「Go 语言上手 - 工程实践」实践项目实现记录 | 青训营笔记

161 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记

这篇文章用来记录一下实现作业的过程:

需求描述

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

需求中使用到两个实体,话题(Topic)和回帖(Post)

erDiagram
Tipic ||--o{ Post : reply

分层结构

整体分为三层,数据层(repository)、逻辑层(service)、视图层(controller)

  • 数据层需要实现与数据文件的交互,包括增删改查等操作。
  • 逻辑层提供核心业务逻辑,计算打包业务实体
  • 视图层处理和外部的交互逻辑

所以可以初步规划项目的目录结构如下:

image.png

其中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数据 image.png post数据 image.png 在浏览器中进行了简单的测试: image.png

总结

这次我们实现了一个简单的通过ID查询话题和回帖的后端服务,数据的持久化为了简单起见并没有使用数据库来保存,而是用文件来实现。通过这个实践项目,我们可以一窥利用Go开发后端服务的一般流程:需求分析,架构确定,数据层、逻辑层、视图层的实现,路由分发等等。

这个实践项目后面还有一些追加需求作为课后作业,这个我可能会在下一篇文章中再继续记录。

最后放上我的代码仓库,供大家参考

如文中有任何错漏之处,还望指出