本系列旨在梳理 Go 的 release notes 与发展史,来更加深入地理解 Go 语言设计的思路。
Go 1.17 值得关注的改动:
unsafe.Add
和 unsafe.Slice
以简化 unsafe.Pointer
的使用。go 1.17
或更高版本的模块,go.mod
文件现在包含更全面的传递性依赖信息,从而启用模块图修剪和依赖懒加载机制。go run
增强: go run
命令现在支持版本后缀(如 cmd@v1.0.0
),允许在模块感知模式下运行指定版本的包,忽略当前模块的依赖。Vet
工具更新: 新增了三项检查,分别针对 //go:build
与 // +build
的一致性、对无缓冲 channel
使用 signal.Notify
的潜在风险,以及 error
类型上 As
/Is
/Unwrap
方法的签名规范。下面是一些值得展开的讨论:
unsafe
包的增强Go 1.17 在语言层面带来了三处增强:
现在可以将一个 切片(slice) s
(类型为 []T
)转换为一个数组指针 a
(类型为 *[N]T
)。
这种转换的语法是 (*[N]T)(s)
。转换后的数组指针 a
和原始切片 s
在有效索引范围内(0 <= i < N
)共享相同的底层元素,即 &a[i] == &s[i]
。
需要特别注意 :如果切片 s
的长度 len(s)
小于数组的大小 N
,该转换会在运行时引发 panic
。这是 Go 语言中第一个可能在运行时 panic
的类型转换,依赖于“类型转换永不 panic”假定的静态分析工具需要更新以适应这个变化。
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5}
// 成功转换:切片长度 >= 数组大小
arrPtr1 := (*[3]int)(s)
fmt.Printf("arrPtr1: %p, %v\n", arrPtr1, *arrPtr1) // 输出指针地址和 {1 2 3}
fmt.Printf("&arrPtr1[0]: %p, &s[0]: %p\n", &arrPtr1[0], &s[0]) // 输出相同的地址
arrPtr2 := (*[5]int)(s)
fmt.Printf("arrPtr2: %p, %v\n", arrPtr2, *arrPtr2) // 输出指针地址和 {1 2 3 4 5}
// 修改通过指针访问的元素,会影响原切片
arrPtr1[0] = 100
fmt.Printf("s after modification: %v\n", s) // 输出 [100 2 3 4 5]
// 失败转换:切片长度 < 数组大小
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r) // 输出 Recovered from panic: runtime error: cannot convert slice with length 5 to pointer to array with length 6
}
}()
arrPtr3 := (*[6]int)(s) // 这行会引发 panic
fmt.Println("This line will not be printed", arrPtr3)
}
arrPtr1: 0xc0000b2000, [1 2 3]
&arrPtr1[0]: 0xc0000b2000, &s[0]: 0xc0000b2000
arrPtr2: 0xc0000b2000, [1 2 3 4 5]
s after modification: [100 2 3 4 5]
Recovered from panic: runtime error: cannot convert slice with length 5 to pointer to array with length 6
unsafe.Add
函数unsafe
包新增了 Add
函数:unsafe.Add(ptr unsafe.Pointer, len IntegerType) unsafe.Pointer
。
它的作用是将一个非负的整数 len
(必须是整数类型,如 int
, uintptr
等)加到 ptr
指针上,并返回更新后的指针。其效果等价于 unsafe.Pointer(uintptr(ptr) + uintptr(len))
,但意图更清晰,且有助于静态分析工具理解指针运算。
这个函数的目的是为了简化遵循 unsafe.Pointer
安全规则的代码编写,但它 并没有改变 这些规则。使用 unsafe.Add
仍然需要确保结果指针指向的是合法的内存分配。
例如,在没有 unsafe.Add
之前,如果要访问结构体中某个字段的地址,可能需要这样做:
package main
import (
"fmt"
"unsafe"
)
type MyStruct struct {
A int32
B float64 // B 相对于结构体起始地址的偏移量是 8 (在 64 位系统上,int32 占 4 字节,需要 4 字节对齐填充)
}
func main() {
data := MyStruct{A: 1, B: 3.14}
ptr := unsafe.Pointer(&data)
// 旧方法:使用 uintptr 进行计算
offsetB_old := unsafe.Offsetof(data.B) // 获取字段 B 的偏移量,类型为 uintptr
ptrB_old := unsafe.Pointer(uintptr(ptr) + offsetB_old)
*(*float64)(ptrB_old) = 6.28 // 修改 B 的值
fmt.Println("Old method result:", data)
// 新方法:使用 unsafe.Add
data = MyStruct{A: 1, B: 3.14} // 重置数据
ptr = unsafe.Pointer(&data)
offsetB_new := unsafe.Offsetof(data.B)
ptrB_new := unsafe.Add(ptr, offsetB_new) // 使用 unsafe.Add 进行指针偏移
*(*float64)(ptrB_new) = 9.42 // 修改 B 的值
fmt.Println("New method result:", data)
}
虽然效果相同,但 unsafe.Add
更明确地表达了“指针加偏移量”的意图。
unsafe.Slice
函数unsafe
包新增了 Slice
函数:unsafe.Slice(ptr *T, len IntegerType) []T
。
对于一个类型为 *T
的指针 ptr
和一个非负整数 len
,unsafe.Slice(ptr, len)
会返回一个类型为 []T
的切片。这个切片的底层数组从 ptr
指向的地址开始,其长度(length)和容量(capacity)都等于 len
。
同样,这个函数的目的是简化遵循 unsafe.Pointer
安全规则的代码,尤其是从一个指针和长度创建切片时,避免了之前需要构造 reflect.SliceHeader
或 reflect.StringHeader
的复杂步骤,但规则本身不变。使用者必须保证 ptr
指向的内存区域至少包含 len * unsafe.Sizeof(T)
个字节,并且这块内存在切片的生命周期内是有效的。
例如,从一个 C 函数返回的指针和长度创建 Go 切片:
package main
/*
#include <stdlib.h>
int create_int_array(int size, int** out_ptr) {
int* arr = (int*)malloc(size * sizeof(int));
if (arr == NULL) {
*out_ptr = NULL;
return 0;
}
for (int i = 0; i < size; i++) {
arr[i] = i * 10;
}
*out_ptr = arr;
return size;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
var cPtr *C.int
cSize := C.create_int_array(5, &cPtr)
defer C.free(unsafe.Pointer(cPtr)) // 必须记得释放 C 分配的内存
if cPtr == nil {
fmt.Println("Failed to allocate C memory")
return
}
// 使用 unsafe.Slice 创建 Go 切片
// 注意:这里的 cSize 类型是 C.int,需要转换为 Go 的整数类型 int32
goSlice := unsafe.Slice((*int32)(unsafe.Pointer(cPtr)), int(cSize))
fmt.Printf("Go slice: %v, len=%d, cap=%d\n", goSlice, len(goSlice), cap(goSlice))
// 输出: Go slice: [0 10 20 30 40], len=5, cap=5
// 可以像普通 Go 切片一样使用
goSlice[0] = 100
fmt.Printf("Modified C data via Go slice: %d\n", *cPtr) // 输出: Modified C data via Go slice: 100
}
piperliu@go-x86:~/code/playground$ go env | grep CGO
GCCGO="gccgo"
CGO_ENABLED="1"
CGO_CFLAGS="-g -O2"
CGO_CPPFLAGS=""
CGO_CXXFLAGS="-g -O2"
CGO_FFLAGS="-g -O2"
CGO_LDFLAGS="-g -O2"
piperliu@go-x86:~/code/playground$ go run main.go
Go slice: [0 10 20 30 40], len=5, cap=5
Modified C data via Go slice: 100
使用 unsafe.Slice
比手动设置 SliceHeader
更简洁且不易出错。
总的来说,unsafe
包的这两个新函数是为了让开发者在需要进行底层操作时,能够更容易地编写出符合 unsafe.Pointer
安全约定的代码,而不是放宽这些约定。
go
命令的诸多改进Go 1.17 对 Go 命令及其模块管理机制进行了多项重要改进,核心目标是提升构建性能、依赖管理的准确性和用户体验。
go.mod
文件,构建一个完整的 模块依赖图(module dependency graph)。即使某些间接依赖对于当前构建并非必需,它们的 go.mod
文件也可能被下载和解析。go 1.17
或更高)go.mod
文件内容变化 :如果一个模块在其 go.mod
文件中声明 go 1.17
或更高版本,运行 go mod tidy
时,go.mod
文件会包含更详细的传递性依赖信息。具体来说,它会为 每一个 提供了被主模块(main module)传递性导入(transitively-imported)的包的模块添加显式的 require
指令。这些新增的间接依赖通常会放在一个单独的 require
块中,以区别于直接依赖。go 1.17
模块时,其构建的模块图可以被“修剪”。对于其他同样声明了 go 1.17
或更高版本的依赖模块,Go 命令只需要考虑它们的 直接 依赖,而不需要递归地探索它们的完整传递性依赖。go.mod
文件包含了构建所需的所有依赖信息,Go 命令现在可以实行 懒加载 。它不再需要读取(甚至下载)那些对于完成当前命令并非必需的依赖项的 go.mod
文件。A
依赖 B
(go 1.17
),B
依赖 C
(go 1.17
),A
直接导入了 B
中的包,间接导入了 C
中的包。A
的 go.mod
可能只写 require B version
。Go 命令会加载 A
, B
, C
的 go.mod
。go mod tidy
后,A
的 go.mod
会包含 require B version
和 require C version
(在间接依赖块)。当处理 A
时,Go 命令看到 B
和 C
都是 go 1.17
模块,并且 A
的 go.mod
已包含所需信息,可能就不再需要去下载和解析 B
或 C
的 go.mod
文件了。go mod tidy -go=1.17
go mod tidy
会保留 Go 1.16 需要的 go.sum
条目。go mod tidy -compat=1.17
(旧版 Go 可能无法使用此模块)。go mod graph -go=1.16
。go.mod
文件顶部添加 // Deprecated: 弃用信息
格式的注释,然后发布一个包含此注释的新版本。go get
:如果需要构建的包依赖了被弃用的模块,会打印警告。go list -m -u
:会显示所有依赖的弃用信息(使用 -f
或 -json
查看完整消息)。// Deprecated: use example.com/mymodule/v2 instead. See migration guide at ...
module example.com/mymodule
go 1.17
require (...)
go get
行为调整-insecure
标志移除 :该标志已被废弃和移除。应使用环境变量 GOINSECURE
来允许不安全的协议,使用 GOPRIVATE
或 GONOSUMDB
来跳过校验和验证。go install
:使用 go get
安装命令(即不带 -d
标志)现在会产生弃用警告。推荐使用 go install cmd@version
(如 go install example.com/cmd@latest
或 go install example.com/cmd@v1.2.3
)来安装可执行文件。在 Go 1.18 中,go get
将只用于管理 go.mod
中的依赖。stringer
工具go install golang.org/x/tools/cmd/stringer@latest
go get
(管理依赖)和 go install
(安装命令/二进制文件)的职责。提高安全性配置的清晰度。go
指令的 go.mod
文件go.mod
:如果主模块的 go.mod
没有 go
指令且 Go 命令无法更新它,现在假定为 go 1.11
(之前是当前 Go 版本)。go.mod
文件(GOPATH 模式开发)或其 go.mod
文件没有 go
指令,现在假定为 go 1.16
(之前是当前 Go 版本)。vendor
目录内容调整 (go 1.17
或更高)vendor/modules.txt
:go mod vendor
现在会在 vendor/modules.txt
中记录每个 vendored 模块在其自身 go.mod
中指定的 go
版本。这个版本信息会在从 vendor 构建时使用。go.mod
/go.sum
:go mod vendor
现在会省略 vendored 依赖目录下的 go.mod
和 go.sum
文件,因为它们可能干扰 Go 命令在 vendor 树内部正确识别模块根。ssh-agent
进行密码保护的 SSH 密钥认证。go mod download
(无参数)go mod download
时,不再将下载内容的校验和保存到 go.sum
(恢复到 Go 1.15 的行为)。要保存所有模块的校验和,请使用 go mod download all
。go mod download
对 go.sum
的意外修改。//go:build
构建约束 (Build Constraints)//go:build
构建约束行,并 优先于 旧的 // +build
行。新语法使用类似 Go 的布尔表达式(如 //go:build linux && amd64
或 //go:build !windows
),更易读写,不易出错。gofmt
工具现在会自动同步同一文件中的 //go:build
和 // +build
行,确保它们的逻辑一致。建议所有 Go 文件都更新为同时包含两种形式,并保持同步。// 旧语法
// +build linux darwin
// 新语法 (由 gofmt 自动添加或同步)
//go:build linux || darwin
package mypkg
// 旧语法
// +build !windows,!plan9
// 新语法
//go:build !windows && !plan9
package mypkg
总结与最佳实践 :
Go 1.17 在模块管理方面带来了显著的性能和健壮性改进。最佳实践包括:
go mod tidy -go=1.17
将项目升级到新的模块管理机制。go install cmd@version
来安装和运行特定版本的 Go 程序。//go:build
语法,并利用 gofmt
来保持与旧语法的同步。// Deprecated:
注释。GOINSECURE
, GOPRIVATE
, GONOSUMDB
)替代 -insecure
标志。go.mod
中新的间接依赖 require
块的含义。这些改动共同体现了 Go 团队持续优化开发者体验、构建性能和依赖管理可靠性的设计理念。
go run
在 Go 1.17 中获得了在模块感知模式下运行指定版本包的能力在 Go 1.17 之前,go run
命令主要用于快速编译和运行当前目录或指定 Go 源文件。如果在一个模块目录下运行,它会使用当前模块的依赖;如果在模块之外,它可能工作在 GOPATH 模式下。要想运行一个特定版本的、非当前模块依赖的 Go 程序,通常需要先用 go get
(可能会修改当前 go.mod
或安装到 GOPATH
)或者 go install
来获取对应版本的源码或编译好的二进制文件。
Go 1.17 对 go run
进行了增强,允许直接运行指定版本的包,即使这个包不在当前模块的依赖中,也不会修改当前模块的 go.mod
文件。
新特性 :
go run
命令现在接受带有版本后缀的包路径参数,例如 example.com/cmd@v1.0.0
或 example.com/cmd@latest
。
行为 :
当使用这种带版本后缀的语法时,go run
会:
go.mod
:它不会使用当前项目(如果在项目目录下运行)的 go.mod
文件来解析依赖,而是为这个临时的运行任务构建一个独立的依赖集。GOPATH/bin
或 GOBIN
。go.mod
:当前项目的 go.mod
和 go.sum
文件不会被这次 go run
操作修改。这个特性非常适合以下情况:
stringer
工具生成代码,但你的项目依赖的是旧版本。go install
,可以直接 go run
指定版本的构建工具或代码生成器。示例 :
假设你想运行 golang.org/x/tools/cmd/stringer
的最新版本来为当前目录下的 mytype.go
文件中的 MyType
生成代码,但你的项目 go.mod
可能没有依赖它,或者依赖了旧版。
# 使用 Go 1.17 的 go run 运行最新版的 stringer
go run golang.org/x/tools/cmd/stringer@latest -type=MyType
# 运行特定版本的内部工具,不影响当前项目依赖
go run mycompany.com/tools/deploy-tool@v1.2.3 --config=staging.yaml
这避免了先 go get golang.org/x/tools/cmd/stringer
(可能污染 go.mod
或全局 GOPATH
)或者 go install golang.org/x/tools/cmd/stringer@latest
(需要写入 GOBIN
)的步骤。
设计理念 :提升 go run
的灵活性和便利性,使其成为一个更强大的临时执行 Go 程序的工具,特别是在需要版本控制和隔离依赖的场景下。
vet
工具增加了对构建标签、信号处理和错误接口方法签名的静态检查Go 1.17 版本中的 go vet
工具(一个用于发现 Go 代码中潜在错误的静态分析工具)新增了三项有用的检查,旨在帮助开发者避免一些常见的陷阱和错误。
//go:build
和 // +build
行//go:build
构建约束语法,并推荐使用它替代旧的 // +build
语法。在过渡期间,推荐两者并存且保持逻辑一致。//go:build
必须在文件顶部,仅前面可以有空行或注释),可能会导致两个约束的实际效果不一致,根据使用的 Go 版本不同,编译结果可能出乎意料。vet
现在会验证同一个文件中的 //go:build
和 // +build
行是否位于正确的位置,并且它们的逻辑含义是否同步。gofmt
工具自动修复,它会根据 //go:build
的逻辑(如果存在)来同步 // +build
,或者反之。// BAD: Logic mismatch
//go:build linux && amd64
// +build linux,arm64 <-- Vet will warn about this mismatch
package main
//go:build
语法迁移的过程中,代码行为保持一致,减少因构建约束不匹配导致的潜在错误。signal.Notify
os/signal.Notify
函数用于将指定的操作系统信号转发到提供的 channel
中。signal.Notify
在发送信号到 channel
时是 非阻塞 的。如果提供的 channel
是无缓冲的 (make(chan os.Signal)
),并且在信号到达时没有 goroutine 正在等待从该 channel
接收 (<-c
),那么 signal.Notify
的发送操作会失败,这个信号就会被 丢弃 。这可能导致程序无法响应重要的 OS 信号(如 SIGINT
(Ctrl+C), SIGTERM
等)。vet
现在会警告那些将无缓冲 channel
作为参数传递给 signal.Notify
的调用。channel
,至少为 1,以确保即使接收者暂时阻塞,信号也能被缓存而不会丢失。package main
import (
"fmt"
"os"
"os/signal"
"time"
)
func main() {
// BAD: Unbuffered channel - Vet will warn here
cBad := make(chan os.Signal)
signal.Notify(cBad, os.Interrupt) // Sending os.Interrupt (Ctrl+C) to cBad
go func() {
// Simulate receiver being busy for a moment
time.Sleep(1 * time.Second)
sig := <-cBad // Might miss signal if it arrives during sleep
fmt.Println("Received signal (bad):", sig)
}()
fmt.Println("Send Ctrl+C within 1 second (bad example)...")
time.Sleep(5 * time.Second) // Wait long enough
// GOOD: Buffered channel
cGood := make(chan os.Signal, 1) // Buffer size of 1 is usually sufficient
signal.Notify(cGood, os.Interrupt)
go func() {
sig := <-cGood // Signal will be buffered if it arrives while this goroutine isn't ready
fmt.Println("Received signal (good):", sig)
}()
fmt.Println("Send Ctrl+C (good example)...")
time.Sleep(5 * time.Second)
}
error
类型上 Is
, As
, Unwrap
方法的签名错误errors
包的 Is
, As
, Unwrap
函数,它们允许错误类型提供特定的方法来自定义错误链的检查、类型断言和解包行为。这些函数依赖于被检查的 error
值(或其链中的错误)实现了特定签名的方法:errors.Is
查找 Is(error) bool
方法。errors.As
查找 As(interface{}) bool
方法(注意参数是 interface{}
,通常写成 any
)。errors.Unwrap
查找 Unwrap() error
方法。error
类型上定义了名为 Is
, As
, 或 Unwrap
的方法,但方法签名与 errors
包期望的不匹配(例如,把 Is(error) bool
写成了 Is(target interface{}) bool
),那么 errors
包的相应函数(如 errors.Is
)会 忽略 这个用户定义的方法,导致其行为不符合预期。开发者可能以为自己定制了 Is
的行为,但实际上没有生效。vet
现在会检查实现了 error
接口的类型。如果这些类型上有名为 Is
, As
, 或 Unwrap
的方法,vet
会验证它们的签名是否符合 errors
包的预期。如果不符合,则发出警告。Is
, As
, Unwrap
方法签名与 errors
包的要求完全一致。package main
import (
"errors"
"fmt"
)
// Define a target error
var ErrTarget = errors.New("target error")
// BAD: Incorrect Is signature (should be Is(error) bool) - Vet will warn here
type MyErrorBad struct{ msg string }
func (e MyErrorBad) Error() string { return e.msg }
func (e MyErrorBad) Is(target interface{}) bool { // Incorrect signature!
fmt.Println("MyErrorBad.Is(interface{}) called") // This won't be called by errors.Is
if t, ok := target.(error); ok {
return t == ErrTarget
}
return false
}
// GOOD: Correct Is signature
type MyErrorGood struct{ msg string }
func (e MyErrorGood) Error() string { return e.msg }
func (e MyErrorGood) Is(target error) bool { // Correct signature!
fmt.Println("MyErrorGood.Is(error) called")
return target == ErrTarget
}
func main() {
errBad := MyErrorBad{"bad error"}
errGood := MyErrorGood{"good error"}
fmt.Println("Checking errBad against ErrTarget:")
// errors.Is finds no `Is(error) bool` method on errBad.
// It falls back to checking if errBad == ErrTarget, which is false.
// The custom MyErrorBad.Is(interface{}) is NOT called.
if errors.Is(errBad, ErrTarget) {
fmt.Println(" errBad IS ErrTarget (unexpected)")
} else {
fmt.Println(" errBad IS NOT ErrTarget (as expected, but custom Is ignored)")
}
fmt.Println("\nChecking errGood against ErrTarget:")
// errors.Is finds the correctly signed `Is(error) bool` method on errGood.
// It calls errGood.Is(ErrTarget).
if errors.Is(errGood, ErrTarget) {
fmt.Println(" errGood IS ErrTarget (as expected, custom Is called)")
} else {
fmt.Println(" errGood IS NOT ErrTarget (unexpected)")
}
}
Checking errBad against ErrTarget:
errBad IS NOT ErrTarget (as expected, but custom Is ignored)
Checking errGood against ErrTarget:
MyErrorGood.Is(error) called
errGood IS ErrTarget (as expected, custom Is called)
Is
/As
/Unwrap
)时,能够正确地实现接口契约,避免因签名错误导致的功能不生效和潜在的逻辑错误。Go 1.17 的编译器带来了一项重要的底层优化和几项相关改进,旨在提升程序性能和开发者体验。
amd64
) 上的 Linux (linux/amd64
)、 macOS (darwin/amd64
) 和 Windows (windows/amd64
) 平台启用。unsafe
代码 :如果代码违反了 unsafe.Pointer
的规则来访问函数参数,或者依赖于比较函数代码指针等未文档化的行为,可能会受到影响。reflect.ValueOf(fn).Pointer()
或 unsafe.Pointer
获取汇编函数的地址 ,现在获取到的可能是适配器的地址,而不是原始函数的地址。依赖这些代码指针精确值的代码可能不再按预期工作。func value
)间接调用汇编函数;二是从汇编代码调用 Go 函数。// 旧:基于栈的调用约定 (简化)
+-----------------+ <-- Higher memory addresses
| Caller's frame |
+-----------------+
| Return Address |
+-----------------+
| Return Value(s) | <--- Space reserved on stack
+-----------------+
| Argument N | <--- Pushed onto stack
+-----------------+
| ... |
+-----------------+
| Argument 1 | <--- Pushed onto stack
+-----------------+ --- Stack Pointer (SP) before call
| Callee's frame |
+-----------------+ <-- Lower memory addresses
// 新:基于寄存器的调用约定 (简化, amd64)
CPU Registers:
RAX, RBX, RCX, RDI, RSI, R8-R15, XMM0-XMM14 etc. used for integer, pointer, float args/results
Stack: (Used only if args don't fit in registers, or for certain types)
+-----------------+ <-- Higher memory addresses
| Caller's frame |
+-----------------+
| Return Address |
+-----------------+
| Stack Argument M| <--- If needed
+-----------------+
| ... |
+-----------------+ --- Stack Pointer (SP) before call
| Callee's frame |
+-----------------+ <-- Lower memory addresses
panic
或调用 runtime.Stack
时,Go 运行时会打印栈跟踪信息,用于调试。struct
、数组 array
、字符串 string
、切片 slice
、接口 interface
、复数 complex
)的参数会用花括号 {}
界定。这大大提高了可读性。panic
和 runtime.Stack
输出信息的可读性,让开发者更容易理解程序崩溃或特定时间点的函数调用状态。reflect
或 unsafe.Pointer
绕过这个限制来比较函数(这本身就是不推荐的做法),那么这种行为可能会暴露这类代码中的潜在 bug,因为之前认为相同的函数现在可能因为内联而具有不同的代码指针。Go 1.17 编译器在 amd64 平台上的核心变化是引入了基于寄存器的调用约定,显著提升了性能。同时,改进了栈跟踪的可读性,并扩大了内联优化的范围。这些改动对大多数开发者是透明的,但使用 unsafe
或依赖底层细节(如函数指针比较)的代码需要注意可能的变化。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。