0
点赞
收藏
分享

微信扫一扫

WEB后端复习——监听器、过滤器

1.语言进阶&依赖管理

1.1 语言进阶

1.1.1 并发&并行

在单核CPU下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,它将CPU的时间片(window下最小约为15毫秒)分给不同的程序使用,只是由于CPU在线程间的切换非常快,人类一般是感觉不到的,所以会觉得他们是同时运行的。一句话说就是微观串行,宏观并行

一般将这种线程轮流使用CPU的做法称为并发,concurrent,多核 cpu下,每个 核(core) 都可以调度运行线程,这时候线程可以是并行的。

这里引用Golang语言创造者的Rob Pike的一句描述:

提到高并发,Go也是一大利器。

1.1.2 线程&协程
image-20230116210841153

线程

一个进程之内可以分为一到多个线程,一个线程就是一个指令流,指令流的一条条指令按照一定的顺序加载给CPU执行,比如在Java中,线程作为最小的调度单位,进程作为资源分配的最小单位。

协程

协程不是系统线程,很多时候被称为轻量级线程、微线程、纤程等,简单来说可以认为协程是线程里面不同的函数,这些函数之间可以相互快速切换,协程和用户线态线程非常接近,用户态线程之间的切换不需要陷入内核,但这不是绝对的,部分系统中的切换也是需要内核态线程的辅助的。协程是编程语言提供的特性(之间的切换方式与过程可以由程序员确定),属于用户态操作。

小结


在Go语言中开启协程只需要在函数调用之前加上go关键字即可。比如下面这段代码,通过协程的方式,打印一段输出。

package main

import (
	"fmt"
	"time"
)
func main() {
	HelloGoRoutine()
}
func hello(i int) {
	println("hello goroutine:" + fmt.Sprint(i))
}

func HelloGoRoutine() {
	for i := 0; i < 5; i++ {
        //开启协程
		go func(j int) {
			hello(j)
		}(i)
	}
	time.Sleep(time.Second)
}

Go的CSP并发模型

与其他编程语言不同,Go语言除了支持传统语言的 多线程共享内存并发模型之外,还有自己特有的 **CSP(communicating sequential processes)**并发模型,一般情况下,这也是Go语言推荐使用的。

与传统的 多线程通过内存来通信 相比,CSP讲究的是 以通信的方式来共享内存

image-20230116215148018

普通的线程并发模型,他们的线程通信一般是通过共享内存的方式来进行的,非常典型的方式就是,在访问共享数据(数组、Map等)的时候是通过锁来访问,因此很多时候会衍生出一种叫做 线程安全的数据结构的东西。

而Go的CSP并发模型则是通过goroutinechannel来实现的。

  • goroutine 是Go语言中并发的执行单位。可以理解为用户空间的线程。
  • channel是Go语言中不同goroutine之间的通信机制,即各个goroutine之间通信的”管道“,有点类似于Linux中的管道。

Channel

channel类似与一个队列,满足先进先出的规则,严格保证收发数据的顺序,每一个通道只能通 过固定类型的数据如果通道进行大型结构体、字符串的传输,可以将对应的指针传进去,尽量的节省空间。

image-20230116221510849

在Go中,可以通过make函数创建通道(channel,后文都简称通道)。

基本操作

  • 操作符<-

取出数据使用操作符 <-操作符右边是输入变量,操作符左边是通道代表数据流入通道内.

// 声明一个通道
var a chan int
a <- 5

下面通过一段简单的生产-消费模式的代码示例,熟悉channel的基本使用。

package main

func main() {
	CalSquare()
}

func CalSquare() {
	//无缓冲channel
	src := make(chan int)
	//有缓冲channel
	dest := make(chan int, 3)
	//协程A生成0-9数字
	go func() {
		defer close(src)
		for i := 0; i < 10; i++ {
			src <- i
		}
	}()
	//协程B计算输入数字的平方
	go func() {
		defer close(dest)
		for i := range src {
			dest <- i * i
		}
	}()
	//主线程打印输出最终结果
	for i := range dest {
		//TODO
		println(i)
	}
}

并发安全Lock—传统并发模式

考虑下面这个场景:

package main

import (
	"sync"
	"time"
)

var (
	x    int64
	lock sync.Mutex
)

func main() {
	Add()
}

