Go 1.14 相比 Go 1.13 有哪些值得注意的改动?

本系列旨在梳理 Go 的 release notes 与发展史,来更加深入地理解 Go 语言设计的思路。

go.dev/doc/go1.14

Go 1.14 值得关注的改动:

  1. 接口嵌入 :允许嵌入方法集重叠(方法名和签名相同)的接口,解决了先前版本中存在的限制,特别是 接口菱形嵌入(diamond-shaped embedding graphs) 问题。
  2. 模块与 Vendoring :当存在 vendor 目录且 go.mod 指定 Go 1.14 或更高版本时,go 命令默认启用 vendor 模式;同时,模块下载支持了 Subversion,并改进了代理错误信息的显示。
  3. 运行时改进defer 的性能开销大幅降低接近于零;Goroutine 实现 异步抢占式调度(asynchronously preemptible),解决了某些循环导致调度器阻塞或 GC 延迟的问题(尽管这可能导致 Unix 系统上出现更多 EINTR 错误);页面分配器和内部计时器效率也得到了提升。
  4. Go Modules 行为变更 :在显式启用模块感知模式(GO111MODULE=on)但无 go.mod 文件时,多数命令功能受限;对于包含 go.mod 文件的模块,go get 默认不再自动升级到不兼容的主版本。
  5. 新增 hash/maphash :提供对字节序列的高性能、非加密安全的哈希函数,适用于哈希表等场景,其哈希结果在单进程内一致,跨进程则不同。

下面是一些值得展开的讨论:

接口嵌入允许方法集重叠

Go 1.14 根据 overlapping interfaces proposal,放宽了接口嵌入的限制。现在允许一个接口嵌入多个其他接口,即使这些被嵌入的接口包含了方法名和方法签名完全相同的方法。

这一改动主要解决了先前版本中存在的一个问题,尤其是在接口构成 接口菱形嵌入(diamond-shaped embedding graphs) 的场景下。

之前的限制 (Go < 1.14):

在 Go 1.14 之前,如果你尝试嵌入两个具有同名同签名方法的接口,编译器会报错。例如:

go 复制代码
package main

import "fmt"

type Reader interface {
    Read(p []byte) (n int, err error)
    Close() error
}

type Writer interface {
    Write(p []byte) (n int, err error)
    Close() error // 与 Reader 中的 Close 方法签名相同
}

// 在 Go 1.14 之前,下面的定义会导致编译错误:
// "ambiguous selector io.ReadWriteCloser.Close" 或
// "duplicate method Close"
/*
type ReadWriter interface {
    Reader // 嵌入 Reader
    Writer // 嵌入 Writer (包含重复的 Close 方法)
}
*/

func main() {
    fmt.Println("Go 1.14 之前的接口嵌入限制示例")
    // 无法直接定义包含重复方法的嵌入接口
}

Go 1.14 的改进:

Go 1.14 允许这种情况。当一个接口嵌入多个包含相同方法(名称和签名一致)的接口时,这些相同的方法在最终的接口方法集中只会出现一次。

go 复制代码
package main

import (
    "fmt"
    "io" // 使用标准库接口作为例子
)

// io.ReadCloser 定义
// type ReadCloser interface {
//     Reader
//     Closer
// }

// io.WriteCloser 定义
// type WriteCloser interface {
//     Writer
//     Closer // 与 ReadCloser 中的 Closer 方法签名相同
// }

// Go 1.14 及之后版本,下面的定义是合法的
type ReadWriteCloser interface {
    io.ReadCloser // 嵌入 io.ReadCloser (包含 Close)
    io.WriteCloser // 嵌入 io.WriteCloser (包含 Close)
    // 最终的 ReadWriteCloser 接口包含 Read, Write, 和 一个 Close 方法
}

type myReadWriteCloser struct{}

func (m *myReadWriteCloser) Read(p []byte) (n int, err error) {
    fmt.Println("Reading...")
    return 0, nil
}

func (m *myReadWriteCloser) Write(p []byte) (n int, err error) {
    fmt.Println("Writing...")
    return len(p), nil
}

func (m *myReadWriteCloser) Close() error {
    fmt.Println("Closing...")
    return nil
}

func main() {
    var rwc ReadWriteCloser
    rwc = &myReadWriteCloser{}

    rwc.Read(nil)
    rwc.Write([]byte("test"))
    rwc.Close() // 调用的是同一个 Close 方法

    // 检查是否同时满足 io.ReadCloser 和 io.WriteCloser
    var rc io.ReadCloser = rwc
    var wc io.WriteCloser = rwc
    fmt.Printf("rwc is ReadCloser: %t\n", rc != nil)
    fmt.Printf("rwc is WriteCloser: %t\n", wc != nil)
}

在这个例子中,ReadWriteCloser 嵌入了 io.ReadCloserio.WriteCloser。两者都包含了一个 Close() error 方法。在 Go 1.14 中,这是允许的,ReadWriteCloser 接口最终只包含一个 Close 方法。任何实现了 ReadWriteCloser 的类型,其 Close 方法必须同时满足 io.ReadCloserio.WriteCloser 的要求。

重要提示: 这个改动只适用于 嵌入 的接口。如果在一个接口定义中 显式声明 了同名同签名的方法,或者显式声明的方法与嵌入接口中的方法冲突,依然会和以前一样导致编译错误。

go 复制代码
package main

import "io"

// 这个定义仍然是错误的,因为 Close 被显式声明了两次
/*
type BadInterface interface {
    Close() error
    Close() error // compile error: duplicate method Close
}
*/

// 这个定义也是错误的,因为显式声明的 Close 与嵌入的 Close 冲突
/*
type AnotherBadInterface interface {
    io.Closer // 嵌入 io.Closer (包含 Close() error)
    Close() error // compile error: duplicate method Close
}
*/

func main() {}

这个改进使得接口设计,尤其是在构建复杂的接口层次结构时更加灵活。

模块与 Vendoring 行为变更

Go 1.14 对 Go Modules 的 vendor 机制和模块下载进行了一些重要的调整和改进。

默认启用 -mod=vendor:

最显著的变化是 go 命令(如 go build, go test, go run 等接受 -mod 标志的命令)在特定条件下的默认行为。

  • 条件
    1. 你的主模块(项目根目录)包含一个名为 vendor 的顶层目录。
    2. 你的主模块的 go.mod 文件中指定了 go 1.14 或更高的 Go 版本 (go 1.14, go 1.15, 等等)。
  • 行为 :如果满足以上两个条件,go 命令现在会 默认 使用 -mod=vendor 标志。这意味着构建、测试等操作会优先使用 vendor 目录中的依赖包,而不是去模块缓存($GOPATH/pkg/mod)中查找。

对比 (Go < 1.14 或 无 vendor 目录):

在 Go 1.14 之前,或者即使在 Go 1.14+ 但没有 vendor 目录,或者 go.mod 指定的版本低于 1.14,go 命令默认的行为类似于 -mod=readonly,它会使用模块缓存中的依赖。

新的 -mod=mod 标志:

为了应对默认行为的改变,Go 1.14 引入了一个新的 -mod 标志值:-mod=mod。如果你满足了默认启用 vendor 模式的条件,但又想强制 go 命令使用模块缓存(就像没有 vendor 目录时那样),你可以显式地使用 -mod=mod 标志。

bash 复制代码
# 假设项目满足条件 (go.mod >= 1.14, vendor/ 存在)

# Go 1.14+ 默认行为,等同于 go build -mod=vendor
go build

# 强制使用 module cache,忽略 vendor/ 目录
go build -mod=mod

vendor/modules.txt 校验:

-mod=vendor 被设置时(无论是显式设置还是默认启用),go 命令现在会校验主模块下的 vendor/modules.txt 文件是否与其 go.mod 文件保持一致。如果不一致,命令会报错。这有助于确保 vendor 目录的内容确实反映了 go.mod 文件中声明的依赖。

go list -m 行为变更:

vendor 模式下 (-mod=vendor),go list -m 命令不再会静默地忽略那些在 vendor 目录中找不到对应包的 传递性依赖(transitive dependencies)。如果请求信息的模块没有在 vendor/modules.txt 文件中列出,go list -m 现在会明确地报错失败。

模块下载改进:

  • Subversion 支持go 命令在模块模式下现在支持从 Subversion (SVN) 版本控制系统下载模块。
  • 更清晰的错误信息 :当从模块代理(Module Proxies)或其他 HTTP 服务器下载模块遇到错误时,go 命令现在会尝试包含一部分来自服务器的纯文本错误信息片段。这有助于诊断下载问题。只有当错误信息是有效的 UTF-8 编码,并且只包含图形字符和空格时,才会被显示。

这些改动使得 vendor 模式更加健壮和符合预期,同时也提升了模块下载的兼容性和问题诊断能力。

运行时性能改进和 Goroutine 抢占

Go 1.14 在运行时(runtime)层面引入了多项重要的性能改进和机制变化。

defer 性能大幅提升:

Go 1.14 显著优化了 defer 语句的实现。对于大多数使用场景,defer 的开销已经降低到几乎为零,与直接调用被延迟的函数相差无几。

  • 影响 :这意味着开发者可以在性能敏感的代码路径中(例如,循环内部)更自由地使用 defer 来进行资源清理(如 Unlock 互斥锁、关闭文件句柄等),而不必过分担心其带来的性能损耗。
  • 对比 (Go < 1.14) :在旧版本中,defer 会带来一定的固定开销,可能导致开发者在性能关键区域避免使用它,转而采用手动调用清理函数的方式。

虽然很难用简单的代码示例直接 展示 性能差异(需要基准测试),但可以想象在旧版本中可能避免的写法:

go 复制代码
// 在 Go 1.14+ 中,即使在循环内部,使用 defer 的性能开销也大大降低
func processItems(items []Item, mu *sync.Mutex) {
    for _, item := range items {
        mu.Lock()
        // 在 Go 1.14+,这里的 defer 开销很小
        defer mu.Unlock() 

        // ... 处理 item ...
        if item.needsSpecialHandling() {
             // 在 Go 1.14 之前,可能会因为性能考虑,在这里手动 Unlock
             // mu.Unlock() 
             handleSpecial(item)
             // continue // 或者 return,需要确保 Unlock 被调用
             // 并且在循环正常结束时也需要 Unlock,代码更复杂
             // mu.Lock() // 如果 continue 后还需要锁
        }
    }
}

Goroutine 异步抢占式调度:

这是一个重要的底层调度机制变化。Goroutine 现在是 异步抢占(asynchronously preemptible) 的。

  • 机制 :在此之前,Go 的调度器是协作式的,抢占点主要发生在函数调用时。如果一个 Goroutine 执行一个没有函数调用的密集计算循环(例如 for {}),它可能会长时间霸占 CPU,导致其他 Goroutine 无法运行,甚至可能阻塞调度器或显著延迟垃圾回收(GC)。
  • 改进 :Go 1.14 引入了基于信号的异步抢占机制。这意味着即使 Goroutine 正在执行一个没有函数调用的循环,运行时也可以发送信号来中断它,让调度器有机会运行其他 Goroutine 或执行 GC。
  • 影响
    • 提高了程序的公平性和响应性,避免了某些类型的死锁或调度延迟。
    • 密集计算循环不再容易饿死其他 Goroutine 或 GC。
  • 平台支持 :此功能在发布时支持除 windows/arm, darwin/arm, js/wasm, plan9/* 之外的所有平台。
  • 副作用 (EINTR 错误) :这种基于信号的抢占实现有一个副作用:在 Unix 系统(包括 Linux 和 macOS)上,用 Go 1.14 构建的程序可能会比旧版本接收到更多的信号。这会导致那些进行 慢系统调用(slow system calls) 的代码(例如,使用 syscallgolang.org/x/sys/unix 包进行网络读写、文件操作等)更频繁地遇到 EINTR (Interrupted system call) 错误。
    • 应对 :程序 必须 正确处理 EINTR 错误,通常的做法是简单地重试该系统调用。
go 复制代码
import "syscall"
import "fmt"

// 示例:处理可能因抢占信号而中断的系统调用
func readFileWithRetry(fd int, buf []byte) (int, error) {
    for {
        n, err := syscall.Read(fd, buf) // Read 是一个可能被信号中断的系统调用

        // 如果错误是 EINTR,说明系统调用被信号中断了(可能是抢占信号)
        // 我们应该重试这个操作
        if err == syscall.EINTR {
            fmt.Println("Syscall interrupted (EINTR), retrying...")
            continue 
        }

        // 如果是其他错误,或者没有错误 (n >= 0)
        // 则返回结果
        return n, err
    }
}

内存分配器 (Page Allocator) 效率提升:

Go 1.14 的页面分配器(Page Allocator)效率更高,并且在高 GOMAXPROCS 值(即使用大量 CPU 核心时)显著减少了锁竞争。

  • 影响 :这主要体现在并行执行大量大内存分配(large allocations)时,可以观察到更低的延迟和更高的吞吐量。

内部计时器 (Internal Timers) 效率提升:

运行时内部使用的计时器(被 time.After, time.Tick, net.Conn.SetDeadline 等标准库函数依赖)也得到了优化。

  • 影响 :减少了锁竞争和上下文切换次数。这是一个内部性能改进,理论上不会导致用户可见的行为变化,但会提升依赖这些计时器的操作的整体性能。

总的来说,Go 1.14 在运行时层面带来了显著的性能提升和调度鲁棒性增强,但也引入了需要开发者注意的 EINTR 错误处理要求。

Go Modules: 无 go.mod 文件及不兼容版本处理

Go 1.14 对 Go Modules 在特定场景下的行为进行了调整,旨在提高构建的确定性和可复现性。

模块感知模式下无 go.mod 文件的行为:

当显式启用模块感知模式(通过设置环境变量 GO111MODULE=on),但当前目录及所有父目录中都 没有 找到 go.mod 文件时,大多数与模块相关的 go 命令(如 go build, go run, go test 等)的功能会受到限制。

  • 限制 :在没有 go.mod 的情况下,这些命令只能构建:
    1. 标准库中的包 (e.g., fmt, net/http)。
    2. 在命令行上直接指定的 .go 文件。
  • 原因 :在 Go 1.14 之前,即使没有 go.modgo 命令也会尝试解析包路径,并隐式地去下载和使用它能找到的最新版本的模块。然而,这种方式 不会记录 下来具体使用了哪个模块的哪个版本。这导致了两个问题:
    1. 构建速度慢 :每次构建可能都需要重新解析和下载。
    2. 不可复现 :不同时间或不同环境下执行相同的命令,可能会因为依赖的最新版本发生变化而得到不同的结果,甚至构建失败。
  • Go 1.14 的改变 :为了强制实现可复现构建,Go 1.14 限制了在无 go.mod 时隐式解析和下载依赖的能力。你需要一个 go.mod 文件来明确管理你的项目依赖。

不受影响的命令:

需要注意的是,以下命令的行为基本保持不变,即使在没有 go.mod 的模块感知模式下:

  • go get <path>@<version>:仍然可以用于下载指定版本的模块到模块缓存。
  • go mod download <path>@<version>:同上。
  • go list -m <path>@<version>:仍然可以查询指定版本模块的信息。
bash 复制代码
# 确保模块模式开启
export GO111MODULE=on

# 创建一个没有 go.mod 的目录
mkdir /tmp/no_gomod_test
cd /tmp/no_gomod_test

# 创建一个简单的 main.go
echo 'package main; import "fmt"; func main() { fmt.Println("Hello from main.go") }' > main.go

# 1. 构建标准库包 (可以)
# (这个命令本身意义不大,只是演示可以访问标准库)
# go build fmt 

# 2. 构建命令行指定的 .go 文件 (可以)
go build main.go 
./main # 输出: Hello from main.go

# 3. 尝试构建一个需要外部依赖的 .go 文件 (如果依赖未下载则会失败)
# echo 'package main; import "rsc.io/quote"; func main() { println(quote.Go()) }' > need_dep.go
# go build need_dep.go  # Go 1.14+ 会报错,无法找到 rsc.io/quote

# 4. 尝试直接运行需要外部依赖的包 (Go 1.14+ 会报错)
# go run rsc.io/quote/cmd/quote # Go 1.14+ 报错

# 5. 使用 go get 下载特定版本 (仍然可以)
go get rsc.io/[email protected] 
# 现在再运行上面的 go build need_dep.go 或 go run ... 可能会成功,因为它在缓存里了
# 但这仍然不是推荐的工作方式,因为它没有被 go.mod 记录

cd ..
rm -rf /tmp/no_gomod_test

处理不兼容的主版本 (+incompatible):

Go Modules 使用语义化版本(Semantic Versioning)。主版本号(Major Version)的改变通常意味着不兼容的 API 变更。Go 1.14 对 go getgo list 处理不兼容主版本的方式进行了调整。

  • 条件 :当你尝试获取或更新一个模块,并且该模块的 最新版本 已经包含了 go.mod 文件时。
  • go get 的行为
    • 默认情况下,go get不再 自动将你的依赖升级到一个 不兼容的主版本 (例如,从 v1.x.y 升级到 v2.0.0 或更高版本)。
    • 它只会升级到当前主版本内的最新兼容版本(例如,从 v1.4.0 升级到 v1.5.2)。
    • 如果你确实 需要 升级到不兼容的主版本,你必须 显式 地指定该版本(例如 go get example.com/[email protected]),或者该不兼容版本已经是你项目依赖图中某个其他模块所必需的依赖。
  • go list 的行为
    • go list 直接从版本控制系统(如 Git)获取模块信息时,它通常也会忽略那些被视为不兼容的主版本(相对于当前已知的版本)。
    • 但是,如果信息是从模块代理获取的,代理可能会报告所有可用的版本,包括不兼容的主版本,这时 go list 可能会包含它们。

这个改变有助于防止意外引入破坏性的 API 变更,使得依赖管理更加安全和可控。对于那些在引入 Go Modules 之前就已经发布了 v2+ 版本但没有遵循模块路径约定的模块,Go 会使用 +incompatible 标记(例如 example.com/mod v2.0.1+incompatible)来标识它们。

bash 复制代码
# 假设 example.com/mod 有以下版本:
# v1.5.0 (有 go.mod)
# v2.1.0 (有 go.mod)

# 当前项目的 go.mod 文件:
# module myproject
# go 1.14
# require example.com/mod v1.4.0

# 运行 go get 更新依赖
go get example.com/mod 
# 在 Go 1.14+, 这通常会将 go.mod 更新到 require example.com/mod v1.5.0
# 而不会跳到 v2.1.0

# 如果确实想使用 v2.1.0,必须显式指定
go get example.com/[email protected]
# 这会将 go.mod 更新到 require example.com/mod/v2 v2.1.0 (如果 v2 遵循了模块路径约定)
# 或者 require example.com/mod v2.1.0+incompatible (如果 v2 没有遵循约定)

新增 hash/maphash

Go 1.14 标准库中增加了一个新的包:hash/maphash。这个包提供了一种用于对字节序列([]bytestring)进行哈希计算的函数。

主要用途:

hash/maphash 主要设计用于实现 哈希表 (hash tables, 在 Go 中通常指 map)或其他需要将任意字符串或字节序列映射到 64 位无符号整数(uint64)上,并期望结果具有良好均匀分布的数据结构。

核心特性:

  1. 高性能: 该哈希算法经过优化,执行速度非常快。
  2. 抗碰撞性 (Collision-Resistant): 算法设计旨在最小化不同输入产生相同哈希值的概率(哈希碰撞),使得哈希值分布均匀。这对于哈希表的性能至关重要。
  3. 非加密安全 (Not Cryptographically Secure) : 极其重要 的一点是,hash/maphash 不是 加密安全的哈希函数。你不应该将它用于任何安全相关的目的,例如:
    • 密码哈希存储
    • 生成消息认证码 (MAC)
    • 数字签名
    • 任何需要抵抗恶意攻击者寻找碰撞或原像的场景 对于这些场景,应该使用 crypto/sha256, crypto/sha512, golang.org/x/crypto/bcrypt 等加密哈希库。
  4. 进程内稳定,跨进程不稳定 :
    • 对于一个给定的字节序列,在 同一个 Go 进程 的单次执行过程中,其 maphash 哈希值是 稳定不变 的。
    • 但是,对于同一个字节序列,在 不同的 Go 进程 中,或者 同 一个程序的多次不同执行 中,计算出的 maphash 哈希值 几乎肯定会不同

为什么跨进程不稳定?

这是故意设计的。maphash 使用一个 哈希种子(seed) 来初始化其内部状态。这个种子在每个 Go 程序启动时由运行时随机生成(通过 maphash.MakeSeed())。这意味着每次运行程序时,哈希函数都会使用不同的种子,从而产生不同的哈希序列。

这种设计的主要目的是 防止 哈希洪水攻击 (Hash Flooding Attacks)。这类攻击依赖于攻击者能够预测哈希函数对于特定输入的输出,从而构造大量会导致哈希碰撞的输入,使得哈希表性能急剧下降(从 O(1) 退化到 O(n)),导致拒绝服务(Denial of Service, DoS)。由于种子在每次运行时都不同,攻击者无法预先构造出在特定运行实例中必然会碰撞的输入。

基本用法:

go 复制代码
package main

import (
    "fmt"
    "hash/maphash"
)

func main() {
    // 1. 创建一个 maphash.Hash 实例
    // 它会自动使用当前进程的随机种子进行初始化
    var h maphash.Hash

    // 如果需要对同一个哈希对象计算多个哈希值,需要 Reset
    // (或者为每个值创建新的 Hash 对象)

    // 2. 添加数据 (string 或 []byte)
    s1 := "hello maphash"
    h.WriteString(s1)

    // 3. 计算 64 位哈希值
    hash1 := h.Sum64()
    fmt.Printf("Hash of \"%s\": %d (0x%x)\n", s1, hash1, hash1)

    // 4. Reset 并计算另一个值
    h.Reset()
    s2 := []byte("hello maphash") // 相同内容,不同类型
    h.Write(s2)
    hash2 := h.Sum64()
    // 注意:即使内容相同,直接比较 []byte 和 string 的哈希值通常也需要确保它们字节表示一致
    fmt.Printf("Hash of []byte(\"%s\"): %d (0x%x)\n", string(s2), hash2, hash2)
    // 在这个例子中,string 和 []byte 的内容完全相同,所以哈希值也应该相同
    fmt.Printf("Hash values match: %t\n", hash1 == hash2)


    // 5. 计算第三个值
    h.Reset()
    s3 := "another value"
    h.WriteString(s3)
    hash3 := h.Sum64()
    fmt.Printf("Hash of \"%s\": %d (0x%x)\n", s3, hash3, hash3)


    // 6. 再次计算第一个值,验证进程内稳定性
    h.Reset()
    h.WriteString(s1)
    hash4 := h.Sum64()
    fmt.Printf("Hash of \"%s\" again: %d (0x%x)\n", s1, hash4, hash4)
    fmt.Printf("Process-local stability check (hash1 == hash4): %t\n", hash1 == hash4)

    fmt.Println("\nRun this program again, the hash values will likely be different.")

    // 你也可以显式管理种子,但这通常只在特殊情况下需要
    // seed := maphash.MakeSeed()
    // h.SetSeed(seed)
    // ...
}

输出:

txt 复制代码
Hash of "hello maphash": 16786359967769308781 (0xe8f52173e6ba2e6d)
Hash of []byte("hello maphash"): 16786359967769308781 (0xe8f52173e6ba2e6d)
Hash values match: true
Hash of "another value": 14091924103374798602 (0xc390924f4f6b7f0a)
Hash of "hello maphash" again: 16786359967769308781 (0xe8f52173e6ba2e6d)
Process-local stability check (hash1 == hash4): true

Run this program again, the hash values will likely be different.

如果你运行上面的程序多次,你会发现每次运行时输出的哈希值都不同,但每次运行内部 hash1hash4 的值总是相同的。

hash/maphash 为 Go 开发者提供了一个内置的、快速且适合用于哈希表实现的哈希函数,同时通过随机种子避免了潜在的安全风险。

相关推荐
Andya_net6 分钟前
SpringBoot | 构建客户树及其关联关系的设计思路和实践Demo
java·spring boot·后端
南囝coding42 分钟前
关于我的第一个产品!
前端·后端·产品
北漂老男孩1 小时前
Spring Boot 自动配置深度解析:从源码结构到设计哲学
java·spring boot·后端
陈明勇1 小时前
MCP 实战:用 Go 语言开发一个查询 IP 信息的 MCP 服务器
人工智能·后端·mcp
小咕聊编程1 小时前
【含文档+PPT+源码】基于SpringBoot+Vue的移动台账管理系统
java·spring boot·后端
景天科技苑2 小时前
【Rust结构体】Rust结构体详解:从基础到高级应用
开发语言·后端·rust·结构体·关联函数·rust结构体·结构体方法
-曾牛2 小时前
Spring Boot常用注解详解:实例与核心概念
java·spring boot·后端·spring·java-ee·个人开发·spring boot 注解
得物技术2 小时前
得物业务参数配置中心架构综述
后端·架构
用户4099322502122 小时前
分层架构在博客评论功能中的应用与实现
后端·ai编程·trae
co松柏2 小时前
程序员必备——AI 画技术图技巧
前端·后端·ai编程