个性化阅读
专注于IT技术分析

Go编程语言:Golang权威入门教程

本文概述

什么是Go编程语言?

相对较新的Go编程语言整齐地位于景观的中间, 提供许多良好的功能, 并故意省略了许多不良的功能。它编译速度快, 运行速度快, 包括运行时和垃圾回收, 具有简单的静态类型系统和动态接口以及出色的标准库。这就是为什么如此多的开发人员热衷于学习Go编程的原因。

教程:徽标插图

去和面向对象

OOP是Go故意忽略的那些功能之一。它没有子类, 因此没有继承菱形, 超级调用或虚拟方法来使你绊倒。尽管如此, OOP的许多有用部分仍然可以通过其他方式获得。

* Mixins *可通过匿名嵌入结构来使用, 从而允许直接在包含的结构上调用其方法(请参见嵌入)。以这种方式提升方法称为* forwarding *, 它与子类化不同:方法仍将在内部嵌入式结构上调用。

嵌入也并不意味着多态。虽然” A”可能有一个” B”, 但这并不意味着它是一个” B”, 带有” B”的函数不会带有” A”。为此, 我们需要接口, 我们将在稍后简要介绍。

同时, Golang在可能导致混淆和错误的功能上占据重要位置。它省略了诸如继承和多态性之类的OOP惯用语, 而采用了组合和简单的接口。它轻视异常处理, 而倾向于返回值中的显式错误。由gofmt工具实施的布局Go代码的方法只有一种正确的方法。等等。

为什么要学习Golang?

Go还是编写并发程序的好语言:具有许多独立运行部分的程序。一个明显的例子是Web服务器:每个请求都单独运行, 但是请求通常需要共享资源, 例如会话, 缓存或通知队列。这意味着熟练的Go程序员需要处理对这些资源的并发访问。

虽然Golang具有一组出色的用于处理并发性的低级功能, 但是直接使用它们会变得很复杂。在许多情况下, 这些低级机制上的少数可重用抽象使生活变得更加轻松。

在今天的Go编程教程中, 我们将介绍一种这样的抽象:一种包装程序, 可以将任何数据结构转换为事务服务。我们将以”基金”类型为例-一家简单的商店, 用于存储创业公司的剩余资金, 我们可以在其中查看余额并进行提款。

为了在实践中证明这一点, 我们将逐步构建服务, 将过程弄得一团糟, 然后再次对其进行清理。随着我们Go语言教程的发展, 我们将遇到许多很棒的Go语言功能, 包括:

  • 结构类型和方法
  • 单元测试和基准
  • 例行程序和通道
  • 接口和动态类型

建立一个简单的基金

让我们编写一些代码来跟踪我们的创业公司的资金。资金以给定的余额开始, 并且只能提取资金(我们稍后会确定收入)。

此图描绘了一个使用Go编程语言的简单goroutine示例。

Go故意不是一种面向对象的语言:没有类, 对象或继承。相反, 我们将声明一个名为Fund的结构类型, 该结构类型具有创建新基金结构的简单函数和两个公共方法。

fund.go

package funding

type Fund struct {
    // balance is unexported (private), because it's lowercase
    balance int
}

// A regular function returning a pointer to a fund
func NewFund(initialBalance int) *Fund {
    // We can return a pointer to a new struct without worrying about
    // whether it's on the stack or heap: Go figures that out for us.
    return &Fund{
        balance: initialBalance, }
}

// Methods start with a *receiver*, in this case a Fund pointer
func (f *Fund) Balance() int {
    return f.balance
}

func (f *Fund) Withdraw(amount int) {
    f.balance -= amount
}

用基准测试

接下来, 我们需要一种测试基金的方法。我们将使用Go的测试包, 而不是编写单独的程序, 该程序包提供了单元测试和基准测试的框架。我们的基金中的简单逻辑确实不值得编写单元测试, 但是由于稍后我们将大量讨论并发访问基金的问题, 因此编写基准是有意义的。

基准测试就像单元测试一样, 但是包含一个循环, 该循环可以多次运行相同的代码(在我们的示例中, 是fund.Withdraw(1))。这使框架能够确定每次迭代花费的时间, 以求出与磁盘搜索, 高速缓存未命中, 进程调度以及其他不可预测因素的瞬时差异。