// 加锁的自增方法
func addWithLock() {
	for i := 0; i < 2000; i++ {
		lock.Lock()
		x += 1
		lock.Unlock()
	}
}

// 不加锁的自增方法
func addWithoutLock() {
	for i := 0; i < 2000; i++ {
		x += 1
	}
}

func Add() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second)
	println("没加锁:", x)
	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	println("加锁的:", x)
}

上面的程序中,同样都是启用5个协程并发对x自增2000次,一个使用了lock一个没有,在并发情况下,没有加锁的操作可能会引起数据的错误,比如未加锁情况下最终的是8337,而加锁的情况下确实正确结果10000,可见加锁在一定程度上可以防止数据错误,保证了原子性。


WaitGroup

在Go语言中除了使用通道(channel)和互斥锁(lock)进行并发同步之外,还可以使用等待组WaitGroup来完成多个任务的同步,与前面提到的锁不同,等待组可以保证在并发环境中完成指定数量的任务。

image-20230117095515900

WaitGroup类型中,每个sync.WaitGroup内部维护了一个计数器,初始默认值为0,详情见上图所示。计数器计数逻辑如下:

修改之前 使用协程打印输出的代码:

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	//HelloGoRoutine()
	ManyGWaitGroup()
}
func hello(i int) {
	println("hello goroutine:" + fmt.Sprint(i))
}

func ManyGWaitGroup() {
	var wg sync.WaitGroup
    //增加5个等待组数量
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
            //当一个goroutine完成之后,减少一个等待组
			defer wg.Done()
			hello(j)
		}(i)
	}
    //直到所有的操作都完成
	wg.Wait()
}
  • func(*WaitGroup) Add(delta int)
  • func(*WaitGroup) Done
  • func(*WaitGroup) Wait

1.2 依赖管理

1.2.1 GOPATH

目前为止,Go的依赖管理主要经历了三个阶段:

整个路线主要围绕着实现下面两个目标来迭代发展的:

  • 不同环境(项目)依赖版本不同
  • 控制依赖库的版本
image-20230117104308978

弊端

image-20230117105056546
  • 没有版本控制的概念
  • 所有的项目需要存放在$GOPATH/src目录下,否则就不能编译。

1.2.2 Go Vender
image-20230117105437601
  1. 项目目录西卡增加了vender文件,所有的依赖包副本形式存放在ProjectRoot/vender下。
  2. 依赖的寻址方式:vender->GOPATH

不足

image-20230117110650847
  1. 无法控制依赖版本
  2. 更新项目又可能出现依赖冲突,导致编译出错

1.2.3 GoModule

核心三要素

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

go.mod

启用了 Go modules 的项目,初始化项目时,会生成一个 go.mod 文件。描述了当前项目(也就是当前模块)的元信息。

image-20230117111736841

image-20230117112409253

image-20230117112419299

版本管理

image-20230117112957421

indirect

image-20230117114422690

依赖图

依赖分发&回源

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

但是直接使用版本管理仓库下载依赖,会存在一些问题,比如无法保证构建确定性,软件作者可以直接在代码平台对软件的版本进行增删改查,导致下次构建使用的是另外一个版本依赖,或者找不到依赖版本,无法保证依赖的可用性,依赖软件作者可以直接在平台删除软件,导致依赖不可用,大幅度增加第三方代码托管平台的压力。 基于此,可以使用下面的Proxy的方式解决这些问题。

Proxy

变量GOPROXY

go module通过goproxy环境变量控制如何使用go proxy;goproxy是一个goproxy站点的URL列表,可以使用direct表示源站。对于示例配置,整体的依赖寻找路径,会优先从proxy下载依赖,如果proxy1不存在,会从proxy2继续寻找,如果proxy2z中不存在则会返回到源站直接下载依赖,缓存到proxy站点中。

go get

官方文档

go mod


2.测试&项目实战

2.1 测试

  • 单元测试
  • Mock测试
  • 基准测试

什么?你写代码不用测试?不要钱了是吧!

所以,测试就成了避免事故的最后一道屏障

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

单元测试主要包括输入、测试单元。输出以及校对,单元测试的概念比较广,包括了接口、函数、模块等;用最后的校对来保证代码的功能与我们的预期相符;一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,也不会破坏原有代码的正确性。另一方面可以提升效率,在代码有bug的情况下,通过编写单元测试,可以在一个较短的周期内定位和修复问题。

