GO语言工程实践 | 青训营

112 阅读10分钟

学习了Go语言进阶与依赖管理和Go语言工程实践之测试,这是我参与青训营项目写下的第二篇笔记。


一、

  • 首先我们确定已经安装了Go语言以及所需的第三方包(Gin和go-sqlite3)

  • 创建一个新的Go模块并初始化:

       go mod init topicforum
    
  • 创建一个main.go文件,并导入所需的包:

      "github.com/gin-gonic/gin"
      "github.com/jinzhu/gorm"
      "github.com/jinzhu/gorm/dialects/sqlite"
      "io/ioutil"
      "log"
    
    
  • main 函数中,我们初始化了数据库并进行了迁移,注册了对应的路由处理函数。

       func main() {
           router := gin.Default()
       ​
           db, err := gorm.Open("sqlite3", "forum.db")
           if err != nil {
               log.Fatal(err)
           }
           defer db.Close()
       ​
           // 迁移数据库结构
           db.AutoMigrate(&Topic{}, &Post{})
       ​
           // 注册路由
           router.GET("/topics", getTopicsHandler)
           router.POST("/topics", createTopicHandler)
           router.GET("/topics/:id/posts", getPostsHandler)
           router.POST("/topics/:id/posts", createPostHandler)
       ​
           router.Run(":8080")
       }
     
      
    
  • TopicPost分别代表话题和回帖的数据结构。

       type Topic struct {
           gorm.Model
           Title       string
           Description string
       }
       ​
       type Post struct {
           gorm.Model
           TopicID uint
           Content string
       }
     
    
  • getTopicsHandler:获取所有话题的处理函数。

           func getTopicsHandler(c *gin.Context) {
               db := getDB()
               var topics []Topic
               db.Find(&topics)
               c.JSON(200, topics)
           }
        
    
  • createTopicHandler:创建新话题的处理函数。

          func createTopicHandler(c *gin.Context) {
              db := getDB()
              var topic Topic
              c.BindJSON(&topic)
              db.Create(&topic)
              c.JSON(200, topic)
          }
       
    
  • getPostsHandler:获取特定话题下的回帖列表的处理函数。

           func getPostsHandler(c *gin.Context) {
               db := getDB()
               topicID := c.Param("id")
               var posts []Post
               db.Where("topic_id = ?", topicID).Find(&posts)
               c.JSON(200, posts)
           }
       
    
  • createPostHandler:创建特定话题下的回帖的处理函数。

          func createPostHandler(c *gin.Context) {
              db := getDB()
              topicID := c.Param("id")
              var post Post
              c.BindJSON(&post)
              post.TopicID = convertID(topicID)
              db.Create(&post)
              c.JSON(200, post)
          }
    
  • getDB() :函数是用来获取数据库连接的实例。通过这个函数获取数据库连接,以便进行数据操作。

          func getDB() *gorm.DB {
          db, err := gorm.Open("sqlite3", "forum.db")
          if err != nil {
          log.Fatal(err) 
          }
          return db
       }
    
  • 用于初始化操作的init()函数

         func init() { 
         // 创建数据库文件
         err := ioutil.WriteFile("forum.db", []byte(""), 0644) 
         if err != nil { 
         log.Fatal(err)
       }
    
  • convertID()函数

     func convertID(id string) uint {
     // 这里可以使用你自己的ID转换逻辑
     // 示例中简单地将字符串转换为uint
     // 请确保转换的结果唯一性和不重复性
     return uint(len(id)) 
     }
    

当我们将一个字符串转换为uint类型时,我们希望转换后的结果是唯一且不重复的。也就是说,不同的字符串转换后不能得到相同的uint值,而同一个字符串每次转换的结果应该是一致的。

这样做的目的是为了确保每个字符串都能生成一个唯一的标识符,例如用于在数据库中标识话题或回帖的ID。

示例:

       func main() { 
       id := "example" 
       convertedID := convertID(id) 
       fmt.Println(convertedID)
   } 
       func convertID(id string) uint {
       // 在实际应用中,可以使用更复杂的转换逻辑来保证结果的唯一性和不重复性。
       // 这里只是一个简单的示例,将字符串转换为其ASCII码之和作为ID。 
       var converted uint 
       for _, char := range id { 
       converted += uint(char) 
       } 
       return converted
   }
  • 运行服务

      go run main.go
    
  • 使用curl或Postman等工具来测试API接口(课程里面推荐Postman我用的这个)

  1. 获取所有话题

    GET http://localhost:8080/topics
    
  2. 创建话题

    POST http://localhost:8080/topics 
    Request Body: 
    { 
    "title": "话题标题", "description": "话题描述"
    }
    
  3. 获取特定话题下的回帖列表

    GET http://localhost:8080/topics/:id/posts
    
  4. 创建特定话题下的回帖:

    POST http://localhost:8080/topics/:id/posts 
    Request Body:
    {
    "content": "回帖内容"
    }
    

    注意:将URL中的:id替换为实际的话题ID。

二、

并发与并行

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

image.png

并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行,所以无论从微观还是从宏观来看,二者都是一起执行的。

image.png

Golang是实现高并发的调度模型,可以最大限度的利用计算资源,充分发挥多核优势,高效运行,可以说go语言是为并发而生。


goroutine

image.png

协程:用户态,轻量级线程,栈KB级别

线程:内核态,线程跑多个协程,栈MB级别

协程的创建和调度由Go语言本身去完成,Go语言可以一次性创建上万个级别的协程。

协程: 同一时间只能执行某个协程。开辟多个协程开销不大。协程适合对某任务进行分时处理。

线程: 同一时间可以同时执行多个线程。开辟多条线程开销很大。线程适合多任务同时处理。

协程与线程的区别主要在于它们的执行方式和资源管理:

  • 执行方式

线程是由操作系统进行调度的,并且多个线程可以同时运行在多个CPU核心上,从而实现并发。 协程是由程序员自行控制的,它在单个线程内进行切换,通过yield、await等关键字实现协程之间的切换,从而实现并发或并行。

  • 资源管理

线程由操作系统管理,因此线程之间的切换会涉及上下文切换,这可能导致一些开销,尤其在多线程高并发的情况下。 协程切换由程序员控制,因此在切换时避免了操作系统的开销,使得协程的切换更加轻量级。

  • 通信方式

线程之间的通信需要使用同步原语,如锁、信号量等,以避免竞态条件和资源冲突。 协程之间的通信可以通过共享数据,但更常见的是通过消息传递方式进行通信,例如使用生成器或异步函数。

  • 并发性和并行性

线程通常用于实现并发性,多个线程在同一时刻执行不同的任务。 协程通常用于实现并发或者并行性,通过在单线程内切换协程,可以让多个任务在同一时间段内执行。 总的来说,线程适用于CPU密集型任务,而协程适用于IO密集型任务,因为协程的切换更为轻量级,适合处理大量的IO操作。

启动goroutine

启动的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个go关键字。

代码第3行:创建一个goroutine ,在新的goroutine中执行Hello函数

 func main() {
     // 启动 goroutine
     go Hello()  
     fmt.Println("This main func")
     time.Sleep(time.Second)
 }
 ​

main()函数返回的时候goroutine便结束了,所有在main()函数中启动的goroutine会随之结束。

time.Sleep

另附加一个点,使用time.sleep可允许main函数等待hello函数

 var wg sync.WaitGroup
 ​
 func Hello() {
     fmt.Println("hello word")
     wg.Done()  // 计数器-1
 }
 func main() {
     wg.Add(1)  // 计数 +1
     go Hello()  
     fmt.Println("This main func")
     wg.Wait()  // 阻塞,一直等待所有的goroutine结束
 }
 ​

csp(Communicating Sequential Processes)

协程之间的通信,go提倡通过通信共享内存,而不是通过共享内存而实现通信

image.png goroutine 是go程序并发的执行体,channel相当于把协程做了一个连接,就像是传输队列遵循一个先入先出,能保证说话数据的顺序。

通过对内存进行一个加速,需要获取临界区的权限,在这种机制下,不同的goroutine之间容易发生数据静态的问题,在一定程度上影响程序性能。

对比两种通信方式,go更提倡通信共享内存。

channel(通道)

channel是一种引用类型

如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。

创建channel

通道类型的空值是nil

 var ch chan int
 fmt.Println(ch)

通道的初始化

声明的通道需要使用make函数初始化,可以选择缓冲大小。

 make(chan 元素类型, [缓冲大小])

例:

 var ch chan int      // 声明一个传递int类型的channel
 ch := make(chan int) // 使用make()函数定义一个channel

channel的三种操作

channel分为:发送(send)、接收(receive)和关闭(close),三种操作。

 //定义一个通道
 ch := make(chan int)
 ​
 //把一个值发送到通道中
 ch <- 5 
 ​
 // 从一个通道中接收值并赋值给变量
 x := <- ch 
 <-ch 
 ​
 //调用内置的close函数来关闭通道
 close(ch)

例子:

A子协程发送0~9数字,B子协程计算输入数字的平方,主协程输出最后的平方数

 func CalSquare(){
 ​
     src:=make(chan int)
     dest:=make(chan int,3)
     
     go func(){
         defer close(src)
         for i:=0;i<10;i++{
             src<-i
         }   
     }()
     
     go func(){
     defer close(dest)
     for i:+range src{
         dest<-i*i
     }
     }()
     
     for i:=range dest{
     println(i)
     }
 }

打印:

 0
 1
 4
 9
 16
 25
 36
 49
 64
 81

并发安全lock

Lock是一个接口。lock()方法是主要的使用方法,与之配合的是unLock()。

image.png

例图是加锁不加锁的区别,预期是10000。

加法函数的实现,addWithLock()通过临界区控制的实现,用sync.mutex一个关键字实现的加锁。

addWithoutLock()函数没有对临界区进行保护,直接进行了操作。

image.png 每个都开启一个协程并发地执行,分别对加锁和未加锁进行测试。

可知不加锁会输出未知的结果,并发安全问题。

实际项目开放中,并发安全问题会有一定几率引起错误结果,且比较难定位,所以应该避免对共享内存做一些非并发安全的读写操作。

WaitGroup

WaitGroup 类实现的功能是:等待一系列协程并发地执行完毕。如果不等待所有协程执行完毕,可能会导致一些线程安全问题。

WaitGroup的三个方法

WaitGroup是Golang并发的两种方式之一,一个是Channel,另一个是WaitGroup。

WaitGroup有3个API:

1)Add(delta int):增加/减少若干计数 2)Done:减少 1 个计数,等价于 Add(-1) 3)Wait:等待,直到计数等于 0