测试框架希望每个基准测试至少运行1秒(默认情况下)。为确保这一点, 它将多次调用基准测试, 每次传递一个递增的”迭代次数”值(b.N字段), 直到运行至少花费一秒钟。

目前, 我们的基准测试只是存入一些钱, 然后一次提取1美元。

fund_test.go

package funding

import "testing"

func BenchmarkFund(b *testing.B) {
    // Add as many dollars as we have iterations this run
    fund := NewFund(b.N)

    // Burn through them one at a time until they are all gone
    for i := 0; i < b.N; i++ {
        fund.Withdraw(1)
    }

    if fund.Balance() != 0 {
        b.Error("Balance wasn't zero:", fund.Balance())
    }
}

现在运行它:

$ go test -bench . funding
testing: warning: no tests to run
PASS
BenchmarkWithdrawals    2000000000             1.69 ns/op
ok      funding    3.576s

一切顺利。我们进行了二十亿次(!)迭代, 最后对余额的检查是正确的。我们可以忽略”没有要运行的测试”警告, 它是指我们未编写的单元测试(在本教程的以后的Go编程示例中, 该警告被剔除)。

Go中的并发访问

现在, 让我们同时进行基准测试, 以模拟不同用户同时提款的情况。为此, 我们将生成十个goroutine, 并让它们各自提取十分之一的钱。

我们如何用Go语言构造多个并发goroutine?

Goroutine是Go语言中并发的基本构建块。它们是绿色线程–由Go运行时而非操作系统管理的轻量级线程。这意味着你可以运行其中的数千个(或数百万个), 而无需任何大笔开销。 Goroutine是使用go关键字生成的, 并且始终以函数(或方法调用)开头:

// Returns immediately, without waiting for `DoSomething()` to complete
go DoSomething()

通常, 我们只想用几行代码就生成一个简短的一次性函数。在这种情况下, 我们可以使用闭包代替函数名:

go func() {
    // ... do stuff ...
}() // Must be a function *call*, so remember the ()

生成所有goroutine后, 我们需要一种等待它们完成的方法。我们可以使用渠道来建立自己的渠道, 但我们还没有遇到过渠道, 因此可以跳过。

目前, 我们只能使用Go的标准库中的WaitGroup类型, 该类型正是为此目的而存在的。我们将创建一个(称为” wg”)并在生成每个工作程序之前调用wg.Add(1), 以跟踪有多少工作程序。然后, 工作人员将使用wg.Done()进行报告。同时, 在主要的goroutine中, 我们可以说wg.Wait()阻塞直到每个工作人员都完成为止。

在下一个示例中的worker goroutine中, 我们将使用defer调用wg.Done()。

defer接受一个函数(或方法)调用, 并在其他所有操作完成后立即在当前函数返回之前运行它。这对于清理很方便:

func() {
    resource.Lock()
    defer resource.Unlock()

    // Do stuff with resource
}()

这样, 我们就可以轻松地将Unlock和Lock匹配起来, 以提高可读性。更重要的是, 即使主函数出现了恐慌, 延迟函数也将运行(我们可能会通过其他语言的try-finally进行处理)。

最后, 延迟函数将以与调用它们相反的顺序执行, 这意味着我们可以很好地进行嵌套清理(类似于嵌套的getos和label的C语言, 但是更整洁):

func() {
    db.Connect()
    defer db.Disconnect()

    // If Begin panics, only db.Disconnect() will execute
    transaction.Begin()
    defer transaction.Close()

    // From here on, transaction.Close() will run first, // and then db.Disconnect()

    // ...
}()

好的, 话虽这么说, 这是新版本:

fund_test.go

package funding

import (
    "sync"
    "testing"
)

const WORKERS = 10

