使用testify包辅助Go测试指南

本文永久链接 – https://tonybai.com/2023/07/16/the-guide-of-go-testing-with-testify-package

我虽然算不上Go标准库的“清教徒”,但在测试方面还多是基于标准库testing包以及go test框架的,除了需要mock的时候,基本上没有用过第三方的Go测试框架。我在《Go语言精进之路》一书中对Go测试组织的讲解也是基于Go testing包和go test框架的。

最近看Apache arrow代码,发现arrow的Go实现使用了testify项目组织和辅助测试:

// compute/vector_hash_test.go

func TestHashKernels(t *testing.T) {
    suite.Run(t, &PrimitiveHashKernelSuite[int8]{})
    suite.Run(t, &PrimitiveHashKernelSuite[uint8]{})
    suite.Run(t, &PrimitiveHashKernelSuite[int16]{})
    suite.Run(t, &PrimitiveHashKernelSuite[uint16]{})
    ... ...
}

type PrimitiveHashKernelSuite[T exec.IntTypes | exec.UintTypes | constraints.Float] struct {
    suite.Suite

    mem *memory.CheckedAllocator
    dt  arrow.DataType
}

func (ps *PrimitiveHashKernelSuite[T]) SetupSuite() {
    ps.dt = exec.GetDataType[T]()
}

func (ps *PrimitiveHashKernelSuite[T]) SetupTest() {
    ps.mem = memory.NewCheckedAllocator(memory.DefaultAllocator)
}

func (ps *PrimitiveHashKernelSuite[T]) TearDownTest() {
    ps.mem.AssertSize(ps.T(), 0)
}

func (ps *PrimitiveHashKernelSuite[T]) TestUnique() {
    ... ...
}

同期,我在grank.io上看到testify这个项目综合排名第一:

这说明testify项目在Go社区有着广泛的受众,testify为何能从众多go test第三方框架中脱颖而出?它有哪些与众不同的地方?如何更好地利用testify来辅助我们的Go测试?带着这些问题,我写下了这篇有关testify的文章,供大家参考。

1. testify简介

testify是一个用于Go语言的测试框架,与go testing包可以很好的融合在一起,并由go test驱动运行。testify提供的功能特性可以辅助Go开发人员更好地组织和更高效地编写测试用例,以保证软件的质量和可靠性。

testify能够得到社区的广泛接纳,与testify项目中包的简洁与独立的设计是密不可分的。下面是testify包的目录结构(去掉了用于生成代码的codegen和已经deprecated的http目录后):

$tree -F -L 1 testify |grep "/" |grep -v codegen|grep -v http
├── assert/
├── mock/
├── require/
└── suite/

关于Go项目代码布局设计的系统讲解,可以参见我的《Go语言第一课》专栏的第5讲。

包目录名直接反映了testify可以提供给Go开发者的功能特性:

  • assert和require:断言工具包,辅助做测试结果判定;
  • mock:辅助编写mock test的工具包;
  • suite:提供了suite这一层的测试组织结构。

下面我们就由浅入深的介绍testify的这几个重要的、可各自独立使用的包。我们先从使用门槛最低的assert包和require包开始,它们是一类的,这里放在一个章节中介绍。

2. assert和require包

我们在使用go testing包编写Go单元测试用例时,通常会用下面代码来判断目标函数执行结果是否符合预期:

func TestFoo(t *testing.T) {
    v := Foo(5, 6) // Foo为被测目标函数
    if v != expected {
        t.Errorf("want %d, actual %d\n", expected, v)
    }
}

这样,如果测试用例要判断的结果很多,那么测试代码中就会存在很多if xx != yy以及Errorf/Fatalf之类的代码。有过一些其他语言编程经验的童鞋此时此刻肯定会说:是时候上assert了! 不过很遗憾,Go标准库包括其实验库(exp)都没有提供带有assert断言机制的包。

注:Go标准库testing/quick包中提供的Check和CheckEqual并非assert,它们用于测试两个函数参数在相同输入的情况下是否有相同的输出。如果不同,则输出导致输出不同的输入。此外,该quick包已经frozen,不再接受新Feature。

testify为Go开发人员提供了assert包,为Go开发人员很大程度“解了近渴”。

assert包使用起来非常简单,下面是assert使用的常见场景示例:

// assert/assert_test.go

func Add(a, b int) int {
    return a + b
}

func TestAssert(t *testing.T) {
    // Equal断言
    assert.Equal(t, 4, Add(1, 3), "The result should be 4")

    sl1 := []int{1, 2, 3}
    sl2 := []int{1, 2, 3}
    sl3 := []int{2, 3, 4}
    assert.Equal(t, sl1, sl2, "sl1 should equal to sl2 ")

    p1 := &sl1
    p2 := &sl2
    assert.Equal(t, p1, p2, "the content which p1 point to should equal to which p2 point to")

    err := errors.New("demo error")
    assert.EqualError(t, err, "demo error")

    // assert.Exactly(t, int32(123), int64(123)) // failed! both type and value must be same

    // 布尔断言
    assert.True(t, 1+1 == 2, "1+1 == 2 should be true")
    assert.Contains(t, "Hello World", "World")
    assert.Contains(t, []string{"Hello", "World"}, "World")
    assert.Contains(t, map[string]string{"Hello": "World"}, "Hello")
    assert.ElementsMatch(t, []int{1, 3, 2, 3}, []int{1, 3, 3, 2})

    // 反向断言
    assert.NotEqual(t, 4, Add(2, 3), "The result should not be 4")
    assert.NotEqual(t, sl1, sl3, "sl1 should not equal to sl3 ")
    assert.False(t, 1+1 == 3, "1+1 == 3 should be false")
    assert.Never(t, func() bool { return false }, time.Second, 10*time.Millisecond) //1秒之内condition参数都不为true,每10毫秒检查一次
    assert.NotContains(t, "Hello World", "Go")
}

我们看到assert包提供了Equal类、布尔类、反向类断言,assert包提供的断言函数有几十种,这里无法一一枚举,选择最适合你的测试场景的断言就好。

另外要注意的是,在Equal对切片作比较时,比较的是切片底层数组存储的内容是否相等;对指针作比较时,比较的是指针指向的内存块儿的数据是否相等,而不是指针本身的值是否相等。

