Go语言入门工程实践|青训营笔记

98 阅读13分钟

语言进阶

  1. 并发与并行

并发是指一个处理器同时处理多个任务。在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。

image.png

A、B代表两个不同的线程,只有一个cpu核心,绿色方块代表cpu时间片。在t1时间点,只有A线程在cpu上执行,在t2时间点,只有B线程在cpu上执行。由于cpu时间片很短(一般在10~100ms),对于人类反应来说很快所以人类观察的现象可能就是下图:

image.png

例如一个电脑只有一个cpu核心,用户打开两个应用,cpu实际上是在这两个应用上交替运行,而对于用户来讲,观察到的现象却是这两个应用在同时执行,所以说并发是宏观并行,微观串行。 并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。

image.png

A、B代表两个不同的线程,有两个cpu核心,绿色方块代表核心1的时间片,黄色方框代表核心2的时间片。在t1时间点,A、B线程都在cpu上执行,在t2时间点,A、B线程都在cpu上执行。

  1. Goroutine

image.png

协程: 协程处于用户态,是轻量级线程,栈只有KB级别。

线程: 线程处于内核态,一个线程可以运行多个协程,栈为MB级别。

使用go协程打印字符串:

package main

import (
   "fmt"
   "time"
)

func main() {
   for i := 0; i < 5; i++ {
      go func(j int) {
         hello(j)
      }(i)
   }
   // 休眠主协程等待goroutine执行完毕
   time.Sleep(time.Second)
}

func hello(j int) {
   fmt.Printf("hello goroutine:%d\n", j)
}

控制台输出:

hello goroutine:1
hello goroutine:0
hello goroutine:4
hello goroutine:3
hello goroutine:2
  1. csp理论

CSP 是 Communicating Sequential Process 的简称,中文可以叫做通信顺序进程,是一种并发编程模型,是一个很强大的并发数据模型,是上个世纪七十年代提出的,用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。它不关注发送消息的实体,而关注与发送消息时使用的channel。

Golang 就是借用CSP模型的一些概念为之实现并发进行理论支持,其实从实际上出发,go语言并没完全实现CSP模型的所有理论,仅仅是借用了 process和channel这两个概念。process是在go语言上的表现就是 goroutine 是实际并发执行的实体,每个实体之间是通过channel通讯来实现数据共享。

不同方式的通信:

image.png

不要通过共享内存来通信,而要通过通信来实现内存共享。

使用Channel通信有以下优点:

  • 与消息队列类似,所属性质使它拥有异步、解耦等功能
  • 我们只需关注数据传递本身,而不用关系数据是谁在传递,简化编程
  • 可以让我们忽略各种复杂的加锁解锁、因为Channel本身是线程安全的
  1. Channel

Channel有两种类型:

  • 无缓冲通道 make(chan int)
  • 有缓冲通道 make(chan int, 2)

image.png

使用goroutine与channel实现一个小程序,A协程发送0~9的数字,B协程接收,并将数字平方,最后将数据传递到主协程中打印。

package main

import "fmt"

func main() {
   c1 := make(chan int)
   c2 := make(chan int, 3)

   go func() {
      defer close(c1)
      for i := 0; i < 10; i++ {
         c1 <- i
      }
   }()

   go func() {
      defer close(c2)
      for i := range c1 {
         c2 <- i * i
      }
   }()

   for i := range c2 {
      fmt.Printf("%v ", i)
   }

}

控制台输出:

0 1 4 9 16 25 36 49 64 81 

需要注意的是,在发送端发送完数据之后,需要调用close()函数关闭channel,否则接收端将会无限等待,造成死锁。

  1. Lock

有时候在Go代码中可能会存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)。

举个例子:

package main

import (
   "sync"
   "time"
)

var (
   x    int64
   lock sync.Mutex
)

func main() {
   // 没有使用锁
   x = 0
   for i := 0; i < 5; i++ {
      go addWithoutLock()
   }
   time.Sleep(time.Second)
   println("WithoutLock:", x)

   // 使用锁
   x = 0
   for i := 0; i < 5; i++ {
      go addWithLock()
   }
   time.Sleep(time.Second)
   println("WithLock:", x)
}

func addWithoutLock() {
   for i := 0; i < 2000; i++ {
      x += 1
   }
}

func addWithLock() {
   lock.Lock()
   defer lock.Unlock()
   for i := 0; i < 2000; i++ {
      x += 1
   }
}

控制台输出:

