一. 测试
测试关乎着系统的质量。
测试是避免事故的最后一道屏障。
测试大致分为3种类型,回归测试,集成测试,单元测试。从前到后,覆盖率依次变大,成本依次减少。
1. 单元测试
单元测试主要
包括,输入,测试单元,输出,以及校对,单元的概念比较广,包括接口,函数,模块等;用最后的校对来保证代码的功能与我们的预期相符;单侧一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。
1)规则
下面是单侧的一些基本规范:
所有测试文件以_test.go结尾这样从文件上就很好了区分源码和测试代码,
测试函数命名 func TestXxx(t *testing.T)以Test开头,且连接的第一个字母大写,
初始逻辑放在TestMain中 func TestMain(m *testing.M),里边主要做:测试前数据装载,配置初始化等前置工作,测试后:释放资源等收尾工作.
package tts
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
//测试前数据装载,配置初始化
code := m.Run()
//测试后:释放资源等收尾工作.
os.Exit(code)
}
2)例子
//c.go
package testc
func HelloTom() string {
return "jerry"
}
//c_test.go
package testc
import "testing"
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
if output != expectOutput {
t.Errorf("Expected %v do not match actual %v", expectOutput, output)
}
}
输出结果
3)assert
assert是开源有equal和noequal比较。
//c_test.go
package testc
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}
输出结果
不通过
通过
4)覆盖率
我们可以通过覆盖率来判断代码的测试水准,评估项目是否到了高水准,代码是否经过了足够的测试。
下面是测试覆盖率的执行代码。
go test src\testc\c_test.go src\testc\c.go --cover
5)tips
在实际项目中,一般的要求是50%~60%覆盖率,而对于资金型服务,覆盖率可能要求达到80%;我们做单元测试,测试分支相互独立,全面覆盖,则要求函数体足够小,这样就比较简单的提升覆盖率,也符合函数设计的单一职责。
6)依赖
工程中复杂的项目,一般会依赖 Cache和file和DB,而我们的单测需要保证稳定性和幂等性,稳定是指相互隔离,能在任何时间,任何环境,运行测试。 幂等是指每一次测试运行都应该产生与之前一样的结果。而要实现这一目的就要用到mock机制。
7)文件测试
下面举个例子,将文件中的第一行字符串中的11替换成00,执行单测,测试通过,而我们的单测需要依赖本地的文件,如果文件被修改或者删除测试就会fail。为了保证测试的稳定性,我们对读取文件函数进行mock,屏蔽对于文件的依赖。
package wenjian
import (
"bufio"
"os"
"strings"
)
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 Pro() string {
line := ReadFirstLine()
destLine := strings.ReplaceAll(line, "11", "00")
return destLine
}
//test
package wenjian
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestPro(t *testing.T) {
firstLine := Pro()
assert.Equal(t, "line00", firstLine)
}
8)Mock
mock可以理解为用一个a去替换b,b为原函数,a是打桩函数
这里我们用了Monkey,monkey是一个开源的mock测试库,可以对method,或者实例的方法进行mock,反射,指针赋值
Mockey Patch 的作用域在 Runtime,在运行时通过通过 Go 的 unsafe 包,能够将内存中函数的地址替换为运行时函数的地址,将待打桩函数或方法的实现跳转到。
package wenjian
import (
"bou.ke/monkey"
"github.com/stretchr/testify/assert"
"testing"
)
func TestPro(t *testing.T) {
monkey.Patch(ReadFirstLine, func() string {
return "line110"
}) //mock所用
defer monkey.Unpatch(ReadFirstLine) //mock所用
firstLine := Pro()
println(firstLine)
assert.Equal(t, "line000", firstLine)
}
2. 基准测试
Go 语言还提供了基准测试框架,基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。使用方法类似于单元测试,
1)例子
这里举一个服务器负载均衡的例子,首先我们有10个服务器列表,每次随机执行select函数随机选择一个执行。
//随机选择服务器执行
package jzcs
import "math/rand"
var ServerIndex [10]int
func InutServerIndex() {
for i := 0; i < 0; i++ {
ServerIndex[i] = i + 100
}
}
func Select() int {
return ServerIndex[rand.Intn(10)] //rand持有了一把全局锁
}
基准测试以Benchmark开头,入参是testing.B, 用b中的N值反复递增循环测试 (对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么 testing.B 中的 N 值将按 1、2、5、10、20、50…递增,并以递增后的值重新进行用例函数测试。) Resttimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围;runparallel是多协程并发测试;执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。
package jzcs
import "testing"
func BenchmarkSelect(b *testing.B) {
InutServerIndex() //准备时间除去
b.ResetTimer()
for i := 0; i < b.N; i++ {
Select()
}
} //串行压力测试
func BenchmarkSelectParallal(b *testing.B) {
InutServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Select()
}
}) //并行压力测试
}
2)优化
为了解决这一随机性能问题,我们可以用一个开源的高性能随机数方法fastrand。
github.com/bytedance/g…
package jzcs
import (
"github.com/bytedance/gopkg/lang/fastrand"
)
var ServerIndex [10]int
func InutServerIndex() {
for i := 0; i < 0; i++ {
ServerIndex[i] = i + 100
}
}
func Select() int {
return ServerIndex[fastrand.Intn(10)]
}
二. 实战项目
都看到过这个掘金的社区话题入口页面,页面的功能包括话题详情,回帖列表,支持回帖,点赞,和回帖回复,我们今天就以此为需求模型,开发一个该页面交涉及的服务端小功能。
1. 需求设计
1)需求描写
展示话题 (标题,文字描述) 和回帖列表
暂不考虑前端页面实现,仅仅实现一个本地web服务
话题和回帖数据用文件存储
2)需求用例
用户浏览消费,涉及页面的展示,包括话题内容和回帖的列表
3)ER图
Er图是用来描述现实世界的概念模型。 有了模型实体,属性以及之间的联系,对我们后续做开发就提供了比较清晰的思路。
4)分层结构
整体分为三层,repository数据层,service逻辑层,controoler视图层,
数据层关联底层数据模型,也就是这里的model,封装外部数据的增删改查,我们的数据存储在本地文件,通过文件操作拉取话题,帖子数据;数据层面向逻辑层,对service层透明,屏蔽下游数据差异,也就是不管下游是文件,还是数据库,还是微服务等,对service层的接口模型是不变的。
Servcie逻辑层处理核心业务逻辑,计算打包业务实体entiy,对应我们的需求,就是话题页面,包括话题和回帖列表,并上送给视图层;
Controller视图层负责处理和外部的交互逻辑,以view视图的形式返回给客户端,对于我们需求,我们封装json格式化的请求结果,api形式访问就好,
2. 代码开发
1)组件工具
go web github.com/gin-gonic/g…
首先是gin,高性能开源的go web框架,我们基于gin 搭建web服务器。
因为引入了web框架,所以就涉及go module依赖管理,我们首先通过go mod是初始化go mod管理配置文件,然后go get下载gin依赖。
go mod init 初始化go.mod文件
2)Repository
我们可以根据之前的er图先定义struct QueryTopicByld,QueryPostsByParentld文件中每行的格式如图所示。
QueryTopicByld
QueryPostsByParentld
3)Repository - index
一方面查询我们可以用全扫描遍历的方式,但是这虽然能达到我们的目的,但是并非高效的方式,所以这里引出索引的概念,索引就像书的目录,可以引导我们快速查找定位我们需要的结果;这里我们用map实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现0(1)的时间复杂度查找操作。
下面是具体的实现,我们过一下,首先是打开文件,基于file 初始化scanner,通过迭代器方式遍历数据行,转化为结构体存储至内存map,
package repository
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
var (
topicIndexMap map[int64]*Topic
postIndexMap map[int64][]*Post
)
func InitTopicIndexMap(filePath string) error {
open, err := os.Open(filePath + "topic.json")
if err != nil {
return err
}
scanner := bufio.NewScanner(open)
topicTmpMap := make(map[int64]*Topic)
for scanner.Scan() {
text := scanner.Text()
var topic Topic
err := json.Unmarshal([]byte(text), &topic)
if err != nil {
return err
}
topicTmpMap[topic.Id] = &topic
}
topicIndexMap = topicTmpMap
fmt.Printf("%#v", topicIndexMap)
return nil
}
func InitPostIndexMap(filePath string) error {
open, err := os.Open(filePath + "post.json")
if err != nil {
return err
}
scanner := bufio.NewScanner(open)
PostTmpMap := make(map[int64][]*Post)
for scanner.Scan() {
text := scanner.Text()
var post Post
err := json.Unmarshal([]byte(text), &post)
if err != nil {
return err
}
PostTmpMap[post.Id][post.ParentId] = &post
}
postIndexMap = PostTmpMap
return nil
}
4)Repository - 查询
有了内存索引,下一步就是实现查询操作就比较简单了,直接根据查询key获得map中的value就好了,这里用到了sync.once,主要适用高并发的场景下只执行一次的场景。
package repository
import "sync"
type Topic struct {
Id int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreateTime string `json:"create_time"`
}
type Post struct {
Id int64 `json:"id"`
ParentId int64 `json:"parent_id"`
Content string `json:"content"`
CreateTime string `json:"create_time"`
}
type TopicDao struct {
}
type PostDao struct {
}
var (
topicDao *TopicDao
postDao *PostDao
topicOnce sync.Once
postOnce sync.Once
)
func NewTopicDaoInstance() *TopicDao {
topicOnce.Do(func() {
topicDao = &TopicDao{}
})
return topicDao
}
func NewPostDaoInstance() *PostDao {
postOnce.Do(
func() {
postDao = &PostDao{}
})
return postDao
}
func (*TopicDao) QueryTopicById(id int64) *Topic {
return topicIndexMap[id]
}
5)Service
有了reposity层以后,下面我们开始实现service层,首先我们定义servcie层实体,包括postlist和topic
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
下面是具体的servcie流程编排,通过err控制流程退出,正常会返回页面信息,err为nil
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
}
关于prepareInfo方法,话题和回帖信息的获取都依赖topicid,这样2者就可以并行执行,提高执行效率。
func (f *QueryPageInfoFlow) prepareInfo() error {
//获取topic信息
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
}()
//获取post列表
go func() {
defer wg.Done()
posts, err := repository.NewPostDaoInstance().QueryPostByParentId(f.topicId)
if err != nil {
postErr = err
return
}
f.posts = posts
}()
wg.Wait()
return nil
}
Service实现完成,下面就是controller层。这里我们定义一个view对象,通过code msg打包业务状态信息,用data承载业务实体信息.
type PageData struct {
Code int64 `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
func PublishPost(uidStr, topicIdStr, content string) *PageData {
//参数转换
uid, _ := strconv.ParseInt(uidStr, 10, 64)
topic, _ := strconv.ParseInt(topicIdStr, 10, 64)
//获取service层结果
postId, err := service.PublishPost(topic, uid, content)
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
return &PageData{
Code: 0,
Msg: "success",
Data: map[string]int64{
"post_id": postId,
},
}
}
最后是web服务的引擎配置,包括初始化数据索引, 初始化引擎配置, 构建路由, 启动服务,path映射到具体的controller。通过path变量传递话题id
func main() {
if err := Init(); err != nil {
os.Exit(-1)
}
r := gin.Default()
r.GET("/community/page/get/:id", func(c *gin.Context) {
topicId := c.Param("id")
data := handler.QueryPageInfo(topicId)
c.JSON(200, data)
})
err := r.Run()
if err != nil{
return
}