注:assert.Equal底层实现使用的是reflect.DeepEqual。

我们看到assert包提供的断言函数第一个参数是testing.T的实例,如果一个测试用例里多次使用assert包的断言函数,我们每次都要传入testing.T的实例,比如下面示例:

// assert/assert_test.go

func TestAdd1(t *testing.T) {
    result := Add(1, 3)
    assert.Equal(t, 4, result, "The result should be 4")
    result = Add(2, 2)
    assert.Equal(t, 4, result, "The result should be 4")
    result = Add(2, 3)
    assert.Equal(t, 5, result, "The result should be 5")
    result = Add(0, 3)
    assert.Equal(t, 3, result, "The result should be 3")
    result = Add(-1, 1)
    assert.Equal(t, 0, result, "The result should be 0")
}

这很verbose! assert包提供了替代方法,如下面示例:

// assert/assert_test.go

func TestAdd2(t *testing.T) {
    assert := assert.New(t)

    result := Add(1, 3)
    assert.Equal(4, result, "The result should be 4")
    result = Add(2, 2)
    assert.Equal(4, result, "The result should be 4")
    result = Add(2, 3)
    assert.Equal(5, result, "The result should be 5")
    result = Add(0, 3)
    assert.Equal(3, result, "The result should be 3")
    result = Add(-1, 1)
    assert.Equal(0, result, "The result should be 0")
}

注:我们当然可以使用表驱动测试的方法将上述示例做进一步优化。

require包可以理解为assert包的“姊妹包”,require包实现了assert包提供的所有导出的断言函数,因此我们将上述示例中的assert改为require后,代码可以正常编译和运行(见require/require_test.go)。

那么require包与assert包有什么不同呢?我们来简单看一下。

使用assert包的断言时,如果某一个断言失败,该失败不会影响到后续测试代码的执行,或者说后续测试代码会继续执行,比如我们故意将TestAssert中的一些断言条件改为失败:

// assert/assert_test.go

    assert.True(t, 1+1 == 3, "1+1 == 2 should be true")
    assert.Contains(t, "Hello World", "World1")

再运行assert_test.go中的测试,我们会看到下面结果:

$go test
--- FAIL: TestAssert (1.00s)
    assert_test.go:34:
            Error Trace:
            Error:          Should be true
            Test:           TestAssert
            Messages:       1+1 == 2 should be true
    assert_test.go:35:
            Error Trace:
            Error:          "Hello World" does not contain "World1"
            Test:           TestAssert
FAIL
exit status 1
FAIL    demo    1.016s

我们看到:两个失败的测试断言都输出了!

我们再换到require/require_test.go下做同样的修改,并执行go test,我们得到如下结果:

$go test require_test.go
--- FAIL: TestRequire (0.00s)
    require_test.go:34:
            Error Trace:
            Error:          Should be true
            Test:           TestRequire
            Messages:       1+1 == 2 should be true
FAIL
FAIL    command-line-arguments  0.012s
FAIL

我们看到当执行完第一条失败的断言后,测试便结束了!

这就是assert包和require包的区别!这有些类似于Errorf和Fatalf的区别!require包中断言函数一旦执行失败便会导致测试退出,后续的测试代码将无法继续执行。

另外require包还有一个“特点”,那就是它的主体代码(require.go和require_forward.go)都是自动生成的:

// github.com/stretchr/testify/require/reqire.go
/*
  CODE GENERATED AUTOMATICALLY WITH github.com/stretchr/testify/_codegen
* THIS FILE MUST NOT BE EDITED BY HAND
 */