WithoutLock: 8513
WithLock: 10000

没有使用锁每次相加的结果都不同,出现了更新数据被覆盖的现象;而使用了锁的方法,每次运行的结果都是正确的。

  1. WaitGroup

在代码中生硬的使用time.Sleep肯定是不合适的,Go语言中可以使用sync.WaitGroup来实现并发任务的同步。 sync.WaitGroup有以下几个方法:

方法名功能
(wg *WaitGroup) Add(delta int)计数器+delta
(wg *WaitGroup) Done()计数器-1
(wg *WaitGroup) Wait()阻塞直到计数器变为0

sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了N个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。

使用sync.WaitGroup来打印字符串:

package main

import (
   "fmt"
   "sync"
)

func main() {
   var wg sync.WaitGroup
   // 初始化计数器
   wg.Add(5)
   for i := 0; i < 5; i++ {
      go func(j int) {
         // 减少计数器
         defer wg.Done()
         hello(j)
      }(i)
   }
   // 阻塞直到计数器变为0
   wg.Wait()
}

func hello(j int) {
   fmt.Printf("hello goroutine:%d\n", j)
}

依赖管理

背景

依赖指各种开发包,我们在开发项目中,需要学会站在巨人的肩膀上,也就是利用已经封装好的、经过验证的开发组件或工具来提升自己的研发效率。

对于hello world以及类似的单体函数只需要依赖原生SDK,而实际工程会相对复杂,我们不可能基于标准库从0到1编码搭建,而更多的关注业务逻辑的实现,而其他的涉及框架、日志、driver、以及collection等一系列依赖都会通过sdk的方式引入,这样对依赖包的管理就显得尤为重要

image.png

Go依赖演进过程

Go的依赖管理主要经历了3个阶段,分别是Go Path、Go Vendor、Go Module,目前被广泛应用的是Go Module。

image.png

Go Path:

GOPATH是Go语言支持的一个环境变量,value是Go项目的工作区。

目录有以下结构:

  • src:存放Go项目的源码;
  • pkg:存放Go项目编译的中间产物,加快编译速度;
  • bin:存放Go项目编译生成的二进制文件;

考虑以下场景:

image.png

弊端: src下只能存在某个包的某个版本,如果开发的不同项目依赖同一个包的不同版本,GOPATH满足不了这样的场景。

Go Vendor:

Vendor是当前项目的一个目录,其中存放了当前项目依赖的副本,在Vendor机制下,如果当前顶目存在Vendor目录,会优先使用该目录下的依赖,如果依赖不存在,会从GOPATH中寻找。Vendor机制可以实现不同项目依赖同一个包的不同版本,但vendor无法很好解决依赖包的版本变动问题和一个项目依赖同—个包的不同版本的问题,下面我们看一个场景:

image.png

项目A依赖pkg B和C,而B和C依赖了D的不同版本,通过vendor的管理模式我们不能很好的控制对于D的依赖版本,一旦更新项目,有可能带来错误。归根到底是因为vendor不能很清晰的标识依赖的版本概念。

Go Module:

Go Modules是Go语言官方推出的依赖管理系统,解决了之前依赖管理系统存在的诸如无法依赖同一个库的多个版本等问题,go module从Go 1.11开始实验性引入,Go 1.16默认开启。

我们可以通过go.mod 文件管理依赖包版本,通过go get/go mod指令工具管理依赖包。

Go Module最佳实践

依赖管理三要素如下:

  • 配置文件,描述依赖 go.mod
  • 中心仓库管理依赖库 proxy
  • 本地工具 go get/mod

go mod

go.mod文件示例如下:

//依赖管理基本单元
module example/project/app 

// 原生库
go 1.16

// 单元依赖
require (
   example/lib1 v1.0.2
   example/lib2 v1.0.0 //indirect
   example/lib3 v0.1.0-20190725025543-5a5fe074e612
   example/lib4 vo.0.0-20180306012644-bacd9c7ef1dd //indirect
   example/lib5/v3 v3.0.2
   example/lib6 v3.2.0+incompatible
)

go.mod文件主要分为三个部分:

  • 第一部分是模块路径,模块路径用来标识一个模块,从模块路径可以看出从哪里找到该模块,如果是github前缀则表示可以从Github仓库找到该模块,依赖包的源代码由github托管。如果项目的子包想被单独引用,则需要通过单独的init go.mod文件进行管理。
  • 第二部分是项目依赖的go版本。
  • 第三部分是单元依赖,每个依赖单元用模块路径+版本来唯一标识。

