MoreRSS

site iconTonyBai | 白明修改

重复
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

TonyBai | 白明的 RSS 预览

深入GOCACHEPROG:Go构建缓存的自定义扩展

2025-03-04 22:29:18

本文永久链接 – https://tonybai.com/2025/03/04/deep-dive-into-gocacheprog-custom-extensions-for-go-build-cache

1. 背景

众所周知,Go build cache是在Go 1.10版本加入到Go工具链中的,缓存的主要目标是避免重复编译相同的代码,从而加快构建速度。

默认情况下,Go构建缓存位于用户主目录下的一个特定目录中,例如,Linux上通常是\$HOME/.cache/go-build,Windows上是%LocalAppData%\go-build)。Mac上则是\$HOME/Library/Caches/go-build。当然,Go开发者也可以通过GOCACHE环境变量自定义缓存位置。构建缓存的目录布局结构如下:

除了Go build/install命令外,go test命令也会利用构建缓存(包括fuzzing test)。除了编译测试代码本身,go test还会缓存测试结果:如果测试代码和依赖项没有变化,并且之前的测试通过,go test会报告(cached),表示测试结果来自缓存,而无需重新运行测试。如果测试代码或依赖项发生变化,或者之前的测试失败,go test会重新编译和运行测试。

我们看到在GOCACHE目录下还有两个文件,一个是trim.txt,另外一个是testexpire.txt。trim.txt用于对Go构建缓存进行修剪(trim)(\$GOROOT/src/cmd/go/internal/cache/cache.go),删除不太可能被重复使用的旧缓存条目,避免因过时的缓存占用过多资源,以保持缓存的高效性和有效性,trim.txt中保存了上次进行修剪的时间。testexpire.txt则是用于go clean清理测试缓存(\$GOROOT/src/cmd/go/internal/clean/clean.go)。

默认的Go构建缓存机制取得了不错的构建和测试加速效果,可以满足了大多数需求。不过,也有一些接纳Go的开发者以及公司希望Go构建缓存支持自定义扩展。前Go核心成员、tailscale联创之一的Brad Fitzpatrick在2023年就提出了Go构建缓存自定义扩展的提案

在提案中,Bradfitz认为Go内置的构建缓存机制仅支持基于本地文件系统的缓存。在一些持续集成 (CI) 环境中,通常的做法是在每次运行时解和压缩\$GOCACHE目录,这种方法效率低下,甚至可能比CI操作本身还要慢(例如,GitHub Actions 中的缓存)。提案希望Go能够支持更灵活地自定义构建缓存机制,例如:

  • 直接利用GitHub的原生缓存系统(而不是低效的 tar/untar)。
  • 在公司内部的可信同事之间实现P2P缓存共享协议。

这些扩展的高级功能不太可能直接添加到Go工具本身中,因此Bradfitz希望Go命令可以支持指定一个特定的程序来扩展和管理缓存,这个特定的程序将作为Go命令启动的一个子进程的形式运行,go命令将内部的缓存接口转换为与子进程的通信协议,通过stdin/stdout与其通信。这样该特定的子程序就可以实现任意的缓存机制和策略了。Go与特定程序(比如my-cache-prog)的关系见下面示意图:

