字节跳动青训营----day2(2)

149 阅读7分钟

课程目录

  1. 语言进阶(并发编程)

  2. 依赖管理(go module)

3. 测试(单元测试)

4. 项目实战

单元测试

测试是避免事故的最后一道屏障

测试分类

image.png

回归测试:一般回到终端层面进行软件测试

集成测试:对系统功能进行测试

单元测试:开发者对单独函数模块进行的测试

组成部分

image.png

  • 输入
  • 测试单元(比较宽泛)
  • 输出
  • 期望值

规则

  • 所有测试文件以 _test.go 结尾
  • func TestXxx(t *testing.T) 函数签名规范
  • 初始化逻辑放到 TestMain 中

代码实例

func HelloTom() string {
    return "Jerry"
}

func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    
    if output != expectOutput {
        t.Errorf("Expected %s do not match actual %s", expectOutput, output)
    }
}

assert

import (
    "github.com/stretchr/tesetify/assert"
    "testing"
)

func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    assert.Equal(t, expectOutput, output)
}

func HelloTom() string {
    return "Tom"
}

这里调用第三方包中的 assert 函数来判断,期望值和实际输出值是否相同,solidity 语言中 assert(a > b, "error message") 和这个其实差不多,满足 a > b 条件就正常执行,否则就输出自定义的错误消息。

代码覆盖率

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

func TestJudgePassLineTrue(t *testing.T) {
    isPass := JudgePassLine(70)
    assert.Equal(t, true, isPass)
}

测试 JudgePassLine 的代码覆盖率 (return false 是没有被覆盖的)

go test judgment_test.go judgement.go --cover

如何提升代码覆盖率

func TestJudgePassLineTrue(t *testing.T) {
    isPass := JudgePassLineTrue(70)
    assert.Equal(t, true, isPass)
}

func TestJudgePassLineFalse(t *testing.T) {
    isPass := JudgePassLineFalse(50)
    assert.Equal(t, false, isPass)
}

image.png

依赖

测试过程中强依赖于 File、DB 或者 Cache 数据。单元测试应满足下面的特性:

幂等性:重复运行一个测试的 Case 时,每次的结果都是相同的

稳定性:单元测试是相互隔离的,可以在任何时间、任何函数独立运行

// firstline.go
package main

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

func ReadFirstLine() string {
	open, err := os.Open("log")

	if err != nil {
		return ""
	}

	defer open.Close()

	scanner := bufio.NewScanner(open)

	for scanner.Scan() {
		// 返回scanner 读取到第一行数据
		return scanner.Text()
	}

	return ""
}

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

	return destLine
}

// firstline_test.go
func TestFirstLine(t *testing.T) {
	firstLine := ProcessFirstLine()

	assert.Equal(t, "line00", firstLine)
}

Mock 机制

如果上述的 log 文件被人篡改或者删除,那么单元测试就会失败,为了单元测试的稳定性,引入了 Mock 机制。

monkey: github.com/bouk/monkey

快速 Mock 函数

  • 为一个函数打桩

常用函数 Patch & Unpatch

// Patch replaces a function with another

/* 
type PatchGuard struct {
    target interface{} // 原函数
    replacement interface{} // 打桩函数
}
*/

// 为函数打桩
func Patch(target, replacement interface{}) *PatchGuard {
    t := reflect.ValueOf(target)
    r := reflect.ValueOf(replacement)
    
    patchValue(t, r)
    
    return &PatchGuard{t, r} 
}

// 测试结束后,卸载桩
// Unpatch removes any monkey patches on target 
// returns whether target was patched in the first place
func Unpatch(target interface{}) bool {
    return unpatchValue(reflect.ValueOf(target))
}

代码实例: 对 ReadFirstLine 函数进行打桩测试,不再依赖本地文件

func TestProcessFirstLineWithMock(t *testing.T) {
 /* Patch(target, replacement) 
    target = ReadFirstLine (函数签名为 func() string)
    因此构造的匿名函数与 ReadFirstLine 相同,这个匿名函数返回 line110
    ReadFirstLine 成功执行返回的也是 line110,就相当于使用构造的匿名函数替代了
    原函数(ReadFirstLine),这样的话,即使 log 文件被篡改或者被删除,也不会影响单元测试的结果 */
    monkey.Patch(ReadFirstLine, func() string {
        return  "line110"
    })
    
    defer monkey.Unpatch(ReadFirstLine)
    line := ProcessFirstLine()
    assert.Equal(t, "line000", line)
}
  • 为一个方法打桩

打桩:使用 B 函数替换 A 函数,A 就是原函数,B 就是打桩函数

基准测试

bench.go

package main

import (
	"math/rand"
	"time"
)

var ServerIndex [10]int

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

// 随机选择执行服务器
func Select() int {
        // 加上随机数种子
	rand.Seed(time.Now().UnixNano())
	return ServerIndex[rand.Intn(10)]
}

benckmark_test.go


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

func BenchmarkSelectParallel(b *testing.B) {
    InitServerIndex()
    b.ResetTimer()
    
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            Select()
        }
    })
}

go test -bench=.

加随机种子

image.png

不加随机种子

image.png

引入 fastrand

package main

import "github.com/bytedance/gopkg/lang/fastrand"

var ServerIndex [10]int

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

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

image.png

为什么并发的测试速度还要慢一点?