go mod定义了版本规则,分为语义化版本和基于commit的伪版本:

  • 语义化版本包括${MAJOR}.${MINOR}.${PATCH}三个部分,不同的MAJOR版本表示是不兼容的API。所以即使是同一个库,MAJOR版本不同也会被认为是不同的模块;MINOR版本通常是新增函数或功能,向后兼容;而PATCH版本一般是修复bug;
  • commit的版本包括vX.0.O-yyyymmddhhmmss-abcdefgh1234几个部分,基础版本前缀是和语义化版本一样的;时间戳(yyymmddhhmmss)是提交Commit的时间;最后是校验码(abcdefabcdef),包含12位的哈希前缀;每次提交commit后Go都会默认生成一个伪版本号。

go.mod依赖单元中有一些特殊标识符:

  1. indirect后缀

表示go.mod对应的当前模块,没有直接导入该依赖模块的包,也就是非直接依赖,标识间接依赖。例如A依赖B,B依赖C,那么A间接依赖C。

  1. +incompatible后缀

go mod 要求每个module从大版本2开始,模块路径必须有类似 /v2 版本号的后缀,假如 module ``example.com/mod 从 v1.0.0发展到v2.0.0,这时它的go.mod中的模块路径应该修改为example.com/mod/v2。go mod 认为如果一个module的两个不同版本之间引入路径相同,则它们必须是相互兼容的,而不同的大版本通常意味着是不兼容的,所以引入路径也不该相同,通过在模块路径上加上大版本后缀,这样就可以同时使用同一个模块的多个不同大版本。

对于 v0 和 v1 两个大版本,go mod不允许存在版本后缀,这是因为 v0 版本通常是不稳定版本,不提供兼容性保证,并且通常 v1 版本兼容最新的 v0 版本,所以从 v0 版本迭代到 v1 版本,不需要修改module 路径 。

对于一些比较老的项目可能当时go mod还没出现,但版本早已经迭代到v2 以上,或者有些项目没有遵循以上的原则,go mod为了能够正常使用它们,会在引入 v2 以上的版本后加上+incompatible以示提醒,比如 github.com/docker/docker

如果X项目依赖了A、B两个项目,且A、B分别依赖了C项目的v1.3、V1.4两个版本,最终编译时所使用的C项目的版本是哪一个?

正确答案是选择v1.4版本,需要选择最低的兼容版本。

go proxy

这里讲解一下go module的依赖分发原则,也就是从哪里下载依赖,如何下载依赖的问题。

image.png

github是比较常见的代码托管系统平台,而Go Modules系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交或版本。这样的话,对于go.mod中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。

但直接使用版本管理仓库下载依赖,存在多个问题:

  • 无法保证构建确定性:软件作者可以直接在代码平台增加/修改/删除 软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本。
  • 无法保证依赖可用性:依赖软件作者可以直接在代码平台删除软件,导致依赖不可用,大幅增加第三方代码托管平台压力。

而Go Proxy就是解决这些问题的方案。Go Proxy是一个服务站点,它会缓存源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了支持"immutability"和"available"的依赖分发;使用Go Proxy之后,构建时会直接从Go Proxy站点拉取依赖。

image.png

Go Modues通过GOPROXY环境变量控制如何使用Go Proxy; GOPROXY是一个Go Proy 站点URL列表。可以使用"direct"表示源站。

对于示例配置:

// direct表示源站
GOPROXY="https://proxy1.cn,https://proxy2.cn,direct"

整体的依赖寻址路径,会优先从proxy1下载依赖,如果proxy1不存在,然后从proxy2寻找,如果proxy2中不存在则会回源到源站直接下载依赖,最后缓存到proxy站点中。

Go get/mod

go get的使用:

image.png

go mod的使用:

image.png

单元测试

测试一般分为回归测试、集成测试、单元测试。回归测试一般是QA同学手动通过终端回归一些固定的主流程场景;集成测试是对系统功能维度做测试验证;而单元测试测试开发阶段,开发者对单独的函数、模块做功能验证,层级从上至下,测试成本逐渐减低,而测试覆盖率确逐步上升,所以单元测试的覆盖率—定程度上决定着代码的质量

image.png

单元测试

单元测试主要包括输入、测试单元、输出以及校对,单元的概念比较广,包括接口、函数、模块等;用最后的校对来保证代码的功能与我们的预期相符。

单测—方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。

image.png