Bradfitz也对比了使用FUSE(用户空间文件系统)的方案(比如使用juicefs将基于S3的共享文件系统挂载到每个开发人员以及ci节点上,但Bradfitz认为FUSE文件系统在linux之外的平台上不稳定,在很多CI环境下无法工作。因此,一个Go原生支持的用户自定义构建缓存机制是非常有必要的,可以解决Go内置缓存的局限性,特别是在CI环境和团队协作场景中。它通过提供一个外部程序接口来实现灵活性,避免了直接修改Go命令本身。

在《Go 1.24中值得关注的几个变化》以及《Go 1.24新特性前瞻:工具链和标准库》我们也提及了Go 1.24新增的实验特性:通过GOCACHEPROG实现Go构建缓存(go build cache)的自定义扩展。并提到了Bradfitz给出的GOCACHEPROG的参考实现go-tool-cache。不过Go 1.24正式版发布后,我使用Go 1.24.0验证了一下go-tool-cache,发现go-tool-cache似乎已经无法与Go 1.24.0正常协作了

$go version
go version go1.24.0 linux/amd64
$GOCACHEPROG="./go-cacher --verbose --cache-dir /tmp/go-cache" go install fmt
2025/03/03 17:00:30 put(action b8310cbc256f74a5f615df68a3a97753d42e1665adc309e78f20fc13259dec98, obj , 902 bytes): failed to write file to disk with right size: disk=1275; wanted=902
2025/03/03 17:00:30 put(action bc54b2b00ab97b34ef769b66fbe4afd5998f46f843cf2beddcd41974a2564bb1, obj , 1650 bytes): failed to write file to disk with right size: disk=116838; wanted=1650
2025/03/03 17:00:30 put(action 9c4f13b659995a6010f99d4427a18cf2e77919d251ef15e0f751bfdc2dff1806, obj , 1473 bytes): failed to write file to disk with right size: disk=273; wanted=1473
2025/03/03 17:00:30 put(action 6600d21f6b5d283315d789f13e681eed1c51d3ddde835b0f14817ecd144a667e, obj , 566 bytes): failed to write file to disk with right size: disk=565; wanted=566
/root/.bin/go1.24.0/src/internal/runtime/maps/runtime_swiss.go:11:2: package internal/asan is not in std (/root/.bin/go1.24.0/src/internal/asan)
/root/.bin/go1.24.0/src/internal/runtime/maps/group.go:10:2: package internal/runtime/sys is not in std (/root/.bin/go1.24.0/src/internal/runtime/sys)
/root/.bin/go1.24.0/src/fmt/print.go:8:2: package internal/fmtsort is not in std (/root/.bin/go1.24.0/src/internal/fmtsort)
/root/.bin/go1.24.0/src/sync/hashtriemap.go:10:2: package internal/sync is not in std (/root/.bin/go1.24.0/src/internal/sync)

修正这个问题还是新实现一个GOCACHEPROG的扩展程序呢?我们选择后者,这样可以让我们更好地从头理解GOCACHEPROG。在这篇文章中,我们会从理解GOCACHEPROG protocol开始,逐步深入到实现自定义缓存管理的具体步骤,包括代码示例。后续基于这个基础,大家可以自己动手,实现满足你的个人/组织需求的Go构建缓存的管理程序。

我们首先来看看Go命令与GOCACHEPROG扩展程序间的协议,这是实现自定义缓存扩展程序的核心。

2. 协议

cmd/go/internal/cacheprog包的文档中,有关于Go命令与GOCACHEPROG扩展程序间的协议的详细说明。下面基于该文档,我们对这个协议做一些说明,并作为后续实现的参考。

前面说过,GOCACHEPROG是Go 1.24引入的新实验特性(很大可能在Go 1.25版本转正),允许使用外部程序实现Go构建缓存。其间的通信协议基于JSON消息通过stdin/stdout进行交换。Go命令将GOCACHEPROG指定的程序(以下称为my-cache-prog)以child process的形式启动,之后my-cache-prog与go命令之间的通信过程大致如下:

  • 初始化: my-cache-prog启动后立即发送一个包含自身支持命令的Response消息(也称为init response,对应的ID=0)给Go命令。
  • 请求-响应模型: Go命令收到init response后,根据其支持的命令,发送Request,缓存程序my-cache-prog收到请求后进行处理,并回复Response

目前协议支持的命令类型包括如下三种:

  • put: 将对象存储到缓存中。
  • get: 从缓存中检索对象。
  • close: 请求缓存程序优雅退出。

显然,通过KnownCommands机制,Go命令可以支持未来协议的扩展。

文档中还给出了协议请求响应模型中Request和Response的定义,这个我们在Go命令的实现中也能找到:

// $GOROOT/src/cmd/go/internal/cacheprog/cacheprog.go

// Cmd is a command that can be issued to a child process.
//
// If the interface needs to grow, the go command can add new commands or new
// versioned commands like "get2" in the future. The initial [Response] from
// the child process indicates which commands it supports.
type Cmd string

const (
    // CmdPut tells the cache program to store an object in the cache.
    //
    // [Request.ActionID] is the cache key of this object. The cache should
    // store [Request.OutputID] and [Request.Body] under this key for a
    // later "get" request. It must also store the Body in a file in the local
    // file system and return the path to that file in [Response.DiskPath],
    // which must exist at least until a "close" request.
    CmdPut = Cmd("put")

    // CmdGet tells the cache program to retrieve an object from the cache.
    //
    // [Request.ActionID] specifies the key of the object to get. If the
    // cache does not contain this object, it should set [Response.Miss] to
    // true. Otherwise, it should populate the fields of [Response],
    // including setting [Response.OutputID] to the OutputID of the original
    // "put" request and [Response.DiskPath] to the path of a local file
    // containing the Body of the original "put" request. That file must
    // continue to exist at least until a "close" request.
    CmdGet = Cmd("get")

    // CmdClose requests that the cache program exit gracefully.
    //
    // The cache program should reply to this request and then exit
    // (thus closing its stdout).
    CmdClose = Cmd("close")
)

// Request is the JSON-encoded message that's sent from the go command to
// the GOCACHEPROG child process over stdin. Each JSON object is on its own
// line. A ProgRequest of Type "put" with BodySize > 0 will be followed by a
// line containing a base64-encoded JSON string literal of the body.
type Request struct {
    // ID is a unique number per process across all requests.
    // It must be echoed in the Response from the child.
    ID int64

    // Command is the type of request.
    // The go command will only send commands that were declared
    // as supported by the child.
    Command Cmd

    // ActionID is the cache key for "put" and "get" requests.
    ActionID []byte `json:",omitempty"` // or nil if not used

    // OutputID is stored with the body for "put" requests.
    //
    // Prior to Go 1.24, when GOCACHEPROG was still an experiment, this was
    // accidentally named ObjectID. It was renamed to OutputID in Go 1.24.
    OutputID []byte `json:",omitempty"` // or nil if not used

    // Body is the body for "put" requests. It's sent after the JSON object
    // as a base64-encoded JSON string when BodySize is non-zero.
    // It's sent as a separate JSON value instead of being a struct field
    // send in this JSON object so large values can be streamed in both directions.
    // The base64 string body of a Request will always be written
    // immediately after the JSON object and a newline.
    Body io.Reader `json:"-"`

    // BodySize is the number of bytes of Body. If zero, the body isn't written.
    BodySize int64 `json:",omitempty"`

    // ObjectID is the accidental spelling of OutputID that was used prior to Go
    // 1.24.
    //
    // Deprecated: use OutputID. This field is only populated temporarily for
    // backwards compatibility with Go 1.23 and earlier when
    // GOEXPERIMENT=gocacheprog is set. It will be removed in Go 1.25.
    ObjectID []byte `json:",omitempty"`
}

// Response is the JSON response from the child process to the go command.
//
// With the exception of the first protocol message that the child writes to its
// stdout with ID==0 and KnownCommands populated, these are only sent in
// response to a Request from the go command.
//
// Responses can be sent in any order. The ID must match the request they're
// replying to.
type Response struct {
    ID  int64  // that corresponds to Request; they can be answered out of order
    Err string `json:",omitempty"` // if non-empty, the error

    // KnownCommands is included in the first message that cache helper program
    // writes to stdout on startup (with ID==0). It includes the
    // Request.Command types that are supported by the program.
    //
    // This lets the go command extend the protocol gracefully over time (adding
    // "get2", etc), or fail gracefully when needed. It also lets the go command
    // verify the program wants to be a cache helper.
    KnownCommands []Cmd `json:",omitempty"`

    // For "get" requests.

    Miss     bool       `json:",omitempty"` // cache miss
    OutputID []byte     `json:",omitempty"` // the ObjectID stored with the body
    Size     int64      `json:",omitempty"` // body size in bytes
    Time     *time.Time `json:",omitempty"` // when the object was put in the cache (optional; used for cache expiration)

    // For "get" and "put" requests.

    // DiskPath is the absolute path on disk of the body corresponding to a
    // "get" (on cache hit) or "put" request's ActionID.
    DiskPath string `json:",omitempty"`
}

Request是由Go命令发送的请求,它包含的几个字段的含义如下:

  • ID: 每个进程中所有请求的唯一编号
  • Command: 请求类型(put/get/close)
  • ActionID: 缓存键
  • OutputID: 存储在缓存中的对象ID,实际也是Body数据的Sha256的值。
  • Body: “put”请求的主体数据,”get”和”close”请求没有Body。
  • BodySize: Body的字节数

Response则是由缓存程序回复给Go命令的结构,它的定义中的几个字段的含义如下:

  • ID: 对应请求的ID
  • Err: 错误信息(如有)
  • KnownCommands: 支持的命令列表(用于初始Response)
  • Miss: 缓存未命中标志
  • OutputID: 存储在缓存中的对象ID
  • Size: 主体大小(字节)
  • Time: 对象放入缓存的时间
  • DiskPath: 对应缓存项在磁盘上的绝对路径

这里要注意几点:

  • 除了init Response,其他Response可以乱序返回,Go命令会通过Response中的ID来匹配对应的Request。
  • 不论缓存数据存储在哪里,最终提供给Go命令的都应该在本地文件系统中,并通过Response中的DiskPath来指示该数据对应的绝对路径。

为了能更好地理解这个协议的交互,我这里画了一幅Go命令与my-cache-prog之间的交互示意图:

到这里,还有一个地方尚未清楚,那就是put请求与put/get请求之间以及put请求内部body的编码格式并未说清楚。在文档中,这部分也不是那么清晰,但这却决定了后续实现的正确性。为了给后面的实现做好铺垫,我们可以通过查看Go命令的对put请求的编码实现来确认这部分内容。在

// $GOROOT/src/cmd/go/internal/cache/prog.go

func (c *ProgCache) writeToChild(req *cacheprog.Request, resc chan<- *cacheprog.Response) (err error) {
    c.mu.Lock()
    if c.inFlight == nil {
        return errCacheprogClosed
    }
    c.nextID++
    req.ID = c.nextID
    c.inFlight[req.ID] = resc
    c.mu.Unlock()

    defer func() {
        if err != nil {
            c.mu.Lock()
            if c.inFlight != nil {
                delete(c.inFlight, req.ID)
            }
            c.mu.Unlock()
        }
    }()

    c.writeMu.Lock()
    defer c.writeMu.Unlock()

    if err := c.jenc.Encode(req); err != nil {
        return err
    }
    if err := c.bw.WriteByte('\n'); err != nil {
        return err
    }
    if req.Body != nil && req.BodySize > 0 {
        if err := c.bw.WriteByte('"'); err != nil {
            return err
        }
        e := base64.NewEncoder(base64.StdEncoding, c.bw)
        wrote, err := io.Copy(e, req.Body)
        if err != nil {
            return err
        }
        if err := e.Close(); err != nil {
            return nil
        }
        if wrote != req.BodySize {
            return fmt.Errorf("short write writing body to GOCACHEPROG for action %x, output %x: wrote %v; expected %v",
                req.ActionID, req.OutputID, wrote, req.BodySize)
        }
        if _, err := c.bw.WriteString("\"\n"); err != nil {
            return err
        }
    }
    if err := c.bw.Flush(); err != nil {
        return err
    }
    return nil
}

通过上述代码,我们可以总结出下面put请求的编码格式:

解释一下这张图。

  • 顶部(蓝色区域): JSON编码的请求元数据

包含ID、ActionID、OutputID和BodySize等字段。这部分使用标准JSON格式。

  • 中间(黄色条): 换行符分隔符(‘\n’)

JSON元数据后的第一个换行符。

  • 中部(绿色区域): Base64编码的请求体(可选)

这部分以双引号(“)开始,紧接着是Base64编码的二进制数据,最后以双引号(“)结束。

  • 底部(黄色条): 最终换行符(‘\n’)

整个请求的结束标记。

总的来说,Go命令的put请求使用了JSON+Base64的组合编码方式:请求的元数据以JSON格式编码,请求体以Base64编码(base64编码前后各有一个双引号),它们之间用换行符分隔,整个请求最后以换行符结束。这种格式便于解析,同时也能处理二进制数据。

注意:根据json.Encoder.Encode的文档,编码后的json文本也会跟着一个换行符(newline)。

不过代码中还有一点非常值得注意,那就是Put请求的BodySize的值为base64编码之前的Body长度!这一点如果不看源码,很容易使用BodySize去读取Body体的内容,从而导致解码出错!

好了,详细了解了上述协议后,我们就来尝试实现一个my-cache-prog程序。程序开源到github.com/bigwhite/go-cache-prog项目中了,大家可以结合项目代码来继续阅读下面的内容。

3. 实现

3.1 整体设计

go-cache-prog的实现采用了模块化设计,将不同的功能划分到独立的包中,以提高代码的可维护性和可扩展性。整体结构如下:

go-cache-prog/
├── cmd/
│   └── go-cache-prog/
│       └── main.go      (可执行程序入口)
├── protocol/
│   └── protocol.go  (请求/响应定义和解析)
├── storage/
│   ├── storage.go   (存储后端接口)
│   └── filesystem/
│       └── filesystem.go (基于本地文件系统的存储实现)
└── cache/
    └── cache.go     (内存缓存逻辑)
  • cmd/go-cache-prog/main.go: 这是可执行程序的入口点。

它负责解析命令行参数、设置日志输出、确定缓存目录、初始化存储和缓存、发送初始能力响应、启动请求处理循环。

// cmd/go-cache-prog/main.go  (部分)
func main() {
    // ... (参数解析、日志设置、缓存目录确定) ...

    store, err := filesystem.NewFileSystemStorage(cacheDir, verbose)
    if err != nil {
        log.Fatalf("Failed to initialize filesystem storage: %v", err)
    }
    cacheInstance := cache.NewCache(store)

    // ... (发送初始响应) ...
    requestHandler := protocol.NewRequestHandler(reader, os.Stdout, cacheInstance, verbose)

    if err := requestHandler.HandleRequests(); err != nil {
        log.Fatalf("Error handling requests: %v", err)
    }
}
  • protocol: 此包处理与go命令的通信协议,定义请求/响应结构,处理请求。
// protocol/protocol.go (部分)
type RequestHandler struct {
    reader        *bufio.Reader
    writer        io.Writer
    cache         *cache.Cache
    verbose       bool
    gets          int //statistics
    getMiss       int
}

func (rh *RequestHandler) HandleRequests() error {
    for {
        req, err := rh.readRequest()
        // ... (错误处理、请求处理) ...
    }
}
  • storage: 此包定义了存储后端的抽象接口。
// storage/storage.go
type Storage interface {
    Put(actionID, outputID []byte, data []byte, size int64) (string, error)
    Get(actionID []byte) (outputID []byte, size int64, modTime time.Time, diskPath string, found bool, err error)
    // ... (可选方法) ...
}
  • storage/filesystem: 此包提供了storage.Storage接口的一个具体实现,使用本地文件系统。
// storage/filesystem/filesystem.go (部分)
type FileSystemStorage struct {
    baseDir string
    verbose bool
}

func NewFileSystemStorage(baseDir string, verbose bool) (*FileSystemStorage, error) {
    // ... (创建目录) ...
}
  • cache: 此包实现了内存缓存层, 位于存储接口之上。
// cache/cache.go (部分)
type Cache struct {
    entries map[string]CacheEntry
    mu      sync.RWMutex
    store   storage.Storage
}

func NewCache(store storage.Storage) *Cache {
    // ... (初始化 map) ...
}

3.2 协议解析

protocol包负责处理go-cache-prog与go命令之间的基于JSON的通信协议。

  • 请求 (Request):
// protocol/protocol.go
type Request struct {
    ID       int64
    Command  Cmd
    ActionID []byte `json:",omitempty"`
    OutputID []byte `json:",omitempty"`
    Body     io.Reader `json:"-"`
    BodySize int64   `json:",omitempty"`
    ObjectID []byte `json:",omitempty"` // Deprecated
}
  • 响应 (Response):
// protocol/protocol.go
type Response struct {
    ID            int64      `json:",omitempty"`
    Err           string     `json:",omitempty"`
    KnownCommands []Cmd      `json:",omitempty"`
    Miss          bool       `json:",omitempty"`
    OutputID      []byte     `json:",omitempty"`
    Size          int64      `json:",omitempty"`
    Time          *time.Time `json:",omitempty"`
    DiskPath      string     `json:",omitempty"`
}

RequestHandler的readRequest方法负责读取和解析请求:

// protocol/protocol.go (部分)
func (rh *RequestHandler) readRequest() (*Request, error) {
    line, err := rh.reader.ReadBytes('\n')
    if err != nil {
        return nil, err
    }
    // ... (处理空行) ...
    var req Request
    if err := json.Unmarshal(line, &req); err != nil {
        // 检查base64
        if len(line) >= 2 && line[0] == '"' && line[len(line)-1] == '"'{
            // ...
        }
        return nil, fmt.Errorf("failed to unmarshal request: %w", err)
    }
    return &req, nil
}

对于put请求, 如果BodySize大于0, 需要读取并解码Base64数据:

// protocol/protocol.go (部分)
func (rh *RequestHandler) handlePut(req *Request) {
    var bodyData []byte
    if req.BodySize > 0 {
        bodyLine, err := rh.reader.ReadBytes('\n')
        // ... (跳过空行)...
        bodyLine, err = rh.reader.ReadBytes('\n')
        // ... (错误处理) ...

        bodyLine = bytes.TrimSpace(bodyLine)
        if len(bodyLine) < 2 || bodyLine[0] != '"' || bodyLine[len(bodyLine)-1] != '"' {
            // ... (格式错误) ...
        }
        base64Body := bodyLine[1 : len(bodyLine)-1]
        bodyData, err = base64.StdEncoding.DecodeString(string(base64Body))
        // ... (解码错误、大小不匹配处理) ...
    }
    // ... (调用 cache.Put) ...
}

3.3 缓存管理

cache包实现了内存缓存层,减少对底层存储的访问。

  • CacheEntry结构体:
// cache/cache.go
type CacheEntry struct {
    OutputID []byte
    Size     int64
    Time     time.Time
    DiskPath string
}
  • Cache结构体和NewCache:
// cache/cache.go
type Cache struct {
    entries map[string]CacheEntry
    mu      sync.RWMutex
    store   storage.Storage
}

func NewCache(store storage.Storage) *Cache {
    return &Cache{
        entries: make(map[string]CacheEntry),
        store:   store,
    }
}
  • Put方法:
// cache/cache.go
func (c *Cache) Put(actionID, outputID []byte, data []byte, size int64) (string, error) {
    diskPath, err := c.store.Put(actionID, outputID, data, size)
    if err != nil {
        return "", err
    }

    entry := CacheEntry{ /* ... */ }

    actionIDHex := fmt.Sprintf("%x", actionID)
    c.mu.Lock()
    c.entries[actionIDHex] = entry
    c.mu.Unlock()

    return diskPath, nil
}
  • Get方法:
// cache/cache.go
func (c *Cache) Get(actionID []byte) (*CacheEntry, bool, error) {
    actionIDHex := fmt.Sprintf("%x", actionID)

    c.mu.RLock()
    entry, exists := c.entries[actionIDHex]
    c.mu.RUnlock()

    if exists {
        return &entry, true, nil // 优先从内存缓存读取
    }

    // ... (从存储中读取, 并更新内存缓存) ...
}

3.4 抽象存储接口与本地文件系统实现

storage.Storage接口定义了存储后端的抽象,目的是为了支持更多的实现扩展,比如支持在S3上存储等。

// storage/storage.go
type Storage interface {
    Put(actionID, outputID []byte, data []byte, size int64) (string, error)
    Get(actionID []byte) (outputID []byte, size int64, modTime time.Time, diskPath string, found bool, err error)
}

storage/filesystem包提供了一种基于本地文件系统的实现。

  • FileSystemStorage和NewFileSystemStorage:
// storage/filesystem/filesystem.go
type FileSystemStorage struct {
    baseDir string
    verbose bool
}

func NewFileSystemStorage(baseDir string, verbose bool) (*FileSystemStorage, error) {
    if err := os.MkdirAll(baseDir, 0755); err != nil {
        return nil, err
    }
    return &FileSystemStorage{baseDir: baseDir, verbose: verbose}, nil
}
  • Put方法:
// storage/filesystem/filesystem.go
func (fss *FileSystemStorage) Put(actionID, outputID []byte, data []byte, size int64) (string, error) {
    actionIDHex := fmt.Sprintf("%x", actionID)
    //outputIDHex := fmt.Sprintf("%x", outputID) //Might not need

    actionFile := filepath.Join(fss.baseDir, fmt.Sprintf("a-%s", actionIDHex))
    diskPath := filepath.Join(fss.baseDir, fmt.Sprintf("o-%s", actionIDHex))
    absPath, _ := filepath.Abs(diskPath)

    // Write metadata
    now := time.Now()
    ie, err := json.Marshal(indexEntry{
        Version:  1,
        OutputID: outputID,
        Size:     size,
        Time:     &now,
    })
    // ... (错误处理, 写入元数据文件) ...
    if size > 0{
        // 写入数据文件
        if err := os.WriteFile(diskPath, data, 0644); err != nil {
            return "", fmt.Errorf("failed to write cache file: %w", err)
        }
    } else {
        //创建空文件
        zf, err := os.OpenFile(diskPath, os.O_CREATE|os.O_RDWR, 0644)
        if err != nil {
            return "", fmt.Errorf("failed to create empty file: %w", err)
        }
        zf.Close()
    }

    return absPath, nil
}
  • Get方法:
// storage/filesystem/filesystem.go
func (fss *FileSystemStorage) Get(actionID []byte) (outputID []byte, size int64, modTime time.Time, diskPath string, found bool, err error) {
    actionIDHex := fmt.Sprintf("%x", actionID)
    actionFile := filepath.Join(fss.baseDir, fmt.Sprintf("a-%s", actionIDHex))

    // Read metadata
    af, err := os.ReadFile(actionFile)
    // ... (文件不存在处理) ...
    var ie indexEntry
    if err := json.Unmarshal(af, &ie); err != nil {
        return nil, 0, time.Time{}, "", false, fmt.Errorf("failed to unmarshal index entry: %w", err)
    }

    objectFile := filepath.Join(fss.baseDir, fmt.Sprintf("o-%s", actionIDHex))
    info, err := os.Stat(objectFile)
    // ... (对象文件不存在、或其他错误处理) ...
    diskPath, _ = filepath.Abs(objectFile)

    return ie.OutputID, info.Size(), info.ModTime(), diskPath, true, nil
}

storage/filesystem使用了两种类型的文件来分别存储缓存数据和元数据:

  • a-{actionID} (Action File): 元数据文件

这个文件存储了关于缓存条目的元数据,使用JSON格式。actionID是缓存键的十六进制表示。

  • o-{actionID} (Object File): 对象文件。

这个文件存储了实际的缓存数据(即Request.Body的内容)。actionID 与对应的元数据文件中的actionID 相同。

对于一些Put请求(with BodySize=0)的,同样会创建元数据文件和对象文件,只是对象文件的size为0。

这么设计便于快速查找:在执行Get操作时,go-cache-prog首先读取a-{actionID}文件。这个文件很小,因为它只包含元数据。通过读取这个文件,go-cache-prog可以快速确定:缓存条目是否存在(如果 a-{actionID} 文件不存在,则肯定不存在)。 如果存在,可以获取到OutputID、数据大小(Size)和最后修改时间(Time),并放入内存缓存中,而无需读取可能很大的o-{actionID}文件,便可以知道对象文件(o-{actionID})是否存在。

4. 验证

下载go-cache-prog源码并编译:

$git clone https://github.com/bigwhite/go-cache-prog.git
$make

注意:go-cache-prog需要与Go 1.24及以上版本配合使用。

接下来,我们将fmt包首次编译安装到go-cache-prog的默认缓存目录下(~/.gocacheprog):

$GOCACHEPROG="./go-cache-prog --verbose" go install fmt
2025/03/04 10:47:59 Using cache directory: /Users/tonybai/.gocacheprog
2025/03/04 10:47:59 Received request: ID=1, Command=get, ActionID=90c776cb58a3c3a99b5622344df5bc959fd2b90f299b40ae21ec6ccf16c77a23, OutputID=, BodySize=0
2025/03/04 10:47:59 Received request: ID=2, Command=put, ActionID=90c776cb58a3c3a99b5622344df5bc959fd2b90f299b40ae21ec6ccf16c77a23, OutputID=4e67091862cdc5ff3d44d51adaf9f5a3f5e993dcbc0b6aad884d00d929f3f4d3, BodySize=3037
2025/03/04 10:47:59 Put request: ID=2, Actual BodyLen=4055
2025/03/04 10:47:59 Received request: ID=3, Command=get, ActionID=b2d3027bda366ae198f991d65f62b5be25aa7fe41092bb81218ba24363923b69, OutputID=, BodySize=0
2025/03/04 10:47:59 Received request: ID=4, Command=get, ActionID=c48dafcc394ccfed5c334ef2e21ba8b5bd09a883956f17601cf8a3123f8afd2b, OutputID=, BodySize=0
2025/03/04 10:47:59 Received request: ID=5, Command=get, ActionID=b16400d94b83897b0e7a54ee4223208ff85b4926808bcae66e488d2dbab85054, OutputID=, BodySize=0
2025/03/04 10:47:59 Received request: ID=6, Command=get, ActionID=789f5b8e5b2390e56d26ac916b6f082bfb3e807ee34302f8aa0310e6e225ac77, OutputID=, BodySize=0

... ...
2025/03/04 10:48:03 Received request: ID=321, Command=close, ActionID=, OutputID=, BodySize=0
2025/03/04 10:48:03 Gets: 107, GetMiss: 107

由于初始情况下,默认缓存目录下(~/.gocacheprog)没有构建缓存的文件,因此上面的所有get都miss了,go命令会发送put请求,go-cache-prog会构建初始cache。在默认缓存目录下(~/.gocacheprog)下,我们可以看到类似这样的文件列表:

$ls ~/.gocacheprog
a-01fae6e8773991089b07eef70a209ee3e99e229231b4956689d7c914a84c70de
a-030b82281d0fae81d44e96b140c276fa232abe46ae92b7fe1d4b7213bc58eef1
a-046d1381c7f1061967c50c5ba2a112486374c6682e80b154f26f17302eb623a4
... ...
o-fc0a0cf26b5a438834ee47a7166286bfb4266c93b667a66e5630502db7651507
o-fc5364bf6b2b714e6a90e8b57652827666b93366f0e322875eefd21b4cc58b3f
o-fde27b35692f9efeae945f00ab029fe156cbfa961bf6149ab9767e1efd057545
o-ff141dd2b1c95d4cba6c3cda5792d8863e428824565ecb5765018710199a2f69

接下来,我们再次执行同样的命令,看看cache是否起到了作用:

$GOCACHEPROG="./go-cache-prog --verbose" go install fmt
2025/03/04 10:50:14 Using cache directory: /Users/tonybai/.gocacheprog
2025/03/04 10:50:14 Received request: ID=1, Command=get, ActionID=90c776cb58a3c3a99b5622344df5bc959fd2b90f299b40ae21ec6ccf16c77a23, OutputID=, BodySize=0
2025/03/04 10:50:14 Received request: ID=2, Command=get, ActionID=c48dafcc394ccfed5c334ef2e21ba8b5bd09a883956f17601cf8a3123f8afd2b, OutputID=, BodySize=0
2025/03/04 10:50:14 Received request: ID=3, Command=get, ActionID=b16400d94b83897b0e7a54ee4223208ff85b4926808bcae66e488d2dbab85054, OutputID=, BodySize=0
2025/03/04 10:50:14 Received request: ID=4, Command=get, ActionID=789f5b8e5b2390e56d26ac916b6f082bfb3e807ee34302f8aa0310e6e225ac77, OutputID=, BodySize=0
2025/03/04 10:50:14 Received request: ID=5, Command=get, ActionID=c6e6427a15f95d70621df48cc68ab039075d66c1087427eb9a04bcf729c5b491, OutputID=, BodySize=0
... ...
2025/03/04 10:50:14 Received request: ID=161, Command=close, ActionID=, OutputID=, BodySize=0
2025/03/04 10:50:14 Gets: 160, GetMiss: 0

我们看到所有的Get请求都命中了缓存(GetMiss: 0),此次执行也肉眼可见的快!

我们再来用一个可执行程序验证一下利用build cache的构建。在go-cache-prog项目下有一个examples/helloworld示例,在该目录下执行make,我们就能看到构建的输出:

$cd examples/helloworld
$make
GOCACHEPROG="../../go-cache-prog --verbose" go build
2025/03/04 10:54:35 Using cache directory: /Users/tonybai/.gocacheprog
2025/03/04 10:54:35 Received request: ID=1, Command=get, ActionID=7c1950a92d55fae91254e8923f7ea4cdfd2ce34953bcf2348ba851be3e2402a1, OutputID=, BodySize=0
2025/03/04 10:54:35 Received request: ID=2, Command=put, ActionID=7c1950a92d55fae91254e8923f7ea4cdfd2ce34953bcf2348ba851be3e2402a1, OutputID=43b1c1a308784cd610fda967d781d3c5ccfd4950263df98d18a2ddb2dd218f5a, BodySize=251
2025/03/04 10:54:35 Put request: ID=2, Actual BodyLen=339
2025/03/04 10:54:35 Received request: ID=3, Command=get, ActionID=90c776cb58a3c3a99b5622344df5bc959fd2b90f299b40ae21ec6ccf16c77a23, OutputID=, BodySize=0

... ...
2025/03/04 10:54:35 Received request: ID=165, Command=close, ActionID=, OutputID=, BodySize=0
2025/03/04 10:54:35 Gets: 163, GetMiss: 1

我们看到绝大部分都是命中缓存的。

执行构建出的helloworld,程序也会正常输出内容:

$./helloworld
hello, world!

5. 小结

本文深入探讨了Go 1.24引入的GOCACHEPROG这一实验性特性,它为Go构建缓存带来了前所未有的灵活性。通过允许开发者使用自定义程序来管理构建缓存,GOCACHEPROG解决了Go内置缓存机制在特定场景下的局限性,特别是CI环境和团队协作中的痛点。

文中,我们基于对协议的理解,逐步构建了一个名为go-cache-prog的自定义缓存程序。go-cache-prog采用了模块化设计,将协议解析、缓存管理和存储抽象分离到不同的包中,提高了代码的可维护性和可扩展性。

最后,我们通过实际的编译和安装示例,验证了go-cache-prog的功能,展示了它如何与Go命令协同工作,实现自定义的构建缓存管理。

go-cache-prog项目提供了一个坚实的基础,开发者可以在此基础上进行扩展,实现更高级的功能,例如:

  • 不同的存储后端:实现storage.Storage接口,支持将缓存数据存储到云存储(如 AWS S3、Google Cloud Storage)、分布式缓存(如 Redis、Memcached)或其他存储系统中。
  • 缓存失效策略:实现更复杂的缓存失效策略,例如基于 LRU(最近最少使用)或 TTL(生存时间)的过期机制。
  • 分布式缓存:构建一个分布式的缓存系统,支持在多个开发机器或 CI 节点之间共享构建缓存。
  • 监控和统计:添加监控和统计功能,跟踪缓存命中率、缓存大小、性能指标等。

此外,目前的go-cache-prog是顺序处理go命令的请求的,大家也可以自行将其改造为并发处理请求,不过务必注意并发处理的同步。


Gopher部落知识星球在2025年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。并且,2025年将在星球首发“Go陷阱与缺陷”和“Go原理课”专栏!此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

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

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

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

© 2025, bigwhite. 版权所有.

Go 1.24中值得关注的几个变化

2025-02-16 23:48:35

本文永久链接 – https://tonybai.com/2025/02/16/some-changes-in-go-1-24

北京时间2025年2月12日,恰逢中国传统元宵佳节,远在美国的Go团队正式发布了Go 1.24的第一个版本Go 1.24.0。这也是Go团队在更换Tech Leader为Austin Clements后发布的首个大版本。

按照惯例,每次Go大版本发布时,我都会撰写一篇“Go 1.x中值得关注的几个变化”的文章。自2014年的Go 1.4版本起,这一系列文章已经持续了11年。

不过,随着从Go 1.17版本开始引入的“Go 1.x新特性前瞻”系列以及针对特定技术特性的专题文章,“Go 1.x中值得关注的几个变化”系列文章的形式也在不断演变。原先的“Go 1.x中值得关注的几个变化”可以理解为被目前的“Go 1.x新特性前瞻” + “特定技术特性文章” + “Go 1.x中值得关注的几个变化”所替代。

不过,随着从Go 1.17版本开始引入的“Go 1.x新特性前瞻”系列以及针对特定技术特性的专题文章,“Go 1.x中值得关注的几个变化”系列的形式也在不断演变。原先的“Go 1.x中值得关注的几个变化”已逐渐被目前的“Go 1.x新特性前瞻” + “特定技术特性文章” + “Go 1.x中值得关注的几个变化(新版)”所替代。希望各位读者能够理解这种变化,“Go 1.x中值得关注的几个变化”系列依然会延续,但文章中将不再进行细致的分析,因为这些内容已经在之前的前瞻和专题文章中讨论过了。

好了,言归正传,我们来说说Go 1.24!

1. 语言变化

正如Go一贯所做的,新版Go 1.24.0继续遵循Go1的兼容性规范。使用Go 1.24.0,你可以顺利编译和运行你用Go 1.11编写的代码。相信许多Gopher正是因为这一点而喜欢上Go,就像下面这位Gopher在Go 1.24发布后所表现出的惊喜一样:

不过,正如Go一贯所做的那样,在语法特性方面,Go显得十分“吝啬”。在Go 1.18大方地引入了泛型之后,Go团队又恢复了这种“吝啬”的风格。在Go 1.24的发布说明中,那短短的一行字充分展现了这一特点:

我们看到,Go 1.24仅仅是将Go 1.23版本中的实验特性“带有类型参数的类型别名”转正了,成为了默认特性。当然你仍然可以GOEXPERIMENT=noaliastypeparams显式关闭它。关于这个特性的具体内容,我们多次说过了,大家可以到《Go 1.24新特性前瞻:语法、编译器与运行时》温习一下它的具体内容。

不过这种“吝啬”也是很多Gopher所期望的,当年Go语言之父Rob Pike在“Simplicity is Complicated”演讲中提到的如下权威观点,影响了诸多Gopher,当然也包括我:

因此,在正在如火如荼的“spec: reduce error handling boilerplate using ?”的讨论中,就当前的情况来看,我也倾向于保持现状

2. 编译器与运行时

在2024年中旬,Fasthttp的作者、VictoriaMetrics的联合创始人Aliaksandr Valialkin曾因Go加入自定义函数迭代的特性而发文抱怨“Go正在朝着错误的方向演进”。不过他也提到,如果Go团队专注于提升Go的性能,而不是在与社区争论一些“华而不实”的语法糖,可能会赢得更多开发者的青睐:

尽管Go 1.24尚未添加对SIMD的官方支持,但引入的优化显然不会让Aliaksandr Valialkin失望。首当其冲的就是对map底层实现的优化——使用更为高效的Swiss Table。关于Swiss Table及Go 1.24重写map的思路,可以参考我的《Go map使用Swiss Table重新实现,性能最高提升近50%》一文。根据文中的实测结果,新版基于Swiss Table的map在多数测试项中表现出显著的性能提升,有些甚至接近50%!

当然,基于Swiss Table的map实现仍在不断完善,其实现者Michael Pratt将持续进行打磨和优化:

参与Go Swiss Table重写方案讨论,并提供参考实现之一的CockroachDB CTO Peter Mattis,也在X.com上分享了新map设计和实现的诞生过程与优势,大家可以阅读以加深理解。

此外,Go 1.24还优化了runtime内部的锁实现,新实现在高竞争情况下取得了显著的可扩展性提升,而不是像Go 1.24之前的实现那样随线程数增加而急剧下降。基准测试表明,在GOMAXPROCS=20时,性能提升达3倍。

更多编译器和运行时的变化,可以参考《Go 1.24新特性前瞻:语法、编译器与运行时》。

Go 1.24版本在编译器和运行时方面的优化投入和勇于改变,正是Go社区所期望的。相信后续版本在这方面的持续投入不会让Aliaksandr Valialkin失望。

3. 工具链

Go团队在Go工具链上的投入和结果一直被Go社区认可和赞扬!《Go 1.24新特性前瞻:工具链和标准库》一文中有对Go 1.24工具链变化的详细介绍,但在这里我还是要再次提及其中的三个变化。

go.mod增加tool指示符,支持对tool的依赖管理

借用《Go工具链版本已不由你定:go和toolchain指令详解》中的那幅图:

Go的目标显然是要实现对Go应用所依赖“全要素”进行版本管理”,涵盖Go版本、工具链版本、第三方包版本以及依赖工具版本的管理。而Go 1.24在go.mod中增加tool指示符就是要实现对依赖工具的版本进行管理。增加tool指示符后,你可以像管理第三方包版本那样,使用go get -tool对依赖的tool的版本进行管理,go install tool对tool进行安装,并支持一个tool同时存在多个版本在本地,这是由于通过go.mod管理的依赖的tool会被像module那样缓存在本地构建缓存中(go build cache)。

btw,再说说go 1.24对toolchain依赖管理和选择的改善。即便看了《Go工具链版本已不由你定:go和toolchain指令详解》一文,很多Gopher还是可能因为gotoolchain决策的复杂性和参与要素的众多而感到困惑,Go 1.24增加了GODEBUG=toolchaintrace=1可以输出做出决策的过程日志,告诉你Go为何会选择某个特定的toolchain版本。

go vet的增强

在Go 1.24中,go vet的功能有了较大变化,新增或增强了如下一些分析器(analyzer):

  • 新增测试分析器

可以检测test、fuzz test、基准测试和example test中的常见错误,避免因命名、签名错误或引用不存在的标识符而导致测试无法运行。

  • printf分析器增强

新增对fmt.Printf(s)的检查,如果这类调用中格式字符串并非常量且没有传入其他参数,则提醒用户使用fmt.Print。

  • buildtag分析器增强

新增对无效Go主版本构建约束的检测,避免错误引用次版本号。例如,如果你使用//go:build go1.23.1,该分析器会提醒你应该使用//go:build go1.23。

  • copylock分析器增强

增强对经典三段式for循环中包含sync.Locker的变量复制的不安全操作的诊断,防止锁的复制带来的潜在问题。这也是Go 1.23修正loopvar语义后避免Go用户误用的一个防卫手段。

新增GOCACHEPROG

另一个大家可能忽视的值得关注的改变是新增了GOCACHEPROG环境变量。

Go语言的cmd/go工具已经具备了强大的缓存支持,但其缓存机制仅限于基于文件系统的缓存。这种缓存方式在某些场景下效率不高,尤其是在CI(持续集成)环境中,用户通常需要将GOCACHE目录打包和解压缩,这往往比CI操作本身还要慢。此外,用户可能希望利用位于网络上的共享缓存(比如S3)或公司内部的P2P缓存协议来提高缓存效率,但这些功能并不适合直接集成到cmd/go工具中。

为了解决上述问题,Brad Fitzpatrick提出了一个新的环境变量GOCACHEPROG,类似于现有的GOCACHE变量。通过设置GOCACHEPROG,用户可以指定一个外部程序,该程序将作为子进程运行,并通过标准输入/输出来与cmd/go工具进行通信。cmd/go工具将通过这个接口与外部缓存程序交互,外部程序可以根据需要实现任意的缓存机制和策略。其大致结构如下:

显然一旦可以在云上存储build cache,也能缓解一下Go用户抱怨本地缓存过大的问题。当然从实际情况来看(我的本地环境),go build cache还不是最大的:

$go env|grep CACHE
GOCACHE='/Users/tonybai/Library/Caches/go-build'
GOCACHEPROG=''
GOMODCACHE='/Users/tonybai/Go/pkg/mod'
$cd /Users/tonybai/Library/Caches/go-build
$du -sh
155M    .

$cd /Users/tonybai/Go/pkg/mod
$du -sh
7.0G

我们看到在我本地的环境中,go build cache和go module cache的size相比,简直是不值得一提,所以说如果后续要有个GOMODCACHEPROG就更好了,我也十分希望能将go module cache搬移到云端(比如S3)中,甚至可以让组织内的Gopher共享这些go module cache(当然要区分不同arch和os)。

更多关于工具链的变化,可以参考《Go 1.24新特性前瞻:工具链和标准库》。

4. 标准库

Go标准库向来是变化的大户,这里我显然不会列出所有变化,甚至一些值得关注的变化,比如:json包增加对omitzero选项的支持新增weak包和weak指针等,也都在新特性前瞻或技术专题性文章中有过详细说明。

这里要说的是Go对fips 140-3合规性的支持,因为这个最终版本与当初新特性前瞻时有所变化。

基于最新的Go fips 140-3文档,我们可以得到关于fips 140-3使用方法的说明,这里简要梳理如下:

  • Go 1.24及更高版本开始,Go二进制文件可以原生运行在FIPS 140-3合规模式下,不必依赖注入boringssl等第三方C++包。
  • Go新增了的一个特殊的Go加密模块 (Go Cryptographic Module),其下有一组新增的标准库包(位于crypto/internal/fips140/…下),实现了 FIPS 140-3批准的算法。这个cryptographic module的版本当前为v1.0.0,目前正在接受CMVP认证实验室的测试。

Go引入了GOFIPS140环境变量,用于go build、go install和go test命令,以选择要链接到可执行程序中的Go加密模块版本。该环境变量有三类可选值:

  • off (默认): 使用标准库中的crypto/internal/fips140/…包。
  • latest: 类似off,但默认启用FIPS 140-3模式。
  • v1.0.0: 使用Go加密模块 v1.0.0 版本(在Go 1.24中首次发布,并在2025年初冻结),默认启用FIPS 140-3模式。

在运行时,可以通过GODEBUG=fips140=xxx来控制上述编译到Go中的Go cryptographic module是否运行在FIPS 140-3模式下,默认是off。

当使用GODEBUG=fips140=on时,Go运行时将会启用Go cryptographic module的FIPS 140-3模式。启用后,Go加密模块会执行以下操作:

  • 完整性自检: 在init阶段,会验证模块对象文件的校验和,确保代码未被篡改。
  • 已知答案自检: 根据FIPS 140-3指南,在init阶段或首次使用时,对算法进行已知答案测试。
  • 密钥一致性测试: 对生成的密钥进行配对一致性测试 (这可能导致某些密钥类型生成速度减慢,特别是临时密钥)。
  • crypto/rand.Reader改进: 使用NIST SP 800-90A DRBG,并从平台CSPRNG获取随机字节混合到输出中。
  • crypto/tls限制: 仅协商符合NIST SP 800-52r2 的协议版本、密码套件、签名算法和密钥交换机制。
  • crypto/rsa.SignPSS限制: 使用PSSSaltLengthAuto时,盐的长度会被限制为哈希的长度。

当使用GODEBUG=fips140=only时,不符合FIPS140-3的加密算法会返回错误或者panic。但是此模式仅为尽力而为,不保证符合所有的FIPS 140-3要求。

不过大家要知道的是:在Go 1.24版本中,GODEBUG=fips140=on和only在OpenBSD、Wasm、AIX和32位Windows平台上暂不受支持。

另外要想要检测FIPS 140-3模式是否已经激活,可以调用crypto/fips140.Enabled函数。

之前,一些场合用户使用BoringCrypto模块来实现某些FIPS 140-3算法的机制仍然可用,但已不被官方支持,并计划在未来版本中移除。另外要知道Go+BoringCrypto与原生FIPS 140-3模式并不兼容。这也是Microsoft Go依旧宣称将保留自己维护的符合fips140-3的Go版本的原因

5. 其他

最后重点说说WebAssembly port。

Go从Go 1.11版本开始通过js/wasm增加了对编译到Wasm的支持。Go 1.21版本又增加了对WASI的支持(GOOS=wasip1),Go 1.24版本中,Go对Wasm的支持又有了新的特性。在Go 1.24发布没多久,Cherry Mui便在官博发表了名为“Extensible Wasm Applications with Go”的介绍Go 1.24中WebAssembly新特性的文章,文章介绍了Go 1.24对Wasm的支持程度以及一些限制。这里也参考了这篇文章,简单梳理一下Cherry给出的内容要点。

Go 1.24引入了新的编译器指示符go:wasmexport,允许将Go函数导出,以便从Wasm模块外部(通常是从运行Wasm运行时的主机应用程序)调用。该指示符指示编译器将带注释的函数作为Wasm导出提供,在生成的Wasm二进制文件中可用,比如:

//go:wasmexport add
func add(a, b int32) int32 { return a + b }

这样,Wasm模块将具有一个名为add的导出函数,可以从主机调用。

这是如何实现的呢?Cherry告诉我们这是通过构建一种名为WASI Reactor的Wasm模块来实现的。WASI Reactor是一种持续运行的WebAssembly模块,可以多次调用以响应事件或请求。与在主函数完成后终止的“命令”模块不同,reactor实例在初始化后保持活动状态,其导出保持可访问状态。

在Go 1.24中,要构建一个WASI reactor需要使用下面命令:

$GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o reactor.wasm

该构建命令指示链接器不生成_start函数(wasm命令模块的入口点),而是生成_initialize函数(执行运行时和包初始化)以及任何导出的函数及其依赖项。_initialize函数必须在任何其他导出函数之前调用。而main函数不会自动调用。

go:wasmexport指示符和reactor构建模式允许通过调用基于Go的Wasm代码来扩展应用程序。这对于采用Wasm作为具有明确定义接口的插件或扩展机制的应用程序特别有价值。通过导出Go函数,应用程序可以利用Go Wasm模块提供功能,而无需重新编译整个应用程序。此外,构建为reactor可确保可以多次调用导出的函数而无需重新初始化,使其适用于长时间运行的应用程序或服务。

次卧,Go 1.24还放宽了对可用于go:wasmimport函数的输入和结果参数类型的限制。例如,可以传递bool、string、指向int32的指针或指向嵌入structs.HostLayout并包含受支持字段类型的结构体的指针,这使得Go Wasm应用程序可以用更自然的方式编写,并消除了不必要的类型转换。

不过,go:wasmexport当前也有局限性,首先,Wasm 是单线程架构,没有并行性。go:wasmexport标识的函数可以生成新的goroutine。但是,如果函数创建了后台goroutine,则当go:wasmexport指示的函数返回时,它将不会继续执行,直到回调到基于Go的Wasm模块。

另外,尽管Go 1.24中放宽了一些类型限制,但对于可与go:wasmimport和go:wasmexport函数一起使用的类型仍然存在限制。比如由于客户端的64位体系结构和主机的32位体系结构之间的不匹配,我们无法传递内存中的指针。例如,go:wasmimport指示的函数不能采用指向包含指针类型字段的结构体的指针。

但不可否认的是go:wasmexport的支持,让Go更稳固了自己成为主流wasm开发语言之一的位置,虽然还有各种不足。近期Docker之父的初创公司Dagger就发博客宣称使用了Go+WebAssembly重写了其Dagger Cloud的前端

6. 小结

Go 1.24的发布,标志着Go语言在保持其核心理念——简洁与兼容性的同时,进入了一个新的发展阶段。这个版本没有在语法上大刀阔斧,而是将重心放在了底层性能优化、工具链完善和新兴技术布局上,展现出Go团队务实且具有前瞻性的发展策略。同时,Go 1.24也可以看成是一个承上启下的版本。它既巩固了Go语言在性能和工具链方面的优势,又为未来的发展方向做出了积极的布局。Go语言正以稳健的步伐,朝着更高效、更安全、更具适应性的方向迈进。我们可以期待,在未来的版本中,Go将继续在云原生计算、WebAssembly、AI应用等领域发挥更大的作用,为开发者带来更多的惊喜。

借此文章插播一条国内Go社区的news!

近期GoCN社区发文“Farewell Go,Hello AI:是时候说再见了”和所有国内Go开发人员分享了“GoCN社区将正式转型升级为ThinkInAI社区”,全面拥抱AI的决定!这也意味国内最大Go技术社区的退出,最大Go技术大会GopherChina的正式落幕!除了AI是热门赛道这一原因之外,文章也给出了Go技术分享遇到瓶颈的说法:

不过就像文中所说的“这是任何技术发展到成熟阶段的必然现象”,在评论中一些Gopher也提到:一个技术不再被更多讨论是成熟的标志

这其实与我在《2024年Go语言盘点:排名历史新高,团队新老传承》一文中表达的Go演进趋势不谋而合!Go真的进入了成熟期了!

GoCn不在了,但go在国内的传播和使用依然会继续。请继续关注诸如Gopher Daily、我的公众号以及国内其他诸如像鸟窝老师的blog以及公众号,了解Go的最新动态以及技术理解。

最后感谢AstaXie(谢孟军)对国内Go社区发展所做出的卓越贡献,我也因有幸多次参与GopherChina以及会上分享而感到无比自豪。


Gopher部落知识星球在2025年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。并且,2025年将在星球首发“Go陷阱与缺陷”和“Go原理课”专栏!此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

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

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

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

© 2025, bigwhite. 版权所有.

关于Go错误处理新提案的一个想法:?操作符这样用行不行

2025-02-08 06:27:11

本文永久链接 – https://tonybai.com/2025/02/08/personal-idea-about-using-question-mark-operator-in-go-error-handling-new-proposal

0. 背景

Ian Taylor在关闭了旨在消除Go错误处理样板代码的issue之后,又另起了一个“同名”的discussion。错误处理真不愧是Go社区呼声最高的问题,几天之内又收到了近500条回复!不过到目前为止,依然没有形成统一和高赞的意见。

关于error handling的样板代码过多,其实我个人是可以接受的,即便不做出任何改变也是ok的,估计Go社区与我有相同看法的也不在少数。比如就有人引用了Rob Pike的权威观点,并认为Go应该按照Rob大神的思路,保持Go语法稳定:
 

 
不过自然也会有另外一批人强烈希望错误处理的样板代码得到改进。

Ian Taylor在discussion中明确了该提案的目标是引入一种新语法,在不影响控制流清晰度的前提下,减少正常情况下检查错误所需的代码量
 
Ian最初的Proposal由于隐式声明变量err以及可选代码块等问题而备受“批评”,并且似乎该proposal违反了他自己提出的目标。
 
今天在discussion中看到一位名为Mukunda Johnson的gopher的评论,我觉得很有道理。其核心观点就是:尽量保持Go的传统语法形式。他还给出了期望中的语法示例:

// 当前错误处理样板代码过多的示例
f, err := open(file)
if err != nil {
   return err
}
defer f.Close()

if err = binwrite(f, signature); err != nil {
   return err
}
if err = binwrite(f, header); err != nil {
   return err
}
if err = binwrite(f, zeroSegment); err != nil {
   return err
}

for _, s := range segments {
   if err = binwrite(f, s); err != nil {
      return err
   }
}

if err = binwrite(f, footer); err != nil {
   return err
}

vs. 

// 使用新语法改进后的代码
f, err := open(file)?
defer f.Close()

binwrite(f, signature)?
binwrite(f, header)?
binwrite(f, zeroSegment)?

for _, s := range segments {
   binwrite(f, s)?
}

binwrite(f, footer)?

这给了我很大启发:我们可以引入?语法,但是如果结合原先err变量的声明形式岂不是更好!比如:

f, err := open(file)?

岂不是要比下面两种形式更好!

f := open(file)?

或

f := open(file)? err { }

通过仅引入一个问号(?)操作符,并避免引入过多的新语法形式,却能解决60%的错误处理样板问题。根据jba对Go开源代码中错误处理的抽样统计,超过60%的错误处理都是直接返回err,而没有对err进行任何修饰。此外,显式声明err可以最大程度地避免隐式声明带来的问题,同时提升代码的可读性。

因此,基于尽量使用已有Go代码风格、最大程度避免隐式声明,并仅解决最常见的错误处理样板代码的原则,下面我基于Ian提案的错误处理改进语法,谈点自己关于新?操作符使用的想法,大家看看是否可行。

1. 对于最常见的未经修饰的错误处理代码

err := SomeFunction2()
if err != nil {
    return err
}

或是

if err := SomeFunction2(); err != nil {
    return err
}

我们使用下面的新语法做等价替代:

err := SomeFunction2() ?

如果声明的错误变量名为err,也可省略赋值操作符左侧代码,从而简化为:

SomeFunction2() ? // 这里略带隐式

2. 如果函数返回值有多个,甚至有多个错误变量的情况

比如下面代码:

a, b, err0, err1, err2 := SomeFunction3()
if err2 != nil {
    return err2
}

我们可以将其改写为:

a, b, err0, err1, err2 := SomeFunction3()?

其语义是如果err2不为nil,返回err2,但前提要保证赋值语句的左侧的最后一个变量err2必须是实现error接口的类型的变量。

如果是像下面这样在err2 != nil时有多个返回值,又该如何处理呢?

a, b, err0, err1, err2 := SomeFunction3()
if err2 != nil {
    return a, b, err2
}

对于这种情况,我认为可以不在新方案的考虑范围之内,现在怎么写,请继续这么写。如果非要解决,请继续看后面支持可选代码块的情况。

实现以上两种情况,就能解决60%以上的错误样板代码问题了!

3. 对于对返回的error值进行修饰的情况

对于像下面两种对返回的error变量进行修饰的情况:

r, err := SomeFunction()
if err != nil {
    return fmt.Errorf("something failed: %v", err)
}

if err := SomeFunction2(); err != nil {
    return fmt.Errorf("something else failed: %v", err)
}

我的第一想法是保持现状 ,不在新方案考虑范围之内。

不过如果非要在新方案中解决,那就需要引入可选代码块(optional block)了!比如:

r, err := SomeFunction() ? {
    return fmt.Errorf("something failed: %v", err)
}

err := SomeFunction2() ? {
    return fmt.Errorf("something else failed: %v", err)
}

和Ian的原proposal中语法不同,这里我们依然显式声明了err,当然你也可以不用err这个名字,由于是显式声明,你用任何名字均可,比如:

r, e := SomeFunction() ? {
    return fmt.Errorf("something failed: %v", e)
}

myErr := SomeFunction2() ? {
    return fmt.Errorf("something else failed: %v", myErr)
}

这将避免隐式声明带来的诸多问题!

基于可选代码块,我们也可以处理一下前面提到的返回多个值的情况。下面代码

a, b, err0, err1, err2 := SomeFunction3()
if err2 != nil {
    return a, b, err2
}

可以改写为:

a, b, err0, err1, err2 := SomeFunction3() ? {
    return a, b, err2
}

这里加入可选代码块后,我建议开发人员负责显式调用return,而不是由?操作符来自动return,也就是说完全将控制权交给你。如果你没有在可选代码块中调用return,那么代码在执行完可选代码块中的代码后,还会继续向下执行。可选代码块相当于一个error handler,而不带可选代码块的情况,默认的error handler其实就是一个return err,伪代码类似这样:

err := SomeFunction2() ?

<=>

err := SomeFunction2() ? {
    return err
}

这样解释后,你是不是觉得在语义层面,不带可选代码块与带有可选代码块的情况就统一和一致了呢!

本质上来说,?+可选代码块仅是让你少敲了个if以及err != nil

4. 综合示例

Mukunda Johnson给出的示例其实已经可以很好地展示?操作符+显式声明err方案带来的消除样板代码的效果,这里再回顾一下(这里没用到可选代码块,因此代码显得格外清晰):

f, err := open(file)?
defer f.Close()

binwrite(f, signature)?
binwrite(f, header)?
binwrite(f, zeroSegment)?

for _, s := range segments {
   binwrite(f, s)?
}

binwrite(f, footer)?

此外,在原discussion中,另外一个gopher提出的示例,我们也可以用上面的想法改写一下:

// 最常见的情况
SomeFunc() ?

// 多个返回值,最后一个为error变量
a, err1 := SomeFunction2() ?

// 返回前对err进行修饰
err := SomeFunc() ? {
  return fmt.Errorf("oh no: %w", err)
}

// 显式声明避免变量遮蔽
err := SomeFunc() ?  {
  otherErr := OtherFunc() ?  {
    err = errors.Wrap(err, otherErr) // 在可选代码块中没有显式调用return,代码还会继续向后执行
  }
  return fmt.Errorf("oh no: %w", err)
}

5. 小结

再来简单总结一下上面想法中的语法形式的优势:

  • 与传统Go语法形式几乎一致,尽量避免引入过多新语法形式,在不使用可选代码块的时候,只是多了一个问号(?)。
  • 显式声明err变量,最大程度避免隐式声明带来的问题。
  • 专注解决最常见的错误处理样板情景,其他场景保持当前写法即可。
  • 即便引入可选代码块,本质上与不用可选代码块的语法在语义层面也是统一和一致的。

这一语法方案保留了原Ian提案中的优势,并能消除一些缺点,如变量遮蔽和隐式声明等。不过,仍然有些原proposal中的劣势问题无法完全消除,但这些问题显然不是主要关注点。

需要注意的是,以上想法目前仅停留在形式讨论层面,技术层面是否可行尚不确定。

大家认为我的想法可行吗?希望大家能提出更具建设性的意见^_^。


Gopher部落知识星球在2025年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。并且,2025年将在星球首发“Go陷阱与缺陷”和“Go原理课”专栏!此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

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

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

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

© 2025, bigwhite. 版权所有.

Go encoding/json/v2提案:JSON处理新引擎

2025-02-05 18:43:35

本文永久链接 – https://tonybai.com/2025/02/05/go-encoding-json-v2-proposal-json-processing-new-engine

Go标准库中的encoding/json包,作为Go社区广泛使用的JSON处理工具,至今已走过十余年。凭借其将JSON数据与原生Go类型相互转换的能力、通过struct tag自定义字段表示的灵活性,以及Go类型自定义JSON格式的特性,赢得了Go开发者的青睐。

然而,随着时间的推移,encoding/json的局限性也逐渐显现。孤立地解决这些问题可能会导致非正交特性之间产生意外的交互。因此,Go团队在2023年下旬发起了关于encoding/json/v2的讨论,旨在全面审视现有encoding/json包,并提出一个面向未来十年的Go JSON处理方案,打造一个 JSON处理新引擎

经过近一年多的讨论、设计调整以及参考实现的优化,Go团队于近期正式提出了关于encoding/json/v2的提案issue。在该issue中,Go团队梳理并总结了讨论结果以及初始设计与后续调整之间的差异,以供Go社区进一步审阅与反馈。

为了让大家更好地了解该提案issue的核心内容,本文将对提案的背景、主要内容、相对于v1版本的主要改进,以及与v1版本的联系等进行全面介绍,希望通过这篇文章,大家能够及时了解到Go标准库json包的演进与变化。

1. 提案背景:现有encoding/json包的局限与改进的需求

在过去十年中,开发者在使用encoding/json的过程中,逐渐意识到了它在功能、API设计、性能和行为上存在的不足。这些问题可以归纳为以下几个方面,也正是encoding/json/v2提案希望解决的核心痛点:

1.1 功能缺失 (Missing functionality)

尽管encoding/json功能完善,但仍存在一些重要的功能缺失,社区也为此提出了诸多Feature Request,其中最突出的包括:

  • time.Time的自定义格式化 (#21990): 缺乏灵活的方式来指定time.Time类型在JSON中的格式,例如自定义日期时间字符串格式。
  • Marshal时忽略特定Go值 (#11939, #22480, #50480, #29310, #52803, #45669): 现有的omitempty标签在某些场景下无法满足需求,开发者希望更精细地控制哪些Go值在Marshal时被忽略,例如忽略零值、空值或特定条件下的值。Go 1.24版本增加了omitzero tag,将在一定层度缓解这个问题。
  • 将nil切片和mapMarshal为空JSON数组和对象 (#37711, #27589): encoding/json默认将nil切片和mapMarshal为JSONnull,但在某些场景下,开发者更期望将其Marshal为空的JSON数组[]和对象{}。
  • 无Go嵌入的Inline类型 (#6213): 希望能够更灵活地将Go类型内联到JSON对象中,而无需依赖Go的struct嵌入机制。

虽然这些功能缺失大部分可以通过向现有encoding/json包添加新功能来解决,但可能会导致API变得臃肿和复杂。

1.2 API 设计缺陷 (API deficiencies)

encoding/json的API设计存在一些尖锐或限制性的问题,影响了开发者的使用体验:

  • 难以正确地从io.Reader进行Unmarshal: 常用的json.NewDecoder(r).Decode(v)方法并不能正确处理JSON payload末尾的垃圾数据 (#36225),容易导致数据解析错误。
  • Marshal和Unmarshal函数无法使用Options: 虽然Encoder和Decoder类型支持Options配置,但Marshal和Unmarshal函数却无法使用,同样,实现Marshaler和Unmarshaler接口的类型也无法利用Options,缺乏选项配置的传递机制(#41144)。
  • Compact, Indent, HTMLEscape函数输出目标受限: 这些格式化函数只能将结果写入bytes.Buffer,而不是更灵活的[]byte或io.Writer,限制了函数的使用场景。

这些API缺陷可以通过向现有encoding/json包引入新的API来修复,但这可能会导致同一个任务在同一个包中存在多种不同的实现方式,增加学习成本和使用困惑。

1.3 性能限制 (Performance limitations)

encoding/json的性能表现一直备受关注,存在诸多限制性能提升的因素:

  • MarshalJSON接口: 强制实现者分配[]byte返回值,且encoding/json需要再次解析返回值以验证JSON的有效性并重新格式化,造成不必要的性能开销。
  • UnmarshalJSON接口: 要求提供完整的JSON value,导致encoding/json需要预先完整解析JSON值以确定边界,之后UnmarshalJSON方法本身还需要再次解析,如果UnmarshalJSON递归调用Unmarshal,则会导致O(N²)的性能退化,例如Kubernetes kube-openapi项目在Unmarshalspec.Swagger时遇到的性能瓶颈 (kubernetes/kube-openapi#315)。
  • Encoder.WriteToken: 缺乏流式Encoder API,虽然提案已被接受(#40127),但尚未实现,且可能同样存在性能问题。
  • Decoder.Token: Token类型是一个接口,可以容纳多种类型 (Delim, bool, float64, Number, string, nil),当boxing数字或字符串到Token接口类型时,会频繁发生内存分配 (#40128)。
  • 缺乏真正的流式处理: 即使Encoder.Encode和Decoder.Decode方法操作io.Writer和io.Reader,它们仍然会将整个JSON value缓冲到内存中,需要二次扫描JSON,与流式处理的初衷背道而驰 (#33714, #7872, #11046)。

encoding/json应该默认以真正的流式方式操作io.Writer和io.Reader。缓冲整个JSON value违背了使用io.Reader和io.Writer的意义。希望避免在发生错误时输出JSON 的用例应该调用Marshal,并在错误为nil时才写入输出。不幸的是,encoding/json无法默认切换到流式处理,因为这将是一个破坏性的行为变更,暗示着需要一个v2版本的json包来实现这个目标。

1.4 行为缺陷 (Behavioral flaws)

encoding/json在行为上存在诸多缺陷,随着JSON规范的日益严格 (RFC 4627, RFC 7159, RFC 7493, RFC 8259),这些缺陷显得愈发突出:

  • JSON 语法处理不严谨: encoding/json允许无效UTF-8字符,而最新的互联网标准 (RFC 8259) 要求使用有效的UTF-8编码。默认行为至少应符合RFC 8259,将无效UTF-8视为错误。
  • 允许重复的对象成员名称: RFC8259规定,重复的对象成员名称会导致未指定的行为。从安全角度考虑,默认行为应更严格,拒绝重复名称,正如 RFC 7493 所建议的那样。
  • Unmarshal时大小写不敏感: Unmarshal时,JSON对象名称与Go struct字段名称使用大小写不敏感匹配 (#14750),这既令人意外,也可能存在安全漏洞和性能瓶颈。
  • 类型定义方法调用不一致: 由于encoding/json及其对Go反射的使用,MarshalJSON和UnmarshalJSON方法在底层值不可寻址时无法调用 (#22967, #27722, #33993, #55890)。
  • Merge语义不一致: Unmarshal到非空的Go值时,是否清除目标、重置并重用目标内存、或合并到目标的行为不一致 (#27172, #31924, #26946)。
  • Error 类型不一致: encoding/json返回的Error类型不一致,难以可靠地检测Syntactic error, Semantic error, I/O error等不同类型的错误。

这些行为缺陷在不破坏向后兼容性的前提下难以修复。虽然可以添加选项来指定不同的行为,但这并非理想方案,因为期望的行为不应作为非默认选项存在。改变默认行为同样意味着需要一个v2版本的json包。

为了解决上述encoding/json包的种种问题,并为Go语言构建更强大、更现代化的JSON处理能力,Go团队正式提出了encoding/json/v2提案。正如“JSON处理新引擎”这个本文标题所寓意的,encoding/json/v2并非简单的修补和改进,而是一次对Go语言JSON处理的彻底革新。下面我们就来介绍一下这个新json引擎的主要功能和特点。

2. encoding/json/v2:Go JSON处理的新引擎

encoding/json/v2提案并非简单地对现有encoding/json进行升级,而是引入了两个全新的包:

  • encoding/json/jsontext: 这是一个纯语法层面的JSON处理包,专注于JSON语法的解析和生成,不依赖Go反射。它提供了对JSON令牌(Token)和原始值(Value)的操作,允许开发者在语法层面精细地控制JSON的编解码过程。
  • encoding/json/v2: 这是一个语义层面的JSON处理包,基于jsontext包实现,并依赖Go反射。它继承了encoding/json的核心功能,负责将Go值与JSON数据进行语义上的转换(Marshal 和 Unmarshal),并提供了更丰富的功能和更优的性能。

提案中还给出了两者的关系图,通过该图大家可以更直观地看出两个包之间的关系:

此外,提案还考虑了与现有encoding/json的兼容性,并提供了选项来实现互操作。encoding/json包本身也将被重构,底层实现将基于encoding/json/v2来重新实现

下面是对jsontext包和json/v2包的核心API的介绍。

2.1 encoding/json/jsontext包的关键API

jsontext包提供了Encoder和Decoder类型,用于JSON的编码和解码,以及Token和Value类型来表示JSON的语法元素。

package jsontext // "encoding/json/jsontext"

type Encoder struct { /* no exported fields */ }
func NewEncoder(io.Writer, ...Options) *Encoder
func (*Encoder) WriteToken(Token) error
func (*Encoder) WriteValue(Value) error

type Decoder struct { /* no exported fields */ }
func NewDecoder(io.Reader, ...Options) *Decoder
func (*Decoder) PeekKind() Kind
func (*Decoder) ReadToken() (Token, error)
func (*Decoder) ReadValue() (Value, error)
func (*Decoder) SkipValue() error

type Kind byte // JSON 令牌类型
type Token struct { /* no exported fields */ } // JSON 令牌
type Value []byte // JSON 原始值

其中:

  • Encoder和Decoder: 提供流式的JSON编码和解码能力,操作io.Writer和io.Reader。
  • Token: 表示JSON的基本语法单元,例如null, true, false, 字符串, 数字, 对象开始{, 对象结束}, 数组开始[, 数组结束]等。
  • Value: 表示JSON的原始值,可以是完整的JSON对象或数组,类似于encoding/json中的RawMessage。
  • Kind: 枚举类型,表示Token和Value的类型,例如’n'(null),’t'(true),’”‘(string),’{‘(object start) 等。

jsontext包还提供了格式化JSON的函数,例如AppendFormat, AppendQuote, AppendUnquote等,以及用于配置行为的Options类型。

2.2 encoding/json/v2包的关键API

encoding/json/v2包提供了Marshal, Unmarshal等核心函数,以及MarshalWrite, MarshalEncode, UnmarshalRead, UnmarshalDecode等变体,用于不同场景下的JSON编解码。

package json // "encoding/json/v2"

func Marshal(in any, opts ...Options) (out []byte, err error)
func MarshalWrite(out io.Writer, in any, opts ...Options) error
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error

func Unmarshal(in []byte, out any, opts ...Options) error
func UnmarshalRead(in io.Reader, out any, opts ...Options) error
func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error

其中:

  • Marshal和Unmarshal: 核心的Marshal和Unmarshal函数,与encoding/json中的函数签名类似,但行为有所改进。
  • MarshalWrite, UnmarshalRead: 直接操作io.Writer和io.Reader,避免中间[]byte的分配。
  • MarshalEncode, UnmarshalDecode: 操作jsontext.Encoder和jsontext.Decoder,提供更底层的流式编解码能力。
  • Options: 用于配置Marshal和Unmarshal的行为,例如大小写敏感性、omitempty语义、错误处理等。

encoding/json/v2包还引入了更丰富的struct tag选项,例如omitzero, omitempty, string, nocase, strictcase, inline,unknown,format等,提供更灵活的字段映射和格式化控制。

2.3 设计原则

下面是该proposal的一些设计原则梳理:

  • 分离语法与语义: 明确区分JSON的语法处理(jsontext)和语义处理(json/v2),使得开发者可以根据需求选择合适的API。
  • 流式处理: 提供流式的Encoder和Decoder,支持高效处理大规模JSON数据,避免一次性加载整个JSON文档到内存。
  • 选项化配置: 通过Options类型提供丰富的配置选项,允许开发者根据具体需求定制JSON编解码的行为,例如大小写敏感性、格式化风格、错误处理方式等。
  • 改进错误处理: 引入SyntacticError和SemanticError类型,提供更详细的错误信息,包括错误发生的位置 (JSON Pointer) 和具体的错误原因,方便问题定位和调试。
  • 兼容性与迁移: encoding/json/v2尽可能兼容现有的encoding/json的行为,并提供选项 (DefaultOptionsV1) 来模拟v1的行为,方便用户平滑迁移。

接下来,我们再来看看json/v2相对于之前版本的提升与改进!

3. 相对于encoding/json的提升与改进

encoding/json/v2相对于现有的encoding/json包,在多个方面进行了显著的提升和改进:

3.1 性能提升

jsontext包采用更高效的语法解析算法,json/v2在语义处理方面也进行了优化,整体性能相比encoding/json有显著提升,尤其在反序列化和流式处理方面。Benchmark 测试显示,encoding/json/v2的反序列化速度比encoding/json快2.7x到10.2x:

以具体类型为例,下面是github.com/go-json-experiment/jsonbench给出的benchmark结果:


3.2 更正的行为

encoding/json/v2修正了encoding/json中一些行为不一致性和历史遗留问题,例如:

  • 大小写敏感的字段匹配: 默认采用严格的大小写敏感匹配,更符合JSON规范,并通过MatchCaseInsensitiveNames和nocasetag选项提供大小写不敏感匹配的灵活性。
  • 重新定义omitempty语义: omitempty基于JSON类型系统重新定义,更加清晰和一致,并通过OmitEmptyWithLegacyDefinition选项提供兼容v1 行为的选择。
  • nil切片和map的处理: 默认将nil切片和mapMarshal为空JSON数组和对象,而非null,并通过FormatNilSliceAsNull和FormatNilMapAsNull选项提供Marshal为null的选择。
  • 字节数组的表示: 默认将[]\byteMarshal为Base64编码的JSON字符串,而非JSON数字数组,并通过FormatBytesWithLegacySemantics和format:arraytag 选项提供兼容v1行为的选择。
  • 方法调用的可寻址性: MarshalJSON方法无论Go值是否可寻址都可调用,更符合预期。
  • Map Key 的方法调用: MarshalJSON和UnmarshalJSON方法可以用于 Map Key,提供更强大的自定义能力。
  • 确定性输出: 通过Deterministic选项,可以保证相同输入Marshal出相同的JSON字节序列。
  • 最小化转义: 默认使用最小化的JSON字符串转义,仅在必要时进行转义,例如只在HTML或JavaScript环境下才进行特殊字符的转义。
  • UTF-8 验证: 默认严格验证UTF-8编码,拒绝包含无效UTF-8的JSON输入,并通过AllowInvalidUTF8选项允许处理无效UTF-8。
  • 重复Key错误: 默认拒绝JSON对象中存在重复的Key,更符合JSON 规范,并通过AllowDuplicateNames选项允许处理重复Key。
  • Null值的Unmarshal: Unmarshal JSONnull时,始终一致地将Go值置零。
  • Unmarshal合并行为: Unmarshal JSON对象时,默认合并到已有的Go值,而非完全替换,提供更灵活的更新语义。
  • time.Duration的表示: 默认将time.DurationMarshal为JSON 字符串,而非纳秒数字,并通过FormatTimeWithLegacySemantics和format:nanotag选项提供兼容v1行为的选择。
  • 运行时错误报告: 对Go结构体类型中的结构性错误(例如错误的tag选项)进行运行时错误报告,提前发现问题。

3.3 更灵活的 API

jsontext包提供了更底层的API,允许开发者直接操作JSON token和原始值,实现更精细的JSON处理逻辑。json/v2提供了更多的选项和 struct tag 选项,支持更丰富的自定义需求。

3.4 更清晰的错误信息

SyntacticError和SemanticError类型提供了更详细的错误信息,包括错误位置 (JSON Pointer) 和错误原因,方便问题排查。

4. encoding/json与encoding/json/v2的联系

encoding/json/v2提案的一个重要目标是实现与现有encoding/json的平滑过渡。为此,提案采取了以下策略:

  • encoding/json基于encoding/json/v2实现: 未来的encoding/json包将完全基于encoding/json/v2包进行重构,这意味着encoding/json/v2将成为Go语言官方JSON处理的核心引擎。
  • DefaultOptionsV1选项: encoding/json包将提供DefaultOptionsV1选项,该选项预设了一系列兼容v1行为的配置,使得encoding/json的默认行为尽可能与旧版本保持一致。
  • 互操作选项: encoding/json/v2和encoding/json都提供了大量的选项,允许开发者在v1和v2行为之间进行灵活切换,逐步迁移到v2的新特性。

5. 示例代码

以下示例展示了encoding/json/v2的基本用法(示例改自https://github.com/go-json-experiment/json/blob/master/example_test.go):

package main

import (
    "fmt"
    "log"

    "github.com/go-json-experiment/json"
    "github.com/go-json-experiment/json/jsontext"
)

func main() {
    var value struct {
        // This field is explicitly ignored with the special "-" name.
        Ignored any `json:"-"`
        // No JSON name is not provided, so the Go field name is used.
        GoName any
        // A JSON name is provided without any special characters.
        JSONName any `json:"jsonName"`
        // No JSON name is not provided, so the Go field name is used.
        Option any `json:",nocase"`
        // An empty JSON name specified using an single-quoted string literal.
        Empty any `json:"''"`
        // A dash JSON name specified using an single-quoted string literal.
        Dash any `json:"'-'"`
        // A comma JSON name specified using an single-quoted string literal.
        Comma any `json:"','"`
        // JSON name with quotes specified using a single-quoted string literal.
        Quote any `json:"'\"\\''"`
        // An unexported field is always ignored.
        unexported any
    }

    b, err := json.Marshal(value)
    if err != nil {
        log.Fatal(err)
    }
    (*jsontext.Value)(&b).Indent() // indent for readability
    fmt.Println(string(b))
}

这段示例代码旨在演示github.com/go-json-experiment/json (即提案中encoding/json/v2的参考实现) 在处理Go结构体字段的json tag时,对于不同命名约定和特殊字符的处理方式。结构体value定义了多个字段,每个字段都使用了不同的json tag,用于演示不同的命名和选项,具体选项含义可以参考proposal中的说明。

(*jsontext.Value)(&b).Indent() // indent for readability

前面说过,jsontext是操作json语法的包,json缩进的工作就交给了该包的Value的Indent方法。在encoding/json中,我们通常直接用MarshalIndent来进行格式化json的工作。

运行上述示例将输出如下结果:

$go run main.go
{
    "GoName": null,
    "jsonName": null,
    "Option": null,
    "": null,
    "-": null,
    ",": null,
    "\"'": null
}

更多示例,可以参见 https://github.com/go-json-experiment/json/blob/master/example_test.go源文件。

6. 小结

encoding/json/v2提案代表了Go语言在JSON处理方面的一次重大升级。通过引入jsontext和json/v2两个包,并提供更强大的API、更丰富的选项和更优的性能,encoding/json/v2将为Go开发者带来更高效、更灵活、更可靠的JSON处理体验。同时,该提案也充分考虑了与现有encoding/json的兼容性,为用户平滑迁移提供了保障。encoding/json/v2的引入,无疑将进一步提升Go语言在Web开发、数据处理等领域的竞争力,为Go开发者构建下一代应用提供更强大的JSON处理新引擎

7. 参考资料


Gopher部落知识星球在2025年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。并且,2025年将在星球首发“Go陷阱与缺陷”和“Go原理课”专栏!此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

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

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

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

© 2025, bigwhite. 版权所有.

Go导出标识符:那些鲜为人知的细节

2025-01-23 03:24:06

本文永久链接 – https://tonybai.com/2025/01/23/the-hidden-details-of-go-exported-identifiers

前不久,在“Go+用户组”微信群里看到有开发者向七牛云老板许式伟反馈七牛云Go SDK中的某些类型没有导出,导致外部包无法使用的问题(如下图)

七牛开发人员迅速对该问题做出了“更正”,将问题反馈中涉及的类型saveasArgs和saveasReply改为了导出类型,即首字母大写:

不过,这看似寻常的问题反馈与修正却引发了我的一些思考。

我们大胆臆想一下:如果saveasReply类型的开发者是故意将saveasReply类型设置为非导出的呢?看一下“更正”之前的saveasReply代码:

type saveasReply struct {
    Fname       string `json:"fname"`
    PersistenId string `json:"persistentId,omitempty"`
    Bucket      string `json:"bucket"`
    Duration    int    `json:"duration"` // ms
}

有读者可能会问:那为什么还将saveasReply结构体的字段设置为导出字段呢?请注意每个字段后面的结构体标签(struct tag)。这显然是为了进行JSON 编解码,因为目前Go的encoding/json包仅会对导出字段进行编解码处理。

除了这个原因,原开发者可能还希望包的使用者能够访问这些导出字段,而又不想完全暴露该类型。我在此不对这种设计的合理性进行评价,而是想探讨这种做法是否可行。

我们对Go导出标识符的传统理解是:导出标识符(以大写字母开头的标识符)可以在包外被访问和使用,而非导出标识符(以小写字母开头的标识符)只能在定义它们的包内访问。这种机制帮助开发者控制类型和函数的可见性,确保内部实现细节不会被随意访问,从而增强封装性。

但实际上,Go的导出标识符机制是否允许在某些情况下,即使类型本身是非导出的,其导出字段依然可以被包外的代码访问呢?该类型的导出方法呢?这些关于Go导出标识符的细节可能是鲜少人探讨的,在这篇博文中,我们将系统地了解这些机制,希望能为各位小伙伴带来更深入的理解。

1. Go对导出标识符的定义

我们先回顾一下Go语言规范(go spec)对导出标识符的定义

我们通常使用英文字母来命名标识符,因此可以将上述定义中的第一句理解为:以大写英文字母开头的标识符即为导出标识符。

注:Unicode字符类别Lu(Uppercase Letter)包含所有的大写字母。这一类别不仅包括英文大写字母,还涵盖多种语言的大写字符,例如希腊字母、阿拉伯字母、希伯来字母和西里尔字母等。然而,我非常不建议大家使用非英文大写字母来表示导出标识符,因为这可能会挑战大家的认知习惯。

而第二句后半部分的描述往往被我们忽视或理解不够到位。一个类型的字段名和方法名可以是导出的,但并没有明确要求其关联的类型本身也必须是导出的

这为我们提供了进一步探索Go导出标识符细节的机会。接下来,我们就用具体示例看看是否可以在包外访问非导出类型的导出字段以及导出方法。

2. 在包外访问非导出类型的导出字段

我们首先定义一个带有导出字段的非导出类型myStruct,并将它放在mypackage里:

// go-exported-identifiers/field/mypackage/mypackage.go

package mypackage

type myStruct struct {
    Field string // 导出的字段
}

// NewMyStruct1是一个导出的函数,返回myStruct的指针
func NewMyStruct1(value string) *myStruct {
    return &myStruct{Field: value}
}

// NewMyStruct1是一个导出的函数,返回myStruct类型变量
func NewMyStruct2(value string) myStruct {
    return myStruct{Field: value}
}

然后我们在包外尝试访问myStruct类型的导出字段:

// go-exported-identifiers/field/main.go

package main

import (
    "demo/mypackage"
    "fmt"
)

func main() {
    // 通过导出的函数获取myStruct的指针
    ms1 := mypackage.NewMyStruct1("Hello1")

    // 尝试访问Field字段
    fmt.Println(ms1.Field) // Hello1

    // 通过导出的函数获取myStruct类型变量
    ms2 := mypackage.NewMyStruct1("Hello2")

    // 尝试访问Field字段
    fmt.Println(ms2.Field) // Hello2
}

在go-exported-identifiers/field目录下编译运行该示例:

$go run main.go
Hello1
Hello2

我们看到,无论是通过myStruct的指针还是实例副本,都可以成功访问其导出变量Field。这个示例的关键就是:我们使用了短变量声明直接通过调用myStruct的两个“构造函数(NewXXX)”得到了其指针(ms1)以及实例副本(ms2)。在这个过程中,我们没有在main包中显式使用mypackage.myStruct这个非导出类型。

采用类似的方案,我们接下来再看看是否可以在包外访问非导出类型的导出方法。

3. 在包外访问非导出类型的导出方法

我们为非导出类型添加两个导出方法M1和M2:

// go-exported-identifiers/method/mypackage/mypackage.go

package mypackage

import "fmt"

type myStruct struct {
    Field string // 导出的字段
}

// NewMyStruct1是一个导出的函数,返回myStruct的指针
func NewMyStruct1(value string) *myStruct {
    return &myStruct{Field: value}
}

// NewMyStruct1是一个导出的函数,返回myStruct类型变量
func NewMyStruct2(value string) myStruct {
    return myStruct{Field: value}
}

func (m *myStruct) M1() {
    fmt.Println("invoke *myStruct's M1")
}

func (m myStruct) M2() {
    fmt.Println("invoke myStruct's M2")
}

然后,试着在外部包中调用M1和M2方法:

// go-exported-identifiers/method/main.go

package main

import (
    "demo/mypackage"
)

func main() {
    // 通过导出的函数获取myStruct的指针
    ms1 := mypackage.NewMyStruct1("Hello1")
    ms1.M1()
    ms1.M2()

    // 通过导出的函数获取myStruct类型变量
    ms2 := mypackage.NewMyStruct2("Hello2")
    ms2.M1()
    ms2.M2()
}

在go-exported-identifiers/method目录下编译运行这个示例:

$go run main.go
invoke *myStruct's M1
invoke myStruct's M2
invoke *myStruct's M1
invoke myStruct's M2

我们看到,无论是通过非导出类型的指针,还是通过非导出类型的变量复本都可以成功调用非导出类型的导出方法。

提及方法,我们会顺带想到接口,非导出类型是否可以实现某个外部包定义的接口呢?我们继续往下看。

4. 非导出类型实现某个外部包的接口

在Go中,如果某个类型T实现了某个接口类型I的方法集合中的所有方法,我们就说T实现了I,T的实例可以赋值给I类型的接口变量。

在下面示例中,我们看看非导出类型是否可以实现某个外部包的接口。

在这个示例中mypackage包中的内容与上面示例一致,主要改动的是main.go,我们来看一下:

// go-exported-identifiers/interface/main.go

package main

import (
    "demo/mypackage"
)

// 定义一个导出的接口
type MyInterface interface {
    M1()
    M2()
}

func main() {
    var mi MyInterface

    // 通过导出的函数获取myStruct的指针
    ms1 := mypackage.NewMyStruct1("Hello1")
    mi = ms1
    mi.M1()
    mi.M2()

    // 通过导出的函数获取myStruct类型变量
    // ms2 := mypackage.NewMyStruct2("Hello2")
    // mi = ms2 // compile error: mypackage.myStruct does not implement MyInterface
    // ms2.M1()
    // ms2.M2()
}

在这个main.go中,我们定义了一个接口MyInterface,它的方法集合中有两个方法M1和M2。根据类型方法集合的判定规则,*myStruct类型实现了MyInterface的所有方法,而myStruct类型则不满足,没有实现M1方法,我们在go-exported-identifiers/interface目录下编译运行这个示例,看看是否与我们预期的一致:

$go run main.go
invoke *myStruct's M1
invoke myStruct's M2

如果我们去掉上面代码中对ms2的注释,那么将得到Compiler error: mypackage.myStruct does not implement MyInterface。

注:关于一个类型的方法集合的判定规则,可以参考我的极客时间《Go语言第一课》专栏的第25讲

接下来,我们再来考虑一个场景,即非导出类型用作嵌入字段的情况,我们要看看该非导出类型的导出方法和导出字段是否会promote到外部类型中。

5. 非导出类型用作嵌入字段

我们改造一下示例,新版的带有嵌入字段的结构见下面mypackage包的代码:

// go-exported-identifiers/embedded_field/mypackage/mypackage.go

package mypackage

import "fmt"

type nonExported struct {
    Field string // 导出的字段
}

// Exported 是导出的结构体,嵌入了nonExported
type Exported struct {
    nonExported // 嵌入非导出结构体
}

func NewExported(value string) *Exported {
    return &Exported{
        nonExported: nonExported{
            Field: value,
        },
    }
}

// M1是导出的函数
func (n *nonExported) M1() {
    fmt.Println("invoke nonExported's M1")
}

// M2是导出的函数
func (e *Exported) M2() {
    fmt.Println("invoke Exported's M2")
}

这里新增一个导出类型Exported,它嵌入了一个非导出类型nonExported,后者拥有导出字段Field,以及两个导出方法M1。我们也Exported类型定义了一个方法M2。

下面我们再来看看main.go中是如何使用Exported的:

// go-exported-identifiers/embedded_field/main.go

package main

import (
    "demo/mypackage"
    "fmt"
)

// 定义一个导出的接口
type MyInterface interface {
    M1()
    M2()
}

func main() {
    ms := mypackage.NewExported("Hello")
    fmt.Println(ms.Field) // 访问嵌入的非导出结构体的导出字段

    ms.M1() // 访问嵌入的非导出结构体的导出方法

    var mi MyInterface = ms
    mi.M1()
    mi.M2()
}

在go-exported-identifiers/embedded_field目录下编译运行这个示例:

$go run main.go
Hello
invoke nonExported's M1
invoke nonExported's M1
invoke Exported's M2

我们看到,作为嵌入字段的非导出类型的导出字段与方法会被自动promote到外部类型中,通过外部类型的变量可以直接访问这些字段以及调用这些导出方法。这些方法还可以作为外部类型方法集中的一员,来作为满足特定接口类型(如上面代码中的MyInterface)的条件。

Go 1.18增加了泛型支持,那么非导出类型是否可以用作泛型函数和泛型类型的类型实参呢?最后我们来看看这个细节。

6. 非导出类型用作泛型函数和泛型类型的类型实参

和前面一样,我们先定义用于该示例的带有导出字段和导出方法的非导出类型:

// go-exported-identifiers/generics/mypackage/mypackage.go

package mypackage

import "fmt"

// 定义一个非导出的结构体
type nonExported struct {
    Field string
}

// 导出的方法
func (n *nonExported) M1() {
    fmt.Println("invoke nonExported's M1")
}

func (n *nonExported) M2() {
    fmt.Println("invoke nonExported's M2")
}

// 导出的函数,用于创建非导出类型的实例
func NewNonExported(value string) *nonExported {
    return &nonExported{Field: value}
}

现在我们将其用于泛型函数,下面定义了泛型函数UseNonExportedAsTypeArgument,它的类型参数使用MyInterface作为约束,而上面的nonExported显然满足该约束,我们通过构造函数NewNonExported获得非导出类型的实例,然后将其传递给UseNonExportedAsTypeArgument,Go会通过泛型的类型参数自动推导机制推断出类型实参的类型:

// go-exported-identifiers/generics/main.go

package main

import (
    "demo/mypackage"
)

// 定义一个用作约束的接口
type MyInterface interface {
    M1()
    M2()
}

func UseNonExportedAsTypeArgument[T MyInterface](item T) {
    item.M1()
    item.M2()
}

// 定义一个带有泛型参数的新类型
type GenericType[T MyInterface] struct {
    Item T
}

func NewGenericType[T MyInterface](item T) GenericType[T] {
    return GenericType[T]{Item: item}
}

func main() {
    // 创建非导出类型的实例
    n := mypackage.NewNonExported("Hello")

    // 调用泛型函数,传入实现了MyInterface的非导出类型
    UseNonExportedAsTypeArgument(n) // ok

    // g := GenericType{Item: n} // compiler error: cannot use generic type GenericType[T MyInterface] without instantiation
    g := NewGenericType(n)
    g.Item.M1()
}

但由于目前Go泛型还不支持对泛型类型的类型参数的自动推导,所以直接通过g := GenericType{Item: n}来初始化一个泛型类型变量将导致编译错误!我们需要借助泛型函数的推导机制将非导出类型与泛型类型进行结合,参见上述示例中的NewGenericType函数,通过泛型函数支持的类型参数的自动推导间接获得GenericType的类型实参。在go-exported-identifiers/generics目录下编译运行这个示例,便可得到我们预期的结果:

$go run main.go
invoke nonExported's M1
invoke nonExported's M2
invoke nonExported's M1

7. 非导出类型使用导出字段以及导出方法的用途

前面的诸多示例证明了:即使类型本身是非导出的,但其内部的导出字段以及它的导出方法依然可以在外部包中使用,并且在实现接口、嵌入字段、泛型等使用场景下均有效。

到这里,你可能会提出这样一个问题:会有Go开发者使用非导出类型结合导出字段或方法的设计吗

其实这种还是很常见的,在Go标准库中就有不少,只不过它们更多是包内使用,类似于非导出类型xxxImpl和它的Wrapper类型XXX的关系,或是xxxImpl或嵌入到XXX中,就像这样:

// 包内实现
type xxxImpl struct {  // 非导出的实现类型
    // 内部字段
}

// 导出的包装类型
type XXX struct {
    impl *xxxImpl  // 包含实现类型
    // 其他字段
}

// 或者通过嵌入方式
type XXX struct {
    *xxxImpl  // 嵌入实现类型
    // 其他字段
}

但也有一些可以包外使用的,比如实现了某个接口,并通过接口值返回,提供给外部使用,例如下面的valueCtx,它实现了Context接口,并通过WithValue返回,供调用WithValue的外部包使用:

//$GOROOT/src/context/context.go

func WithValue(parent Context, key, val any) Context {  // 构造函数,实现接口
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    if key == nil {
        panic("nil key")
    }
    if !reflectlite.TypeOf(key).Comparable() {
        panic("key is not comparable")
    }
    return &valueCtx{parent, key, val}
}

// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
    Context
    key, val any
}

func (c *valueCtx) Value(key any) any {
    if c.key == key {
        return c.val
    }
    return value(c.Context, key)
}

这么做的目的是什么呢?大约有如下几点:

  • 隐藏实现细节

非导出类型的主要作用是防止外部直接使用和依赖其内部实现细节。通过限制类型的直接使用,库作者可以保持实现的灵活性,随时调整或重构类型的内部逻辑,而无需担心破坏外部调用代码; 还可以避免暴露多余的API,使库的接口更加简洁。

  • 控制实例的创建和管理

通过非导出类型,开发者还可以确保外部代码无法直接实例化类型,而必须通过导出的构造函数或工厂函数,就像前面举的示例那样。这种模式可以保证对象始终以特定的方式初始化,避免错误使用。同时,它还可以用来实现更复杂的初始化逻辑,如依赖注入或资源管理。

  • 在接口实现中的作用

非导出类型可以用来实现导出的接口,从而将接口的实现细节完全隐藏。对于用户来说,只需要关心接口的定义,而无需关注其实现。

8. 小结

本文探讨了Go语言中的导出标识符及其相关细节,特别是非导出类型如何与其导出字段和导出方法结合使用。

尽管某些类型是非导出的,其内部的导出字段和方法依然可以在包外访问。此外,非导出类型在实现接口、嵌入字段和泛型中也展现出良好的应用。这种设计不仅促进了封装和接口实现的灵活性,还允许开发者通过构造函数返回非导出类型的实例,从而有效控制实例的创建与管理。这种方式帮助隐藏实现细节,简化外部接口,使得代码结构更加清晰。

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


Gopher部落知识星球在2025年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。并且,2025年将在星球首发“Go陷阱与缺陷”和“Go原理课”专栏!此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾
。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

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://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

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

© 2025, bigwhite. 版权所有.

探索Go gcflags的使用模式与完整参数选项列表

2025-01-22 18:37:43

本文永久链接 – https://tonybai.com/2025/01/22/gcflags-options-list-and-usage

Go build是Go开发中不可或缺的构建工具,其中-gcflags参数为开发者提供了向编译器传递额外选项的能力。然而,关于-gcflags的完整参数选项和使用模式,官方文档多有局限,很多开发者对此了解不深。本文将系统性地解析-gcflags的完整参数来源以及其结合包模式(package pattern)的使用方法,供大家参考。

注:本文主要以-gcflags为例,其实go build的-ldflags参数与-gcflags在使用方法上如出一辙,唯一不同的是ldflags是将参数传递给go链接器。

gcflags是Go构建工具的一个标志,用于向Go编译器 (go tool compile) 传递额外的编译参数。通过它,开发者可以调整编译行为,例如禁用优化、生成调试信息或输出反汇编代码等。

Go build文档中关于-gcflags的说明很短小精悍:

$go help build
... ...
    -gcflags '[pattern=]arg list'
        arguments to pass on each go tool compile invocation.
    -ldflags '[pattern=]arg list'
        arguments to pass on each go tool link invocation.
... ...

The -asmflags, -gccgoflags, -gcflags, and -ldflags flags accept a space-separated list of arguments to pass to an underlying tool during the build. To embed spaces in an element in the list, surround it with either single or double quotes. The argument list may be preceded by a package pattern and an equal sign, which restricts the use of that argument list to the building of packages matching that pattern (see 'go help packages' for a description of package patterns). Without a pattern, the argument list applies only to the packages named on the command line. The flags may be repeated with different patterns in order to specify different arguments for different sets of packages. If a package matches patterns given in multiple flags, the latest match on the command line wins. For example, 'go build -gcflags=-S fmt' prints the disassembly only for package fmt, while 'go build -gcflags=all=-S fmt' prints the disassembly for fmt and all its dependencies.

... ...

多数Go初学者初次看到上述关于gcflags的说明,都无法知道到底有哪些arg可用以及究竟如何使用gcflags,而Go cmd文档中关于gcflags的内容也仅限于上述这些。

我将大家遇到的主要问题总结为下面两条:

  • gcflags的完整参数选项列表在哪里可以找到?
  • gcflags的使用模式,尤其是其中的package pattern应该如何正确使用?

如果你能正确回答上述两个问题,那你就基本掌握了gcflags的使用,大可不必继续往下看了

否则,我们就一起分别看一下这两个问题该如何解答。

在哪里能查找到gcflags可用的全部参数选项呢?go help build不行,go command的web文档中没有!甚至Go tool compile的web文档中列举的gcflag的参数列表也是不全的(或者说是文档没有及时同步最新的参数列表变化),也许我们应该提一个issue给Go团队^_^。

远在天边近在眼前!下面命令可以让-gcflag可用的参数选项完整列表尽收眼底:

$go tool compile -h
usage: compile [options] file.go...
  -%    debug non-static initializers
  -+    compiling runtime
  -B    disable bounds checking
  -C    disable printing of columns in error messages
  -D path
        set relative path for local imports
  -E    debug symbol export
  -I directory
        add directory to import search path
  -K    debug missing line numbers
  -L    also show actual source file names in error messages for positions affected by //line directives
  -N    disable optimizations
  -S    print assembly listing
  -V    print version and exit
  -W    debug parse tree after type checking
  -asan
        build code compatible with C/C++ address sanitizer
  -asmhdr file
        write assembly header to file
... ...

同样,如果你要查看-ldflags的完整参数选项列表,你可以使用下面命令:

$go tool link -h
usage: link [options] main.o
  -B note
        add an ELF NT_GNU_BUILD_ID note when using ELF; use "gobuildid" to generate it from the Go build ID
  -E entry
        set entry symbol name
  -H type
        set header type
  -I linker
        use linker as ELF dynamic linker
  -L directory
        add specified directory to library path
  -R quantum
        set address rounding quantum (default -1)
  -T int
        set the start address of text symbols (default -1)
  -V    print version and exit
  -X definition
        add string value definition of the form importpath.name=value
  -a    no-op (deprecated)
  -asan
        enable ASan interface
... ...

到这里,我们得到了第一个问题的答案。

接下来,我们再来看第二个问题:-gcflags的使用模式。

根据go help build的输出,我们知道-gcflags的使用形式如下:

-gcflags '[pattern=]arg list'

其中:

  • [pattern=](可选):包模式(package pattern),用于作用范围控制,即限定参数仅应用于特定的包。如果省略此部分,则参数仅适用于命令行中指定的包。
  • arg list:参数选项列表,多个参数以空格分隔。

对包模式有很好地理解并非是使用好gcflags的必要条件。但在一些复杂项目中,我们可能会通过包模式精确控制调试和优化,在这种情况下,对包模式有深入理解还是大有裨益的。

包模式是一种通过匹配规则指定目标包的方式,常见的包模式有几下几种:

  • ./…:匹配当前目录及其所有子目录中的包。
  • /DIR/…:匹配/DIR及其子目录中的包。
  • cmd/…:匹配Go仓库中cmd目录下的所有命令包。
  • github.com/user/repo/…:匹配该github仓库中的所有包。
  • all:GOPATH模式下,匹配的是所有GOPATH路径中的包,Go module模式下,all匹配主模块及其所有依赖的包(包括测试依赖)。
  • std:仅匹配标准库包。
  • cmd:匹配Go仓库中的Go命令及其内部包(internal)。

基于上述关于gcflags使用形式以及包模式的说明,我们举几个示例来直观理解一下gcflags的用法:

  • 对单个包设置参数
$go build -gcflags=-S fmt

上述命令中的参数-S仅作用于fmt包,显示其反汇编代码。

  • 对特定模式(比如all/std等)的包设置参数
$go build -gcflags='all=-N -l'

在Go module模式下,参数-N和-l应用于当前主模块所有包及其依赖,禁用优化和内联。

  • 对不同包模式设置不同参数
$go build -gcflags='fmt=-S' -gcflags='net/http=-N'

Go build命令行中可以多次使用-gcflags,上述命令中的第一个gcflags对fmt包启用反汇编输出(-S)。第二个gcflags对net/http包禁用优化(-N)。

  • 模式的优先级
$go build -gcflags='all=-N' -gcflags='fmt=-S'

像上面命令中,两个gcflags都匹配了fmt包,或者说两个gcflags的作用范围都包含了fmt包,这种情况下哪些参数会对fmt包生效呢?Go规定:当一个包匹配多个模式时,以最后一个匹配的参数为准。在这个例子中,fmt包将只应用-S参数,而其他包应用-N参数。

到这里,我们完成了对两个关于gcflags问题的回答!

最后小结一下:

  • gcflags(以及-ldflags)是Go构建工具中的重要选项,能极大提升调试和优化效率。
  • gcflags的完整的参数选项需通过底层工具获取,即go tool compile -h和go tool link -h。
  • 对包模式的灵活使用能够精确控制gcflags参数的作用范围,为复杂项目提供了更大的自由度。

通过本篇文章,希望你能掌握查看gcflags完整参数列表的方法以及gcflags的使用模式,并在构建和调试Go项目时能更加得心应手。


Gopher部落知识星球在2025年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。并且,2025年将在星球首发“Go陷阱与缺陷”和“Go原理课”专栏!此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾
。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

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://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

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

© 2025, bigwhite. 版权所有.