func BenchmarkWithdrawals(b *testing.B) {
    // Skip N = 1
    if b.N < WORKERS {
        return
    }

    // Add as many dollars as we have iterations this run
    fund := NewFund(b.N)

    // Casually assume b.N divides cleanly
    dollarsPerFounder := b.N / WORKERS

    // WaitGroup structs don't need to be initialized
    // (their "zero value" is ready to use).
    // So, we just declare one and then use it.
    var wg sync.WaitGroup

    for i := 0; i < WORKERS; i++ {
        // Let the waitgroup know we're adding a goroutine
        wg.Add(1)
        
        // Spawn off a founder worker, as a closure
        go func() {
            // Mark this worker done when the function finishes
            defer wg.Done()

            for i := 0; i < dollarsPerFounder; i++ {
                fund.Withdraw(1)
            }
            
        }() // Remember to call the closure!
    }

    // Wait for all the workers to finish
    wg.Wait()

    if fund.Balance() != 0 {
        b.Error("Balance wasn't zero:", fund.Balance())
    }
}

我们可以预测这里会发生什么。工人将相互执行撤回。在其中, f.balance-=数量将读取余额, 减去一, 然后写回。但是有时, 两个或更多的工作人员会读取相同的余额, 并且进行相同的减法运算, 因此最终得出错误的总数。对?

$ go test -bench . funding
BenchmarkWithdrawals    2000000000             2.01 ns/op
ok      funding    4.220s

不, 它仍然通过。这里发生了什么?

请记住, goroutine是绿色线程, 它们由Go运行时而不是由操作系统管理。运行时在可用的多个OS线程之间调度goroutine。在撰写本Go语言教程时, Go并没有尝试猜测它应该使用多少个OS线程, 如果我们想要多个, 则必须这样说。最后, 当前的运行时不会取代goroutine, goroutine将继续运行, 直到它执行表明已准备好休息的操作(例如与通道进行交互)为止。

所有这些意味着, 尽管我们的基准测试现在是并发的, 但并不是并行的。我们一次只有一名工人会运转, 直到完成为止。我们可以通过GOMAXPROCS环境变量告诉Go使用更多线程来更改此设置。

$ GOMAXPROCS=4 go test -bench . funding
BenchmarkWithdrawals-4    --- FAIL: BenchmarkWithdrawals-4
    account_test.go:39: Balance wasn't zero: 4238
ok      funding    0.007s

这样更好现在, 我们显然正在丢失我们的部分提款, 正如我们所料。

在此Go编程示例中,多个并行goroutine的结果不理想。

使其成为服务器

在这一点上, 我们有各种选择。我们可以在基金周围添加明确的互斥或读写锁定。我们可以使用带有版本号的比较交换。我们可以全力以赴并使用CRDT方案(也许用每个客户的交易清单替换余额字段, 然后从中计算余额)。

但是我们现在不会做任何事情, 因为它们是混乱的或可怕的, 或两者都有。取而代之的是, 我们决定将资金用作服务器。什么是服务器?你在跟你说话。在Go中, 事物通过渠道交流。

通道是goroutine之间的基本通信机制。值被发送到通道(通道<-值), 并且可以在另一侧接收(值= <-通道)。通道是” goroutine安全”的通道, 这意味着可以同时发送和接收任意数量的goroutine。

缓冲

在某些情况下, 缓冲通信通道可以优化性能, 但应格外小心(和基准测试!)。

但是, 缓冲通道有一些用途, 它们与通信不直接相关。

例如, 一个常见的限制习惯用法会创建一个(例如)缓冲区大小为” 10″的通道, 然后立即向其中发送十个令牌。然后产生任意数量的工作程序, 每个工作程序在开始工作之前从通道接收令牌, 然后将其发送回去。这样, 无论有多少工人, 只有10个工人同时工作。

默认情况下, Go通道是无缓冲的。这意味着向通道发送值将阻塞, 直到另一个goroutine准备立即接收它为止。 Go还支持通道的固定缓冲区大小(使用make(chan someType, bufferSize))。但是, 对于正常使用, 这通常是个坏主意。

想象一下我们的基金的网络服务器, 每个请求都在其中提取。当事情很忙时, FundServer将无法跟上, 尝试发送到其命令通道的请求将开始阻塞并等待。届时, 我们可以在服务器中强制执行最大请求数, 并在该限制范围内向客户端返回明智的错误代码(例如503服务不可用)。服务器超载时, 这是可能的最佳行为。