Go单元测试规则:

  • 所有测试文件以_test.go结尾
  • 函数声明为func TestXxx(*testing.T)
  • 初始化逻辑放到TestMain中

查看以下例子:

package main

func helloTom() string {
   return "Jerry"
}

在goland中右键函数,选择生成,选择函数测试,将在目录下生成选中函数的测试模板:

package main

import (
   "testing"
)

func Test_helloTom(t *testing.T) {
   tests := []struct {
      name string
      want string
   }{
      {
         name: "test01",
         want: "Jerry",
      },
   }
   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         if got := helloTom(); got != tt.want {
            t.Errorf("helloTom() = %v, want %v", got, tt.want)
         }
      })
   }
}

如何衡量代码是否经过了足够的测试?如何评价项目的测试水准?如何评估项目是否达到了高水准测试等级?那就是代码覆盖率。

代码覆盖(英语:Code coverage)是软件测试中的一种度量,描述 程序 源代码 测试 比例 和程度,所得比例称为代码覆盖率。

查看以下示例:

package main

func JudgePassLine(score int16) bool {
   if score >= 60 {
      return true
   }
   return false
}

为JudgePassLine编写单元测试:

package main

import "testing"

func TestJudgePassLine(t *testing.T) {
   type args struct {
      score int16
   }
   tests := []struct {
      name string
      args args
      want bool
   }{
      {
         name: "test01",
         args: args{
            score: 10,
         },
         want: false,
      },
   }
   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         if got := JudgePassLine(tt.args.score); got != tt.want {
            t.Errorf("JudgPassLine() = %v, want %v", got, tt.want)
         }
      })
   }
}

运行go test .\main_test.go --cover命令,查看输出:

ok      demo/demo31     0.036s  coverage: 66.7% of statements

代码覆盖率为66.7%,因为我们只测试了10这个输入,一共有三行有效代码,最终只执行了两行代码,因此代码覆盖率为66.7%。

我们增加70这个输入:

{
   name: "test01",
   args: args{
      score: 10,
   },
   want: false,
},
{
   name: "test01",
   args: args{
      score: 90,
   },
   want: true,
},

再次运行go test .\main_test.go --cover命令,查看输出:

ok      demo/demo31     0.035s  coverage: 100.0% of statements

代码覆盖率为已到达100%。

在实际项目中,一般的要求如下:

  • 一般覆盖率为50%~60%,较高覆盖率80%+
  • 测试分支相互独立、全面覆盖。
  • 测试单元粒度足够小,函数单一职责

Mock测试

工程中复杂的项目,一般会依赖外部项目,而我们的单测需要保证稳定性和幂等性,稳定是指相互隔离,能在任何时间,任何环境,运行测试。幂等是指每一次测试运行都应该产生与之前一样的结果,而要实现这一目的就要用到mock机制。

image.png

举个例子,将文件中的第一行字符串中的11替换成00,执行单测,测试通过,而我们的单测需要依赖本地的文件,如果文件被修改或者删除测试就会fail。为了保证测试case的稳定性,我们对读取文件函数进行mock,屏蔽对于文件的依赖。

package main

import (
   "bufio"
   "fmt"
   "os"
   "strings"
)

func main() {
   fmt.Println(ProcessFirstLine())
}

func ReadFirstLine() string {
   open, err := os.Open("log")
   defer open.Close()
   if err != nil {
      return ""
   }
   scanner := bufio.NewScanner(open)
   for scanner.Scan() {
      return scanner.Text()
   }
   return ""
}

func ProcessFirstLine() string {
   line := ReadFirstLine()
   destLine := strings.ReplaceAll(line, "11", "00")
   return destLine
}

这里我们使用monkey,monkey是一个开源的mock测试库,可以对method,或者实例的方法进行mock、反射、指针赋值。

Mockey Patch的作用域在Runtime,在运行时通过通过Go的unsafe包,能够将内存中函数的地址替换为运行时函数的地址,将待打桩函数或方法的实现跳转到我们想要执行的函数。

package main

import (
   "bou.ke/monkey"
   "testing"
)

func TestProcessFirstLine(t *testing.T) {
   tests := []struct {
      name string
      want string
   }{
      {
         name: "test01",
         want: "00",
      },
   }
   monkey.Patch(ReadFirstLine, func() string {
      return "11"
   })
   defer monkey.Unpatch(ReadFirstLine)
   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         if got := ProcessFirstLine(); got != tt.want {
            t.Errorf("ProcessFirstLine() = %v, want %v", got, tt.want)
         }
      })
   }
}

