标签 API 下的文章

Go 零拷贝“最后一公里”:Peek API背后的设计哲学与权衡

本文永久链接 – https://tonybai.com/2025/10/10/proposal-add-buffer-peek

大家好,我是Tony Bai。

在 Go 的世界里,io.Reader 是一个神圣的接口。它如同一条设计精良、四通八达的高速公路,为数据流的传输提供了统一、优雅的抽象。然而,在这条高速公路的尽头,当数据流的目的地就在眼前——一块已然存在的内存([]byte)时,我们却常常被迫驶下一条颠簸、缓慢的“土路”,进行一次本可避免的内存拷贝。

这个从 []byte 到 io.Reader 再回到 []byte 的性能损耗,正是 Go io 体系中长期存在的“最后一公里”问题。

近期,一个看似微小却意义深远的提案(#73794: bytes: add Buffer.Peek)被社区纳入提案委员会的考察范围(Active),它标志着 Go 团队为铺平这条“最后一公里”迈出了务实而关键的一步。这背后,是一场长达数年、关于性能、抽象与设计哲学的深度思辨。

“最后一公里”的痛点:当 io.Reader 遭遇 []byte

问题的根源,正如开发者 Ted Unangst 在其广为流传的文章《Too Much Go Misdirection》中所抱怨的那样:

“我手里明明已经有了一份完整的 []byte 数据,但许多标准库函数(如 image.Decode)却只接受一个 io.Reader 接口。为了满足这个接口,我不得不将 []byte 包装成一个 bytes.Reader。结果,本应可以零拷贝完成的操作,却因为这层“中间商”,被迫进行了一次代价高昂的内存拷贝。”

image.Decode 的工作机制完美地暴露了这个问题:为了确定图片格式,它需要“窥探”(peek) 数据流的头部几个字节。如果传入的 io.Reader 没有 Peek 方法,image.Decode 就会用 bufio.NewReader 将其包裹起来,这个过程必然涉及数据的拷贝

不幸的是,bytes.Reader 和 bytes.Buffer 这两个最常用的、基于内存的 io.Reader 实现,长期以来都缺少一个 Peek 方法。这使得无数 Gopher 的“零拷贝之梦”在这“最后一公里”上戛然而止,甚至催生了使用 unsafe 包来“强行”获取底层字节切片的黑魔法,只为绕开这层不必要的抽象。

科普角:io 体系中的“窥探”艺术

在深入探讨提案之前,让我们先厘清几个核心的 io 操作概念,它们是铺平“最后一公里”所需的关键工具:

  • Read(p []byte): 这是 io.Reader 的核心。它从数据源读取数据并填充到调用者提供的 p 切片中,同时消耗掉源头的数据。
  • Peek(n int): “窥探”。它返回接下来的 n 个字节,但不消耗它们。下一次 Read 操作依然能读到这些字节。这对于需要根据数据头部信息来决定下一步操作的解析器(如 image.Decode)至关重要。
  • Discard(n int): “丢弃”。它直接消耗掉接下来的 n 个字节,但不把它们复制到任何地方。这通常与 Peek 配合使用:先 Peek 数据进行分析,然后 Discard 掉已经分析过的部分。

Peek + Discard 的组合,是实现高性能、零拷贝流式处理的关键。

第一次尝试:宏大的 io.ReadPeeker 接口(#63548)

社区为铺平“最后一公里”的第一次尝试是宏大的、雄心勃勃的。提案 #63548 建议在 io 包中定义一个全新的标准接口:

type ReadPeeker interface {
    io.Reader
    Peek(n int) ([]byte, error)
}

其目标是为所有支持“窥探”的 io.Reader 提供一个统一的、可供类型断言的契约,从而在标准库层面建立起“零拷贝读取”的通用范式。

然而,这个看似完美的“高速公路”方案,却在深入讨论中陷入了泥潭。Go 核心团队,包括 Russ Cox (rsc),提出了一系列极其棘手的现实问题:

  • 缓冲区的模糊性:Peek(n) 时,如果内部缓冲区不足 n 字节,应该怎么做?是返回一个短读取,还是尝试从底层 Reader 读取更多数据?
  • 错误的定义:如果 n 太大,超出了缓冲区的最大容量,应该返回什么错误?ErrBufferFull 的定义和行为该如何统一?Russ Cox 尖锐地指出:“如果一个实现只能 Peek 2 个字节,但你需要 1536 个字节,会发生什么?这似乎让客户端代码总是需要包裹一层 fallback 逻辑,非常笨拙。”
  • API 的完备性:是否还需要一个 Buffered() 方法来告知调用者可以安全 Peek 的最大字节数?但 bufio.Reader 的 Buffered() 并非 Peek 的上限,这又引入了新的不一致。

由于无法就这些细节达成一个足够简单、清晰且无歧义的共识,rsc 最终以“这感觉还没有找到正确的路径”(This all seems not quite there yet) 为由,最终将这个宏大的提案标记为[decline]。这次“失败”深刻地揭示了 Go 团队的设计原则:宁缺毋滥。一个不够完美的标准接口,比没有这个接口更糟糕。

第二次尝试:务实的 bytes.Buffer.Peek(#73794)

在宏大的方案搁浅后,社区回归了更务实的思考。提案 #73794 不再追求修建一条完美的“超级高速公路”,而是聚焦于修复那条最常用、最拥堵的“最后一公里”路段:让 bytes.Buffer 支持 Peek

// 提案的核心:为 bytes.Buffer 增加一个 Peek 方法
func (b *Buffer) Peek(n int) ([]byte, error)

这个提案的讨论过程要顺利得多,但也并非没有争议。其中最核心的权衡和63548提案其实是一样的,都聚焦于安全性与一致性

  • 反对者的声音:bytes.Reader 的一个隐性优点是其内容的“事实不可变性”。一旦为其添加 Peek,就会暴露其底层 []byte,一个“淘气的用户”可能会修改这个切片,从而破坏 Reader 的状态。这不仅带来了安全隐患,也使得 bytes.Reader 与完全不可变的 strings.Reader 在 API 设计上出现了不对称。
  • 支持者的反驳:社区很快指出,这种“事实不可变性”早已被打破。通过 bytes.Reader.WriteTo 方法和一个特制的 io.Writer,已经可以在不使用 unsafe 的情况下获取并修改其底层切片。因此,增加 Peek 并非引入新的风险,只是将一个隐晦的“后门”变成了一个明确的、有用的 API。

最终,务实主义战胜了理论上的纯粹性。Go 团队认为,为这个极其常见的用例提供便利,其收益远大于它所带来的、本就存在的微小风险。这个小而美的提案最终得到了提案委员会的青睐。

小结:对我们日常开发者的启示

bytes.Buffer.Peek 的诞生故事,是理解 Go 语言设计哲学的一面绝佳棱镜。它告诉我们,Go 的世界里,优雅的抽象是准则,但务实的性能是现实。对于我们日常的 API 设计而言,这个故事同样富有启发:

  1. 考虑提供双重 API:在针对“too much go misdirection”一文的Hacker News 的讨论中,一个被反复提及的观点是,一个好的 API 应该同时接受 []byte 和 io.Reader。标准库的 encoding/json 就是这样做的。这允许用户在拥有完整数据时选择最高效的路径,在处理流数据时选择最具弹性的路径。

  2. 编写“窥探感知”的函数:当你设计的函数接受 io.Reader 时,可以借鉴 image.Decode 的模式:首先通过类型断言检查传入的 Reader 是否已经实现了 Peeker 接口。如果是,就直接使用其高性能的 Peek 方法;如果不是,再用 bufio.NewReader 将其包裹起来作为 fallback。

  3. 理解“特殊优待”是 Go 的一部分:Go 标准库充满了对特定类型(如 *bytes.Buffer, *bytes.Reader, *strings.Reader)的“特殊优待”。例如,http.Client 在处理请求体时,会检查 body 是否是这几种类型,以便获取 Content-Length 或实现请求重试。这并非设计缺陷,而是 Go 在通用性与现实世界性能需求之间取得平衡的务实之道。

后续如果bytes.Buffer.Peek 成功加入标准库,虽然只是标准库中一个微小的改动,但它成功地铺平了 Go io 体系中最常见的一段“最后一公里”。

参考资料

  • https://github.com/golang/go/issues/73794
  • https://news.ycombinator.com/item?id=44031009#44036152
  • https://flak.tedunangst.com/post/too-much-go-misdirection
  • https://github.com/golang/go/issues/63548

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

Go团队成员的忠告:在你的API变得无法挽回之前,必须掌握的四条原则

本文永久链接 – https://tonybai.com/2025/09/24/evolving-your-go-api

大家好,我是Tony Bai。

你在 package 中导出的每一个 func 和 type,都是一份对用户的承诺。然而,变化是软件开发中唯一不变的真理。当需求变更、bug 修复、甚至认知升级时,你将如何修改这份“承诺”,同时又最大限度地减少对你和你的用户造成的破坏?

在最近的 GopherCon EU 大会上,来自 Google Go 团队的 Jonathan Amsterdam 就“如何管理 Go API 变更”这一核心议题,分享了官方团队的深刻见解与最佳实践。

这次演讲更像是一堂关于工程哲学与用户同理心的必修课。本文为你提炼了其中最关键的四大核心原则,供大家参考。

原则一:“未来防护”——在设计之初就预见变化

避免破坏性变更的最好时机,是在你写下第一行 API 代码的时候。Amsterdam 强调,通过“未来防护” (Future-Proofing) 的设计,可以从源头上消除大量未来的麻烦。

核心理念:保持最小化

“你可以随时添加东西,但修改或移除它们要困难得多。” 这是未来防护的第一信条。在你导出任何一个符号之前,请三思:

  • 非必要,不导出:如果一个符号可以在包内私有化,就绝不要导出它。一个常见的误区是为了方便测试而导出内部函数,更好的做法是使用 _test.go 文件和黑盒测试。
  • 巧用 internal 包:如果一个符号需要在你的模块内部跨包共享,但又不希望被外部用户依赖,请将它放在 internal 包中。你知道吗?标准库的 net/http 树下有 4 个 internal 包,而 crypto 树下则多达 58 个!这正是 Go 团队严控公共 API 暴露面积的体现。

拥抱选项模式

当你预见到一个函数或类型的创建过程未来可能需要更多配置时,请立即使用选项模式。

Go 社区主要有两种主流实现:选项结构体 (Option Structs)可变函数选项 (Variadic Functional Options)。在演讲中,Jonathan Amsterdam 对两者进行了比较,并明确表达了对前者的偏爱。

推荐方案:选项结构体 (Option Structs)

这是 Amsterdam 推荐的模式。它通过定义一个 Options 结构体,并通过指针传递来实现。这种方法:

  • 轻量且直观:定义一个字段比定义一个函数更简单。
  • 默认行为清晰:用户可以简单地传入 nil 来获取所有默认行为。
  • 零值友好:结构体的零值本身也应代表一套有效的默认配置。
  • 扩展方便:新增一个配置选项,你只需在 Options 结构体中添加一个新字段即可。所有现有的、使用旧 Options 结构体或传入 nil 的客户端代码都不会被破坏,它们会自然地获得新字段的零值。

下面是一个选项结构体方案的示例:

package main

import (
    "fmt"
    "time"
)

// Client 是我们要创建的类型
type Client struct {
    addr    string
    retries int
    timeout time.Duration
}

// Options 定义了所有可配置项
type Options struct {
    Retries int
    Timeout time.Duration
}

// NewClient 使用选项结构体进行初始化
func NewClient(addr string, opts *Options) (*Client, error) {
    // 默认值
    const (
        defaultRetries = 3
        defaultTimeout = 10 * time.Second
    )

    // 内部复制 opts,避免外部修改影响内部状态
    var o Options
    if opts != nil {
        o = *opts // 浅拷贝
    }

    // 如果用户未提供,则使用默认值
    if o.Retries == 0 {
        o.Retries = defaultRetries
    }
    if o.Timeout == 0 {
        o.Timeout = defaultTimeout
    }

    c := &Client{
        addr:    addr,
        retries: o.Retries,
        timeout: o.Timeout,
    }
    fmt.Printf("Client created: %+v\n", c)
    return c, nil
}

func main() {
    // 使用默认配置 (传入 nil)
    fmt.Println("--- Using defaults ---")
    NewClient("localhost:8080", nil)

    // 自定义部分配置
    fmt.Println("\n--- Using custom options ---")
    NewClient("remote:9090", &Options{
        Timeout: 5 * time.Second,
    })
}

输出:

--- Using defaults ---
Client created: &{addr:localhost:8080 retries:3 timeout:10000000000}

--- Using custom options ---
Client created: &{addr:remote:9090 retries:3 timeout:5000000000}

然而,选项结构体模式也存在一个核心挑战如何处理非零值的默认值,或者如何区分用户显式传入的“零值”与“未提供该值”的情况?

例如,在我们的 Client 示例中,如果 Retries 的默认值不是 0 而是 3,但用户可能确实想将重试次数显式设置为 0。此时,if o.Retries == 0 的判断逻辑就会失效。

这个问题的标准解决方案是在结构体中使用指针字段

type Options struct {
    Retries *int
    Timeout *time.Duration
}

通过指针,我们可以清晰地表达三种状态:
* Retries 为 nil:用户未提供此选项,应使用默认值。
* Retries 指向 0:用户显式将重试次数设置为 0。
* Retries 指向其他值:用户设置了自定义值。

但这种解决方案带来了新的缺点

  1. 用户体验变得笨拙:用户无法直接创建指向字面量 (literal) 的指针。他们必须先创建一个临时变量,这增加了代码的冗余。
// 为了传入一个 *int,用户必须这么写:
retries := 0
timeout := 5 * time.Second
client, _ := NewClient("addr", &Options{
    Retries: &retries,
    Timeout: &timeout,
})
  1. 需要工具函数辅助:为了解决上述问题,很多库会提供 aws.Int(0) 或 google.String(“foo”) 这样的辅助函数来简化指针的创建,但这又增加了额外的认知负担。

Jonathan Amsterdam 在演讲中也坦诚地指出了这一点,并兴奋地提到 Go 团队正在积极解决这个问题。Go 1.26 有望通过一个备受期待的提案,让 new(0) 或 new(“foo”) 这样的语法成为可能,从而极大地改善选项结构体模式的用户体验。

替代方案:可变函数选项(Variadic Functional Options)

正如 Amsterdam 在演讲中提到的,这是一种“非常流行”的模式,由 Dave Cheney 等人推广。它将每个选项定义为一个函数,并通过可变参数传入。

我们同样用一个可运行的示例来展示其实现:

package main

import (
    "fmt"
    "time"
)

// Client 结构体保持不变
type Client struct {
    addr    string
    retries int
    timeout time.Duration
}

// Option 是一个函数类型,用于修改 Client 实例
type Option func(*Client)

// WithRetries 是一个返回 Option 的函数
func WithRetries(r int) Option {
    return func(c *Client) {
        c.retries = r
    }
}

// WithTimeout 是另一个返回 Option 的函数
func WithTimeout(t time.Duration) Option {
    return func(c *Client) {
        c.timeout = t
    }
}

// NewClient 使用可变函数选项进行初始化
func NewClient(addr string, opts ...Option) (*Client, error) {
    // 首先设置默认值
    c := &Client{
        addr:    addr,
        retries: 3,
        timeout: 10 * time.Second,
    }

    // 循环应用所有传入的选项函数
    for _, opt := range opts {
        opt(c)
    }

    fmt.Printf("Client created: %+v\n", c)
    return c, nil
}

func main() {
    // 使用默认配置 (不传入任何 option)
    fmt.Println("--- Using defaults ---")
    NewClient("localhost:8080")

    // 自定义部分配置
    fmt.Println("\n--- Using custom options ---")
    NewClient("remote:9090", WithTimeout(5*time.Second))
}

输出:

--- Using defaults ---
Client created: &{addr:localhost:8080 retries:3 timeout:10000000000}

--- Using custom options ---
Client created: &{addr:remote:9090 retries:3 timeout:5000000000}

优点:
* 优雅地处理非零值默认值:这是它最大的优势。因为只有当用户显式传入某个选项函数时,对应的配置才会被修改,否则自然保持默认值。

缺点:
* 可能产生语义模糊:Amsterdam 提出了一个关键问题:“当你重复传入同一个选项时,会发生什么?” 例如,NewClient(“addr”, WithTimeout(5*time.Second), WithTimeout(10*time.Second)) 的行为是什么?是第一个生效、最后一个生效,还是应该报错?这为 API 带来了不确定性,需要额外的文档和实现来约束。
* 感觉“有些重”:他直言:“我只是觉得它有点重量级,因为你必须为每个选项都定义一个函数,而不是一个字段,这对我来说感觉更轻量。” 这种“重量级”的感觉,源于它需要更多的样板代码(为每个选项创建一个 With… 函数),与 Go 追求简洁的哲学有所出入。

虽然两种模式都能有效地实现未来防护,但 选项结构体 模式因其更轻量、语义更明确的特点,成为了 Go 官方团队成员更倾向于推荐的选择。

保护接口的技巧

向一个已发布的接口添加方法,是绝对的破坏性变更。如果你的接口主要用于包内实现,不希望被外部用户实现,可以添加一个私有方法来“锁定”它。

// 这是一个被“锁定”的接口,外部包无法实现它
type LockedInterface interface {
    ExportedMethod()
    // 这个私有方法让其他包无法实现该接口
    unexportedMethod()
}

这样,未来你就可以自由地向 LockedInterface 添加新的导出方法,而不会破坏任何用户代码,因为唯一能实现它的代码就在你的掌控之中。

到这里,一些小伙伴儿可能要问:“既然不想让用户实现该接口,直接将接口定义为非导出接口不就行了吗?” 这里简单说一下非导出接口与都带有私有方法的导出接口的应用场景差异。

非导出接口适用于接口及其所有实现都完全是包内部的私有细节,不希望任何外部代码感知或使用其类型;而包含私有方法的导出接口则适用于接口类型本身需要作为公共API的一部分(例如作为函数参数或返回值)来定义契约,但同时又严格限制只有定义该接口的包内部才能提供其具体实现,以防止外部用户自行实现该接口。

原则二:“增加,而非修改”——当变更不可避免时的黄金法则

即便你做了万全的准备,总有一天,你还是需要修改 API。此时,请牢记这条黄金法则:增加新功能,而非修改旧功能。

  • 要为函数添加参数? -> 增加一个新函数
    在 Go 中,我们通常会为新函数起一个更具描述性的名字,例如在函数名后加上 Context 或 Ex。

  • 要为接口添加能力? -> 增加一个新接口,并使用类型断言。
    net/http 中的 Pusher 接口就是绝佳范例。服务器通过类型断言检查 ResponseWriter 是否“顺便”实现了 Pusher 接口,从而实现可选的功能增强,而不是粗暴地修改 ResponseWriter 接口本身。

下面是一个可运行的示例:

package main

import "fmt"

// Reader 是我们已发布的 v1 接口
type Reader interface {
    Read() string
}

// Closer 是我们希望添加的新能力
type Closer interface {
    Close()
}

// StringReader 是 Reader 的一个实现
type StringReader struct {
    content string
}

func (s *StringReader) Read() string {
    return s.content
}

// FileLikeReader 是一个同时实现了 Reader 和 Closer 的新类型
type FileLikeReader struct {
    content string
}

func (f *FileLikeReader) Read() string {
    return f.content
}
func (f *FileLikeReader) Close() {
    fmt.Println("FileLikeReader closed!")
}

// Process 函数只依赖于 v1 的 Reader 接口
func Process(r Reader) {
    fmt.Println("Processing data:", r.Read())

    // 使用类型断言来可选地使用新功能
    if c, ok := r.(Closer); ok {
        c.Close()
    } else {
        fmt.Println("This reader cannot be closed.")
    }
}

func main() {
    fmt.Println("--- Processing StringReader ---")
    Process(&StringReader{content: "hello"})

    fmt.Println("\n--- Processing FileLikeReader ---")
    Process(&FileLikeReader{content: "world"})
}

输出:

--- Processing StringReader ---
Processing data: hello
This reader cannot be closed.

--- Processing FileLikeReader ---
Processing data: world
FileLikeReader closed!

原则三:游戏规则改变者——Go 1.26 的 //go:fix 内联指令

谈到废弃旧 API,Amsterdam 兴奋地宣布了一个即将在 Go 1.26 中登场的“游戏规则改变者”//go:fix inline 指令

这个特殊的注释指令,允许包的作者声明一个旧函数可以被其新版本安全地内联替换。这为 API 迁移提供了一条平滑、半自动化且绝对安全的路径。

一个即将可用的示例:

// in package ioutil (old)
package ioutil

import "io"

//go:fix inline io.ReadAll
// Deprecated: Use io.ReadAll instead.
func ReadAll(r io.Reader) ([]byte, error) {
    return io.ReadAll(r)
}

// --- in user's code ---
package main

import (
    "bytes"
    "fmt"
    "io/ioutil" // 初始依赖
)

func main() {
    reader := bytes.NewBufferString("hello world")
    // 用户最初调用的是旧的 API
    data, _ := ioutil.ReadAll(reader)
    fmt.Println(string(data))
}

// 运行 gofix (或使用 gopls) 后,用户的代码会自动变成:
/*
package main

import (
    "bytes"
    "fmt"
    "io" // import 被自动重写
)

func main() {
    reader := bytes.NewBufferString("hello world")
    // 调用被自动替换为新的 API
    data, _ := io.ReadAll(reader)
    fmt.Println(string(data))
}
*/

这个功能极其强大,因为它将 API 迁移的痛苦降到了最低。它甚至可以用于引导用户从 v1 版本平滑过渡到 v2 版本,极大地减轻了作者的维护负担和用户的升级阻力。

原则四:安全阀——用构建标签进行有原则的实验

有时候,你不确定一个新 API 是否是“正确”的,需要让它在真实世界中“烘焙”一段时间。Amsterdam 强烈建议使用构建标签 (Build Tags) 来管理这类实验性功能。

不要使用像 //go:build experiment 这样通用的标签,而应使用特定于你的模块和实验的、唯一的标签,以避免与其他模块的实验标签冲突。

一个可运行的示例:
假设我们有两个文件。

//features.go

//go:build !mymodule_coolfeature

package main

import "fmt"

func Greet() {
    fmt.Println("Hello, old world!")
}
// features_experimental.go

//go:build mymodule_coolfeature

package main

import "fmt"

func Greet() {
    fmt.Println("Hello, experimental new world!")
}
// main.go
go
package main

func main() {
    Greet()
}

如何运行:

# 默认情况下,运行不带构建标签的版本
$ go run .
Hello, old world!

# 通过 -tags 标志开启实验性功能
$ go run -tags=mymodule_coolfeature .
Hello, experimental new world!

log/slog 在进入标准库前,正是通过这种方式在 x/exp 仓库中进行了长时间的孵化。这种方法为新功能的引入提供了一个宝贵的“安全阀”,让你可以收集用户反馈,同时又不做出任何稳定性承诺。

小结:API 设计的核心是同理心

Jonathan Amsterdam 的分享,为我们描绘了一幅清晰的 Go API 演进路线图。从“未来防护”的先见之明,到“增加而非修改”的务实操作,再到 //go:fix 的自动化迁移和构建标签的灵活实验,Go 团队为我们提供了一整套既强大又优雅的工具箱。

这些原则和工具的背后,贯穿着一条核心思想:对用户的同理心。一个优秀的 API 设计者,会始终将用户的迁移成本、信任和体验放在首位。因为最终,让你陷入维护泥潭的,往往不是技术的复杂性,而是那些因破坏性变更而失去的用户。

视频链接:https://www.youtube.com/watch?v=9Mb0yy8u-Gs


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 Go语言精进之路1 Go语言精进之路2 Go语言第一课 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