Filebeat 是一款功能强大的日志传送器,旨在简化从不同来源收集、处理和转发日志到不同目的地的过程。Filebeat 在开发时充分考虑了效率,可确保日志管理无缝且可靠。它的轻量级特性和处理大量数据的能力使其成为开发人员和系统管理员的首选。

在本综合指南中,您将深入探索 Filebeat 的功能。从基础知识开始,您将设置 Filebeat 以从各种来源收集日志。然后,您将深入研究高效处理这些日志的复杂性,从 Docker 容器收集日志并将其转发到不同的目标进行分析和监视。

前言

在上一篇文章《Filebeat vs Logstash:日志采集工具对比》中,我们对比了 Filebeat 和 Logstash 的一些优缺点,下面是一份简介版的总结:

我们可以看到,我们选择 Filebeat 一方面是因为它占用资源少,另外一方面是我们不需要对日志做复杂的处理,同时也不需要将日志发送到多个目的地。

环境准备

创建用以测试的目录:

1
2
mkdir log-processing-stack
cd log-processing-stack

为演示应用程序创建一个子目录并移动到该目录中:

1
mkdir logify && cd logify

完成这些步骤后,您可以在下一节中创建演示日志记录应用程序。

开发演示日志记录应用程序

在本节中,你将使用 Bash 脚本语言构建一个基本的日志记录应用程序。应用程序将定期生成日志,模拟应用程序生成日志数据的真实场景。

logify 目录中,创建一个名为 logify.sh 的文件,并将以下内容添加到文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
filepath="/var/log/logify/app.log"