在我们的频道中添加缓冲会降低这种行为的确定性。根据客户端早些时候所看到的信息(以及可能自上游以来已超时的请求), 我们很容易以长长的未处理命令队列结束。在许多其他情况下也是如此, 例如当接收方无法跟上发送方时, 在TCP上施加背压。

无论如何, 对于我们的Go示例, 我们将坚持默认的无缓冲行为。

我们将使用渠道将命令发送到FundServer。每个基准工作人员都会将命令发送到通道, 但是只有服务器会接收到它们。

我们可以将Fund类型直接转换为服务器实现, 但这很麻烦–我们将并发处理和业务逻辑混合在一起。取而代之的是, 我们将保留原样的Fund类型, 并将FundServer单独包装。

像任何服务器一样, 包装器将有一个主循环, 在其中等待命令并依次响应每个命令。我们需要在这里处理更多细节:命令的类型。

该Go编程教程中用作服务器的资金图。

指针

我们可以使命令通道将* pointer *指向命令(” chan * TransactionCommand”)。我们为什么不呢?

在goroutine之间传递指针是有风险的, 因为任何一个goroutine都可能对其进行修改。由于其他goroutine可能在不同的CPU内核上运行(意味着更多的缓存失效), 因此效率通常也较低。

只要有可能, 就最好传递简单的值。

在下面的下一部分中, 我们将发送几个不同的命令, 每个命令都有自己的结构类型。我们希望服务器的”命令”通道接受其中任何一个。在OOP语言中, 我们可以通过多态性来做到这一点:让通道采用超类, 其中的各个命令类型是子类。在Go中, 我们改用接口。

接口是一组方法签名。实现所有这些方法的任何类型都可以视为该接口(无需声明这样做)。对于我们的第一次运行, 我们的命令结构实际上不会公开任何方法, 因此我们将使用空接口interface {}。由于没有要求, 因此任何值(包括整数之类的原始值)都可以满足空接口的要求。这不是理想的选择-我们只想接受命令结构-但我们稍后会再讲。

现在, 让我们开始使用Go服务器的脚手架:

server.go

package funding

type FundServer struct {
    Commands chan interface{}
    fund Fund
}

func NewFundServer(initialBalance int) *FundServer {
    server := &FundServer{
        // make() creates builtins like channels, maps, and slices
        Commands: make(chan interface{}), fund: NewFund(initialBalance), }

    // Spawn off the server's main loop immediately
    go server.loop()
    return server
}

func (s *FundServer) loop() {
    // The built-in "range" clause can iterate over channels, // amongst other things
    for command := range s.Commands {
    
        // Handle the command
        
    }
}

现在, 为命令添加几个Golang结构类型:

type WithdrawCommand struct {
    Amount int
}

type BalanceCommand struct {
    Response chan int
}

WithdrawCommand仅包含要提取的金额。没有回应。 BalanceCommand确实有一个响应, 因此它包含一个发送它的通道。这样可以确保即使我们的基金后来决定无序响应, 响应也将始终正确地进行。

现在我们可以编写服务器的主循环:

func (s *FundServer) loop() {
    for command := range s.Commands {

        // command is just an interface{}, but we can check its real type
        switch command.(type) {

        case WithdrawCommand:
            // And then use a "type assertion" to convert it
            withdrawal := command.(WithdrawCommand)
            s.fund.Withdraw(withdrawal.Amount)

        case BalanceCommand:
            getBalance := command.(BalanceCommand)
            balance := s.fund.Balance()
            getBalance.Response <- balance

        default:
            panic(fmt.Sprintf("Unrecognized command: %v", command))
        }
    }
}

嗯这有点丑陋。我们正在使用类型断言来打开命令类型, 并且可能会崩溃。无论如何, 让我们继续前进, 并更新基准以使用服务器。

func BenchmarkWithdrawals(b *testing.B) {
    // ...

    server := NewFundServer(b.N)

    // ...

    // Spawn off the workers
    for i := 0; i < WORKERS; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for i := 0; i < dollarsPerFounder; i++ {
                server.Commands <- WithdrawCommand{ Amount: 1 }
            }
        }()
    }

    // ...

    balanceResponseChan := make(chan int)
    server.Commands <- BalanceCommand{ Response: balanceResponseChan }
    balance := <- balanceResponseChan

    if balance != 0 {
        b.Error("Balance wasn't zero:", balance)
    }
}