image.png

开启协程+1; 执行结束-1 ;主协程阻塞直到计数器为0。

例子:

 package main
 ​
 import (
    "fmt"
    "sync"
 )
 ​
 func hello(i int) {
    fmt.Printf("hello goroutine: %v\n", i)
 }
 ​
 func main() {
    wg := sync.WaitGroup{} 
    //调用Add(),声明协程的数量delta=5
    wg.Add(5)
    //协程执行
    for i := 0; i < 5; i++ {
       go func(j int) {
          //每个协程执行完后,调用Done(),delta-1
          defer wg.Done()
          hello(j)
       }(i)
    }
    //调用Wait(),当等待全部协程执行完毕才执行后续代码
    wg.Wait()
 ​
    fmt.Println("所有协程执行完毕,可执行下一项操作...")
 }
 ​

打印结果:

 hello goroutine: 4
 hello goroutine: 1                   
 hello goroutine: 0                   
 hello goroutine: 3                   
 hello goroutine: 2                   
 所有协程执行完毕,可执行下一项操作...

三、

go的依赖管理主要经历了三个阶段:

分别是GOPATH,GO Vendor和 Go Module,到现在为止广泛运用的是Go Module。

主要是围绕以下两个目的:

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

GOPATH

GOPATH是go支持的一个环境变量。

