2024年11月

背景

前段时间为公司的产品增加了磁力链、种子下载的能力,测试时发现网上搜到的热门种子,有时好用,有时不好用,不好用主要表现在:没速度、速度慢、速度不稳定、下载一部分后没速度等,严重拖累了测试工作。为此,想到搭建一套内网测试环境,用来保证下载速度的同时,还能自己制作测试种子,方便控制种子的文件数、总大小等,从而进行各种各样的测试。

方案

网上参考了一些案例 (附录 2),发现大部分都是在公网环境搭建的,比如一些 NAS 设备,本身有公网 IP,可以在内网直接请求。然而我仅有的两台设备,都没有公网 IP,想申请公网 IP 也非常难,其中一台 Linux 测试机还要通过跳板机访问:

主要的服务部署在 Linux 机上,这样除了我自己,测试人员也能用;下载侧就放在 Mac 上,或者移动设备,目前有一台 Android 和一台 iPhone;需要注意的是,出于安全管控,Linux 机只有 8000~9000 范围内的端口可以建立侦听,这对后面服务侦听端口的设置有一定影响。

tracker

主要提供种子的声明、查询 peer 的能力。这里需要搭建内网 tracker 的主要原因是:
如果指定公网 tracker,那么它们只能记录公司 gateway 的 IP 作为 peer 地址,通过这个地址是无法找到并连接内网供种机的,即使下载者位于内网

这里选取的是 opentracker,它本身是开源的,除去性能强大外,安装也比较简单,直接源码编译,这里归纳为以下脚本:

#! /bin/sh
if [ ! -f "libowfat.tar.gz" ]; then
    wget --no-check-certificate https://kokodayo.site/usr/uploads/Opentracker/libowfat.tar.gz
fi

if [ ! -d "libowfat" ]; then
    tar xvf libowfat.tar.gz
fi

cd libowfat
# note: add -std=gnu99 for GNUmakefile
# CFLAGS=-std=gnu99 -pipe $(WARN) $(DEFINE) $(OPT_REG)
# CFLAGS_OPT=-std=gnu99 -pipe $(WARN) $(DEFINE) $(OPT_PLUS)
make -j2
cd ..

if [ ! -f "opentracker.tar.gz" ]; then
    wget --no-check-certificate https://kokodayo.site/usr/uploads/Opentracker/opentracker.tar.gz
fi

if [ ! -d "opentracker" ]; then
    tar xvf opentracker.tar.gz
fi

cd opentracker
# note: add -D_GNU_SOURCE for Makefile
# CFLAGS+=-I$(LIBOWFAT_HEADERS) -Wall -pipe -Wextra -D_GNU_SOURCE #-ansi -pedantic
make -j2

if [ ! -f /usr/bin/opentracker ]; then
    sudo cp opentracker /usr/bin/opentracker
fi

cd ..

echo "done!"

在 CentOS 7.9 和 gcc 4.8.5 上编译这两个库,存在两个坑,需要修改配置文件解决。

问题 I:libowfat -std=gnu99

注意是加在 GNUMakefile 而不是 Makefile,后者不生效。没添加之前会报下面的编译错误:

问题 II:opentracker -D_GNU_SOURCE

没添加时会报下面的编译错误:

这两个问题,怀疑是 gcc 版本过低所致。

编译

$ make
cc -c -o opentracker.o -I../libowfat -Wall -pipe -Wextra -D_GNU_SOURCE  -O3 -DWANT_FULLSCRAPE opentracker.c
cc -o opentracker opentracker.o trackerlogic.o scan_urlencoded_query.o ot_mutex.o ot_stats.o ot_vector.o ot_clean.o ot_udp.o ot_iovec.o ot_fullscrape.o ot_accesslist.o ot_http.o ot_livesync.o ot_rijndael.o -L../libowfat -lowfat -pthread -lpthread -lz
strip opentracker
$ ls -lh opentracker
-rwxr-xr-x 1 yunhai01 DOORGOD 88K Oct 25 17:10 opentracker

编译时 opentracker 无脑找 ../libowfat 目录作为输入库,只要保证两个库同目录即可。

脚本最后将生成的 opentracker 复制到
/usr/bin
以便全局生效。

运行

将 opentracker 配置为系统服务,在 CentOS 上就是通过 systemctl,它需要一个配置文件:

[Unit]
Description=opentracker server

[Service]
User=yunhai01
ExecStart=/usr/bin/opentracker -p 8888 -P 8888
Restart=on-abort

[Install]
WantedBy=multi-user.target

将这个文件放置在:
/usr/lib/systemd/system/opentracker.service
,注意命令带的两个参数 -p 和 -P,分别表示侦听的 tcp & udp 端口,需要选取 8000~9000 之间的端口,原因这里不再赘述。

之后可以通过下面的命令启动 opentracker:

$ sudo systemctl enable opentracker.service
Created symlink from /etc/systemd/system/multi-user.target.wants/opentracker.service to /usr/lib/systemd/system/opentracker.service.
$ sudo systemctl start opentracker.service
$ systemctl status opentracker.service
● opentracker.service - opentracker server
   Loaded: loaded (/usr/lib/systemd/system/opentracker.service; disabled; vendor preset: disabled)
   Active: active (running) since Sat 2024-10-12 15:59:31 CST; 1 weeks 6 days ago
 Main PID: 107808 (opentracker)
   CGroup: /system.slice/opentracker.service
           └─107808 /usr/bin/opentracker -p 8888 -P 8888

第一条 enable 命令,设置了开机自启动;第二条 start 命令启动了服务;第三条 status 命令查看命令状态。
举一反三:停止是 stop,开机不启动就是 disable 啦。

除了在 Linux 的命令行查看服务状态,还可以通过 web 查看,地址是:
http://yunhai.bcc-bdbl.baidu.com:8888/stats

其中
yunhai.bcc-bdbl.baidu.com
是 Linux 机域名,8888 是刚才在配置文件中指定的端口。

图中输出显示 tracker 中记录了 9 个种子,14 个 peer,12 个供种 peer。想要查看详细信息,可以加上
?mode=everything
参数。

最后需要注意的是,如果开启了防火墙,需要添加相应的出入端口以保证服务可以被网络上的其它设备访问。

供种侧

主要提供种子制作、上传、做种的能力,这里选取在 Linux 上应用广泛的 Transmission 软件包,它是很多 Linux 发行版的默认 BT 下载工具,对 Linux、Mac、Windows 等 PC 设备支持的比较好。也可以选取其它流行的 BT 工具,这方面没有限制。

安装

使用 Transmission 前需要安装,这里直接通过包管理器安装:

$ sudo yum install transmission transmission-daemon transmission-cli

其中 daemon 是后台服务,用于供种;cli 是命令行工具,用来和 daemon 交互;工具包中的其它工具可以制作、查看种子文件。

运行

daemon 的配置文件位于:
/var/lib/transmission/.config/transmission-daemon/settings.json
,在启动前需要做一些配置。

{
    "alt-speed-down": 50,
    "alt-speed-enabled": false,
    "alt-speed-time-begin": 540,
    "alt-speed-time-day": 127,
    "alt-speed-time-enabled": false,
    "alt-speed-time-end": 1020,
    "alt-speed-up": 50,
    "bind-address-ipv4": "0.0.0.0",
    "bind-address-ipv6": "::",
    "blocklist-enabled": false,
    "blocklist-url": "http://www.example.com/blocklist",
    "cache-size-mb": 4,
    "dht-enabled": false,
    "download-dir": "/var/lib/transmission/Downloads",
    "download-queue-enabled": true,
    "download-queue-size": 5,
    "encryption": 2,
    "idle-seeding-limit": 30,
    "idle-seeding-limit-enabled": false,
    "incomplete-dir": "/var/lib/transmission/Downloads",
    "incomplete-dir-enabled": false,
    "lpd-enabled": false,
    "message-level": 1,
    "peer-congestion-algorithm": "",
    "peer-id-ttl-hours": 6,
    "peer-limit-global": 200,
    "peer-limit-per-torrent": 50,
    "peer-port": 8082,
    "peer-port-random-high": 9000,
    "peer-port-random-low": 8000,
    "peer-port-random-on-start": false,
    "peer-socket-tos": "default",
    "pex-enabled": true,
    "port-forwarding-enabled": true,
    "preallocation": 1,
    "prefetch-enabled": true,
    "queue-stalled-enabled": true,
    "queue-stalled-minutes": 30,
    "ratio-limit": 2,
    "ratio-limit-enabled": false,
    "rename-partial-files": true,
    "rpc-authentication-required": true,
    "rpc-bind-address": "0.0.0.0",
    "rpc-enabled": true,
    "rpc-host-whitelist": "",
    "rpc-host-whitelist-enabled": false,
    "rpc-password": "{5a5bfec5cb304bb9018c9fcf985f87eec2053f00joXosjaH",
    "rpc-port": 8081,
    "rpc-url": "/transmission/",
    "rpc-username": "admin",
    "rpc-whitelist": "127.0.0.1",
    "rpc-whitelist-enabled": false,
    "scrape-paused-torrents-enabled": true,
    "script-torrent-done-enabled": false,
    "script-torrent-done-filename": "",
    "seed-queue-enabled": false,
    "seed-queue-size": 10,
    "speed-limit-down": 100,
    "speed-limit-down-enabled": false,
    "speed-limit-up": 100,
    "speed-limit-up-enabled": false,
    "start-added-torrents": true,
    "trash-original-torrent-files": false,
    "umask": 18,
    "upload-slots-per-torrent": 14,
    "utp-enabled": true
}

主要修改的字段如下:

  • 端口范围
    • rpc-port:通过 rpc 访问的端口,设置为 8081
    • peer-port:告诉 tracker 下载侧连接的端口,设置为 8082
    • peer-port-random-low, peer-port-random-high:随机 peer-port 允许的范围,设置为 8000~9000
  • 用户账户
    • rpc-authentication-required:需要帐密校验,不设置这个直接裸访问会失败
    • rpc-username:用户名,设置为 admin
    • rpc-password:密码,设置为 abc123

密码在服务重启后会加密,所以看到一串奇怪的数字字符组合不要惊讶。

如果 daemon 已在运行,所有变更均会在退出时被服务用当前配置覆盖,所以要确保服务停止后再修改配置文件。

daemon 的启动、状态查询、开机启动和 opentracker 一致,这里不再赘述。

查询

服务启动后可通过 web 查看,地址是:
http://yunhai.bcc-bdbl.baidu.com:8081/transmission/web/index.html

注意 8081 端口就是配置文件中的 rpc-port 字段。

网上有一些美化后的 WebUI (例如 TWC),可以无缝替换官方简陋的界面:

安装也不复杂:

$ wget https://github.com/ronggang/transmission-web-control/raw/master/release/install-tr-control-cn.sh
$ sudo sh install-tr-control-cn.sh
/bin/whoami

	欢迎使用 Transmission Web Control 中文安装脚本。
	官方帮助文档:https://github.com/ronggang/transmission-web-control/wiki
	安装脚本版本:1.2.5

	1. 安装最新的发布版本(release);
	2. 安装指定版本,可用于降级;
	3. 恢复到官方UI;
	4. 重新下载安装脚本(install-tr-control-cn.sh);
	5. 检测 Transmission 是否已启动;
	6. 指定安装目录;
	9. 安装最新代码库中的内容(master);
	===================
	0. 退出安装;

	请输入对应的数字:1

具体可参考附录 4。关于 CUI 命令行的查询方式,在下一节制种中介绍。

制种

基于原文件,transmission-create 可以一键制种:

$ transmission-create -h
Usage: transmission-create [options] <file|directory>

Options:
 -h --help                    Display this help page and exit
 -p --private                 Allow this torrent to only be used with the
                              specified tracker(s)
 -o --outfile   <file>        Save the generated .torrent to this filename
 -s --piecesize <size in KiB> Set how many KiB each piece should be, overriding
                              the preferred default
 -c --comment   <comment>     Add a comment
 -t --tracker   <url>         Add a tracker's announce URL
 -V --version                 Show version number and exit

这里主要使用 -t 参数:

$ ls -lh *.mp4
-rwxr--r-- 1 yunhai01 DOORGOD 1.2G Oct 25 19:59 盲道.mp4
$ transmission-create 盲道.mp4 -t http://yunhai.bcc-bdbl.baidu.com:8888/annouce
Creating torrent "/ext/torrent/movie/盲道.mp4.torrent" ........ done!
$ ls -lh *.torrent
-rw------- 1 yunhai01 DOORGOD 24K Oct 25 20:01 盲道.mp4.torrent

-t 指定 tracker 地址,这里需要输入上面搭建的内网 tracker 地址:
http://yunhai.bcc-bdbl.baidu.com:8888/annouce

否则在加载种子的时候,Transmission 不知道向哪个 tracker 声明 (announce) 种子
,允许指定多个 -t 参来添加多个 tracker 地址;-o 可以指定输出的种子文件名,默认文件命名规则为"输入文件.torrent";-s 可以指定分片 (piece) 大小,如果不指定,transmission-create 将根据文件长度智能设定。

分片是数据校验的基础,针对每个分片,种子文件中都会存储它的 md5 以及其它的一些必要信息,方便下载完成后进行对比。如果分片太小,分片数量就会非常多,导致种子文件膨胀。因此大文件的分片通常会大一些,保证整个种子文件大小控制在几百 KB 的尺寸。

transmission-edit 可以修改种子的 tracker 信息:

$ transmission-edit -h
Usage: transmission-edit [options] torrent-file(s)

Options:
 -h --help                Display this help page and exit
 -a --add     <url>       Add a tracker's announce URL
 -d --delete  <url>       Delete a tracker's announce URL
 -r --replace <old> <new> Search and replace a substring in the announce URLs
 -V --version             Show version number and exit

方便添加遗漏的 tracker 信息,还能删改。

transmission-show 可以查看种子的详情:

$ transmission-show -h
Usage: transmission-show [options] <.torrent file>

Options:
 -h --help     Display this help page and exit
 -m --magnet   Give a magnet link for the specified torrent
 -s --scrape   Ask the torrent's trackers how many peers are in the torrent's
               swarm
 -V --version  Show version number and exit

不带参数时显示种子信息;-m 将种子转换为磁力链接;-s 查询种子所在的 tracker,获取 peer 信息:

$ transmission-show 盲道.mp4.torrent
Name: 盲道.mp4
File: 盲道.mp4.torrent

GENERAL

  Name: 盲道.mp4
  Hash: 7659ead46555d97a98e25a373d8216edc926d78a
  Created by: Transmission/2.94 (d8e60ee44f)
  Created on: Fri Oct 25 20:01:31 2024
  Piece Count: 1200
  Piece Size: 1.00 MiB
  Total Size: 1.26 GB
  Privacy: Public torrent

TRACKERS

  Tier #1
  http://yunhai.bcc-bdbl.baidu.com:8888/annouce

FILES

  盲道.mp4 (1.26 GB)

种子信息分三大块:

  • 通用
    • 文件名
    • 唯一 hash 值
    • 分片长度
    • 分片个数
    • 文件长度
    • 各种时间
  • tracker:列表
  • 文件:列表
$ transmission-show 盲道.mp4.torrent -m
magnet:?xt=urn:btih:7659ead46555d97a98e25a373d8216edc926d78a&dn=%E7%9B%B2%E9%81%93.mp4&tr=http%3A%2F%2Fyunhai.bcc-bdbl.baidu.com%3A8888%2Fannouce
$ transmission-show movie/月光宝盒.mkv.torrent -s
Name: 月光宝盒.mkv
File: movie/月光宝盒.mkv.torrent

http://1337.abcvg.info:80/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... error: unexpected response 502 "Bad Gateway"
http://bvarf.tracker.sh:2086/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... no match
http://ipv6.rer.lol:6969/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... error: Couldn't connect to server
http://jvavav.com:80/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... error: Couldn't resolve host name
http://retracker.x2k.ru:80/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... no match
http://yunhai.bcc-bdbl.baidu.com:8888/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... 1 seeders, 1 leechers

-m 转为磁力链方便在设备间传递;-s 查询 peer 信息,新制作的种子因为还未声明到 tracker,是查不到 peer 信息的,这里使用另外一个添加好的种子做个展示,它有 6 个 tracker,返回的信息中:有的解析不了、有的连不上、有的返回了错误、有的不包含这个种子、还有的正确返回了 peer 和 seeder 的数量。

如果所有原文件都位于一个目录,例如 movie,那么可以使用脚本一键生成所有种子:

for file in $(ls *.mkv); do
	echo "create $file.torrent"
	param="-t http://yunhai.bcc-bdbl.baidu.com:8888/announce"
	transmission-create ${param} $PWD/$file
done

添加

有两种方式可以将种子添加到 Transmission:WebUI 与 CUI 命令行,先说比较简单的 WebUI 方式:

这种方式需要事先将种子传递到启动 WebUI 的设备,并选取种子。注意这里的保存目录,是指启动 Transmission 服务的设备存放源文件的地方,如果设置不对,Transmission 无法进行数据检验,就会认为是一个下载任务而非做种任务。

刚添加的种子会进行检验,这个过程结束后就可以正常做种了。

另外一种方式是通过 transmission-remote,它除了添加种子,还可以列出、查看、删改种子,这个命令功能强大选项多,这里就不一一罗列了,只讲一下日常使用的几个子命令:

$ transmission-remote 8081 -n admin:abc123 -si
VERSION
  Daemon version: 2.94 (d8e60ee44f)
  RPC version: 15
  RPC minimum version: 1

CONFIG
  Configuration directory: /var/lib/transmission/.config/transmission-daemon
  Download directory: /var/lib/transmission/Downloads
  Listenport: 8082
  Portforwarding enabled: Yes
  uTP enabled: Yes
  Distributed hash table enabled: No
  Local peer discovery enabled: No
  Peer exchange allowed: Yes
  Encryption: required
  Maximum memory cache size: 4.00 MiB

LIMITS
  Peer limit: 200
  Default seed ratio limit: Unlimited
  Upload speed limit: Unlimited (Disabled limit: 100 kB/s; Disabled turtle limit: 50 kB/s)
  Download speed limit: Unlimited (Disabled limit: 100 kB/s; Disabled turtle limit: 50 kB/s)

MISC
  Autostart added torrents: No
  Delete automatically added torrents: No

-si 展示 session 信息。

$ transmission-remote 8081 -n admin:abc123 -st

CURRENT SESSION
  Uploaded:   115.6 GB
  Downloaded: None
  Ratio:      Inf
  Duration:   13 days (1139476 seconds)

TOTAL
  Started 1 times
  Uploaded:   115.6 GB
  Downloaded: None
  Ratio:      Inf
  Duration:   13 days (1139476 seconds)

-st 展示 session 状态。

$ transmission-remote -l
ID     Done       Have  ETA           Up    Down  Ratio  Status       Name
   4   100%    6.23 GB  Done         0.0     0.0    1.4  Idle         EP01-06.mkv
   5   100%    7.39 GB  Done         0.0     0.0    1.0  Idle         EP07-13.mkv
   6   100%    7.32 GB  Done         0.0     0.0    1.0  Idle         EP14-20.mkv
Sum:          20.94 GB               0.0     0.0
$ transmission-remote 8081 -n admin:abc123 -l
ID     Done       Have  ETA           Up    Down  Ratio  Status       Name
   7   100%    6.23 GB  Done         0.0     0.0    0.5  Idle         EP01-06.mkv
   8   100%    7.39 GB  Done         0.0     0.0    0.0  Idle         EP07-13.mkv
   9   100%    7.32 GB  Done         0.0     0.0    0.0  Idle         EP14-20.mkv
 163   100%    1.01 GB  Done         0.0     0.0   17.3  Idle         月光宝盒.mkv
 164   100%    1.51 GB  Done         0.0     0.0   10.9  Idle         西游伏魔篇.rmvb
 165   100%    4.61 GB  Done         0.0     0.0    6.0  Idle         大开眼界.mkv
 166   100%    1.65 GB  Done         0.0     0.0    5.5  Idle         逆转时空.mp4
 167   100%   647.2 MB  Done         0.0     0.0    8.7  Idle         大圣娶亲.rmvb
 168   100%    2.94 GB  Done         0.0     0.0    6.0  Idle         龙马精神.mp4
Sum:          33.32 GB               0.0     0.0

-l 列出所有种子。如果不指定账号密码,则只能查看本地添加的种子,通过 WebUI 添加的看不到。其中 ID 字段很重要,后面索引文件时会使用。

$ transmission-remote 8081 -n admin:abc123 -t 168 -i
NAME
  Id: 168
  Name: 龙马精神.mp4
  Hash: b46c1fa9b7e18b2fd91bdb3f6d7130a966865d99
  Magnet: magnet:?xt=urn:btih:b46c1fa9b7e18b2fd91bdb3f6d7130a966865d99&dn=%E9%BE%99%E9%A9%AC%E7%B2%BE%E7%A5%9E.mp4&tr=http%3A%2F%2F1337.abcvg.info%3A80%2Fannounce&tr=http%3A%2F%2Fbvarf.tracker.sh%3A2086%2Fannounce&tr=http%3A%2F%2Fipv6.rer.lol%3A6969%2Fannounce&tr=http%3A%2F%2Fjvavav.com%3A80%2Fannounce&tr=http%3A%2F%2Fretracker.x2k.ru%3A80%2Fannounce&tr=http%3A%2F%2Fyunhai.bcc-bdbl.baidu.com%3A8888%2Fannounce

TRANSFER
  State: Idle
  Location: /ext/torrent/movie
  Percent Done: 100%
  ETA: 0 seconds (0 seconds)
  Download Speed: 0 kB/s
  Upload Speed: 0 kB/s
  Have: 2.94 GB (2.94 GB verified)
  Availability: 100%
  Total size: 2.94 GB (2.94 GB wanted)
  Downloaded: None
  Uploaded: 17.68 GB
  Ratio: Inf
  Corrupt DL: None
  Peers: connected to 1, uploading to 0, downloading from 0

HISTORY
  Date added:       Mon Oct 14 14:35:37 2024
  Date started:     Mon Oct 14 14:36:08 2024
  Latest activity:  Fri Oct 25 11:41:15 2024
  Seeding Time:     11 days (972122 seconds)

ORIGINS
  Date created: Mon Oct 14 14:31:42 2024
  Public torrent: Yes
  Creator: Transmission/2.94 (d8e60ee44f)
  Piece Count: 1404
  Piece Size: 2.00 MiB

LIMITS & BANDWIDTH
  Download Limit: Unlimited
  Upload Limit: Unlimited
  Ratio Limit: Default
  Honors Session Limits: Yes
  Peer limit: 50
  Bandwidth Priority: Normal

-t 指定查看的种子,-i 展示详情,注意下面的命令都要搭配 -t 使用。

$ transmission-remote 8081 -n admin:abc123 -t 168 -if
龙马精神.mp4 (1 files):
  #  Done Priority Get      Size  Name
  0: 100% Normal   Yes   2.94 GB  龙马精神.mp4

-if 展示种子文件列表详情。

$ transmission-remote 8081 -n admin:abc123 -t 168 -it

  Tracker 0: http://1337.abcvg.info:80
  Active in tier 0
  Got a list of 1 peers 4 hours (17784 seconds) ago
  Asking for more peers in 4 hours (17362 seconds)
  Tracker had 0 seeders and 0 leechers 4 hours (14768 seconds) ago
  Asking for peer counts in 6 hours (24449 seconds)

  Tracker 1: http://bvarf.tracker.sh:2086
  Active in tier 1
  Got a list of 1 peers 15 minutes (948 seconds) ago
  Asking for more peers in 1 hour, 44 minutes (6268 seconds)
  Tracker had 1 seeders and 0 leechers 15 minutes (948 seconds) ago
  Asking for peer counts in 14 minutes (859 seconds)

  Tracker 2: http://ipv6.rer.lol:6969
  Active in tier 2
  Got an error "Could not connect to tracker" 13 minutes (780 seconds) ago
  Asking for more peers in 1 hour, 47 minutes (6430 seconds)
  Got a scrape error "Could not connect to tracker" 1 hour, 16 minutes (4619 seconds) ago
  Asking for peer counts in 43 minutes (2599 seconds)

  Tracker 3: http://jvavav.com:80
  Active in tier 3
  Got an error "Could not connect to tracker" 7 minutes (454 seconds) ago
  Asking for more peers in 1 hour, 53 minutes (6789 seconds)
  Got a scrape error "Could not connect to tracker" 1 hour, 18 minutes (4721 seconds) ago
  Asking for peer counts in 41 minutes (2499 seconds)

  Tracker 4: http://retracker.x2k.ru:80
  Active in tier 4
  Got a list of 2 peers 18 minutes (1101 seconds) ago
  Asking for more peers in 21 minutes (1299 seconds)
  Tracker had 1 seeders and 1 leechers 18 minutes (1101 seconds) ago
  Asking for peer counts in 11 minutes (699 seconds)

  Tracker 5: http://yunhai.bcc-bdbl.baidu.com:8888
  Active in tier 5
  Got a list of 2 peers 18 minutes (1114 seconds) ago
  Asking for more peers in 13 minutes (795 seconds)
  Tracker had 1 seeders and 1 leechers 18 minutes (1114 seconds) ago
  Asking for peer counts in 11 minutes (689 seconds)

-it 展示种子 tracker 列表。

$ transmission-remote 8081 -n admin:abc123 -t 168 -ic
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111
  11111111 11111111 11111111 11111111 11111111 11111111 11111111 1111

-ic 展示种子分片下载详情,注意这里展示的下载块位图,上传没有位图可显示,因为 peer 可能有多个,且每个可能只申请一部分数据。

$ transmission-remote 8081 -n admin:abc123 -t 168 -ip
Address               Flags         Done  Down    Up      Client

-ip 展示种子 peer 详情。

$ transmission-remote 8081 -n admin:abc123 -t 168 -S
localhost:8081/transmission/rpc/ responded: "success"
$ transmission-remote 8081 -n admin:abc123 -t 168 -l
ID     Done       Have  ETA           Up    Down  Ratio  Status       Name
 168   100%    2.94 GB  Done         0.0     0.0    6.0  Stopped      龙马精神.mp4
Sum:           2.94 GB               0.0     0.0
$ transmission-remote 8081 -n admin:abc123 -t 168 -s
localhost:8081/transmission/rpc/ responded: "success"
$ transmission-remote 8081 -n admin:abc123 -t 168 -l
ID     Done       Have  ETA           Up    Down  Ratio  Status       Name
 168   100%    2.94 GB  Done         0.0     0.0    6.0  Idle         龙马精神.mp4
Sum:           2.94 GB               0.0     0.0

-S 停止种子任务;-s 启动种子任务。以此类推:-a 添加种子,-r 删除种子,下面重点看下如何借助 transmission-reomte 添加本地种子资源:

$ transmission-remote 8081 -n admin:abc123 -a 盲道.mp4.torrent -w /ext/torrent/movie/
localhost:8081/transmission/rpc/ responded: "success"
$ transmission-remote 8081 -n admin:abc123  -l
ID     Done       Have  ETA           Up    Down  Ratio  Status       Name
   7   100%    6.23 GB  Done         0.0     0.0    0.5  Idle         EP01-06.mkv
   8   100%    7.39 GB  Done         0.0     0.0    0.0  Idle         EP07-13.mkv
   9   100%    7.32 GB  Done         0.0     0.0    0.0  Idle         EP14-20.mkv
 163   100%    1.01 GB  Done         0.0     0.0   17.3  Idle         月光宝盒.mkv
 164   100%    1.51 GB  Done         0.0     0.0   10.9  Idle         西游伏魔篇.rmvb
 165   100%    4.61 GB  Done         0.0     0.0    6.0  Idle         大开眼界.mkv
 166   100%    1.65 GB  Done         0.0     0.0    5.5  Idle         逆转时空.mp4
 167   100%   647.2 MB  Done         0.0     0.0    8.7  Idle         大圣娶亲.rmvb
 168   100%    2.94 GB  Done         0.0     0.0    6.0  Idle         龙马精神.mp4
 173    27%   340.8 MB  Unknown      0.0     0.0    0.0  Verifying    盲道.mp4
Sum:          33.66 GB               0.0     0.0

要领就是通过 -w 参数指定原文件所在路径,这与 WebUI 中的"保存目录"如出一辙。下面是 -w 选项的 man 说明:

 -w   --download-dir           <path>      When used in conjunction with --add,
                                           set the new torrent's download
                                           folder. Otherwise, set the default
                                           download folder

单独使用是指定全局的下载目录了。也可以不指定 -w,直接使用全局的默认下载目录,这需要修改上面介绍过的 setting.json 文件,字段名为 donwload-dir。

注意观察上面的输出,添加后也有一个 Verify 状态。

下载侧

环境搭好了,下面在 mac 端测一下吧~

这里选取 Transmission mac 客户端做演示,首先将种子加入到 Transmission 中:

种子中记录的 tracker 信息会让 Transmission 连接内网 tracker 进行查询。

在接下来的界面中选择:

  • 要下载的文件
  • 本地保存路径
  • 任务分组
  • 任务优先级
  • ....

一会儿就能看到有速度了:

刷新 tracker 统计,看到 peer 数会增加 1:

Linux 机的 Transmission 也能观察到上传速度了:

在新版 WebUI 我没找到上传进度,老版通过 peer 信息是可以看到的:

Transmission 下载侧也能看到 peer 信息,也是没有进度:

另外还能展示块的信息:

除了下载完成的块,还能查看已分配正在下载中的:

不得不说很技术范儿~

下载完成后,mac Transmission 也会加入供种行列,查看 tracker 状态:

发现供种数也增 1。

如果不方便传递种子文件,也可以通过磁力链接下载:

磁力链未解析前很多信息 Transmission 拿不到,所以只能选择存储位置:

及任务分组与优先级:

在查询到种子信息之前,任务没有进度显示:

拉到种子文件后,就一样了。

二次开发

其实公司的产品也是基于 Transmission 库做的二次开发,库名为 btsdk。一般而言,含有内网 tracker 的种子文件和磁力链,是可以直接“喂”给 btsdk,然而实际使用中,由于各种技术原因,产品传递给 sdk 的是只包含 hash 值的磁力链,一些额外的信息被“过滤”掉了,以上面的例子来说,完整的磁力链是:

magnet:?xt=urn:btih:7659ead46555d97a98e25a373d8216edc926d78a&dn=%E7%9B%B2%E9%81%93.mp4&tr=http%3A%2F%2Fyunhai.bcc-bdbl.baidu.com%3A8888%2Fannouce

传递给 btsdk 的是:

magnet:?xt=urn:btih:7659ead46555d97a98e25a373d8216edc926d78a

导致 libtransmission 不知道要去内网 tracker 请求种子信息,从而下载超时。

为了解决这个问题,需要在 debug 版本为种子任务加入本地 tracker,下面是摸索过程。

tr_torrentSetTrackerList

这个接口可以在种子任务的类型上直接设置 tracker 列表,简单粗暴但有效,总的流程是:

  • tr_torrentNew
    创建种子任务
  • tr_torrentSetTrackerList
    添加种子内网 tracker
  • 设置超时定时器
  • ....

刚开始用是没问题的,然而隔了一天再试,就全超时了。仔细分析后发现,由于目前启用了创建就启动的选项,导致这里有线程竞争:位于调用者线程的 tr_torrentSetTrackerList,和位于 libtransmission 工作线程中使用 tracker 列表进行请求的地方 (通过 tr_torrentNew 触发) 存在竞争,后者可能先于前者被系统调度,这里插入的 tracker 就不会被用到,进而导致超时。

这个问题也有简单的解决方案,就是统一设置创建种子后不自启动,等到任务准备好后,手动调用 tr_torrentStart 再启动。

不过我没有尝试这种方案,而是寻求在全局记录额外的 tracker 信息,当 libtransmission 启动任务时,会从全局记录的信息中将额外的 tracker 添加到种子任务,从而实现内网 tracker 的访问。

TR_KEY_trackerList

在 key 枚举中搜索,得到了这个标识:

设置一下试试:

tr_variant settings = {};
tr_variantInitDict(&settings, 0);
...
tr_variantDictAddStr(&settings, TR_KEY_trackerList, BT_DEBUG_TRACKER_URLS); 
...
tr_sessionLoadSettings(&settings, param.config_dir, "btsdk");
tr_session* session = tr_sessionInit(param.config_dir, true, &settings);
...
tr_ctor* ctor = tr_ctorNew(session);
int n = tr_sessionLoadTorrents(session, ctor);
tr_ctorFree(ctor);

这段代码是初始化的骨架,主要流程是:

  • tr_variantDictAddXXX 添加设置项
  • tr_sessionLoadSettings 加载设置,上面添加的设置会覆盖本地读取的设置
  • tr_sessionInit 创建会话,一个 transmission 会话可以包含多个种子文件。设置由这里传入
  • tr_ctorNew / tr_ctorFree / tr_sessionLoadTorrents 加载本地种子,用于种子持久化后的恢复

按理说 session 通过 settings 可以拿到 tracker 设置,然而实际跑了一下,新代码没有起作用,内网种子仍然超时。

TR_KEY_default_trackers

继续搜索,发现了这个,感觉要靠谱不少:

替换上面代码中的 key 后,内网种子果然有速度啦,这两个 key 的差别就需要分析源码了,具体的就不在这里展开分析了,毕竟这只是一篇环境搭建的文章,搞的太深奥了会劝退一部分读者,哈哈~

后记

复盘一下整个探索过程,发现最困难的是确认 Transmission 是否声明 (announce) peer 信息到 tracker 这一步。以 opentracker 这简陋的输出,不要说看种子下面有哪些 peer,就连有哪些种子都看不到,打开繁琐输出 (
mode=everything
) 也是一样的抽象:

<?xml version="1.0" encoding="UTF-8"?>
<stats>
  <tracker_id>121091771</tracker_id>
  <version>
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
$Source$: $Revision$
  </version>
  <uptime>1557624</uptime>
  <torrents>
    <count_mutex>10</count_mutex>
    <count_iterator>10</count_iterator>
  </torrents>
  <peers>
    <count>15</count>
  </peers>
  <seeds>
    <count>13</count>
  </seeds>
  <completed>
    <count>105</count>
  </completed>
  <connections>
    <tcp>
      <accept>37716</accept>
      <announce>24538</announce>
      <scrape>12655</scrape>
    </tcp>
    <udp>
      <overall>0</overall>
      <connect>0</connect>
      <announce>0</announce>
      <scrape>0</scrape>
      <missmatch>0</missmatch>
    </udp>
    <livesync>
      <count>0</count>
    </livesync>
  </connections>
  <debug>
    <renew>
      <count interval="00">20</count>
      <count interval="01">475</count>
      <count interval="02">4</count>
      <count interval="03">5</count>
      <count interval="04">8</count>
      <count interval="05">5</count>
      <count interval="06">8</count>
      <count interval="07">5</count>
      <count interval="08">3</count>
      <count interval="09">8</count>
      <count interval="10">5</count>
      <count interval="11">12</count>
      <count interval="12">3</count>
      <count interval="13">12</count>
      <count interval="14">10</count>
      <count interval="15">2</count>
      <count interval="16">6</count>
      <count interval="17">4</count>
      <count interval="18">2</count>
      <count interval="19">3</count>
      <count interval="20">1</count>
      <count interval="21">0</count>
      <count interval="22">0</count>
      <count interval="23">0</count>
      <count interval="24">0</count>
      <count interval="25">0</count>
      <count interval="26">2</count>
      <count interval="27">1957</count>
      <count interval="28">3920</count>
      <count interval="29">3833</count>
      <count interval="30">3799</count>
      <count interval="31">3716</count>
      <count interval="32">3888</count>
      <count interval="33">1875</count>
      <count interval="34">0</count>
      <count interval="35">0</count>
      <count interval="36">0</count>
      <count interval="37">1</count>
      <count interval="38">0</count>
      <count interval="39">0</count>
      <count interval="40">0</count>
      <count interval="41">0</count>
      <count interval="42">0</count>
      <count interval="43">0</count>
      <count interval="44">1</count>
    </renew>
    <http_error>
      <count code="302 Redirect">0</count>
      <count code="400 Parse Error">22</count>
      <count code="400 Invalid Parameter">1</count>
      <count code="400 Invalid Parameter (compact=0)">0</count>
      <count code="400 Not Modest">0</count>
      <count code="402 Payment Required">0</count>
      <count code="403 Access Denied">0</count>
      <count code="404 Not found">152</count>
      <count code="500 Internal Server Error">1</count>
    </http_error>
    <mutex_stall>
      <count>0</count>
    </mutex_stall>
  </debug>
</stats>

带来的问题就是无法区分连不通的原因:是 Transmisstion 没上报?还是上报了地址不行?

方案 I:libtransmission 增加日志输出

早期的一个解决方案,是在 libtransmission 源码中增加了日志,再通过 btsdk 将 libtransmission 的日志打印到文件中查看,效果如下:

2024-11-13 14:36:26 -- tracker knows of 1 seeders and 1 leechers and gave a list of 2 peers.
2024-11-13 14:36:26 -- pex 0: [10.127.82.16]:51413
2024-11-13 14:36:26 -- pex 1: [10.138.62.136]:8082
2024-11-13 14:36:26 -- peer counts: 1 seeders, 1 leechers.
2024-11-13 14:36:26 -- tracker knows of 1 seeders and 1 leechers and gave a list of 2 peers.
2024-11-13 14:36:26 -- pex 0: [10.127.82.16]:51413
2024-11-13 14:36:26 -- pex 1: [10.138.62.136]:8082
2024-11-13 14:36:26 -- peer counts: 1 seeders, 1 leechers.

tracker 返回了两条记录:

  • 51413 端口这条记录就是 sdk 自身
  • 8082 端口就是 Linux 机上的 Transmission 服务,IP 是能对的上的

增加的日志代码如下:

void publishPeersPex(tr_tier* tier, int seeders, int leechers, std::vector<tr_pex> const& pex)
{
    if (tier->tor->torrent_announcer->callback != nullptr)
    {
        auto e = tr_tracker_event{};
        e.type = tr_tracker_event::Type::Peers;
        e.seeders = seeders;
        e.leechers = leechers;
        e.pex = pex;
        tr_logAddDebugTier(
            tier,
            fmt::format(
                "tracker knows of {} seeders and {} leechers and gave a list of {} peers.",
                seeders,
                leechers,
                std::size(pex)));

        for (auto i=0; i<pex.size(); ++i)
        {
            tr_logAddDebugTier(
                    tier,
                    fmt::format(
                        "pex {}: {}",
                        i, 
                        pex[i].display_name())); 
        }


        tier->tor->torrent_announcer->callback(*tier->tor, &e);
    }
}

for 循环中的 tr_logAddDebugTier 就是了。在请求 tracker 响应中会调用这个 publishPeersPex,具体调用链也没深究,主要是根据上面这条日志顺藤摸瓜而来:

tracker knows of 1 seeders and 1 leechers and gave a list of 2 peers.

这种方式需要修改源码、编译,非常不便。

方案 II:模拟请求 tracker

另一种解决方案是直接向 tracker 发送模拟请求,这一点是受 transmission-show -s 命令的启发:

$ transmission-show -s 月光宝盒.mkv.torrent
Name: 月光宝盒.mkv
File: 月光宝盒.mkv.torrent

http://1337.abcvg.info:80/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... 0 seeders, 0 leechers
http://bvarf.tracker.sh:2086/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... no match
http://ipv6.rer.lol:6969/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... error: Couldn't connect to server
http://jvavav.com:80/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... error: Couldn't resolve host name
http://retracker.x2k.ru:80/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... no match
http://yunhai.bcc-bdbl.baidu.com:8888/scrape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19 ... 1 seeders, 0 leechers

opentracker 支持 scrape 查询操作,这个接口返回的信息中包含了 peer 数量与供种数量,有可能会包含 peer 列表。查询了一些公开的资料,它的主要参数有下面几个:

  • info_hash:sha1 效验码,共 20 比特
  • peer_id:BT 客户端的唯一标识,在客户端启动时产生,共 20 比特
  • ip:可选,不提供时服务端会自己找到
  • port:监听端口
  • uploaded/downloaded:上传/下载的字节数
  • left:还需下载的字节数
  • numwant:可选,客户端希望从 Tracker 服务器得到的 peer 数量
  • key:可选,一个扩展的唯一性标识,即使改变了IP地址,也可以使用该字段标识该 BT 客户机
  • compact:压缩标志。如果值为 1 表示接受压缩格式的 peer 列表,即用 6 字节表示一个 peer 地址 (前 4 字节表示 IP 地址,后 2 字节表示端口号);值为 0 表示不接受

下面是一个请求示例:

$ curl "http://yunhai.bcc-bdbl.baidu.com:8888/scape?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19&peer_id=-TR2940-yhp3i52s0fyz&port=8088&uploaded=0&downloaded=0&left=1007978447&compact=0&numwant=10&event=started"
d5:filesd20:�lG�;;��l��rC�d�d8:completei1e10:downloadedi16e10:incompletei0eeee

由于返回内容是 bencode 编码的,一些二进制内容在 Console 窗口中显示为乱码。

结果解析放下不表,先看如何自动构造这个请求,后面通过脚本可以对任意一个种子文件发起请求。

请求组装

请求中可选的字段一律不填,剩下的字段都填写默认值:

  • tracker list
    地址列表
  • info_hash SHA1 填写为 torrent 中记录的值,注意是 hex 直接 url encode
  • peer_id 唯一 ID 固定设置一个随机值即可,需要 url encode
  • port 侦听端口随便设置,因为后面不请求数据不会用到,这里为 8088
  • downloaded 下载量设置为 0,表示从头下载
  • uploaded 上传量不设置
  • left 文件大小可设置为 0
  • compact 压缩标志设置为 0,方便后面解析
  • numwant 想要的 peer 数量可设置为 10,这里应该只有一两个 peer

这里必填的信息有 tracker 地址、info_hash 字段,都可以使用 transmission-show 获取到:

$ transmission-show 月光宝盒.mkv.torrent
Name: 月光宝盒.mkv
File: 月光宝盒.mkv.torrent

GENERAL

  Name: 月光宝盒.mkv
  Hash: db6c47953b3bddfdbc6c8cf0bf7243ba641ed519
  Created by: Transmission/2.94 (d8e60ee44f)
  Created on: Mon Oct 14 14:29:13 2024
  Piece Count: 1923
  Piece Size: 512.0 KiB
  Total Size: 1.01 GB
  Privacy: Public torrent

TRACKERS

  Tier #1
  http://1337.abcvg.info:80/announce

  Tier #2
  http://bvarf.tracker.sh:2086/announce

  Tier #3
  http://ipv6.rer.lol:6969/announce

  Tier #4
  http://jvavav.com:80/announce

  Tier #5
  http://retracker.x2k.ru:80/announce

  Tier #6
  http://yunhai.bcc-bdbl.baidu.com:8888/announce

FILES

  月光宝盒.mkv (1.01 GB)

下面在脚本中提取它们:

#! /bin/sh

# @brief: url encode string
# @param: text
# @return: encoded-text
function url_encode()
{
    local input=$1
    local output=""
    # yum install gridsite-clients
    # type "urlencode" > /dev/null 2>&1
    # if [ $? -ne 0 ]; then
        # output=$(echo "${input}" | tr -d '\n' | xxd -plain | tr -d '\n' | sed 's/\(..\)/%\1/g')
        local n=0
        while [ $n -lt ${#input} ];
        do
            case ${input:$n:1} in
                [a-z]|[A-Z]|[0-9]|.|-|_)
                    # regular urlencode only replace aboving characters
                    output="${output}${input:$n:1}"
                    # echo "${input:$n:1}" >> "${BASEDIR}/raw.data"
                    ;;
                *)
                    # for chinese charactor, more than one chars are replaced
                    output="${output}$(echo "${input:$n:1}" | tr -d '\n' | xxd -plain | tr -d '\n' | sed 's/\(..\)/%\1/g')"
                    # echo "${input:$n:1}" >> "${BASEDIR}/raw.data"
                    ;;
            esac
            n=$((n+1))
        done
    # linux urlencode is problemly while handling chinese characters
    # else
    #     output=$(urlencode "${input}")
    # fi

    # echo "${input} after urlencode: ${output}" >> "${BASEDIR}/raw.data"
    echo "${output}"
}

function hexstr2urlenc()
{
    bin=$(echo $1 | xxd -r -p)
    enc=$(url_encode "${bin}")
    #echo "after url encode: ${enc}"
    echo "${enc}"
}

function main()
{
    local file=$1
    infohash=$(transmission-show "${file}"  | grep Hash | awk '{print $2}')
    peerid='2d5452323934302d79687033693532733066797a' # hard coded
    port=8088
    # size=$(stat -c"%s" "${file}")
    size=0
    echo "infohash: ${infohash}, peerid ${peerid}, size ${size}"

    infohash_enc=$(hexstr2urlenc "${infohash}")
    peerid_enc=$(hexstr2urlenc "${peerid}")
    echo "after url encode: ${infohash_enc}, ${peerid_enc}"

    transmission-show "${file}" | sed -n '/TRACKERS/,/FILES/p' | sed '1d;$d;/^$/d;/Tier/d;s/announce/scrape/' > ${file}.tracker
    while read tracker; do
        echo "consult tracker $tracker"
        # echo "${tracker}?info_hash=${infohash_enc}&peer_id=${peerid_enc}&port=$port&uploaded=0&downloaded=0&left=$size&compact=0&numwant=10"
        resp=$(curl -s "${tracker}?info_hash=${infohash_enc}&peer_id=${peerid_enc}&port=$port&uploaded=0&downloaded=0&left=$size&compact=0&numwant=10&event=started")
        if [ -z "${resp}" ]; then
            echo "no data"
            continue
        fi

        echo "${resp}"
        echo
    done < ${file}.tracker
    rm "${file}.tracker"
}

main $@

需要注意的是无论是 info_hash 还是 peer_id,得到的已经是 hex string,需要先将它们转回为十六进制,再进行 url_encode,否则会请求失败。

下面是脚本的运行输出:

$ sh consult_tracker.sh 月光宝盒.mkv.torrent
infohash: db6c47953b3bddfdbc6c8cf0bf7243ba641ed519, peerid 2d5452323934302d79687033693532733066797a, size 0
after url encode: %dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19, -TR2940-yhp3i52s0fyz
consult tracker http://1337.abcvg.info:80/scrape
no data
consult tracker http://bvarf.tracker.sh:2086/scrape
d5:filesdee

consult tracker http://ipv6.rer.lol:6969/scrape
no data
consult tracker http://jvavav.com:80/scrape
no data
consult tracker http://retracker.x2k.ru:80/scrape
d5:filesd40:db6c47953b3bddfdbc6c8cf0bf7243ba641ed519d8:completei1e10:downloadedi1e10:incompletei0eee5:flagsd20:min_request_intervali300eee

consult tracker http://yunhai.bcc-bdbl.baidu.com:8888/scrape
d5:filesd20:�lG�;;��l��rC�d�d8:completei1e10:downloadedi16e10:incompletei0eeee

能请求通。过程中会生成 tracker.txt,可以看到脚本处理的中间结果:

$ cat 月光宝盒.mkv.torrent.tracker
  http://1337.abcvg.info:80/scrape
  http://bvarf.tracker.sh:2086/scrape
  http://ipv6.rer.lol:6969/scrape
  http://jvavav.com:80/scrape
  http://retracker.x2k.ru:80/scrape
  http://yunhai.bcc-bdbl.baidu.com:8888/scrape

注意已经将 announce 接口替换为了 scrape。

结果解析

opentracker 返回的信息是经过 bencode 的,bencode 是一种 BitTorrent 专用的传输格式,主要上的是减少文本的空间占用,感兴趣的可以查看附录 7。

如果想查看文本形式的内容,还需要转换一下。经过一番搜索,发现有基于 python 的解析器:bencodepy

$ pip3 install bencode.py
Collecting bencode.py
  Downloading https://files.pythonhosted.org/packages/15/9f/eabbc8c8a16db698d9c4bd24953763df2594b054237b89afe1ec56d3965e/bencode.py-4.0.0-py2.py3-none-any.whl
Installing collected packages: bencode.py
Successfully installed bencode.py-4.0.0

bencodepy 是在 Python 环境中调用的,在 shell 中使用还得封装一下:

#! /usr/bin/python3
import bencodepy
import sys

json = bencodepy.decode(sys.argv[1]) 
print ('%s' % json)

文件命名为 bdecode.py 放在脚本同名目录,待解析的内容放在第一个参数,就可以这样调用了:

$ python3 bdecode.py 'd5:filesd40:db6c47953b3bddfdbc6c8cf0bf7243ba641ed519d8:completei2e10:downloadedi2e10:incompletei0eee5:flagsd20:min_request_intervali300eee' 
{b'files': {b'db6c47953b3bddfdbc6c8cf0bf7243ba641ed519': {b'complete': 2, b'downloaded': 2, b'incomplete': 0}}, b'flags': {b'min_request_interval': 300}}

能成功解析。在脚本中增加解析代码
再次运行上面的示例:

$ sh consult_tracker.sh 月光宝盒.mkv.torrent
infohash: db6c47953b3bddfdbc6c8cf0bf7243ba641ed519, peerid 2d5452323934302d79687033693532733066797a, size 0
after url encode: %dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19, -TR2940-yhp3i52s0fyz
consult tracker http://1337.abcvg.info:80/scrape
d5:filesd20:�lG�;;��l��rC�d�d8:completei1e10:downloadedi1e10:incompletei0eee5:flagsd20:min_request_intervali41354eee

Traceback (most recent call last):
  File "/usr/local/lib/python3.6/site-packages/bencodepy/decoder.py", line 84, in decode
    value = to_binary(value)
  File "/usr/local/lib/python3.6/site-packages/bencodepy/compat.py", line 28, in to_binary
    return s.encode('utf-8', 'strict')
UnicodeEncodeError: 'utf-8' codec can't encode character '\udcdb' in position 12: surrogates not allowed

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "./bdecode.py", line 5, in <module>
    json = bencodepy.decode(sys.argv[1])
  File "/usr/local/lib/python3.6/site-packages/bencodepy/__init__.py", line 155, in bdecode
    return DEFAULT.decode(value)
  File "/usr/local/lib/python3.6/site-packages/bencodepy/__init__.py", line 72, in decode
    return self.decoder.decode(value)
  File "/usr/local/lib/python3.6/site-packages/bencodepy/decoder.py", line 87, in decode
    raise BencodeDecodeError("not a valid bencoded string")
bencodepy.exceptions.BencodeDecodeError: not a valid bencoded string

consult tracker http://bvarf.tracker.sh:2086/scrape
no data
consult tracker http://ipv6.rer.lol:6969/scrape
no data
consult tracker http://jvavav.com:80/scrape
no data
consult tracker http://retracker.x2k.ru:80/scrape
d5:filesd40:db6c47953b3bddfdbc6c8cf0bf7243ba641ed519d8:completei1e10:downloadedi1e10:incompletei0eee5:flagsd20:min_request_intervali300eee

{b'files': {b'db6c47953b3bddfdbc6c8cf0bf7243ba641ed519': {b'complete': 1, b'downloaded': 1, b'incomplete': 0}}, b'flags': {b'min_request_interval': 300}}
consult tracker http://yunhai.bcc-bdbl.baidu.com:8888/scrape
d5:filesd20:�lG�;;��l��rC�d�d8:completei1e10:downloadedi16e10:incompletei0eeee

Traceback (most recent call last):
  File "/usr/local/lib/python3.6/site-packages/bencodepy/decoder.py", line 84, in decode
    value = to_binary(value)
  File "/usr/local/lib/python3.6/site-packages/bencodepy/compat.py", line 28, in to_binary
    return s.encode('utf-8', 'strict')
UnicodeEncodeError: 'utf-8' codec can't encode character '\udcdb' in position 12: surrogates not allowed

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "./bdecode.py", line 5, in <module>
    json = bencodepy.decode(sys.argv[1])
  File "/usr/local/lib/python3.6/site-packages/bencodepy/__init__.py", line 155, in bdecode
    return DEFAULT.decode(value)
  File "/usr/local/lib/python3.6/site-packages/bencodepy/__init__.py", line 72, in decode
    return self.decoder.decode(value)
  File "/usr/local/lib/python3.6/site-packages/bencodepy/decoder.py", line 87, in decode
    raise BencodeDecodeError("not a valid bencoded string")
bencodepy.exceptions.BencodeDecodeError: not a valid bencoded string

看起来效果一般,可能是 info_hash 等字段解析后是二进制,python 无法直接打印。通过 xxd 转为二进制查看会好一些:

    while read tracker; do
        echo "consult tracker $tracker"
        resp=$(curl -s "${tracker}?info_hash=${infohash_enc}&peer_id=${peerid_enc}&port=$port&uploaded=0&downloaded=0&left=$size&compact=0&numwant=10")
        if [ -z "${resp}" ]; then
            echo "no data"
            continue
        fi

        echo "${resp}"
        echo
        echo "${resp}" | xxd -u -g 1
        #echo $(python3 ./bdecode.py "$resp")
    done < ${file}.tracker

下面是新的输出:

$ sh consult_tracker.sh 月光宝盒.mkv.torrent
infohash: db6c47953b3bddfdbc6c8cf0bf7243ba641ed519, peerid 2d5452323934302d79687033693532733066797a, size 0
after url encode: %dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19, -TR2940-yhp3i52s0fyz
consult tracker http://1337.abcvg.info:80/scrape
d5:filesd20:�lG�;;��l��rC�d�d8:completei0e10:downloadedi0e10:incompletei0eee5:flagsd20:min_request_intervali34648eee

0000000: 64 35 3A 66 69 6C 65 73 64 32 30 3A DB 6C 47 95  d5:filesd20:.lG.
0000010: 3B 3B DD FD BC 6C 8C F0 BF 72 43 BA 64 1E D5 19  ;;...l...rC.d...
0000020: 64 38 3A 63 6F 6D 70 6C 65 74 65 69 30 65 31 30  d8:completei0e10
0000030: 3A 64 6F 77 6E 6C 6F 61 64 65 64 69 30 65 31 30  :downloadedi0e10
0000040: 3A 69 6E 63 6F 6D 70 6C 65 74 65 69 30 65 65 65  :incompletei0eee
0000050: 35 3A 66 6C 61 67 73 64 32 30 3A 6D 69 6E 5F 72  5:flagsd20:min_r
0000060: 65 71 75 65 73 74 5F 69 6E 74 65 72 76 61 6C 69  equest_intervali
0000070: 33 34 36 34 38 65 65 65 0A                       34648eee.
consult tracker http://bvarf.tracker.sh:2086/scrape
no data
consult tracker http://ipv6.rer.lol:6969/scrape
no data
consult tracker http://jvavav.com:80/scrape
no data
consult tracker http://retracker.x2k.ru:80/scrape
d5:filesd40:db6c47953b3bddfdbc6c8cf0bf7243ba641ed519d8:completei1e10:downloadedi1e10:incompletei0eee5:flagsd20:min_request_intervali300eee

0000000: 64 35 3A 66 69 6C 65 73 64 34 30 3A 64 62 36 63  d5:filesd40:db6c
0000010: 34 37 39 35 33 62 33 62 64 64 66 64 62 63 36 63  47953b3bddfdbc6c
0000020: 38 63 66 30 62 66 37 32 34 33 62 61 36 34 31 65  8cf0bf7243ba641e
0000030: 64 35 31 39 64 38 3A 63 6F 6D 70 6C 65 74 65 69  d519d8:completei
0000040: 31 65 31 30 3A 64 6F 77 6E 6C 6F 61 64 65 64 69  1e10:downloadedi
0000050: 31 65 31 30 3A 69 6E 63 6F 6D 70 6C 65 74 65 69  1e10:incompletei
0000060: 30 65 65 65 35 3A 66 6C 61 67 73 64 32 30 3A 6D  0eee5:flagsd20:m
0000070: 69 6E 5F 72 65 71 75 65 73 74 5F 69 6E 74 65 72  in_request_inter
0000080: 76 61 6C 69 33 30 30 65 65 65 0A                 vali300eee.
consult tracker http://yunhai.bcc-bdbl.baidu.com:8888/scrape
d5:filesd20:�lG�;;��l��rC�d�d8:completei1e10:downloadedi16e10:incompletei0eeee

0000000: 64 35 3A 66 69 6C 65 73 64 32 30 3A DB 6C 47 95  d5:filesd20:.lG.
0000010: 3B 3B DD FD BC 6C 8C F0 BF 72 43 BA 64 1E D5 19  ;;...l...rC.d...
0000020: 64 38 3A 63 6F 6D 70 6C 65 74 65 69 31 65 31 30  d8:completei1e10
0000030: 3A 64 6F 77 6E 6C 6F 61 64 65 64 69 31 36 65 31  :downloadedi16e1
0000040: 30 3A 69 6E 63 6F 6D 70 6C 65 74 65 69 30 65 65  0:incompletei0ee
0000050: 65 65 0A

最后一个请求的输出,可以看到二进制 info_hash 的存在:db6c47953b3bddfdbc6c8cf0bf7243ba641ed519,从 0000000 行后半部分一直延伸到 0000010。

经过一番确认,这里仍没有返回 peer 的 IP & port 信息,想要查询它们,还得请求 announce 接口,它的请求参数与 scrape 基本相同,仅增加:

  • event:可选参数,可能取值为 started、completed、stopped,可以分别在下载开始、下载完成和停止下载时发送,这里设置为 started

因为是可选参数,就没添加,直接将 scrape 接口改为 annouce 进行请求,下面是一个示例:

$ curl http://1337.abcvg.info:80/announce?info_hash=%dblG%95%3b%3b%dd%fd%bcl%8c%f0%bfrC%bad%1e%d5%19&peer_id=-TR2940-yhp3i52s0fyz&port=8088&uploaded=0&downloaded=0&left=0&compact=0&numwant=10
d8:intervali39012e5:peersld2:ip13:10.138.62.1367:peer id25:-TR2940-yhp3i52s0fyz4:porti8088eeee

约摸能看到 ip 字段,值为:10.138.62.136,与 Linux 机地址也能对得上。

不过上面的请求内网自建的 tracker 会返回错误:

<title>Invalid Request</title>

怀疑是字段没设置对,感兴趣的读者可以排查下原因。

总结

本文记录了 BitTorrent 内网测试环境的搭建过程,特别是没有公网设备的场景。

如果已经有公网设备,可以直接使用国内一些活跃的公共 tracker,具体请参考附录 9,
作者会不定时更新。

需要注意的是,这里面一部分是 PT 站点,没有身份是不能上报种子的,PT 是 BT 的深化,即私有种子。用户注册站点时会分配一个 passkey,之后使用这个 key 做种子的上传下载。基于种子身份,站点可以做供种时长的统计,对于只下载不上传的“吸血”用户,可以进行有效治理,以提升社区的健康度。这种站点一般不公开注册,需要邀请码才能进入,但分享的资源也有速度保证。

参考

[1].
CentOS7上OpenTracker的搭建

[2].
opentracker 搭建自己的 BT Tracker 服务器

[3].
搭建自己的 BT Tracker

[4].
Transmission 搭建记录

[5].
BT(带中心Tracker)通信协议的分析

[6].
BitTorrent Tracker 协议详解

[7].
BT 协议规范文档中文版

[8].
bencode.py · PyPI

[9].
XIU2/TrackersListCollection

[10].
BT Tracker的原理及.Net Core简单实现Tracker Server

[11].
Linux | 如何挂PT:CentOS 7安装配置美化Transmission及制作种子

[12].
PT站种子制作发布新手全攻略

[13].
制作BT(BitTorrent)种子和磁力链接教程通过BT分享文件

[14].
如何用 Transmission 做种

[15].
PT作弊与反作弊

[16].
实现DHT网络上的种子爬虫

[17].
杂谈网络协议之种子与P2P

[18].
一次对BT种子的追踪小记

前言

本规范的目的是提升代码质量,提升团队协作效率,规范中出现的强制,推荐,参考含义如下:

【强制】:必须严格遵守,如有特殊情况,需架构委员会评审报备。

【推荐】:没特殊情况必须遵守,在开发组长允许下可以不遵守。

【参考】:可以参考,不做严格要求。

后台开发规范

1.1 命名规范

  1. 【强制】驼峰式命名,其他不允许,常量除外。

  2. 【强制】拼音和英文混合不允许。

    正例: alibaba / taobao / youku / hangzhou 等国际通用的名称,可视同英文。
    反例: DaZhePromotion [ 打折 ] / getPingfenByName() [ 评分 ] 
    
  3. 【强制】常量命名大写,单词以下划线隔开,语义尽量表达完整,比如MAX就语义不明确。

     正例: MAX _STOCK _COUNT
     反例: MAX _COUNT
    
  4. 【强制】抽象类使用Abstract开头或者Base结尾,异常类以Exception结尾, 测试类命名以它要测试的类的名称开始,以 Test 结尾。

  5. 【强制】除非业界通用缩写,否则不允许单词缩写。

     反例: AbstractClass “缩写”命名成 AbsClass;condition “缩写”命名成 condi ,此类随意缩写严重降低了代码的可阅读性。 
    
  6. 【推荐】工具类以Utils结尾,帮助类以Helper结尾,帮助类跟工具类的区别在于帮助类是方便业务逻辑使用的,工具类是更通用的。

     正例: 应用工具类包名为 com . yujiahui . common . util 、类名为 MessageUtils( 此规则参考spring 的框架结构 ) 
    
  7. 【推荐】枚举类使用Enum结尾。

  8. 【推荐】如果模块、接口、类、方法使用了设计模式,在命名时体现出具体模式。

     说明:将设计模式体现在名字中,有利于阅读者快速理解架构设计理念。
     正例: public class OrderFactory;
     public class LoginProxy;
     public class ResourceObserver;
    
  9. 【参考】分层命名规范

      1.  DTO命名规范,如果DTO是命令,则Cmd结尾,如果是查询,Query结尾,如果是view object,VO结尾,其他无法归类的DTO结尾。 
      2.  Service层以Service结尾,Dao层以Dao结尾。方法获取单个对象以get开头,获取多个对象以list开头,获取数量已count开头。
          插入以create开头,更新以update开头,删除以delete开头 
      3.  领域层工厂以Factory结尾,领域服务建议以DomainService结尾,实体和值对象不需要后缀,是什么名称就什么名称,比如订单实体就叫Order 
      4.  领域模型层命名尽量与数据表一致,比如表order_detail,命名为OrderDetail,如果表有统一前缀,前缀是否体现到模型对象名上在一个项目内统一。
    
  10. 【推荐】实体里面有些布尔方法如果用is开头容易被框架判断为属性,建议都用iz开头,比如izEasy。

1.2 常量规范

  1. 【强制】魔鬼数字不允许。

  2. 【强制】long 或者 Long 初始赋值时,使用大写的 L ,不能是小写的 l ,小写容易跟数字 1 混淆,造成误解。

    说明: Long a = 2 l; 写的是数字的 21,还是 Long 型的 2?。
    
  3. 【推荐】不要在一个类里面维护所有常量,比如领域模型的常量可以放到领域模型里面,也可以另外建立一个常量类,常量类以Constants结尾

     正例:缓存相关常量放在类 CacheConstants 下 ; 系统配置相关常量放在类 ConfigConstants 下 
    
  4. 【推荐】常量类共享应该按层次放置,层次分为:跨应用共享,应用内共享,模块内共享,类内共享。跨应用共享的常量类放置在一个jar的constant包下,应用内共享的常量类放置下通用模块下的constant包下,模块内共享的常量类放置在本模块的constant包下。

    反例:易懂变量也要统一定义成应用内共享常量,两位攻城师在两个类中分别定义了表示“是”的变量:
     类 A 中: public static final String YES = " yes " ;
     类 B 中: public static final String YES = " y " ;
     A . YES . equals(B . YES) ,预期是 true ,但实际返回为 false ,导致线上问题。
    

1.3 格式规范

  1. 【强制】第一个大括号不换行,单行字符120个,其他采用IDE默认格式。
  2. 【推荐】不同业务逻辑或者不同语义的代码之间需要有空行。
  3. 【强制】IDE设置文件编码为UTF-8。
  4. 【强制】一行不允许定义多个变量。

1.4 Java规范

  1. 【强制】所有覆写的方法都必须加上@Override。

  2. 【推荐】equals方法容易报空指针异常,常量放前面或者使用Objects.equals(jdk7引入)。

     正例:" test " .equals(object);
     反例: object.equals( " test " );
    
  3. 【强制】包装类的相等比较用equals,不能用==。

  4. 【推荐】基本类型和包装类型的使用标准:

      1. pojo类型的属性用包装类型
      2. RPC方法的参数和返回值用包装类型
      3. 局部变量使用基本类型
    
  5. 【强制】领域模型类必须实现toString方法

  6. 【推荐】类内方法定义的顺序是:公有方法》保护方法》私有方法》getter,setter。

  7. 【推荐】类的方法的访问控制从严。类的方法只在内部使用必须是private,只对继承类开放,必须是protected,变量跟方法类似。

  8. 【强制】不要在类里面使用静态变量存储数据,如果需要,使用线程安全的数据结构。

  9. 【强制】不能在foreach循环中删除集合元素,删除元素使用迭代器。

    	// 正面案例
        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
                String item = iterator.next();
                if (删除元素的条件) {
                        iterator.remove();
                }
        }
    
    	// 反面案例
         List<String> a = new ArrayList<String>();
            list.add("1");
            list.add("2");
            for (String item : list) {
                    if ("1".equals(item)) {
                            list.remove(item);
                    }
            }
    
  10. 【强制】SimpleDateFormat线程不安全不要定义为static变量。

     	// 正例:注意线程安全,使用 DateUtils 。亦推荐如下处理:
        private static final ThreadLocal<DateFormat> df = new ThreadLocal<DateFormat>() {
                @ Override
                protected DateFormat initialValue() {
                        return new SimpleDateFormat("yyyy-MM-dd");
                }
        };
    
  11. 【推荐】高并发时,考虑锁的性能,尽量用无锁数据结构,能锁区块就不要锁整个方法,能锁对象及不要锁整个类。

  12. 【强制】有并发修改同一个对象的场景,需要加锁,并发修改的概率大于20%,使用悲观锁,否则使用乐观锁, 乐观锁根据业务场景考虑重试次数。

  13. 【推荐】有返回值的函数尽量不要修改入参。

  14. 【推荐】尽量少用else,使用卫语句。比如:if(condition) {return obj;} 其他逻辑; 如果实在if-else多,采用状态模式。

        //  正例:超过 3 层的 if-else 的逻辑判断代码可以使用卫语句、策略模式、状态模式等来实现,
        public void today() {
            if (izBusy()) {
                System.out.println(“change time.”);
                return;        
            }
            if (izFree()) {
                System.out.println(“go to travel.”);
                return;
            }
            return;
        }
    

1.5 业务代码规范

  1. 【强制】方法的参数不允许超过5个。

  2. 【推荐】参数和返回值不要用Map这种泛化参数。

  3. 【强制】方法的大括号层级不允许超过4层。

  4. 【推荐】一个方法只做一件事情,方法不超过30行。

  5. 【强制】方法不能有副作用,比如查询类方法,不允许改变入参的属性值。

  6. 【推荐】类不能超过500行。

  7. 【推荐】不允许大量重复代码。

  8. 【强制】批量操作必须分组,比如批量插入一千条数据,分为500一组。

 正例:List groups = Lists.partition(list,500);
  1. 【强制】业务查询返回数据过多必须分页,比如不能超过5000条返回数据。

  2. 【推荐】工具类先看项目中是否有提供,不允许随意添加,如果碰到项目中和jar内有同名工具类,优先使用项目中的类,比如StringUtils在多个jar中有,先使用本项目的StringUtils,满足不了要求,再用其他jar的类或者移植方法到本项目的StringUtils。

  3. 【推荐】重要业务流程必须有业务日志,采用BizLog注解,记录新旧值。

  4. 【推荐】Service层提供的都是业务逻辑方法,不要放大量查询方法,有多种查询的业务模型抽取出Query类,比如OrderQueryService,也可以采用CQRS模式,命令和查询分离。

  5. 【强制】业务上存在并发操作的场景,考虑方法的幂等性,或者使用乐观锁。

  6. 【推荐】时刻进行代码重构,避免代码腐化,去掉下次再改的心态(潜台词:永不再改)

  7. 【强制】业务上涉及订单、用户信息、金额等安全敏感数据文件导出并上传阿里云OSS时,必须使用文件服务的
    /api/file/uploadPrivate
    接口上传到阿里云OSS。

  8. 【强制】业务逻辑尽量避免跨数据库事务操作,严禁事务中穿插执行不同数据库的sql语句,必要时要考虑失败场景补偿方案和告警机制。

    反例:
    执行A数据库更新逻辑X
    执行B数据库更新逻辑Y
    执行A数据库更新逻辑X
    执行B数据库更新逻辑Y
    

1.6 异常规范

  1. 【推荐】不允许对大段代码进行try-catch。
  2. 【强制】对于有核心功能的死循环线程必须try-catch整个循环体,防止任何异常导致循环线程退出,如果有性能问题,可以酌情优化
  3. 【推荐】不允许捕获异常后不做任何处理,如果不想处理,抛出去。
  4. 【推荐】方法可能返回null,调用方要做非空判断防止NPE问题。
  5. 【推荐】避免直接抛RuntimeException,使用业务异常,比如BusinessException,ApplicatinException,DomainException。

1.7 日志规范

  1. 【推荐】应用的扩展日志文件命名格式 logName.时间.logType.log。logType:日志类型,比如json表示结构化日志,app表示普通日志,logName:日志名称。
  2. 【推荐】错误日志和业务日志分开。
  3. 【推荐】异常日志必须包含堆栈信息和现场参数。
  4. 【强制】严禁输出大量无效日志,比如在大循环中输出日志。

1.8 注释规范

  1. 【参考】类、类属性、类方法的注释使用 Javadoc 规范,使用/**内容*/格式,避免使用// xxx 方式。

  2. 【推荐】所有的抽象方法 ( 包括接口中的方法 ) 必须要用 Javadoc 注释、除了返回值、参数、异常说明外,还必须指出该方法做什么事情,实现什么功能。

 说明:对子类的实现要求,或者调用注意事项,请一并说明。
  1. 【推荐】所有的枚举类型字段要有注释,说明每个数据项的用途。

  2. 【推荐】代码修改的同时,注释也要进行相应的修改,尤其是参数、返回值、异常、核心逻辑等的修改。

  3. 【参考】谨慎注释掉代码。在上方详细说明,而不是简单的注释掉。如果无用,则删除。

 说明:代码被注释掉有两种可能性:
  1. 后续会恢复此段代码逻辑。
  2. 永久不用。前者如果没有备注信息,难以知晓注释动机。后者建议直接删掉 ( 代码仓库保存了历史代码 ) 。
  1. 【参考】对于注释的要求:第一、能够准确反应设计思想和代码逻辑 ; 第二、能够描述业务含义,使别的程序员能够迅速了解到代码背后的信息。完全没有注释的大段代码对于阅读者形同天书,注释是给自己看的,即使隔很长时间,也能清晰理解当时的思路 ; 注释也是给继任者看的,使其能够快速接替自己的工作。

  2. 【参考】好的命名、代码结构是自解释的,注释力求精简准确、表达到位。避免出现注释的一个极端:过多过滥的注释,代码的逻辑一旦修改,修改注释是相当大的负担。


// 反例 put elephant into fridge
put(elephant, fridge);
方法名 put ,加上两个有意义的变量名 elephant 和 fridge ,已经说明了这是在干什么,语义清晰的代码不需要额外的注释。
  1. 【推荐】及时清理不再使用的代码段或配置信息。

     说明:对于垃圾代码或过时配置,坚决清理干净,避免程序过度臃肿,代码冗余。
     正例:对于暂时被注释掉,后续可能恢复使用的代码片断,在注释代码上方,统一规定使用三个斜杠(///)来说明注释掉代码的理由。
    

接口规范

  1. 【强制】Rest接口返回值必须是BaseResult对象及其子类对象,封装了错误码,错误描述,isSuccess,data信息。data放业务数据。业务错误码自己定义,推荐优先使用英文,避免使用通用错误码,通用错误码参考ExceptionEnum。

比如{code:”0”,msg:”操作成功”,data:{XX}}。

  1. 【推荐】Rest接口必须标明请求的content_type。比如content_type=applicaton/json

  2. 【强制】接口提供方必须考虑幂等性,防止重复调用导致严重的业务灾难。

  3. 【强制】查询接口如果数据量过多,需要分页返回。

  4. 【推荐】接口必须标明字段的类型,长度,是否必填,文字说明必须准确,反例:person:人员。

  5. 正例:person:人员编码。

  6. 【推荐】Rest接口返回值需要综合考虑实际功能、安全和性能需求,精细化按需返回业务数据。


    1. 比如,移动端接口需要考虑性能问题,要避免返回无效字段;对于中台服务移动端接口返回的多余字段场景,需要业务应用封装处理后再返回给移动端。


      PC端接口返回值要求 移动端接口返回值要求
      中台服务 满足实际功能、安全即可 满足实际功能、安全即可。性能要求需要业务应用封装处理。
      业务应用 满足实际功能、安全即可 除了满足实际功能、安全需求之外,要考虑性能,避免返回无效字段
  7. 【推荐】Feign Api提供方禁止在给下游使用方的jar中引入AutoConfiguration等影响启动的配置类

数据库规范

1. 建表相关规范

  1. 库名、表名、字段名,使用小写和下划线 _ 分割
  2. 库名、表名、字段名,不超过12个字符。默认支持64个字符。
  3. 库名、表名、字段名,见名知意,建议使用名词而不是动词。
  4. 使用 InnoDB 存储引擎。支持;事务、锁、高并发 性能好。
  5. 推荐使用 utf8mb4 可以存emoji
  6. 单表字段数,建议不超过40个

2. 字段相关规范

  1. 整型定义中不显示设置长度,如使用 INT,而不是INT(4)
  2. 存储精度浮点数,使用 DECIMAL 替代 FLOAT、DOUBLE
  3. 所有字段,都要有 Comment 描述
  4. 所有字段应定义为 NOT NULL
  5. 超过2038年,用DATETIME存储
  6. 短数据类型 0~80 选用 TINYINT 存储
  7. UUID 有全局唯一统一字段属性,适合做同步ES使用。
  8. IPV4,用无符号 INT 存储
  9. IPV6,用VARBINARY存储
  10. JSON MySql 8.x 新增特性
  11. update_time 设置 on update 更新属性

3. 索引相关规范

  1. 要求有自增ID作为主键,不要使用随机性较强的 order_id 作为主键,会导致innodb内部page分裂和大量随机I/O,性能下降。

  2. 单表索引建议控制在5个以内,单索引字段数不超过5个。注意:已有idx(a, b)索引,又有idx(a)索引,可以把idx(a)删了,浪费空间,降低更新、写入性能。* 单个索引中,每个索引记录的长度不能超过64KB

  3. 利用覆盖索引来进行查询操作,避免回表。另外建组合索引的时候,区分度最高的在最左边。

  4. select(count(distinct(字段)))/count(id) = 1
    的区分度,更适合建索引。在一些低区分度的字段,例如type、status上建立独立索引几乎没意义,降低更新、写入性能。

  5. 防止因字段不同造成的隐式转换,导致索引失效。

  6. 更新频繁的字段,不要建索引。

4. 使用相关规范

  1. 单表数据量不超过500万行,ibc 文件大小不超过 2G

  2. 水平分表用取模,日志、报表类,可以用日期

  3. 单实例表数目小于 500

  4. alter表之前,先判断表数据量,对于超过100W行记录的表进行alter table,必须在业务低峰期执行。因为alter table会产生表锁,期间阻塞对于该表的所有写入

  5. SELECT语句必须指定具体字段名称,禁止写成
    “*”select *
    会将不需要读的数据也从MySQL里读出来,造成网卡压力,数据表字段一旦更新,但model层没有来得及更新的话,系统会报错

  6. insert语句指定具体字段名称,不要写成 `insert into t1 values(…)``

  7. ``insert into…values(XX),(XX),(XX)..` 这里XX的值不要超过5000个,值过多会引起主从同步延迟变大。

  8. union all

    union
    ,不要超过5个子句,如果没有去重的需求,使用union all性能更好。

  9. in 值列表限制在500以内,例如
    select… where userid in(….500个以内…)
    ,可以减少底层扫描,减轻数据库压力。

  10. 除静态表或小表(100行以内),DML语句必须有where条件,且尽量使用索引查找

  11. 生产环境禁止使用 hint,如 sql_no_cache,force index,ignore key,straight join等。 要相信MySQL优化器。hint是用来强制SQL按照某个执行计划来执行,但随着数据量变化我们无法保证自己当初的预判是正确的。

  12. where条件里,等号左右字段类型必须一致,否则会造成隐式的类型转化,可能导致无法使用索引

  13. 生产数据库中强烈不推荐在大表执行全表扫描,查询数据量不要超过表行数的25%,否则可能导致无法使用索引

  14. where子句中禁止只使用全模糊的LIKE条件进行查找,如like ‘%abc%’,必须有其他等值或范围查询条件,否则可能导致无法使用索引

  15. 索引列不要使用函数或表达式,如
    where length(name)=10

    where user_id+2=1002
    ,否则可能导致无法使用索引

  16. 减少使用or语句 or有可能被 mysq l优化为支持索引,但也要损耗 mysql 的 cpu 性能。可将or语句优化为union,然后在各个where条件上建立索引。如
    where a=1 or b=2
    优化为
    where a=1… union …where b=2, key(a),key(b)
    某些场景下,也可优化为
    in

  17. 分页查询,当limit起点较高时,可先用过滤条件进行过滤。如
    select a,b,c from t1 limit 10000,20
    ; 优化为
    select a,b,c from t1 where id>10000 limit 20
    ;

  18. 同表的字段增删、索引增删等,合并成一条DDL语句执行,提高执行效率,减少与数据库的交互。

  19. replace into

    insert on duplicate key update
    在并发环境下执行都可能产生死锁(后者在5.6版本可能不报错,但数据有可能产生问题),需要catch异常,做事务回滚,具体的锁冲突可以关注
    next key lock

    insert intention lock

  20. TRUNCATE TABLE 比 DELETE 速度快,且使用的系统和事务日志资源少,但 TRUNCATE 无事务且不触发 trigger ,有可能造成事故,故不建议在开发代码中使用此语句。说明: TRUNCATE TABLE 在功能上与不带 WHERE 子句的 DELETE 语句相同。

安全规范

接口安全

  1. 【强制】
    涉及大批量敏感数据的接口增加限流和内网访问保护
  2. 【强制】
    涉及小批量敏感数据的接口增加内网访问保护
  3. 【强制】
    所有不宜公开数据都必须添加接口权限

代码安全

  1. 【强制】新加的jar要经过技术中台组安全检测评估才能使用
  2. 【强制】敏感字段禁止明文存储,需要使用统一工具类进行加密处理
  3. 【强制】mybatis中能用#{}时不要用${},#{}能防止SQL注入。禁止SQL拼接不安全参数。

密码安全

  1. 【强制】所有使用中的业务系统(包括测试环境)密码必须由大写字母+小写字母+数字+特殊字符四种组合,字符数不少于8位。

技术文档的写作规范

  1. 设计文档需要的核心要素有业务架构 ,应用架构,领域模型(数据架构),技术架构,。
  2. 业务架构描绘系统的业务流程和功能
  3. 应用架构描绘系统之间的关系
  4. 数据架构是指领域模型的关系
  5. 技术架构描绘系统实现,我们都是比较统一的技术架构,可以省略

Java开发编程军规

一、禁止循环中查询数据库,尽量在循环外一次查询

  • 说明

系统性能瓶颈很大一部分都是指向了数据库,而循环中查询数据库非常耗资源。

  • 案例

展示少量树结构数据时,循环内查询数据后进行数据组装。导致服务器在测试环境就频繁宕机。

使用java.util.Comparator#compare方法调用数据库查询接口,导致线上性能极低。

二、禁止把redis这种缓存当数据库用

  • 说明

缓存无法完全符合事务特性ACID原则,数据存在不可使用的风险比较大。

  • 案例

会员数据直接存储到redis缓存中,数据量也比较大,经常会丢数据。

三、禁止循环中创建新线程,尽量使用线程池

四、死循环必须有退出机制

  • 说明

死循环中最好有休眠语句存在,另外还要退出机制。

  • 案例

订单同步应用请求第三方平台数据时,平台方没有翻页的结束标志,同时代码中没有退出机制直接导致该平台订单同步异常。

五、共享变量必须考虑线程安全

  • 说明

尽量避免使用共享变量,无法避免时必须考虑线程安全。

  • 案例

微信抽奖功能中,每次中奖都是同一个,原因是对共享变量进行了修改操作,后面的逻辑获取的是脏数据。

六、浮点计算必须使用BigDecimal

  • 说明

如果需要精确计算,非要用String来够造BigDecimal不可。

七、批量操作必须考虑合理分组

  • 说明

数据量大时须批量操作,而批量操作必须分组,避免一次操作耗时过久导致连锁反应。

  • 案例

订单历史迁移数据时,分组为5000,导致数据库删除操作没有走索引。建议分组数量在100~500之间。

八、禁止单点部署

  • 说明

增加一台服务器部署可以降低50%的服务不可用风险。

九、禁止大表的全表扫描不加限流

  • 说明

全表扫描已经很耗数据库资源了,频繁处理请求不加限流就更雪上加霜。

  • 案例

售后问题跟踪单的导出,时间索引没有控制范围,导致全表扫描。导出数据接口没有加限流加剧服务资源消耗。

十、读写分离架构,必须考虑读到过期数据

  • 说明

读写分离在业务数据更新写入后再重现读取时会存在延迟问题,导致读到脏数据。

  • 案例

A. 双十一开启读写分离,主从同步有延迟,导致业务事件重复发送,原因是读取到历史 脏数据。

B. 会员积分服务创建数据后其他服务应用马上查询,结果是查询到空数据。

十一、事务内有外部调用,必须考虑外部不稳定和性能问题

  • 说明

事务本身是很耗资源,极易产生超时的问题,要避免再引入外部不稳定因素。

  • 案例

个人中心服务,事务内远程查询美丽分享官的积分,导致性能极低。外部接口调用需要设置超时和最长等待时间。

十二、接口提供方和Xxljob定时任务必须考虑幂等性,防止重复调用导致严重的业务灾难

  • 说明

根据墨菲定律,接口重复调用是会必现的线上问题。

  • 案例

订单付款接口,幂等逻辑不严谨导致重复付款问题。apollo报表中心由于xxljob一秒内重复调度任务,导致统计数据重复,严重影响管理层的决策判断。

十三、禁止资源操作(IO等)后未释放

十四、嵌套事务的默认传播属性是Propagation.REQUIRED,如果需要开启新事务,必须手动设置事务传播属性为Propagation.REQUIRES_NEW。尽量不要使用嵌套事务。

  • 说明

使用事务注解或者编程式事务时,需要考虑默认的事务传播属性,根据需要决定是并入同一个事务还是开启新事务。

  • 案例

OMS异步操作任务,调用第三方接口时,修改状态为确认中状态,需要先提交事务更新,后面的逻辑操作成功则需要修改为已确认,失败则修改为待审核。当第三方接口没有返回明确的成功或失败时,状态应该保持确认中不变。如果调用接口前不开启新事务,会导致后面回滚的数据有误。

十五、覆写对象的equals()方法时必须同时覆写hashCode()方法

  • 说明

equals和hashCode方法是对象在hash容器内高效工作的基础,正确的覆写这两个方法才能保证在hash容器内查找对象的正确性,同时一个好的hashCode方法能大幅提升hash容器效率。

十六、禁止含事务的循环内加线程同步锁

  • 说明

循环上层包含事务,使用synchronized锁,会导致MySQL事务锁和JVM同步锁互相等待死锁问题。

  • 案例
@Transactional(rollbackFor = Exception.class) 
public void storeData(List<Order> orderList) { 
    /**
    order_id = {1,2,3}
    线程A更新order_id为1后进入下一轮循环,事务锁还未释放,同步锁需要重新获取。
    同时线程B已获取同步锁,需要更新order_id=1的事务操作。结果就是线程A等待线程B持有的
    JVM同步锁,线程B等待线程A持有的事务锁。
    */
    for(Order order : orderList) {
        synchronized (LOCK) {
            updateOrderId(order);
        }
    }
}

十七、使用线程池时,必须设置合理的大小,禁止不加限制动态批量创建线程

  • 说明

不加限制的批量创建线程会抢占大量系统的资源,引发OOM等连锁异常,最终导致宕机

  • 案例

将new MapReduce<>(xxx)创建线程池的代码放在API接口实现方法中没有加其他限制,导致引发OOM宕机,中间触发了Redis连接超时、Kafka重复消费等异常

十八、所有公共代码(比如api包、通用工具类)或公共服务(中台服务、业务应用自身服务)的改动,必须考虑向下兼容

  • 说明·

对多个系统项目都有依赖的公共代码进行修改时,需要考虑兼容历史逻辑,除非确认所有使用方都能够接受功能修改产生的影响

  • 案例

项目A的开发人员将公共jar包中逻辑进行了修改(该修改需要开启一个新配置才和原逻辑一致),同时deploy jar包进行测试,新配置只在测试环境进行了操作。依赖了公共jar包的项目B这时进行线上发版,但是没有进行配置(而且也不知道有这个配置),导致线上事故。

十九、禁止在新建表或者增加修改表字段时设置字符集和排序规则

  • 说明·

字符集和排序规则后期修改需要耗费巨大的资源,影响业务稳定性,为了保持schema-表-表字段三者的字符集和排序规则一致,禁止在新建表或者增加修改表字段时设置字符集和排序规则。

  • 案例

A项目的某一个表的字段设置了字符集和排序规则,导致与表-schema的排序规则不一致,在联合查询时这个表作为关联字段,报关联字段排序规则不一致的错误,无法进行关联查询,只能修改,如果是一张大表修改会非常耗时,占用大量的io会影响业务。

二十、新建表或者增加修改表字段时使用TEXT/BLOD字段需要评估必要性

  • 说明·

TEXT/BLOD的大字段会产生磁盘临时表,而且不能使用全文索引,各种操作的代价都非常高昂,在业务中最好不要使用,如果实在是要使用,也要独立出一张表专门用于存储,不得跟业务表中使用。

  • 案例

A项目前期的一张表中使用了一个TEXT存储json大字段,导致这张表占用了600多G的空间,其中那一个大字段就占用90%的表空间,后续的查询,迁移,碎片整理都非常的耗资源。

序言:
当前基于 Transformer 架构的大语言模型人工智能技术,由于投入大、成本高、人才需求苛刻,导致许多企业望而却步。动辄几千万甚至上亿的成本,现实中有几家企业能够承担?真正具有竞争力的技术应当在成本上更低、效率上更高,因此,各大院校和商业公司已不再仅仅关注模型的参数规模,而是在积极探索创新方法,显著降低大语言模型的研发与使用成本,使得大多数企业也能轻松采用。斯坦福大学的这一最新研究成果正是朝着这一目标迈出了一大步——将一个 80 亿参数模型的训练成本降至 20 美元。同时,已有中国企业基于这一研究成果推出了适用于企业的私有 AI 模型及服务器、前端应用的垂直解决方案,为 AI 在企业中的私有化部署铺平了道路。

斯坦福大学的一组研究人员推出了LoLCATs,一种线性化标准Transformer LLMs的新方法,大幅降低了计算需求,同时保留了大部分最先进的(SOTA)性能。而这一切只需要几个小时的GPU计算,总成本不到20美元,最终使模型性能在训练投入上的效率提高至35,500倍。这听起来难以置信,但他们今天展示的三种技巧让这一切成为可能。这项非凡的成就可能很快会成为AI工程团队追求一流表现的基本技能。

那么,他们是如何做到的?

标准LLM的问题: 大型语言模型(LLM)为AI行业带来了巨大的激动与资金,似乎正在走向全球主导的直线路径,作为一种能让文明迈向新时代的优越技术(至少,这是他们想让我们相信的)。令人惊讶的是,这一巨大的赌注迫使大科技公司投资数十亿,集中于一种架构类型——Transformer,然而它们效率极低,所以成本巨高,提高Transformer的效率就必然会降低成本。

Transformer是我们所需的一切。 简单来说,AI不过是一个数据压缩算法,输入数据后学习其中的模式,进而利用这种已掌握的知识做出有用的预测。在现有的实现方法中,没有一种比Transformer更接近所需的效果,原因有二:

  1. 它们完全可并行化,非常适合用大量数据训练模型。

  2. 模型的规模越大,效果越好,这引发了人们在如何扩大模型模型和训练预算上的投资和研究狂潮。

然而,以Transformer为基础的模型虽然具有无与伦比的表现力,并不意味着它们是完美的,因为它们的优点带来了自身的问题。

平方复杂度问题。 最大的问题在于它们无法压缩状态或内存。换句话说,如果你希望模型能记住某些信息,那就必须将其以原始状态存储在内存中。人类并不是这样记忆的。人类不会记住所有的事情或经历的事件;人类仅保留被认为重要的部分。

想象你在读一本书。如果你在第11章,主角可能会提到书中之前发生的事情,或许是第1章。如果你留心了,你可能会记得,否则可能需要回头重读那部分。然而,这对内存负担并不是问题,因为如果第2章中没有发生什么特别的事情,你可以随意忘记它。Transformer并不是这样处理信息的。当阅读第11章时,如果需要回忆第1章的内容,它会立即记住,因为它依然能够访问整个11章内容。事实上,如果不是我们在LLM推理中构建的KV缓存(KV Cache),Transformer实际上在阅读每一个新词时都在重读之前的内容。

这是否显得低效?希望是如此,因为确实如此。

但这意味着什么呢?简单来说,这种未压缩的记忆在序列变长时会增长。如果你在第15章,之前的章节会积累,内存需求比读第8章时(只有七个章节)大得多。

更糟的是,由于底层注意力机制的工作方式(为了节省篇幅这里不展开讨论),我们需要为每个章节的每个词存储两种信息(键和值)。这意味着Transformer的计算和内存复杂度是O(n²),也就是说,每当序列长度加倍,计算和内存需求就增加四倍(而三倍则意味着增加九倍)。

通过FlashAttention技术(一部分今天的研究人员开发的),我们帮助降低内存需求至次平方复杂度,通过避免将整个注意力矩阵物化到内存中,也就是说,以上问题部分解决。然而,FlashAttention并未处理计算复杂度,它仍然是平方复杂度(特别是O(n²*d),其中n是序列长度,d是模型维度,即每个向量嵌入的数字量)。原因是因为我们不压缩内存,每个词必须关注每一个先前的词,而在Transformer中主要的操作是成对的乘法运算,因此计算在序列长度方面仍然是平方复杂度。

但现在,一组研究人员找到了将计算复杂度扩展至次平方领域的方法。

如何线性化注意力 正如我们之前提到的,Transformer依赖于一种称为注意力机制的数学操作,它的复杂度在序列长度方面是平方的。简单来说,较长的序列会急剧增加计算需求。然而,其他注意力机制(如线性注意力)则具有线性复杂度,虽然它们在纸面上表现不佳(即线性注意力模型表现较差)。

但如果我们通过训练线性注意力层去模仿它们更高计算密度的对等层,会发生什么呢?

因此,LoLCaTs的目标是创建线性化的LLM,它们在保留其平方复杂度对等体性能的同时具备成本效率。

为此,他们将问题分为三步。

步骤1:替换层 线性化模型的第一步是插入一组线性注意力层,并训练它们模仿标准注意力层的输出。如下所示,通过两层输出之间的均方误差(MSE),我们可以训练新层表现得像原始层。

但此时,虽然新层个体上在模仿对等层,已创建的模型表现异常。为了解决这个问题,我们需要重新训练模型执行标准的下一个词预测任务。

步骤2:LoRA微调 当然,重新训练模型以提高效率似乎是一个难以证明合理性的资源使用方式。幸运的是,完全模型微调并非必要,因为有了LoRA适配器。我在这个通讯的多个期刊中提到过它们,但概念是:对任何给定任务,模型本质上是低秩的。简而言之,只有一小部分权重对任何给定预测是重要的。因此,我们可以添加一组小权重,称为适配器,然后将其添加到每层中,同时保持原始模型权重‘未触动’。某种程度上,这些适配器会根据它们所训练的数据调整模型的行为。

重要的是,这些适配器比实际模型小得多,因此它们可以快速且低成本地训练。

经过LoRA训练后,我们得到一个在下一个词预测上表现与原模型相似的模型,尽管其具有线性注意力层。

该模型并非完全线性化。实际上,他们将标准注意力(平方)与线性注意力结合起来,因此如果一个序列有D=N+M个词,最后的M个词使用标准注意力计算,而前面的N个(数量更多)则使用线性注意力。

这在标准注意力的表现力和线性注意力的计算效率之间找到了一个良好的平衡。由于语言通常具有邻近性偏向,这意味着最近的词通常比过去的词对预测下一个词更重要,我们使用softmax注意力(标准注意力)处理更近的词,并使用线性注意力处理其余词。

序列中最后的M个词采用传统计算,其他则采用线性形式。

而在此之上,研究人员添加了最后一步。

步骤3:分层优化 在注意力迁移过程中,我们将模型中的所有层一起更新。然而,研究人员观察到,这意味着模型的最后几层具有更大的均方误差(MSE),对于像Llama 3.1 405B这样更深层的模型(比小型版本拥有更多层)尤其如此。因此,他们分批训练层,以确保不会发生这种情况。

那么,最终结果是什么?

最先进…且便宜

LoLCaTs通过在4000万个tokens上优化0.2%的模型参数,大幅提升了线性化大型LLM的效率,关闭了与完全softmax(标准)Transformer性能差距的80%,尽管所用训练tokens量级少了数百倍,比标准微调高达35,500倍的训练效率。

更令人印象深刻的是,在所有三个Llama尺寸上,LoLCaT模型的表现类似于原始模型,但享有线性注意力层带来的显著计算效率,而不是完全的平方注意力。

这些成果仅花费了Llama 3.1 8B单GPU上的几个训练小时(成本低于20美元)。

当然,我们必须承认这种方法仍需一个预训练的平方Transformer来执行这一层蒸馏,但像LoLCaTs这样的基于推理的优化将成为任何希望采用生成式AI的企业的重要组成部分,在获得优异性能的同时节省大量计算成本。

总结:
LoLCaTs技术可以让大型语言模型(LLM)的训练成本大大降低。利用这个技术,80 亿参数的模型训练成本只需要不到 20 美元,同时还保持了原本的高性能。和传统的 Transformer 方法相比,LoLCaTs 大幅提升了效率,让模型训练变得又快又省钱。这一突破让更多企业可以低成本获得强大的 AI 能力,为 AI 的普及铺平了道路。

本文分享自
来源:
《华为云DTSE》第五期开源专刊
,作者:任洪彩 华为云高级软件工程师,Karmada社区Maintainer。

管理和协调跨多个云平台的容器化应用是当前企业面临的复杂性挑战之一,Karmada多云容器编排技术使得用户能够像操作单一集群一样轻松管理多集群,简化了多云环境的运维复杂度,加速分布式云原生应用升级。

行业背景

随着云计算技术的飞速发展,企业对于云基础设施的需求日益多样化,多云策略成为了众多企业的首选。多云环境不仅能够提高业务的灵活性和可用性,还能有效降低对单一云服务商的依赖风险。根据最新的调查报告显示,超过87%的企业正在使用多个云厂商的服务,然而,随之而来的是管理和协调跨多个云平台的容器化应用的复杂性挑战。

业界流行的容器编排工具Kubernetes(简称K8s),虽然在单一集群内展现了强大的资源管理和自动化部署能力,但在面对多云场景时,其跨集群的资源调度、统一管理以及数据一致性等问题成为了亟待解决的痛点。

现阶段,云原生多云多集群业务的编排面临着诸多挑战:

  1. 集群繁多的重复劳动:运维工程师需要应对繁琐的集群配置、不同云厂商集群间的管理差异以及碎片化的API访问入口等问题。

  2. 业务过度分散的维护难题:应用在各集群的差异化配置繁琐;业务跨云访问以及集群间的应用同步难以管理。

  3. 集群的边界限制:应用的可用性受限于集群;资源调度、弹性伸缩受限于集群。

  4. 厂商绑定:业务部署的黏性问题,缺少自动化故障迁移;缺少中立的开源多云容器编排项目。

Karmada多云容器编排引擎,
简化多云环境管理复杂度

为了解决上述挑战,华为于2021年正式推出了开源项目Karmada,旨在打造一个云原生的多云容器编排平台。Karmada(Kubernetes Armada,舰队之意)继承并超越了社区Federation v1和v2(kubefed)的设计理念,它不是简单地在不同集群间复制资源,而是通过一套全新的API和控制面组件,实现了在保持Kubernetes原有资源定义API不变的前提下,无缝地在多云环境中部署和管理分布式工作负载。

Karmada提供了一个全局的控制面板,使得用户能够像操作单一集群一样管理多云上的Kubernetes集群,简化了多云环境的运维复杂度,引入了高级的跨集群调度策略,根据资源需求、成本、合规性等因素,自动将工作负载优化部署到最适合的云平台或区域。通过分布式数据管理和同步机制,确保多云间的数据和配置一致性,降低了数据管理的复杂度。

实践案例:Karmada在工业智能检测领域的应用

工业智能检测领域亟需标准化智能检测提升效率

在液晶面板生产领域,由于多种因素,产品常出现不良品。为此,关键工艺节点后引入了自动光学检测(AOI)设备,通过光学原理检测常见缺陷。然而,现有 AOI 设备仅识别缺陷有无,需要人工分类和识别缺陷,这一过程耗时且影响生产效率。数之联的客户企业,
某面板龙头企业
,引入自动缺陷分类系统(ADC)以提高判定准确性并减轻劳动强度,使用深度学习技术自动分类 AOI 输出的缺陷图片,并筛除误判,从而提高生产效率。

客户企业率先在一个工厂引入 ADC,后续在其他工厂推广,节省人力资源,提高判定效率。尽管如此,由于工艺复杂和供应商差异,现场建设呈现出割裂和分散管理的趋势,给数据共享和运维带来困难。为解决这些问题,客户企业启动了工业智能检测平台的建设,该平台利用人工智能技术,实现标准化智能检测并提高生产效率和良率。

工业智能检测平台

工业智能检测平台将 ADC 作为核心,扩展至模型训练和检测复判,实现“云”(管理+训练)+“边”(推理)+“端”(业务)的一体化方案,旨在通过标准化平台提高生产质量和数据价值。建设范围包括资源共享中心、现地训练和边侧推理等子平台,将在若干工厂实施。

工业智能检测平台架构图

项目目标是实现现地 ADC 上线、资源共享和云边端标准化,以减轻运维负荷、提升标准。工业智能检测平台旨在通过规范化和标准化客户企业全集团的 ADC 系统,为后续 ADC 建设提供样本和模板,降低成本和周期,提高生产和质检效率以及产品良率。平台包含系统管理员、资源配置员等用户角色,并涉及 ADC 推理、模型训练、数据共享等信息流,以及云端协同功能,确保 ADC 的自动缺陷分类生产过程,并提高模型和缺陷图片的利用率。

结合Karmada多集群管理构建解决方案

一、集群管理:多地域集群统一纳管

不同地域的 K8s 集群注册至中心云系统,中心云系统对多个现地的集群进行管理。

二、应用管理:全局统一部署、监控

通过 Karmada提供的集群统一访问能力,用户在中心云实现可视化大屏等需要聚合成员集群的数据的功能。

1、集群监控

针对在线的集群,中心云系统可对内存、CPU、磁盘、网络流入流出速率、GPU、日志等指标进行监控数据展示,并可切换集群进行数据查看。

资源监控

中心云可以看到和训练云相同的监控,通过 Karmada 聚合层 API 由集群的 Java 程序对 PromQL 封装后提供给前端页面。

2、中心云数据下发

用户在中心云上传的数据,可自由选择下发至指定现地,包括数据集、标注、算子工程、算子镜像以及模型等。

数据集、算子工程、模型,通常是文件,在完成传输后,会保存到本地或NAS等存储中。标注,通常是结构化数据,在完成传输后,会保存到 DB 中。算子镜像,一般导出为 tar 包,在完成传输后,会推送到当前集群的 harbor 中。 中心云除了 Karmada 的控制面以外,也带有自己的业务 K8s 集群,也包括存储,因此可以作为一个中转器。以上均通过 Karmada 的聚合层 API 来调用我们提供的文件上传到 svc,实现了集群和集群之间的调用。

3、跨现地训练

针对某现地训练资源不足的情况下,可通过申请其他现地资源的方式,进行跨现地训练。该功能实现方式为将 A 现地训练所需要的数据集、标注、算子工程、算子镜像等数据发送至 B 现地,通过 B 现地的资源进行训练。再将训练好的模型返回给 A 现地。

原理和中心云数据下发类似,任务所需的数据会直接发送到对应集群,体现了成员集群和成员集群之间的调用关系。

4、可视化大屏

根据中心云注册的现地,统计不同现地的各类指标数据进行大屏展示。在这类大屏中展示实时数据的时候,通过 Karmada 聚合层 API,我们可以方便地直接调用成员集群的 svc,而无需让所有的数据显示都走大数据的离线分析、实时分析,提供更高的时效性。

总结展望

Karmada项目自2021年开源并加入云原生计算基金会(CNCF)成为沙箱项目以来,已经取得了显著的发展与认可。项目于2023年底正式晋升为CNCF的孵化级别项目。这一成就标志着Karmada技术生态获得全球业界的广泛认可,进一步巩固了其在分布式云原生技术领域的领先地位。该项目凭借其创新的多云多集群容器编排能力,已被全球范围内超过30家知名企业所采纳用于构建企业级云原生平台。

Karmada的出现,为多云时代的企业提供了一个强大且灵活的容器编排解决方案,它不仅解决了多云管理的痛点,还为企业在云原生旅程中探索更广阔的应用场景提供了坚实的技术支撑。随着云原生技术的不断演进,Karmada有望成为连接和简化多云生态的关键力量,助力企业释放云的全部潜能,加速数字化转型的进程。

点击关注,第一时间了解华为云新鲜技术~

前言

本篇文章前面客观评估了 .NET 创建动态方方案多个方面的优劣,后半部分是
Natasha V9
的新版特性。

.NET 中创建动态方法的方案

创建动态方法的不同选择

以下陈列了几种创建动态方法的方案:以下示例输入为 value, 输出为 Math.Floor(value/0.3):

emit 版本

 DynamicMethod dynamicMethod = new DynamicMethod("FloorDivMethod", typeof(double), new Type[] { typeof(double) }, typeof(Program).Module);
 ILGenerator ilGenerator = dynamicMethod.GetILGenerator();

 ilGenerator.Emit(OpCodes.Ldarg_0);  
 ilGenerator.Emit(OpCodes.Ldc_R8, 0.3);  
 ilGenerator.Emit(OpCodes.Div);  
 ilGenerator.Emit(OpCodes.Call, typeof(Math).GetMethod("Floor", new Type[] { typeof(double) }));  
 ilGenerator.Emit(OpCodes.Ret); 

 Func<double, double> floorDivMethod = (Func<double, double>)dynamicMethod.CreateDelegate(typeof(Func<double, double>));

表达式树版本

 ParameterExpression valueParameter = Expression.Parameter(typeof(double), "value");

 Expression divisionExpression = Expression.Divide(valueParameter, Expression.Constant(0.3));
 Expression floorExpression = Expression.Call(typeof(Math), "Floor", null, divisionExpression);
 Expression<Func<double, double>> expression = Expression.Lambda<Func<double, double>>(floorExpression, valueParameter);

 Func<double, double> floorDivMethod = expression.Compile();

Natasha 版本

AssemblyCSharpBuilder builder = new();
var func = builder
    .UseRandomLoadContext()
    .UseSimpleMode()
    .ConfigLoadContext(ctx => ctx
        .AddReferenceAndUsingCode(typeof(Math))
        .AddReferenceAndUsingCode(typeof(double)))
    .Add("public static class A{ public static double Invoke(double value){ return Math.Floor(value/0.3);  }}")
    .GetAssembly()
    .GetDelegateFromShortName<Func<double, double>>("A", "Invoke");

Natasha 方法模板封装版

该扩展库
DotNetCore.Natasha.CSharp.Extension.MethodCreator
在原 Natasha 基础上封装,并在 Natasha v9.0 版本后发布。

  1. 轻量化构建方案:
var simpleFunc = "return Math.Floor(arg1/0.3);"
    .WithMetadata(typeof(Math))
    .WithMetadata(typeof(Console)) //如果报 object 未定义, 加上
    .ToFunc<double, double>();
  1. 智能构建方案:
var smartFunc = "return Math.Floor(arg1/0.3);".ToFunc<double, double>();

方案对比与分析

时由此可以看出,无论哪种动态构建,都无法挣脱
typeof(Math)
的束缚,甚至需要反射更多的元数据。

元数据在动态方法创建中是必不可少的结构,既然大家都依赖元数据,不妨做一个对比;

方案名称 编码形式 Using 管理 内存占用 卸载功能 构建速度 执行性能 断点调试 学习成本
Emit 繁琐 不需要 .NET9 可卸载 .NET9 支持
Expression 一般 不需要 .NET9 可卸载 .NET9 支持
Natasha 一般 需要 可卸载 首次慢 支持
Natasha Method 简单 需要 可卸载 首次慢 支持

场景

首先从场景角度来说,Emit / Expression 方案在 .NET 生态建设中扮演了非常重要的角色,也是不少高性能类库的主要核心技术栈。而 Roslyn 的动态编译技术从初期到完成,走的是一个全面的编译流程,Natasha 是基于 Roslyn 的。虽然我是 Natasha 作者,但我还是建议,小打小闹,使用表达式树。而那些比较复杂的动态业务、动态框架、动态类库使用 Natasha 更加舒适。举几个例子,比如规则引擎, Natasha 不是规则引擎,但你可以用 Natasha 来定制一个符合你操作习惯的规则引擎。再比如对象映射, Natasha 不是对象映射库,但你可以用 Natasha 来定制一个符合你操作习惯的对象映射库; 如果你觉得市面上的 ORM 用着都不顺手,当然可以用 Natasha 来定制一款自己喜欢的 ORM.

编码形式程与 Using 管理

从编码过程来说,Emit 是比较繁琐的,从编码思维上来讲,Emit 属于“栈式编程”,数据的操作顺序后进先出,与平常使用的 C# 关键字不同,它更趋近于底层的指令,你不会看到 if/switch/while 等操作,而是 Label 和一些跳转指令,为此你也无法享受到正常编译所带来的便捷,甚至需要还原一些操作,比如 "str1" == "str2" 实际上要换成 Equal() 方法。
而表达式树相比 Emit 就要舒服一点了,然而这也不是正儿八经的 C# 编程思维, 仍然还是需要经过加工处理的,如果你非常享受这种转换过程,并能从中获得成就感或者其他感觉,那无可厚非,它适合你。我想大多数人是因为没有办法才选择动态方案,无论哪种,能从中获得愉快感觉的开发者并不会很多。
相比前两者,Natasha 需要注意的是 “域” 操作和 Using 引用,有了 “域” 更加方便隔离程序集和卸载,如果你的动态代码创建的程序集永远都是有用的,使用默认域即可。反之那些需要卸载或更新的动态功能,就得选择一个非默认域了。
除卸载之外,另一个参与动态构建过程的就是 Using ,因为 Using 代码是 C# 脚本必不可少的一环,因此以 C# 脚本方式构建动态功能需要考虑到
using System;
这类代码. 而 Using 中遇到的最大的问题是二义性引用 (CS0104) 问题:
假设有
namespace MyFile{ public static class File{} }
在 VS 里开启隐式
<ImplicitUsings>enable</ImplicitUsings>
后引用它你会发现错误,表面原因是
MyFile

System.IO
命名空间冲突了,实际原因是这两个命名空间都有 File 相关的元数据,而语义分析不会对后续代码功能进行推断你到底想使用哪个 File, 这种情况在复杂的编程环境下或许会出现,不可避免只能发现和改正。

一旦发生这种情况,您需要排除不想引用的 Using.

//排除 System.IO
builder.AppendExceptUsings("System.IO");

我们继续看第四种,基于 Natasha 封装的动态方法构建,非常的简单,其中:

  • 轻量化构建写法是按需引用元数据编译成委托。
  • 智能构建写法是直接在元数据和 using 全覆盖的情况下编译成委托,该写法的前提是预热了元数据和 Using 集合,详情可以看 Natasha 预热相关的方法。

内存占用

前二者的编译占用系统内存很少,毕竟是直接一步到位的做法,少了很多分析转换缓存的过程。可以这么理解,前二者是 Roslyn 编译中的一环。

卸载功能

后文 Natasha 的"域"均用 AssemblyLoadContext(ALC) 代替。

限定在本文4种编码案例范围内,目前我还没看到过关于 直接卸载 Emit/表达式树 创建的动态方法相关的文章。
但 .NET9 推出的 PersistedAssemblyBuilder 将允许编译 Emit 并输出流到 ALC 中。

PersistedAssemblyBuilder ab = ....;
using var stream = new MemoryStream();
ab.Save(stream);

NatashaDomain domain = new("MyDomain");
var newAssembly = domain.LoadFromStream(stream);

虽然 .NET9 支持保存程序集了,但 ALC 的卸载功能有点难搞,.NET 官方对 ALC 的卸载操作几乎是无能为力的,从理论上来讲只要你的类在使用中,这个类就无法被卸载,这种情况很多,一个静态泛型类,或一个全局事件,或被编译到某个不被清理的方法中,又不提供清理方法,他们就会成为程序的僵尸类型。官方没有办法强制对正在使用的数据做些什么强制处理。假如我的程序有 60 个依赖项,我需要找到这些依赖项的作者,也许作者不止 60 个,然后一一询问他们:请问您的库如何能够清除保存在您这里的 ALC 创建的元数据?然后附上一些调试证据,告诉他,在 2 代 GC 中发现了大量无法被释放的元数据,这些元数据与你的 XXX 有关。读到这里你觉得非常难办,甚至有点荒谬,没错,就是这样,也只能这样。所以我在制作 HotExector 的过程中也不断的思考和实验整域代理以屏蔽域外引用的干扰。
话说回来如果是自己封装的框架,那么这个卸载的问题是会很好解决,因为你知道什么东西该清理,什么字段该置空。

构建速度

与内存占用说明类似,一个完整的编译过程肯定要比其中一环占用的时间长,况且 Roslyn 内部还会缓存和预热一些数据。首次编译后,编译速度就会非常的快。

执行性能

如果被编译的 Emit 代码逻辑能够和 Roslyn 内部优化后的脚本逻辑一样,那么执行性能理论上是持平的。例如在多条 if 或 switch 的数值查找逻辑中,Roslyn 可能会对这些查找分支进行优化,比如使用二分查找树代替原有的查找逻辑,如果你想不到这些优化点, Emit 代码的性能只能依靠后续 JIT 的持续优化来提高了,因为考虑到 JIT 后续的优化可能会让它们都达到最优结果,因此都给了 “高”。而二者的区别开发者应该了解,相比 Emit 原生编程,Roslyn 编译的 Emit 逻辑更加优秀和高性能。

断点调试

Natasha 自 V8 版本起开始支持脚本断点调试,V9 版本升级了不同平台、不同 PDB 输出方式的兼容性优化, Natasha 编译框架支持 .netstd2.0。
而 Emit 也迎来的自己的调试方案,上文提到 .NET9 的 PersistedAssemblyBuilder,通过使用该类的
GenerateMetadata
方法来生成元数据流,进而创建
PortablePdbBuilder
调试数据实例,然后转化为
Blob
(BlobBuilder),最后写入 PDB 流. 有了 PDB 文件, Debug 断点调试将变得可行。

Natasha 以及动态方法模板的优势

套娃编译

使用 Natasha 可以进行套娃编译,使用动态编译进行动态编译,这种逻辑很复杂,举个简单的例子,假如需求是生成动态类型 A,在 A 的静态初始化方法中生成一个动态委托 B, 而且 B 的部分逻辑是根据动态类型 C 和 D 所接受到的数据决定的。这在表达式树和 Emit 的编码思维中,可能对数据和类型做一个中转处理,而且在编译过程中要用到
还没有被编译的 A 的成员元数据
,这里很绕。这种情况我更推荐使用 Natasha ,因为是考虑到学习成本和时间成本,按照正常思维 5 分钟编写完脚本,为啥还要用 20 分钟甚至 1 小时去找解决方案,设计缓存,定制运行时强编码规则呢?

私有成员

很多开发者应该都有读源码的习惯,甚至在高性能场景会对源码进行魔改和定制,部分开发者有 200% 的信心保证他们获取的私有实例和方法不会在程序中被乱用,这里就会遇到一些问题,重新定制源码,或者使用未开放的实例方法会遇到访问权限的问题。节省时间和篇幅直接上例子:

已知在支持热重载的 MVC 框架中,有
IControllerPropertyActivator / IModelMetadataProvider
两个服务实例,它们提供了私有方法
ClearCache
方法清除元数据缓存的接口,但
IControllerPropertyActivator
接口由于访问限制,写代码时 IDE 会报错,事已至此,运行时要拿到
IControllerPropertyActivator
接口类型只能先获取它的实例然后通过反射获取类型,而这里的操作就是矛盾的操作,如果我不知道哪个类型实现了该接口,或者说实现接口的类型也是个 private 级别,那么我又该如何获取到实例.

Natasha V9 版以前需要自己定制开放私有操作,我们看一下 V9 更新后的操作:

//打开私有编译开关
builder.WithPrivateAccess();
//改写脚本
builder.Add(script.ToAccessPrivateTree("Microsoft.AspNetCore.Mvc.ModelBinding","Microsoft.AspNetCore.Mvc.ModelBinding.Metadata"));
//或
builder.Add(script.ToAccessPrivateTree(typeof(IModelMetadataProvider),"Microsoft.AspNetCore.Mvc.ModelBinding.Metadata"));
//或
builder.Add(script.ToAccessPrivateTree(instance1,instance2...));

开启以上选项,Natasha 将重写语法树以支持私有操作。最终脚本无需任何处理,一气呵成,以下是使用 Natasha 绕过了访问检查的脚本案例:

//该脚本如果在 IDE 中会有访问权限问题
var modelMetadataProvider = app.Services.GetService<IModelMetadataProvider>();
var controllerActivatorProvider = app.Services.GetService<IControllerPropertyActivator>();
((DefaultModelMetadataProvider)modelMetadataProvider).ClearCache();
((DefaultControllerPropertyActivator)controllerActivatorProvider).ClearCache();

安全相关

有人说使用脚本会导致安全问题,我觉得这种说法太过片面,不应该把人为因素强加到某个类库中,Natasha 不会自行的为应用程序提供后门漏洞,任何上传的文本和图片都需要有严格的审核,包括上传的脚本,即便没有非法的网络请求代码,占用资源,数据安全等问题的代码也要进行严格排查。对于需要大量动态脚本的支持的服务,服务应该严格限制元数据和规范功能脚本粒度。

Natasha V9 新版变化

项目主页:
https://github.com/dotnetcore/Natasha

链式初始化 API

为了让初始化更容易懂,在新版本中增加了一组链式操作的 API.
此组 API 更容易控制 Natasha 的初始化行为。

NatashaManagement
    .GetInitializer()
    .WithMemoryUsing() //不写这句, Natasha 将不会扫描和预存内存中的 UsingCode. 
    .WithMemoryReference()
    .Preheating<NatashaDomainCreator>();

注:在使用智能模式时,预热 Using 和 Reference 是必要的,除非你能很好的管理这些。

更灵活的元数据管理

  • 自 v9 版本起,简单模式(自管理元数据模式)支持单独添加元数据,单独添加 using code, 而不是 引用和using 一起添加。
  • 编译单元允许添加排除 using code 集合,
    builder.AppendExceptUsings("System.IO","MyNamespace",....)
    , 该方法将防止指定的 using 被添加到语法树中.

外部异常获取

  • 增强错误提示,引发编译异常时,将首先抛出错误级别的异常,而不是警告。
  • 增加 “GetException” API, 在 Natasha 编译周期外,获取异常错误。

重复编译

V9 版本在重复编译方面做了一些增强,进一步增加复用性和灵活性。

1. 重复的文件输出

  • WithForceCleanOutput 该 API 使用后将开启 强制清除文件 开关,以避免在重复编译时产生 IO 方面的错误。
  • WithoutForceCleanOutput 是默认使用的 API, 此时遇到重复文件,Natasha 将自动改名到新文件中, oldname 被替换成 repeate.guid.oldname.

2. 重复的编译选项

  • WithPreCompilationOptions 该 API 开启后,将复用上一次的生成的编译选项(若没有则生成新的),该编译选项对应着 CSharpCompilationOptions 相关参数, 如果第二次编译需要切换 debug/release,unsafe/nullable 等选项,请关闭该选项。
  • WithoutPreCompilationOptions 是默认使用的 API, 该 API 不会锁定 CompilationOptions,保证每次编译都是最新的。

3. 重复的引用

  • WithPreCompilationReferences 该 API 开启后,将复用上一次的元数据引用集。
  • WithoutPreCompilationReferences 是默认使用的 API。
  • 新版本中增强了对 “引用API” 的注释,让其行为更加容易被看懂。321,
    上链接

4. 私有脚本支持

在使用前需要在工程中加上 IgnoresAccessChecksToAttribute.cs 文件

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
    public class IgnoresAccessChecksToAttribute : Attribute
    {
        public IgnoresAccessChecksToAttribute(string assemblyName)
        {
            AssemblyName = assemblyName;
        }
        public string AssemblyName { get; }
    }
}
//给当前脚本增加私有注解(标签)
//privateObjects 可以是私有实例,可以是私有类型,也可以是命名空间字符串
classScript = classScript.ToAccessPrivateTree(privateObjects)

builder
.WithPrivateAccess() //编译单元开启私有元数据支持
.Add(classScript );

5.编译优化级别

注意:使用动态调试前,请先在工具-选项-调试中关闭[地址级调试]。

Natasha v9 对编译优化级别做了细化:

//普通 Debug 模式
WithDebugCompile(item=>item.ForCore()/ForStandard()/ForAssembly())
//加强 Debug 模式
WithDebugPlusCompile(item=>item.ForCore()/ForStandard()/ForAssembly())
//普通 Release 模式
WithReleaseCompile()
//加强 Release 模式
WithReleasePlusCompile()

理论上的加强模式可以理解为“刨根问底,全部显现”模式,虽然普通的模式就已经足够用,但这个模式可以更细粒度的输出调试信息,包括一些隐式的转换。
注:实验中没有看到更为细致的调试结果,有经验的同志可以告知我哪些代码可以呈现出更细腻的结果。

6. 其他 API

  • 新版本对 API 的注释进行了大量中文重写,小伙伴们可以看到更接地气,容易懂的注释,由于编译单元 (AssemblyCSharpBuilder) 多采用状态方式保存用户配置,因此在 API 上还简单增加了复用方面的说明。
  • 熟知的
    UseDefaultDomain()
    已过时,更符合 API 本意的
    UseDefaultLoadContext()
    名称更为合适, Domain 系列已经不能成为编译单元的前沿 API, 从 V9 版本起 LoadContext 系列将取而代之。
  • 增加
    CompileWithoutAssembly
    API, 允许开发者在编译后不向域中注入程序集,程序将不实装编译后的程序集。

结尾

之前以为自己入了 Roslyn 的冰山一角,没想到只是浮冰一块。

谢谢俞佬在文档上的支持。

码字不易,感谢看完,多谢点赞。