本帖最后由 花老板 于 2026-6-26 12:17 编辑
ProxyTool:从 Python 到 Go,一个代理工具的两次重生
一、起点:为什么我要重写它
事情很简单——我维护着一个从 GitHub 公开列表采集代理、自动验证、桌面监控的 Python 工具。最早用 Tkinter 糊的,跑起来能用,但当代理池膨胀到上千条的时候,问题来了。
GIL。200 个线程就跑不动了。`ThreadPoolExecutor` 的 `as_completed` 回调堆积在 UI 线程,15 秒一轮采集,窗口直接卡成幻灯片。CPU 其实很闲——代理验证本质是网络 IO 密集——但 Python 的线程模型让"闲"变成了"卡"。
我需要一个方案:高并发、低 CPU、单文件分发、Windows 原生 GUI。Go 是唯一同时满足这四个条件的。
事实证明,网络 IO 密集型的工具,语言切换带来的性能提升远超直觉预期。Go 的 goroutine 不是线程——它是"恰好让 IO 密集场景表现最优"的并发模型。
二、技术栈选择的真实考量
选 Go 不是跟风。我对比了三样东西:
维度 Python (现状) Go (目标) C# (备选) 并发模型 GIL 瓶颈,线程池上限 ~200 goroutine 轻松 800+,~2MB 栈 async/await + ThreadPool,也不错 部署形态 打包 PyInstaller,50MB+ 体积 单文件 EXE,-ldflags 裁剪后 ~10MB 依赖 .NET Runtime GUI 生态 Tkinter(内置但陈旧) lxn/walk(Windows 原生) WPF/WinForms,成熟但绑定平台 网络库 requests,生态好但同步阻塞 net/http + x/net,标准库即够用 HttpClient,也足够
Go 的劣势是 GUI 生态弱。lxn/walk 这个库作者已经停更了,最后 commit 停在 2021 年。但好在它本质是 Win32 API 的薄封装,Windows 11 上依然能跑,而且给了我最想要的东西:原生控件、主题感知、DPI 自适应。用 `proxytool.exe.manifest` 开启 DPI 感知后,高分屏上不再模糊——这一点直接毙掉了 Tkinter。
踩坑预警: lxn/walk 的 `declarative` 子包用链式 API 构建 UI,看起来很优雅,但错误信息极其不友好——控件 ID 冲突时报的是 native access violation,而不是 "duplicate widget"。调试 GUI 布局时建议用 `walk.MainWindow.SetSize` 显式设置窗口大小,别指望它自动计算,自动算出来的经常偏小一截。
三、代理采集:在国内网络环境下的生存之道
原版 Python 直接从 GitHub raw 地址拉代理列表。Python 版有 21 个源,包括 `raw.githubusercontent.com`、`proxyscrape.com`、`proxy-list.download`、`openproxy.space`。
切到 Go 后第一轮测试,21 个源里 18 个超时 。`raw.githubusercontent.com` 在国内 DNS 污染严重,`proxyscrape` 和 `openproxy.space` 的 API 直接 TCP reset。能用的只剩 3 个。
这不是 Go 的问题。这是你在国内写网络工具时必须面对的第一课:任何指向境外直连的 URL 都是不可靠的。
解决方案是双镜像兜底:
[] 纯文本查看 复制代码
// 按序兜底:ghfast.top快(~1.4s),失败再试ghproxy.net
var ghMirrors = []string{
"https://ghfast.top/https://raw.githubusercontent.com/",
"https://ghproxy.net/https://raw.githubusercontent.com/",
}
func ghRaw(path string) []string {
out := make([]string, len(ghMirrors))
for i, m := range ghMirrors {
out = m + path
}
return out
}
每个 GitHub 源生成两个镜像 URL,`collector.go` 里按序尝试,拿到数据就停。ghfast.top 平均响应 ~1.4s,ghproxy.net 备用。被墙的 `proxyscrape`/`openproxy` 直接移除——不是镜像能解决的,它们是整站被 reset。
最终保留 11 个 GitHub 仓库源(TheSpeedX/SOCKS-List、clarketm/proxy-list、monosans/proxy-list、hookzof/socks5_list、roosterkid/openproxylist 等),全部走镜像。
踩坑: 最初我把镜像 URL 放在一个 `map` 里随机选,结果 `ghproxy.net` 时有不可用,约 15% 的轮次拿不到数据。改成确定性序后稳定 100% 命中。不要迷信"故障转移"——在网络不可靠的场景下,确定性的优先级列表比随机选择更可靠。
四、验证引擎:从 200 线程到 800 协程的跃迁
这是重写收益最大的部分。
Python 版验证逻辑:`ThreadPoolExecutor(max_workers=200)` → `requests.get(proxy, timeout=5)` → 回主线程更新 UI。问题在于:
1. `concurrent.futures` 的线程是真 OS 线程,200 个线程的上下文切换开销可观
2. `requests` 是同步阻塞的,每个线程在等待 IO 时占用完整线程栈
3. 结果通过 `as_completed` 逐个回调,UI 更新频率不可控
Go 版的方案:
[] 纯文本查看 复制代码
// 800协程持久池,channel驱动
for i := 0; i < VerifyThreads; i++ {
go func() {
for addr := range verifyChan {
elapsed := tryConnect(addr, VerifyTimeout)
outcomeChan <- verifyOutcome{addr, elapsed, ...}
}
}()
}
// UI端:150ms定时批量刷新
go func() {
ticker := time.NewTicker(150 * time.Millisecond)
for range ticker.C {
app.mw.Synchronize(func() {
app.model.PublishRowsReset()
app.updateStats()
})
}
}()
关键改动:
持久协程池 :不销毁重建,800 个 goroutine 在 `select` 上阻塞等待 channel,切换开销接近于零 连接超时降到 1s :死代理连不上 1 秒就放弃,不等 5 秒 DNS 超时 流水线即时更新 :每轮开始立即把全部代理丢进验证队列,各源边抓边验,不等到"所有源都抓完" UI 批量刷新 :`Synchronize` 回到主线程,150ms 批量重绘,不逐条更新
实测数据: 首个可用结果 ~58ms 出现在列表中。Python 版同样场景下需要 2~4 秒——因为它在等 `as_completed` 逐个回调。Go 版 CPU 占用始终稳定在 1~3%,800 协程在 4 核机器上几乎没有调度压力。
踩坑 2: 最初用 `time.Ticker` 做 UI 刷新,没有加 `Synchronize`,直接在 goroutine 里调 `PublishRowsReset()`。结果随机触发 access violation——walk 的 TableView 不是线程安全的。`Synchronize` 本质是向 Windows 消息队列 `PostMessage` 一个回调,保证在主线程执行。这是任何一个 Windows GUI 框架的铁律,但 walk 的文档完全没有提到这一点。
五、纯真 IP 库:GBK 编码的跨语言之痛
`qqwry.dat` 是中文社区最常用的离线 IP 归属地库,26MB,纯二进制格式。Python 版直接用 `qqwry` 包,一行 `query(ip)` 搞定。
Go 版——没有现成的库。我参考 qqwry 的逆向文档手写了解析器。核心数据结构是一个二叉查找树,索引区存了每条记录的文件偏移:
[] 纯文本查看 复制代码
func (q *QQwry) Query(ipStr string) (country, city string) {
ip := ipToUint32(net.ParseIP(ipStr).To4())
offset := q.search(ip, q.indexStart, q.indexEnd)
return q.readRecord(offset)
}
func (q *QQwry) search(ip uint32, low, high uint32) uint32 {
// 二分查找索引区,每条7字节:4字节IP + 3字节偏移
for low <= high {
mid := low + (high-low)/7*7
midIP := q.readUint32(mid)
if ip > midIP {
low = mid + 7
} else {
high = mid - 7
}
}
return q.readUint24(high + 4) // 返回记录偏移
}
GBK 解码才是真正的坑。 qqwry.dat 中的地区字符串是 GBK 编码,Go 标准库没有 GBK 支持。用 `golang.org/x/text/encoding/simplifiedchinese` 可以解,但有些边界情况:
某些记录的字符串以 NUL 字节结尾。先截断再解码,否则 `GBK.NewDecoder().Bytes()` 遇到 NUL 会直接返回原样,不解码 库中有些"保留"区域的记录是空字符串,返回 `" CZ88.NET"` ——这是 qqwry 官方的标记,不是错误 26MB 全部加载到内存的 `[]byte`,用 `binary.LittleEndian` 做偏移读取,不建额外索引——纯内存二分,单次查询 ~2us
在 Go 里处理中文编码一直是个微妙话题。标准库不包含 GBK 是合理的设计选择,但当你需要处理国内遗留数据时,`x/text` 就是必经之路。好在它是官方子仓库,质量和维护都有保障。
六、ProxyProbe:另一个维度的工具
proxytool_go 解决的是"日常使用"。但当我需要在一个 `/16` 网段里全端口扫描代理,而且目标网络有 IDS 的时候,GUI 工具就完全不够用了。
于是有了 ProxyProbe——一个独立的 CLI 工具,核心设计目标是隐蔽性 。
它的数据管道分两层:
Provider 层 :三种目标源——RangeProvider(IP 网段)、ScraperProvider(URL 抓取)、FileProvider(历史结果优先复测) Pipeline 层 :TCP Ping 预过滤 → 全局洗牌 → 令牌桶限速 → 随机抖动 → TCP 端口扫描 → 协议验证
调度器的实现是整个工具的灵魂:
[] 纯文本查看 复制代码
func (s *Scheduler) Dispatch(ctx context.Context) <-chan models.Target {
out := make(chan models.Target, 1000)
go func() {
defer close(out)
for _, t := range s.targets {
// 令牌桶:严格限制 PPS
if s.limiter != nil {
s.limiter.Wait(ctx)
}
// 随机抖动:毫秒级延迟
if s.cfg.SlowMode && s.cfg.Jitter > 0 {
delay := time.Duration(s.rng.Intn(s.cfg.Jitter)) * time.Millisecond
time.Sleep(delay)
}
out <- t
}
}()
return out
}
全局洗牌在 `prepareTargets` 中完成——把所有目标对 `[IP1:80, IP1:8080, IP2:80...]` 用 `math/rand.Shuffle` 彻底打乱。这样当限速 100 PPS 时,同一个 IP 的不同端口不会连续出现在线路上,避免了按 IP 聚集的特征。
严格 HTTPS 验证 是另一个硬骨头。很多公开代理会伪造 200 OK 响应或者劫持 HTTPS 流量。ProxyProbe 的 `strict_transport.go` 手动实现了 CONNECT 隧道 + TLS 握手:
[] 纯文本查看 复制代码
// 1. TCP拨号到代理
conn, _ := net.DialTimeout("tcp", proxyAddr, timeout)
// 2. 发送 CONNECT 请求
fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", addr, addr)
// 3. 验证 CONNECT 响应码
resp, _ := http.ReadResponse(bufio.NewReader(conn), nil)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("proxy refused CONNECT: %d", resp.StatusCode)
}
// 4. 在隧道上执行 TLS 握手
tlsConn := tls.Client(conn, &tls.Config{ServerName: host})
tlsConn.HandshakeContext(ctx)
只有真正透传了 TLS 握手的代理才通过验证。伪造 200 的代理在第三步就会暴露——它们不等 `CONNECT` 就返回响应。
这个方案有一个取舍:如果代理本身就是 HTTP-only(不支持 CONNECT),它会被直接拒绝。但对于需要 HTTPS 的场景来说,一个不能建 CONNECT 隧道的代理跟不可用没有区别。
七、数据可靠性:原子写入的必要性
代理工具的验证结果文件 (`valid_proxies.txt`) 每轮都会全量重写。如果在写入过程中用户关了窗口、或被系统杀掉进程,半个文件留在磁盘上就会导致下次启动时解析失败。
Go 版的解决很简单但有效:
[] 纯文本查看 复制代码
func saveProxies(path string, proxies []*Proxy) error {
tmp := path + ".tmp"
// 全部写入临时文件
f, _ := os.Create(tmp)
bufio.NewWriter(f).WriteString(...)
f.Sync() // 确保落盘
f.Close()
// 原子替换
return os.Rename(tmp, path)
}
`os.Rename` 在 Windows NTFS 上是原子的——要么旧文件在,要么新文件在,不存在半个文件的状态。这个模式复制自 SQLite 的 WAL 策略,简单但异常可靠。
不要相信任何"先打开再清空再写入"的模式。 如果一个工具需要长期运行并定期持久化,原子写入是唯一正确的选择。中间状态的存在窗口哪怕只有几毫秒,在足够长的运行时间里也一定会被触发。
八、总结:两个工具,两种哲学
维度 proxytool_go (GUI) ProxyProbe (CLI) 目标用户 日常代理使用者 渗透测试、安全运维 并发模型 800 协程持久池 可配置并发 + 令牌桶限速 隐蔽性 不关注 全局洗牌 + 随机抖动,核心设计目标 协议验证 基础 HTTP HTTP + SOCKS5 + 严格 HTTPS (CONNECT+TLS) 部署形态 单文件 EXE,双击运行 CLI 二进制,支持守护进程 生态定位 "代理的日常监控面板" "低噪探测与批量验证引擎"
两个工具共享同一个数据文件格式 (`valid_proxies.txt`),可以互换使用。采集到一批代理后直接用 ProxyProbe 做深度验证,成果无缝回流到 proxytool_go 继续监控。
项目已完整开源,代码中每一行都是为实际场景写的——没有为了"整洁架构"引入不必要的抽象,也没有因为是工具类项目就降低质量标准。
八、源码下载