image.png

GOPATH文件结构需要有三个子目录:

 ./go
 ​
     |—— bin
 ​
         |—— 存储所编译生成的二进制文件。
 ​
     |—— pkg
 ​
         |—— 存储预编译的目标文件,以加快程序的后续编译速度。
 ​
     |—— src
 ​
         |—— 存储所有.go文件或源代码。
 ​
         |—— ......

当我们在src目录中写的模块是main时, 它会对应到一个可执行文件, 并且编译后的文件会被复制到bin目录。如果是其他模块, 它会被编译成一个库文件, 并且被复制到pkg目录。即一个放源代码, 一个放编译后的可执行文件, 另外一个放编译后的库文件。

项目代码直接依赖src下的代码;go get下载最新版本的包到src目录下。

go get命令的使用

默认情况下,go get 可以直接使用。

为了 go get 命令能正常工作,你得确保安装了合适的源码管理工具,同时把这些命令加入到你的 PATH 中。

若想获取 go 的源码并编译,可使用以下命令:

 $ go get github.com/davyxu/cellnet

获取前,请确保 GOPATH 已经设置。Go 1.8 版本之后,GOPATH 默认在用户目录的 go 文件夹下。

cellnet 是一个网络库,没有可执行文件,因此在 go get 操作成功后 GOPATH 下的 bin 目录下不会有任何编译好的二进制文件。

需要测试获取并编译二进制的,可以尝试下面的这个命令。当获取完成后,会自动在 GOPATH 的 bin 目录下生成编译好的二进制文件。

 $ go get github.com/davyxu/tabtoy

GOPATH的弊端

image.png

右边的v2没做到前后的一个兼容

A和B依赖于某一package的不同版本;无法实现package的多版本控制

Go Vendor

image.png 首先在项目目录下添加一个vendor文件,将所有依赖包以副本形式放在$ProjectRoot/vendor下