在以上测试代码中,我们使用monkey.Patch()修改了ReadFirstLine函数的返回值,因此无论本地是什么环境,最终返回的值都是我们指定的数据。

基准测试

Go语言还提供了基准测试框架,基准测试是指测试—段程序的运行性能及耗费CPU的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。使用方法与单元测试类似。

基准测试以Benchmark为前缀,需要一个*testing.B类型的参数b,基准测试必须要执行b.N次,这样的测试才有对照性,b.N的值是系统根据实际情况去调整的,从而保证测试的稳定性。

基准测试并不会默认执行,需要增加-bench参数,所以我们通过执行go test -bench=函数名命令执行基准测试。

举一个例子,我们模拟服务器进行负载均衡,首先我们有10个服务器列表,每次随机执行select函数随机选择一个执行。

package main

import "math/rand"

var ServerIndex [10]int

func InitServerIndex() {
   for i := 0; i < 10; i++ {
      ServerIndex[i] = i + 100
   }
}

func Select() int {
   return ServerIndex[rand.Intn(10)]
}

编写测试代码:

package main

import "testing"

func BenchmarkSelect(b *testing.B) {
   for i := 0; i < b.N; i++ {
      Select()
   }
}

运行测试,查看控制台:

goos: windows
goarch: 386
pkg: demo/demo33
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkSelect
BenchmarkSelect-16      72000288                17.49 ns/op
PASS

其中BenchmarkSelect-16表示对Select函数进行基准测试,数字16表示GOMAXPROCS的值,这个对于并发基准测试很重要。10000000和203ns/op表示每次调用Select函数耗时17.49ns,这个结果是72000288次调用的平均值。

字节跳动为了解决这一随机负载的性能问题,开源了一个高性能随机数方法fastrand;我们这边再做一下基准测试,性能提升了百倍。fastrand的主要思路是牺牲了一定的数列一致性,在大多数场景是适用的。

func SelectFast() int {
   return ServerIndex[fastrand.Intn(10)]
}

我们编写基准测试来测试SelectFast的性能:

func BenchmarkSelectFast(b *testing.B) {
   for i := 0; i < b.N; i++ {
      SelectFast()
   }
}

运行测试,查看控制台:

goos: windows
goarch: 386
pkg: demo/demo33
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkSelectFast
BenchmarkSelectFast-16          226054856                5.352 ns/op
PASS

发现平均调用耗时从17.49ns下降到了5.352ns,快了不少。

项目实战

需求设计

在掘金文章页面中,功能包括话题详情、回帖列表、支持回帖、点赞、和回帖回复,我们今天就以此为需求模型,开发一个该页面涉及的服务端小功能。

实现掘金社区话题页面:

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

用户浏览消费,涉及页面的展示,包括话题内容和回帖的列表,我们需要抽出2个实体,需要具体设计实体的属性以及他们的联系。

image.png

从以上需求可以分析出我们需要两个实体:话题和帖子。话题所具有的属性一般有标题、内容、创建时间等,帖子具有的属性一般有内容、创建时间。一般我们需要用一个属性来唯一标识一个实体,因此在话题和帖子属性中加入主键,一个话题下有多个帖子,一个帖子只属于一个话题,因此话题与帖子的关系是一对多的关系。我们再在帖子中加入话题主键来标识该帖子属于哪一个话题。

以下是话题与帖子的UML图设计:

image.png

下面我们设计代码的具体架构,一般项目中我们将代码逻辑分为三层,也就是常说的三层架构。三层架构分别为repository数据层,service逻辑层,controoler视图层。

image.png

Repository数据层关联底层数据模型,也就是这里的model,封装外部数据的增删改查,我们的数据存储在本地文件,通过文件操作拉取话题,帖子数据;数据层面向逻辑层,对service层透明,屏蔽下游数据差异,也就是不管下游是文件,还是数据库,还是微服务等,对service层的接模型是不变的。

Servcie逻辑层处理核心业务逻辑,计算打包业务实体entiy,对应我们的需求,就是话题页面,包括话题和回帖列表,并上送给视图层。

Controller视图层负责处理和外部的交互逻辑,以view视图的形式返回给客户端,对于我们需求,我们封装son格式化的请求结果, api形式访问就好。

我们需要使用到的组件和工具:

我们基于gin 搭建web服务器,这里我们只是简单的使用,主要涉及路由分发,不会涉及其他复杂的概念。因为我们引入了web框架,所以就涉及go module依赖管理。通过go mod init初始化go mod管理配置文件,然后go get下载gin依赖,这里显示使用V1.3.0版本。

代码开发

项目实战:完整代码地址

router

router也就是web服务器入口,其中注册了接口与handler的映射,我们也可以在其中实现一些初始化操作,main.go文件如下:

package main

import (
   "github.com/gin-gonic/gin"
   "go-web-test/controller"
   "go-web-test/reposity"
)

func main() {
   if err := reposity.InitData("data/"); err != nil {
      panic(err)
   }
   r := gin.Default()
   r.GET("/community/page/get/:id", func(c *gin.Context) {
      data := controller.QueryPageInfo(c)
      c.JSON(200, data)
   })
   err := r.Run()
   // 监听并在 0.0.0.0:8080 上启动服务
   if err != nil {
      return
   }
}

controller

PageData.go文件:

package controller

type PageData struct {
   Code int64       `json:"code"`
   Msg  string      `json:"msg"`
   Data interface{} `json:"data"`
}

topicController.go文件:

package controller

import (
   "fmt"
   "github.com/gin-gonic/gin"
   "go-web-test/service"
)

func QueryPageInfo(c *gin.Context) *PageData {
   pageInfo, err := service.QueryPageInfo(c)
   if err != nil {
      return &PageData{
         Code: 1,
         Msg:  fmt.Sprintf("query page info error, error is %v", err),
      }
   }
   return &PageData{
      Code: 200,
      Msg:  "success",
      Data: pageInfo,
   }
}

service

pageInfo.go文件:

package service

import "go-web-test/reposity"

type PageInfo struct {
   Topic    *reposity.Topic  `json:"topic"`
   PostList []*reposity.Post `json:"post_list"`
}

queryPageInfoFlow.go文件:

package service

import (
   "fmt"
   "github.com/gin-gonic/gin"
   "go-web-test/reposity"
   "strconv"
   "sync"
)

type QueryPageInfoFlow struct {
   topicId  int64
   c        *gin.Context
   pageInfo *PageInfo
   topic    *reposity.Topic
   postList []*reposity.Post
}

func (f *QueryPageInfoFlow) Do() (info *PageInfo, err 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 {
   topicId, err := strconv.ParseInt(f.c.Param("id"), 10, 64)
   if err != nil {
      return err
   }
   f.topicId = topicId
   if f.topicId < 0 {
      return fmt.Errorf("invalid topic id")
   }
   return nil
}

func (f *QueryPageInfoFlow) prepareInfo() error {
   var wg sync.WaitGroup
   wg.Add(2)
   go func() {
      f.topic = reposity.TopicDaoInstance().QueryTopicById(f.topicId)
      wg.Done()
   }()
   go func() {
      f.postList = reposity.PostDaoInstance().QueryPostListByTopicId(f.topicId)
      fmt.Println(f.postList)
      wg.Done()
   }()
   wg.Wait()
   return nil
}

func (f *QueryPageInfoFlow) packPageInfo() error {
   f.pageInfo = &PageInfo{
      Topic:    f.topic,
      PostList: f.postList,
   }
   return nil
}

topicService.go文件:

package service

import (
   "github.com/gin-gonic/gin"
)

func QueryPageInfo(c *gin.Context) (info *PageInfo, err error) {
   queryPageInfoFlow := &QueryPageInfoFlow{
      c: c,
   }
   return queryPageInfoFlow.Do()
}

reposity

post.go文件:

package reposity

import "time"

type Post struct {
   Id         int64     `json:"id"`
   TopicId    int64     `json:"topic_id"`
   Content    string    `json:"content"`
   CreateTime time.Time `json:"create_time"`
}

postDao.go文件:

package reposity

import "sync"

type PostDao struct {
}

var (
   postDao  *PostDao
   postOnce sync.Once
)

func PostDaoInstance() *PostDao {
   postOnce.Do(
      func() {
         postDao = &PostDao{}
      })
   return postDao
}

func (*PostDao) QueryPostListByTopicId(id int64) []*Post {
   return postIndexMap[id]
}

reposity.go文件:

package reposity

import (
   "bufio"
   "encoding/json"
   "log"
   "os"
)

var (
   topicIndexMap map[int64]*Topic
   postIndexMap  map[int64][]*Post
)

func InitData(filePath string) error {
   if err := initTopicIndexMap(filePath); err != nil {
      log.Panic("init topic index map error!", err)
   }
   if err := initPostIndexMap(filePath); err != nil {
      log.Panic("init post index map error!", err)
   }
   return nil
}

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
   }
   topicIndexMap = topicTmpMap
   return nil
}

func initPostIndexMap(filePath string) error {
   open, err := os.Open(filePath + "post")
   if err != nil {
      return err
   }
   scanner := bufio.NewScanner(open)
   postTmpMap := make(map[int64][]*Post)
   for scanner.Scan() {
      text := scanner.Text()
      var postList []*Post
      if err := json.Unmarshal([]byte(text), &postList); err != nil {
         return err
      }
      postTmpMap[postList[0].TopicId] = postList
   }
   postIndexMap = postTmpMap
   return nil
}

topic.go文件:

package reposity

import "time"

type Topic struct {
   Id         int64     `json:"id"`
   Title      string    `json:"title"`
   Content    string    `json:"content"`
   CreateTime time.Time `json:"create_time"`
}

topicDao.go文件:

package reposity

import "sync"

type TopicDao struct {
}

var (
   topicDao  *TopicDao
   topicOnce sync.Once
)

func TopicDaoInstance() *TopicDao {
   topicOnce.Do(
      func() {
         topicDao = &TopicDao{}
      })
   return topicDao
}

func (*TopicDao) QueryTopicById(id int64) *Topic {
   return topicIndexMap[id]
}

测试运行

在命令行输入 curl --request GET ``http://localhost:8080/community/page/get/1命令,查看输出:

{
    "code": 200,
    "msg": "success",
    "data": {
        "topic": {
            "id": 1,
            "title": "1",
            "content": "1",
            "create_time": "2023-05-19T14:42:14.7916423+08:00"
        },
        "post_list": [
            {
                "id": 10,
                "topic_id": 1,
                "content": "1-0",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            },
            {
                "id": 11,
                "topic_id": 1,
                "content": "1-1",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            },
            {
                "id": 12,
                "topic_id": 1,
                "content": "1-2",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            },
            {
                "id": 13,
                "topic_id": 1,
                "content": "1-3",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            },
            {
                "id": 14,
                "topic_id": 1,
                "content": "1-4",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            },
            {
                "id": 15,
                "topic_id": 1,
                "content": "1-5",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            },
            {
                "id": 16,
                "topic_id": 1,
                "content": "1-6",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            },
            {
                "id": 17,
                "topic_id": 1,
                "content": "1-7",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            },
            {
                "id": 18,
                "topic_id": 1,
                "content": "1-8",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            },
            {
                "id": 19,
                "topic_id": 1,
                "content": "1-9",
                "create_time": "2023-05-19T14:31:22.9046815+08:00"
            }
        ]
    }
}

课后实践

项目实践功能扩展:

  • 支持发布帖子。
  • 本地ld生成需要保证不重复、唯一性。
  • Append文件,更新索引,注意Map的并发安全问题。

课后实践:完整代码地址

router

在router中增加一个接口:

r.POST("/community/page/post/", func(c *gin.Context) {
   data := controller.PostPage(c)
   c.JSON(200, data)
})

controller

在controller中增加一个PostPage方法:

func PostPage(c *gin.Context) *PageData {
   if err := service.PostPage(c); err != nil {
      return &PageData{
         Code: 1,
         Msg:  fmt.Sprintf("post page error, error is %v", err),
      }
   }
   return &PageData{
      Code: 200,
      Msg:  "success",
   }
}

service

在service包中的topicService中增加PostPage方法:

func PostPage(c *gin.Context) error {
   postPageFlow := &PostPageFlow{
      c: c,
   }
   return postPageFlow.Do()
}

在service包下增加postPageFlow.go文件,抽象逻辑处理流程:

package service

import (
   "encoding/json"
   "github.com/gin-gonic/gin"
   "go-web-test/reposity"
)

type PostPageFlow struct {
   c     *gin.Context
   topic reposity.Topic
}

func (f *PostPageFlow) Do() error {
   if err := f.check(); err != nil {
      return err
   }
   if err := f.prepare(); err != nil {
      return err
   }
   if err := f.pack(); err != nil {
      return err
   }
   return nil
}

func (f *PostPageFlow) check() error {
   return nil
}

func (f *PostPageFlow) prepare() error {
   raw, err := f.c.GetRawData()
   if err != nil {
      return err
   }
   var topic reposity.Topic
   if err := json.Unmarshal(raw, &topic); err != nil {
      return err
   }
   f.topic = topic
   return nil
}