这也很难看, 特别是当我们检查余额时。没关系。试试看:

$ GOMAXPROCS=4 go test -bench . funding
BenchmarkWithdrawals-4     5000000           465 ns/op
ok      funding    2.822s

更好的是, 我们不再丢失取款。但是代码变得难以阅读, 并且存在更严重的问题。如果我们发出了BalanceCommand, 然后忘记阅读响应, 则我们的资金服务器将永远阻止尝试发送该响应。让我们整理一下。

提供服务

服务器是你与之交谈的东西。什么是服务?服务是你使用API​​与之交谈的东西。与其让客户代码不直接与命令通道一起使用, 不如使该通道不导出(私有)并将可用命令包装在函数中。

type FundServer struct {
    commands chan interface{} // Lowercase name, unexported
    // ...
}

func (s *FundServer) Balance() int {
    responseChan := make(chan int)
    s.commands <- BalanceCommand{ Response: responseChan }
    return <- responseChan
}

func (s *FundServer) Withdraw(amount int) {
    s.commands <- WithdrawCommand{ Amount: amount }
}

现在, 我们的基准测试可以只说server.Withdraw(1)和balance:= server.Balance(), 因此偶然发送无效命令或忘记阅读响应的机会就更少了。

在此示例Go语言程序中,将资金用作服务的内容如下。

这些命令还有很多额外的样板, 但我们稍后会再讲。

交易次数

最终, 钱总是用光了。让我们同意, 当我们的资金降到最后10美元时, 我们将停止提取资金, 并将这笔钱花在公共比萨饼上庆祝或同情。我们的基准将反映以下内容:

// Spawn off the workers
for i := 0; i < WORKERS; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 0; i < dollarsPerFounder; i++ {

            // Stop when we're down to pizza money
            if server.Balance() <= 10 {
                break
            }
            server.Withdraw(1)
        }
    }()
}

// ...

balance := server.Balance()
if balance != 10 {
    b.Error("Balance wasn't ten dollars:", balance)
}

这次我们确实可以预测结果。

$ GOMAXPROCS=4 go test -bench . funding
BenchmarkWithdrawals-4    --- FAIL: BenchmarkWithdrawals-4
    fund_test.go:43: Balance wasn't ten dollars: 6
ok      funding    0.009s

我们回到了开始的地方–几名工人可以一次读取余额, 然后全部更新。为了解决这个问题, 我们可以在基金本身中添加一些逻辑, 例如minimumBalance属性, 或者添加另一个名为WithdrawIfOverXDollars的命令。这些都是可怕的想法。我们之间的协议是我们之间的共识, 而不是基金的财产。我们应该将其保留在应用程序逻辑中。

与数据库事务相同, 我们真正需要的是事务。由于我们的服务一次只执行一个命令, 因此非常容易。我们将添加一个包含回调(闭包)的Transact命令。服务器将在自己的goroutine中执行该回调, 并传入原始资金。然后, 回调可以安全地对基金进行任何操作。

信号量和错误

在下一个示例中, 我们在做两个小错误。

首先, 我们使用” Done”通道作为信号量, 以使调用代码知道其事务何时完成。很好, 但是为什么通道类型为`bool`?我们只会向其中发送” true”以表示”完成”(发送” false”甚至意味着什么)。我们真正想要的是单状态值(没有值的值?)。在Go中, 我们可以使用空的struct类型来执行此操作:struct {}。这也具有使用较少内存的优点。在示例中, 我们将坚持使用” bool”, 以免显得太吓人。

其次, 我们的事务回调不返回任何东西。稍后我们将看到, 我们可以使用范围技巧将值从回调中获取到调用代码中。但是, 实际系统中的事务有时可能会失败, 因此Go约定将是使事务返回一个”错误”(然后检查调用代码中它是否为” nil”)。

我们现在也不会这样做, 因为我们不会产生任何错误。

// Typedef the callback for readability
type Transactor func(fund *Fund)

// Add a new command type with a callback and a semaphore channel
type TransactionCommand struct {
    Transactor Transactor
    Done chan bool
}

// ...

// Wrap it up neatly in an API method, like the other commands
func (s *FundServer) Transact(transactor Transactor) {
    command := TransactionCommand{
        Transactor: transactor, Done: make(chan bool), }
    s.commands <- command
    <- command.Done
}

// ...

func (s *FundServer) loop() {
    for command := range s.commands {
        switch command.(type) {
        // ...

        case TransactionCommand:
            transaction := command.(TransactionCommand)
            transaction.Transactor(s.fund)
            transaction.Done <- true

        // ...
        }
    }
}

我们的交易回调不会直接返回任何内容, 但是Go语言可以轻松地直接从闭包中获取值, 因此我们将在基准测试中进行设置, 以在资金短缺时设置pizzaTime标志:

pizzaTime := false
for i := 0; i < dollarsPerFounder; i++ {

    server.Transact(func(fund *Fund) {
        if fund.Balance() <= 10 {
            // Set it in the outside scope
            pizzaTime = true
            return
        }
        fund.Withdraw(1)
    })

    if pizzaTime {
        break
    }
}

并检查它是否有效:

$ GOMAXPROCS=4 go test -bench . funding
BenchmarkWithdrawals-4     5000000           775 ns/op
ok      funding    4.637s

只是交易

你可能已经发现了现在可以清理更多东西的机会。由于我们有通用的Transact命令, 因此不再需要WithdrawCommand或BalanceCommand。我们将根据交易来重写它们:

func (s *FundServer) Balance() int {
    var balance int
    s.Transact(func(f *Fund) {
        balance = f.Balance()
    })
    return balance
}

func (s *FundServer) Withdraw(amount int) {
    s.Transact(func (f *Fund) {
        f.Withdraw(amount)
    })
}

现在, 服务器唯一需要的命令是TransactionCommand, 因此我们可以删除实现中的整个interface {}混乱, 并使它仅接受事务命令:

type FundServer struct {
    commands chan TransactionCommand
    fund *Fund
}

func (s *FundServer) loop() {
    for transaction := range s.commands {
        // Now we don't need any type-switch mess
        transaction.Transactor(s.fund)
        transaction.Done <- true
    }
}

好多了。

我们可以在这里采取最后一步。除了其便利的”取款和提款”功能外, 服务实施不再与基金挂钩。除了管理基金外, 它还可以管理界面{}并用于包装任何东西。但是, 每个事务回调然后都必须将interface {}转换回真实值:

type Transactor func(interface{})

server.Transact(func(managedValue interface{}) {
    fund := managedValue.(*Fund)
    // Do stuff with fund ...
})

这很丑陋且容易出错。我们真正想要的是编译时泛型, 因此我们可以为特定类型(例如* Fund)”模板化”服务器。

不幸的是, Go还不支持泛型。一旦有人弄清楚了它的一些合理的语法和语义, 就有望最终实现。同时, 仔细的接口设计通常消除了对泛型的需求, 如果没有泛型, 我们可以使用类型断言(在运行时进行检查)来​​解决。

我们完了吗?

是。

好吧, 不。

例如:

  • 交易中的恐慌将杀死整个服务。

  • 没有超时。永不返回的事务将永远阻止该服务。

  • 如果我们的基金增加了一些新字段, 而交易在更新中途中途崩溃, 则状态会不一致。

  • 交易会泄漏托管基金对象, 这是不好的。

  • 没有合理的方法来跨多个基金进行交易(例如从一个基金中撤出并存入另一个基金)。我们不能只是嵌套事务, 因为它会导致死锁。

  • 现在异步运行事务需要一个新的goroutine和很多麻烦。与此相关, 我们可能希望能够在长时间运行的交易进行期间从其他地方读取最新的基金状态。

在我们的下一个Go编程语言教程中, 我们将探讨解决这些问题的一些方法。

相关:结构良好的逻辑:Golang OOP教程

赞(0)
未经允许不得转载:srcmini » Go编程语言:Golang权威入门教程

评论 抢沙发

评论前必须登录!