由于需要保证多线程的并发安全,所以需要使用并发安全锁,那么 goroutine 间就存在锁的竞争,没抢到锁的 goroutine 只能等待锁的释放,这就浪费了一些时间。

项目实战

需求描述

社区话题页面

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

需求用例

image.png

可抽象出两个实体(Topic、Post)

ER 图-Entity Relationship Diagram

image.png

话题和帖子属于一对多的关系

分层结构

image.png

  • 数据层:数据 Model,外部数据的增删改查
  • 逻辑层:业务 Entity,处理核心业务逻辑输出
  • 视图层:视图 view,处理和外部的交互逻辑

分层可实现不同领域分工,提高代码可读性

组件工具

  • Gin 高性能 go web 框架

  • Go Mod

    go mod init

    go get gopkg.in/gin-gonic/gin.v1@v1.3.0

Repository

Topic

{
    "id": 1,
    "title": "青训营",
    "content": "快到碗里来~",
    "create_time": 1650437625
}

QueryTopicById

Post

{
    "id": 1, 
    "parent_id": 1,
    "content": "快来!",
    "create_time": 1650437616
}

QueryPostsByParentId

Repository-index

全扫描遍历的方式进行查询:一个个对比,看是否和我们想要的相符

这当然也能实现查询功能,但是效率太低,因此引入了索引(类似书的目录)

将数据行映射成内存的 Map,来实现数据索引功能,通过索引就可以很快定位到内存中的数据。

image.png


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

type Post struct {
    Id int64 `json: "id"`
    ParentId int64 `json: "parent_id"`
    Content string  `json: "content"`
    CreateTime int64  `json: "create_time"`
}

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

代码:初始化话题数据索引

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
        }
        // 将 topic 地址存储到 Topic_Id 对应的 Map 值中
        // 以后需要查询这个 topic 时,只需要查询键 id 对应的值即可
        topicIndexMap[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 scannner.Scan() {
        text := scannner.Text()
        
        var post Post
        if err := json.Unmarshal([]byte(text), &post); err != nil {
            return err
        }
        
        postTmpMap[post.ParentId] = &post
    }
    
    postIndexMap = postTmpMap
    return nil
}

Repository 查询

QueryTopicById

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

type TopicDao struct {
}

var (
    topicDao *TopicDao
    topicOnce sync.Once
)
// 仅实例化一次
func NewTopicDaoInstance() *TopicDao {
    topicOnce.Do(
        func() {
            topicDao = &TopicDao{}
        })
    return topicDao
}

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

QueryPostByParentId

type Post struct {
    Id int64 `json:"id"`
    ParentId int64 `json:"parent_id"`
    Content string `json:"content"`
    CreateTime int64 `json:"create_time"`
}

type PostDao struct {}

var (
    postDao *PostDao
    postOnce sync.Once
)

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

func (*PostDao) QueryPostByParentId(parentId int64) *Post {
    return postIndexMap[parentId]
}

Service

实体

// 页面信息,包含两个实体(话题、帖子列表)
type PageInfo struct {
    Topic *repository.Topic
    PostList []*repository.Post
}

流程

参数校验 -> 准备数据 -> 组装实体

  • 参数校验:对传入的 topic_id 做合法性校验
  • 准备数据:从 Repository 层拿数据
  • 组装实体:根据读取到的数据组装(填充)实体内容

代码流程编排

func (f *QueryPageInfoFlow) Do() (*PageInfo, error) {
    // 参数校验
    if err := f.checkParam(); err != nil {
        return nil, err
    }
    // 通过 repository 层获取 topic 和 postList 数据
    if err := f.prepareInfo(); err != nil {
        return nil, err
    }
    
    if err := f.packPageInfo(); err != nil {
        return nil, err 
    }
    
    return f.pageInfo, nil
}

// topic 信息和 postList 信息同时依赖于 topicid,且相互隔离,因此可以使用并行处理
func (f *QueryPageInfoFlow) prepareInfo() error {

    var wg sync.WaitGroup
    // 开启两个 goroutine,分别获取 topic 和 post 数据
    wg.Add(2)
        
    // 获取 topic 信息
    go func() {}()
    // 获取 post 信息
    go func() {}()
    
    wg.Wait() // 等待子 goroutine
    return nil
}

Controller 层

  • 构建 View 对象
  • 业务错误码
type PageData struct {
    Code int64 `json:"code"`
    Msg string `json:"msg"`
    Data interface{} `json:"data"`
}

func QueryPageInfo(topicIdStr string) *PageData {
    topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
    
    if err != nil {
        return &PageData{} // 非 0 状态码
    }
    
    pageInfo, err := service.QueryPageInfo(topicId)
    if err != nil {
        return &PageData{Data: pageInfo} // 成功状态码
    }
    
    return &PageData{} //未查询到的状态码
}

Router

  • 初始化数据索引
  • 初始化引擎配置
  • 构建路由
  • 启动服务
func main() {
    //初始化数据索引
    if err := Init("./data/"); err != nil {
        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)
    })
    
    // 启动服务
    err := r.Run()
    
    if err != nil {
        return
    }
}

运行测试

  1. go run server.go

  2. curl --location --request GET 'http://0.0.0.0:8080/community/page/get/2' | json

总结

  • 项目拆解
  • 代码设计
  • 测试运行

课后实践

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