create_log_entry() {
local info_messages=("Connected to database" "Task completed successfully" "Operation finished" "Initialized application")
local random_message=${info_messages[$RANDOM % ${#info_messages[@]}]}
local http_status_code=200
local ip_address="127.0.0.1"
local emailAddress="user@mail.com"
local level=30
local pid=$$
local time=$(date +%s)
local log='{"status": '$http_status_code', "ip": "'$ip_address'", "level": '$level', "emailAddress": "'$emailAddress'", "msg": "'$random_message'", "pid": '$pid', "timestamp": '$time'}'
echo "$log"
}

while true; do
log_record=$(create_log_entry)
echo "${log_record}" >> "${filepath}"
sleep 3
done

create_log_entry() 函数以 JSON 格式生成日志记录,包含严重性级别、消息、HTTP 状态代码和其他关键字段等基本详细信息。此外,它还包括敏感字段,例如电子邮件地址、和 IP 地址,这些字段是特意包含的,以展示 Filebeat 屏蔽字段中敏感数据的能力。

接下来,程序进入无限循环,重复调用 create_log_entry() 函数并将日志写入 /var/log/logify 目录中的指定文件。

添加完代码后,保存更改并使脚本可执行:

1
chmod +x logify.sh

然后,创建 /var/log/logify 用于存储应用程序日志的目录:

1
sudo mkdir /var/log/logify

接下来,使用 $USER 环境变量将 /var/log/logify 目录的所有权分配给当前登录的用户:

1
sudo chown -R $USER:$USER /var/log/logify/

在后台运行 logify.sh 脚本:

1
./logify.sh &

命令末尾的 & 符号指示脚本在后台运行,允许您在日志记录应用程序独立运行时继续使用终端执行其他任务。

当程序启动时,它将显示如下所示的输出:

1
[1] 91773

此处表示 91773 进程 ID,如果需要,该 ID 可用于稍后终止脚本。

若要查看 app.log 文件的内容,可以使用以下 tail 命令:

1
tail -n 4 /var/log/logify/app.log

此命令以 JSON 格式显示 app.log 文件中的最后 4 个日志条目:

1
2
3
4
{"status": 200, "ip": "127.0.0.1", "level": 30, "emailAddress": "user@mail.com", "msg": "Connected to database", "pid": 6512, "timestamp": 1709286422}
{"status": 200, "ip": "127.0.0.1", "level": 30, "emailAddress": "user@mail.com", "msg": "Initialized application", "pid": 6512, "timestamp": 1709286425}
{"status": 200, "ip": "127.0.0.1", "level": 30, "emailAddress": "user@mail.com", "msg": "Initialized application", "pid": 6512, "timestamp": 1709286428}
{"status": 200, "ip": "127.0.0.1", "level": 30, "emailAddress": "user@mail.com", "msg": "Operation finished", "pid": 6512, "timestamp": 1709286431}

现在,您已成功创建用于生成示例日志条目的日志记录应用程序。

安装 Filebeat

我的系统是 MacOS,所以执行下面的命令即可:

1
2
curl -L -O https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-8.12.2-darwin-x86_64.tar.gz
tar xzvf filebeat-8.12.2-darwin-x86_64.tar.gz

最后,进入 filebeat 目录下去执行 filebeat 命令:

1
2
cd filebeat-8.12.2-darwin-x86_64
./filebeat version

输出:

1
filebeat version 8.12.2 (amd64), libbeat 8.12.2 [0b71acf2d6b4cb6617bff980ed6caf0477905efa built 2024-02-15 13:39:16 +0000 UTC]

Filebeat 的工作原理

在开始使用 Filebeat 之前,了解其工作原理至关重要。在本节中,我们将探讨其基本组件和流程,确保您在深入研究实际使用之前有一个坚实的基础:

要了解 Filebeat 的工作原理,主要需要熟悉以下组件:

  • 收割机(Harvesters),收割机负责逐行读取文件的内容。当 Filebeat 配置为监控特定日志文件时,会为每个文件启动一个收集器。这些收割机不仅可以读取日志数据,还可以管理打开和关闭文件。通过逐行增量读取文件,收集器可确保有效地收集新附加的日志数据并转发以进行处理。
  • 输入(Inputs):输入充当收割机和数据源之间的桥梁。他们负责管理收割机并找到 Filebeat 需要从中读取日志数据的所有来源。可以为各种源(例如日志文件、容器或系统日志)配置输入。用户可以通过定义输入来指定 Filebeat 应监控的文件或位置。

Filebeat 读取日志数据后,日志事件将进行转换或使用数据进行扩充。最后发送到指定的目的地。

可以在 filebeat.yml 配置文件中指定以下行为:

我们可以在下载的目录中看到有一个 filebeat.yml 的配置文件。

1
2
3
4
5
6
filebeat.inputs:
. . .
processors:
. . .
output.plugin_name:
. . .

现在让我们详细研究每个部分:

  • filebeat.inputs:Filebeat 实例应监控的输入源。
  • processors:在将数据发送到输出之前对其进行扩充、修改或筛选。
  • output.plugin_name:Filebeat 应转发日志数据的输出目标。

这些指令中的每一个都要求您指定一个执行其相应任务的插件。

现在,让我们来探讨一些可以与 Filebeat 一起使用的输入、处理器和输出。

Filebeat 输入插件

Filebeat 提供了一系列输入插件,每个插件都经过定制,用于从特定来源收集日志数据:

  • container:收集容器日志。
  • filestream:主动从日志文件中读取行。
  • syslog:从 Syslog 中获取日志条目。
  • httpjson:从 RESTful API 读取日志消息。

Filebeat 输出插件

Filebeat 提供了多种输出插件,使您能够将收集的日志数据发送到不同的目的地:

  • File:将日志事件写入文件。
  • Elasticsearch:使 Filebeat 能够使用其 HTTP API 将日志转发到 Elasticsearch。
  • Kafka:将日志记录下发给 Apache Kafka。
  • Logstash:直接向 Logstash 发送日志。

Filebeat 模块插件

Filebeat 通过其模块简化日志处理,提供专为特定日志格式设计的预配置设置。这些模块使您能够毫不费力地引入、解析和丰富日志数据,而无需进行大量手动配置。以下是一些可以显著简化日志处理工作流程的可用模块:

  • Logstash
  • AWS
  • PostgreSQL
  • Nginx
  • RabbitMQ
  • HAproxy

Filebeat 入门

现在您已经了解了 Filebeat 的工作原理,让我们将其配置为从文件中读取日志条目并将其显示在控制台上。

首先,打开位于解压目录的 Filebeat 配置文件 filebeat.yml

1
vim filebeat.yml

接下来,清除文件的现有内容,并将其替换为以下代码:

1
2
3
4
5
6
7
filebeat.inputs:
- type: log
paths:
- /var/log/logify/app.log

output.console:
pretty: true

在本节 filebeat.inputs 中,您指定 Filebeat 应使用 logs 插件从文件中读取日志。paths 参数指示 Filebeat 将监控的日志文件的路径,此处设置为 /var/log/logify/app.log

output.console 部分将收集到的日志数据发送到控制台。pretty: true 参数可确保日志条目在控制台上显示时以可读且结构良好的格式显示。

添加这些配置后,保存文件。

在执行 Filebeat 之前,必须验证配置文件语法以识别和纠正任何错误:

1
./filebeat -c ./filebeat.yml test config

如果配置文件正确,则应看到以下输出:

1
Config OK

现在,继续运行 Filebeat:

1
./filebeat -c ./filebeat.yml

当 Filebeat 开始运行时,它将显示类似于以下内容的日志条目:

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
{
"@timestamp": "2024-03-02T01:35:34.696Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"log": {
"offset": 2875279,
"file": {
"path": "/var/log/logify/app.log"
}
},
"message": "{\"status\": 200, \"ip\": \"127.0.0.1\", \"level\": 30, \"emailAddress\": \"user@mail.com\", \"msg\": \"Task completed successfully\", \"pid\": 6512, \"timestamp\": 1709343333}",
"input": {
"type": "log"
},
"ecs": {
"version": "8.0.0"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"version": "8.12.2",
"ephemeral_id": "310a7b92-f2fb-42ca-b3d8-e32e348c7a57",
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local",
"type": "filebeat"
}
}

Filebeat 现在在控制台中显示日志消息。Bash 脚本中的日志事件位于 message 字段下,Filebeat 添加了其他字段以提供上下文。您现在可以通过按 CTRL + C 停止 Filebeat。

成功配置 Filebeat 以读取日志并将其转发到控制台后,下一节将重点介绍数据转换。

使用 Filebeat 转换日志

当 Filebeat 收集数据时,您可以在将其发送到输出之前对其进行处理。您可以使用新字段来丰富它,解析数据,以及删除或编辑敏感字段以确保数据隐私。

在本部分中,你将通过以下方式转换日志:

  • 解析 JSON 日志。
  • 删除不需要的字段。
  • 添加新字段。
  • 屏蔽敏感数据。

使用 Filebeat 解析 JSON 日志

由于演示日志记录应用程序以 JSON 格式生成日志,因此必须正确解析它们以进行结构化分析。

让我们检查上一节中的示例日志事件:

1
2
3
4
5
6
...
"message": "{\"status\": 200, \"ip\": \"127.0.0.1\", \"level\": 30, \"emailAddress\": \"user@mail.com\", \"msg\": \"Task completed successfully\", \"pid\": 6512, \"timestamp\": 1709343333}",
"input": {
"type": "log"
},
...

要将日志事件解析为有效的 JSON,请打开 Filebeat 配置文件:

1
vim filebeat.yml

然后,使用以下代码行更新文件:

1
2
3
4
5
6
7
8
9
10
11
12
filebeat.inputs:
- type: log
paths:
- /var/log/logify/app.log

processors:
- decode_json_fields:
fields: ["message"]
target: ""

output.console:
pretty: true

在上面的代码片段中,您将处理器配置为 decode_json_fields 解码每个日志条目 message 字段中的 JSON 编码数据,并将其附加到日志事件。

保存并退出文件。使用以下命令重新运行 Filebeat:

1
./filebeat -c ./filebeat.yml

如果配置文件正确,则应看到以下输出:

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
{
"@timestamp": "2024-03-02T01:43:07.367Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"status": 200,
"ip": "127.0.0.1",
"level": 30,
"message": "{\"status\": 200, \"ip\": \"127.0.0.1\", \"level\": 30, \"emailAddress\": \"user@mail.com\", \"msg\": \"Initialized application\", \"pid\": 6512, \"timestamp\": 1709343785}",
"ecs": {
"version": "8.0.0"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"type": "filebeat",
"version": "8.12.2",
"ephemeral_id": "f33a1fc6-e8f1-4dde-8740-5f85a1e8bcfd",
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local"
},
"pid": 6512,
"timestamp": 1709343785,
"log": {
"offset": 2898143,
"file": {
"path": "/var/log/logify/app.log"
}
},
"input": {
"type": "log"
},
"emailAddress": "user@mail.com",
"msg": "Initialized application"
}

在输出中,您将看到 message 字段中的所有属性(如 msgip 等)都已添加到日志事件中。

现在,您可以解析 JSON 日志,您将修改日志事件的属性。

使用 Filebeat 添加和删除字段

日志事件包含需要保护的敏感 emailAddress 字段。在本部分中,你将删除该 emailAddress 字段,并向日志事件添加一个新字段,以提供更多上下文。

打开 Filebeat 配置文件:

1
vim filebeat.yml

添加以下行以修改日志事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
filebeat.inputs:
- type: log
paths:
- /var/log/logify/app.log

processors:
- decode_json_fields:
fields: ["message"]
target: ""
- drop_fields:
fields: ["emailAddress", "message"]

- add_fields:
fields:
env: "environment" # Add a new 'env' field set to "development"
output.console:
pretty: true

若要修改日志事件,请添加 drop_fields 处理器,该处理器具有一个 field 选项,用于获取要删除的字段列表,包括敏感 EmailAddress 字段和 message 字段。删除 message 字段是因为在分析数据后,message 字段的属性已合并到日志事件中,从而使原始 message 字段过时。

编写代码后,保存并退出文件。然后,重新启动 Filebeat:

1
./filebeat -c ./filebeat.yml

运行 Filebeat 时,您会注意到该 emailAddress 字段已被成功删除,并且已将一个新 env 字段添加到日志事件中:

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
{
"@timestamp": "2024-03-02T01:47:33.907Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"ip": "127.0.0.1",
"level": 30,
"log": {
"offset": 2911714,
"file": {
"path": "/var/log/logify/app.log"
}
},
"ecs": {
"version": "8.0.0"
},
"msg": "Operation finished",
"pid": 6512,
"timestamp": 1709344053,
"status": 200,
"fields": {
"env": "environment"
},
"input": {
"type": "log"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"version": "8.12.2",
"ephemeral_id": "607c49c8-9339-4851-b8e6-caab3bf6138b",
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local",
"type": "filebeat"
}
}

现在,您可以扩充和删除不需要的字段,接下来将编写条件语句。

在 Filebeat 中使用条件语句

Filebeat 允许您检查条件,并在条件计算结果为 true 时添加字段。在本节中,您将检查该 status 值是否等于 true ,如果满足条件,您将向日志事件添加一个 is_successful 字段。

为此,请打开配置文件:

1
vim filebeat.yml

之后,添加突出显示的行以根据指定条件添加 is_successful 字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
processors:
- decode_json_fields:
fields: ["message"]
target: ""
- drop_fields:
fields: ["emailAddress"] # Remove the 'emailAddress' field

- add_fields:
fields:
env: "environment" # Add a new 'env' field set to "development"
# 如果 status 字段的值为 200,则添加 is_successful 字段
- add_fields:
when:
equals:
status: 200
target: ""
fields:
is_successful: true
...

when 选项检查 status 字段值是否等于 200。如果为 true,则将该 is_successful 字段添加到日志事件中。

保存新更改后,启动 Filebeat:

1
./filebeat -c ./filebeat.yml

Filebeat 将生成与此内容密切相关的输出:

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
{
"@timestamp": "2024-03-02T01:50:31.697Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"status": 200,
"ip": "127.0.0.1",
"level": 30,
"ecs": {
"version": "8.0.0"
},
"fields": {
"env": "environment"
},
"input": {
"type": "log"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"version": "8.12.2",
"ephemeral_id": "20cca3c1-6ba3-4a78-8e0e-cb07bdb885f1",
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local",
"type": "filebeat"
},
"is_successful": true,
"msg": "Task completed successfully",
"pid": 6512,
"log": {
"file": {
"path": "/var/log/logify/app.log"
},
"offset": 2920724
},
"timestamp": 1709344231
}

在输出中,该 is_successful 字段已添加到日志条目中,HTTP 状态代码为 200

这负责根据条件添加新字段。

使用 Filebeat 编辑敏感数据

在本文前面,您删除了 emailAddress 字段以确保数据隐私。但是,IP 地址敏感字段仍保留在日志事件中。此外,组织内的其他开发人员可能会无意中将敏感数据添加到日志事件中。通过编辑与特定模式匹配的数据,您可以屏蔽任何敏感信息,而无需删除整个字段,从而确保保留消息的重要性。

在文本编辑器中,打开 Filebeat 配置文件:

1
vim filebeat.yml

添加以下代码以编辑 IP 地址:

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
...
processors:
- script:
lang: javascript
id: redact-sensitive-info
source: |
function process(event) {
// Redact IP addresses (e.g., 192.168.1.1) from the "message" field
event.Put("message", event.Get("message").replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, "[REDACTED-IP]"));
}
- decode_json_fields:
fields: ["message"]
target: ""
- drop_fields:
fields: ["emailAddress"] # Remove the 'emailAddress' field

- add_fields:
fields:
env: "environment" # Add a new 'env' field set to "development"
- add_fields:
when:
equals:
status: 200
target: ""
fields:
is_successful
...

在添加的代码中,您将定义一个用 JavaScript 编写的脚本,用于编辑日志事件中的敏感信息。该脚本使用正则表达式来标识 IP 地址,并分别将它替换为 [REDACTED-IP]

添加代码后,运行 Filebeat:

1
./filebeat -c ./filebeat.yml

输出:

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
{
"@timestamp": "2024-03-02T01:53:50.792Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"log": {
"offset": 2930777,
"file": {
"path": "/var/log/logify/app.log"
}
},
"pid": 6512,
"status": 200,
"fields": {
"env": "environment"
},
"msg": "Initialized application",
"is_successful": true,
"level": 30,
"input": {
"type": "log"
},
"ecs": {
"version": "8.0.0"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local",
"type": "filebeat",
"version": "8.12.2",
"ephemeral_id": "8bf595e9-0376-42ec-be51-42a104527449"
},
"timestamp": 1709344429,
"ip": "[REDACTED-IP]"
}

输出中的日志事件现在将 IP 地址替换为 [REDACTED-IP]

注意:上面的脚本会将 message 中的所有 IP 地址都替换。

您现在可以停止 Filebeat 和 logify.sh 程序。

若要停止 bash 脚本,请获取进程 ID:

1
jobs -l | grep "logify"

输出:

1
[1]  + 6512 running    ./logify.sh

替换 kill 命令中的进程 ID:

1
kill -9 <6512>

成功编辑敏感字段后,您现在可以使用 Filebeat 从 Docker 容器收集日志,并将它们集中起来以进行进一步的分析和监控。

总结

在本文中,我们介绍了 Filebeat 的基本概念和工作原理。我们还演示了如何配置 Filebeat 以收集日志,并使用处理器对日志事件进行转换。我们还讨论了 Filebeat 的输入、输出和模块插件,以及如何使用条件语句和脚本处理器来编辑日志事件。

Filebeat 和 Logstash 均由 Elastic 开发,是 Elastic Stack 不可或缺的组件,它们都充当日志收集器,具有不同的特性和功能。Logstash 是 ELK Stack(Elasticsearch、Logstash、Kibana)的原始组件,旨在高效地从多个来源收集大量日志并将其分发到不同的目的地。

虽然 Logstash 越来越受欢迎,但由于资源密集型操作,尤其是在资源有限的系统上,它面临着挑战。为了解决这个问题,Elastic 推出了 Filebeat 作为 Beats 系列的一部分,为 Logstash 提供了轻量级的替代品。这一新增导致 ELK Stack 更名为 Elastic Stack,以更好地涵盖包括 Beats 在内的不断增长的工具套件。Filebeat 旨在补充 Logstash,但随着时间的推移演变成一个独立的日志收集器。

因此,鉴于这两个日志收集器的独特优势和功能,在这两个日志收集器之间做出决定可能是一项艰巨的任务。本文将比较 Filebeat 和 Logstash,探讨它们的优缺点,并提供有关何时选择其中一种的见解。

什么是 Filebeat?

Filebeat 是由 Elastic 开发的免费开源日志收集器,是 Elastic Stack 中 beats 系列的一部分。这套工具对于收集和传送各种类型的数据(如日志、指标和网络信息)至关重要。Filebeat 最初主要设计用于 Logstash,但随着时间的推移,随着 Elastic 对其日志处理能力的不断更新,Filebeat 已经超越了这一点。

此外,Filebeat 具有各种内置输入和输出,可满足不同的来源和目标。如果这些内置选项不符合特定要求,它还允许创建自定义插件。Filebeat 还包括内部模块,用于从广泛使用的工具(如 NGINX、Apache、系统日志和 MySQL)收集和解析日志。

Filebeat 因其轻量级设计、可靠性和稳健性而受到重视。它支持加密数据传输,并使用背压敏感协议,这在处理大量数据时很有用。此功能使其能够调整其数据传输速率,防止目的地过载。

什么是 Logstash?

Logstash 是由 Elastic 创建的免费开源数据管道工具。它旨在有效地收集、处理日志并将其发送到各种目标。Logstash 以其灵活性而闻名,提供多个输入、过滤器和输出,使其能够适应不同的日志处理需求。它擅长过滤、解析和转换日志,提供高级日志处理能力。

作为 Elastic Stack 的关键组件,Logstash 可与 Beats、Elasticsearch 和 Kibana 等其他工具无缝协作。它从各种来源提取数据并将其推送到 Elasticsearch,然后转发到 Kibana 进行分析和可视化。

Logstash 拥有超过 200 个插件,并提供了一个 API,用于创建满足特定用户需求的自定义插件。Logstash 的一个显著特点是其可靠性,它由一个持久队列支撑,该队列在传输日志事件时保存日志事件。在事件发送失败的情况下,Logstash 可以将其重新路由到另一个队列进行进一步检查和重新处理。

现在我们已经对 Filebeat 和 Logstash 有了基本的了解,让我们来比较一下这两个工具。我们的比较将重点关注以下关键标准:

特征 Filebeat Logstash
支持的平台 跨平台 跨平台
内存使用率/性能 轻量级 利用大量内存
生态系统和插件 少于 60 个插件 超过 200 个插件
日志解析 具有内置解析器和模块 具有更强大的解析器
事件路由 不支持 使用条件语句路由日志
传输 缓冲日志事件并具有持久性队列 缓冲日志事件并具有持久性队列
UI & UX 没有 UI,但可以与 Kibana 集成 没有 UI,但可以与 Kibana 集成

1. 支持的平台

Filebeat 是使用 Go 开发的,Go 是一种以创建高性能网络和基础设施程序而闻名的现代语言。其设计允许 Filebeat 以较低的内存占用量收集、处理和转发日志。此外,它的兼容性跨越各种平台,包括 Linux、Windows、MacOS,甚至容器化环境。

另一方面,Logstash 是使用 JRuby 构建的,JRuby 是 Java 中 Ruby 编程语言的高性能实现。要运行 Logstash,需要 Java 虚拟机 (JVM)。JVM 的跨平台兼容性确保 Logstash 可以在各种系统上运行,包括 Linux、Windows 和 MacOS。这使得 Logstash 在平台支持方面同样通用。

考虑到支持的平台,Filebeat 和 Logstash 都表现出很强的适应性,使其成为跨平台支持的绝佳选择。

2. 内存使用率/性能:Filebeat 获胜

Filebeat 旨在专注于轻量级效率,使其能够处理大量数据,同时保持最小的内存消耗。Filebeat 的单个实例通常使用不到 2MB 的内存,并使用不到 30% 的 CPU。如前所述,这种非凡的效率主要归功于它在 Go 中的发展。

此外,Filebeat 还具有负载平衡和故障转移功能,这对于确保一致的日志数据检索和转发至关重要,尤其是在高流量场景中。

相比之下,Logstash 需要的内存占用要高得多。根据官方文档,为了有效运行,它需要一个至少具有 2GB 内存的主机,建议的内存分配约为 4GB。这种需求的增加源于 Logstash 对 Java 虚拟机 (JVM) 及其复杂的日志处理功能的依赖,这自然会消耗更多的资源。虽然 Logstash 确实包含负载平衡和故障转移功能以实现可靠的日志处理,但其较高的资源要求使其不太适合内存和 CPU 使用率是关键限制的环境。

总之,对于性能和可扩展性至关重要且内存使用率至关重要的环境,Filebeat 是更有利的选择,因为它具有更低的内存占用和高效的资源利用率。

3. 生态系统和插件:Logstash 获胜

Filebeat 专注于轻量级和高效,仍然提供包含 60 多个插件的大量库。这些插件涵盖了各种输入和输出,包括 AWS S3、Kafka、Redis 和 File。这些插件的全面详细信息可以在文档中的详细输入和输出页面上找到。对于那些熟练使用 Go 的人来说,Filebeat 还允许创建自定义插件,为特定用例提供灵活性。此外,Filebeat 的模块简化了从 MySQL 或 Nginx 等流行工具读取日志的过程,增强了其易用性。

另一方面,Logstash 拥有丰富的插件生态系统,数量超过 200 个,分为输入、输出、过滤器和编解码器。这些插件大多是内置的,构成了 Logstash 高级日志处理能力的基础。广泛使用的插件包括 Grok 过滤器插件,它使用正则表达式解析日志,以及 GeoIP 过滤器,它为 IP 地址生成地理信息。

为 Logstash 创建自定义插件非常简单,官方文档提供了全面的指导。

由于其广泛的插件生态系统,Logstash 在需要高级数据处理的场景中成为明显的赢家。

4. 日志解析:Logstash 获胜

在日志解析方面,Filebeat 和 Logstash 都展示了高级日志解析功能,集成了能够处理结构化和非结构化日志的内置插件。

Filebeat 通过使用可用的处理器增强了其解析功能,能够解析 JSON 和 CSV 等标准格式。此外,内部模块使 Filebeat 能够解析来自 Nginx、MySQL 或 Apache 等来源的流行格式。

同样,Logstash 提供了强大的解析器和内置功能。Logstash 的与众不同之处在于包含 Grok,它使用正则表达式将日志事件与特定模式进行匹配。Grok 具有 200 多种预定义模式,能够解析来自不同来源(如 MongoDB、Postgres 和 AWS)的结构化和非结构化日志,并灵活地定义用于解析自定义日志格式的模式。

在日志解析方面,Filebeat 适合处理标准日志格式,而 Logstash 则凭借强大的解析功能脱颖而出。

5. 事件路由:Logstash 获胜

事件路由是一个过程,其中日志事件根据条件或每个日志事件中的内容定向到特定目标。这意味着您可以设置规则,根据每个日志包含的数据确定应将每个日志发送到何处。例如,您可以将日志收集器配置为将 HTTP 状态代码为 200 的所有日志事件发送到远程位置,而将状态代码为 400 的日志事件写入特定文件。

Filebeat 虽然在日志收集方面很高效,但并非设计为事件路由。它通常会将所有收集的日志转发到单个端点(通常是 Logstash),这与其原始设计目的一致。因此,在需要将日志从多个来源分发到不同目标的场景中,Filebeat 的功能会受到限制。

另一方面,Logstash 在事件路由方面表现出卓越的熟练程度。它支持使用条件语句(如 if-then-else )将日志事件路由到多个目标。这些条件允许 Logstash 根据特定条件评估日志事件并相应地指导它们。

鉴于事件路由在根据特定需求定制日志分发方面的重要性,Logstash 在这方面的表现优于 Filebeat。

6. 传输

在传输数据方面,Filebeat 和 Logstash 不相上下,它们都提供有效的输出插件,用于从各种来源收集日志并将其传送到多个目的地,包括云存储、Kafka、AWS 和本地文件。

这两种工具的一个关键特性是它们使用内存中队列来缓冲日志事件。此功能对于管理日志数据中的峰值和临时存储日志以防止数据输出过载至关重要。内存中队列在重新发送可能由于输出目标问题而无法传输的日志事件方面也发挥着作用。

在意外中断或过早退出时,存在丢失存储在这些内存中队列中的日志事件的风险。但是,Filebeat 和 Logstash 通过提供为持久性数据存储配置磁盘队列的选项来降低这种风险。这种额外的弹性层确保了工具可以无缝地恢复其操作,在突然关闭时从中断的地方继续运行。

需要注意的是,尽管 Logstash 和 Filebeat 具有持久队列,但偶尔会发生故障,尤其是在处理大量数据时。为了缓解这种情况,建议使用专用工具(如 Kafka)从日志收集器中卸载负载。该工具充当临时数据持久层,为日志管理基础结构增加了额外的弹性和稳定性。

考虑到它们在数据传输方面的能力,Filebeat 和 Logstash 都比对方具有明显的优势。两者都可以可靠地传输和持久化数据。

什么时候应该使用 Filebeat 或 Logstash ?

在 Filebeat 和 Logstash 之间做出决定取决于您的特定日志管理需求。

当您的主要需求是从各种来源收集日志并将其定向到 Elasticsearch 或云存储等单一目标时,Filebeat 是一个理想的选择。其他理想的情况是,您的日志需要基本解析,例如处理 Nginx 日志或管理不需要复杂处理的 syslog 输入。

另一方面,如果您正在处理需要复杂操作、扩充或过滤的半结构化或非结构化日志,那么 Logstash 更合适。当您的目标是将日志事件路由到多个目标时,它也是首选工具。

更有效的策略是结合使用 Filebeat 和 Logstash,特别是用于管理来自不同来源的日志。通常,由于 Filebeat 的效率,您可以使用 Filebeat 来收集日志。然后,收集的日志将转发到 Logstash 进行更复杂的处理。Logstash 可以配置为将日志分发到不同的目的地,或者将它们转发到 Elasticsearch 进行索引,然后转发到 Kibana 进行可视化。这种组合方法有效地利用了 Filebeat 和 Logstash 的优势。

有关更具可伸缩性的设置,请参阅下图:

实例

让我们看一个使用 Elastic Stack 的实际示例,并考虑 Filebeat 和 Logstash 如何协同工作来管理和处理日志。

假设您有以下格式的 Nginx 日志:

1
203.0.113.1 - - [14/Jan/2022:08:30:45 +0000] "GET /example-page HTTP/1.1" 200 1024 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"

以下是使用 Filebeat 和 Logstash 处理这些日志的方法:

  1. 使用 Filebeat:首先,使用 Filebeat 从 Nginx 收集日志。Filebeat 可以配置 Nginx 模块,该模块旨在解析 Nginx 日志。
  2. 转发到 Logstash:收集和初始解析后,配置 Filebeat 将这些日志发送到 Logstash。
  3. 在 Logstash 中处理:在 Logstash 中,您可以对日志进行高级操作。这包括提取特定字段、设置日期格式、编辑 IP 地址、重命名字段和其他所需的转换。
  4. 发送到 Elasticsearch 和 Kibana:处理完成后,Logstash 会将日志转发到 Elasticsearch 进行索引。索引后,可以在 Kibana 中对数据进行可视化和分析。

此工作流程演示了 Filebeat 和 Logstash 如何无缝协作。此设置对于处理需要简单和复杂处理和可视化过程的日志非常方便。

结论

在本文中,我们比较了 Filebeat 和 Logstash,展示了每种工具如何满足不同的需求。

Filebeat 是一个轻量级选项,非常适合资源有限且需要基本日志解析的环境。相反,Logstash 是为需要高级日志处理的场景量身定制的。

同时使用这两种工具也可以具有战略意义,以获得两全其美的优势。使用 Filebeat 收集日志并将其转发到 Logstash 进行高级转换提供了一种平衡的方法。

什么是分布式锁?

分布式锁是一种在分布式系统中用于控制并发访问的机制。在分布式系统中,多个客户端可能会同时对同一个资源进行访问,这可能导致数据不一致的问题。分布式锁的作用是确保同一时刻只有一个客户端能够对某个资源进行访问,从而避免数据不一致的问题。

分布式锁的实现通常依赖于一些具有分布式特性的技术,如 ZooKeeperRedis、数据库等。这些技术提供了在分布式环境中实现互斥访问的机制,使得多个客户端在竞争同一个资源时能够有序地进行访问。

通过使用分布式锁,可以确保分布式系统中的数据一致性和并发访问的有序性,从而提高系统的可靠性和稳定性。

Zookeeper 与 Redis 的分布式锁对比

ZooKeeperRedis 都是常用的实现分布式锁的工具,但它们在实现方式、特性、适用场景等方面有一些区别。以下是 ZooKeeper 分布式锁与 Redis 分布式锁的比较:

实现方式

  • ZooKeeper 分布式锁主要依赖于其临时节点和顺序节点的特性。客户端在 ZooKeeper 中创建临时顺序节点,并通过监听机制来实现锁的获取和释放。
  • Redis 分布式锁通常使用 SETNX(set if not exists) 命令来尝试设置一个 key,如果设置成功则获取到锁。也可以通过设置过期时间和轮询机制来防止死锁和提高锁的可靠性。

特性

  • ZooKeeper 分布式锁具有严格的顺序性和公平性,保证了锁的获取顺序与请求顺序一致,避免了饥饿问题。
  • Redis 分布式锁的性能通常更高,因为它是一个内存数据库,读写速度非常快。然而,它可能存在不公平性和死锁的风险,需要额外的机制来避免这些问题。

适用场景

  • ZooKeeper 分布式锁适用于对顺序性和公平性要求较高的场景,如分布式调度系统、分布式事务等。
  • Redis 分布式锁适用于对性能要求较高的场景,如缓存系统、高并发访问的系统等。Redis 的高性能使得它在处理大量并发请求时具有优势。

可靠性

  • ZooKeeper 分布式锁具有较高的可靠性,因为它依赖于 ZooKeeper 的高可用性和强一致性保证。即使部分节点宕机,ZooKeeper 也能保证锁的正确性和一致性。
  • Redis 分布式锁的可靠性取决于其实现方式和配置。在某些情况下,如 Redis 节点宕机或网络故障,可能会导致锁失效或死锁。因此,需要合理配置 Redis 和采取额外的措施来提高锁的可靠性。

综上所述,ZooKeeper 分布式锁和 Redis 分布式锁各有优缺点,具体选择哪种方式取决于实际业务场景和需求。在需要保证顺序性和公平性的场景下,ZooKeeper 分布式锁可能更适合;而在需要高性能和快速响应的场景下,Redis 分布式锁可能更合适。

为什么 Zookeeper 可以实现分布式锁

ZooKeeper 可以实现分布式锁,主要得益于其以下几个特性:

  1. 临时节点:ZooKeeper 支持创建临时节点,这些节点在创建它们的客户端会话结束时会被自动删除。这种特性使得 ZooKeeper 的节点具有生命周期,可以随着客户端的存活而存在,客户端断开连接后自动消失,非常适合作为锁的标识。
  2. 顺序节点:ZooKeeper 的另一个重要特性是支持创建顺序节点。在创建节点时,ZooKeeper 会在节点名称后自动添加一个自增的数字,确保节点在 ZNode 中的顺序性。这个特性使得 ZooKeeper 可以实现分布式锁中的公平锁,按照请求的顺序分配锁。
  3. Watcher 机制:ZooKeeper 还提供了 Watcher 机制,允许客户端在指定的节点上注册监听事件。当这些事件触发时,ZooKeeper 服务端会将事件通知到感兴趣的客户端,从而允许客户端做出相应的措施。这种机制使得 ZooKeeper 的分布式锁可以实现阻塞锁,即当客户端尝试获取已经被其他客户端持有的锁时,它可以等待锁被释放。

基于以上特性,ZooKeeper 可以实现分布式锁。具体实现流程如下:

  1. 客户端需要获取锁时,在 ZooKeeper 中创建一个临时顺序节点作为锁标识。
  2. 客户端判断自己创建的节点是否是所有临时顺序节点中序号最小的。如果是,则客户端获得锁;如果不是,则客户端监听序号比它小的那个节点。
  3. 当被监听的节点被删除时(即持有锁的客户端释放锁),监听者会收到通知,然后重新判断自己是否获得锁。
  4. 当客户端释放锁时,只需要将会话关闭,临时节点就会被自动删除,从而释放了锁。

因此,ZooKeeper 通过其临时节点、顺序节点和 Watcher 机制等特性,实现了分布式锁的功能。

使用 Golang 实现 Zookeeper 分布式锁

下面我们通过一个简单的例子来演示如何使用 Golang 实现 ZooKeeper 分布式锁。

创建 zookeeper 客户端连接

1
2
3
4
5
6
7
8
9
10
import "github.com/go-zookeeper/zk"

func client() *zk.Conn {
// 默认端口 2181
c, _, err := zk.Connect([]string{"192.168.2.168"}, time.Second)
if err != nil {
panic(err)
}
return c
}

创建父节点 - /lock

我们可以在获取锁之前,先创建一个父节点,用于存放锁节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Lock struct {
c *zk.Conn
}

// 父节点 /lock 不存在的时候进行创建
func NewLock() *Lock {
c := client()
e, _, err := c.Exists("/lock")
if err != nil {
panic(err)
}
if !e {
_, err := c.Create("/lock", []byte(""), 0, zk.WorldACL(zk.PermAll))
if err != nil {
panic(err)
}
}

return &Lock{c: c}
}

获取锁

在 Zookeeper 分布式锁实现中,获取锁的过程实际上就是创建一个临时顺序节点,并判断自己是否是所有临时顺序节点中序号最小的。

获取锁的关键是:

  1. 创建的需要是临时节点
  2. 创建的需要是顺序节点

具体创建代码如下:

1
p, err := l.c.Create("/lock/lock", []byte(""), zk.FlagEphemeral|zk.FlagSequence, zk.WorldACL(zk.PermAll))

其中 zk.FlagEphemeral 表示创建的是临时节点,zk.FlagSequence 表示创建的是顺序节点。

判断当前创建的节点是否是最小节点

具体步骤如下:

  1. 通过 l.c.Children("/lock") 获取 /lock 下的所有子节点
  2. 对所有子节点进行排序
  3. 判断当前创建的节点是否是最小节点
  4. 如果是最小节点,则获取到锁,函数调用返回;如果不是,则监听前一个节点(这会导致函数调用阻塞)
1
2
3
4
5
6
7
8
9
10
11
12
13
childs, _, err := l.c.Children("/lock")
if err != nil {
return "", err
}

// childs 是无序的,所以需要排序,以便找到当前节点的前一个节点,然后监听前一个节点
sort.Strings(childs)

// 成功获取到锁
p1 := strings.Replace(p, "/lock/", "", 1)
if childs[0] == p1 {
return p, nil
}

不是最小节点,监听前一个节点

具体步骤如下:

  1. 通过 sort.SearchStrings 找到当前节点在所有子节点中的位置
  2. 调用 l.c.ExistsW 判断前一个节点是否依然存在(锁有可能在调用 ExistsW 之前已经被释放了),如果不存在则获取到锁
  3. 如果前一个节点依然存在,则阻塞等待前一个节点被删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 监听锁,等待锁释放
// 也就是说,如果当前节点不是最小的节点,那么就监听前一个节点
// 一旦前一个节点被删除,那么就可以获取到锁
index := sort.SearchStrings(childs, p1)
b, _, ev, err := l.c.ExistsW("/lock/" + childs[index-1])
if err != nil {
return "", err
}

// 在调用 ExistsW 之后,前一个节点已经被删除
if !b {
return p, nil
}

// 等待前一个节点被删除
<-ev

return p, nil

在调用 ExistsW 的时候,如果前一个节点已经被删除,那么 ExistsW 会立即返回 false,否则我们可以通过 ExistsW 返回的第三个参数 ev 来等待前一个节点被删除。

<-ev 处,我们通过 <-ev 来等待前一个节点被删除,一旦前一个节点被删除,ev 会收到一个事件,这个时候我们就可以获取到锁了。

释放锁

如果调用 Lock 可以成功获取到锁,我们会返回当前创建的节点的路径,我们可以通过这个路径来释放锁。

1
2
3
func (l *Lock) Unlock(p string) error {
return l.c.Delete(p, -1)
}

完整代码

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
74
75
76
77
78
79
package main

import (
"github.com/go-zookeeper/zk"
"sort"
"strings"
"time"
)

func client() *zk.Conn {
c, _, err := zk.Connect([]string{"192.168.2.168"}, time.Second) //*10)
if err != nil {
panic(err)
}
return c
}

type Lock struct {
c *zk.Conn
}

func NewLock() *Lock {
c := client()
e, _, err := c.Exists("/lock")
if err != nil {
panic(err)
}
if !e {
_, err := c.Create("/lock", []byte(""), 0, zk.WorldACL(zk.PermAll))
if err != nil {
panic(err)
}
}

return &Lock{c: c}
}

func (l *Lock) Lock() (string, error) {
p, err := l.c.Create("/lock/lock", []byte(""), zk.FlagEphemeral|zk.FlagSequence, zk.WorldACL(zk.PermAll))
if err != nil {
return "", err
}
childs, _, err := l.c.Children("/lock")
if err != nil {
return "", err
}

// childs 是无序的,所以需要排序,以便找到当前节点的前一个节点,然后监听前一个节点
sort.Strings(childs)

// 成功获取到锁
p1 := strings.Replace(p, "/lock/", "", 1)
if childs[0] == p1 {
return p, nil
}

// 监听锁,等待锁释放
// 也就是说,如果当前节点不是最小的节点,那么就监听前一个节点
// 一旦前一个节点被删除,那么就可以获取到锁
index := sort.SearchStrings(childs, p1)
b, _, ev, err := l.c.ExistsW("/lock/" + childs[index-1])
if err != nil {
return "", err
}

// 在调用 ExistsW 之后,前一个节点已经被删除
if !b {
return p, nil
}

// 等待前一个节点被删除
<-ev

return p, nil
}

func (l *Lock) Unlock(p string) error {
return l.c.Delete(p, -1)
}

测试代码

下面这个例子模拟了分布式的 counter 操作,我们通过 ZooKeeper 分布式锁来保证 counter 的原子性。

当然这个例子只是为了说明 ZooKeeper 分布式锁的使用,实际上下面的功能通过 redis 自身提供的 incr 就可以实现,不需要这么复杂。

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
package main

import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"sync"
)

func main() {
var count = 1000
var wg sync.WaitGroup
wg.Add(count)

l := NewLock()
// 创建 redis 客户端连接
redisClient = redis.NewClient(&redis.Options{
Addr: "192.168.2.168:6379",
Password: "", // no password set
DB: 0, // use default DB
})

for i := 0; i < count; i++ {
go func(i1 int) {
defer wg.Done()

// 获取 Zookeeper 分布式锁
p, err := l.Lock()
if err != nil {
return
}
// 成功获取到了分布式锁:
// 1. 从 redis 获取 zk_counter 的值
// 2. 然后对 zk_counter 进行 +1 操作
// 3. 最后将 zk_counter 的值写回 redis
cmd := redisClient.Get(context.Background(), "zk_counter")
i2, _ := cmd.Int()
i2++
redisClient.Set(context.Background(), "zk_counter", i2, 0)
// 释放分布式锁
err = l.Unlock(p)
if err != nil {
println(fmt.Errorf("unlock error: %v", err))
return
}
}(i)
}

wg.Wait()

l.c.Close()
}

我们需要将测试程序放到不同的机器上运行,这样才能模拟分布式环境。

总结

最后,再来回顾一下本文内容:

  1. sync.Mutex 这种锁只能保证单进程内的并发安全,无法保证分布式环境下的并发安全。
  2. 使用 ZookeeperRedis 都能实现分布式锁,但是 Zookeeper 可以保证顺序性和公平性,而 Redis 可以保证高性能。
  3. Zookeeper 通过其临时节点、顺序节点和 Watcher 机制等特性,实现了分布式锁的功能。

在实际的工作中,我们很多时候开发环境跟应用程序最终运行的环境是不同的操作系统,比如在 Windows 上进行开发,但是应用程序最终是要在 Linux 上运行的, 又或者是在 mac 下开发,在 Linux 下运行。这个时候我们就需要进行交叉编译,即在一个操作系统上编译出另一个操作系统的可执行文件。

使用 Go 的时候,我们可以很方便的进行交叉编译,只需要设置好环境变量或者设置构建标签即可,本文会通过一个简单的例子来演示如何进行交叉编译。

GOOS 和 GOARCH 环境变量所有可能的值

在 Go 语言中,我们可以通过设置环境变量 GOOSGOARCH 来指定目标操作系统和目标架构。 比如在我的系统上,查看 GOOSGOARCH 的值:

1
2
3
➜ go env GOOS GOARCH
darwin
amd64

在 Go 编译的时候,默认的 GOOSGOARCH 的值是当前系统的操作系统和架构,比如在我的系统上,GOOS 的值是 darwinGOARCH 的值是 amd64。 所以编译出来的就是当前系统可以执行的二进制文件,如果我们想要编译出其他系统的二进制文件,就需要设置 GOOSGOARCH 的值。

首先,我们需要了解这两个环境变量支持哪些值。下面是所有可能的值,我们可以通过 go tool dist list 列出来:

1
2
3
4
5
6
7
8
9
10
11
aix/ppc64        freebsd/amd64   linux/mipsle   openbsd/386
android/386 freebsd/arm linux/ppc64 openbsd/amd64
android/amd64 illumos/amd64 linux/ppc64le openbsd/arm
android/arm js/wasm linux/s390x openbsd/arm64
android/arm64 linux/386 nacl/386 plan9/386
darwin/386 linux/amd64 nacl/amd64p32 plan9/amd64
darwin/amd64 linux/arm nacl/arm plan9/arm
darwin/arm linux/arm64 netbsd/386 solaris/amd64
darwin/arm64 linux/mips netbsd/amd64 windows/386
dragonfly/amd64 linux/mips64 netbsd/arm windows/amd64
freebsd/386 linux/mips64le netbsd/arm64 windows/arm

在上面的输出中,/ 前面操作系统,/ 后面是架构。以 linux/386 为例,键值对以 GOOS 开始,在本例中将是 linux ,指的是 Linux 操作系统。这里的 GOARCH 将是 386 ,代表 Intel 80386 微处理器。

我们发现其实 Go 支持很多操作系统和架构,但是大多数情况下,你最终会使用 linuxwindowsdarwin 中的一个作为 GOOS 的值,这涵盖了三大操作系统平台:Linux、Windows 和 macOS。

使用文件名后缀实现交叉编译

使用场景:不同操作系统需要通过不同代码来实现。

Go 标准库中 path/filepath 包中的 Join 函数,在不同平台下会有不同的效果。该函数接受一些字符串,并返回一个使用正确文件路径分隔符连接在一起的字符串。

这是一个很好的示例程序,因为程序的操作取决于它运行的操作系统。在 Windows 上,路径分隔符是反斜杠 \,而 Unix 系统使用正斜杠 /

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

import (
"fmt"
"path/filepath"
)

func main() {
s := filepath.Join("a", "b", "c")
fmt.Println(s)
}

这个程序在 Windows 上运行时,将输出 a\b\c,而在 Unix 系统上运行时,将输出 a/b/c

这是如何实现的呢?这就涉及到了 Go 中实现交叉编译的其中一种方式,就是指定文件名后缀, 我们看 Go 的源码或者一些开源项目的源码,就会发现有些文件的文件名带了操作系统的后缀,比如 file_windows.gofile_linux.gofile_darwin.go 等等。

同样的,path/filepath 包中的 Join 函数也是这样实现的,我们可以看到 path/filepath 包中有很多文件,比如 path_windows.gopath_unix.go 等等,其中:

  • path_windows.go 中实现了 Join 函数在 Windows 上的实现
  • path_unix.go 中实现了 Join 函数在 Unix 系统上的实现

我们点开 path_unix.go 文件,可以看到如下的代码:

1
2
3
4
5
// ...
const (
PathSeparator = '/' // OS-specific path separator
PathListSeparator = ':' // OS-specific path list separator
)

也就是说,Join 函数的路径分隔符是在这里通过 PathSeparator 定义成 / 的,而在 path_windows.go 文件中,PathSeparator 是定义成 \ 的。

1
2
3
4
5
6
// path_windows.go
// ...
const (
PathSeparator = '\\' // OS-specific path separator
PathListSeparator = ';' // OS-specific path list separator
)

有两个 \ 是因为需要转义。

同时在文件名中加上 GOARCH 后缀

在命名文件时,您可以按照顺序将 GOOSGOARCH 添加为文件名的后缀,用下划线(_)分隔这些值。如果您有一个名为 filename.go 的 Go 文件,您可以通过将文件名更改为 filename_GOOS_GOARCH.go 来指定操作系统和架构。例如,如果您希望将其编译为具有 64 位 ARM 架构的 Windows 文件,您将文件名更改为 filename_windows_arm64.go 。这种命名约定有助于保持代码整洁有序。

在我们编译的时候,如果我们当前的 GOOSGOARCH 跟文件名不匹配,则 Go 会忽略这个文件。

使用构建标签实现交叉编译

使用场景:不同操作系统需要使用不同代码。(跟上一个类似)

除了指定文件名后缀以外,我们还可以使用构建标签来实现交叉编译。具体来说,就是在文件的第一行添加 // +build 标签,比如:

1
2
3
4
5
// +build windows

package main

const PathSeparator = "\\"

这样的话,这个文件就只会在 Windows 上编译,而在其他系统上不会编译。

使用你本地 GOOS 和 GOARCH 的值进行交叉编译

使用场景:在本地开发环境编译出其他系统的可执行文件。

之前,您运行了 go env GOOS GOARCH 命令来查看您正在使用的操作系统和架构。当您运行 go env 命令时,它会查找两个环境变量 GOOSGOARCH;如果找到,它们的值将被使用,但如果未找到,则 Go 将使用当前平台的信息来设置它们。这意味着您可以更改 GOOSGOARCH,以便它们不会默认为您的本地操作系统和架构。这样就可以编译出其他平台的可执行文件。

go build 命令的行为方式类似于 go env 命令。您可以使用 go build 设置 GOOSGOARCH 环境变量以构建不同平台的应用程序。

如果您没有使用 Windows 系统,请在运行 go build 命令时将 GOOS 环境变量设置为 windows

1
GOOS=windows go build

你也可以同时设置 GOARCH 环境变量:

1
GOOS=linux GOARCH=amd64 go build

这将编译出一个 Linux 平台上的 64 位可执行文件,我们如果使用的是 macOS,我们可以通过 file 命令查看编译出来的文件的信息:

1
2
file main
main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=xx, with debug_info, not stripped

我们会看到这是一个 64 位的 ELF 可执行文件,而 ELF 是 Linux 下的可执行文件格式。

更加现代化的交叉编译方式

我们前面讲了很多如何进行交叉编译,但是如果我们每次都需要针对不同平台来手动编译,未免过于麻烦,当然我们可以写一个脚本来自动化这个过程。

这一小节,我将介绍一个比较好用的交叉编译工具 goreleaser,我们只需要简单的配置一下,它就可以帮我们自动化交叉编译的过程。 比如 frp 这个开源项目就是使用 goreleaser 来进行发布新版本的。

下面是一个示例配置文件:

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
# .goreleaser.yml,放在项目根目录下
# 项目名称
project_name: goss
# 在执行前需要执行的命令
before:
hooks:
- go mod tidy
# 编译配置
builds:
- env:
# 可以指定环境变量
- CGO_ENABLED=0
goos: # 需要编译的操作系统
- linux
- windows
- darwin
archives:
- replacements: # 将 GOARCH 替换,因为用户更熟悉 x86_64
386: i386
amd64: x86_64
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'

说明:

  • project_name 是项目的名称
  • before 是在执行前需要执行的命令
  • builds 是编译配置,env 是环境变量,goos 是需要编译的操作系统
  • archives 是归档配置,replacements 是将 GOARCH 替换。
  • checksum 是生成 checksum 的配置

接着我们只需要执行 goreleaser build 命令即可进行编译:

goreleaser 的安装方式可参考它的官网。

1
goreleaser build

输出:

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
• starting build...
• loading config file file=.goreleaser.yaml
• loading environment variables
• getting and validating git state
• building... commit=e913b9258e649f8f2784d9daaebbf3a4d7cf7a17 latest tag=v0.0.9
• parsing tag
• setting defaults
• running before hooks
• running hook=go mod tidy
• running hook=go generate ./...
• checking distribution directory
• loading go mod information
• build prerequisites
• writing effective config file
• writing config=dist/config.yaml
• generating changelog
• writing changelog=dist/CHANGELOG.md
• building binaries
• building binary=dist/goss_windows_amd64_v1/goss.exe
• building binary=dist/goss_darwin_arm64/goss
• building binary=dist/goss_windows_arm64/goss.exe
• building binary=dist/goss_darwin_amd64_v1/goss
• building binary=dist/goss_linux_amd64_v1/goss
• building binary=dist/goss_linux_arm64/goss
• building binary=dist/goss_windows_386/goss.exe
• building binary=dist/goss_linux_386/goss
• took: 39s
• storing release metadata
• writing file=dist/artifacts.json
• writing file=dist/metadata.json
• build succeeded after 39s

编译完成后,我们会在 dist 目录下看到编译好的文件:

1
ls -l dist/

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
total 32
-rw-r--r-- 1 ruby staff 36 Feb 3 11:12 CHANGELOG.md
-rw-r--r-- 1 ruby staff 1803 Feb 3 11:13 artifacts.json
-rw-r--r-- 1 ruby staff 3509 Feb 3 11:12 config.yaml
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_darwin_amd64_v1
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_darwin_arm64
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_linux_386
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_linux_amd64_v1
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_linux_arm64
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_windows_386
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_windows_amd64_v1
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_windows_arm64
-rw-r--r-- 1 ruby staff 219 Feb 3 11:13 metadata.json

接着,我们就可以来发布这些二进制文件了。如果我们有其他个性化的需求,我们可以通过修改 .goreleaser.yml 文件来满足我们的需求。它还有很多配置可以自定义。 如果后续我们需要调整,只需要修改一下配置文件就行了,比如我们需要支持一个新的操作系统,只需要在 goos 下面增加一个新的操作系统即可。

使用 goreleaser 进行交叉编译的好处是,它会自动帮我们打包、生成 checksum、生成 changelog 等等,省去了很多手动操作。

另外,它还支持直接发布到 Github,使用 Github Actions 来自动化这个过程,这样我们只需要 push 代码,就可以自动进行编译、打包、发布。

下面是一个 github workflow 的示例配置文件:

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
name: goreleaser

on:
push:
# run only against tags
tags:
- '*'

permissions:
contents: write
# packages: write
# issues: write

jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
-
name: Fetch all tags
run: git fetch --force --tags
-
name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.19
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
distribution: goreleaser
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

这个配置文件的意思是,当我们 push tag 的时候,就会触发这个 workflow,它会自动运行 goreleaser,然后进行编译、打包、发布。

总结

Go 支持我们很方便的进行交叉编译,只需要设置好环境变量或者设置构建标签即可:

  • 环境变量:GOOSGOARCH
  • 文件名后缀:filename_GOOS_GOARCH.go
  • 构建标签:// +build 标签

另外,我们还可以使用 goreleaser 这个工具来自动化交叉编译的过程,它还支持直接发布到 Github,使用 Github Actions 来自动化这个过程,这样我们只需要 push tag,就可以自动进行编译、打包、发布。

Go 语言中的泛型是指一种语言特性,允许创建可以处理不同类型的函数、数据结构和接口。换句话说,泛型使得可以创建不受特定类型或数据结构限制的代码。如果我们此前有使用 Java 或者 C++ 的经验,那么会很好理解。

在 Go 语言引入泛型之前,开发人员必须编写多个函数来处理不同类型的数据。这种方法通常很繁琐,并导致代码重复。有了泛型,开发人员可以编写更简洁和可重用的代码,可以处理不同类型的数据。

Go 语言中的泛型是在 2021 年 2 月发布的 1.18 版本中引入的。Go 语言中的泛型实现是基于类型参数的概念。类型参数是传递给函数或数据结构的类型的占位符,使它们能够处理不同类型的数据。

Go 中的泛型是什么?

泛型是一种代码,允许我们通过改变函数类型来在各种函数中使用它们。泛型的创建是为了使代码独立于类型和函数。

泛型的主要目的是通过添加更少的代码行来实现更大的灵活性。

为了更好地理解,看下面的例子。我们创建一个打印任何类型参数的函数,就像这样:

1
2
3
4
5
func Print(s[] string) {
for _, v := range s {
fmt.Print(v)
}
}

现在,我们突然希望打印一个整数,所以我们相应地改变了代码。

1
2
3
4
5
func Print(s[] int) {
for _, v := range s {
fmt.Print(v)
}
}

但是每次像这样更改代码可能看起来令人生畏,这就是泛型发挥作用的地方。通过将任何类型分配给其泛型形式,我们可以将相同的代码用于不同的函数。看一下这个:

1
2
3
4
5
func Print[T any](s[] T) {
for _, v := range s {
fmt.Print(v)
}
}

在这里,我们将 "T" 定义为 any 类型。这个任意类型允许我们在同一个函数中解析不同类型的变量。S 是相应的变量,它是 T 类型的一个切片。现在,调用该方法,我们可以在同一个函数中打印一个字符串和一个整数。

1
2
3
4
5
6
func main() {
str := []string{"Hello", "Again Hello"}
intArray := []int{1, 2, 3}
Print(str)
Print(intArray)
}

Go 中的泛型是如何工作的?

Go 中的泛型是使用类型参数实现的,它允许创建可以在不同类型上操作的泛型函数和数据结构,而无需显式类型转换。

考虑以下示例,其中类型参数 “T” 是使用 “any” 关键字定义的,该关键字指定该函数可以与任何类型一起使用。

1
2
3
func Swap[T any](a, b * T) {
*a, *b = *b, *a
}

函数体然后执行传入的两个指针指向的值的简单交换。

当函数被调用时,编译器为与函数一起使用的类型生成特定版本的函数。例如,如果函数被用于两个整数指针,编译器会生成一个操作整数的函数版本。

类型参数是什么?

在 Go 中,类型参数是使用方括号括起的类型参数列表来指定的,紧跟在函数、数据结构或接口名称之后。类型参数由单个大写字母或一系列大写字母表示,并用尖括号括起来。

类型参数用于在 Go 中创建通用函数、数据结构和接口。类型参数是在编译时确定的类型的占位符。

1
2
3
4
5
6
7
// 这里的 T 是类型参数,any 是类型约束;
// 这里表示 T 可以是任何类型。
func Print[T any](s []T) {
for _, v := range s {
fmt.Print(v)
}
}

使用:

1
2
3
4
5
6
func main() {
str := []string{"Hello", "Again Hello"}
intArray := []int{1, 2, 3}
Print(str)
Print(intArray)
}

例如,考虑上面的示例,显式了使用类型参数的函数声明。在这个函数中,类型参数由大写字母 "T" 表示。"any" 关键字表示函数可以使用任何类型。当调用此函数时,类型参数将被替换为传递给函数的实际类型。

类型参数使得在 Go 语言中可以创建更通用和可重用的代码,因为它允许函数和数据结构可以处理不同类型的数据。

在泛型中使用类型参数

在上面的例子中,我们看到了如何在同一个函数下结合多种类型的变量。

在这个例子中,使用 "any" 关键字声明了一个带有类型参数 "T" 的函数。"any" 关键字表示该函数可以处理任何类型。该函数以类型 "T" 的切片作为参数,并打印其内容。

T 是类型参数,any 是类型约束;这里表示 T 可以是任何类型。

要使用此功能,您可以使用下面给出的任何类型的切片来调用它:

1
2
3
4
5
6
7
8
intSlice := []int{
1, 2, 3, 4, 5,
}
stringSlice := []string{
"apple", "banana", "cherry",
}
Print(intSlice) // prints 1 2 3 4 5
Print(stringSlice) // prints apple banana cherry

在这个例子中,Print 函数被调用时使用了整数切片和字符串切片。类型参数 "T" 被实际传递给函数的参数类型所替换。

您还可以使用类型参数在 Go 中创建通用数据结构和接口。以下是一个使用类型参数的通用数据结构示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Stack[T any] struct {
items []T
}

func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() T {
if len(s.items) == 0 {
panic("stack is empty")
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item
}
  • 在这里,使用 “any” 关键字声明了带有类型参数 “T” 的栈数据结构。
  • Push 方法接受类型为 "T" 的项目作为参数,并将其添加到栈中。
  • Pop 方法从栈顶返回一个类型为 "T" 的项目。

要使用这种数据结构,您可以创建任何类型的栈:

1
2
3
4
5
6
7
8
9
10
intStack := &Stack[int]{}
stringStack := &Stack[string]{}
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
stringStack.Push("apple")
stringStack.Push("banana")
stringStack.Push("cherry")
fmt.Println(intStack.Pop()) // prints 3
fmt.Println(stringStack.Pop()) // prints cherry

在这个例子中,创建了两个栈,一个是 int 类型,另一个是 string 类型。类型参数 “T” 被替换为创建栈的实际类型。

类型约束

泛型中的类型约束定义了可以与泛型函数或数据结构一起使用的类型集合。类型约束允许编译器强制执行类型安全,并确保只有兼容的类型与泛型结构一起使用。

类型约束使用 "interface" 关键字指定,后跟接口的名称和类型必须实现的方法。例如,考虑以下使用类型约束的通用函数:

1
2
3
4
5
6
func Equal[T comparable](a, b T) T {
if a == b {
return a
}
return b
}

在这个例子中,类型参数 "T" 受到 "comparable" 接口的约束,该接口要求类型可以进行 ==!= 比较。这确保了函数只能被支持比较的类型调用。

comparable 是一个内置接口,用于将泛型类型参数限制为仅支持比较运算符(!= ,和 ==)的类型。

comparable 接口是由 Go 语言规范隐式定义的,并不需要在代码中显式定义。这意味着任何支持比较运算符的类型都可以作为 Equal 函数的类型参数,而无需额外声明 comparable 接口。

类型约束也可以是用户定义的接口,它允许对可以与通用函数或数据结构一起使用的类型进行更具体的约束。例如,考虑以下用户定义的接口:

1
2
3
4
5
6
type Number interface {
Add(other Number) Number
Sub(other Number) Number
Mul(other Number) Number
Div(other Number) Number
}

该接口定义了一组方法,一个类型必须实现这些方法才能被视为 “Number”。使用该接口作为类型约束的泛型函数或数据结构只能与实现了这些方法的类型一起使用,确保类型安全和兼容性。

Go 中的泛型类型约束提供了一种确保类型安全并限制可以与泛型结构一起使用的类型集的方法,同时仍然允许泛型提供的灵活性和可重用性。

在 Golang 中使用泛型的示例

这里有一些在Go中使用泛型的例子:

通用函数

该函数接受任何类型 T 的切片和类型 T 的值,并返回该值在切片中的索引。类型参数中的 any 关键字指定可以使用任何类型。

1
2
3
4
5
6
7
8
func findIndex[T any](slice []T, value T) int {
for i, v := range slice {
if reflect.DeepEqual(v, value) {
return i
}
}
return -1
}

通用类型

这定义了一个通用的栈类型,可以保存任何类型 T 的元素。关键字 any 指定任何类型都可以用作元素类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Stack[T any] []T

func (s *Stack[T]) Push(value T) {
*s = append(*s, value)
}

func (s *Stack[T]) Pop() T {
if len(*s) == 0 {
panic("Stack is empty")
}
value := (*s)[len(*s)-1]
*s = (*s)[:len(*s)-1]
return value
}

类型参数的约束

这定义了对类型参数 T 的类型约束,要求其实现 Equatable 接口。这允许 findIndex 函数使用 Equals 方法来比较类型T的值。

1
2
3
4
5
6
7
8
9
10
11
12
type Equatable interface {
Equals(other interface{}) bool
}

func findIndex[T Equatable](slice []T, value T) int {
for i, v := range slice {
if v.Equals(value) {
return i
}
}
return -1
}

支持多种数据类型的加法

让我们编写一个函数 SumGenerics ,它对各种数值类型进行加法操作,比如 intint16int32int64int8float32float64

1
2
3
4
5
6
7
8
9
10
func SumGenerics[T int | int16 | int32 | int64 | int8 | float32 | float64](a, b T) T {
return a + b
}

func main() {
sumInt := SumGenerics[int](2, 3) // returns 5
sumFloat := SumGenerics[float32](2.5, 3.5) // returns 6.0
sumInt64 := SumGenerics[int64](10, 20) // returns 30
println(sumInt, sumFloat, sumInt64)
}

在上面的代码中,我们可以看到,在调用泛型函数时通过在方括号 [] 中指定类型参数,我们可以对不同的数值类型执行加法操作。类型约束确保只有指定的类型 [T int, int16, int32, int64, int8, float32, or float64] 可以用作类型参数。

map 中的泛型

map 的泛型需要两种类型,一个 key 类型和一个 value 类型。值类型没有任何限制,但键类型应该始终满足 comparable 约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// keys 返回一个 map 的所有 key
// m 参数是使用了 K 和 V 泛型的 map
// K 是使用了 comparable 约束的泛型,也就是说 K 必须支持 != 和 == 操作
// V 是使用了 any 约束的泛型,也就是说 V 可以是任意类型
func keys[K comparable, V any](m map[K]V) []K {
// 创建一个长度为 map 长度的 K 类型的 slice
key := make([]K, len(m))
i := 0
for k, _ := range m {
key[i] = k
i++
}
return key
}

结构体中的泛型

Go 允许使用类型参数定义 struct 。语法类似于泛型函数。类型参数可用于结构体上的方法和数据成员。

1
2
3
4
5
6
7
8
9
10
11
12
// T 是类型参数,使用了 any 约束
type MyStruct[T any] struct {
inner T
}

// 在 struct 方法中不允许使用新的类型参数
func (m *MyStruct[T]) Get() T {
return m.inner
}
func (m *MyStruct[T]) Set(v T) {
m.inner = v
}

在结构体方法中不允许定义新的类型参数,但在结构体定义中定义的类型参数可以在方法中使用。

多个泛型参数

泛型可以嵌套在其他类型中。在函数或结构中定义的类型参数可以传递给具有类型参数的任何其他类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 拥有两个泛型类型的泛型 struct
type Entries[K comparable, V any] struct {
Key K
Value V
}

// entries 函数返回一个 Entries 的 slice,代表了传入的 map 的所有 key 和 value
// K 和 V 是泛型类型参数,K 有 comparable 约束,V 没有约束
func entries[K comparable, V any](m map[K]V) []*Entries[K, V] {
// 创建一个 Entries 类型的 slice,传入 K 和 V 类型参数
e := make([]*Entries[K, V], len(m))
i := 0
for k, v := range m {
// 定义一个 Entries 类型的变量
newEntry := new(Entries[K, V])
newEntry.Key = k
newEntry.Value = v
e[i] = newEntry
i++
}
return e
}

我们可以通过逗号分隔多个类型参数来实现多个泛型参数。

类型并集

我们知道,在以往的 interface 定义中,往往都是只包含了方法定义的,如下面这样:

1
2
3
type Stringer interface {
String() string
}

而现在,我们还可以在 interface 中定义多个类型,如下面这样:

1
2
3
type Number interface {
int | int8
}

这种带有类型的 interface 可以帮助我们写出更加简洁的泛型代码,因为它可以用一个 intreface 来表示多个不同的相似类型。 但是这种带有类型的接口,不能用于定义变量,只能用于泛型的类型约束中。

在上面的泛型加法实现中,我们使用了 [T int | int16 | int32 | int64 | int8 | float32 | float64] 这种方式来给 T 定义了一个约束, 但是这种方式并不是很优雅,我们可以将约束定义为一个 interface,然后将 interface 作为约束。

我们称通过 | 连接的多个类型的 interface 为类型并集。

1
2
3
type Number interface {
int | int8 | int16 | int32 | int64 | float32 | float64
}

使用 Number 来作为泛型的约束:

1
2
3
4
5
6
7
8
// T 可以是任意 int 或 float 类型
// T 只能是支持算术运算的类型
func Min[T Number](x, y T) T {
if x < y {
return x
}
return y
}

使用多种类型的联合允许执行这些类型支持的常见操作,并编写适用于联合中所有类型的代码。

这些只是一些示例,说明了在 Go 中如何使用泛型来编写更灵活、可重用的代码。

类型交集

类似的,还有一种类型交集的概念,它是通过在 interface 中写多行类型来实现的:每一行定义了一种或多种类型的并集。

1
2
3
4
5
6
7
type AllInt interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type Uint interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

在上面的代码中,AllInt 是一个类型并集,它包含了所有整数类型。Uint 是一个类型并集,它包含了所有无符号整数类型。

下面是一个使用类型交集的例子:

1
2
3
4
5
6
// 取 AllInt 和 Uint 的交集
// 也就是:~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
type Int interface {
AllInt
Uint
}

其实它的最终的结果等同于:

1
2
3
type Int interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

除此之外,如果其中不同行之间没有任何交集,那么它们的交集就是空集。在现实中可能意义不大。

泛型接口和泛型结构体

在 Go 中,structinterface 都可以使用泛型。

例如,在下面的代码片段中,类型参数 T 的任何值只支持 String 方法 - 您可以使用 len() 或对其进行任何其他操作。

1
2
3
4
5
6
7
8
9
// Stringer 是一个约束
type Stringer interface {
String() string
}

// T 需要实现 Stringer 接口,T 只能执行 Stringer 接口中定义的操作
func stringer[T Stringer](s T) string {
return s.String()
}

再比如,下面的例子中,是一个使用了泛型的 struct

1
2
3
4
5
6
7
type Person[T int] struct {
age T
}

func (p Person[T]) Age() T {
return p.age
}

使用这个 struct

1
2
3
var p Person[int]
p.age = 10
fmt.Println(p.Age()) // 10

使用 ~ 指定底层类型

在 Go 中,定义了一个 cmp.Ordered 接口:

1
2
3
4
5
6
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}

这个声明表示 Ordered 是所有整数、浮点数、和字符串类型的集合。

对于类型约束,我们通常不关心特定类型,比如 string,我们对所有字符串类型感兴趣,所以我们使用 ~string 来表示所有字符串类型的集合。 ~string 表达式表示所有底层类型为 string 的类型的集合,这包括类型 string 本身以及所有使用如 type MyString string 声明定义的类型。

下面是一个错误的例子:

1
2
3
4
5
6
7
8
type Slice[T int] struct {
}

var s1 Slice[int] // 正确

type MyInt int
// 错误。MyInt 类型底层类型是 int 但并不是 int 类型,不符合 Slice[T] 的类型约束
var s2 Slice[MyInt]

正确的做法是,将 Slice 的类型约束修改为 ~int

1
2
3
4
5
6
7
8
9
// T 的底层类型是 int 即可,不一定是 int 类型
type Slice[T ~int] struct {
}

var s1 Slice[int] // 正确

type MyInt int
// 错误。MyInt 类型底层类型是 int 但并不是 int 类型,不符合 Slice[T] 的类型约束
var s2 Slice[MyInt]

使用 ~ 有个限制:

  • ~ 后面的类型不能为接口
  • ~ 后面的类型必须为基础类型

比如,下面是一个错误的例子:

1
2
3
// 错误:Invalid use of ~ ('cmp.Ordered' is an interface)
type Ab[T ~cmp.Ordered] struct {
}

泛型的限制

尽管 Go 语言中的泛型带来了许多好处和新的可能性,但它们的实现仍然存在一些限制和挑战。以下是 Go 语言中泛型的一些主要限制:

  • 性能:在 Go 语言中,泛型的一个主要问题是对性能的潜在影响。引入泛型后,Go 编译器需要在编译时为不同类型生成代码,这可能导致更大的二进制文件和更慢的编译时间。
  • 类型约束:Go 语言的泛型实现依赖于类型约束来确保类型安全。然而,这些约束可能会限制可以与泛型函数和数据结构一起使用的类型。
  • 语法复杂性:声明和使用泛型函数和数据结构的语法可能会很复杂,尤其对于初学者来说难以理解。
  • 错误消息:Go 编译器生成的与泛型相关的问题的错误消息可能难以理解,使得调试和故障排除更具挑战性。
  • 代码可读性:在 Go 中,泛型有时会使代码变得不太易读,更难理解,特别是在大量使用类型约束和类型参数的情况下。
  • 无法进行切换:当您想要从一个基础泛型类型切换到另一个时,使用泛型是不可能的。唯一的方法是使用接口,并在运行时运行类型切换函数。

总结

泛型为创建通用接口、结构体和函数提供了一种强大而简单的方法。

它们可以减少冗余信息,并且至少在某些情况下,提供了一种比反射更优越的替代方案。

当然,长时间以来,泛型受到激烈反对的主要原因是它们可能使代码更难阅读和解析,这似乎与 Go 语言的简洁性相悖。 鉴于此,本文也不会介绍太多复杂的泛型用法,上面提到的这些用法应该可以覆盖 90% 以上的使用场景了,因为复杂的代码必然会牺牲不少代码的可维护性。

另一方面,泛型是语言中的一个很好且必要的补充,如果明智地使用并且在有意义的地方使用的话。

0%