单元测试规则

下面是单元测试的一些基本规范,这样从文件上就很好的区分源代码和测试代码,以Test开头,且理解的第一个字母大写。

package test

import "testing"

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

前面测试直接使用的是比较运算符,除此之外,还有很多现有的aeert包可以帮助我们实现测试中的比较操作。

package test

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

func HelloTom() string {
	return "Tom"
}

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

单测-覆盖率

package test

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

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

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

这是一个判断成绩是否合格的程序,返回bool,输入分数为80,执行测试之后发现只有%66.7左右的覆盖率。因为用例为80的时候,只是跑了程序的前面两行,也就是分数大于60的逻辑,而剩下的返回false部分的逻辑并没有得到测试,所有覆盖率自然不会是100%

所以,我们新增一个测试如下:这样就可以做到测试覆盖率百分百。

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

func TestJudgePassLineFail(t *testing.T) {
	isPass := JudgePassLine(40)
	assert.Equal(t, false, isPass)
}

单测-Tips

单测-依赖

实际工程中,复杂的项目一般都会有依赖,而我们单元测试需要保证稳定性和幂等性,稳定性是指相互隔离,能在任何环境、任何时间运行测试。

幂等性指的是每一次测试运行都因该产生与之前一样的结果,而要实现这一目的就要用到Mock机制。

示例-文件处理

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


2.1.2 Mock测试

我们可以使用Monkey,这个开源的mock测试库,对method或者实例的方法进行mock,Mockey Patch的作用域在Runtime,在运行时通过Gounsafe包,能够将内存中的函数地址替换为运行时函数地址。

image-20230118154214486

快速Mock函数:

  • 为一个函数打桩
  • 为一个方法打桩

参考阅读


2.1.3 基准测试

[见]【高质量编程与性能调优】篇


2.2 项目实战

2.2.1 需求背景

社区话题页面

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

需求用例

用户浏览页面消费,涉及页面的展示,包括话题内容和回帖列表,从图中可以抽象出来两个实体,以及实体之间的属性与联系,从而定义出对应的结构体。

E-R图


2.2.2 分层结构

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

组件工具

  • gin

  • go mod

    • go mod init
    • go get

    如果执行get命令之后长时间没反应出现超时情况,可以在命令行执行下面的命令之后再次尝试即可下载。

    go env -w GOPROXY=https://goproxy.cn
    

2.2.3 开发步骤

Reposity

数据索引

由于需要根据ID查询到帖子和话题数据,在没有使用数据库的情况下, 我们如何实现呢?最直接的方式就是针对数据文件进行全盘扫描,但显然这是比较耗时的操作,并不是最优的选择,所以这里就用到了 索引的概念。

索引就类似于书本的目录,通过这种方式,我们可以快速的定位到我们所需内容的位置。具体的,这里使用map结构来实现内存索引,在数据服务对外暴露之前,利用文件元数据初始化全局内存索引,这样就可以实现O(1)时间复杂度的查找操作了。

  • 初始化话题数据索引
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 post Post
		if err := json.Unmarshal([]byte(text), &post); err != nil {
			return err
		}
		posts, ok := postTmpMap[post.ParentId]
		if !ok {
			postTmpMap[post.ParentId] = []*Post{&post}
			continue
		}
		posts = append(posts, &post)
		postTmpMap[post.ParentId] = posts
	}
	postIndexMap = postTmpMap
	return nil
}

  • 查询话题数据
func NewTopicDaoInstance() *TopicDao {
	topicOnce.Do(
		func() {
			topicDao = &TopicDao{}
		})
	return topicDao
}

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

Service

具体的编排流程,通过err控制流程退出,正常会返回页面信息。

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
}

接下来只需要编写对应的每一个实现方法即可。写完controller之后,创建一个服务启动入口server.go

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
	}
}

func Init(filePath string) error {
	if err := repository.Init(filePath); err != nil {
		return err
	}
	return nil
}

执行下面的命令启动并访问接口:

项目扩展:

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

2.2.4 项目小结
  • os.Open()

  • func NewReader(rd io.Reader) *Reader


致谢&参考

  • 协程的概念
  • 《Java并发编程之美》
  • 字节内部课PPT
  • 使用monkey进行mock
  • Go在线手册
举报

相关推荐

0 条评论