依赖寻址方式是vendor=>GOPATH

优点:

通过每个项目引入一个依赖副本,解决了多个项目需要同一个package依赖的冲突问题。

Go Vendor的弊端

image.png

通过vendor的管理模式不能很好地控制v1和v2的版本选择,一旦更新项目有可能出现依赖冲突导致编译出错。

Go Module

go语言官方推出的依赖管理系统,解决了以前依赖管理系统存在的无法依赖同一个库,多个版本等问题。

在Go v1.11/1.12版本引入,在Go 1.13版本稳定并默认打开。

  • 通过go.mod 文件管理依赖包版本
  • 通过go get/go mad 指令工具管理依赖包

如何开启Go Modules

使用go env命令可以查看你的Go Modules是否开启。

初始化Go Modules

 go mod init [MODULE_PATH]
 //初始化Go Modules,它将会生成go.mod文件。
 //[MODULE_PATH]是指模块引入路径,可修改

基础命令使用

命令作用
go mod init生成 go.mod 文件
go mod tidy整理现有依赖
go mod download下载 go.mod 文件中指明的所有依赖
go mod edit编辑 go.mod 文件
go mod graph查看现有的依赖结构
go mod vendor导出项目所有的依赖到vendor目录
go mod verify校验模块是否被篡改过
go mod why查看为什么需要依赖某模块
go list -m all列出当前模块及其所有依赖项
go get -u更新现有的依赖
分析关键字

image.png

  • module: 用于定义当前项目的模块路径
  • go:标识当前Go版本.即初始化版本

还有一个require(我图上未用到)

  • require: 当前项目依赖的一个特定的必须版本

依赖配置 -version

分为两个版本:语义化版本和基于commit 伪版本。

语义化版本

${MAJOR}.${MINOR}.\${PATCH}

MAJOR:不同的major版本不兼容(代码隔离)

MINOR:新增函数或者功能需要保持在这个major下做到兼容

PATCH:bug代码的修复

例:

 V1.3.0
 ​
 V2.3.0
 ​

基于commit 伪版本

 vX.0.0-yyymmddhhmmss(时间戳)-abcdefgh1234(12位的hash码的前缀)

例:

 v0.0.0-20220401081311-c38fb5932b7

GOPROXY

Proxy指的是代理软件代理服务器,也可以认为是一种网络访问方式

Go的1.11版本以后可以设置环境变量 GOPROXY,来设置代理,以加速下载。

它有下载速度快没有限制功能齐全,以及数据可视化等的特点。

 GOPROXY="https://proxy1.cn,https://proxy2.cn,direct"

服务站点URL列表,"direct"表示源站

以下是项目查找依赖的路径:

image.png

覆盖率

示例代码

 package main
 ​
 func Size(a int) string {
 switch {
 case a < 0:
 return "negative"
 case a == 0:
 return "zero"
 case a < 10:
 return "small"
 case a < 100:
 return "big"
 case a < 1000:
 return "huge"
 }
 return "enormous"
 }
 ​

测试代码

 package main
 import "testing"
 ​
 ​
 type Test struct {
     in  int
     out string
 }
 ​
 var tests = []Test{
     {-1, "negative"},
     {5, "small"},
 }
 ​
 func TestSize(t *testing.T) {
     for i, test := range tests {
         size := Size(test.in)
         if size != test.out {
             t.Errorf("#%d: Size(%d)=%s; want %s", i, test.in, size, test.out)
         }
     }
 }
 ​

覆盖率测试如下:

image.png

我测试的时候出现了一个问题,但也找到了解决方法:

 no Go files in  D:\Trysomething

这个错误提示表明Go命令在指定路径下找不到任何Go文件。请确保在正确的目录下执行测试命令,以我的文件举例,该目录中包含size.gosize_test.go文件。可以使用cd命令切换到正确的目录,然后再次运行go test命令。

顺便说说另一个问题:

 no required module provides package size.go; to add it: go get size.go

这个错误提示表明Go命令无法找到size.go文件所在的模块。请注意,go get命令是用来获取/安装包或模块的,而不是用来获取单个源文件的。如果你的项目使用模块管理,请确保在正确的模块路径下执行测试命令。

查看测试结果

  • 使用 -coverprofile 标志来指定输出的文件:

image.png

  • 运行 test coverage tool

image.png

  • 将注释源代码用html展示,由 -html 标志调用:

image.png 已覆盖 (绿色), 未覆盖(红色) 和 未埋点(灰色)

参考地址:

Go 语言进阶与依赖管理(主要课程)

go get命令——一键获取代码、编译并安装

GoLang之精通Golang项目依赖Go modules