testify的代码生成采用了基于模板的方法,具体的自动生成原理可以参考[《A case for Go code generation: testify》] (https://levelup.gitconnected.com/a-case-for-go-code-generation-testify-73a4b0d46cb1)这篇文章。

3. suite包

Go testing包没有引入testsuite(测试套件)或testcase(测试用例)的概念,只有Test和SubTest。对于熟悉xUnit那套测试组织方式的开发者来说,这种缺失很“别扭”!要么自己基于testing包来构建这种结构,要么使用第三方包的实现。


该图来自网络

testify的suite包为我们提供了一种基于suite/case结构组织测试代码的方式。下面是一个可以对testify suite定义的suite结构进行全面解析的示例(改编自testify suite包文档中的ExampleTestSuite示例):

// suite/suite_test.go

package main

import (
    "fmt"
    "testing"

    "github.com/stretchr/testify/suite"
)

type ExampleSuite struct {
    suite.Suite
    indent int
}

func (suite *ExampleSuite) indents() (result string) {
    for i := 0; i < suite.indent; i++ {
        result += "----"
    }
    return
}

func (suite *ExampleSuite) SetupSuite() {
    fmt.Println("Suite setup")
}

func (suite *ExampleSuite) TearDownSuite() {
    fmt.Println("Suite teardown")
}

func (suite *ExampleSuite) SetupTest() {
    suite.indent++
    fmt.Println(suite.indents(), "Test setup")
}

func (suite *ExampleSuite) TearDownTest() {
    fmt.Println(suite.indents(), "Test teardown")
    suite.indent--
}

func (suite *ExampleSuite) BeforeTest(suiteName, testName string) {
    suite.indent++
    fmt.Printf("%sBefore %s.%s\n", suite.indents(), suiteName, testName)
}

func (suite *ExampleSuite) AfterTest(suiteName, testName string) {
    fmt.Printf("%sAfter %s.%s\n", suite.indents(), suiteName, testName)
    suite.indent--
}

func (suite *ExampleSuite) SetupSubTest() {
    suite.indent++
    fmt.Println(suite.indents(), "SubTest setup")
}

func (suite *ExampleSuite) TearDownSubTest() {
    fmt.Println(suite.indents(), "SubTest teardown")
    suite.indent--
}

func (suite *ExampleSuite) TestCase1() {
    suite.indent++
    defer func() {
        fmt.Println(suite.indents(), "End TestCase1")
        suite.indent--
    }()

    fmt.Println(suite.indents(), "Begin TestCase1")

    suite.Run("case1-subtest1", func() {
        suite.indent++
        fmt.Println(suite.indents(), "Begin TestCase1.Subtest1")
        fmt.Println(suite.indents(), "End TestCase1.Subtest1")
        suite.indent--
    })
    suite.Run("case1-subtest2", func() {
        suite.indent++
        fmt.Println(suite.indents(), "Begin TestCase1.Subtest2")
        fmt.Println(suite.indents(), "End TestCase1.Subtest2")
        suite.indent--
    })
}

func (suite *ExampleSuite) TestCase2() {
    suite.indent++
    defer func() {
        fmt.Println(suite.indents(), "End TestCase2")
        suite.indent--
    }()
    fmt.Println(suite.indents(), "Begin TestCase2")

    suite.Run("case2-subtest1", func() {
        suite.indent++
        fmt.Println(suite.indents(), "Begin TestCase2.Subtest1")
        fmt.Println(suite.indents(), "End TestCase2.Subtest1")
        suite.indent--
    })
}

func TestExampleSuite(t *testing.T) {
    suite.Run(t, new(ExampleSuite))
}

要知道testify.suite包定义的测试结构是什么样的,我们运行一下上述代码即可:

$go test
Suite setup
---- Test setup
--------Before ExampleSuite.TestCase1
------------ Begin TestCase1
---------------- SubTest setup
-------------------- Begin TestCase1.Subtest1
-------------------- End TestCase1.Subtest1
---------------- SubTest teardown
---------------- SubTest setup
-------------------- Begin TestCase1.Subtest2
-------------------- End TestCase1.Subtest2
---------------- SubTest teardown
------------ End TestCase1
--------After ExampleSuite.TestCase1
---- Test teardown
---- Test setup
--------Before ExampleSuite.TestCase2
------------ Begin TestCase2
---------------- SubTest setup
-------------------- Begin TestCase2.Subtest1
-------------------- End TestCase2.Subtest1
---------------- SubTest teardown
------------ End TestCase2
--------After ExampleSuite.TestCase2
---- Test teardown
Suite teardown

信息量很大,我们慢慢说!

利用testify建立测试套件,我们需要自行定义嵌入了suite.Suite的结构体类型,如上面示例中的ExampleSuite。

testify与go testing兼容,由go test驱动执行,因此我们需要在一个TestXXX函数中创建ExampleSuite的实例,调用suite包的Run函数,并将执行权交给suite包的这个Run函数,后续的执行逻辑就是suite包Run函数的执行逻辑。在上述代码中,我们只定义了一个TestXXX,并使用suite.Run函数执行了ExampleSuite中的所有测试用例。

suite.Run函数的执行逻辑大致是:通过反射机制得到了*ExampleSuite类型的方法集合,并执行方法集合中名字以Test为前缀的所有方法。testify将用户自定义的XXXSuite类型中的每个以Test为前缀的方法当作是一个TestCase。

除了Suite和TestCase的概念外,testify.suite包还“预埋”了很多回调点,包括suite的Setup、TearDown;test case的Setup和TearDown、testcase的before和after;subtest的Setup和TearDown,这些回调点也由suite.Run函数来执行,回调点的执行顺序可以通过上面示例的执行结果看到。

注意:subtest要通过XXXSuite的Run方法执行,而不要通过标准库testing.T的Run方法执行。

我们知道:go test工具可以通过-run命令行参数来选择要执行的TestXXX函数,考虑到testify使用TestXXX函数拉起测试套件(XXXSuite),因此从testify视角来看,通过go test -run可以选择执行哪个XXXSuite,前提是一个TestXXX中仅初始化和运行一种XXXSuite的所有测试用例。

如果要选择XXXSuite的方法(即testify眼中的测试用例),我们不能用-run了,需要使用testify新增的-m命令行选项,下面是一个仅执行带有Case2关键字测试用例的示例:

$go test -testify.m Case2
Suite setup
---- Test setup
--------Before ExampleSuite.TestCase2
------------ Begin TestCase2
---------------- SubTest setup
-------------------- Begin TestCase2.Subtest1
-------------------- End TestCase2.Subtest1
---------------- SubTest teardown
------------ End TestCase2
--------After ExampleSuite.TestCase2
---- Test teardown
Suite teardown
PASS
ok      demo    0.014s

综上,如果你使用testify的Suite/Case概念来组织你的测试代码,建议在每个TestXXX中仅初始化和运行一个XXXSuite,这样你可以通过-run选择特定的Suite执行。

4. mock包

最后我们来看看testify为辅助Go开发人员编写测试代码而提供的一个高级特性:mock。

在之前的文章中,我提到过:尽量使用fake object,而不是mock object。mock这种测试替身有其难于理解、使用场合局限以及给予开发人员信心不足等弊端。

注:近期原Go官方维护的golang/mock也将维护权迁移给了uber,迁移后的新的mock库为go.uber.org/mock。我在《Go语言精进之路 vol2》一书中对golang/mock做过详细的使用介绍,有兴趣的朋友可以去读一读。

但“存在即合理”,显然mock也有它的用武空间,在社区也有它的拥趸,既然testify提供了mock包,这里就简单介绍一下它的基本使用方法。

我们用一个经典repo service的例子来演示如何使用testify mock,如下面代码示例:

// mock/mock_test.go

type User struct {
    ID   int
    Name string
    Age  int
}

type UserRepository interface {
    CreateUser(user *User) (int, error)
    GetUserById(id int) (*User, error)
}

type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) CreateUser(name string, age int) (*User, error) {
    user := &User{Name: name, Age: age}
    id, err := s.repo.CreateUser(user)
    if err != nil {
        return nil, err
    }
    user.ID = id
    return user, nil
}

func (s *UserService) GetUserById(id int) (*User, error) {
    return s.repo.GetUserById(id)
}

我们要提供一个UserService服务,通过该服务可以创建User,也可以通过ID获取User信息。服务的背后是一个UserRepository,你可以用任何方法实现UserRepository,为此我们将其抽象为一个接口UserRepository。UserService要依赖UserRepository才能让它的两个方法CreateUser和GetUserById正常工作。现在我们要测试UserService的这两个方法,但我们手里没有现成的UserRepository实现可用,我们也没有UserRepository的fake object。