func (f *PostPageFlow) pack() error {
   return reposity.TopicDaoInstance().AppendTopicList([]reposity.Topic{
      f.topic,
   })
}

reposity

在reposity包下的reposity.go文件修改初始化数据代码,获取nextTopicId以及nextPostId:

package reposity

import (
   "bufio"
   "encoding/json"
   "fmt"
   "log"
   "math"
   "os"
)

var (
   nextTopicId   int64
   topicIndexMap map[int64]*Topic
   nextPostId    int64
   postIndexMap  map[int64][]*Post
)

func InitData(filePath string) error {
   if err := initTopicIndexMap(filePath); err != nil {
      log.Panic("init topic index map error!", err)
   }
   if err := initPostIndexMap(filePath); err != nil {
      log.Panic("init post index map error!", err)
   }
   return nil
}

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)
   var nextTmpTopicId int64
   for scanner.Scan() {
      text := scanner.Text()
      var topic Topic
      if err := json.Unmarshal([]byte(text), &topic); err != nil {
         return err
      }
      topicTmpMap[topic.Id] = &topic
      nextTmpTopicId = int64(math.Max(float64(nextTmpTopicId), float64(topic.Id)))
   }
   topicIndexMap = topicTmpMap
   nextTopicId = nextTmpTopicId + 1
   s, _ := json.Marshal(topicIndexMap)
   fmt.Println(string(s))
   return nil
}

func initPostIndexMap(filePath string) error {
   open, err := os.Open(filePath + "post")
   if err != nil {
      return err
   }
   scanner := bufio.NewScanner(open)
   var nextTmpPostId int64
   postTmpMap := make(map[int64][]*Post)
   for scanner.Scan() {
      text := scanner.Text()
      var postList []*Post
      if err := json.Unmarshal([]byte(text), &postList); err != nil {
         return err
      }
      postTmpMap[postList[0].TopicId] = postList
      for _, post := range postList {
         nextTmpPostId = int64(math.Max(float64(nextTmpPostId), float64(post.Id)))
      }
   }
   postIndexMap = postTmpMap
   nextPostId = nextTmpPostId + 1
   s, _ := json.Marshal(topicIndexMap)
   fmt.Println(string(s))
   return nil
}

在topicDao中增加AppendTopicList方法修改数据,同时对查询和修改方法加读写锁,保证线程安全:

package reposity

import (
   "bufio"
   "encoding/json"
   "log"
   "os"
   "strings"
   "sync"
   "time"
)

var (
   lock sync.RWMutex
)

type TopicDao struct {
}

var (
   topicDao  *TopicDao
   topicOnce sync.Once
)

func TopicDaoInstance() *TopicDao {
   topicOnce.Do(
      func() {
         topicDao = &TopicDao{}
      })
   return topicDao
}

func (*TopicDao) QueryTopicById(id int64) *Topic {
   lock.RLock()
   defer lock.RUnlock()
   return topicIndexMap[id]
}

func (*TopicDao) AppendTopicList(topicList []Topic) error {
   lock.Lock()
   defer lock.Unlock()
   topicTmpList := make([]string, 0, len(topicList))
   for i := range topicList {
      topicList[i].Id = nextTopicId
      nextTopicId++
      topicList[i].CreateTime = time.Now()
      s, err := json.Marshal(topicList[i])
      if err == nil {
         topicTmpList = append(topicTmpList, string(s))
      }
   }
   str := strings.Join(topicTmpList, "\n")
   if len(topicTmpList) == 1 {
      str += "\n"
   }
   // 写文件
   if err := appendStringToTopic("data/", str); err != nil {
      return err
   }
   // 写内存
   for i := range topicList {
      topicIndexMap[topicList[i].Id] = &topicList[i]
   }
   return nil
}

func appendStringToTopic(filePath string, str string) error {
   topicOpen, err := os.OpenFile(filePath+"topic", os.O_APPEND, 0)
   defer topicOpen.Close()
   if err != nil {
      log.Panic(err)
   }
   topicWriter := bufio.NewWriter(topicOpen)
   defer topicWriter.Flush()
   if _, err := topicWriter.WriteString(str); err != nil {
      return err
   }
   return nil
}

代码测试

这里我们使用postman进行测试,首先添加一个topic:

我们查看topic文件,新添加的数据已经写入文件:

我们再根据此id查询topic,同样也能查询到数据,说明内存也已经修改成功: