0%

《Go 语言精进之路》读书笔记(09):标准库、反射与 cgo

Go 拥有功能强大且质量上乘的标准库,这篇文章将介绍高频使用的标准库包,如 net/http、strings、bytes、time 等正确使用方式,以及 reflect 包、cgo 在使用时的注意事项。

理解 Go TCP Socket 网络编程模型

网络通信是服务端程序必不可少也是至关重要的一部分。Go 是自带运行时的跨平台编程语言,Go 中暴露给语言使用者的 TCP Socket 接口是建立在操作系统原生 TCP Socket 接口之上的。

TCP Socket 网络编程模型

网络 I/O 模型定义的是应用线程与操作系统内核之间的交互行为模式。我们通常用阻塞(Blocking)和非阻塞(Non-Blocking)来描述网络 I/O 模型。阻塞和非阻塞是以内核是否等待条件全部就绪才返回(给发起系统调用的应用线程)来区分的。常见的网络 I/O 模型有:

  • 阻塞 I/O 模型:在该模型下,所有 Socket 默认都是阻塞的。在该模型下,如果内核无法满足 I/O 操作的条件,用户空间线程将会阻塞在该 I/O 系统调用上,无法进行后续处理,只能等待
  • 非阻塞 I/O 模型:在该模型下,如果内核无法满足 I/O 操作的条件,会立即返回返回对应的错误码(例如 EAGAIN 或者 EWOULDBLOCK),用户空间线程可以根据系统调用的返回状态决定下一步如何做
  • I/O 多路复用模型:为了避免非阻塞 I/O 模型轮询对计算资源的浪费以及阻塞 I/O 模型的低效,开发人员开始首选 I/O 多路复用模型作为网络 I/O 模型。I/O多路复用模型建立在操作系统提供的 select/poll 等多路复用函数(以及性能更好的 epoll 等函数)的基础上。在该模型下,应用线程可以同时处理多个 Socket,而且由于是内核实现的可读/可写事件的通知,这避免了非阻塞模型中应用线程轮询带来的 CPU 计算资源的浪费
  • 异步 I/O 模型:在该模型中,应用线程发起异步 I/O 调用后,内核将启动等待数据的操作并马上返回。之后应用线程可以继续执行其他操作。在内核空间数据就绪后,内核会主动生成信号以驱动执行用户线程在异步 I/O 调用时注册的信号处理函数,或者主动执行用户线程注册的回调函数

目前主流网络服务器采用的多是 I/O 多路复用模型,有的也结合了多线程。I/O 多路复用模型在支持更多连接、提升 I/O 操作效率的同时,也给使用者带来了不低的复杂性,因此出现了很多高性能的 I/O 多路复用框架,例如 libevent、libev、libuv 等,以降低开发复杂性。

但是 Go 设计者认为 I/O 多路复用这种通过回调割裂控制流的模型依旧复杂,且有悖于一般顺序的逻辑设计,为此结合 Go 语言自身的特点,将该复杂性隐藏在 Go 运行时中。因此大部分情况下,Go 开发者只需要在每个连接对应的 goroutine 中以最简单、最易用的阻塞 I/O 模型的方式进行 Socket 操作即可,大大降低了网络应用开发的复杂度。

所以一个典型的 Go 网络服务端程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func handleConn(c net.Conn) {
defer c.Close()
for {
// read from c

// write to c
}
}

func main() {
l, err := net.Listen("tcp", ":80")
if err != nil {
return
}

for {
c, err := l.Accept()
if err != nil {
break
}

go handleConn(c)
}
}

在 Go 程序的用户层(相对于 Go 运行时)看来,goroutine 采用 阻塞 I/O 模型 进行网络 I/O 操作,Socket 都是阻塞的。但实际上,这样的假象是 Go 运行时中的 netpoller 通过 I/O 多路复用机制模拟出来的,对应的底层操作系统 Socket 实际上是非阻塞的

TCP 连接的建立

在 TCP 连接建立过程中,服务端是标准的 Listen + Accept 的结构,而在客户端 Go 程序使用 Dial 或者 DialTimeout 函数发起连接建立请求:

  • Dial 在调用后将一直阻塞,直到连接建立成功或者失败
  • DialTimeout 是带有超时机制的 Dial

客户端常见的错误包括:

  • 网络不可达或者对方服务未启动:如果服务端地址是网络不可达的,或者服务地址中的端口并没有监听,则 Dial 几乎会立即返回错误
  • 对方服务的 listen backlog 队列满了:这将导致客户端的 Dial 调用阻塞(服务端即使不调用 accept,只要在 backlog 数量范围内,客户端的连接操作也会成功,因为新的连接已经加入服务端的内核 listen 队列中,accept 操作只是从这个队列中取出一个连接而已)。在 Linux 系统中,可以通过 net.ipv4.tcp_max_syn_backlog 参数来调整 listen backlog 的默认设置
  • 如果网络延迟较大,Dial 将会阻塞。如果经过长时间阻塞后依旧无法建立连接,那么 Dial 也会返回类似 getsockopt: operation timed out 的错误

Socket 读写

Dial 连接成功后,会返回一个 net.Conn 类型的对象,它的底层类型为一个 *TCPConn

1
2
3
4
5
6
7
type TCPConn struct {
conn
}

type conn struct {
fd *netFD
}

conn 类型实现了 Read 和 Write 方法。下面先简单总结 conn.Read 的特点:

  • Socket 无数据:此时会阻塞在 Socket 的读取操作上,直到有数据时才进行返回
  • Socket 中有部分数据:如果 Socket 中有部分数据就绪,且数据数量小于一次读操作所期望读出的数据长度,那么读操作将会成功读出这部分数据并返回,而不是等待期望长度数据全部读取后再返回
  • Socket 中有足够多的数据:如果连接上有数据,且数据长度大于或等于一次 Read 操作所期望读出的数据长度,那么 Read 将会成功读出这部分数据并返回
  • Socket 关闭:如果对端主动关闭了 Socket,那么继续对本端 socket 读取时,将首先读取本端 socket 中的剩余数据,所有数据读取完成后再次读取将会返回 EOF 错误(代表连接断开)
  • 可以通过 SetReadDeadline 方法设置 Socket 读操作的超时时间

接下来总结 conn.Write 的特点:

  • 成功写 是指 Write 调用时返回的 n 与预期要写入的数据长度相等,且 error == nil
  • TCP socket Write 其实是将数据写入操作系统协议栈中的数据缓冲区中,当发送方自己的发送缓冲区满后,Write 调用就会被阻塞
  • Write 操作存在写入部分数据的情况,例如当写入部分数据后,Write 操作被阻塞,假设此时连接断开,那么就会出现部分写的情况
  • 可以通过 SetWriteDeadline 方法设置 Socket 写操作的超时时间

因此虽然 Go 提供了阻塞 I/O 的便利,但是在调用 Read 和 Write 时依旧要结合这两个方法返回的 n 和 err 的结果来做出正确处理。

另外再考虑以下 goroutine 安全的并发读写问题。

  • 在 Go 运行时内部,每次 Write 操作都是受锁保护的,直到此次数据全部写完。因此在应用层面,要想保证多个 goroutine 在一个 conn 上的 Write 操作是安全的,需要让每一次 Write 操作完整地写入一个业务包。一旦将业务包的写入拆分为多次 Write 操作,就无法保证某个 goroutine 的某业务包数据在 conn 上发送的连续性。

  • Read 操作也是有锁保护的,多个 goroutine 对同一 conn 的并发读不会出现内容重叠的情况,但是内容断点也是运行时调度随机确定的。其实多个 goroutine 对同一个 conn 进行 Read 操作的意义不大,因为 goroutine 读不到完整的业务包反而增加了业务处理的难度

Socket 属性

原生的 Socket API 提供了丰富的 sockopt 设置接口,Go 提供的 socket options 接口也可以对 socket 属性进行设置。例如 SetNoDelay 等接口可以作用在 TCPConn 类型上:

1
2
3
4
5
6
tcpConn, ok = c.(*net.TCPConn)
if !ok {

}

tcpConn.SetNoDelay(true)

对于监听 socket,Go 默认设置了 SO_REUSEADDR 选项。

关闭连接

Socket 是全双工的,在己方已关闭 Socket 和对方已关闭 Socket 上操作的结果有所不同:

  • 在己方已经关闭的 Socket 上进行 Read/Write 操作,会得到错误:use of closed network connection
  • 在对方已经关闭的 Socket 上进行 Read 操作会得到 EOF 错误,但是 Write 操作依然会成功(数据可以成功写入己方内核缓冲区,即使最终发不到对方的 Socket 缓冲区中)

使用 net/http 包实现安全通信

Go 在 Web 开发领域的广泛应用得益于 Go 标准库内置了 net/http 包,如下代码就能快速实现一个 Hello, World 级别的 Web 服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"fmt"
"net/http"
)

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!\n")
})

http.ListenAndServe("localhost:8080", nil)
}

但是 HTTP 协议是明文协议,使用 HTTP 协议传输的数据存在如下风险:

  • 使用不加密的明文进行通信,内容可能被窃听
  • 不验证通信方的身份,可能遭遇伪装
  • 无法验证明文的完整性,内容可能遭遇篡改

这一节介绍如何基于标准库的 net/http 包实现安全通信。

HTTPS:在安全传输层上运行的 HTTP 协议

HTTPS 协议就是用来解决传统 HTTP 协议明文传输不安全的问题,与普通 HTTP 协议不同,HTTPS 协议在传输层(TCP 协议)和应用层(HTTP 协议)之间增加了一个安全传输层(SSL/TLS)。SSL/TLS 负责 HTTP 协议传输内容加密、通信双方身份验证、内容完整性保护等。HTTPS 协议就是在安全传输层上运行的 HTTP 协议。

下面的代码展示了如何创建采用 HTTPS 协议的 Web 服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"fmt"
"net/http"
)

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!\n")
})

http.ListenAndServeTLS("localhost:8081", "server.crt", "server.key", nil)
}

这里 server.crtserver.key 是 HTTP 包针对 HTTPS 协议进行内容加密、身份验证和内容完整性验证的前提,利用 openssl 工具可以生成所需要使用的 server.crt 和 server.key 文件。

1
2
# openssl genrsa -out server.key 2048
# openssl req -new -x509 -key server.key -out server.crt -days 365

我们从浏览器访问该站点,仍然会得到告警,这是因为我们使用的是自签名证书。也可以使用 curl -k 的方式进行访问:

1
2
# curl -k https://127.0.0.1:8081/
Hello, World!

如果不使用 -k 也会有自签名证书告警,因为 curl 使用的是运行该命令的系统环境中内置的各种数字证书授权机构的公钥证书无法对其进行验证。而 -k 选项可以忽略对 HTTPS 服务服务端证书的校验:

1
2
# curl https://127.0.0.1:8081/
curl: (60) SSL certificate problem: self-signed certificate

HTTPS 安全传输层的工作机制

HTTPS 协议是构建在基于 SSL/TLS 协议实现的安全传输层之上的 HTTP 协议,所以一旦通信双方在传输安全层之上成功建立连接,那么之后所有的 HTTP 协议数据经过安全层传输时都会被自动加密/解密。

安全传输层建立连接的过程称为握手阶段:

  • ClientHello(客户端->服务端):客户端首先发送建立安全层传输连接的请求消息,在这个请求中,客户端会向服务端提供本地最新 TLS 版本、支持的加密算法组合的集合

  • ServerHello & Server certificate & ServerKeyExchange(服务端->客户端):

    • 服务端收到 ClientHello 消息后,会选择一个合适的 TLS 版本和加密算法组合(ClientHello 中所列举的与本地所支持的进行对比),然后生成一个随机数,一并通过 ServerHello 消息给客户端
    • 服务器将自己的服务端公钥证书发送给客户端(Server certificate),这个公钥证书主要有两大职责:客户端对服务端身份的验证以及后续双方会话秘钥的协商和生成
    • 如果服务端要验证客户端身份,那么服务端还会发送一个 CertificateRequest 请求给客户端,要求对客户端的公钥证书进行验证
    • 发送开启双方会话秘钥协商的请求(ServerKeyExchange):HTTPS 协议会在非对称加密算法的帮助下协商一个用于对称加密算法的秘钥。在密钥协商环节,通常会使用到 Diffie-Hellman(DH)密钥交换算法,这是一种密钥协商的协议,支持通信双方在不安全的通道上生成对称加密算法所需的共享密钥
    • 最后服务端以 Server Finished(也称为 ServerDone)作为通信结束标志
  • ClientKeyExchange & ClientChangeCipher & Finished (客户端 → 服务端)

    • 客户端在收到服务端公钥证书后会对服务端身份验证。验证通过,会从证书中提取服务端的公钥,用于加密后续秘钥协商时发送给服务端的信息
    • 如果服务端要求对客户端身份进行验证,那么客户端还需要通过 ClientCertificate 将自己的公钥证书发送给服务端进行验证
    • 收到服务端对称加密共享密钥协商的请求后,客户端根据之前的随机数、确定的加密算法组合以及服务端发来的参数计算出最终的会话密钥,然后将服务端单独计算出会话密钥所需的信息用服务端的公钥加密后以ClientKeyExchange 请求发送给服务端
    • 随后客户端用 ClientChangeCipher 通知服务端从现在开始发送的消息都是加密过的
    • 最后的 Finished 消息用来验证双方的对称加密共享秘钥协商是否成功
  • ServerChangeCipher & Finished(服务端 → 客户端)

    • 服务端收到客户端发送的 ClientKeyExchange 消息后,也会单独计算出会话秘钥。之后同样,服务端用 ServerChangeCipher 通知客户端从现在开始发送的消息都是加密过的
    • 最后的 Finished 消息用来验证双方的对称加密共享秘钥协商是否成功

非对称加密和公钥证书

公钥证书是非对称加密体系的重要内容,非对称加密又称为公钥加密体系,与对称加密对比:

  • 对称加密是指通信双方使用同一个共享密钥,该秘钥同时用于数据的加密和解密
  • 非对称加密指通信的每一方都有两个秘钥:一个公钥,一个私钥:数据的发送方使用对方的公钥数据进行加密,数据接收方自己的私钥进行数据解密。公钥是公开的,而私钥则自己保存。

对称加密性能更好,但是秘钥报错、管理、分发存在较大安全风险。非对称加密就是为了解决对称加密秘钥分发安全隐患而设计的,但是非对称加密性能更差。所以 HTTPS 的传输安全层会将两种加密方式结合使用。

但是 TLS 握手过程并非直接传输公钥信息,而是使用携带公钥信息的数字证书来保证公钥信息的正确性和完整性。数字证书可以认为是互联网的身份证,用来唯一标识网络上的一个域名地址或者一台服务器主机。一般来说,数字证书是从受信任的的数字证书授权机构(CA)获取,一般浏览器或者操作系统在出厂时就内置了诸多知名 CA 的公钥证书。对于这些内置 CA 公钥证书无法识别的证书,浏览器就会告警,这就是为什么上面示例中,我们使用自签名证书会出现警告。

一般公钥证书都会包含站点的名称、主机名、公钥、证书签发机构(CA)名称和来自签发机构的签名等。对 服务器公钥证书 的校验就是使用本地 CA 公钥证书来验证 服务器公钥证书 中的签名是不是这个 CA 签发的。

下面详细介绍 公钥证书申请公钥证书校验 两个过程。首先是 公钥证书申请 过程:

  • 站点如果需要开启 HTTPS 服务,需要向 CA 提交数字证书申请,该请求以 CSR(Certificate Signing Request)文件的形式提交给 CA

如下 openssl 命令基于申请者的私钥生成 CSR 文件,CSR 文件中包含了申请人的基本信息,同时也包括了申请者的公钥信息

1
2
# openssl req -new -key server.key -subj "/CN=localhost" -out server.csr
# openssl req -in server.csr -noout -text
  • CA 收到客户的证书申请后,会按照标准数字证书规范生成该申请人的数字公钥证书

接下来我们将模拟一个 CA,CA 的核心就是一个私钥以及该私钥自签名的 CA 公钥证书(内置到操作系统和浏览器中分发)。因此可以通过如下命令生成 CA 私钥及其公钥证书:

1
2
# openssl genrsa -out ca.key 2048
# openssl req -new -x509 -nodes -key ca.key -subj "/CN=myca.com" -out ca.crt -days 365

接下来就可以使用 ca.key 和 ca.crt 处理 CSR 文件了:

1
# openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server-signed-by-ca.crt -days 5000

生成的 server-signed-by-ca.crt 为该服务端颁发的公钥证书了。

接下来再介绍一下客户端如何通过内置的 ca.crt 对服务端下发的 server.crt 进行校验:

  • 再非对称加密中,公钥和私钥还有一个重要的功能,那就是验证信息来源与数据完整性。这个功能就被用于对通信对方公钥证书的校验上了。当一对私钥和公钥被用于验证信息来源以及保证数据完整性时,我们使用私钥对数据(或数据摘要)进行签名(加密),然后用公钥对签过名的数据进行校验(解密)

如下展示了这一点:

1
2
3
4
5
# echo "hello, world" > hello.txt

# openssl pkeyutl -sign -in hello.txt -inkey ca.key -out hello.signed
# openssl pkeyutl -verify -sigfile hello.signed -in hello.txt -inkey ca.crt -certin
Signature Verified Successfully

也可以直接查看 CA 对服务端证书的校验结果:

1
2
# openssl verify -CAfile ca.crt server-signed-by-ca.crt
server-signed-by-ca.crt: OK

整个加密解密过程可以表示为:

1
2
d = digest(server.pub, certificate info) // server.pub为公钥信息
sign = encrypt_with_ca_key(d) // 使用ca.key对d进行加密(签名)
1
2
d' = decrypt_with_ca_crt(sign)  // 使用 ca.crt 对sign进行解密

然后用解密得到的 d’ 与使用相同摘要算法对证书中公钥信息与证书属性信息进行摘要计算后的结果 d 进行比较,如果一致,则说明证书校验通过;否则,证书校验失败。通过对证书签名信息的校验可以保证证书内容未被中途篡改,同时也确定了证书归属

对服务端公钥证书的校验

在 Go 代码中,如果想要直接信任某个服务端,忽略服务端证书的校验,可以通过如下方法设置:

1
2
3
4
5
6
7
tr := &http.Transport{
TLSClientConfig: &tls.Config{
InSecureSkipVerify: true,
}
}

client := &http.Client{Transport: tr}

如果要实现客户端对服务端公钥证书的校验,那么就需要让客户端知晓并加载 CA 的公钥证书:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
pool := x509.NewCertPool()
caCertPath := "ca.crt"

caCrt, err := ioutil.ReadFile(caCertPath)
if err != nil {
fmt.Println("ReadFile err:", err)
return
}
pool.AppendCertsFromPEM(caCrt)

tr := &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool},
}
client := &http.Client{Transport: tr}

当然也可以将自签名的 CA 公钥证书导入系统 CA 证书存储目录下。例如在 Ubuuntu 上,可以通过如下方式导入:

1
2
3
4
5
6
# apt-get install -y ca-certificates
# cp local-ca.crt /usr/local/share/ca-certificates
# update-ca-certificates

# ls -l /etc/ssl/certs/ | grep local
lrwxrwxrwx 1 root root 39 Sep 25 14:08 ca.pem -> /usr/local/share/ca-certificates/ca.crt

之后如果服务端使用该 server-signed-by-ca.crt 作为证书的话,curl 就可以不用再使用 -k 参数了:

1
2
# curl https://localhost:8081/
Hello, World!

对客户端公钥证书的校验

在一些安全要求严格的场景下,服务端也可以要求对客户端的公钥证书进行校验,以严格识别客户端的身份,限制不合法身份的客户端访问。

服务端需要增加校验客户端公钥证书的设置,并加载用于校验客户端公钥证书的 ca.crt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {
pool := x509.NewCertPool()
caCertPath := "ca.crt"

caCrt, err := ioutil.ReadFile(caCertPath)
if err != nil {
fmt.Println("ReadFile err:", err)
return
}
pool.AppendCertsFromPEM(caCrt)

s := &http.Server{
Addr: "localhost:8081",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!\n")
}),
TLSConfig: &tls.Config{
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert,
},
}

fmt.Println(s.ListenAndServeTLS("server-signed-by-ca.crt",
"server.key"))
}

客户端为了提供公钥证书,主要逻辑为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func main() {
pool := x509.NewCertPool()
caCertPath := "ca.crt"

caCrt, err := ioutil.ReadFile(caCertPath)
if err != nil {
fmt.Println("ReadFile err:", err)
return
}
pool.AppendCertsFromPEM(caCrt)

cliCrt, err := tls.LoadX509KeyPair("client.crt", "client.key")
if err != nil {
fmt.Println("Loadx509keypair err:", err)
return
}

tr := &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: pool,
Certificates: []tls.Certificate{cliCrt},
},
}

client := &http.Client{Transport: tr}
.....

掌握字符集的原理和字符编码方案间的转换

Go 默认使用 Unicode 字符集,并采用 UTF-8 编码方案。Go 还提供了 rune 原生类型来表示 Unicode 字符。

字符与字符集

所有字符组成的集合就称为字符集,而计算机种数据存储和传输都是以比特的形式来表示的,而如何用比特来表示字符就是编码方案。ASCII 字符集中有意义的字符只有 128 个,预留 128 个备用,而表示这 256 个字符只需要使用 8 bit 即可,即用一个字节就可以表示 ASCII 字符集中的一个字符。

计算机字符集中的每个字符都有两个属性:码点(即该字符在字符集中的唯一序号值)和表示这个码点的内存编码(位模式,表示这个字符码点的二进制比特串)。ASCII 字符集中的每个字符的码点与其内存编码表示是一致的。

而乱码的原因则是字符集不兼容,具体来说就是一个内存编码表示(位模式)在不同的字符集中对应的是不同的字符。

Unicode 字符集的诞生于 UTF-8 编码方案

Unicode 字符集是以收纳人类所有字符位目的统一字符集,它致力于为全世界现存的每种语言中的每个字符分配一个统一且唯一的字符编号,以满足跨语言、跨平台进行文本数据交换、处理、存储和显示的需求。为了考虑于 ASCII 字符集兼容,Unicode 的前 128 个码点与 ASCII 字符码点是一一对应的。

在有了 Unicode 字符集的码点表后,还需要直到每个码点在计算机中的内存表示(为模式)。Unicode 目前有 3 种较为常用的编码方案:

  • UTF-16:该方案使用 2 或 4 字节表示每个 Unicode 字符码点
  • UTF-32:固定使用 4 字节表示每个 Unicode 字符码点
  • UTF-8:UTF-8 使用变长字节长度对 Unicode 码点进行编码,使用的字节数从 1 - 4 不等。前 128 个与 ASCII 字符重合的码点使用 1 字节表示,从而兼容 ASCII 字符内存表示。这意味着采用 UTF-8 编码方案在内存中表示 Unicode 字符时,已有的 ASCII 字符可以被直接当成 Unicode 字符进行存储和传输,无须做任何改变。而且 UTF-8 的编码单元为 1 字节(也就是一次解编码 1 字节),所以无需考虑字节序问题。同时空间效率最高

UTF-8 编码方案由于优点众多,已经成为 Unicode 字符集事实标准上的编码方案。Go 语言顺应了这一趋势,其源码文件的字符编码采用的也是 UTF-8 编码。

字符编码方案间的转换

我们经常需要在不同字符集的字符编码方案间进行转换。Go 语言中默认源码文件中的字符是采用 UTF-8 编码方案的 Unicode 字符:

  • 每个 rune 对应一个 Unicode 字符的码点
  • Unicode 字符在内存中的编码表示则放在 []byte 类型中
  • 从 rune 到 []byte 的类型转换,称为 编码,反之则称为 解码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
"fmt"
"unicode/utf8"
)

func encodeRune() {
var r rune = '中'
buf := make([]byte, 3)

n := utf8.EncodeRune(buf, r)
fmt.Printf("encode: \n")
for i := 0; i < n; i++ {
fmt.Printf("0x%X", buf[i])
}
fmt.Printf("\n")
}

func decodeRune() {
var buf = []byte{0xE4, 0xB8, 0xAD}
r, _ := utf8.DecodeRune(buf)
fmt.Printf("decode: 0x%X\n", r)
fmt.Printf("code point of 中: 0x%X\n", '中')

for _, b := range []byte("中") {
fmt.Printf("0x%X\n", b)
}
}

func main() {
encodeRune()
decodeRune()
}
1
2
3
4
5
6
7
8
# ./main
encode:
0xE40xB80xAD
decode: 0x4E2D
code point of 中: 0x4E2D
0xE4
0xB8
0xAD

可以看到看到,直接打印一个字符,其实是打印它的码点值,而码点值和该字符的编码字节序列并一定是相同(ASCII 字符的码点值和其内存编码表示是一致的)

使用 Go 标准库及其依赖库 golang.org/x/text 下的包,我们不仅可以实现 Go 默认字符编码 UTF-8 与其他字符集编码的相互转换,还可以实现任意字符编码方案之间的相互转换。

掌握使用 time 包的正确方式

Go 语言通过标准库的 time 包为常见的时间操作提供了全面的支持。

时间的基础操作

time.Now() 用于获取当前时间,它返回一个以 Time 结构体类型作为返回值。

1
2
3
4
5
type Time struct {
wall unit64
ext int64
loc *Location
}

由三个字段组成的 Time 结构体要同时表示两种时间,并且精度级别为 ns。

  • 挂钟时间(wall time):主要用于告知当前时间,这个时间和日常真实使用的墙上挂钟行为非常相似,这个时间可以人为重新设定。因此从其行为特征来看,连续两次通过 Now 函数获取的挂钟时间之间的差值不一定为正值
  • 单调时间(monotonic time):它表示的是程序启动后流逝的时间,永远不会出现时间倒流的现象。单调时间常用来两个即时时间之间的比较和间隔计算

Time.wall 用于标识挂钟时间,它内部被分为三段:

  • hasMonotonic(第 63 bit):结构体中是否包含单调时间
  • 秒数(第 30 ~ 62 bit):表示距离 1885.1.1 的秒数
  • 纳秒数(第 0 ~ 29 bit)

当 hasMonotonic 为 1 时,Time.ext 用于表示单调时间,表示程序进程启动后的单调流逝时间,以纳秒为单位。

Time.loc 用于获取时区信息,通过 Now 函数获取的即时时间是时区相关的,如果没有显式指定时区,则默认使用系统时区。对于 Linux 系统:

1
2
# ls -l /etc/localtime
lrwxrwxrwx 1 root root 33 Mar 2 2024 /etc/localtime -> /usr/share/zoneinfo/Asia/Shanghai

当 hasMonotonic 为 0 时,time.Time 结构体仅表示挂钟时间,此时 Time.Wall 中的秒数(第 30 ~ 62 bit)也设置为 0,而 Time.ext 用于表示挂钟时间的整秒部分,其含义为距离公元元年 1 月 1 日的秒数。

通过 time.Parsetime.Datetime.Unix 函数构建的 Time 实例仅表示挂钟时间,而没有单调时间。而 time.Now 获取的 Time 实例则同时表示挂钟时间和单调时间。

如果想要获取特定时区(而不是本地时区)的当前时间,有以下方法:

  • 设置 TZ 环境变量,time.Now 函数会尝试读取 TZ 环境变量所指定的时区信息并输出对应时区的即时时间表示
1
2
3
4
# ./main
2024-09-25 17:31:13.358454615 +0800 CST m=+0.000037891
# TZ=America/New_York ./main
2024-09-25 05:31:16.984356093 -0400 EDT m=+0.000067383
  • 如果不想设置环境变量,还可以在代码中利用 time 包提供的 LoadLocation 函数显式加载时区信息,并将本地时间转换为特定时区的时间

由于 time.Time 类型除了包括挂钟时间,还包括单调时间和时区信息。因此不适合直接使用 == 或 != 运算符进行比较,否则会出现不同时区的同一时刻的两个 Time 实例不相等的情况。这也是为什么 time.Time 类型不应该用作 map 类型的 key 值。我们应该使用 time.Equal 函数比较 time.Time 的实例。Time 类型还提供了 BeforeAfter 函数用于判断两个时间的先后关系。

time 包还提供了 Sub 方法计算时间的差值,它返回的是 time.Duration 类型,它是一个纳秒值。

时间的格式化输出

在 C 语言中,我们通过 strftime 函数并使用类似于 %Y-%m-%d %H:%M:%S 的格式串来输出格式化后的时间。Go 语言采用更为直观的参考时间(reference time)的方式来构造时间的格式化输出。使用参考时间构造出来的时间格式串与最终的输出串是一致的,降低了编程人员的心智负担。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"fmt"
"time"
)

func main() {
fmt.Println(time.Now().Format("2006年01月02日 15时04分05秒"))
fmt.Println(time.Now().Format("2006-01-02 15:04:05 PM -07:00 Jan Mon MST"))
fmt.Println(time.Now().Format("2006-01-02 15:04:05"))
}
1
2
3
4
# go run main.go
2024年09月25日 18时09分18秒
2024-09-25 18:09:18 PM +08:00 Sep Wed CST
2024-09-25 18:09:18

Go 文档中给出的标准的参考时间是,这个时间本身没有什么意义,仅仅是出于好记。

1
2006-01-02 15:04:05 PM -07:00 Jan Mon MST

定时器的使用

time 包还提供了定时器的实现,包括两类定时器:

  • 一次性定时器 Timer
  • 重复定时器 Ticker

time 包提供了多种创建 Timer 定时器的方式,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
"fmt"
"time"
)

func create_timer_by_afterfunc() {
_ = time.AfterFunc(1*time.Second, func() {
fmt.Println("timer created by afterfunc fired!")
})
}

func create_timer_by_newtimer() {
timer := time.NewTimer(2 * time.Second)
select {
case <-timer.C:
fmt.Println("timer created by newtimer fired!")
}
}

func create_timer_by_after() {
select {
case <-time.After(2 * time.Second):
fmt.Println("timer created by after fired!")
}
}

func main() {
create_timer_by_afterfunc()
create_timer_by_newtimer()
create_timer_by_after()
}

无论采用哪种方式创建 Timer,本质上都是在用户层实例化一个 time.Timer 结构体。通过 AfterFunc 创建的定时器还会启动一个额外的 goroutine 来执行用户传入的函数:

1
2
3
4
type Timer struct {
C <-chan Time
r runtimeTimer
}

Go 中的定时器是在 Go 运行时层面实现的,并不会占用系统资源。虽然如此,即时调用定时器的 Stop 方法来将尚未触发的定时器从运行时中移除,可以减少 GO 运行时最小堆管理和 GC 的压力

另外,可以重用现有的 Timer 实例,此时要用到 Timer 的 Reset 方法。Go 官方建议只对如下两种定时器调用 Reset 方法:

  • 已经停止了的定时器(Stopped)
  • 已经触发过且 Timer.C 中的数据已经被读空

推荐的使用模式为:

1
2
3
4
if !t.Stop() {
<-t.C
}
t.Reset(d)

但是需要注意,对于已经 fired 且其对应 channel 已经被读空的 timer,如果我们按照上述模式来进行 Reset,此时 goroutine 会阻塞在 <-t.C 这一行。为了避免这个问题,我们可能需要使用带 default 分支的 select 来进行处理。但是需要注意,重用 Timer 仍然存在竞态风险,这主要是用户层对 t.C 的读空操作与运行时向 t.C 中写入通知数据是在两个 goroutine 中执行的,谁先谁后,完全依靠运行时调度

1
2
3
4
5
6
if !t.Stop() {
select {
<- t.C:
default:
}
}

不要忽略对系统信号的处理

由于 Go 多用于后端编程,而后端应用多以守护进程(daemon)的方式运行。守护程序对健壮性的要求很高,即便在退出时也要做好收尾和清理工作,即优雅退出。在 Go 中,通过系统信号是实现优雅退出的一种常见手段。

为什么不能忽略对系统信号的处理

系统信号(signal)是一种软件中断,它提供了一种异步的事件处理机制,用于操作系统内核或其他应用进程通知某一应用进程发生了某种事件。应用程序在收到系统信号后,一般有 3 种处理方式:

  • 执行系统默认处理动作:对于大多数系统信号,系统的默认处理动作就是终止该进程。此时进程没有时机执行清理和收尾工作
  • 忽略信号:如果应用选择忽略某些信号,那么应用进程在收到信号后,既不会执行默认处理动作,也不会执行自定义动作,信号直接被忽略
  • 捕捉信号并执行自定义处理动作:应用程序可以预先提供一个包含自定义处理动作的函数,并告诉系统在接收到某些信号时调用这个函数。但是有两个信号是不能被捕捉的:终止程序信号 SIGKILL 和挂起程序信号 SIGSTOP

对于运行在生产环境下的程序,我们不要忽略对系统信号的处理,而应该采用捕捉退出信号的方式执行自定义的收尾处理函数。

Go 对系统信号处理的支持

可以通过 kill -l 命令查看系统所支持的信号,每个信号都包含名称和对应的编号。通过 kill 命令也可以给指定的进程发送信号:

1
2
# kill -s signal_name pid
# kill -signal_number pid

Go 通过 os/signal 包为应用程序提供对系统信号的处理。其中最主要的是 Notify 函数,它用来设置捕捉应用程序所关心的系统信号,并在 Go 运行时层与 Go 用户层之间用一个 channel 相连。Go 运行时捕捉到应用所关注的信号后,会将信号写入 channel,这样用户层代码就可以收到该信号通知。

1
func Notify(c chan<- os.Signal, sig ...os.Signal)

Go 将信号分为两类:同步信号和异步信号

  • 同步信号是指那些由程序错误引发的信号,例如 SIGBUS、SIGSEVG 等,此时 Go 直接将信号转换成一个运行时 panic 并抛出。如果用户层没有专门的 panic 恢复代码,那么 Go 应用将默认异常退出
  • 同步信号之外的信号都被归类为异步信号,异步信号的默认处理行为因信号而异,对于通过 Notify 函数所捕获的信号,Go 运行时通过 channel 将信号发送给用户层

因此 Notify 无法捕捉 SIGKILL 和 SIGSTOP 信号(操作系统限制),也无法捕捉同步信号(Go 运行时决定),只有捕捉异步信号才有意义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package main

import (
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
)

func catchAsyncSignal(c chan os.Signal) {
for {
s := <-c
fmt.Println("async signal: ", s)
}
}


func triggerSyncSignal() {
time.Sleep(3 * time.Second)
defer func() {
if e := recover(); e != nil {
fmt.Println("recover from panic", e)
return
}
}()

var a, b = 1, 0
fmt.Println(a / b)
}

func main() {
var wg sync.WaitGroup
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGPIPE, syscall.SIGINT, syscall.SIGKILL)

wg.Add(2)
go func() {
catchAsyncSignal(c)
wg.Done()
}()

go func() {
triggerSyncSignal()
wg.Done()
}()

wg.Wait()

}
1
2
3
4
5
# ./main
recover from panic runtime error: integer divide by zero
^Casync signal: interrupt
^Casync signal: interrupt
^Casync signal: interrupt

如果多次调用 Notify 拦截某信号,但每次调用使用的 channel 不同,那么当应用进程收到异步信号时,Go 运行时会给每个 channel 发送一份异步信号副本。

使用 Notify 函数后,用户层与运行时层的唯一联系就是 channel。运行时收到异步信号后,会将信号写入channel,而且 channel 的缓冲区大小也可能会造成信号丢失,因此需要根据业务场景,选择适当的 channel 缓冲区大小。

使用系统信号实现程序的优雅退出

优雅退出指的是的程序在退出前有机会等待尚未完成的事务处理、清理资源、保存必要中间状态、持久化内存数据等。与之相对应的是强制退出(例如 kill -9),它强制杀死进程,不会给进程任何时间进行额外的处理,这就可能导致不一致的状态。

http 包的 Shutdown() 就可以实现 HTTP 服务内部的退出清理工作,因此在收到退出信号(例如使用 SIGHUP、SIGQUIT 等信号作为退出信号)就可以调用该函数进行清理。另外 http.Server 还提供了 RegisterOnShutdown 函数,用户可以注册 shutdown 时的回调函数。但是需要注意 Shutdown() 并不会等待所有清理工作结束(包括用户自定义的回调函数)才返回。

使用 crypto 下的密码学包构建安全应用

密码学(cryptography)是对信息及其传输的数学性研究,现代密码学是整个互联网安全的基石。Go 语言在标准库 crypto 下的相关包中为广大 gopher 提供了各种主流密码学算法的实现,对 Go 开发人员来说,了解密码学的基础知识并使用现成的密码学算法来构建安全应用即可满足日常大部分需求。

Go 密码学包概览与设计原则

Go 密码学包由两部分组成:标准库 crypto 下的相关包和 x/crypto 下的扩展包。根据密码学技术的类别,标准库下已经实现的密码学包大致可以分为如下几类:

  • 分组密码(block ciphers)
  • 公钥密码(或者称为非对称密码)与数字签名
  • 单向散列函数,也称为消息摘要或指纹
  • 消息认证码(Message Authentication Code,MAC)
  • 随机数生成

分组密码算法

密码算法可以分为分组密码和流密码两种。流密码是对数据流进行连续处理的一类算法,我们日常使用的 DES、AES 加密算法都归类于分组密码算法范畴。分组密码是一种一次仅能处理固定长度数据块的算法

分组密码算法每次仅加密一个明文分组。如果明文因总长度超过分组长度而存在多个分组,那么分组密码算法会被迭代调用以逐个处理明文分组。迭代的方法称为分组密码算法的模式,常见的模式包括:ECB、CBC、CFB、OFB、CTR 模式等。例如在 CBC 模式下,每个明文分组与前一个密文分组进行异或运算(XOR)后再进行加密,所有的密文分组组合成最终的密文。第一个明文分组由于不存在前一个密文分组,因此使用一个被称为 初始向量(Initialization Vector,IV)的随机数据。

对称密码是典型的分组密码,之所以称为对称,是因为加密和解密所采用的是同一把秘钥。Go 语言实现的 DES、3 重 DES(TDEA)、AES 算法都是对称密码算法,其中 AES 算法通常被当成对称密码算法的首选。AES 标准使用的分组长度为固定的 128 比特,即 16 字节,而根据选择的秘钥长度不同,可以选择不同的 AES 算法,例如 AES-128(16 字节秘钥)、AES-256(32 字节秘钥)等。

如下展示了一个使用 crypto/aes 包进行加解密的例子,这里使用 CBC 模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"crypto/aes"
"crypto/cipher"
"fmt"
)

func main() {
key := []byte("12345678123456781234567812345678")

aesCipher, err := aes.NewCipher(key)
if err != nil {
panic(err)
}

plainText := []byte("hello, world!!!!")
cipherText := make([]byte, aes.BlockSize+len(plainText))

// iv length must equal block size 16
iv := []byte("abcdefghijklmnop")
cbcModeEncrypter := cipher.NewCBCEncrypter(aesCipher, iv)

cbcModeEncrypter.CryptBlocks(cipherText[aes.BlockSize:], plainText)
copy(cipherText[:aes.BlockSize], iv)

fmt.Printf("%s\n", plainText)
fmt.Printf("%x\n", cipherText)
}
1
2
3
# ./main
hello, world!!!!
6162636465666768696a6b6c6d6e6f70cb34cae371ba92549f7b516e7d5d3a47

对应的解密代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"crypto/aes"
"crypto/cipher"
"encoding/hex"
"fmt"
)

func main() {
key := []byte("12345678123456781234567812345678")

cipherTextWithIV, err := hex.DecodeString("6162636465666768696a6b6c6d6e6f70cb34cae371ba92549f7b516e7d5d3a47")
if err != nil {
panic(err)
}

// iv length must equal block size 16
iv := cipherTextWithIV[:aes.BlockSize]
cipherText := cipherTextWithIV[aes.BlockSize:]

aesCipher, err := aes.NewCipher(key)
if err != nil {
panic(err)
}

plainText := make([]byte, len(cipherText))

cbcModeDecrypter := cipher.NewCBCDecrypter(aesCipher, iv)

cbcModeDecrypter.CryptBlocks(plainText, cipherText)
copy(cipherText[:aes.BlockSize], iv)

fmt.Printf("%x\n", cipherTextWithIV)
fmt.Printf("%s\n", plainText)
}
1
2
3
# ./main
6162636465666768696a6b6c6d6e6f706162636465666768696a6b6c6d6e6f70
hello, world!!!!

公钥密码

在对称密码系统中,加密与解密使用相同的秘钥,这就涉及到秘钥的配送问题,常见的秘钥配送方案有:

  • 事先共享密钥(事先以安全的方式将秘钥交给通信方)
  • 秘钥分配中心
  • Diffie-Hellman 密钥交换算法
  • 公钥密钥

这里重点介绍公钥秘钥。之前也介绍过,公钥秘钥也被称为非对称密钥。RSA 是使用最为广泛的公钥秘钥算法。Go 的 crypto/rsa 包实现了 RSA 算法。RSA 加解密使用 PKCS#1 v1.5 填充方案,但其存在一些安全风险,而 RSA-OAEP 则被认为是一种可信赖、满足强度要求的填充方案。

如下展示了 RSA 加解密的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"fmt"
)

func main() {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}

publicKey := privateKey.PublicKey

text := []byte("hello, world!!!")
cipherText, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &publicKey, text, nil)
if err != nil {
panic(err)
}

textDecrypted, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, cipherText, nil)
if err != nil {
panic(err)
}

fmt.Printf("%s", textDecrypted)
}

rsa.EncryptOAEP 和 rsa.DecryptOAEP 的第二个参数都是一个随机数生成器,这样可以保证每次生成的密文呈现不同的排列方式,而第一个参数则是 hash.Hash 接口实现的实例,其产生的散列值可作为随机数生成器的种子

单向散列函数

单向散列函数是一个接受不定长输入但产生定长输出的函数。这个定长输出被称为摘要(digest)或者指纹(fingerprint)。密码学级别的单向散列函数具有如下性质:

  • 强抗碰撞性:要找到散列值相同的两条不同消息是非常困难的
  • 单向性:无法通过散列值(摘要值)反向推算出消息原文

因此单向散列函数常被用于检测下载文件是否被篡改、基于口令的身份验证、数字签名、消息验证码以及随机数生成器等。

Go 标准库密码学包提供了多种单向散列函数的实现,包括 MD5、SHA-1、SHA-256、SHA-384 和 SHA-512 等。由于 MD5、SHA-1 的强抗碰撞性已经被攻破,因此推荐使用 SHA-256、SHA-384 和 SHA-512。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"crypto/sha256"
"fmt"
)

func sum256(data []byte) string {
sum := sha256.Sum256(data)
return fmt.Sprintf("%x", sum)
}

func main() {
s := []byte("hello, world!!!")
println(sum256(s))
}

消息验证码

参与通信的一方在发送的消息的同时,还发送消息的散列值,之后消息接收方就可以通过单向散列函数实现对消息的完整性检查。但是有时候光有完整性检查还是不够的。单向散列函数虽然能够辨别出数据是否被篡改,但是无法辨别出数据是不是伪装的(完整的恶意数据也可以正常携带其自己的散列值,该散列值也能通过接收方的完整性检查)。此时我们还需要对消息进行认证(Authentication),即校验消息的来源是不是我们所期望的。解决该问题的常见密码技术就是消息验证码(Message Authentication Code,MAC)。

消息认证码是一种不仅能确认数据完整性,还能保证消息来自期望来源的密码技术。消息认证码技术是以通信双方共享密钥为前提的。对于任意长度的消息,我们都可以计算出一个固定长度的消息认证码数据,这个数据被称为MAC值。参与通信的一方在发送消息的同时,还发送消息的 MAC 值,数据接收方可以通过这个 MAC 值校验出消息是否来自期望的发送方。

可以将消息认证码理解成一种与秘钥相关联的单向散列函数。消息散列码有多种实现,包括单向散列函数实现、使用分组密码实现、公钥密码实现等。crypto/hmac 就是一种基于单向散列函数的消息认证码实现,它实现了 HMAC 标准。该标准中所使用的单向散列函数有多种,任何高强度的单向散列函数都可以用于 HMAC。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"crypto/hmac"
"crypto/sha256"
"fmt"
)

func main() {
key := []byte("12345678123456781234567812345678")
message := []byte("hello, world!!!")

mac := hmac.New(sha256.New, key)
mac.Write(message)

m := mac.Sum(nil)
ms := fmt.Sprintf("%x", m)
println(ms)
}

在实际使用中,对数据进行对称加密且携带 MAC 值的方式被称为 认证加密。认证加密同时满足了:机密性(对称加密)、完整性(MAC 中的单向散列)以及认证(MAC)的特性,因此有广泛的应用。认证加密主要有 3 种:

  • Encrypt-then-MAC:先加密,后对密文计算 MAC 值
  • Encrypt-and-MAC:先加密,后对明文计算 MAC 值
  • MAC-then-Encrypt:先计算明文的 Mac 值,后将明文和 Mac 值一起用对称密码加密

分组密码中的 GCM 就是一种认证加密模式,它使用 CTR 分组模式和 128 bit 分组长度的 AES 加密算法进行加密,并使用 Carter-Wegman MAC 算法实现 MAC 值计算。

数字签名

在消息认证码中,生成 MAC 和验证 MAC 使用的是同一秘钥,这是无法防止否认问题的根源,即任何一方都没有办法防止对方否则该条消息是自己发送的(或者说通信双方无法向第三方证明这条消息就是对方发送的)。数字签名(Digital Signature)就是为了解决该问题而生的。

公钥密码系统就可以实现数字签名:

  • 使用私钥生成签名:即用私钥对消息原文或摘要进行加密
  • 使用公钥验证签名:用公钥对私钥加密的消息进行解密

在实际生产应用中,通常对消息的摘要进行签名。因为公钥密码加密算法本身很慢,如果对消息全文进行加密将会非常耗时。如果首先使用高性能的单向散列函数计算出消息的摘要,再用私钥加密摘要以获得数字签名,这样就可以大幅降低数字签名的耗时。而且,对摘要进行签名与对原文进行签名在最终消息内容的完整性和签名验证上的效果是等价的。

数字签名可以识别篡改和伪装,还可以防止否认。但数字签名正确应用的前提是:公钥属于真正的发送者。如果公钥是伪装的,那么在强大的数字签名算法也会完全失效。这就又涉及公钥分发的问题。数字证书本质上就是将公钥当做消息,由一个可信的第三方证书签发授权组织(CA)使用其私钥对其进行签名。公钥和签名共同构成数字证书

除了 RSA,Go 标准库还提供了 dsa、ecdsa、ed25519 等签名算法的实现。

随机数生成

Go 密码学包 crypto/rand 提供了密码学级别的随机数生成器实现 rand.Reader。crypto 相关密码包很多都依赖 rand.Reader 这个随机数生成器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"crypto/rand"
"fmt"
)

func main() {
buf := make([]byte, 32)

_, err := rand.Read(buf)
if err != nil {
panic(err)
}

fmt.Printf("%x\n", buf)
}

掌握 bytes 包和 strings 包的基本操作

我们知道,字节切片本质上是一个三元组(array、len 和 cap),而字符串则是一个二元组(str、len)。Go 字节切片为内存中的字节序列提供了抽象,而 Go 字符串则代表了采用 UTF-8 编码的 Unicode 字符的数组。标准库中的 bytes 包和 strings 包分别为字节切片和字符串这两种抽象类型提供了基本操作类 API。

查找和替换

bytes 包和 strings 提供了一组名字相同的定性查找 API,包括 Contains 系列、HasPrefix 和 HasSuffix。注意任意字符串都包含空串(””),任意字节切片也都包含空字节切片([]byte{})及 nil 切片。

  • Contains 函数返回的是第一个参数代表的字符串/字节切片中是否包含第二个参数代表的字符串/字节切片
  • ContainsAny 函数将其两个参数看成两个 Unicode 字符的集合,如果两个集合存在不为空的交集,则返回 true
  • ContainsRune 用于判断第一个参数代表的字符串或字节切片中是否包含第二个参数所代表的 Unicode 字符(以码点形式,即 rune 类型值传入)
  • HasPrefix、HasSuffix 这两个函数分别用于判断第二个参数代表的字符串/字节切片是不是第一个参数的前缀、后缀;注意空字符串是任何字符串(包括空字符串本身)的前缀和后缀,空字节切片([]byte{} 以及 nil 切片)也是任何字节切片(包括本身)的前缀的后缀

和定性查找不同,定位查找函数会给出第二个参数代表的字符串/字节切片在第一个参数中第一次出现的位置(下标)。定位查找包括正常查找 Index 系列、反向定位查找 LastIndex 系列。特别注意,反向查找空串或者 nil 切片时,返回的第一个参数的长度,但是作为下标位置使用时是越界访问。

Go 标准库在 strings 包中提供了两种进行字符串替换的方法:Replace 函数和 Replacer 类型。bytes 包只提供了 Replace 函数用于字节切片的替换:

1
func Replace(s, old, new string, n int) string
  • Replace 函数的最后一个参数用于控制替换的次数,如果是 -1,则是全部替换
  • 如果 old 传入空串或者 nil(对于字节切片的替换)时,Replace 会将 new 所表示的要替入的字符串/切片插入原字符串/字节切片的每两个字符(字节)间的空隙中(包括首尾也会被插入)
  • Replacer 类型实例化时可以传入多组 old 和 new 参数,从而一次实施多组不同的字符串替换

比较

  • 根据 Go 语言规范,切片类型的变量不能直接通过操作符进行等值比较,但是可以与 nil 做等值比较。
  • Go 语言原生支持通过操作符 == 或者 != 对 string 类型变量进行等值比较
  • bytes 包的 Equal 函数的实现就是基于原生字符串的等值比较的
1
2
3
func Equal(a, b []byte) bool {
return string(a) == string(b)
}
  • strings 包和 bytes 包还共同提供了 EqualFold 函数,用于进行不区分大小写的 Unicode 字符的等值比较。字节切片在比较时,字节序列将被解释为 UTF-8 编码表示后再进行比较
  • bytes 包和 strings 包提供了 Compare 方法来对两个字符串/字节切片做排序比较。对于字符串类型变量,Go 原生就支持通过 >, >=, <<= 操作符进行比较

分割

  • 空白分割的字符串是最简单常见的由特定分隔符分隔的数据,strings 包和 bytes 包中的 Fields 函数就可以直接用于处理这类数据的分割。Fields 函数采用 Unicode 空白字符的定义
  • Fields 函数会忽略数据前后及中间连续的空白字符。如果输入字符串只包含空白字符,那么该函数将返回一个空的 string 类型的切片
  • 另外 FieldsFunc 函数接受一个函数作为参数,该函数可以用于指示字符是否为 空白字符
  • Split 相关函数可以使用任意字符串/字节序列作为分隔符,对字符串/字符切片进行分割
  • 当传入空串时(或者 bytes.Split 被传入 nil 切片)作为分隔符,Split 函数会按照 UTF-8 字符编码边界对 Unicode 进行分割
  • SplitN 函数的最后一个参数表示对原字符串进行分割后产生的分段数量(如果最后一个参数为 -1,则不限制结果的分段数量)

拼接

strings 和 bytes 包分别提供了各自的 Join 函数,用于实现字符串或字节切片的拼接。strings 包还提供了 Builder 类型及相关方法用于高效地构建字符串,而 bytes 包与之对应的用于拼接切片的则是 Buffer 类型及相关方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
"bytes"
"fmt"
"strings"
)

func main() {
ss := []string{"hello", "world"}

var sb strings.Builder
for i, s := range ss {
sb.WriteString(s)
if i != len(s)-1 {
sb.WriteString(" ")
}
}

fmt.Printf("%s\n", sb.String())

bb := [][]byte{[]byte("hello"), []byte("bytes")}
var buf bytes.Buffer
for i, b := range bb {
buf.Write(b)
if i != len(bb)-1 {
buf.WriteString(" ")
}
}

fmt.Printf("%s\n", buf.String())
}

修建和变换

bytes 包和 strings 包提供了一系列 Trim API 可以完成数据的修建:

  • TrimSpace:去除输入字符串/字节切片首部和尾部的空白字符
  • Trim 函数可以允许自定义要修剪掉的字符集合。Trim 函数可以同时处理两端的修剪,而 TrimLeft 仅处理输入左端的修剪,TrimRight 仅处理输入右端的修剪
  • TrimPrefix 和 TrimSuffix 函数分别用于修剪输入数据中的的前缀和后缀字符串(将其当成一个整体)

如下 API 可以完成输入数据的变换处理:

  • ToLower 和 ToUpper 函数分别用于对输入数据进行小写转换、大写转换
  • Map 函数用于将原始字符串/字节切片中的部分数据按照传入的映射规则变换为新数据

快速对接 I/O 模型

Go 语言的整个 I/O 模型都建立在 io.Writer 及 io.Reader 这两个神奇的接口类型之上,标准库中绝大多数进行 I/O 操作的函数或方法均将它们作为参与 I/O 操作的参数的类型。例如:

1
func Copy(dst io.Writer, src io.Reader) (written int64, err error)

字符串类型与字节切片常被作为数据源传递给 I/O 操作的函数或者方法,但是我们不能直接将字符串类型/字节切片类型变量传递给 io.Reader 类型的参数。利用这两个包的 NewReader 函数并传入我们的数据即可创建一个满足 io.Reader 接口的实例。通过创建的 strings.Reader 或 bytes.Reader 新实例,我们就可以读取作为数据源的字符串或字节切片中的数据。

理解标准库的读写模型

Go 基于 io.Readerio.Writer 这两个简单的接口类型构建了 Go 标准库读写模型。

1
2
3
type Reader interface {
Read(p []byte) (n int, err error)
}
1
2
3
type Writer interface {
Write(p []byte) (n int, err error)
}
  • 模型支持通过 io.Writer 的 Write 方法将 []byte 类型的字节序列写入存储数据或传输数据的实体抽象
  • 模型支持通过 io.Reader 的 Read 方法将数据从这些抽象实体中读取并填充到 []byte 类型的变量实例中
  • 模型也支持通过 io.Writer 将抽象数据类型(如原生整型、自定义结构体类型)直接写入存储数据或传输数据的实体抽象,也支持通过 io.Reader 从这些实体抽象中读取数据并填充到抽象数据类型中。本质上模型上所支持的这些直接写入抽象数据类型实例也先进行了编码转换
  • 标准库的 os.File、net.Conn 等类型实现了 io.Writer 和 io.Reader 接口,可以作为存储数据或传输数据的实体抽象。另外通过接口的包裹模式,可以简单地在这些实体抽象的基础上实现有缓冲的读写、数据变换等特性

这里重点解释一下直接读写抽象数据类型的实例,借助标准库的包就可以直接将抽象数据类型实例写入文件或者从文件中读取数据并填充到抽象数据类型实例中。实际上这个过程中,标准库的 API 隐式地帮我们在抽象数据类型实例和 []byte 类型的字节序列之间进行编码转换

  • fmt.Fprint 系列函数可以按照 format 参数的格式将任意抽象数据类型实例写入某个 io.Writer 实例中,之后通过 fmt.Fscan 系列函数可以从某个 io.Reader 实例中读取数据并填充到抽象数据类型实例中
  • fmt.Fscanf 系列函数的运作本质是扫描和解析读出的文本字符串,这导致其数据还原能力有限:无法将从文件中读取的数据直接整体填充到抽象数据类型实例中,只能逐字段填充
  • 在数据还原方面,二进制编码有着先天的优势。可以借助 binary 包实现抽象数据的二进制读写。但是它有一些限制,结构体每个字段的类型需要是定长类型
  • gob 包也支持对任意抽象数据类型的实例进行直接读写,唯一的约束就是自定义结构体类型中的字段至少有一个是导出的。gob 包也是 Go 标准库提供的一个序列化/反序列化方案

而且,Go 标准库的读写模型广泛应用了包裹函数模式,并且基于这种模式实现了带缓冲的 I/O,数据格式变换等。例如 bufio 包就实现了带缓冲的 io:

1
2
func NewReader(rd io.Reader) *Reader
func NewWriter(w io.Writer) *Writer

而 Go 标准库中的 compress/zip 包则提供了实现数据变换(这里是压缩)的包裹函数和包裹类型:

1
2
func NewReader(r io.Reader) (*Reader, error)
func NewWriter(w io.Writer) *Writer

掌握 unsafe 包的安全使用模式

C 语言是一门静态类型语言,但它却不是类型安全语言。在 Go 语言中,我们无法通过常规语法手段穿透 Go 在类型系统层面对内存数据的保护。Go 在常规操作下是类型安全的。所谓类型安全是指一块内存数据一旦被特定的类型所解释(该内存数据与该类型变量建立关联,也就是变量定义)​,它就不能再被解释为其他类型,不能再与其他类型变量建立关联

Go 的类型安全是建立在 Go 编译器的静态检查以及 Go 运行时利用类型信息进行的运行时检查。在语法层面为了类型安全,Go 有如下限制:

  • 不支持隐式类型转换,所有类型转换必须显式进行
  • 只有底层类型相同的两个类型的指针之间才能进行类型转换
  • 不支持指针运算

但是 Go 的定位也是一门系统语言,在考虑类型安全的同时,语言设计者还要兼顾性能以及如何实现与操作系统、C 代码交互等低级代码问题。因此 Go 在类型系统上开了一个后门,即在标准库中内置了一个特殊的 Go 包:unsafe 包。

unsafe 包允许我们实现性能更高、与底层系统交互更容易的低级代码,但是也有了绕过 Go 类型安全屏障的方法。因此需要明确 unsafe 包的安全使用模式。

简洁的 unsafe 包

unsafe 包主要提供以下函数:

1
2
3
func Sizeof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
  • Sizeof 用于获取一个表达式值的大小
  • Alignof 用于获取一个表达式的内存地址对齐系数。在不同计算机体系结构下,处理器对变量的地址都有着对齐要求,即变量的地址必须可被该对齐系统整除
  • Offsetof 可用于获取结构体中某字段的地址偏移量(相对于结构体变量的地址)

unsafe 包之所以命名为 unsafe 包,主要是因为该包中定义了 unsafe.Pointer 类型,该类型可用于表示任意类型的指针,并且具备了以下其他指针类型所不具备的性质:

  • 任意类型的指针值都可以被转换为 unsafe.Pointer
  • unsafe.Pointer 可以被转换为任意类型的指针值
  • uintptr 类型值可以被转换为一个 unsafe.Pointer
  • unsafe.Pointer 也可以转换为一个 uintptr 类型值
1
2
var i uintptr = 0x80010203
p := unsafe.Pointer(i)
1
2
p := unsafe.Pointer(&a)
var i = uintptr(p)

通过上述性质,可以很容易穿透 Go 的类型安全保护:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"unsafe"
)

func main() {
var a uint32 = 0x12345678
fmt.Printf("0x%x\n", a)

p := (unsafe.Pointer)(&a)
b := (*[4]byte)(p)

b[0] = 0x23
b[1] = 0x45
b[2] = 0x67
b[3] = 0x8a

fmt.Printf("0x%x\n", a)
}
1
2
3
# go run main.go
0x12345678
0x8a674523

unsafe 包的典型应用

Go 语言需要这样一种机制来实现一些低级代码,以满足运行时或性能敏感系统对性能的需求。标准库的 reflect、sync、syscall 和 runtime 包都是 unsafe 包的重度用户。

其中,unsafe.Pointer 则是 unsafe 包中被使用最多的特性。因为使用该特性,Gopher 可以绕过 Go 的类型系统的安全检查,因此可以通过 unsafe 包实现性能更好的类型转换。

正确理解 unsafe.Pointer 与 uintptr

unsafe.Pointer 和其他常规类型指针一样,可以作为对象引用。如果一个对象仍然被一个 unsafe.Pointer 变量引用着,那么该对象就不会被垃圾回收。但是 uintptr 并不是指针,它仅仅是一个整型值,即使它存储的是某个对象的内存地址,它不会被算作对该对象的引用

使用 uintptr 类型的变量保存栈上变量的地址通常是有风险的,因为 Go 使用的是连续栈的栈管理方案。当 goroutine 上栈空间不足时,Go 运行时就会新分配一块更大的内存空间作为该 goroutine 的新栈空间,并将原有栈空间整体复制过来,这就导致原来栈上分配的变量地址发生变化。而如果使用 unsafe.Pointer 类型的变量来保存地址则不会有问题,因为 Go 运行时会对 unsafe.Pointer 变量所指向的地址进行同步变更。

unsafe.Pointer 的安全使用模式

为了想要 unsafe.Pointer 被安全使用,需要遵循一些安全使用模式:

  • *T1 -> unsafe.Pointer -> *T2:实现内存块的重新解释,将原本解释为 T1 类型的内存重新解释为 T2 类型。此时我们要注意内存对齐问题,对未对齐内存地址进行指针解引用可能会出现总线错误等无法恢复的异常情况(ARM、SPARC 等),因此转换后类型 T2 的对齐系数不能比转换前类型 T1 的对齐系数更严格
  • unsafe.Pointer -> uintptr
  • 模拟指针运算:即在一个表达式中,将 unsafe.Pointer 转换为 uintptr 类型,使用 uintptr 类型的值进行算术运算后,再转换回 unsafe.Pointer。该模式常用于访问结构体内字段或数组中的元素。尤其注意,这个连续转换要在一个表达式中,防止 GC 回收内存
1
p = unsafe.Pointer(uintptr(unsafe.Pointer(&a)) + 10*unsafe.Sizeof(a[0]))
  • 调用 syscall.Syscall 系列函数时指针类型到 uintptr 类型参数的转换。而且这种转换操作一定要放入 Syscall 的参数表达式中,Go 编译器能够识别出这种特殊的使用模式,保证转换过程中原内存对象的有效性
1
2
var p *T // 待传给Syscall系列函数的指针变量
syscall.Syscall(SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(p)), uintptr(n))
  • reflect.Value.Pointerreflect.Value.UnsafeAddr 转换为指针。这两个方法都是返回 uintptr 类型值。同样该转换也需要在一个表达式中完成
  • reflect.SliceHeaderreflect.StringHeader 必须通过 *T1 -> unsafe.Pointer -> *T2 的方式构建。这样可以保证返回的对象不被垃圾回收掉

通过 go vet 可以检查 unsafe.Pointeruintptr 之间的转换是否符合上述安全模式。Go 1.14 编译器在 -race-msan 命令行选型开启的情况下,会执行 -d=checkptr 检查,即对 unsafe.Pointer 也执行更严格的合规性检查(对齐系数是否满足要求、指针运算后没有出现越界)。

谨慎使用 reflect 包提供的反射能力

Go 标准库提供的 reflect 包让 Go 程序具备运行时反射的能力(reflection)。反射是程序在运行时访问、检测和修改它本身状态或行为的一种能力。Go 语言的 interface{} 类型变量具有析出任意类型变量的类型信息(type)和值信息(value)的能力,Go 反射的本质就是利用 interface{} 的这种能力在运行时对任意变量的类型和值信息进行检视甚至是对值进行修改的机制

Go 反射的三大法则

反射让静态类型语言 Go 在运行时具备了某种基于类型信息的动态特性。如下代码利用了 Go 反射机制实现 ORM(Object Relational Mapping):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package main

import (
"bytes"
"errors"
"fmt"
"reflect"
)

func ConstructQuery(obj interface{}) (stmt string, err error) {
typ := reflect.TypeOf(obj)
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}

if typ.Kind() != reflect.Struct {
err = errors.New("only struct is supported")
return
}

buffer := bytes.NewBufferString("")
buffer.WriteString("SELECT ")

if typ.NumField() == 0 {
err = fmt.Errorf("the type[%s] has no fields", typ.Name())
return
}

for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)

if i != 0 {
buffer.WriteString(", ")
}

column := field.Name
if tag := field.Tag.Get("orm"); tag != "" {
column = tag
}

buffer.WriteString(column)
}

stmt = fmt.Sprintf("%s FROM %s", buffer.String(), typ.Name())
return

}

type Product struct {
ID uint32
Name string
Batch string `orm:"batch_number"`
}

type Person struct {
ID uint32
Name string
Addr string `orm:"address"`
}

func main() {
s, err := ConstructQuery(&Product{})
if err != nil {
return
}
fmt.Println(s)

s, err = ConstructQuery(&Person{})
if err != nil {
return
}
fmt.Println(s)
}

Go 反射十分适合处理这类问题,它们的典型特点包括:

  • 输入参数的类型无法提前确定
  • 函数或方法的处理结果因传入参数(的类型和值信息)的不同而异

但是反射并不是 Go 推荐的惯用法,建议大家谨慎使用。如果必须使用反射才能实现你要的功能特性,那么使用反射时需要牢记这 3 条法则:

  • 反射世界的入口:经由接口(interface{})类型变量进入反射的世界并获得对应的反射对象(reflect.Value 或 reflect.Type)
  • 反射世界的出口:反射对象(reflect.Value)通过化身为一个接口(interface{})类型变量值的形式走出反射世界
  • 修改反射对象的前提:反射对象对应的 reflect.Value 必须是可设置的

反射世界的入口

reflect.TypeOf()reflect.ValueOf() 是进入反射世界仅有的两扇大门

  • reflect.TypeOf() 获得一个 reflect.Type 对象,它包含了被反射 Go 变量实例的所有类型信息
  • reflect.ValueOf() 获得一个 reflect.Value 对象,它包含了被反射 Go 变量实例的所有值信息
  • 通过 reflect.Value 对象的 Type() 方法(即 reflect.Value.Type()),又可以得到 Go 实例变量的类型信息,即 reflect.Type 对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"reflect"
)

func main() {
var i int = 5

val := reflect.ValueOf(i)
typ := reflect.TypeOf(i)

fmt.Println(reflect.DeepEqual(typ, val.Type()))
}

在进入反射世界之后,可以通过 reflect.Type 实例和 reflect.Value 实例进行类型信息和值信息的检视。

  • reflect.Type 是一个接口类型,它包含了很多检视类型信息的方法。例如通过 Name() 得到类型名称、通过 Kind() 得到类型类别。
  • reflect.Value 类型拥有很多方便我们进行值检视的方法,比如 Bool()Int()String() 等,这些方法只对对应的变量类型适用。比如:Bool() 方法仅适用于对 布尔型变量进行反射后得到的 Value 对象
    • 通过 reflect.Value 提供的 Index() 方法可以获取到切片及数组元素对应的 Value 对象
    • 通过 reflect.Value 提供的 MapRange()MapIndex() 等方法,可以获取到 map 中的 key 和 value 对象所对应的 Value 对象
    • 通过 reflect.Value 提供的 Field() 系列方法获取结构体字段所对应的 Value 对象
    • 对于函数类型变量或包含有方法的类型实例反射出的 Value 对象,可以通过其 Call 方法调用该函数或类型的方法。函数或方法的参数以 reflect.Value 的切片形式提供(要保证 Value 参数的类型信息与原始函数/方法的类型相匹配),返回值也是以 reflect.Value 的切片形式返回

反射世界的出口

reflect.Value.Interface()reflect.ValueOf() 的逆过程。通过 Interface() 方法可以将 reflect.Value 对象重新恢复为一个 interface{} 类型的变量值。这个离开反射世界的过程实质是将 reflect.Value 中的类型信息和值信息重新打包成一个 interface{} 的内部表示,之后就可以通过类型断言得到一个反射前的类型的变量值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"reflect"
)

func main() {
var i = 5
val := reflect.ValueOf(i)
r := val.Interface().(int)
println(r)
r = 6
println(i, r)

val = reflect.ValueOf(&i)
p := val.Interface().(*int)
println(*p)
*p = 7
println(i, *p)
}
1
2
3
4
5
# go run main.go
5
5 6
5
7 7

通过 reflect.Value.Interface() 函数重建后得到的新变量与原变量是两个不同的变量,它们唯一的联系就是值相同

输出参数、interface{} 类型变量及反射对象的可设置性

Go 函数参数的传递都是传值,即值复制。对于以 interface{} 类型变量(假设为 i)作为形式参数的 reflect.ValueOfreflect.TypeOf 函数来说,i 自身是被反射对象的 复制品。而新创建的反射对象又复制了 i 中所包含的所有值信息。因此当被反射对象以值类型(T)传递给 reflect.ValueOf 函数时,在反射世界中对反射对象值信息的修改不会对被反射对象产生影响。Go 认为这种修改无意义,因此禁止了该行为。当发生该行为时,会导致运行时 panic。

1
2
3
4
5
6
7
8
9
10
11
package main

import (
"reflect"
)

func main() {
var i = 17
val := reflect.ValueOf(i)
val.SetInt(8)
}
1
2
# go run main.go
panic: reflect: reflect.Value.SetInt using unaddressable value

reflect.Value 提供了 CanSet、CanAddr 及 CanInterface 方法来帮助我们判断反射对象是否可设置、可寻址、可恢复为 interface{} 类型变量。

  • 当被反射对象以值类型(T)传递给 reflect.ValueOf 时,所得到的反射对象(Value)是不可设置和不可寻址的
  • 当被反射对象以指针类型(*T)传递给 reflect.ValueOf 时,通过 reflect.ValueElem 方法可以得到代表该指针所指内存对象的 Value 反射对象。而这个反射对象是可设置和可寻址的,对其进行修改(比如利用 Value 的 SetInt 方法)将会像函数的输出参数那样直接修改被反射对象所指向的内存空间的值
  • 当传入结构体或数组指针时,通过 FieldIndex 方法得到的代表结构体字段或数组元素的 Value 反射对象也是可设置和可寻址的。如果结构体中某个字段是非导出字段,则该字段是可寻址但不可设置的
  • 当被反射对象的静态类型是接口类型时​,该被反射对象的动态类型决定了其进入反射世界后的可设置性。如果动态类型为 *T& T时,那么通过 Elem 方法获得的反射对象就是可设置和可寻址的
  • map 类型被反射对象比较特殊,它的 key 和 value 都是不可寻址和不可设置的。但我们可以通过 Value 提供的 SetMapIndex 方法对 map 反射对象进行修改,这种修改会同步到被反射的map变量中。
1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"reflect"
)

func main() {
var i int = 17

val := reflect.ValueOf(&i).Elem()
val.SetInt(8)
println(i)
}
1
2
# go run main.go
8

了解 cgo 的原理和使用开销

在如下一些场景中,我们可能需要使用 cgo 来实现 Go 与 C 的互操作性。

  • 为了提升局部代码性能,用 C 代码替换一些 Go 代码
  • 对 Go 内存 GC 的延迟敏感,需要自己手动进行内存管理
  • 为一些 C 语言专有而没有 Go 替代品的库制作 Go 绑定(binding)或包装
  • 与遗留的且很难重写或替换的 C 代码进行交互

使用 cgo 需要付出一些成本,且其复杂性高,这里介绍以下 cgo 的原理和使用方法。

Go 调用 C 代码的原理

如下是一个使用 cgo 的 Go 代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

// #include <stdio.h>
// #include <stdlib.h>
//
// void print(char *str) {
// printf("%s\n", str);
// }
import "C"

import "unsafe"

func main() {
s := "Hello, cgo"
cs := C.CString(s)
defer C.free(unsafe.Pointer(cs))
C.print(cs)
}

这个代码有如下特殊之处:

  • C 代码直接出现在 Go 源文件中,只是以注释的形式存在
  • 紧邻注释的 C 代码块之后(中间没有空行),导入了一个名为 C 的包。这里的 C 可以理解为 伪包名,它是一种类似名字空间的概念。C 语言的所有语法元素均在该伪包下面
  • 在 main 函数中通过 C 这个包调用 C 代码中定义 print 函数

还是通过 go build 或者 go run 来编译执行该代码:

1
2
# go run main.go
Hello, cgo

在实际编译过程中,go build 会调用 cgo 工具,其会识别和读取 Go 源文件中的 C 代码,并将其提取后交给外部的 C 编译器(clang 或者 gcc)进行编译,最后与 Go 源代码编译后的目标文件链接成一个可执行程序。

在 Go 中使用 C 语言的类型

  • 在 Go 中可以用如下方式访问 C 的原生的数值类型。由于 Go 的数值类型与 C 的数值类型不是一一对应的,因此在使用对方类型变量时需要显式类型转换。
1
2
3
4
5
6
C.char, C.schar, C.uchar
C.short, C.ushort
C.int, C.uint
C.long, C.ulong
C.longlong, C.ulonglong
C.float, C.double
  • 为了表示指针类型,可以直接按照 Go 语法在类型前面加上星号(*),例如 var p *C.int。但是 void* 比较特殊,在 Go 中使用 unsafe.Pointer 表示它。
  • 通过 C.CString 函数可以将 Go 的 string 类型转换为 C 的字符串(以 \0 结尾的字符数组)。该转型会在堆上重新分配一块内存空间,且这块内存空间属于 C 的世界,不能由 Go 的 GC 管理。因此使用完成后需要手动释放所占用的内存
  • 通过 C.GoString 函数可以将 C 的字符串转换为 Go 的 string 类型。得到的 Go 字符串和其他 Go 对象一样接受 GC 的管理
  • C 和 Go 数组差异较大,后者是原生的值类型。Go 仅提供了 C.GoBytes 来将 C 中的 char 类型数组转换为 Go 中的 []byte 切片类型。而对于其他类型的 C 数组,可以通过特定转换函数来将 C 的特定类型数组转换为 Go 的切片类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

// int cArray[] = {1, 2, 3, 4, 5, 6, 7};
import "C"

import (
"fmt"
"unsafe"
)

func CArrayToGoArray(cArray unsafe.Pointer, elemSize uintptr, len int) (goArray []int32) {
for i := 0; i < len; i++ {
j := *(*int32)((unsafe.Pointer)(uintptr(cArray) + uintptr(i)*elemSize))
goArray = append(goArray, j)
}

return
}

func main() {
goArray := CArrayToGoArray(unsafe.Pointer(&C.cArray[0]), unsafe.Sizeof(C.cArray[0]), 7)
fmt.Println(goArray)
}
  • 对于 C 中定义的具名枚举类型 xx,可以通过 C.enum_xx 来访问该类型。如果是匿名类型,只能访问其字段
  • 类似地,可以通过 C.struct_xx 来访问 C 中定义的结构体类型 xx
  • 可以通过 C.union_xx 来访问 C 中定义的 union 类型 xx。但是 Go 将 union 类型看成 [N]byte,其中 N 为 union 类型中最长字段的大小(圆整后)
  • 对于 C 中定义的别名类型,其访问方式与原类型的访问方式相同。对于原生类型别名,直接访问这个新类型名即可。对于复合类型别名,需要根据原复合类型的访问方式对新别名进行访问
1
2
// typedef struct employee myemployee;
// var m C.struct_myemployee
  • 为了方便获得 C 世界中的类型的大小,Go 提供了 C.sizeof_T 来获取 C.T 类型的大小。如果是结构体、枚举及联合体类型,我们需要在 T 前面分别加上 struct_enum_union_ 的前缀

在 Go 中链接外部 C 库

在 Go 源文件中大量编写 C 代码并不是 Go 推荐的惯用法,可以将 C 的代码以共享库的形式提供给 Go 源码。Go 提供了 #cgo 指示符,可以用它指定 Go 源码在编译后与哪些共享库进行链接。

1
2
3
// foo.h
extern int count;
void foo();
1
2
3
4
5
6
7
8
9
10
// foo.c
#include "stdio.h"
#include "foo.h"

int count = 6;


void foo() {
printf("I'm foo!\n");
}
1
2
3
4
# gcc -c foo.c
# ar rv libfoo.a foo.o
ar: creating libfoo.a
a - foo.o
1
2
3
4
5
6
7
8
9
10
11
12
13
package main

// #cgo CFLAGS: -I${SRCDIR}
// #cgo LDFLAGS: -L${SRCDIR} -lfoo
// #include "foo.h"
import "C"

import "fmt"

func main() {
fmt.Println(C.count)
C.foo()
}
1
2
3
4
# go build -o main main.go
# ./main
6
I'm foo!

虽然这里使用的是静态库,但是 Go 同样支持动态库。

另外需要注意的是,Go 支持多返回值,而 C 不支持,因此当 C 函数用在多返回值的 Go 调用中时,C 的 errno 将作为函数返回值列表中最后那个 error 返回值返回。

在 C 中使用 Go 函数

在 C 中使用 Go 函数的场合极少。在 Go 中可以使用 export + 函数名 来导出 Go 函数为 C 所用。但是 Go 中类似于垃圾回收这样的高级功能让导出 Go 函数这一特性难于完美实现,导出的函数依旧无法完全脱离 Go 的环境,因此实用性不高。

使用 cgo 的开销

  • 在 go 中调用 C 函数的开销比调用 Go 函数要多出一个或甚至多个数量级。因此一定要使用 cgo,一个不错的方案是将代码尽量下推到 C 中以减少语言间相互调用的次数,以减少平均调用开销
  • 另外由于 Go 调度器无法掌握 C 的世界,由于我们很容易在 C 空间中写出导致线程阻塞的 C 代码,这会使得 Go 应用进程内现成数量暴涨的可能性大增,这与 Go 承诺的轻量级并发有背离
  • Go 跨平台交叉编译是极其简单的,仅需要指定目标平台的操作系统类型(GOOS)和处理器类型(GOARCH)即可。但这种跨平台交叉编译能力仅限于纯 Go 代码。如果 Go 源文件中使用了 cgo 技术,则失去跨平台交叉构建能力
  • 另外,像内存管理机制、Go 工具链的使用、代码调试等方面,cgo 机制都带来了额外的复杂性

使用 cgo 代码的静态构建

所谓静态构建,就是指构建后的应用程序所需的所有符号、指令和数据都包含在自身的二进制文件中,没有任何对外部动态库的依赖。但是静态构建的应用程序通常比非静态构建的应用更大。默认情况下,Go 采用静态构建。

但是查看如下 go 代码,可以看到其编译出的可执行文件仍然有对共享库依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# cat main.go
package main

import (
"net/http"
"os"
)

func main() {
cwd, err := os.Getwd()
if err != nil {
panic(err)
}

srv := &http.Server{
Addr: ":8000",
Handler: http.FileServer(http.Dir(cwd)),
}

srv.ListenAndServe()
}
1
2
3
4
# ldd main
linux-vdso.so.1 (0x00007ffcf83cb000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f483c84b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f483ca82000)

这是因为标准库中很多包的实现都有 cgo 版本(例如 net 包),这些版本存在外部依赖。这些 cgo 版本都有对应的 go 版本,ke可以通过设置 CGO_ENABLED=0 来关闭 cgo 机制,得到一个静态编译的程序。

CGO_ENABLED=1 情况下(默认),也可以实现纯静态链接。其原理是告诉链接器在最后链接时采用静态链接方式,哪怕依赖的 Go 标准库中某些包使用的是 C 版本实现。如果此时在 go build 的命令行参数中传入 -ldflags 'extldflags "-static"',那么 gcc/clang 将会做静态链接,将 .o 中未定义(undefined)的符号都替换为真正的代码指令。同时需要通过 -linkmode=external 来强制 Go 链接器采用外部链接。