这时我们仅能用mock。下面是使用testify mock给出的UserRepository接口的实现UserRepositoryMock:

// mock/mock_test.go

type UserRepositoryMock struct {
    mock.Mock
}

func (m *UserRepositoryMock) CreateUser(user *User) (int, error) {
    args := m.Called(user)
    return args.Int(0), args.Error(1)
}

func (m *UserRepositoryMock) GetUserById(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

我们基于mock.Mock创建一个新结构体类型UserRepositoryMock,这就是我们要创建的模拟UserRepository。我们实现了它的两个方法,与正常方法实现不同的是,在方法中我们使用的是mock.Mock提供的方法Called以及它的返回值来满足CreateUser和GetUserById两个方法的参数与返回值要求。

UserRepositoryMock这两个方法的实现是比较“模式化”的,其中调用的Called接收了外部方法的所有参数,然后通过Called的返回值args来构造满足外部方法的返回值。返回值构造的书写格式如下:

args.<ReturnValueType>(<index>) // 其中index从0开始

以CreateUser为例,它有两个返回值int和error,那按照上面的书写格式,我们的返回值就应该为:args.int(0)和args.Error(1)。

对于复杂结构的返回值类型T,可使用断言方式,书写格式变为:

args.Get(index).(T)

再以构造GetUserById的返回值*User和error为例,我们按照复杂返回值构造的书写格式来编写,返回值就应该为args.Get(0).(*User)和args.Error(1)。

有了Mock后的UserRepository,我们就可以来编写UserService的方法的测试用例了:

// mock/mock_test.go

func TestUserService_CreateUser(t *testing.T) {
    repo := new(UserRepositoryMock)
    service := NewUserService(repo)

    user := &User{Name: "Alice", Age: 30}
    repo.On("CreateUser", user).Return(1, nil)

    createdUser, err := service.CreateUser(user.Name, user.Age)

    assert.NoError(t, err)
    assert.Equal(t, 1, createdUser.ID)
    assert.Equal(t, "Alice", createdUser.Name)
    assert.Equal(t, 30, createdUser.Age)

    repo.AssertExpectations(t)
}

func TestUserService_GetUserById(t *testing.T) {
    repo := new(UserRepositoryMock)
    service := NewUserService(repo)

    user := &User{ID: 1, Name: "Alice", Age: 30}
    repo.On("GetUserById", 1).Return(user, nil)

    foundUser, err := service.GetUserById(1)

    assert.NoError(t, err)
    assert.Equal(t, 1, foundUser.ID)
    assert.Equal(t, "Alice", foundUser.Name)
    assert.Equal(t, 30, foundUser.Age)

    repo.AssertExpectations(t)
}

这两个TestXXX函数的编写模式也十分相近,以TestUserService_GetUserById为例,它先创建了UserRepositoryMock和UserService的实例,然后利用UserRepositoryMock来设置即将被调用的GetUserById方法的输入参数与返回值:

user := &User{ID: 1, Name: "Alice", Age: 30}
repo.On("GetUserById", 1).Return(user, nil)

这样当GetUserById在service.GetUserById方法中被调用时,它返回的就是上面设置的user地址值和nil。

之后,我们像常规测试用例那样,用assert包对返回的值与预期值做断言即可。

5. 小结

在本文中,我们讲解了testify这个第三方辅助测试包的结构,并针对其中的assert/require、suite和mock这几个相对独立的Go包的用法做了重点说明。

assert/require包是功能十分全面的测试断言包,即便你不使用suite、mock,你也可以单独使用assert/require包来减少你的测试代码中if != xxx的书写行数。

suite包则为我们提供了一个类xUnit的Suite/Case的测试代码组织形式的实现方案,并且这种方案与go testing包兼容,由go test驱动。

虽然我不建议用mock,但testify mock也实现了mock机制的基本功能。并且文中没有提及的是,结合mockery工具和testify mock,我们可以针对接口为被测目标自动生成testify的mock部分代码,这会大大提交mock test的编写效率。

综上来看,testify这个项目的确非常有用,可以很好的辅助Go开发者高效的编写和组织测试用例。目前testify正在策划dev v2版本 ,相信不久将来落地的v2版本能给Go开发者带来更多的帮助。

本文涉及到的源码可以在这里下载。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go语言开发者的Apache Arrow使用指南:数据操作

本文永久链接 – https://tonybai.com/2023/07/13/a-guide-of-using-apache-arrow-for-gopher-part4

在前面的Arrow系列文章中,我们介绍了Arrow的基础数据类型以及高级数据类型,这让我们具备了在内存中建立起一个immutable数据集的能力。但这并非我们的目标,我们最终是要对建立起来的数据集进行查询和分析等操作(manipulation)的。

在这一篇文章中,我们就来看看如何基于Go arrow的实现对内存中的Arrow数据集进行操作。

注:由于Arrow官方文档尚没有Go语言的cookbook,这里的一些例子参考了其他语言的Cookbook,比如Python

1. 从CSV文件中读取数据

在操作数据之前,我们首先需要准备数据,并将数据读取到内存中以Arrow的列式存储形式组织起来。Arrow的Go实现支持从多种文件格式中将数据读取出来并在内存中构建出Arrow列式数据集:

从图中我们看到:Arrow Go支持读取的文件格式包括CSV、JSON和Parquet等。CSV、JSON都是日常最常用的文件格式,那么Parquet是什么呢?这是一种面向列的文件存储格式,支持高效的数据存储和数据获取能力。influxdb iox存储引擎采用的就是Apache Arrow + Parquet的经典组合。我们在本系列的后续文章中会单独说一下Arrow + Parquet,在本文中Parquet不是重点。

注:Parquet的读音是:[’pɑːkeɪ] 。

在这篇文章中,我们以从CSV文件中读取数据为例。我们的CSV文件来自于Kaggle平台上的开放数据集,这是一份记录着Delhi这个地方(应该是印度城市德里)1996年到2017年小时级的天气数据的CSV文件:testset.csv。该文件带有列头,有20列,10w多行记录。

我们先来小试牛刀,即取该csv文件前10几行,存成名为testset.tiny.csv的文件。我们编写一段Go程序来读取CSV中的数据并在内存中建立一个Arrow Record Batch!大家还记得Arrow Record Batch是什么结构了么?我们回顾一下“高级数据结构”中的那张图你就记起来了:

接下来我们就使用Arrow Go实现提供的csv包读取testset.tiny.csv文件并输出经由读出的数据建构的Record Batch:

// read_tiny_csv_multi_trunks.go

package main

import (
    "fmt"
    "io"
    "os"

    "github.com/apache/arrow/go/v13/arrow/csv"
)

func read(data io.ReadCloser) error {
    // read 5 lines at a time to create record batches
    rdr := csv.NewInferringReader(data, csv.WithChunk(5),
        // strings can be null, and these are the values
        // to consider as null
        csv.WithNullReader(true, "", "null", "[]"),
        // assume the first line is a header line which names the columns
        csv.WithHeader(true))

    for rdr.Next() {
        rec := rdr.Record()
        fmt.Println(rec)
    }

    return nil
}

func main() {
    data, err := os.Open("./testset.tiny.csv")
    if err != nil {
        panic(err)
    }
    read(data)
}

这里的csv包可不是标准库中的那个包,而是Arrow Go实现中专门用于将csv文件数据读取并转换为Arrow内存对象的包。csv包提供了两个创建csv.Reader实例的函数,这里使用的是NewInferringReader(即带列类型推导的Reader)。该函数可以自动读取位于第一行的csv文件的header,获取列字段的名称与个数,形成Record的schema,并在读取下一条记录时尝试推导(infer)这一列的类型(data type)。

这里在调用NewInferringReader时还传入了一个功能选项开关WithChunk(5),即一次读取5条记录来构建一个新的Record Batch。

我们运行一下上面的代码:

$go run read_tiny_csv_multi_trunks.go
record:
  schema:
  fields: 20
    - datetime_utc: type=utf8, nullable
    -  _conds: type=utf8, nullable
    -  _dewptm: type=int64, nullable
    -  _fog: type=int64, nullable
    -  _hail: type=int64, nullable
    -  _heatindexm: type=utf8, nullable
    -  _hum: type=int64, nullable
    -  _precipm: type=utf8, nullable
    -  _pressurem: type=int64, nullable
    -  _rain: type=int64, nullable
    -  _snow: type=int64, nullable
    -  _tempm: type=int64, nullable
    -  _thunder: type=int64, nullable
    -  _tornado: type=int64, nullable
    -  _vism: type=int64, nullable
    -  _wdird: type=int64, nullable
    -  _wdire: type=utf8, nullable
    -  _wgustm: type=utf8, nullable
    -  _windchillm: type=utf8, nullable
    -  _wspdm: type=float64, nullable
  rows: 5
  col[0][datetime_utc]: ["19961101-11:00" "19961101-12:00" "19961101-13:00" "19961101-14:00" "19961101-16:00"]
  col[1][ _conds]: ["Smoke" "Smoke" "Smoke" "Smoke" "Smoke"]
  col[2][ _dewptm]: [9 10 11 10 11]
  col[3][ _fog]: [0 0 0 0 0]
  col[4][ _hail]: [0 0 0 0 0]
  col[5][ _heatindexm]: [(null) (null) (null) (null) (null)]
  col[6][ _hum]: [27 32 44 41 47]
  col[7][ _precipm]: [(null) (null) (null) (null) (null)]
  col[8][ _pressurem]: [1010 -9999 -9999 1010 1011]
  col[9][ _rain]: [0 0 0 0 0]
  col[10][ _snow]: [0 0 0 0 0]
  col[11][ _tempm]: [30 28 24 24 23]
  col[12][ _thunder]: [0 0 0 0 0]
  col[13][ _tornado]: [0 0 0 0 0]
  col[14][ _vism]: [5 (null) (null) 2 (null)]
  col[15][ _wdird]: [280 0 0 0 0]
  col[16][ _wdire]: ["West" "North" "North" "North" "North"]
  col[17][ _wgustm]: [(null) (null) (null) (null) (null)]
  col[18][ _windchillm]: [(null) (null) (null) (null) (null)]
  col[19][ _wspdm]: [7.4 (null) (null) (null) 0]

我们看到结果输出了将csv文件中数据读取并转换后的Record Batch的信息!

不过这个结果有一个问题,那就是我们的testset.tiny.csv有12行数据,上述结果为什么仅读出了5行呢?利用go.work引用本地下载的arrow代码做一下print调试后发现这样的一个错误:

strconv.ParseInt: parsing "1.2": invalid syntax

翻看一下testset.tiny.csv文件,在第五行发现了包含1.2的这条数据:

19961101-16:00,Smoke,11,0,0,,47,,1011,0,0,23,0,0,1.2,0,North,,,0

1.2这数据对应的是” _vism”这一列,我们看一下上面这一列的schema信息:

-  _vism: type=int64, nullable

我们看到NewInferringReader将这一列识别成int64类型了!NewInferringReader是根据第一行数据中来做类型推导的,而vism这一列的第一条数据恰为5,将其推导为int64也就不足为奇了。那么如何修正上述问题呢?NewInferringReader提供了一个WithColumnTypes的功能选项,通过它我们可以指定vism列的Arrow DataType:

    rdr := csv.NewInferringReader(data, csv.WithChunk(5),
        // strings can be null, and these are the values
        // to consider as null
        csv.WithNullReader(true, "", "null", "[]"),
        // assume the first line is a header line which names the columns
        csv.WithHeader(true),
        csv.WithColumnTypes(map[string]arrow.DataType{
            " _vism": arrow.PrimitiveTypes.Float64,
        }),
    )

修改后,我们再来运行一下read_tiny_csv_multi_trunks.go这个文件的代码:

$go run read_tiny_csv_multi_trunks.go
record:
  schema:
  fields: 20
    - datetime_utc: type=utf8, nullable
    -  _conds: type=utf8, nullable
    -  _dewptm: type=int64, nullable
    -  _fog: type=int64, nullable
    -  _hail: type=int64, nullable
    -  _heatindexm: type=utf8, nullable
    -  _hum: type=int64, nullable
    -  _precipm: type=utf8, nullable
    -  _pressurem: type=int64, nullable
    -  _rain: type=int64, nullable
    -  _snow: type=int64, nullable
    -  _tempm: type=int64, nullable
    -  _thunder: type=int64, nullable
    -  _tornado: type=int64, nullable
    -  _vism: type=float64, nullable
    -  _wdird: type=int64, nullable
    -  _wdire: type=utf8, nullable
    -  _wgustm: type=utf8, nullable
    -  _windchillm: type=utf8, nullable
    -  _wspdm: type=float64, nullable
  rows: 5
  col[0][datetime_utc]: ["19961101-11:00" "19961101-12:00" "19961101-13:00" "19961101-14:00" "19961101-16:00"]
  col[1][ _conds]: ["Smoke" "Smoke" "Smoke" "Smoke" "Smoke"]
  col[2][ _dewptm]: [9 10 11 10 11]
  col[3][ _fog]: [0 0 0 0 0]
  col[4][ _hail]: [0 0 0 0 0]
  col[5][ _heatindexm]: [(null) (null) (null) (null) (null)]
  col[6][ _hum]: [27 32 44 41 47]
  col[7][ _precipm]: [(null) (null) (null) (null) (null)]
  col[8][ _pressurem]: [1010 -9999 -9999 1010 1011]
  col[9][ _rain]: [0 0 0 0 0]
  col[10][ _snow]: [0 0 0 0 0]
  col[11][ _tempm]: [30 28 24 24 23]
  col[12][ _thunder]: [0 0 0 0 0]
  col[13][ _tornado]: [0 0 0 0 0]
  col[14][ _vism]: [5 (null) (null) 2 1.2]
  col[15][ _wdird]: [280 0 0 0 0]
  col[16][ _wdire]: ["West" "North" "North" "North" "North"]
  col[17][ _wgustm]: [(null) (null) (null) (null) (null)]
  col[18][ _windchillm]: [(null) (null) (null) (null) (null)]
  col[19][ _wspdm]: [7.4 (null) (null) (null) 0]

record:
  schema:
  fields: 20
    - datetime_utc: type=utf8, nullable
    -  _conds: type=utf8, nullable
    -  _dewptm: type=int64, nullable
    -  _fog: type=int64, nullable
    -  _hail: type=int64, nullable
    -  _heatindexm: type=utf8, nullable
    -  _hum: type=int64, nullable
    -  _precipm: type=utf8, nullable
    -  _pressurem: type=int64, nullable
    -  _rain: type=int64, nullable
    -  _snow: type=int64, nullable
    -  _tempm: type=int64, nullable
    -  _thunder: type=int64, nullable
    -  _tornado: type=int64, nullable
    -  _vism: type=float64, nullable
    -  _wdird: type=int64, nullable
    -  _wdire: type=utf8, nullable
    -  _wgustm: type=utf8, nullable
    -  _windchillm: type=utf8, nullable
    -  _wspdm: type=float64, nullable
  rows: 5
  col[0][datetime_utc]: ["19961101-17:00" "19961101-18:00" "19961101-19:00" "19961101-20:00" "19961101-21:00"]
  col[1][ _conds]: ["Smoke" "Smoke" "Smoke" "Smoke" "Smoke"]
  col[2][ _dewptm]: [12 13 13 13 13]
  col[3][ _fog]: [0 0 0 0 0]
  col[4][ _hail]: [0 0 0 0 0]
  col[5][ _heatindexm]: [(null) (null) (null) (null) (null)]
  col[6][ _hum]: [56 60 60 68 68]
  col[7][ _precipm]: [(null) (null) (null) (null) (null)]
  col[8][ _pressurem]: [1011 1010 -9999 -9999 1010]
  col[9][ _rain]: [0 0 0 0 0]
  col[10][ _snow]: [0 0 0 0 0]
  col[11][ _tempm]: [21 21 21 19 19]
  col[12][ _thunder]: [0 0 0 0 0]
  col[13][ _tornado]: [0 0 0 0 0]
  col[14][ _vism]: [(null) 0.8 (null) (null) (null)]
  col[15][ _wdird]: [0 0 0 0 0]
  col[16][ _wdire]: ["North" "North" "North" "North" "North"]
  col[17][ _wgustm]: [(null) (null) (null) (null) (null)]
  col[18][ _windchillm]: [(null) (null) (null) (null) (null)]
  col[19][ _wspdm]: [(null) 0 (null) (null) (null)]

record:
  schema:
  fields: 20
    - datetime_utc: type=utf8, nullable
    -  _conds: type=utf8, nullable
    -  _dewptm: type=int64, nullable
    -  _fog: type=int64, nullable
    -  _hail: type=int64, nullable
    -  _heatindexm: type=utf8, nullable
    -  _hum: type=int64, nullable
    -  _precipm: type=utf8, nullable
    -  _pressurem: type=int64, nullable
    -  _rain: type=int64, nullable
    -  _snow: type=int64, nullable
    -  _tempm: type=int64, nullable
    -  _thunder: type=int64, nullable
    -  _tornado: type=int64, nullable
    -  _vism: type=float64, nullable
    -  _wdird: type=int64, nullable
    -  _wdire: type=utf8, nullable
    -  _wgustm: type=utf8, nullable
    -  _windchillm: type=utf8, nullable
    -  _wspdm: type=float64, nullable
  rows: 2
  col[0][datetime_utc]: ["19961101-22:00" "19961101-23:00"]
  col[1][ _conds]: ["Smoke" "Smoke"]
  col[2][ _dewptm]: [13 12]
  col[3][ _fog]: [0 0]
  col[4][ _hail]: [0 0]
  col[5][ _heatindexm]: [(null) (null)]
  col[6][ _hum]: [68 64]
  col[7][ _precipm]: [(null) (null)]
  col[8][ _pressurem]: [1009 1009]
  col[9][ _rain]: [0 0]
  col[10][ _snow]: [0 0]
  col[11][ _tempm]: [19 19]
  col[12][ _thunder]: [0 0]
  col[13][ _tornado]: [0 0]
  col[14][ _vism]: [(null) (null)]
  col[15][ _wdird]: [0 0]
  col[16][ _wdire]: ["North" "North"]
  col[17][ _wgustm]: [(null) (null)]
  col[18][ _windchillm]: [(null) (null)]
  col[19][ _wspdm]: [(null) (null)]

这次12行数据都被成功读取出来了!

接下来,我们再来读取一下完整数据集testset.csv,我们通过输出读取的数据集行数来判断一下读取是否完全成功:

// read_csv_rows_count.go

func read(data io.ReadCloser) error {
    var total int64
    // read 10000 lines at a time to create record batches
    rdr := csv.NewInferringReader(data, csv.WithChunk(10000),
        // strings can be null, and these are the values
        // to consider as null
        csv.WithNullReader(true, "", "null", "[]"),
        // assume the first line is a header line which names the columns
        csv.WithHeader(true),
        csv.WithColumnTypes(map[string]arrow.DataType{
            " _vism": arrow.PrimitiveTypes.Float64,
        }),
    )

    for rdr.Next() {
        rec := rdr.Record()
        total += rec.NumRows()
    }

    fmt.Println("total columns =", total)
    return nil
}

我们开着错误输出的调试语句,看看上面的代码的输出结果:

======nextn: strconv.ParseInt: parsing "N/A": invalid syntax
total columns = 10000

我们看到上述程序仅读取了1w条记录,并输出了一个错误信息:CSV文件中包含“N/A”字样的数据,导致CSV Reader读取失败。经过数据对比核查,发现hum的数据存在大量“N/A”,另外pressurem的类型也有问题。那么如何解决这个问题呢?NewInferringReader提供了WithIncludeColumns功能选项可以供我们提供我们想要的列,这样我们可以给出一个列白名单,将hum列排除在外。修改后的read代码如下:

// read_csv_rows_count_with_col_filter.go

func read(data io.ReadCloser) error {
    var total int64
    // read 10000 lines at a time to create record batches
    rdr := csv.NewInferringReader(data, csv.WithChunk(10000),
        // strings can be null, and these are the values
        // to consider as null
        csv.WithNullReader(true, "", "null", "[]"),
        // assume the first line is a header line which names the columns
        csv.WithHeader(true),
        csv.WithColumnTypes(map[string]arrow.DataType{
            " _pressurem": arrow.PrimitiveTypes.Float64,
        }),
        csv.WithIncludeColumns([]string{
            "datetime_utc", // 19961101-11:00
            " _conds",      // Smoke、Haze
            " _fog",        // 0
            " _heatindexm",
            " _pressurem", //
            " _rain",      //
            " _snow",      //
            " _tempm",     //
            " _thunder",   //
            " _tornado",   //
        }),
    )   

    for rdr.Next() {
        rec := rdr.Record()
        total += rec.NumRows()
    }   

    fmt.Println("total columns =", total)
    return nil
}

运行修改后的代码:

$go run read_csv_rows_count_with_col_filter.go
total columns = 100990

我们顺利将CSV中的数据读到了内存中,并组织成了多个Record Batch。

2. Arrow compute API介绍

一旦内存中有了Arrow格式的数据后,我们就可以基于这份数据进行数据操作了,比如过滤、查询、计算、转换等等。那么是否需要开发人员自己根据对Arrow type的结构的理解来实现针对这些数据操作的算法呢?不用的

Arrow社区提供了compute API以及各种语言的高性能实现以供基于Arrow格式进行数据操作的开发人员直接复用。

Go Arrow实现也提供了compute包用于操作内存中的Arrow object。不过根据compute包的注释来看,目前Go compute包还属于实验性质,并非stable的API,将来可能有变:

// The overwhemling majority of things in this package require go1.18 as
// it utilizes generics. The files in this package and its sub-packages
// are all excluded from being built by go versions lower than 1.18 so
// that the larger Arrow module itself is still compatible with go1.17.
//
// Everything in this package should be considered Experimental for now.
package compute

另外我们从上面注释也可以看到,compute包用到了泛型,因此需要Go 1.18及以后版本才能使用。

为了更好地理解compute API,我们需要知道一些有关compute的概念,首先了解一下Datum。

2.1 Datum

compute API中的函数需要支持多种类型数据作为输入,可以是arrow的array type,也可以是一个标量值(scalar),为了统一输出表示,compute API建立了一个名为Datum的抽象。Datum可以理解为一个compute API函数可以接受的各种arrow类型的union类型,union中既可以是一个scalar(标量值),也可以是Array、Chunked Array,甚至是一整个Record Batch或一个Arrow Table。

不出预料,Go中采用接口来建立Datum这个抽象:

// Datum is a variant interface for wrapping the various Arrow data structures
// for now the various Datum types just hold a Value which is the type they
// are wrapping, but it might make sense in the future for those types
// to actually be aliases or embed their types instead. Not sure yet.
type Datum interface {
    fmt.Stringer
    Kind() DatumKind
    Len() int64
    Equals(Datum) bool
    Release()

    data() any
}

Datum支持的类型通过DatumKind的常量枚举值可以看出:

// DatumKind is an enum used for denoting which kind of type a datum is encapsulating
type DatumKind int

const (
    KindNone    DatumKind = iota // none
    KindScalar                   // scalar
    KindArray                    // array
    KindChunked                  // chunked_array
    KindRecord                   // record_batch
    KindTable                    // table
)

2.2 Function Type

compute包提供的是协助数据操作和分析的函数,这些函数可以被分为几类,我们由简单到复制的顺序逐一看一下:

2.2.1 标量(scalar)函数或逐元素(element-wise)函数

这类函数接受一个scalar参数或一个array类型的datum参数,函数会对输入参数中的逐个元素进行操作,比如求反、求绝对值等。如果传入的是scalar,则返回scalar,如果传入的是array类型,则返回array类型。传入和返回的array长度应相同。

下图(来自《In-Memory Analytics with Apache Arrow》一书)直观地解释了这类函数的操作特性:

我们用go代码实现一下上图中的两个示例,先来看unary element-wise操作的例子:

// unary_elementwise_function.go

func main() {
    data := []int32{5, 10, 0, 25, 2}
    bldr := array.NewInt32Builder(memory.DefaultAllocator)
    defer bldr.Release()
    bldr.AppendValues(data, nil)
    arr := bldr.NewArray()
    defer arr.Release()

    dat, err := compute.Negate(context.Background(), compute.ArithmeticOptions{}, compute.NewDatum(arr))
    if err != nil {
        fmt.Println(err)
        return
    }

    arr1, ok := dat.(*compute.ArrayDatum)
    if !ok {
        fmt.Println("type assert fail")
        return
    }
    fmt.Println(arr1.MakeArray()) // [-5 -10 0 -25 -2]
}

compute包实现了常见的一元和二元arithmetic函数:

下面是二元Add操作的示例:

// binary_elementwise_function.go

func main() {
    data1 := []int32{5, 10, 0, 25, 2}
    data2 := []int32{1, 5, 2, 10, 5}
    scalarData1 := int32(6)

    bldr := array.NewInt32Builder(memory.DefaultAllocator)
    defer bldr.Release()
    bldr.AppendValues(data1, nil)
    arr1 := bldr.NewArray()
    defer arr1.Release()

    bldr.AppendValues(data2, nil)
    arr2 := bldr.NewArray()
    defer arr2.Release()

    result1, err := compute.Add(context.Background(), compute.ArithmeticOptions{},
        compute.NewDatum(arr1),
        compute.NewDatum(arr2))
    if err != nil {
        fmt.Println(err)
        return
    }

    result2, err := compute.Add(context.Background(), compute.ArithmeticOptions{},
        compute.NewDatum(arr1),
        compute.NewDatum(scalarData1))
    if err != nil {
        fmt.Println(err)
        return
    }

    resultArr1, ok := result1.(*compute.ArrayDatum)
    if !ok {
        fmt.Println("type assert fail")
        return
    }
    fmt.Println(resultArr1.MakeArray()) // [6 15 2 35 7]

    resultArr2, ok := result2.(*compute.ArrayDatum)
    if !ok {
        fmt.Println("type assert fail")
        return
    }
    fmt.Println(resultArr2.MakeArray()) // [11 16 6 31 8]
}

在这个示例里,我们实现了array + array和array + scalar两个操作,两个加法操作的结果都是一个新array。

接下来我们来看

2.2.2 array-wise(逐array)函数

这一类的函数使用整个array进行操作,经常进行转换或输出与输入array不同长度的结果。下图(来自《In-Memory Analytics with Apache Arrow》一书)直观地解释了这类函数的操作特性:

Go compute包没有提供sort_unique函数,这里用Unique模拟一个unary array-wise操作,代码如下:

// unary_arraywise_function.go

func main() {
    data := []int32{5, 10, 0, 25, 2, 10, 2, 25}
    bldr := array.NewInt32Builder(memory.DefaultAllocator)
    defer bldr.Release()
    bldr.AppendValues(data, nil)
    arr := bldr.NewArray()
    defer arr.Release()

    dat, err := compute.Unique(context.Background(), compute.NewDatum(arr))
    if err != nil {
        fmt.Println(err)
        return
    }

    arr1, ok := dat.(*compute.ArrayDatum)
    if !ok {
        fmt.Println("type assert fail")
        return
    }
    fmt.Println(arr1.MakeArray()) // [5 10 0 25 2]
}

而上图中的二元array-wise Filter操作可以由下面代码实现:

// binary_arraywise_function.go

func main() {
    data := []int32{5, 10, 0, 25, 2}
    filterMask := []bool{true, false, true, false, true}

    bldr := array.NewInt32Builder(memory.DefaultAllocator)
    defer bldr.Release()
    bldr.AppendValues(data, nil)
    arr := bldr.NewArray()
    defer arr.Release()

    bldr1 := array.NewBooleanBuilder(memory.DefaultAllocator)
    defer bldr1.Release()
    bldr1.AppendValues(filterMask, nil)
    filterArr := bldr1.NewArray()
    defer filterArr.Release()

    dat, err := compute.Filter(context.Background(), compute.NewDatum(arr),
        compute.NewDatum(filterArr),
        compute.FilterOptions{})
    if err != nil {
        fmt.Println(err)
        return
    }

    arr1, ok := dat.(*compute.ArrayDatum)
    if !ok {
        fmt.Println("type assert fail")
        return
    }
    fmt.Println(arr1.MakeArray()) // [5 0 2]
}

注意:compute.Filter函数要求传入的value datum和filter datum的底层array长度要相同。

2.2.3 聚合(Aggregation)函数

Arrow compute支持两类聚合函数,一类是标量聚合(scalar aggregation),它的操作对象为一个array或一个标量,计算后输出一个标量值,常见的例子包括:count、min、max、mean、avg、sum等聚合计算;另外一类则是分组聚合(grouped aggregation),即先按某些“key”列进行分组后,再分别聚合,有些类似SQL中的group by操作。下图(来自《In-Memory Analytics with Apache Arrow》一书)直观地解释了这两类函数的操作特性:

不过遗憾的是Go尚未提供对这类聚合函数的直接支持

要想实现上述十分有用的聚合数据操作,在官方尚未提供支持之前,我们可以考虑自行扩展compute包。

注:相对完整的标量聚合和分组聚合的函数列表,可以参考C++版本的API ref

3. 小结

鉴于本篇篇幅以及Go对聚合函数的尚未支持,计划中对Delhi CSV文件的聚合分析只能留到后面系列文章了。

简单回顾一下本文内容。我们介绍了Go Arrow实现从CSV文件读取数据的方法以及一些技巧,然后我们介绍了Arrow除了其format之外的一个重点内容:compute API,这为基于arrow的array数据进行数据操作提供了开箱即用和高性能的API,大家要理解其中Datum的抽象概念,以及各类Function的操作对象和返回结果类型。

注:本文涉及的源代码在这里可以下载。

4. 参考资料

  • Make Data Files Easier to Work With Using Golang and Apache Arrow – https://voltrondata.com/resources/make-data-files-easier-to-work-with-golang-arrow
  • 《In-Memory Analytics with Apache Arrow》- https://book.douban.com/subject/35954154/
  • C++ compute API – https://arrow.apache.org/docs/cpp/compute.html
  • C++ Acero高级API – https://arrow.apache.org/docs/cpp/streaming_execution.html

“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats