普通视图

发现新文章,点击刷新页面。
昨天以前Terrarum::异世界丨居正博客

逆向飞书思维导图 Protobuf,获取含层级关系的文本(借助 Claude Code)

2025年12月17日 16:04
Featured image of post 逆向飞书思维导图 Protobuf,获取含层级关系的文本(借助 Claude Code)

最近有一个项目重度依赖飞书的思维导图,大概是这样的一个东西:

PixPin_2025-12-17_16-08-52

好处是协作方便,自定义 style 直观快捷;坏处是与纯文本八字不合。

虽然能导出 pdf,但层级关系完全丧失了。

能理解为什么不使用 mermaid 之类的标准格式…但在这个 AI 时代,这样的东西真的很难发给 AI。

用截图的话,小一点的图还好,大图就完全乏力了。而该项目几个月来产生的思维导图恰恰是那种超大型的。

于是想着是否可以通过逆向的方式,提取出纯文本。目标是包含层级关系,最好输出为 markdown、yaml 或者 json 等易读的格式。

获取 block 文件

因为网页端是直接通过 canvas 渲染的,所以从 DOM 里什么也看不出来。

问了问 AI 才恍然大悟,原来还可以抓包呀!

PixPin_2025-12-17_19-40-25

原谅自己最近 AI 用得太多,思考能力减退了…

于是 F12 抓包:

PixPin_2025-12-17_16-12-33

这一步其实很简单,通过浏览器控制台抓一下包就能发现是个二进制。

二进制逆向确实有点困难。

不过好在现在有 AI…之前一直在用 Cursor,现在感觉 Claude Code 更加聪明。

应该说 GPT 在 Codex 上最聪明,Claude 在 Claude Code 中使用最聪明,而 Cursor…就两个模型局限都比较大。

让 Claude Code 直接分析二进制

最开始的话,其实没有什么想法。

甚至都不知道这个二进制是 protobuf,而且还是直接把整个项目的巨大的思维导图 block 丢给它。

单纯把二进制文件保存下来,然后随便截了一张截图丢给 Claude,让它自己去分析。

不过让 AI 拟定计划后再执行,每一步都形成文档,光是这一点还是知道的。

详见:https://github.com/tukuaiai/vibe-coding-cn

prompt:

1
2
3
4
whiteboard/whiteboard 这是飞书思维导图的二进制文件 [Image #1]
你尝试去逆向一下这个文件还原原始的思维导图,最好以JSON或markdown形式提供。可以使用任何语言写任何脚本、做测试之
类的。反复尝试和迭代。先拟定plan,形成plan的文档以及todolist。每进行一步都要更新文档和目前已知的信息和做的尝试
等等,然后确定下一步计划,不断往前推进。工作区限定在 whiteboard/ 文件夹下

PixPin_2025-12-17_16-17-35

结果是 Claude 调用工具一通分析,勉强糊出来一个 parser 的 Python 程序,采用的是手写解析二进制的方法。

输出的结果很难看,各种 padding 都不对,文字和乱码掺杂着。

这样的结果肯定不能用。

不过至少知道了文件格式是 protobuf(由 Claude 通过二进制特征发现)。

逆向 proto 失败,转为借助 blackboxprotobuf

稍微谷歌一下,发现有一个叫做 blackboxprotobuf 的 Python 库,就是用来干这个的。

https://github.com/nccgroup/blackboxprotobuf/blob/master/lib/CLI.md

其中提供一个 bbpb 的二进制程序,可以从二进制中读取 protobuf 的结构。

尝试着对二进制运行了一下:

PixPin_2025-12-17_16-25-27

输出的文件巨大,如上画风的有几千行。

感觉对 AI 太不友好了,让 Claude 直接去读取这个文件分析特征,得爆上下文了吧。

归根到底还是使用的二进制文件太大。既然是逆向,就应该做一个最小的实现的 example 文件,对分析友好一点。

于是就有了开头那张图,先自己做一个小一点的思维导图,把二进制下载下来。

只是这种程度的话,大概还是可以接收的:

PixPin_2025-12-17_16-51-37

这次运行的时候也写一个 CLAUDE.md 文件吧:

 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
# 飞书whiteboard二进制格式逆向

目标:思维导图,需要逆向出来纯文本,要保留内容和层次关系。字体、边框样式这些都可以舍弃。

思维导图里面的文本有中文和英文,大概率是utf8

必须读一下看看思维导图在飞书上显示的是啥样:PixPin_2025-12-17_11-47-29.jpg

源文件:block

工作区限定为:whiteboard目录(当前README文件的目录)

目前已知是用protobuf编码的

注意我们的pip环境安装的软件要带前缀才能执行:

```bash
cat block | /Users/anon/.local/share/uv/python/cpython-3.13.5-macos-aarch64-none/bin/bbpb -ot ./types.json
```

保存在 types.json

你在需要时候可以去网上搜索、git clone、pip install、npm install -g、brew install等方式安装使用任何工具

要求:看看能不能逆向出proto文件,用protobuf的方式去decode它,不要手写二进制读取。不断编程和测试,小步迭代。每一步都要有todolist,每一步进行完成后要写文档

因为之前被朋友提醒说,Claude 有时候不提醒它的话,它就不知道可以用某些工具(比如 git clone 等等)。所以也明确写一下。

以及因为思维导图变小了,可以把整张图的截图也顺便发给 Claude,让它对导图的结构更加胸有成竹一点。

然后让 Claude 直接开始任务:

PixPin_2025-12-17_16-29-08

其实最开始想的是让 Claude 从 bbpb 生成的 types 中直接洞察出原始的 proto 文件,然后我们也直接用 protobuf 正统的那一套进行 decode。

但实际上这样还是有点天马行空,毕竟原始的 proto 文件可能很复杂。而 protobuf 这种格式稍微错一点整个就解析失败了。

实际上,Claude 在执行过程中自己会发现问题所在,自动转为使用 blackboxprotobuf 这个库:

PixPin_2025-12-17_16-30-55

确实好聪明。

经过几次改错后把 example 的思维导图完美提取出来了:

PixPin_2025-12-17_16-32-55

优化脚本

最后把我们项目原本的思维导图 protobuf 二进制文件,用这个脚本运行。

顺便让 Claude 加上输出 markdown、yaml 等能力。

遇到了一些问题,诸如 yaml 格式只能提取出一个父节点而忽略其他的、markdown 格式有换行问题、可能存在节点循环引用导致卡死等等。

都一一让 Claude 解决了。

输出的 yaml 是这种感觉:

PixPin_2025-12-17_16-48-37

最后的成品姑且贴上,留一个备份:

  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
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
#!/usr/bin/env python3
"""
Feishu Whiteboard Mindmap Extractor
Extract text content and hierarchy from Feishu whiteboard binary format
"""
import json
import blackboxprotobuf
import sys
import yaml
import argparse

def decode_whiteboard(filename):
    """Decode whiteboard binary file using blackboxprotobuf"""
    with open(filename, 'rb') as f:
        data = f.read()

    message, typedef = blackboxprotobuf.protobuf_to_json(data)
    return json.loads(message)

def get_text(node_data):
    """Extract text from node"""
    try:
        return node_data['2']['2']['33']['1']
    except (KeyError, TypeError):
        return None

def get_parent(node_data):
    """Extract parent ID from node"""
    try:
        return node_data['2']['2']['61']['1']
    except (KeyError, TypeError):
        return None

def build_tree(data):
    """Build tree structure from decoded data"""
    nodes_dict = {}

    # Add simple nodes (field 1)
    if '1' in data['3']:
        for node in data['3']['1']:
            node_id = node['1']
            nodes_dict[node_id] = {
                'id': node_id,
                'text': None,
                'parent': None,
                'children': []
            }

    # Add full nodes (field 2)
    if '2' in data['3']:
        for node in data['3']['2']:
            node_id = node['1']
            nodes_dict[node_id] = {
                'id': node_id,
                'text': get_text(node),
                'parent': get_parent(node),
                'children': []
            }

    # Build parent-child relationships
    for node_id, node_info in nodes_dict.items():
        if node_info['parent'] and node_info['parent'] in nodes_dict:
            parent = nodes_dict[node_info['parent']]
            parent['children'].append(node_id)

    # Find root nodes (nodes without parent and with text content)
    root_nodes = [nid for nid, info in nodes_dict.items()
                  if not info['parent'] and info['text']]

    return nodes_dict, root_nodes

def print_tree(node_id, nodes_dict, indent=0, prefix="", visited=None):
    """Recursively print tree structure"""
    # Initialize visited set on first call
    if visited is None:
        visited = set()

    # Check for cycles
    if node_id in visited:
        print(f"{prefix}[CYCLE: {node_id}]")
        return

    # Check if node exists
    if node_id not in nodes_dict:
        print(f"{prefix}[MISSING: {node_id}]")
        return

    visited.add(node_id)
    node = nodes_dict[node_id]
    text = node['text'] or node['id']

    if indent == 0:
        print(f"{text}")
    else:
        print(f"{prefix}{text}")

    # Print children
    children = node['children']
    for i, child_id in enumerate(children):
        is_last = (i == len(children) - 1)
        if indent == 0:
            child_prefix = "├── " if not is_last else "└── "
            continuation = "│   " if not is_last else "    "
        else:
            child_prefix = prefix.replace("├── ", "│   ").replace("└── ", "    ")
            child_prefix += "├── " if not is_last else "└── "
            continuation = prefix.replace("├── ", "│   ").replace("└── ", "    ")
            continuation += "│   " if not is_last else "    "

        print_tree(child_id, nodes_dict, indent + 1, child_prefix, visited.copy())

def export_text(node_id, nodes_dict, indent=0, visited=None):
    """Export as indented text"""
    # Initialize visited set on first call
    if visited is None:
        visited = set()

    # Check for cycles
    if node_id in visited:
        prefix = "  " * indent
        print(f"{prefix}[CYCLE: {node_id}]")
        return

    # Check if node exists
    if node_id not in nodes_dict:
        prefix = "  " * indent
        print(f"{prefix}[MISSING: {node_id}]")
        return

    visited.add(node_id)
    node = nodes_dict[node_id]
    text = node['text'] or node['id']

    prefix = "  " * indent
    print(f"{prefix}{text}")

    # Export children
    for child_id in node['children']:
        export_text(child_id, nodes_dict, indent + 1, visited.copy())

def export_markdown_list(node_id, nodes_dict, indent=0, visited=None, output_lines=None):
    """Export as markdown list format"""
    # Initialize on first call
    if visited is None:
        visited = set()
    if output_lines is None:
        output_lines = []

    # Check for cycles
    if node_id in visited:
        prefix = "  " * indent
        output_lines.append(f"{prefix}- [CYCLE: {node_id}]")
        return output_lines

    # Check if node exists
    if node_id not in nodes_dict:
        prefix = "  " * indent
        output_lines.append(f"{prefix}- [MISSING: {node_id}]")
        return output_lines

    visited.add(node_id)
    node = nodes_dict[node_id]
    text = node['text'] or node['id']

    prefix = "  " * indent
    if indent == 0:
        output_lines.append(f"# {text}")
    else:
        output_lines.append(f"{prefix}- {text}")

    # Export children
    for child_id in node['children']:
        export_markdown_list(child_id, nodes_dict, indent + 1, visited.copy(), output_lines)

    return output_lines

def export_yaml(node_id, nodes_dict, visited=None):
    """Export as YAML format with multiline strings"""
    # Initialize visited set on first call
    if visited is None:
        visited = set()

    # Check for cycles
    if node_id in visited:
        return {"error": f"CYCLE: {node_id}"}

    # Check if node exists
    if node_id not in nodes_dict:
        return {"error": f"MISSING: {node_id}"}

    visited.add(node_id)
    node = nodes_dict[node_id]
    text = node['text'] or node['id']

    result = {"text": text}

    # Export children if any
    if node['children']:
        result['children'] = []
        for child_id in node['children']:
            child_data = export_yaml(child_id, nodes_dict, visited.copy())
            result['children'].append(child_data)

    return result

def main():
    parser = argparse.ArgumentParser(
        description='Extract mindmap from Feishu Whiteboard binary format',
        formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument('file', help='Input block file')
    parser.add_argument('--format', choices=['tree', 'simple', 'markdown', 'yaml'],
                        default='tree',
                        help='Output format (default: tree)')

    args = parser.parse_args()

    try:
        # Decode the file
        data = decode_whiteboard(args.file)

        # Build tree structure
        nodes_dict, root_nodes = build_tree(data)

        if not root_nodes:
            print("Error: No root node found")
            sys.exit(1)

        # Determine output file name
        output_file = None
        if args.format == 'markdown':
            output_file = 'out.md'
        elif args.format == 'yaml':
            output_file = 'out.yaml'

        # Collect output
        output_lines = []

        # Collect data for YAML format
        yaml_data_list = []

        # Export based on format
        for i, root_id in enumerate(root_nodes):
            if args.format == 'tree':
                # For tree format, print directly (no file save)
                print_tree(root_id, nodes_dict)
            elif args.format == 'simple':
                # For simple format, print directly (no file save)
                export_text(root_id, nodes_dict)
            elif args.format == 'markdown':
                # Add blank line between root sections (except for the first one)
                if i > 0:
                    output_lines.append('')
                lines = export_markdown_list(root_id, nodes_dict)
                output_lines.extend(lines)
            elif args.format == 'yaml':
                yaml_data = export_yaml(root_id, nodes_dict)
                yaml_data_list.append(yaml_data)

        # Print and save for markdown/yaml formats
        if args.format in ['markdown', 'yaml']:
            if args.format == 'markdown':
                full_output = '\n'.join(output_lines)
            else:
                # Dump all root nodes together as a YAML list
                full_output = yaml.dump(yaml_data_list, allow_unicode=True, default_flow_style=False, sort_keys=False)

            # Print to stdout
            print(full_output)

            # Save to file
            if output_file:
                with open(output_file, 'w', encoding='utf-8') as f:
                    f.write(full_output)
                print(f"\nOutput saved to {output_file}", file=sys.stderr)

    except Exception as e:
        print(f"Error: {e}")
        import traceback
        traceback.print_exc()
        sys.exit(1)

if __name__ == "__main__":
    main()

美中不足的是,使用 blackboxprotobuf 处理比较大的文件,确实太慢了。

但其他语言上似乎也没有比较好用的、能灵活处理 protobuf 的工具。

以及,如果飞书以后更新 protobuf 的结构的话,恐怕现有的脚本就不能用了?但 protobuf 应该还好,毕竟是向后兼容的。

而且反正还可以再用 AI 改。

秉着「能用就行」的想法,在这收尾了。

总结

又一次深刻感受到了 AI 时代工作效率上的提升。

这种程度的二进制逆向,虽然也并不是特别复杂,但在以前的话,自己再怎么也得花一整天吧。

而如今直接让 Claude 帮忙解决了,自己仅仅做了一些判断和引导。

甚至都不太理解代码是怎么跑起来的,主打一个氛围(vibe)。(笑)

感觉最难的问题还是有关上下文的,例如:

  • 如何将复杂的信息转为 AI 能够接收的格式(图片、纯文本)
  • 如何尽量缩减上下文(使用最小可复现模型)

顺便,目前使用的 Claude Code 中转站感觉好慢,希望余额用完后换一个响应快一点的…

以上。

迁移早先用 Oneinstack 安装的 PHP 到 Docker

2025年7月19日 18:16
Featured image of post 迁移早先用 Oneinstack 安装的 PHP 到 Docker

引言

最近需要更新一台老机器的 PHP 版本,目标是从 PHP 7.4 到 PHP 8.2。说是老机器,也确实年代久远,系统甚至还是 Ubuntu 20.04。

当时的 LNMP 一整套是使用 Oneinstack 安装的,彼时还是一个非常不错的开源产品…近年发生了什么事大家也都知道了,也就是Oneinstack 投毒事件

于是更新的话…自然不能用 Oneinstack 的升级脚本了。但由于是老系统,apt 安装多少也有些困难,因此就干脆决定用 Docker 部署吧。

Oneinstack 一整套安装的东西是高度客制化的,路径也是非标准的。比如 php 和 php-fpm 包括其配置文件在/usr/local/php下,采用 unix socket 而非 TCP 来通信(nginx 中也是这样配置的)。以及包括一些扩展,比如 gd、redis、memcached 等等。使用 Docker 进行 PHP 部署的时候,也要将这些纳入考虑。

最终的效果应该是,只有 PHP 相关的东西在容器内运行,MySQL、Nginx 都保持在容器外不变。

Docker 相关

Dockerfile

先在命令行输入php -i查看当前 php 环境,输出的是一大堆和环境有关的描述,相当于控制台模式下的 phpinfo。将这些内容全部复制给 AI,让它构建一个 PHP 8.2 的 Dockerfile。

由于 Oneinstack 安装的 php-fpm 和 nginx 都以 www 用户运行,所以也要把这一点信息告诉 AI。

可以先cat /etc/passwd看一下 www 用户的 ID 号。

我使用的是 Gemini 2.5 Pro,在撰写本文时,算是第一梯队的模型了。

 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
# 使用一个具体的 PHP 8.2 FPM 版本
FROM php:8.2.29-fpm

# 设置环境变量,避免 apt-get 在构建过程中进行交互式提问
ENV DEBIAN_FRONTEND=noninteractive

# 定义构建参数,用于设置用户ID和组ID,默认值为 1001
ARG PUID=1001
ARG PGID=1001

# 1. 安装系统依赖
# 添加了 imagick 和 memcached 所需的开发库
RUN apt-get update && apt-get install -y \
    build-essential \
    pkg-config \
    autoconf \
    gettext \
    libcurl4-openssl-dev \
    libfreetype-dev \
    libicu-dev \
    libjpeg62-turbo-dev \
    # 新增:imagick 依赖
    libmagickwand-dev \
    # 新增:memcached 依赖
    libmemcached-dev \
    libonig-dev \
    libpng-dev \
    libsodium-dev \
    libssl-dev \
    libxml2-dev \
    libxslt-dev \
    libzip-dev \
    # 新增:unzip 工具,常用于处理压缩包
    unzip \
    --no-install-recommends \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# 2. 配置并安装 PHP 扩展
# -j$(nproc) 利用所有 CPU 核心并行编译,加快构建速度
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) \
        bcmath \
        exif \
        ftp \
        gd \
        gettext \
        intl \
        mbstring \
        mysqli \
        pcntl \
        pdo_mysql \
        shmop \
        sockets \
        soap \
        sysvsem \
        xsl \
        zip \
        sodium \
        curl \
    # xmlrpc 在 PHP 8.0+ 中已从核心移除,需要通过 PECL 安装
    && pecl install xmlrpc-beta \
    && docker-php-ext-enable xmlrpc \
    # 显式启用 opcache (在 fpm 镜像中通常默认启用)
    && docker-php-ext-enable opcache

# 3. 安装缺失的 PECL 扩展 (imagick, redis, memcached)
RUN pecl install imagick redis memcached \
    && docker-php-ext-enable imagick redis memcached

# 6. 配置 FPM 用户和组
# 创建 'www' 用户和组,并修改 FPM 配置文件以使用它们
RUN groupadd -g ${PGID} www \
    && useradd -u ${PUID} -g www -s /sbin/nologin www \
    && sed -i 's/user = www-data/user = www/' /usr/local/etc/php-fpm.d/www.conf \
    && sed -i 's/group = www-data/group = www/' /usr/local/etc/php-fpm.d/www.conf

这里 AI 用的是官方的docker-php-ext-configure帮助脚本来安装扩展。其实也可以用这个项目,这样就不用手动写 apt 指令和使用 pecl 安装了。不过这样也就相当于引入另一个开源项目了…

我们希望在容器外部管理 php 相关的配置文件,于是打包镜像之后,然后先随便启动一个容器,把容器内部/usr/local/etc/底下的文件复制出来到容器外部,假设为~/my-php/etc。这些文件包括了 php-fpm 的基础配置,以及安装扩展后自动生成的配置文件。

可以使用docker cp命令来复制。

然后我们需要把 Oneinstack 的php.ini文件同样拷贝到~/my-php/etc/php底下。Oneinstack 的 php.ini 包括了一些常用的配置,比如调整 POST 方法可上传的文件大小、禁用一些不安全的函数等等。

最终的目录应该是长这样子的:

 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
root@witch:~/my-php/etc# tree
.
├── pear.conf
├── php
│   ├── conf.d
│   │   ├── docker-fpm.ini
│   │   ├── docker-php-ext-bcmath.ini
│   │   ├── docker-php-ext-exif.ini
│   │   ├── docker-php-ext-ftp.ini
│   │   ├── docker-php-ext-gd.ini
│   │   ├── docker-php-ext-gettext.ini
│   │   ├── docker-php-ext-imagick.ini
│   │   ├── docker-php-ext-intl.ini
│   │   ├── docker-php-ext-memcached.ini
│   │   ├── docker-php-ext-mysqli.ini
│   │   ├── docker-php-ext-opcache.ini
│   │   ├── docker-php-ext-pcntl.ini
│   │   ├── docker-php-ext-pdo_mysql.ini
│   │   ├── docker-php-ext-redis.ini
│   │   ├── docker-php-ext-shmop.ini
│   │   ├── docker-php-ext-soap.ini
│   │   ├── docker-php-ext-sockets.ini
│   │   ├── docker-php-ext-sodium.ini
│   │   ├── docker-php-ext-sysvsem.ini
│   │   ├── docker-php-ext-xmlrpc.ini
│   │   ├── docker-php-ext-xsl.ini
│   │   └── docker-php-ext-zip.ini
│   ├── php.ini
│   ├── php.ini-development
│   └── php.ini-production
├── php-fpm.conf
├── php-fpm.conf.default
└── php-fpm.d
    ├── docker.conf
    ├── www.conf
    ├── www.conf.default
    └── zz-docker.conf

3 directories, 32 files

docker-compose

接下来是 docker-compose 文件:

 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
services:
  php-fpm:
    image: my-php
    container_name: my-php-fpm-container
    restart: always

    # 卷挂载
    volumes:
      # 1. 挂载网站根目录
      # 将宿主机的 /data/wwwroot 目录挂载到容器内相同的路径
      - /data/wwwroot:/data/wwwroot
      # 2. 挂载配置文件
      # :ro 表示在容器内为只读,这是一个好习惯,防止容器意外修改配置
      - ./etc:/usr/local/etc:ro

      # 3. 挂载 sock 文件目录
      # 将宿主机的 /var/run/php 目录挂载到容器内,用于共享 sock 文件
      # 如果您选择使用 sock 文件方式连接,请使用此项
      - /tmp:/tmp
      - /usr/local/php/var/run/:/usr/local/php/var/run/

    # 设置工作目录,方便执行 docker exec 命令时直接进入网站根目录
    working_dir: /data/wwwroot

    command:
      - /usr/local/sbin/php-fpm
      - --nodaemonize
 

其中/data/wwwroot是网站目录。当 nginx 进行 fastcgi 调用的时候,会把 php 脚本的路径发送给 php-fpm。

挂载/tmp主要是为了让容器内能够访问到/tmp/mysql.sock,以便通过 unix socket 的方式访问数据库。顺便一提,如果在 php 的代码中,数据库地址写127.0.0.1的话,走的是 TCP,而写localhost的话走的就是 unix socket 了。

/usr/local/php/var/run/是我们等下创建 php-fpm 的 sock 文件的地址。

PHP 和 Nginx 的配置修改

PHP

编辑etc/php-fpm.d/zz-docker.conf文件:

1
2
3
4
5
6
7
root@witch:~/my-php# cat etc/php-fpm.d/zz-docker.conf 
[global]
daemonize = no

[www]
listen = /usr/local/php/var/run/sock
listen.mode = 0666

将 listen 字段改成/usr/local/php/var/run/sock,意思是启动时就在/usr/local/php/var/run/下创建一个sock作为名称的 unix socket 文件来监听连接。这样容器外的 nginx 就可以通过这个文件请求容器内部的 php-fpm 服务了。

需要注意区分一下容器的启动用户和 php-fpm 的运行用户。后者我们刚才已经在 Dockerfile 中改成 www 了,但前者还是 root。由于套接字文件本身是以容器的启动用户身份创建的,而外部 nginx 的运行用户是 www,所以 nginx 可能会出现没有执行权限,无法联通的情况。因此listen.mode这一行是必要的。

顺带一提,php-fpm 在加载 php-fpm.d 下的文件时,是按照字母顺序加载的,后加载的文件中如果有相同的配置项,会覆盖掉前面的配置。所以我们编辑的是zz-docker.conf,也就是按照字母顺序最后加载的这个文件。

Nginx

接着修改 nginx 的配置,/usr/local/nginx/conf/vhost/www.xxx.com.conf,定位到相应的行:

1
2
# 原来的:fastcgi_pass unix:/dev/shm/php-cgi.sock;
fastcgi_pass unix:/usr/local/php/var/run/sock;

然后service nginx reload

这样应该就大功告成了,可以使用docker compose up -d启动试试。

其他

如果之后需要为容器增加新的 php 扩展的话,需要重新随便启动一个容器,把这个目录底下的东西复制出来,覆盖掉现有的配置:

 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
root@witch:~/my-php/etc/php/conf.d# tree
.
├── docker-fpm.ini
├── docker-php-ext-bcmath.ini
├── docker-php-ext-exif.ini
├── docker-php-ext-ftp.ini
├── docker-php-ext-gd.ini
├── docker-php-ext-gettext.ini
├── docker-php-ext-imagick.ini
├── docker-php-ext-intl.ini
├── docker-php-ext-memcached.ini
├── docker-php-ext-mysqli.ini
├── docker-php-ext-opcache.ini
├── docker-php-ext-pcntl.ini
├── docker-php-ext-pdo_mysql.ini
├── docker-php-ext-redis.ini
├── docker-php-ext-shmop.ini
├── docker-php-ext-soap.ini
├── docker-php-ext-sockets.ini
├── docker-php-ext-sodium.ini
├── docker-php-ext-sysvsem.ini
├── docker-php-ext-xmlrpc.ini
├── docker-php-ext-xsl.ini
└── docker-php-ext-zip.ini

0 directories, 22 files

因为安装扩展的时候,会创建新的 ini 配置项。如果不复制出来的话,新扩展就无法加载了。

浅谈 Rust 中如何把 Async Function 放在 HashMap 里

2025年4月30日 15:00
Featured image of post 浅谈 Rust 中如何把 Async Function 放在 HashMap 里

需求

经常会有这样的需求,需要根据具体参数的不同调用不同的处理函数。

比如根据 URL 的不同,调用不同的路由 handler;根据 command 的不同,调用不同的处理 handler。

这些需求基本都可以抽象为一件事:把 function 放在 HashMap 里。

如果是异步编程的话,那么就是把 async function 放在 HashMap 里了。

这件事在 Rust 中做起来,要比想象中困难。

今天就来浅谈一下这个主题。

框架

我们希望设计一个路由器结构体,里面放一个 HashMap。

HashMap 以 String 为 key,具体的处理函数为 value。

在初始化的时候把处理函数按不同的 key 放到 HashMap 里,调用时根据不同情况按 key 取出。

简化的话,处理函数就定为接受一个 String 并返回一个 String。

大概是这种感觉:

 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
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::sleep;

type HandlerFn = // TODO 需要写一个 Handler 的具体类型
#[derive(Default, Clone)]
struct Router {
    table: Arc<RwLock<HashMap<String, HandlerFn>>>,
}
impl Router {
    // TODO 需要一个 add 函数
    // async fn add
}
#[tokio::main]
async fn main() {
    let mut router = Router::default();
    router.add("handler1", handler1).await;
    router.add("handler2", handler2).await;
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            handler("req1".into()).await;
        });
        handle
    };
    let handle2 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler2").unwrap();
            handler("req2".into()).await;
        });
        handle
    };
    let _ = tokio::join!(handle1, handle2);
}

async fn handler1(req: String) -> String {
    sleep(Duration::from_secs(1)).await;
    println!("handler1");
    "handler1".into()
}
async fn handler2(req: String) -> String {
    sleep(Duration::from_secs(1)).await;
    println!("handler2");
    "handler2".into()
}

HandlerFn 的类型

我们的 HandlerFn 应该是一个 async 函数。

在 Rust 里 async 函数实际上就是一个普通函数,只不过它需要返回 Future。

我们先来尝试一下这么做:

1
2
3
4
5
type HandlerFn = Fn(String) -> Future<Output = String>;
#[derive(Default, Clone)]
struct Router {
    table: Arc<RwLock<HashMap<String, HandlerFn>>>,
}

输出的错误是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
error[E0782]: expected a type, found a trait
 --> src\bin\attempt1.rs:8:18
  |
8 | type HandlerFn = Fn(String) -> Future<Output = String>;
  |                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
help: you can add the `dyn` keyword if you want a trait object
  |
8 | type HandlerFn = dyn Fn(String) -> Future<Output = String>;
  |                  +++

Fn 和 Future 都是特征,而不是具体类型。

作为 HashMap 的 value 我们需要一个具体的类型。

直接加 dyn 显然是不行的,需要先把它进行装箱。

所以变成了这样子:

1
2
3
4
5
type HandlerFn = Box<dyn Fn(String) -> Box<dyn Future<Output = String>>>;
#[derive(Default, Clone)]
struct Router {
    table: Arc<RwLock<HashMap<String, HandlerFn>>>,
}

这样编译姑且是没问题的。

但这不是最终的类型,之后会说明为什么。

实现 add 函数

接下来需要一个函数把 handler 放到 HashMap 里,供初始化的时候调用。

我们可能会写出这样的代码,这里直接把我们上面定义的 HandlerFn 作为参数:

1
2
3
4
5
6
impl Router {
    async fn add(&mut self, key: &str, handler: HandlerFn) {}
}
...
router.add("handler1", handler1).await;
router.add("handler2", handler2).await;

但是 router.add 调用的时候报错了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
error[E0308]: mismatched types
  --> src\bin\attempt1.rs:26:28
   |
26 |     router.add("handler1", handler1).await;
   |            ---             ^^^^^^^^ expected `Box<dyn Fn(String) -> Box<...>>`, found fn item
   |            |
   |            arguments to this method are incorrect
   |
   = note: expected struct `Box<(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + 'static)>`
             found fn item `fn(String) -> impl Future<Output = String> {handler1}`

因为我们是直接把 async function 传到 add 里面的,async function 又没有装箱,当然不能直接传进去。

那我们应该为 add 函数的 handler 这个参数定一个类型,让它能直接接收 async function 的引用。

参数类型

看编译的报错信息,似乎可以这样写:

1
async fn add(&mut self, key: &str, handler: Fn(String) -> impl Future<Output = String>) {}

很遗憾这样是不行的:

1
2
3
4
5
6
7
error[E0562]: `impl Trait` is not allowed in the return type of `Fn` trait bounds
  --> src\bin\attempt1.rs:14:63
   |
14 |     async fn add(&mut self, key: &str, handler: Fn(String) -> impl Future<Output = String>) {}
   |                                                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: `impl Trait` is only allowed in arguments and return types of functions and methods

这里的 handler 参数的类型被指定为 Fn(String) -> impl Future<Output = String>。这是一个 Trait 约束,它要求 handler 参数的类型必须实现 Fn Trait,并且这个 Fn Trait 的调用签名是接受一个 String 参数,并返回一个实现了 Future<Output = String> Trait 的某个具体但未指定类型

impl Trait 主要用于函数的参数位置(表示接受任何实现了该 Trait 的类型)和函数的返回类型位置(表示返回一个实现了该 Trait 的某个具体类型,但调用者不知道具体是哪个类型,这被称为“不透明返回类型”)。它是一种语法糖,用于避免写出复杂的具体类型名称,尤其是在处理闭包和异步函数返回的 Future 类型时非常方便。

当你在参数位置使用 Fn(Args) -> Return 时,你是在定义一个 Trait 约束,说明参数的类型必须实现 Fn Trait,并且其调用签名符合 (Args) -> Return。这里的 Return 部分实际上是在描述 Fn Trait 的关联类型 Output

impl Future<Output = String> 表示一个不透明的、具体类型。而 Fn Trait 约束的 Return 位置需要的是一个具体的类型或者一个关联类型的定义。你不能在一个 Trait 定义(或者 Trait 约束,它本质上是基于 Trait 定义的)中使用 impl Trait 来表示关联类型,因为 impl Trait 本身不是一个具体的类型名称,它只是一个类型占位符,其具体类型只有实现 Trait 的那个类型才知道。

简单来说,impl Trait 是用来隐藏具体类型的,而 Trait 定义(或 Trait 约束)需要知道它操作的类型是什么(即使是通过关联类型)。你不能说一个 Trait 的关联类型是“某个实现了 Future 的东西”,你必须说它是“一个实现了 Future 的具体类型 MyFuture”或者使用一个泛型参数来代表这个具体类型。

对 handler 本身和返回的 Future 装箱

我们改成用泛型类型来实现:

1
2
3
4
5
6
7
8
9
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: Fn(String) -> Fut,
        Fut: Future<Output = String>,
    {
        self.table.write().await.insert(key.to_string(), handler);
    }
}

这样可以了,但是有新的报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
error[E0308]: mismatched types
    --> src\bin\attempt1.rs:19:58
     |
14   |     async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
     |                  --- found this type parameter
...
19   |         self.table.write().await.insert(key.to_string(), handler);
     |                                  ------                  ^^^^^^^ expected `Box<dyn Fn(String) -> Box<...>>`, found type parameter `Fun`
     |                                  |
     |                                  arguments to this method are incorrect
     |
     = note:      expected struct `Box<(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + 'static)>`
             found type parameter `Fun`

因为 HashMap 的 value 必须是一个 Box 装箱的 Fn,所以直接把 handler 作为 Fn 传进去不行。

那我们用 Box::new 装箱?

1
2
3
4
5
6
7
8
9
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: Fn(String) -> Fut,
        Fut: Future<Output = String>,
    {
        self.table.write().await.insert(key.to_string(), Box::new(handler));
    }
}

还是不行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
error[E0271]: expected `Fun` to be a type parameter that returns `Box<dyn Future<Output = String>>`, but it returns `Fut`
  --> src\bin\attempt1.rs:19:58
   |
14 |     async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
   |                       --- found this type parameter
...
19 |         self.table.write().await.insert(key.to_string(), Box::new(handler));
   |                                                          ^^^^^^^^^^^^^^^^^ expected `Box<dyn Future<Output = String>>`, found type parameter `Fut`
   |
   = note:      expected struct `Box<(dyn Future<Output = String> + 'static)>`
           found type parameter `Fut`
   = note: required for the cast from `Box<Fun>` to `Box<(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + 'static)>`

为啥?因为我们 HandlerFn 要求的 Future 也是装箱的。只把 handler 本身装箱不行。

那我怎么可以改掉 handler 返回的 Future 类型?

答案是使用闭包:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: Fn(String) -> Fut,
        Fut: Future<Output = String>,
    {
        self.table.write().await.insert(
            key.to_string(),
            Box::new(move |s| {
                let fut = handler(s);
                Box::new(fut)
            }),
        );
    }
}

这里两个 Box::new,第一个是把 handler 函数装箱,第二个是把调用这个 handler 函数返回的 Future 装箱。

注意闭包的 move,因为我们要把 handler 的所有权移动到闭包里。

这样看起来行了吧?

解决生命周期问题

下面是新的报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
error[E0310]: the parameter type `Fun` may not live long enough
  --> src\bin\attempt1.rs:21:13
   |
21 | /             Box::new(move |s| {
22 | |                 let fut = handler(s);
23 | |                 Box::new(fut)
24 | |             }),
   | |              ^
   | |              |
   | |______________the parameter type `Fun` must be valid for the static lifetime...
   |                ...so that the type `Fun` will meet its required lifetime bounds
   |
help: consider adding an explicit lifetime bound
   |
16 |         Fun: Fn(String) -> Fut + 'static,
   |                                +++++++++

我们既然要把 handler 函数装箱,那 Box 就必须拥有它的所有权。

而我们使用 Fun 类型约束传入的 handler 函数的,因此我们必须约束传入的 handler 是所有权的,而不是一个引用。

解决方法是给 Fun 加上’static 约束:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: 'static + Fn(String) -> Fut,
        Fut: Future<Output = String>,
    {
        self.table.write().await.insert(
            key.to_string(),
            Box::new(move |s| {
                let fut = handler(s);
                Box::new(fut)
            }),
        );
    }
}

关于’static 和所有权到底有啥关系,推荐看看这篇文章:Rust 中常见的有关生命周期的误解

好了,又有新的报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
error[E0310]: the parameter type `Fut` may not live long enough
  --> src\bin\attempt1.rs:23:17
   |
23 |                 Box::new(fut)
   |                 ^^^^^^^^^^^^^
   |                 |
   |                 the parameter type `Fut` must be valid for the static lifetime...
   |                 ...so that the type `Fut` will meet its required lifetime bounds
   |
help: consider adding an explicit lifetime bound
   |
17 |         Fut: Future<Output = String> + 'static,
   |                                      +++++++++

由于我们也给 Future 装箱了,所以我们也要保证 Fut 类型拥有所有权。同样给它加上’static 约束:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: 'static + Fn(String) -> Fut,
        Fut: 'static + Future<Output = String>,
    {
        self.table.write().await.insert(
            key.to_string(),
            Box::new(move |s| {
                let fut = handler(s);
                Box::new(fut)
            }),
        );
    }
}

这样没问题了,但很可惜东窗事发,下面 tokio::spawn 那边出问题了。

解决 Pin、Send 和 Sync 问题

Future 必须是 Unpin

还记得我们的 main 函数是啥?

 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
#[tokio::main]
async fn main() {
    let mut router = Router::default();
    router.add("handler1", handler1).await;
    router.add("handler2", handler2).await;
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            handler("req1".into()).await;
        });
        handle
    };
    let handle2 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler2").unwrap();
            handler("req2".into()).await;
        });
        handle
    };
    let _ = tokio::join!(handle1, handle2);
}

报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
error[E0277]: `dyn Future<Output = String>` cannot be unpinned
  --> src\bin\attempt1.rs:38:36
   |
38 |             handler("req1".into()).await;
   |             -----------------------^^^^^
   |             |                     ||
   |             |                     |the trait `Unpin` is not implemented for `dyn Future<Output = String>`
   |             |                     help: remove the `.await`
   |             this call returns `dyn Future<Output = String>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = String>>` to implement `Future`
   = note: required for `Box<dyn Future<Output = String>>` to implement `IntoFuture`

这是说 Future 必须要是 Unpin 的,因为我们在一个 tokio 协程中调用了 await,在这个 await 点上它会不断去 poll 这个 Future,在这个过程中它可能在线程中移动,导致其中的自引用指针不安全。所以必须把它 Pin 起来。用 Pin 包装一层之后,Pin<Box<dyn Future>> 本身是 Unpin 的,就可以安全地 await 它了。

好,那我们把它 Pin 一下。由于 Future 已经在 Box 里了,我们直接使用 Box::into_pin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            let fut = handler("req1".into());
            let pinned_fut = Box::into_pin(fut);
            pinned_fut.await;
        });
        handle
    };

注意哈,关于 Pin 常用的有三个函数的区别:

  • Box::into_pin(x):x 本身是个 Box<Future>,把它装到 Pin 里,变成 Pin<Box<Future>>
  • Box::pin(x):x 本身是个 Future,把它装到 Pin<Box>里,变成 Pin<Box<Future>>;相当于 Box::into_pin(Box::new(x))
  • Pin::new(x):x 是个&mut 指针;基本用不到这个函数

Fn 必须是 Send

下面来看新的报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
error[E0277]: `(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + 'static)` cannot be sent between threads safely
   --> src\bin\attempt1.rs:35:35
    |
35  |           let handle = tokio::spawn(async move {
    |  ______________________------------_^
    | |                      |
    | |                      required by a bound introduced by this call
36  | |             let lock = router_clone.table.read().await;
37  | |             let handler = lock.get("handler1").unwrap();
38  | |             let fut = handler("req1".into());
39  | |             let pinned_fut = Box::into_pin(fut);
40  | |             pinned_fut.await;
41  | |         });
    | |_________^ `(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + 'static)` cannot be sent between threads safely
    |
    = help: the trait `Send` is not implemented for `(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + 'static)`
    = note: required for `Unique<(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + 'static)>` to implement `Send`

tokio::spawn 创建的任务可以在不同的线程上执行,Tokio 运行时可能会在 .await 点之间将任务从一个线程移动到另一个线程。当一个任务被移动时,它所拥有的所有数据(包括从 Router 中读取并正在使用的 HandlerFn)也必须能够安全地跨线程移动。Fn 特征对象(dyn Fn(...))代表一个函数或闭包,它可能捕获了环境中的变量。如果这个函数或闭包捕获了非 Send 的数据(例如 Rc 或裸指针),那么将它移动到另一个线程是不安全的,会导致数据竞争或其他未定义行为。因此,为了保证在多线程环境中安全地从共享的 HashMap 中获取并调用 Fn 特征对象,该特征对象本身必须实现 Send 特征,表明它可以安全地在线程间转移所有权。

为什么要显式声明?因为 Box 里面的特征对象 Rust 是不会帮我们自动推导它的约束的,所以 Send、Sync 包括’static,如果要约束的话,都得我们自己写。

因此改一下 HandlerFn 的类型定义:

1
type HandlerFn = Box<dyn Fn(String) -> Box<dyn Future<Output = String>> + Send>;

Fn 必须是 Sync

解决之后,又来一个报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
error[E0277]: `(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + Send + 'static)` cannot be shared between threads safely
   --> src\bin\attempt1.rs:35:35
    |
35  |           let handle = tokio::spawn(async move {
    |  ______________________------------_^
    | |                      |
    | |                      required by a bound introduced by this call
36  | |             let lock = router_clone.table.read().await;
37  | |             let handler = lock.get("handler1").unwrap();
38  | |             let fut = handler("req1".into());
39  | |             let pinned_fut = Box::into_pin(fut);
40  | |             pinned_fut.await;
41  | |         });
    | |_________^ `(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + Send + 'static)` cannot be shared between threads safely
    |
    = help: the trait `Sync` is not implemented for `(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + Send + 'static)`
    = note: required for `Unique<(dyn Fn(String) -> Box<(dyn Future<Output = String> + 'static)> + Send + 'static)>` to implement `Sync`

这段代码中 Fn 需要是 Sync 的原因,是我们用了 RwLock,因为存储在 RwLock 内部的 HandlerFn 会在多个线程间通过共享引用(读锁)被并发访问和调用,而通过共享引用调用 Fn trait 的方法要求该类型是 Sync

一个 Fn 闭包可能捕获了非 Sync 的环境数据(例如 RcCell)。Send bound 只保证 trait 对象本身可以跨线程移动,但不能保证通过共享引用并发调用它是安全的。为了保证通过共享引用并发调用是安全的,需要 Sync bound。

如果我们把 RwLock 改成 Mutex,就不需要这个 Sync 了。因为 Mutex 是独占访问的。

因此:

1
type HandlerFn = Box<dyn Fn(String) -> Box<dyn Future<Output = String>> + Send + Sync>;

Future 必须是 Send

来看这部分最后一个报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
error: future cannot be sent between threads safely
   --> src\bin\attempt1.rs:35:22
    |
35  |           let handle = tokio::spawn(async move {
    |  ______________________^
36  | |             let lock = router_clone.table.read().await;
37  | |             let handler = lock.get("handler1").unwrap();
38  | |             let fut = handler("req1".into());
39  | |             let pinned_fut = Box::into_pin(fut);
40  | |             pinned_fut.await;
41  | |         });
    | |__________^ future created by async block is not `Send`
    |
    = help: the trait `Send` is not implemented for `dyn Future<Output = String>`
note: future is not `Send` as it awaits another future which is not `Send`
   --> src\bin\attempt1.rs:40:13
    |
40  |             pinned_fut.await;
    |             ^^^^^^^^^^ await occurs here on type `Pin<Box<dyn Future<Output = String>>>`, which is not `Send`

前面说了,在任务执行过程中,特别是在 .await 点之间,Tokio 运行时可能会将这个任务从一个线程移动到另一个线程上继续执行。因此,传递给 tokio::spawnFuture 必须是 Send 的,因为 Tokio 运行时需要在其内部的线程池中安全地调度和执行这个 Future,这可能涉及将 Future 的状态在不同的线程之间移动。

所以我们来改一下:

1
type HandlerFn = Box<dyn Fn(String) -> Box<dyn Future<Output = String> + Send> + Send + Sync>;

需要注意,我们改了 HandlerFn 之后,别忘了把 add 函数里面 Fun 和 Fut 的声明也给相应改了,不然对不上一样编译不通过:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: 'static + Sync + Send + Fn(String) -> Fut,
        Fut: 'static + Send + Future<Output = String>,
    {
        self.table.write().await.insert(
            key.to_string(),
            Box::new(move |s| {
                let fut = handler(s);
                Box::new(fut)
            }),
        );
    }
}

到目前为止的完整代码:

 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
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::sleep;

type HandlerFn = Box<dyn Fn(String) -> Box<dyn Future<Output = String> + Send> + Send + Sync>;
#[derive(Default, Clone)]
struct Router {
    table: Arc<RwLock<HashMap<String, HandlerFn>>>,
}
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: 'static + Sync + Send + Fn(String) -> Fut,
        Fut: 'static + Send + Future<Output = String>,
    {
        self.table.write().await.insert(
            key.to_string(),
            Box::new(move |s| {
                let fut = handler(s);
                Box::new(fut)
            }),
        );
    }
}
#[tokio::main]
async fn main() {
    let mut router = Router::default();
    router.add("handler1", handler1).await;
    router.add("handler2", handler2).await;
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            let fut = handler("req1".into());
            let pinned_fut = Box::into_pin(fut);
            pinned_fut.await;
        });
        handle
    };
    let handle2 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler2").unwrap();
            let fut = handler("req2".into());
            let pinned_fut = Box::into_pin(fut);
            pinned_fut.await;
        });
        handle
    };
    let _ = tokio::join!(handle1, handle2);
}

async fn handler1(req: String) -> String {
    sleep(Duration::from_secs(1)).await;
    println!("handler1");
    "handler1".into()
}
async fn handler2(req: String) -> String {
    sleep(Duration::from_secs(1)).await;
    println!("handler2");
    "handler2".into()
}

终于没有编译错误了,运行试试:

1
2
3
4
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
     Running `target\debug\attempt1.exe`
handler2
handler1

把 Pin<Box>包装的 Future 放到 HashMap 里

现在我们有这样的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            let fut = handler("req1".into());
            let pinned_fut = Box::into_pin(fut);
            pinned_fut.await;
        });
        handle
    };
    let handle2 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler2").unwrap();
            let fut = handler("req2".into());
            let pinned_fut = Box::into_pin(fut);
            pinned_fut.await;
        });
        handle
    };

我们每次调用 handler 的时候都要把 Future 给 Pin 一下,有点麻烦。所以我们最好把 Pin 的封装这个过程放到 add 函数里。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 这里改了返回的 Future 类型
type HandlerFn = Box<dyn Fn(String) -> Pin<Box<dyn Future<Output = String> + Send>> + Send + Sync>;
#[derive(Default, Clone)]
struct Router {
    table: Arc<RwLock<HashMap<String, HandlerFn>>>,
}
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: 'static + Sync + Send + Fn(String) -> Fut,
        Fut: 'static + Send + Future<Output = String>,
    {
        self.table.write().await.insert(
            key.to_string(),
            Box::new(move |s| {
                let fut = handler(s);
                Box::pin(fut) // 这里从 into_pin 改成了 pin
            }),
        );
    }
}

这样,我们就可以直接对 HashMap 获取到的 Future 进行 await:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            let fut = handler("req1".into());
            fut.await;
        });
        handle
    };
    let handle2 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler2").unwrap();
            let fut = handler("req2".into());
            fut.await;
        });
        handle
    };

使用#[async_trait]

一直到目前为止我们都是自己封装的,感觉要写一大坨类型和约束,还要操作闭包,特别麻烦,有没有简单一点的办法?

有的,就是使用 async_trait 这个 crate。

Rust 一般是不允许在 trait 里面使用 async 函数的,如果硬要用的话可能会碰到这样的报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
error[E0038]: the trait `HandlerFn` cannot be made into an object
  --> src\bin\attempt2.rs:31:9
   |
31 |         self.table.write().await.insert(key.to_string(),Box::new(handler));
   |         ^^^^^^^^^^^^^^^^^^ `HandlerFn` cannot be made into an object
   |
note: for a trait to be "dyn-compatible" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety>
  --> src\bin\attempt2.rs:9:14
   |
8  | trait HandlerFn {
   |       --------- this trait cannot be made into an object...
9  |     async fn handle(&self, req: String) -> String;
   |              ^^^^^^ ...because method `handle` is `async`
   = help: consider moving `handle` to another trait

看这里:this trait cannot be made into an object... because method handle is async

但这个 crate 可以帮助我们实现这一点。

实际上还是一种语法糖,它帮我们解决了繁琐的封装工作。

请看改过的代码:

 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
#[async_trait]
trait HandlerFn {
    async fn handle(&self, req: String) -> String;
}
#[async_trait]
impl<Fun, Fut> HandlerFn for Fun
where
    Fun: 'static + Sync + Send + Fn(String) -> Fut,
    Fut: 'static + Send + Future<Output = String>,
{
    async fn handle(&self, req: String) -> String {
        self(req).await
    }
}

#[derive(Default, Clone)]
struct Router {
    table: Arc<RwLock<HashMap<String, Box<dyn HandlerFn>>>>,
}
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: 'static + Sync + Send + Fn(String) -> Fut,
        Fut: 'static + Send + Future<Output = String>,
    {
        self.table
            .write()
            .await
            .insert(key.to_string(), Box::new(handler));
    }
}
#[tokio::main]
async fn main() {
    let mut router = Router::default();
    router.add("handler1", handler1).await;
    router.add("handler2", handler2).await;
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            let fut = handler.handle("req1".into());
            fut.await;
        });
        handle
    };
    let handle2 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler2").unwrap();
            let fut = handler.handle("req2".into());
            fut.await;
        });
        handle
    };
    let _ = tokio::join!(handle1, handle2);
}
  • 我们把 HandlerFn 声明为了 async trait,其中有一个 handle 方法
  • 我们为 Fun 函数类型实现了 HandlerFn 这个 trait,里面用 self(req).await 调用了函数自身
  • 现在往 HashMap 里面放的 value 变成了 Box 包装的 HandlerFn 这个特征对象
  • add 方法改成直接把 Box 包装的 handler 函数 insert 到 HashMap 里
  • tokio::spawn 里改成了调用拿出来 HandlerFn 特征对象的 handle 函数

看看现在的报错:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
error[E0277]: `(dyn HandlerFn + 'static)` cannot be sent between threads safely
   --> src\bin\attempt2.rs:47:35
    |
47  |           let handle = tokio::spawn(async move {
    |  ______________________------------_^
    | |                      |
    | |                      required by a bound introduced by this call
48  | |             let lock = router_clone.table.read().await;
49  | |             let handler = lock.get("handler1").unwrap();
50  | |             let fut = handler.handle("req1".into());
51  | |             fut.await;
52  | |         });
    | |_________^ `(dyn HandlerFn + 'static)` cannot be sent between threads safely
    |
    = help: the trait `Send` is not implemented for `(dyn HandlerFn + 'static)`
    = note: required for `Unique<(dyn HandlerFn + 'static)>` to implement `Send`

似曾相识吧,原因是现在我们把 Box<dyn xx>给写到 table 这个 HashMap 的类型上面了,然后忘记加 Send 和 Sync 约束了。

改一下:

1
2
3
struct Router {
    table: Arc<RwLock<HashMap<String, Box<dyn HandlerFn + Send + Sync>>>>,
}

现在编译通过了,运行也没问题,这次的完整代码:

 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
use async_trait::async_trait;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::sleep;

#[async_trait]
trait HandlerFn {
    async fn handle(&self, req: String) -> String;
}
#[async_trait]
impl<Fun, Fut> HandlerFn for Fun
where
    Fun: 'static + Sync + Send + Fn(String) -> Fut,
    Fut: 'static + Send + Future<Output = String>,
{
    async fn handle(&self, req: String) -> String {
        self(req).await
    }
}

#[derive(Default, Clone)]
struct Router {
    table: Arc<RwLock<HashMap<String, Box<dyn HandlerFn + Send + Sync>>>>,
}
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: 'static + Sync + Send + Fn(String) -> Fut,
        Fut: 'static + Send + Future<Output = String>,
    {
        self.table
            .write()
            .await
            .insert(key.to_string(), Box::new(handler));
    }
}
#[tokio::main]
async fn main() {
    let mut router = Router::default();
    router.add("handler1", handler1).await;
    router.add("handler2", handler2).await;
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            let fut = handler.handle("req1".into());
            fut.await;
        });
        handle
    };
    let handle2 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler2").unwrap();
            let fut = handler.handle("req2".into());
            fut.await;
        });
        handle
    };
    let _ = tokio::join!(handle1, handle2);
}

async fn handler1(req: String) -> String {
    sleep(Duration::from_secs(1)).await;
    println!("handler1");
    "handler1".into()
}
async fn handler2(req: String) -> String {
    sleep(Duration::from_secs(1)).await;
    println!("handler2");
    "handler2".into()
}

为啥现在不需要把 Future 给 Pin 起来了?

因为我们用了async_trait 宏,会转换我们的代码,使得 async fn 方法在编译后实际上返回一个堆分配的、已 Pin 的特征对象,通常是 Pin<Box<dyn Future + Send + 'life>> 这样的类型。非常方便。

提前释放读锁

看下面的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            let fut = handler.handle("req1".into());
            fut.await;
        });
        handle
    };

lock 会一直持有,直到 fut 的 await 结束,持有时间太长了感觉。

虽然在我们的例子里,HashMap 写入只有在初始化时进行,之后就都是读了。RwLock 支持并发读,所以这样没什么性能问题。

但能修还是修一下吧。

下面看完整代码,其中包含注释:

 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
80
81
82
use async_trait::async_trait;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::sleep;

#[async_trait]
trait HandlerFn: Send + Sync + 'static {
    async fn handle(&self, req: String) -> String;
}
#[async_trait]
impl<Fun, Fut> HandlerFn for Fun
where
    Fun: 'static + Sync + Send + Fn(String) -> Fut,
    Fut: 'static + Send + Future<Output = String>,
{
    async fn handle(&self, req: String) -> String {
        self(req).await
    }
}
// HashMap 的 value 把 Arc 换成了 Box
type RouterTableValue = Arc<dyn HandlerFn + Send + Sync>;
#[derive(Default, Clone)]
struct Router {
    table: Arc<RwLock<HashMap<String, RouterTableValue>>>,
}
impl Router {
    async fn add<Fun, Fut>(&mut self, key: &str, handler: Fun)
    where
        Fun: 'static + Sync + Send + Fn(String) -> Fut,
        Fut: 'static + Send + Future<Output = String>,
    {
        self.table
            .write()
            .await
            .insert(key.to_string(), Arc::new(handler));
    }
}
#[tokio::main]
async fn main() {
    let mut router = Router::default();
    router.add("handler1", handler1).await;
    router.add("handler2", handler2).await;
    let handle1 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler1").unwrap();
            let handler_clone = handler.clone(); // clone 一份
            drop(lock); // 这里释放锁
            let fut = handler_clone.handle("req1".into());
            fut.await;
        });
        handle
    };
    let handle2 = {
        let router_clone = router.clone();
        let handle = tokio::spawn(async move {
            let lock = router_clone.table.read().await;
            let handler = lock.get("handler2").unwrap();
            let handler_clone = handler.clone();
            drop(lock);
            let fut = handler_clone.handle("req2".into());
            fut.await;
        });
        handle
    };
    let _ = tokio::join!(handle1, handle2);
}

async fn handler1(req: String) -> String {
    sleep(Duration::from_secs(1)).await;
    println!("handler1");
    "handler1".into()
}
async fn handler2(req: String) -> String {
    sleep(Duration::from_secs(1)).await;
    println!("handler2");
    "handler2".into()
}

为啥把装 HandlerFn 的 Box 换成 Arc 就行了?

Box 和 Arc 都是智能指针,其中 Box 单纯是为了把数据分配在堆上,而 Arc 还有一个功能就是引用计数。

Arc 在 clone 的时候会给内部 HandlerFn 的引用计数增加 1,然后返回一个完整的拥有所有权的 Arc 对象。

使用lock.get("handler1").unwrap()获取到的东西是&RouterTableValue这个不可变引用。

如果调用在这上面调用handler.clone()的话,当RouterTableValue是不同的东西,效果也不同:

  • 如果是 Box,实际上只是把这个不可变引用给 clone 了一份,得到的仍然是和原来一样的&RouterTableValue,仍然持有对 lock 的借用
  • 如果是 Arc,调用的会是 Arc 实现的 clone 方法,获取到的是一个全新的Arc<dyn HandlerFn+Send+Sync>,就不持有对 lock 的借用了,所以之后可以安全地 drop 掉 lock

总结

这样一个小小的需求,在 Rust 里实现起来还是挺麻烦的。

还涉及到了很多比较进阶的知识,不太了解的话可能会有点晕。

所以可以和 AI 多多交流,这篇文章写作的过程中,一些解释部分也用了 AI 生成。(Gemini 2.5 Flash 思考模式,目前感觉很强)

如果在 Go 里实现的话,一般也就是写个函数类型,完事,然后读取写入的时候记得加个锁。

不过 Rust 大概正是麻烦,才保证了运行时的安全吧。

这一套编译错误解决下来也感觉学到了不少东西。

用 Incus/LXD 在 VPS 上开小鸡,给虚拟机分发 /64 的独立 IPv6

2025年1月14日 09:37
Featured image of post 用 Incus/LXD 在 VPS 上开小鸡,给虚拟机分发 /64 的独立 IPv6

如果手上有 /64 这种整段 IPv6 的 KVM 虚拟机的话,可以玩一下用这种方式开小鸡。

这篇之前的博文介绍了 Proxmox VE 配置 IPv6 的情况,其实原理都是一样的。不过这里操作的是 Ubuntu,主要用 netplan。

Incus/LXD 是另一个虚拟机管理平台,对比 Proxmox VE 的优点是可以在任意 Linux 系统上安装,且不需要更换内核,也更节省资源。缺点是功能不如 PVE 多。关于 Incus 的安装和 Web UI 的使用可以去网上搜搜教程。

Incus 是 LXD 的一个社区版 Fork,两者没什么大的区别。但由于 LXD 改用了 Canonical 的许可证,且需要 snap 安装,所以一般建议用 Incus。

母鸡配置:

网关:2403:71c0:2000::1

拥有的子网:2403:71c0:2000:a217::/64

首先在/etc/sysctl.conf 里开启 IPv6 转发:

1
net.ipv6.conf.all.forwarding=1

然后 sysctl -p 生效。

在 Incus UI 里加一个 vmv6 的网卡:

image-20250114094226086

关闭 IPv4 地址和 IPv6 的 NAT。这里给的 vmv6 网卡是/64 整个子网,网关是 2403:71c0:2000:a217::1212(母鸡本机 IPv6)。

母鸡 netplan:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
network:
  version: 2
  ethernets:
    eth0:
      addresses:
        - 2403:71c0:2000:a217::1212/128
      routes:
        - to: "2403:71c0:2000::1/128"
          scope: link
        - to: "::/0"
          via: "2403:71c0:2000::1"

几个地方需要注意:

  • 母鸡上的 eth0 实际上相当于物理网卡,我们虽然用 incus vmv6 代理了 IPv6 的子网分发,但真正最终上网要经过的还是这个物理网卡。

  • 母鸡 IPv6 是 2403:71c0:2000:a217::1212/128,只分配单个/128 的,防止和 vmv6 的冲突。2403:71c0:2000:a217::1212 是母鸡的本机 IPv6,也相当于作为小鸡的网关。

  • 走物理网关地址单个 2403:71c0:2000::1/128 配置直接 link,不需要路由转发。

  • 母鸡上网直接走物理网关 2403:71c0:2000::1,vmv6 上网实际上最终的流量也还是会被路由到这个物理网关,正如前面所说的。

效果:

 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
root@anontokyo:~# ip a
...
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:16:3c:ad:57:5d brd ff:ff:ff:ff:ff:ff
    altname enp0s3
    altname ens3
    inet6 2403:71c0:2000:a217::1212/128 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::216:3cff:fead:575d/64 scope link 
       valid_lft forever preferred_lft forever
...
19: vmv6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 00:16:3e:b0:8e:a5 brd ff:ff:ff:ff:ff:ff
    inet6 2403:71c0:2000:a217::1212/64 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::216:3eff:feb0:8ea5/64 scope link 
       valid_lft forever preferred_lft forever

root@anontokyo:~# ip -6 r
2403:71c0:2000::1 dev eth0 proto static metric 1024 pref medium
2403:71c0:2000:a217::1212 dev eth0 proto kernel metric 256 pref medium
2403:71c0:2000:a217::/64 dev vmv6 proto kernel metric 256 pref medium
fe80::/64 dev eth0 proto kernel metric 256 pref medium
fe80::/64 dev veth478de25 proto kernel metric 256 pref medium
fe80::/64 dev br-9714ce2fd96a proto kernel metric 256 pref medium
fe80::/64 dev veth62aa42c proto kernel metric 256 pref medium
fe80::/64 dev docker0 proto kernel metric 256 pref medium
fe80::/64 dev vmv6 proto kernel metric 256 pref medium
default via 2403:71c0:2000::1 dev eth0 proto static metric 1024 pref medium

给虚拟机(容器)增加 vmv6 网卡,内部映射的名称为 eth0:

image-20250114112232264

虚拟机(容器)netplan:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
network:
  version: 2
  ethernets:
    eth0:
      dhcp6: false
      addresses:
        - 2403:71c0:2000:a217::114/64
      routes:
        - to: default
          via: "2403:71c0:2000::1212"

注意:

  • 小鸡 IPv6 配置 2403:71c0:2000:a217::114/128,这里写/128 或/64 都无所谓,反正子网内 IPv6 都能随便拿。
  • 配置所有 IPv6 走 2403:71c0:2000::1212 网关,也就是母鸡的 IPv6 地址,由母鸡作为路由器帮我们代理上网。

效果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
root@ubuntu:~# ip a
...
22: eth0@if23: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 00:16:3e:27:b9:5a brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 2403:71c0:2000:a217:216:3eff:fe27:b95a/64 scope global mngtmpaddr noprefixroute 
       valid_lft forever preferred_lft forever
    inet6 2403:71c0:2000:a217::114/64 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::216:3eff:fe27:b95a/64 scope link 
       valid_lft forever preferred_lft forever

root@ubuntu:~# ip -6 r
2403:71c0:2000:a217::/64 dev eth0 proto kernel metric 256 pref medium
2403:71c0:2000:a217::/64 dev eth0 proto ra metric 1024 mtu 1500 hoplimit 64 pref medium
fe80::/64 dev eth0 proto kernel metric 256 pref medium
default via fe80::216:3eff:feb0:8ea5 dev eth0 proto ra metric 1024 expires 1727sec mtu 1500 hoplimit 64 pref medium

可以看到小鸡除了我们配置的 IPv6 之外还通过 SLAAC 自己拿到了一个 IPv6。另外这里走网关 2403:71c0:2000::1212 配置好像没生效,走的是 fe80::216:3eff:feb0:8ea5 这个母鸡 vmv6 网卡的本地链路地址,不过没关系,和走公网 IPv6 效果是一样的。

修改完 netplan 都要记得 netplan apply。

然后配置母鸡的 iptables:

1
2
3
4
ip6tables -A FORWARD -i vmv6 -j ACCEPT
ip6tables -A FORWARD -o vmv6 -j ACCEPT

netfilter-persistent save # 持久化iptables规则

(这里用 AI 帮忙解释一下)这两条 ip6tables 命令配置了 IPv6 的网络地址转换 (NAT) 和转发规则:

  1. ip6tables -A FORWARD -i vmv6 -j ACCEPT
    • 允许从 vmv6 接口进入的流量转发
    • -A FORWARD: 在 FORWARD 链上添加规则
    • -i vmv6: 匹配从 vmv6 接口进入的流量
    • -j ACCEPT: 接受这些流量
  2. ip6tables -A FORWARD -o vmv6 -j ACCEPT
    • 允许转发到 vmv6 接口的流量
    • -o vmv6: 匹配发往 vmv6 接口的流量
    • -j ACCEPT: 接受这些流量

这两条规则一起工作:

  1. 允许内部网络 (vmv6) 的流量转发到外网 (eth0)
  2. 允许外网的响应流量返回到内部网络

运行ndpresponder

1
ndpresponder -i eth0 -n 2403:71c0:2000:a217::/64

这里的配置是在 eth0 物理网卡上回应我们母鸡的整个 IPv6 段,让物理网关把发给我们这个 IP 段底下任何一个 IP 地址的数据包都转发给我们。

测试小鸡能否上网:

1
2
root@ubuntu:~# curl ipv6.ip.sb
2403:71c0:2000:a217:216:3eff:fe27:b95a

测试外网能否能联通小鸡(换一台 VPS 运行):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
root@dev:~# ping 2403:71c0:2000:a217:216:3eff:fe27:b95a
PING 2403:71c0:2000:a217:216:3eff:fe27:b95a(2403:71c0:2000:a217:216:3eff:fe27:b95a) 56 data bytes
64 bytes from 2403:71c0:2000:a217:216:3eff:fe27:b95a: icmp_seq=1 ttl=54 time=2.18 ms
64 bytes from 2403:71c0:2000:a217:216:3eff:fe27:b95a: icmp_seq=2 ttl=54 time=1.66 ms
^C
--- 2403:71c0:2000:a217:216:3eff:fe27:b95a ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 1.658/1.919/2.180/0.261 ms
root@dev:~# ping 2403:71c0:2000:a217::114
PING 2403:71c0:2000:a217::114(2403:71c0:2000:a217::114) 56 data bytes
64 bytes from 2403:71c0:2000:a217::114: icmp_seq=1 ttl=54 time=1.69 ms
64 bytes from 2403:71c0:2000:a217::114: icmp_seq=2 ttl=54 time=1.59 ms
^C
--- 2403:71c0:2000:a217::114 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 1.591/1.642/1.694/0.051 ms

不管是我们自己配上的 IPv6 还是 SLAAC 拿到的 IPv6 都是可以联通的。

使用 Step CLI 模拟 TLS 证书签发、证书链验证全流程操作

2024年11月30日 09:20
Featured image of post 使用 Step CLI 模拟 TLS 证书签发、证书链验证全流程操作

证书详解

TLS 证书大家并不陌生,每天上网我们都在访问 HTTPS 网站,所以说实际上每天都在接触也不为过。不少朋友可能自建过网站,也曾经使用过 acme 之类的工具申请过 Let’s Encrypt 的证书,为网站部署 HTTPS。但有关 TLS 证书里面的概念实在太多了,证书、证书秘钥、CSR 等不同的种类,以及 P12、DER、PEM 等不同的格式,大家肯定多多少少听说过也使用过,但恐怕很少有人真正系统地了解过。

最近翻到了一篇文章,从头到尾把有关证书的所有概念系统地阐释了一遍,语言也简单易懂,非常推荐阅读。如果你还对证书这东西一知半解的话,现在是时候学起来了!

文章链接:Everything you should know about certificates and PKI but are too afraid to ask

原文是英文的,如果阅读有困难可以直接开个沉浸式翻译

在读了以上这篇文章的基础上才能看懂本文剩下的部分哦。

Step CLI

在熟悉了证书整套运作原理的基础上,让我们使用 step cli 这个工具来做一次实验,来模拟有关证书操作的全流程吧。大家可能由于开发的需要,有自签过证书,一般都会使用 OpenSSL 的 cli 工具。但不得不说 OpenSSL 的 cli 非常反人类,用过的人都明白。这里推荐的 step cli 工具则是一个和 OpenSSL 一样强大的,但是交互十分简单的开源证书工具。我们接下来就使用它作为实验的器材。

下载地址:https://github.com/smallstep/cli/releases

证书实验

安装好 step cli 之后,新建一个文件夹作为我们的实验场所。输入step certificate可以看到支持的命令,每个命令都可以查看帮助,并且有非常详细和实用的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
...
COMMANDS
      bundle         bundle a certificate with intermediate certificate(s) needed for certificate path validation
      create         create a certificate or certificate signing request
      format         reformat certificate
      inspect        print certificate or CSR details in human readable format
      fingerprint    print the fingerprint of a certificate
      lint           lint certificate details
      needs-renewal  Check if a certificate needs to be renewed
      sign           sign a certificate signing request (CSR)
      verify         verify a certificate
      key            print public key embedded in a certificate
      install        install a root certificate in the supported trust stores
      uninstall      uninstall a root certificate from the supported trust stores
      p12            package a certificate and keys into a .p12 file

Root CA(根证书)生成

为了进行接下来一系列的操作,我们需要使用certificate create子命令先生成一个 Root CA,包括证书和秘钥:

1
2
3
4
D:\projects\test-repo\cert-exp (master)
λ step certificate create --profile root-ca --no-password --insecure MyRoot root-ca.crt root-ca.key
Your certificate has been saved in root-ca.crt.
Your private key has been saved in root-ca.key.

为了方便起见我们这里就不设密码了。有关create命令的各种参数可以使用step certificate create -h查看到。我们这里创建的是名为 MyRoot 的证书,证书文件和密钥都存储在当前目录下。

我们可以使用certificate inspect命令查看证书的内容:

image-20241130094317023

图中圈出来的是几个关键的点。首先 Issuer(签发者)和 Subject(主体)是一样的,表明这是一个自签证书,所有 CA 都是自签的,自己验证自己的合法性。下面的 Basic Constraints 表明这是一个 CA,pathlen 为 1 表示它允许有一个中间证书。

中间证书生成

我们可以使用 Root CA 直接签发叶子证书(域名直接用的证书),但这不太符合安全标准。于是我们再模拟一下签发一个名为 MyIntermediate 的中间证书,之后再使用中间证书签发叶子证书。

1
2
3
4
D:\projects\test-repo\cert-exp (master)
λ step certificate create --profile intermediate-ca MyIntermediate intermediate.crt intermediate.key --ca root-ca.crt --ca-key root-ca.key --no-password --insecure
Your certificate has been saved in intermediate.crt.
Your private key has been saved in intermediate.key.

签发中间证书的时候需要使用--ca命令指定 Root CA 文件,代表我们使用 Root CA 对这个证书进行签名。

使用inspect检查intermediate.crt

image-20241130094856989

可以看到签发者是 MyRoot,主体是 MyIntermediate。下面的 Constraints 显示这是一个 CA,并且 pathlen 为 0,表示它只能签发叶子证书,不能再签发下一层中间证书了。这是由于我们 Root CA 的 pathlen 为 1,它签发中间证书的时候就会把这个值减去 1。

签发叶子证书

叶子证书也就是我们常用的域名证书,是可以直接部署到 Nginx、Apache 之类的软件上给网站使用的。下面我们来进行一次这样的过程。

方法有两种,一种是直接用 CA 签发叶子证书,生成证书和密钥。另一种是先生成叶子证书的 CSR 文件和秘钥,然后用这个 CSR 拿给 CA 去签发证书本身。显然后者更安全,也是最佳实践。不过我们可以两种都尝试一下。

直接签发叶子证书

1
2
3
4
D:\projects\test-repo\cert-exp (master)
λ step certificate create --profile leaf --ca intermediate.crt --ca-key intermediate.key --no-password --insecure blog.skyju.cc blog.skyju.cc.crt blog.skyju.cc.key
Your certificate has been saved in blog.skyju.cc.crt.
Your private key has been saved in blog.skyju.cc.key.

image-20241130095631268

可以看到签发者是我们的中间证书,主体是 blog.skyju.cc。下面有个 SAN(Subject Alternative Name)的部分标志着有效的 DNS 名称。使用 SAN 而非 Subject 字段来填充域名是现在的推荐做法,并且还可以签发泛域名证书,SAN 也是可以自定义的。我们下面用 CSR 方式签发证书的时候就来测试一下。

使用 CSR 签发叶子证书

首先需要生成密钥和 CSR:

1
2
3
4
D:\projects\test-repo\cert-exp (master)
λ step certificate create --csr --san "*.skyju.cc" --no-password --insecure blog.skyju.cc blog.skyju.cc.csr blog.skyju.cc.key
Your certificate signing request has been saved in blog.skyju.cc.csr.
Your private key has been saved in blog.skyju.cc.key.

image-20241130100503086

用这条命令我们生成了一个主体为 blog.skyju.cc,但是 SAN 的 DNS 名为*.skyju.cc 泛域名的 CSR 文件。那么实际上这个证书签发出来就不止 blog.skyju.cc 能用,而是*.skyju.cc 的所有域名都能用的。

现在我们用中间证书为这个 CSR 签发证书文件,需要使用到的是certificate sign子命令:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
D:\projects\test-repo\cert-exp (master)
λ step certificate sign blog.skyju.cc.csr intermediate.crt intermediate.key
-----BEGIN CERTIFICATE-----
MIIBuDCCAV+gAwIBAgIQQJJKTWC0d2wKVoK1H/Ry1zAKBggqhkjOPQQDAjAZMRcw
FQYDVQQDEw5NeUludGVybWVkaWF0ZTAeFw0yNDExMzAwMjA1MjdaFw0yNDEyMDEw
MjA1MjdaMBgxFjAUBgNVBAMTDWJsb2cuc2t5anUuY2MwWTATBgcqhkjOPQIBBggq
hkjOPQMBBwNCAARTXzjia9iCgK04+cWmSyIKMhGBZr64xXxnvvP2xOmud2/JAFg7
aqsZHw7yn06GyvG9nPVL5yqLtr0is66mvTjho4GJMIGGMA4GA1UdDwEB/wQEAwIH
gDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFJJVDMkX
ouuFY3xDLDYBkjvSBHGRMB8GA1UdIwQYMBaAFH4xhy4Wm/ANcykwqP1N5M90PCmp
MBUGA1UdEQQOMAyCCiouc2t5anUuY2MwCgYIKoZIzj0EAwIDRwAwRAIgDwTQzL/R
D/dgfCe8sHWSm3nQy/h1/Z/uGVtVWjXpy4oCIBw/bkMiab8J67YIPUse4OJQqfcW
6YBA1d7WXlK+cksA
-----END CERTIFICATE-----

D:\projects\test-repo\cert-exp (master)
λ step certificate sign blog.skyju.cc.csr intermediate.crt intermediate.key > blog.skyju.cc.crt

它这里直接把证书内容输出了,于是我们需要使用>把内容重定向到文件里。

检查一下:

image-20241130100700883

和我们刚才直接签发的效果一样,是一个有效的证书文件。

捆绑中间证书和叶子证书

如果我们想把证书部署到网站,直接部署这个blog.skyju.cc.crt是不行的,因为缺少一个中间证书文件。而操作系统信任的是根证书(Root CA)文件。有些浏览器不会自动补齐证书链,我们的域名就无法通过 TLS 证书链验证。所以为了最大的兼容性考虑,还需要把中间证书的证书文件和我们叶子证书的证书文件捆绑在一起再部署到 Nginx 之类的软件上。

我们需要使用certificate bundle这个子命令:

 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
D:\projects\test-repo\cert-exp (master)
λ step certificate bundle blog.skyju.cc.crt intermediate.crt blog.skyju.cc-bundle.crt
Your certificate has been saved in blog.skyju.cc-bundle.crt.

D:\projects\test-repo\cert-exp (master)
λ cat blog.skyju.cc-bundle.crt
-----BEGIN CERTIFICATE-----
MIIBuTCCAV+gAwIBAgIQTu4WKgFliJNAWn0ayIQBiTAKBggqhkjOPQQDAjAZMRcw
FQYDVQQDEw5NeUludGVybWVkaWF0ZTAeFw0yNDExMzAwMjA1NDJaFw0yNDEyMDEw
MjA1NDJaMBgxFjAUBgNVBAMTDWJsb2cuc2t5anUuY2MwWTATBgcqhkjOPQIBBggq
hkjOPQMBBwNCAARTXzjia9iCgK04+cWmSyIKMhGBZr64xXxnvvP2xOmud2/JAFg7
aqsZHw7yn06GyvG9nPVL5yqLtr0is66mvTjho4GJMIGGMA4GA1UdDwEB/wQEAwIH
gDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFJJVDMkX
ouuFY3xDLDYBkjvSBHGRMB8GA1UdIwQYMBaAFH4xhy4Wm/ANcykwqP1N5M90PCmp
MBUGA1UdEQQOMAyCCiouc2t5anUuY2MwCgYIKoZIzj0EAwIDSAAwRQIhAIoaaE6P
dgQxwe/yxCcMObEhKDxPqbYnP/0azvSofpZEAiARxN0BXwM5kZ1ze+LGg+ERI8FE
OFmgmp6+0dsJWwN17Q==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIBjjCCATSgAwIBAgIQMENcIBy+me/6x49TZhLDpDAKBggqhkjOPQQDAjARMQ8w
DQYDVQQDEwZNeVJvb3QwHhcNMjQxMTMwMDE0NjUyWhcNMzQxMTI4MDE0NjUyWjAZ
MRcwFQYDVQQDEw5NeUludGVybWVkaWF0ZTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABN2RYufmRm+/T+bDswiZ7ad4E6jUiWQI9PSySc+txRR3AE0Fk+l9ImwcGvfl
J67W+f5oSga4o7N+WR8w33dHOJijZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMB
Af8ECDAGAQH/AgEAMB0GA1UdDgQWBBR+MYcuFpvwDXMpMKj9TeTPdDwpqTAfBgNV
HSMEGDAWgBRv0ckqxbsxjjU1pYzUUIp5IP2+ajAKBggqhkjOPQQDAgNIADBFAiBt
NJRcIMtSpdi8KKzgFNYDDbkDQCTIhLSlzFY3EyL/xAIhAIZv/g/ybSe5PajdVv5V
YEdQ6Lch6JR18nGK0bo+pltc
-----END CERTIFICATE-----

使用step certificate inspect --bundle可以检查完整的证书链:

1
step certificate inspect blog.skyju.cc-bundle.crt --bundle

输出效果这里就不展示了,实际上就是叶子证书后面跟一个中间证书。

验证证书有效性

使用certificate verify子命令可以验证证书有效性:

1
2
D:\projects\test-repo\cert-exp (master)
λ step certificate verify blog.skyju.cc-bundle.crt --host blog.skyju.cc --roots root-ca.crt

这里我们使用--host代表待验证的域名,--roots代表使用的根证书证书文件。如果根证书已经被添加到了操作系统中的话,这边的--roots其实是不用加的。

没有任何输出,代表验证有效。

现在我们改一下--host的参数做一下实验:

1
2
3
4
5
6
D:\projects\test-repo\cert-exp (master)
λ step certificate verify blog.skyju.cc-bundle.crt --host another.skyju.cc --roots root-ca.crt

D:\projects\test-repo\cert-exp (master)
λ step certificate verify blog.skyju.cc-bundle.crt --host google.com --roots root-ca.crt
failed to verify certificate: x509: certificate is valid for *.skyju.cc, not google.com

验证another.skyju.cc有效,是因为我们的证书是泛域名证书;而验证google.com无效是因为我们的证书不属于google.com

现在我们试一下验证没有 bundle 过的证书文件:

1
2
3
D:\projects\test-repo\cert-exp (master)
λ step certificate verify blog.skyju.cc.crt --host blog.skyju.cc --roots root-ca.crt
failed to verify certificate: x509: certificate signed by unknown authority

看到出错了,这是因为certificate verify命令不会自动补全证书链,所以跨了一个中间证书验证是不成功的。对此,我们只能先用根证书验证中间证书,再用中间证书验证叶子证书:

1
2
3
4
5
D:\projects\test-repo\cert-exp (master)
λ step certificate verify intermediate.crt --roots root-ca.crt

D:\projects\test-repo\cert-exp (master)
λ step certificate verify blog.skyju.cc.crt --host blog.skyju.cc --roots intermediate.crt

顺便提一句,如果我们在生产环境中签发真实的 TLS 证书,但由于某些原因缺失了中间证书,可以使用一些在线的 TLS 证书链补全工具帮助我们自动找出中间证书然后 bundle。比如说这个工具

另一个工具:mkcert

step cli 已经比 OpenSSL 易用很多了,但我们有时候做开发需要一些更傻瓜式的工具。这里推荐另一个开源工具 mkcert,可以一键创建根证书,把根证书安装到操作系统和浏览器,一键直接签发对应域名的叶子证书。

项目地址:https://github.com/FiloSottile/mkcert

image-20241130102241707

step cli 的好处是支持一些更复杂的操作,比如设置证书过期时间、查看证书信息等等。大家可以自由选择使用。

Go 正则表达式 regexp 使用$匹配行尾时在 CRLF(\r\n)上不工作

2024年10月5日 20:36
Featured image of post Go 正则表达式 regexp 使用$匹配行尾时在 CRLF(\r\n)上不工作

大家知道正则表达式^是用来匹配行首,$是用来匹配行尾。如果是多行全局的情况,就会分别匹配每一行。比如一个匹配 QQ 邮箱的正则^\d+@qq\.com$,用 regex101 测试的结果:

image-20241005204251769

而在 Go 里面如果要启用多行匹配一般是这样写:

1
re := regexp.MustCompile(`(?m)^\d+@qq\.com$`)

这里的(?m)就是多行匹配的意思。

全局匹配在其他语言中是一般会用 g 表示,而在 Go 中应该调用带 All 的方法,比如 FindAllString。

但是 Go 的多行全局匹配时其实对换行符有要求,不会匹配 CRLF(\r\n,Windows 中的换行)和 CR(\r,macOS 中的换行)的,只会匹配 LF(\n,Linux 中的换行)。

示例代码:

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

import (
	"log/slog"
	"regexp"
)

func main() {
	str1 := "156161656@qq.com\r\n51381651@qq.com\r\n1115258262@qq.com"
	str2 := "156161656@qq.com\n51381651@qq.com\n1115258262@qq.com"
	str3 := "156161656@qq.com\r51381651@qq.com\r1115258262@qq.com"
	re := regexp.MustCompile(`(?m)^\d+@qq\.com$`)
	slog.Info("str1", "matched", re.FindAllString(str1, -1))
	slog.Info("str2", "matched", re.FindAllString(str2, -1))
	slog.Info("str2", "matched", re.FindAllString(str3, -1))
}

输出:

1
2
3
2024/10/05 20:39:16 INFO str1 matched=[1115258262@qq.com]
2024/10/05 20:39:16 INFO str2 matched="[156161656@qq.com 51381651@qq.com 1115258262@qq.com]"
2024/10/05 20:39:16 INFO str2 matched=[]

可以看到只有 str2 是正确匹配的。str1 由于最后一个邮箱后面没有\r\n,也作为字符串的结尾被匹配了。

如果是读文件的情况,可能 Windows 上的文件本来就是\r\n换行的,目前没有找到什么好的办法,只能用 strings.ReplaceAll 把\r全部都替换掉。

Rust 的正则库似乎也有这个问题:https://github.com/rust-lang/regex/issues/244

Firefox 使用 userChrome.css 自定义垂直标签栏(Vertical Tab Bar)

2024年8月11日 13:42
Featured image of post Firefox 使用 userChrome.css 自定义垂直标签栏(Vertical Tab Bar)

自己在桌面平台上使用火狐浏览器大概已经超过十年了吧,从 XP 时代开始,Win7、Win8 一路走来,中间还用过一段时间的黑苹果,再到现在的 Win10。操作系统不知道重装和升级了多少遍,电脑里也一直装有 Chrome、Edge 等浏览器备用,但一直以来主力浏览器一直都是 Firefox。

要说支持 Firefox 的原因,除了支持开源、防止谷歌一家独大这个公认理由之外,还源自习惯于火狐的插件生态和高度可定制化能力。在谷歌宣布 Chrome 要抛弃 Manifest V2,强推 Manifest V3 的当下,一众广告屏蔽插件怨声载道,而如 Firefox 这样的一个声明会始终支持 Manifest V2 的浏览器便显得尤其难能可贵。

Tree Style Tabs + userChrome.css

再举一个例子,前几年发现了火狐浏览器上的Tree Style Tab这个插件,开始习惯于垂直标签栏。它的做法是占用侧边栏的空间来显示标签。

垂直标签页用起来很舒适,对于我这样一不注意就开出来很多标签页的人来说尤为如此。美中不足的是顶部也有一个原生的标签栏,和垂直标签栏重复了。于是开始调查有没有办法可以把原生的标签栏隐藏,发现了 Firefox 可以通过自定义 userChrome.css 的技巧。

具体方法可以看一下这个网站:https://www.userchrome.org/how-create-userchrome-css.html

按键盘上的Ctrl + Alt + Shift + I可以打开针对浏览器布局的开发者工具。这个工具和在网页上按 F12 打开的工具差不多,不过它针对的是浏览器本身的布局而不是网页内容,以及它的窗口是漂浮在外的:

我的 userChrome.css 内容如下:

1
2
3
4
5
6
hbox#TabsToolbar-customization-target{
    visibility: collapse;
}
box#sidebar-header{
    visibility: collapse;
}

保存之后重启浏览器,这样就可以把顶部原生的标签栏隐藏。

Chrome 的情况

出于好奇也调查了一下 Chrome 上类似的解决方案,也确实有一个叫Vertical Tabs的插件,原理也是占用侧边栏来显示标签:

不过由于谷歌浏览器对扩展组件的限制严格,有以下缺点令人非常不适:

  • 垂直标签栏的 min-width 太大了(这个应该是 Chrome 对侧边栏的硬性设定),没法调小,严重挤压屏幕可视空间;
  • 不能开启浏览器自动启动,必须手动点一下插件的 icon;
  • 由于没有 userChrome.css 的支持,不能隐藏顶部的原生标签栏。

Reddit 上甚至有用户调侃「谷歌浏览器啥时候出个 userFirefox.css」(笑)。

原生垂直标签栏 + userChrome.css

Tree Style Tab 可定制性很高,总体上各方面都能满足日常需求,但有一个比较难以忍受的点是性能太差。尤其在一次性开多个后台标签页的情况下,切换标签页的时候很卡。而原生的标签栏实现就没有这个问题。

从 Firefox 129(2024 年 8 月的版本)开始已经支持了原生垂直标签栏,虽然不支持 Tree Style Tab 那样嵌套,但也足够了。只需要在about:config中打开:

只需要把图中的两项都设成 true 然后重启浏览器即可。

开启之后,不知道是不是实验性特性的缘故,感觉有一些违和感。比如每个标签高度太大、加号按钮没居中、底下的插件栏太多余、关闭标签按钮只在当前标签页显示,于是修改了一下 userChrome.css 进行定制化,现在的文件内容如下:

(2024.9.8更新,适配最新版Firefox)

 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
hbox#TabsToolbar-customization-target{
    visibility: collapse;
}
box#sidebar-header{
    visibility: collapse;
}

div.wrapper{
    padding-inline-start: unset !important;
    min-width: 169px !important;
    background: #9de1bc !important;
    padding-top: 0!important;
    padding-bottom: 0!important;
}
#newtab-button-container{
    padding-top: 0!important;
    order: 999;
}
*{
    --tab-min-height: 28px!important;
}
tab.tabbrowser-tab .tab-background{
    margin: 2px 0 !important;
}
button-group.tools-and-extensions.actions-list{
    display: none !important;
}
div.bottom-actions.actions-list{
    display: none !important;
}

实在是 CSS 苦手的缘故,不少地方都用了绝对像素值(算啦,能用就行)。

也顺便把背景色换成了自己浏览器的主题色。现在看起来就顺眼多了,能够显示信息的密度也更大:

Proxmox VE 配置 NAT IPv4+IPv6、分发独立 IPv6 之网络配置模板和理解

2023年12月29日 16:00
Featured image of post Proxmox VE 配置 NAT IPv4+IPv6、分发独立 IPv6 之网络配置模板和理解

最近弄了一台资源比较充沛的服务器,提供一个 IPv4 地址和/64 子网的 IPv6 地址,所以就装个 Proxmox VE 来开小鸡玩玩。在配置的过程中学到了很多新知识,这里特此记录一下。

安装

Proxmox VE 是一个类似 VMware ESXi 的软件,用于服务器上的虚拟机管理,和 ESXi 一样提供 Web 面板。但与之不同的是,Proxmox 是开源软件,并且基于 Debian 技术栈,因此可定制程度更高。可以直接安装官方提供的 ISO 镜像,也可以先安装 Debian 系统后再根据官方教程安装软件本体。

配置网卡

安装完之后就是进行网络配置了。我参考了这个一键脚本的网络配置,如下:

 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
auto lo
iface lo inet loopback
auto vmbr0
iface vmbr0 inet static
    address 202.194.15.228/24
    gateway 202.194.15.254
    bridge_ports ens33
    bridge_stp off
    bridge_fd 0

iface vmbr0 inet6 static
    address 2001:da8:7000:15:20c:29ff:feff:e247/128
    gateway fe80::5298:b8ff:fed2:3001

auto vmbr1
iface vmbr1 inet static
    address 172.16.1.1/24
    bridge_ports none
    bridge_stp off
    bridge_fd 0
    post-up echo 1 > /proc/sys/net/ipv4/ip_forward
    post-up echo 1 > /proc/sys/net/ipv4/conf/vmbr1/proxy_arp
    post-up iptables -t nat -A POSTROUTING -s '172.16.1.0/24' -o vmbr0 -j MASQUERADE
    post-down iptables -t nat -D POSTROUTING -s '172.16.1.0/24' -o vmbr0 -j MASQUERADE

iface vmbr1 inet6 static
    address 2001:db8:1::1/64
    post-up sysctl -w net.ipv6.conf.all.forwarding=1
    post-up ip6tables -t nat -A POSTROUTING -s 2001:db8:1::/64 -o vmbr0 -j MASQUERADE
    post-down sysctl -w net.ipv6.conf.all.forwarding=0
    post-down ip6tables -t nat -D POSTROUTING -s 2001:db8:1::/64 -o vmbr0 -j MASQUERADE

auto vmbr2
iface vmbr2 inet6 static
    address 2001:da8:7000:15:20c:29ff:feff:e247/64
    bridge_ports none
    bridge_stp off
    bridge_fd 0

由于对于这种 IPv4 和 IPv6 双栈的机器,网络配置起来还是比较复杂的。比如因为只有一个 IPv4,所以需要做 NAT;而 IPv6 有一个子网,所以可以从其中取出/128 的 IP 地址分配给小鸡。那么这样就需要配置多网卡,用来区分各种网络配置情况。

可以提前学习一下官方文档中配置网络的部分。不过官方文档写的太简单了,不支持很多复杂的情况。可以了解一下其中的概念,比如桥接、路由、伪装各种模式等等。

下面就来详细解析一下这份配置。

原始网络

Debian 12 机器安装好,还没有安装 PVE 的原始网络配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).

source /etc/network/interfaces.d/*

# The loopback network interface
auto lo
iface lo inet loopback

# The primary network interface
# This is an autoconfigured IPv6 interface
allow-hotplug ens33
iface ens33 inet static
address 202.194.15.228/24
gateway 202.194.15.254

我们给机器配置了一个 202.194.15.228 的静态 IP,属于/24 网段。实际上在这个机房里可以拿到 202.194.15.x 的任何一个 IP(没有 MAC 地址绑定),只要不被别人占用。

配置文件中没有 IPv6,但使用 ip a 命令查看还是有 IPv6 的,也能访问 IPv6 的网站。这是因为机房网络启用了 SLACC 来自动进行配置。在这种情况下,我们自动拿到 IPv6 的后缀就是主机 MAC 地址的变种。比如 MAC 地址是 00:0c:29:ff:e2:47,那么拿到的 IPv6 是 2001:da8:7000:15:20c:29ff:feff:e247。

有关 SLACC 的内容,可以看下面几篇博文,非常精彩:

[译]理解 IPv6:Link-Local 地址的魔法

[译]理解 IPv6:组播 MAC 地址

[译]理解 IPv6:什么是被请求节点 (solicited-node) 组播 (预备知识)

[译]理解 IPv6:什么是被请求节点 (solicited-node) 组播

[译]理解 IPv6:Ping 过程与被请求节点 (solicited-node) 组播的联系

ens33 网卡

ens33 相当于机器本身的物理网卡。在 PVE 的配置中就没有对其进行额外配置了。实际上是把它作为了 manual 模式,相当于以下配置:

1
iface ens33 inet manual

相关资料:https://askubuntu.com/questions/645000/what-is-the-difference-between-iface-eth0-inet-manual-and-iface-eth0-inet-static

vmbr0 网卡

我们把 ens33 作为 manual 模式,实际上就是要把其原本的功能转移到 vmbr0 这个 PVE 创建的虚拟网卡上。

1
2
3
4
5
6
iface vmbr0 inet static
    address 202.194.15.228/24
    gateway 202.194.15.254
    bridge_ports ens33
    bridge_stp off
    bridge_fd 0

可以看到我们为它配置了 static IP,将原始配置中本来为 ens33 配置的 address 和 gateway 放到这里了。并且指定了bridge_ports ens33,意思是桥接到 ens33 网卡上。

我们知道现在家里办宽带运营商都会发给你一个光猫。这个光猫在默认配置下承担了光纤信号调制解调和路由器两个功能。比如电信的光猫,会开启一个前缀是 ChinaNet 的 WiFi。但我们可以打电话要求客服远程将我们的光猫改成「桥接模式」,然后我们在光猫的网络接口上接一个自己的路由器,用路由器拨号和发射 WiFi 信号,而光猫只作为调制解调的用途。这样就可以在路由器上做一些自己的定制了,比如安装广告屏蔽插件等等。这边虚拟网卡的桥接模式也是同样的道理。因为我们使用桥接模式,因此原来的 ens33 就不应该配置自己的 IP 和网关了,而是全权交给 vmbr0。

vmbr0 网卡同样配置 IPv6:

1
2
3
iface vmbr0 inet6 static
    address 2001:da8:7000:15:20c:29ff:feff:e247/128
    gateway fe80::5298:b8ff:fed2:3001

可以看到这里的 IPv6 地址设的就是我们刚刚 SLACC 自动获取到的那个 IPv6 地址。当然,如果你的机器拥有一整个 IPv6 子网所有 IP 的使用权限的话,也可以自己设置,比如设成 2001:da8:7000:15::1。

网关这里我们手动配置了。而在原始配置中是通过 SLACC 的 RA(Router Advertisement)自动获取的。这里的网关就是在原始配置里运行ip -6 r命令,会看到这样一行,就是获取到的 IPv6 网关了:

1
default via fe80::5298:b8ff:fed2:3001 dev vmbr0 proto kernel metric 1024 onlink pref medium

这里的网关以 fe80 开头,是 link local 的地址。有关 link local,前面贴的一串博文中已经详细介绍了。

vmbr1 网卡

vmbr1 网卡是用于 NAT 的,划分了一个 IPv4 内部子网和一个 IPv6 内部子网,并且分别使用 iptables 配置了 NAT4 和 NAT6:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
auto vmbr1
iface vmbr1 inet static
    address 172.16.1.1/24
    bridge_ports none
    bridge_stp off
    bridge_fd 0
    post-up echo 1 > /proc/sys/net/ipv4/ip_forward
    post-up echo 1 > /proc/sys/net/ipv4/conf/vmbr1/proxy_arp
    post-up iptables -t nat -A POSTROUTING -s '172.16.1.0/24' -o vmbr0 -j MASQUERADE
    post-down iptables -t nat -D POSTROUTING -s '172.16.1.0/24' -o vmbr0 -j MASQUERADE
    
iface vmbr1 inet6 static
    address 2001:db8:1::1/64
    post-up sysctl -w net.ipv6.conf.all.forwarding=1
    post-up ip6tables -t nat -A POSTROUTING -s 2001:db8:1::/64 -o vmbr0 -j MASQUERADE
    post-down sysctl -w net.ipv6.conf.all.forwarding=0
    post-down ip6tables -t nat -D POSTROUTING -s 2001:db8:1::/64 -o vmbr0 -j MASQUERADE

所有小鸡,如果接入这个虚拟网卡,都需要使用 172.16.1.1 作为 IPv4 的网关,使用 2001:db8:1::1 作为 IPv6 的网关,以便 NAT 转发流量。

IPv6 按照设计标准其实是不必使用 NAT 的,但为什么这里还是配置了 NAT6?因为在有些网络情况下小鸡无法分配到独立的 IPv6 地址,这一点我们在下面一节来看。

vmbr2 网卡

接下来我们配置 vmbr2 网卡,用于把机器拥有的/64 网段中的 IP 分给小鸡。

1
2
3
4
5
6
auto vmbr2
iface vmbr2 inet6 static
    address 2001:da8:7000:15:20c:29ff:feff:e247/64
    bridge_ports none
    bridge_stp off
    bridge_fd 0

这里的子网掩码配置的是/64,代表该网卡管理/64 段的 IP 地址。同时将 2001:da8:7000:15:20c:29ff:feff:e247 本机 IP 作为网关。小鸡需要分配 IP 时需要将其设置为自己的网关。

此时还有一个问题,就是我们可能拿到了/64 段的 IP(即:通过 SLACC 路由器发送给我们的信息是子网前缀为 64),但实际上不能自由分配其中的 IP,即实际上我们拿到的仅仅是一个与我们 MAC 地址对应的/128 的 IP。到底是否可以自由分配,可以通过下面这个脚本验证:

https://github.com/spiritLHLS/ecs/blob/main/archive/eo6s.sh

其实原理就是用 ip addr add 命令随便拿一个子网下的 IP 附加到机器上,然后访问 IP 地址查询看看出网 IP 是否是新附加的这个 IP。如果是的话,说明我们附加新 IP 成功了,也就是可以自由地拿到子网下任何一个 IP。那么我们当然就也可以从中给小鸡自由分配 IPv6 地址了。

而如果显示子网掩码为 128 的话,就不能自由分配了。这时候 vmbr2 网卡就失去了存在的意义,我们就只能用 vmbr1 网卡给小鸡开 NAT6 的 IP 了。

网络生效

改动网络配置文件后,一般我们使用这样的命令使其生效:

1
2
service networking restart
systemctl restart networking.service

官方文档提供的命令是:

1
ifreload -a

但实际上测试发现很多情况下单纯这些命令不能生效,必须要重启机器才行。可能是缓存或者路由表没有刷新的问题。

内核参数

这是/etc/network/interfaces 中新增的参数:

1
2
3
4
5
6
7
net.ipv6.conf.all.forwarding=1
net.ipv6.conf.all.proxy_ndp=1
net.ipv6.conf.default.proxy_ndp=1
net.ipv6.conf.vmbr0.proxy_ndp=1
net.ipv6.conf.vmbr1.proxy_ndp=1
net.ipv6.conf.vmbr2.proxy_ndp=1
net.ipv4.ip_forward=1

其中 proxy_ndp 是为了在网卡上代理小鸡的 NDP(Neighbor Discovery Protocol)协议。关于这个协议可以自行谷歌。

运行 ndpresponder

在一些情况下 NDP 协议可能没法被完全代理,可以参考这篇博文。因此可以使用这个软件:

https://github.com/yoursunny/ndpresponder

这是一个用户态的 NDP 代理工具。配置 systemd 服务(/etc/systemd/system/ndpresponder.service)如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[Unit]
Description=NDPPD Daemon
After=network.target
 
[Service]
ExecStart=/usr/local/bin/ndpresponder -i vmbr0 -n 2001:da8:7000:15:20c:29ff:feff::/64
Restart=on-failure
RestartSec=2
 
[Install]
WantedBy=multi-user.target

小鸡配置

完全使用 NAT4 和 NAT6 只需要配置一个 vmbr1 网卡就行,如下:

当然如果不使用 IPv6,可以不用填。

如果还需要独立 IPv6 的话,需要增加一个 vmbr2 网卡:

注意如果没有设置防火墙策略的话,默认是屏蔽所有端口的。所以还不如直接在这里把防火墙的对勾去掉。

一种基于 Gin + etcd 的微服务架构实现:Gin Hybrid Microservice

2023年7月26日 08:05
Featured image of post 一种基于 Gin + etcd 的微服务架构实现:Gin Hybrid Microservice

背景

由于写某个项目的需要,设计了一套基于 Gin 的微服务框架。

框架基于原先Gin Hybrid的代码上进行修改,增加了 etcd 作为服务注册中心、配置中心和发现中心,以及使用 JSON API 进行微服务间通信的功能。

现在 Go 圈子中没有一套像 Spring 一样大一统的微服务框架,但也有不少优秀的解决方案,例如国内的go-zero、国外的Go Micro以及 B 站开源的Kratos等等。

但这些框架都略微复杂,具有一定学习成本。例如 go-zero 使用自研的模板生成工具,用起来感觉很奇怪。另外在 go-zero 的架构中,service 层实现具体的逻辑暴露 gRPC 接口,controller 层暴露 JSON API 接口,使用 gRPC 去调用 service 层。service 层微服务之间的调用走的是 gRPC。

额外增加的 gRPC 和 protobuf 声明带来了复杂性,不易于修改。在不同微服务上也免不了重复声明同一个 dto。另外也还需要 JSON API 的层暴露给用户。

这种架构在大型的多人团队中比较合适,但作为 one man 的小型项目来说未免过于臃肿。

因此 Gin Hybrid Microservice 想要实现的是一个适合 one man 使用的迷你微服务框架。

另外使用了 Go 1.18 的泛型特性,将许多模型定义进行了简化。

目前项目发布的地址是 Gin Hybrid 的microservice分支。

架构

Gin Hybrid Microservice 使用如下架构:

  • Gin Hybrid 的路由封装,具体可以查看之前的博客文章
  • etcd 作为服务注册中心、发现中心和配置中心
  • 不论是微服务之间调用还是用户调用,都通过统一格式的 HTTP JSON API

下面展示所使用的例子是一个算法平台的架构。该平台架构图如下所示(不是重点):

所有微服务共用一个仓库,在 cmd 包中包含不同的启动目录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
. // cmd包
├── algo
│   ├── config.toml
│   └── main.go
├── cmd.go
├── file
│   ├── config.toml
│   └── main.go
├── gateway
│   ├── config.toml
│   └── main.go
├── model
│   ├── config.toml
│   └── main.go
└── user
    ├── config.toml
    └── main.go

在每个微服务的main.go中注册各自的路由。

config.toml中提供微服务的本地配置,例如:

1
2
3
4
5
6
7
8
name = "algo"
ip = "10.10.10.1"

[etcd]
endpoints = ["x.x.x.x:2379"]
user = "root"
pass = "root"
namespace = "vc"

name为微服务的名称,必须全局唯一。ip为微服务间互相访问的 IP,也就是其他微服务能够通过这个 IP 地址对其进行 RPC 调用,通常为内网地址。再往下则是连接 etcd 的配置文件。

端口号将从 etcd 中读取,不需要在本地配置中写。

鉴权

在通常的微服务架构中,有一个承担网关职责的微服务,在 Gin Hybrid Microservice 中也是如此。用户不直接访问各个微服务的端口,而是将网关服务作为用户所有流量的入口,这样可以方便配置上层 Nginx 进行反代等操作。

在 Gin Hybrid Microservice 中,网关微服务不承担用户鉴权的职责,只是根据路由,将用户请求完整转发到各个微服务中,附带上所有的 HTTP Headers 等信息。各个微服务自行完成提取 JWT Token 及后续的鉴权步骤。

组件

etclient

etclient 包提供 etcd 操作的客户端封装。同时负责将微服务自身通过注册到 etcd,并赋予自己一个 LeaseID,定时续租。

关于 etcd 的 Lease 机制可以参考一下网络上的文档。

默认 Lease 的租期是 10s,在 5s 的时候会续租一次。因为考虑到微服务可能出现超过 10s 的网络中断情况,所以在续租失败的情况下将会重新申请 LeaseID,抛弃旧的 ID。

一个微服务可能依赖其他微服务的 RPC 接口,在这种情况下,服务应使用 etcd 的 watch 机制和 prefix 机制,监听某一个微服务目录下服务实例的上线下线情况,以便更新自己调用该服务时使用的负载均衡列表。但如果为服务自身从 etcd 断线,watch 机制便无法收到断线这段时间内的变更。因此,重新连接后需要重新使用 Get By Prefix 获取到最新的服务实例列表。

 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 (c *Client) registerServiceOnce() error {
	resp, err := c.client.Lease.Grant(context.Background(), leaseTTL)
	if err != nil {
		return err
	}
	c.leaseID = resp.ID
	err = c.updateListDirectory()
	if err != nil {
		return err
	}
	log.Println("registered service successfully with lease id: " + strconv.Itoa(int(c.leaseID)))
	go c.serviceRegisterEventObservers.NotifyAll()
	return nil
}
// updateListDirectory register current service into the etcd directory
func (c *Client) updateListDirectory() error {
	value := c.conf.IP + ":" + strconv.Itoa(c.conf.Port)
	err := c.PutRawKey("list/"+c.conf.Name+"/"+strconv.Itoa(int(c.leaseID)), value, clientv3.WithLease(c.leaseID))
	if err != nil {
		return err
	}
	log.Println("updated list directory: " + value)
	return nil
}

以上 registerServiceOnce 函数在初次启动微服务时调用,并在每次从 etcd 断线后都调用。该函数会申请一个 LeaseID,将自己的服务实例注册到 etcd 的对应目录下(value 中写入当前微服务的 IP 和端口以供其他微服务访问)。最后调用 NotifyAll 函数通知一个观察者列表,以此获取当前服务依赖的那些服务的最新状态。后续要介绍的 rest 包中有对应逻辑,将服务依赖的更新函数添加到这个列表中。

conf

conf 包读取本地配置文件,初始化当前微服务,并加载 etcd 的云端配置文件。使用 watch 机制监听云端配置文件的状态并进行及时更新。同时还承担了初始化一些公共的依赖,例如数据库的职责。

配置文件分为以下几种:

  • InitConf:本地配置文件,即为config.toml中所写的内容。不同微服务的格式均相同。
  • ParentConf:父级配置文件,所有微服务都共有的配置项,例如数据库的配置、JWT 的配置等等。不同微服务的格式均相同。
  • SelfConf:每个微服务自身独有的配置项,例如启动端口等。使用了泛型。不同微服务可以自己定义格式。

这些配置项被封装在 conf 包的核心结构体 ServiceConfig 中,与此同时该结构体还包括一些其他依赖,如 DB 等:

1
2
3
4
5
6
7
8
type ServiceConfig[T any] struct {
	InitConf     Init
	ParentConf   Parent
	SelfConf     T
	Etclient     *etclient.Client
	InitConfPath string
	DB           *gorm.DB
}

不同微服务虽然端口不一样,但端口字段的形式是一样的(都是port)。所有还需要一个Common结构,用于定义一些相同字段但值不同的配置项,让所有 SelfConf 都以组合的形式「继承」它。

因此各种 SelfConf 的定义看起来可能是这样的:

 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
type Gateway struct {
	Common
}

type User struct {
	Common
	Email UserEmail `toml:"email"`
}
type UserEmail struct {
	Address  string `toml:"address"`
	Username string `toml:"username"`
	Password string `toml:"password"`
	Host     string `toml:"host"`
	Port     int    `toml:"port"`
	TLS      bool   `toml:"tls"`
}

type Algo struct {
	Common
	Mq string `toml:"mq"`
}

type File struct {
	Common
}

type Model struct {
	Common
}

在 conf 初始化之时,还不能启动 web server,因为端口需要从 etcd 中读取。因此需要先初始化 etclient。在通过 etclient 获取到端口后,再调用 etclient 的 RegisterService 函数,正式将当前微服务注册到 etcd 的服务实例列表中。同时启动 watch 线程监听配置文件变化。

LoadConfig 函数在初始化 etclient 后,依次读取父级配置文件和自身配置文件,并通过反射从自身配置文件中读取到Common部分的内容,从中提取出本服务的启动端口。

 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
func LoadConfig[T any](config *ServiceConfig[T]) (*etclient.Client, error) {
	// load local config
	_, err := toml.DecodeFile(config.InitConfPath, &config.InitConf)
	if err != nil {
		return nil, err
	}
	// initialize etclient using local config
	etclientConf := etclient.Conf{
		Endpoints: config.InitConf.Etcd.Endpoints,
		Namespace: config.InitConf.Etcd.Namespace,
		Name:      config.InitConf.Name,
		IP:        config.InitConf.IP,
		User:      config.InitConf.Etcd.User,
		Pass:      config.InitConf.Etcd.Pass,
		Port:      0, // not available for now
	}
	etclientIns, err := etclient.NewClient(etclientConf)
	if err != nil {
		return nil, err
	}
	parentV, err := etclientIns.GetRawKey("parent_config")
	if err != nil && err != etclient.ErrNotExist {
		return nil, err
	}
	err = toml.Unmarshal([]byte(parentV), &config.ParentConf)
	if err != nil {
		return nil, err
	}
	// initialize config for current service
	configV, err := etclientIns.GetRawKey(config.InitConf.Name + "/config")
	if err != nil && err != etclient.ErrNotExist {
		return nil, err
	}
	err = toml.Unmarshal([]byte(configV), &config.SelfConf)
	if err != nil {
		return nil, err
	}
	commonV := reflect.ValueOf(&config.SelfConf).Elem().FieldByName("Common").Interface().(Common)
	etclientConf.Port = commonV.Port
	err = etclientIns.RegisterService(etclientConf)
	if err != nil {
		return nil, err
	}
	go watchConfigThread(config)
	return etclientIns, nil
}

rest

rest 包负责处理微服务间的依赖关系,以及服务间进行 RPC 调用的逻辑。

要创建一个 restClient,需要传入*conf.ServiceConfig对象。一个服务启动时只需要创建一个 restClient,后续使用该 client 添加所有的服务依赖。

一个微服务可能依赖多个其他微服务。开发者在 cmd 包中调用 AddServiceDependency 函数通过服务名称声明依赖的服务,得到一个*Service对象。其函数定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (c *Client[T]) AddServiceDependency(name string) (*Service, error) {
	service := &Service{
		Name:         name,
		Endpoints:    map[clientv3.LeaseID]string{},
		mu:           sync.Mutex{},
		etclientInst: c.srvConf.Etclient,
		httpClient:   c.httpClient,
		rpcKey:       c.srvConf.ParentConf.RPCKey,
	}
	err := service.UpdateServiceDirectory()
	if err != nil {
		return nil, err
	}
	go service.updateServiceDirectoryThread()
	c.srvConf.Etclient.AddServiceRegisterEventListener(func() {
		err := service.UpdateServiceDirectory()
		if err != nil {
			log.Println("observer failed to update service directory of " + service.Name + ": " + err.Error())
		}
	})
	c.services = append(c.services, service)
	return service, nil
}

AddServiceDependency 方法创建并返回一个 Service 对象,更新运行该微服务的所有节点列表,并启动 watch 线程。同时向 etclient 添加一个监听器函数,用于在 etclient 发生网络错误重连时及时更新节点列表。

由于不区分 JSON API 的路由与 RPC Call 的路由,部分敏感的服务可能只允许微服务间调用使用,而不允许用户直接耐用使用。因此设定了一个 RPCKey 机制,微服务间互相调用都会在 HTTP Header 中带上这个头,在路由绑定时可以指定某个路由是否为 RPCOnly。如果是的话,则需要校验 RPCKey。RPCKey 的具体配置也在 ParentConf 中。

router 包中,在 Gin Hybrid 的基础之上增加了 RPCKey 的校验:

1
2
3
4
5
6
7
if apiRouter.RPCOnly {
	rpcKey := ctx.GetHeader("X-RPC-Key")
	if rpcKey != conf.ParentConf.RPCKey {
		ctx.JSON(401, "direct API Call sent to RPC-only routes")
		return
	}
}

在持有 Service 对象后,可以通过 Call 方法进行微服务间的调用。该方法的函数签名如下:

1
func (s *Service) Call(v any, method string, path string, data any, jwt string) error

v变量应该为一个指向结构体的指针,用于接收返回的结果。method为接口调用的方法,如 GET、POST 等。若为 GET,将调用参数序列化后通过 URL Parameter 的方式发送;如果为 POST,将调用参数通过 URL 编码后放置于 POST Body 中发送。path变量为接口的路径。data变量是调用时传入的 HTTP 请求参数,可以为 map 或一个结构体。如果为结构体,将通过反射的方式从中取得所有值并进行序列化。

jwt为用户的 jwt,可以为空。若不为空,则将其使用在Authorization头中,作为用户鉴权使用。这是由于 gateway 微服务不承担用户鉴权的职责,而是每个微服务各自进行 JWT 鉴权。因此在对需要进行用户鉴权的接口进行 RPC 调用时,只需要附带上当前微服务从用户那边收到的 JWT Token 即可;而对于仅供 RPC 调用的接口,应该在路由声明处设置 RPCOnly 为 true,Call 函数会自动带上 RPCKey 的头。

所有 JSON API 的封装均为如下:

1
2
3
4
5
type Result struct {
	Code int             `json:"code"`
	Msg  string          `json:"msg,omitempty"`
	Data json.RawMessage `json:"data,omitempty"`
}

Call 函数将从该类型微服务的实例列表中随机取出一个实例地址进行调用,如果调用错误,将返回 error;如果调用成功,则把 Data 字段的 JSON 内容 Unmarshal 到传入的 v 参数中,返回的 error 为 nil。

service

service 包存放业务逻辑。在创建 Service 实例时应传入其对应的conf.ServiceConfig对象以便从中读取配置,并进行一些初始化操作等。如果该服务依赖其他微服务,也应该在 New 的函数中传入。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type AlgoService struct {
	srvConf               *conf.ServiceConfig[conf.Algo] // 对应配置实例
	algoDAO               *dao.AlgoDAO // 初始化数据库 DAO
	mqClient              *mq.Client // 初始化 RabbitMQ Client
	instructionUpdateChan chan<- dto.MqInstruction // 初始化 channel
	userService           *rest.Service // algo 服务依赖的 user 服务
}

func NewAlgoService(srvConf *conf.ServiceConfig[conf.Algo], mqClient *mq.Client,
	userService *rest.Service) *AlgoService {
	instructionUpdateChan := make(chan dto.MqInstruction, 128)
	srv := &AlgoService{srvConf: srvConf, algoDAO: dao.NewAlgoDAO(srvConf.DB), mqClient: mqClient,
		instructionUpdateChan: instructionUpdateChan, userService: userService}
	statusDelivery, err := srv.mqClient.ConsumeQueue("status")
	if err != nil {
		panic(err)
	}
	logDelivery, err := srv.mqClient.ConsumeQueue("log")
	if err != nil {
		panic(err)
	}
	go srv.mqHandler(statusDelivery, logDelivery, instructionUpdateChan)
	return srv
}

data/dto

DTO(Data Transfer Objects)包用于存放用户与服务、服务与服务之间数据传输所用的结构体。当用户与服务之间传递时,可以在 Service 中用 Gin 标准的方法绑定参数到结构体上:

1
2
3
4
var req dto.CreateUpdateProjectReq
if err := aw.Ctx.ShouldBind(&req); err != nil {
	return aw.Error(err.Error())
}

当服务与服务之间传递时,可以之间将结构体对象传入 Call 方法中:

1
2
3
4
5
6
err = a.userService.Call(nil, "post", "/send_message", dto.UserSendMessage{
	TaskGroupID: taskGroup.ID,
	UserID:      user.ID,
	Email:       user.Email,
	Message:     msg,
}, "")

一个 RPCOnly 为 false 的接口可以由用户之间调用,也可以由服务之间 RPC 调用。如果这两种调用都使用同一种结构,那么在同一个仓库、同一个包的存放 dto 就能避免使用多仓库时的重复声明。

cmd

cmd 包中存放每个微服务各自的入口包,每个微服务声明所需的依赖,如依赖的其他服务、依赖的组件等(所有微服务共同依赖的组件应在 conf.ServiceConfig 中声明)。例如对于 algo 微服务的main.go

1
2
3
4
5
6
7
8
9
func main() {
	srvConf := conf.MustNewServiceConfig[conf.Algo]()
	mqClient := mq.MustNewClient(srvConf.SelfConf.Mq)
	restClient := rest.NewClient(srvConf)
	userService := restClient.MustAddServiceDependency("user")
	cmd.Entry(cmd.EntryConfig{Port: srvConf.SelfConf.Port}, func(engine *gin.Engine, api *gin.RouterGroup) {
		router.RegisterAPIRouters(getRouters(srvConf, mqClient, userService), api, srvConf)
	})
}

getRouters 同样是一个main.go中的函数,用于注册路由:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func getRouters(srvConf *conf.ServiceConfig[conf.Algo], mqClient *mq.Client,
	userService *rest.Service) []router.APIRouter {
	srv := service.NewAlgoService(srvConf, mqClient, userService)
	routers := []router.APIRouter{
		{
			Method:   "post",
			Path:     "/raw_data",
			Handlers: router.AssembleHandlers(middleware.Auth, srv.CreateRawData),
		},
        ...
		{
			Method:   "delete",
			Path:     "/task_groups/subscriptions",
			Handlers: router.AssembleHandlers(srv.DeleteEmailSubscription),
			RPCOnly:  false,
		},
	}
	return routers
}

总结

本文介绍了一种基于 Gin + etcd 的微服务架构实现:Gin Hybrid Microservice。该架构的主要特点是:

  • 使用 Gin 作为 web 框架,提供了一种简洁的路由封装方式,支持 RESTful API 和模板渲染的混合模式。
  • 使用 etcd 作为服务注册中心、配置中心和服务发现中心,利用 Lease 机制实现服务实例的自动注册和续租,利用 watch 机制实现配置文件和服务列表的实时更新。
  • 不论是微服务之间调用还是用户调用,都通过统一格式的 HTTP JSON API,避免了额外引入 gRPC 和 protobuf 的复杂性。同时使用 RPCKey 机制保证了部分敏感接口只能由微服务间调用。
  • 使用泛型特性简化了配置文件和 rest 客户端的定义,提高了代码的复用性和可读性。
  • 使用 DTO 包存放数据传输对象,避免了在不同微服务间重复声明同一个结构体。

该架构的优点是:

  • 简单易用,适合 one man 或小型团队使用,无需学习复杂的框架和工具,只需掌握 Gin 和 etcd 的基本用法即可。
  • 灵活可扩展,可以根据不同业务场景自定义微服务的功能和依赖,也可以根据需要增加或减少微服务的数量和类型。
  • 高效可靠,使用 HTTP JSON API 作为通信协议,保证了数据传输的速度和兼容性,使用 etcd 作为中心化的管理组件,保证了服务实例和配置文件的一致性和可用性。

总之,Gin Hybrid Microservice 是一种轻量级的微服务架构实现,旨在提供一种快速开发、部署和运维微服务应用的方案。希望本文能够对有兴趣使用 Go 语言开发微服务应用的读者有所帮助和启发。

Go Gin:一种同时支持 REST API 和 Go Template 服务端模板渲染的解决方案

2023年2月21日 08:45
Featured image of post Go Gin:一种同时支持 REST API 和 Go Template 服务端模板渲染的解决方案

背景

在现实生产环境中,我们常常需要在同一网站中提供两种服务:一种是针对客户端的 REST API,另一种是基于服务器端的 Go Template 服务端模板渲染。这两种服务有不同的使用场景,因此需要同时支持。但是,由于 SEO 等方面的考虑,我们也不能完全使用单页面应用(SPA)的架构,而必须采用服务端渲染。

这个问题在实际生产中很普遍。比如,在一个电商网站中,我们需要提供给客户端一个接口用于获取商品列表,同时也需要在服务端渲染出页面,让搜索引擎能够索引网站内容并提高 SEO 效果。但是如果 REST API 和 Go Template 服务端模板渲染调用的是不同的后端服务,就会存在数据不一致、接口定义不统一等问题,同时也造成了重复开发。

为了解决这个问题,我们需要一种同时支持 REST API 和 Go Template 服务端模板渲染,并调用同一后端服务的解决方案。这样就能够保证数据的一致性和接口的统一性,同时也能够保证 SEO 效果。本文尝试基于 Gin 架构提供一种这样的解决方案,可以帮助我们实现同时支持 REST API 和 Go Template 服务端模板渲染,从而更好地满足我们的业务需求。

项目结构

GitHub 地址:https://github.com/juzeon/gin-hybrid/

项目结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.
├── data # 数据结构定义
│   └── dto
├── go.mod
├── go.sum
├── LICENSE
├── main.go
├── middleware # 中间件
│   └── auth.go
├── pkg # 常用包
│   ├── app
│   └── util
├── README.md
├── router # 路由
│   ├── router.go
│   ├── user.go
│   └── web_router.go
├── service # 服务
│   ├── service.go
│   └── user.go
└── web # Go Template模板
    ├── static
    └── template

分解介绍

入口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
	engine := gin.New()
	engine.Use(gin.Logger(), nice.Recovery(router.RecoveryFunc))
	service.Setup()
	router.Setup(engine)
	err := engine.Run(fmt.Sprintf(":%v", 7070))
	if err != nil {
		panic(err)
	}
}

这段代码是项目的入口,它主要实现了以下功能:

  1. 创建一个 Gin 引擎实例 engine,用于处理 HTTP 请求。
  2. 使用 gin.Logger() 中间件记录请求日志,并使用 nice.Recovery() 中间件处理请求时的异常情况。
  3. 调用 service.Setup() 初始化项目的服务层。
  4. 调用 router.Setup(engine) 加载路由配置。
  5. 启动 HTTP 服务,监听 7070 端口,处理客户端的请求。如果启动服务失败,会通过 panic(err) 来抛出异常。

服务

无论是 REST API 还是 Go Template 均调用统一的服务。对于每一个服务(例如 User 服务),其声明都是类似的:

1
2
3
4
5
6
type UserService struct {
}

func NewUserService() *UserService {
	return &UserService{}
}

使用结构体来定义服务的目的是,如果该服务存在依赖项(例如某个 DAO 层的示例),可以通过 New 函数传入并作为结构体内的变量接收,类似于 Spring Boot 中的 Bean。这样的好处是能很明确地知道某个服务依赖了哪些外部 Bean。

以 Login 函数为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func (u UserService) Login(aw *app.Wrapper) app.Result {
	type UserLoginReq struct {
		Username string `form:"username" binding:"required"`
		Password string `form:"password" binding:"required"`
	}
	var req UserLoginReq
	if err := aw.Ctx.ShouldBind(&req); err != nil {
		return aw.Error(err.Error())
	}
	if req.Username != "admin" || req.Password != "123456" {
		return aw.Error("Wrong username or password (tips: admin, 123456)")
	}
	jwt := util.GenerateJWT(1, 5, "administrator")
	aw.Ctx.SetCookie("hybrid_authorization", jwt, 60*60*24*365, "/", "", false, true)
	return aw.Success(jwt)
}

这段代码是一个用户登录函数,主要实现以下功能:

  1. 定义了一个 UserLoginReq 结构体用于接收客户端发送的登录请求,其中包括用户名和密码两个字段。
  2. 使用 Gin 的 ShouldBind() 方法将客户端发送的表单数据绑定到 UserLoginReq 结构体中。
  3. 对于绑定过程中的错误,返回一个带有错误信息的 Result。
  4. 对于用户名和密码的验证,如果不是指定的用户名和密码,返回一个带有错误信息的 Result。
  5. 使用 util.GenerateJWT() 方法生成 JWT(Json Web Token)并将其存储在 HTTP Cookie 中。
  6. 返回一个带有 JWT 信息的 Result。

在登录部分既通过 REST API 方式返回了生成的 JWT,以便客户端等获取到并保存到本地缓存中;又调用 SetCookie 函数设置 JWT Cookie,这是因为以模板为基础的前端可能通过 axios.post 这样的方式请求登录接口,因此可以让浏览器自己的逻辑将 Cookie 设置好。注意 SetCookie 的 httponly 为 true,可以一定程度上保证安全性,但也使 JavaScript 不可操作该 Cookie,因此 SetCookie 函数是必须的。

服务层的 Setup 函数负责初始化服务 Bean,以供路由调用:

1
2
3
4
5
var ExUser *UserService

func Setup() {
	ExUser = NewUserService()
}

app 包

上述服务函数传入一个 aw *app.Wrapper,返回一个 app.Result,这与传统 Gin 项目使用 gin.Context 有所不同。实际上 app.Wrapper 是对 gin.Context 的封装,app.Result 是对 REST API 的 JSON 返回格式的统一定义,并提供了一些辅助函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ...
type Result struct {
	Code    int         `json:"code"`
	Msg     string      `json:"msg,omitempty"`
	Data    interface{} `json:"data,omitempty"`
	wrapper *Wrapper
}
// ...
type Wrapper struct {
	Ctx *gin.Context
}
// ...

其中 Wrapper 提供一个 ExtractUserClaims 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (w Wrapper) ExtractUserClaims() *dto.UserClaims {
	raw, exist := w.Ctx.Get("userClaims")
	if !exist {
		panic("userClaims not exists")
	}
	uc, ok := raw.(*dto.UserClaims)
	if !ok {
		panic("userClaims failed to convert")
	}
	return uc
}

// dto.UserClaims
type UserClaims struct {
	jwt.StandardClaims
	UserID    int       `json:"user_id"`
	RoleID    int       `json:"role_id"`
	RoleName  string    `json:"role_name"`
	LoginTime time.Time `json:"login_time"`
}

该方法从 gin.Context 中获取 userClaims 变量,作为 JWT 解析结果,即用户的 JWT 信息。userClaims 变量是在路由层被解析和设置的,我们稍后会介绍到相关的代码。

路由

路由层是整个项目架构的核心,担当使 REST API 和模板共存的职能。

在 Setup 函数中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func Setup(e *gin.Engine) {
	e.Use(func(ctx *gin.Context) {
		if ctx.GetHeader("Authorization") != "" {
			return
		}
		if token, err := ctx.Cookie("hybrid_authorization"); err == nil {
			ctx.Request.Header.Set("Authorization", token)
		}
	})

	api := e.Group("/api")
	RegisterAPIRouters(GetUserAPIRouters(), api.Group("/user"))

	e.HTMLRender = loadTemplates()
	e.Static("/static", "web/static")
	RegisterWebRouters(GetWebRouters(), e)
}

该函数是路由层的初始化函数,由 main 函数调用。e 是从 main 函数传入的 gin.Engine。

代码首先为 gin.Engine 加入了一个中间件。由于客户端等直接调用 REST API 的应用使用 Authorization 的 HTTP Header,以Bearer ...的形式传递 JWT Token,而网页则是使用hybrid_authorization的 Cookie。该中间件将这两种传递方式相统一。

接下来是 API 接口的注册部分。将所有 REST API 注册在/api目录下。其有关的两个函数我们稍后会介绍到。

最后是模板页面的注册部分。我们将web/static作为静态资源注册在/static目录下,并注册 Go Template 模板页面。其中 loadTemplates 函数返回了一个基于github.com/gin-contrib/multitemplate的多模板渲染引擎,因为我们将模板进行了拆分。以下是web目录的具体结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
.
├── static # 存放静态资源,直接挂载于 /static 目录下。例如一些css和js等。
│   ├── js
│   │   ├── app_axios.js
│   │   └── helper.js
│   └── style
│       └── layout.css
└── template # 存放go template
    ├── base # 布局页面,包括html、head、meta等标签,其中body的内容部分将由具体选择的页面决定。
    │   └── layout.gohtml
    ├── head # 可按需引入的头文件,例如在用户设置页面为了更换头像需要引入头像裁剪的类库,而在其他页面则不需要。这样可以缩减页面引入文件的数量和体积。
    │   ├── image_processor.gohtml
    │   └── marked.gohtml
    ├── page # 每个具体的页面
    │   ├── index.gohtml # 主页
    │   └── user
    │       ├── login.gohtml # 用户登录页
    │       └── me.gohtml # 用户信息页
    └── standalone # 额外的独立页面
        └── error.gohtml # 错误页面

API 路由

在传统的 Gin 开发中,我们常常在路由中为调用一个服务插入一系列前置的处理函数或中间件。例如一个展示用户信息的服务需要确认用户已经登录,可能还需要判断用户权限。因此它将在调用具体的服务函数之前插入鉴权中间件,该中间件可能检查 JWT Token,检查权限,并将 JWT 解析后的实体设置到 gin.Context 中等。

因此在我们的封装中,对于每一个 API 调用(我们称之为一个 APIRouter),也包含了一系列中间件和服务,我们称之为 Handler:

1
2
3
4
5
type APIRouter struct {
	Method   string // HTTP 类型,如 get 或 post
	Path     string // 接口路径,如 /login
	Handlers []func(aw *app.Wrapper) app.Result // 一系列的处理函数,其函数签名即为中间件、服务函数的签名
}

我们将每一个 APIRouter 看作是一个整体,对应一个独立的 API 服务。GetUserAPIRouters 函数便返回了一组 APIRouter,包括登录、查看用户信息和一个测试的获取信息服务:

 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
func GetUserAPIRouters() []APIRouter {
	srv := service.ExUser
	routers := []APIRouter{
		{
			Method:   "post",
			Path:     "/login",
			Handlers: AssembleHandlers(srv.Login),
		},
		{
			Method:   "get",
			Path:     "/me",
			Handlers: AssembleHandlers(middleware.Auth, srv.Me),
		},
		{
			Method:   "get",
			Path:     "/get_data",
			Handlers: AssembleHandlers(srv.GetData),
		},
	}
	return routers
}

// middleware.Auth
func Auth(aw *app.Wrapper) app.Result {
	authHeader := aw.Ctx.GetHeader("Authorization")
	if strings.HasPrefix(authHeader, "Bearer ") {
		authHeader = authHeader[7:]
	}
	claims, err := util.ParseJWT(authHeader)
	if err != nil {
		return aw.Error("Login Required")
	}
	aw.Ctx.Set("userClaims", claims)
	return aw.OK()
}
// ...
func AssembleHandlers(handlers ...func(aw *app.Wrapper) app.Result) []func(aw *app.Wrapper) app.Result {
	var result []func(aw *app.Wrapper) app.Result
	for _, handler := range handlers {
		result = append(result, handler)
	}
	return result
}

AssembleHandlers 仅仅是一个辅助函数,作为一个variadic function,将传入的可变数量的 handlers 组装为切片并返回。

/me接口获取了用户信息,因此在之前需要使用 Auth 中间件鉴权。Auth 中间件检查 HTTP Header 中的 Authorization 头,如果解析成功,则将其设置在 gin.Context 的 userClaims 中。如果解析失败,则返回一个错误类型的 app.Result,我们自定义的路由处理函数捕获到这个错误,就会停止于此,不会继续调用后面的 srv.Me 函数。RegisterAPIRouters 函数即实现了这个功能:

 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
func RegisterAPIRouters(apiRouters []APIRouter, g *gin.RouterGroup) {
	if !strings.HasPrefix(g.BasePath(), "/") {
		panic("BasePath must start with /: " + g.BasePath())
	}
	for _, apiRouter := range apiRouters {
		apiRouter := apiRouter
		if !strings.HasPrefix(apiRouter.Path, "/") {
			panic("Path must start with /: " + apiRouter.Path)
		}
		apiRouter.Method = strings.ToLower(apiRouter.Method)
		commonHandler := func(ctx *gin.Context) {
			aw := app.NewWrapper(ctx)
			var result app.Result
			for _, handler := range apiRouter.Handlers {
				result = handler(aw)
				if !result.IsSuccessful() {
					break
				}
			}
			ctx.JSON(result.GetResponseCode(), result)
		}
		switch apiRouter.Method {
		case "get":
			g.GET(apiRouter.Path, commonHandler)
		case "post":
			g.POST(apiRouter.Path, commonHandler)
		default:
			panic("method " + apiRouter.Method + " not found")
		}
		PathAPIRouterMap[g.BasePath()+apiRouter.Path] = apiRouter
	}
}

函数接受一个 APIRouter 的切片(对应一组完成的 API,如用户登录、获取用户信息等),使用 for 遍历对于每一个 APIRouter 都进行处理。而对于每一个 APIRouter,链式调用其中的 handlers。一旦某一个 handler 返回的 app.Result 结果包含错误码,则认为本 APIRouter 的调用出错,停止调用后续的 handler,返回 result 的结果。我们使用 gin.Context 的 JSON 方法将 app.Result 实例转为 JSON 并写入 HTTP Response。

PathAPIRouterMap 是一个全局 map 变量,将路径(如/user/login)映射到对应的 APIRouter,以便后续模板路由使用。

因此我们刚刚提到的注册 user 路由的部分调用了方才阐释的两个函数:

1
RegisterAPIRouters(GetUserAPIRouters(), api.Group("/user")) // 将 user 的路由挂载到 /user 目录下

模板路由

定义模板路由的结构体,与 APIRouter 对应:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type WebRouter struct {
	Name           string               // name of router
	OverwritePath  string               // use this to rewrite relativePath if it's not null
	UseAPIs        []APIRouter          // APIRouters to call
	Process        func(map[string]any) // additionally process renderMap
	Title          string
	GetTitle       func(map[string]any) string // use GetTitle instead of Title if this function exists
	GetKeywords    func(map[string]any) string
	GetDescription func(map[string]any) string
}

每个 WebRouter 对应一个在服务端渲染的 HTML 页面,其中包含一个以 UseAPIs 命名的 APIRouter 切片,用于存放该页面调用的 APIRouter,因为有可能一个页面需要调用多个 API。

与 APIRouter 的声明类似,GetWebRouters 返回一个 WebRouter 的切片。AssemblePaths 与 AssembleHandlers 功能类似,将variadic function的 path 映射为该页面需要调用的 APIRouters(通过先前填充的 PathAPIRouterMap 完成路径到 APIRouter 实例的映射)。

 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
func GetWebRouters() []WebRouter {
	routers := []WebRouter{
		{
			Name:  "index",
			Title: "Index",
		},
		{
			Name:  "user/login",
			Title: "Login",
		},
		{
			Name:    "user/me",
			Title:   "User Information",
			UseAPIs: AssemblePaths("/user/me"),
		},
	}
	return routers
}
// ...
func AssemblePaths(paths ...string) []APIRouter {
	var routers []APIRouter
	for _, path := range paths {
		if !strings.HasPrefix(path, "/") {
			panic("path must start with /: " + path)
		}
		if !strings.HasPrefix(path, "/api") {
			path = "/api" + path
		}
		router, ok := PathAPIRouterMap[path]
		if !ok {
			panic("router path " + path + " not exist")
		}
		routers = append(routers, router)
	}
	return routers
}

函数 GetWebRoutersCommonAPIs 定义了通用调用 API,在所有页面渲染前均需要调用,如获取用户信息的接口。每个页面均可以使用此信息获取用户的登录状态,如果用户已登录,那么其中将包含更多的附加信息。map[string]APIRouter的键部分即该实体在模板中被使用的变量名,按照 Go Template 的写法,如.user.UserID在用户已登录状态下即为用户 ID。

GetWebRoutersFuncs 定义了 Go Template 中可以使用的辅助函数。包含sprig的函数库和自定义的一些函数。

两个函数代码如下:

 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
func GetWebRoutersCommonAPIs() map[string]APIRouter {
	return map[string]APIRouter{
		"user": AssemblePaths("/user/me")[0],
	}
}

func GetWebRoutersFuncs() map[string]any {
	merged := map[string]any{}
	for key, item := range map[string]any(sprig.FuncMap()) {
		merged[key] = item
	}
	custom := map[string]any{
		"raw": func(str string) template.HTML {
			return template.HTML(str)
		},
		"concat": func(values ...any) string {
			v := ""
			for range values {
				v += "%v"
			}
			return fmt.Sprintf(v, values...)
		},
		"ago": func(value time.Time) string {
			return timeago.NoMax(timeago.Chinese).Format(value)
		},
	}
	for key, item := range custom {
		merged[key] = item
	}
	return merged
}

GetWebRouters 函数返回的结果被传入到 RegisterWebRouters 函数中,在这个函数中注册模板路由。其实现与 RegisterAPIRouters 大同小异,区别在于根据 UseAPIs 的列表调用了多个 API,并使用 gin.Context 的 HTML 函数渲染模板。

 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
// ...

// call APIs specified by templates
for ix, apiRouter := range webRouter.UseAPIs {
	for _, apiHandler := range apiRouter.Handlers {
		result = apiHandler(aw)
		if !result.IsSuccessful() {
			break
		}
	}
	if !result.IsSuccessful() {
		ctx.HTML(result.GetResponseCode(), "error.gohtml", result)
		return
	}
	if ix == 0 {
		renderMap["d"] = result.Data
		renderMap["code"] = result.Code
		renderMap["msg"] = result.Msg
	} else {
		renderMap["d"+strconv.Itoa(ix)] = result.Data
		renderMap["code"+strconv.Itoa(ix)] = result.Code
		renderMap["msg"+strconv.Itoa(ix)] = result.Msg
	}
}

// call common APIs
for name, apiRouter := range GetWebRoutersCommonAPIs() {
	for _, apiHandler := range apiRouter.Handlers {
		result = apiHandler(aw)
		if !result.IsSuccessful() {
			break
		}
	}
	renderMap[name] = result.Data
}

// ...

ctx.HTML(200, templateName+".gohtml", renderMap)

对于 UseAPIs 中的 API 调用返回的结果,以.d作为访问的实体。如调用的 API 对应的 APIRouter 在 REST API 中最终将返回一个Data中包含Name的 JSON,那么在模板中使用.d.Name即可访问这项数据。UseAPIs 第二项的实体名为.d1,第三项为.d2,以此类推。可以通过检查.code.msg判断错误的发生情况。

模板和 Vue

本模板项目提供了可选的 Vue 3 和 Vuetify 支持(使用 CDN Mode),在web/template/base/layout.gohtml包含对其的初始化。

为了使分页面写法的 Vue 页面在 IDE 中得到更好的代码提示,layout 页面使用了一个 dummy function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<script>
    let pageVueOptions

    // dummy function for pages
    function createApp(options) {
        pageVueOptions = options
        return {
            mount(str) {
            }
        }
    }
</script>

之后在每个具体业务逻辑的页面中可以参考使用如下模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{{define "head"}}
{{end}}

<div id="page">
    {{define "page"}}
    
    {{end}}
</div>

{{define "script"}}
<script>
    createApp({
        data() {
            return {
            }
        },
    }).mount("#page")
</script>
{{end}}

这样可以让每个页面的写法更类似于 Vue 的单页面组件。在head块中引入所需要的外部资源文件(为了保证每个页面引入的资源版本统一,可以在web/template/head中定义);在page块中定义页面的 HTML 代码部分;在script块中定义 JavaScript 逻辑。

需要特别提醒的是,为了更好的 SEO,请仔细考虑使用 Go Template 的 for 函数和 Vue 的 v-for 函数的不同场景。对于博客主页的文章列表,为了让搜索引擎更好地索引,我们需要在服务端渲染博文列表和链接,因此我们在 UseAPIs 中声明 API 调用,并在模板中使用 Go Template 的 for 函数;而对于一些 SEO 不太重要的页面,例如用户的关注列表,可以在前端使用 axios 异步调用 REST API,然后使用 v-for 函数动态渲染到页面上。

解决高德地图 JavaScript API:map.containerToLnglat is not a function

Featured image of post 解决高德地图 JavaScript API:map.containerToLnglat is not a function

高德的官方文档质量实在太差了,同一个地方方法名大小写都不一样。

在 JS API v2 的升级指南中提到LngLat的大小写变更:

但实际上在 v1 的 SDK 中就已经改变大小写了,如果按照 v1 的文档调用会报错is not a function

相关链接:

https://lbs.amap.com/api/javascript-api/guide/transform/coord_trans

https://lbs.amap.com/api/jsapi-v2/update

对国内环境比较友好的地图,相比之下,高德应该算是勉强能用的了。iOS 中国部分的地图用的也是高德的底图。但奈何不住文档实在太烂,开发起来也十分费劲。

如果不是对中国区的底图有特别精确的需求,或者需要路径规划之类的功能,建议选用Open Street Map。基于这个底图的地图也有不少,例如 Web 平台上的Leaflet

加不加「/」?Nginx location 路径与 proxy_pass 的规律

2022年5月17日 11:00
Featured image of post 加不加「/」?Nginx location 路径与 proxy_pass 的规律

从一张梗图开始

起源于在 TG 某个频道看到的一张图:

图下面的评价是:Nginx is so hard!

实际上这张图描述的是 nginx location 的路径配置,及 location 代码块中 proxy_pass 的路径关系,属于 nginx 应用中路径转发的知识。例如图中 Case 1 对应的代码块应该为:

1
2
3
location /test1 {
    proxy_pass http://127.0.0.1:8080;
}

其中 127.0.0.1:8080 是运行的一个后端服务。

例如域名为example.com,那么我在域名后加上 Test URL:example.com/test1/abc/test,那么我的后端服务接收到的路径将是:/test1/abc/test

咋一看似乎完全没有规律,其实之前在一些 nginx 实践中,我个人也深受这个问题的困扰。网上许多文章也并没有详细地解释这个问题。

但把这张梗图和一位朋友交流后,发现了其中的规律。

规律

重点在于 proxy_pass URL 的 IP(域名)、端口后是否有加东西。

如果 proxy_pass URL 的 IP、端口后没加东西

例如:proxy_pass 为http://127.0.0.1:8080,属于没加东西的,而http://127.0.0.1:8080/http://127.0.0.1:8080/app1http://127.0.0.1:8080/app1/这些都归为一类,属于有加东西的。

那么,将 nginx 接收到的 URL(即图中的 Test URL),原封不动地直接加到 proxy_pass URL 后面,就成为了后端程序接收到的路径。

图中的 Case 1 3 9 都是这种情况。

当然,前提是 Test URL 需要与 location 后声明的表达式本身匹配。

如果 proxy_pass URL 的 IP、端口后有加东西

即使是加了一个「/」,也叫有加东西。

如果是这种情况,则进行如下操作:

  1. 将 nginx 接收到的 URL(即图中的 Test URL)中删掉 nginx location 的前缀。
  2. 将上一步得到的字符串直接加到 proxy_pass URL 后面。
  3. 上一步得到的字符串 IP、端口后面的部分,就是后端程序接收到的路径。

例如在 Case 2 中:

Test URL 是/test2/abc/test,nginx location 是/test2。那么将 Test URL 中去掉 nginx location 的部分即为:/abc/test。而 proxy_pass URL 为http://127.0.0.1:8080/,直接加到这后面,得到http://127.0.0.1:8080//abc/test。取 IP、端口后面的部分,为://abc/test。这也就是后端程序接收到的路径中会有两个「/」的原因。

可以自行用这个规律套一下后面几个 Case,都能够符合。

▓后缀 RLO 控制字符欺骗░░

2014年12月16日 08:00

先上一张图: 大概谁也没见过这么奇怪的选择吧..

这是用 Unicode 控制字符完成的操作,关于这方面大家可能了解的比较少,现在我来科普一下~

首先在记事本里打入以下的字:

右键’我是’和‘淀粉’中间,选择插入 unicode 控制字符——RLO 字符

变成了这样: (我是粉淀)

其实这个字符的作用是把这个字符以后的文本反过来,利用这里点就可以做出第一幅图的欺骗样式。假如这本来是一个病 + 毒的应用程序(picpgj.exe),黑 + 客在中间插入一个 RLO 字符,将会变成“picexe.jpg”如果你的文件夹浏览方式不是“详细信息”,那极有可能被误 导而打开病 + 毒,后果将是非常危险的。。。

所以,资源管理器最好要将浏览方式选成“详细信息”,这样比较安全。运行文件时也要留意其中有没有敏感的字符,比如“EXE”、“com”等等。。

还有一种方法: 最好把这个显示 Unicode 给选上!.

还有其他的控制字符,有各种不同的效果,大家可以自己慢慢尝试~~

VB 窗体内有菜单,就无法无边框化的解决方法

2014年12月14日 08:00

在窗体内任意地方输入下列代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<pre class="brush:vb;toolbar:false">Private   Declare   Function   GetWindowLong   Lib   "user32"   Alias   
"GetWindowLongA"   (ByVal   hwnd   As   Long,   ByVal   nIndex   As   
Long)   As   Long   
  Private   Declare   Function   SetWindowLong  
 Lib   "user32"   Alias   "SetWindowLongA"   (ByVal   hwnd   As   
Long,   ByVal   nIndex   As   Long,   ByVal   dwNewLong   As   Long)   
As   Long   
    
  Private   Sub   Form_Load()   
  Dim   sy   As   Long   
  Dim   newsy   As   Long   
  Const   GWL_STYLE   =   -16   
  Const   WS_CAPTION   =   &HC00000   
  Const   WS_BORDER   =   &H800000   
  sy   =   GetWindowLong(Me.hwnd,   GWL_STYLE)   
  newsy   =   SetWindowLong(Me.hwnd,   GWL_STYLE,   sy   -   WS_CAPTION   -   WS_BORDER)   
  End   Sub
❌
❌