Skip to content

Development

Hades edited this page Aug 23, 2026 · 6 revisions

开发与发布

环境

git clone https://github.com/yuanqimanong/IPClick.git
cd IPClick
uv sync --all-groups

要连可选适配器一起装,得逐个 --extra 列出

uv sync --all-groups \
  --extra niquests --extra camoufox --extra patchright --extra drissionpage

不能用 --all-extras [tool.uv].conflicts 里声明了两组互斥: [camoufox] × [playwright](camoufox 钉着 playwright<1.61,而 [playwright] extra 要 >=1.62.0),以及 [linux] × [playwright]——后者是因为 [linux] 本身聚合了 camoufox,所以 --extra linux --extra playwright 同样会失败。--all-extras 会一次点亮 所有互斥项,必定解不出来。声明成互斥的好处是 uv 会为两者各解一份依赖,[playwright] 才能跟上上游版本。

pyproject 一共 7 个 extras,其中两个是按平台的聚合:[win] = DrissionPage, [linux] = camoufox + patchright。

不用 uv 的话:

pip install -e ".[niquests,patchright]"
pip install ruff basedpyright pytest pytest-asyncio

依赖分组

按用途分组,让每个 CI job 只装自己要的那份:

装什么
test pytest、pytest-asyncio
lint ruff、basedpyright
proto grpcio-tools
release twine
dev 上面四组的聚合(默认组)

构建后端是 uv_build,包根在 src/

门禁

提交前跑这四条:

uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv run basedpyright src/ tests/
uv run pytest
  • basedpyright 覆盖 src/tests/ 两处,warning 也算失败。 它在有 warning 时 退出码同样是 1,所以本地和 CI 判定一致。当前是全 0。
  • ruff format 不能碰生成文件 —— task_pb2.py / task_pb2.pyi 已在排除列表里。 漏掉的话 format 会改写它们,之后每次重新生成 protobuf 都产生一堆无意义 diff。 注意 .pyi 必须单独排除:"*_pb2*.py" 这个 glob 匹配不到它。
  • 没有 pytest-cov。 覆盖率不在门禁里,--cov 参数用不了;要看覆盖率自己临时装。

CI

ci.yml 有五个 job:

job 干什么
checks 上面四条门禁的前三条 + 验证 protobuf 生成产物是最新的。装 lint / proto 两组依赖 + 四个可选 extra(niquests / camoufox / patchright / drissionpage,刻意不含 playwright——它与 camoufox 互斥),好让 basedpyright 能解析它们的 import
test (3.11) / test (3.14) 刻意不装任何 extra——绝大多数用户只装核心依赖,这个 job 同时验证"可选适配器缺席时仍能 import 与运行"
build uv build + twine check + 校验包数据(配置模板、proto、py.typedSKILL.md)都打进了 wheel
docker 先建 ENGINE=none 的 slim 镜像快速验 Dockerfile,再建实际发布的默认镜像,最后断言 ipclick component list 里 patchright 显示「就绪」——确认浏览器本体真被打进去、且非 root 用户读得到。刻意不发真实请求,CI 里依赖外部站点会引入无关的红

Python 版本只测 requires-python 的两端(3.11 / 3.14)。中间的 3.12 / 3.13 仍是 声称支持但不做验证——这是换 CI 时间的有意取舍。

别把矩阵写成 "3.14t":自由线程版的 SOABI 是 cpython-314t,grpcio 没发对应轮子, 会退化成源码编译并卡在工具链上。

改协议

# 改完 src/ipclick/dto/proto/task.proto 之后
uv run python src/ipclick/dto/proto/generate.py

这个脚本调 protoc,并把生成代码里的顶层导入改成包内相对导入。 CI 会验证生成产物是最新的——改了 .proto 不重新生成会直接失败。

兼容性规矩

做法 允许?
加新字段(新字段号)
字段 stringbytes wire 兼容,编码完全相同
删字段 ✅ 但字段号必须进 reserved,永不复用
删枚举值 ❌ 标 deprecated = true 保留
改字段号
改字段类型(除上面那条)

枚举值标 deprecated 而不是删掉,是为了让老客户端传 HTTPX 时能得到 "已移除,请改用 niquests"这样一句有用的话,而不是"未知枚举值"。

需要区分"没传"和"传了默认值"时用 optional(显式 presence)——verify_sslallow_redirectsstream 这类布尔字段少了它就分不清"显式传 false"和"没传", 服务端只能把两者一律当成 false,"未设置时按 true 处理"这种默认值就无从实现。

项目结构

src/ipclick/
├── rpc/               # gRPC 通道与选项的唯一来源
│   ├── options.py         keepalive 等 channel 选项(客户端与服务端在此对齐)
│   └── channel.py         secure / insecure 频道构造
├── adapters/          # 适配器:curl_cffi / niquests / 四个浏览器引擎
│   ├── base.py            适配器基类、脚本规范化、错误分类
│   ├── retry.py           重试策略与重试循环(同步 / 协程共用)
│   ├── sessions.py        会话缓存(同步 / 异步,含关闭)
│   ├── registry.py        名字 → 实现,以及"没装"与"已移除"两张提示表
│   ├── browser_adapter.py 浏览器适配器与实例复用
│   └── browser_engines.py 引擎安装状态检测(三态)
├── services/          # gRPC 服务实现
│   ├── task_service.py    主服务:准入、限流、执行、记链路
│   ├── async_task_service.py  协程版
│   ├── errors.py          异常 → gRPC status code 的规则表
│   ├── components.py      远程组件管理
│   └── detached.py        脱离真实 RPC 的 ServicerContext
├── cluster/           # 集群
│   ├── node.py            节点模型与配置
│   ├── pool.py            节点池与健康探测
│   ├── balancer.py        轮询 / 随机 / 加权
│   ├── client.py          客户端分发 + 故障转移
│   ├── forwarder.py       服务端转发(async_forwarder.py 是协程版)
│   ├── discovery.py       static / dns 节点发现
│   ├── probe.py           节点探测:连通性与集群内部鉴权分开报
│   ├── status_page.py     只读状态页
│   └── tokens.py          共享密钥派生每节点令牌
├── web/               # Web 管理端
│   ├── server.py          HTTP、会话、CSRF、路由、CSP
│   ├── auth.py            凭据、会话、登录限速
│   ├── snapshot.py        仪表盘 / 请求流 / 集群的数据快照
│   ├── pages/             各页面的数据与操作(门面 + 5 个页面对象)
│   ├── templates/         渲染,按页面分文件
│   ├── assets.py          CSS 与两段内联 JS —— CSP 脚本哈希的唯一来源
│   ├── installer.py       受限子进程装 / 卸可选组件(白名单 + 后台任务)
│   ├── deploy.py          为子节点生成 toml / .env / 启动命令 / zip
│   ├── curl_parser.py     把 curl 命令解析成「试一试」表单
│   └── editable.py        可改配置的白名单
├── config_loader/     # 配置加载与写回
├── dto/               # 数据模型与 protobuf
├── utils/             # coerce / config_util / url_util / log_util …
├── server.py          # 服务端装配与启动
├── async_server.py    # 协程服务端
├── server_settings.py # [SERVER] 段的解析、校验与派生
├── multiprocess.py    # 多进程工作模式(SO_REUSEPORT)
├── protocols.py       # 跨层协议类型
├── limiter.py         # 按 host 的并发与速率闸门(async_limiter.py 是协程版)
├── trace/             # 链路记录:records / counters / store / recorder 四层
└── sdk.py / aio.py    # 同步 / 异步客户端

几处刻意的分层:

  • trace/ 拆成四个模块。 records(一条记录的形状、[TRACE] 配置、URL 脱敏)、 counters(本进程累计计数——它不落盘、不保留单条记录,和链路记录没有共同状态)、 store(SQLite 建表/迁移/异步写入/保留期/查询,以及库文件的进程认领协议)、 recorder(门面与全局单例)。公开名字仍从 ipclick.trace 导出, from ipclick.trace import TraceRecord 这类写法不受影响。

  • 逐跳重定向跟随只有一份实现adapters/redirects.pyHopFollowingMixin), 由 curl_cffiniquests 共用。这一层压着 SSRF 准入,分叉过一次就出现过 "某条路径不校验"的缺口,所以有用例断言两个适配器解析到的是同一个函数对象。 同理 automation_config 的解析与等待钳位统一在 adapters/browser_settings.py, 由两个浏览器适配器共用。

  • web/server.pyweb/pages/ 分开。 前者管 HTTP(路由、会话、CSRF、响应头), 后者管"页面展示什么、提交上来怎么处理"。混在一起的话,每加一页都得往 HTTP 处理器里 塞一段业务代码。

  • rpc/options.py 是 keepalive 的唯一来源。 这些选项曾在三处各抄一份, 结果客户端 ping 间隔撞上服务端下限,空闲通道必然被 GOAWAY 掉。 现在 min_ping_interval_without_data 由客户端 keepalive 间隔推导(取一半), 改一处就够;min_time_between_pings 仍是写死的 10 秒,它只要小于客户端间隔就安全。

  • utils/coerce.py 收敛所有配置强转。 宽松读取(读不出来用默认值)与严格校验 (读不出来报错)是两套函数,别混用。

测试

430 个测试(其中 1 个只在 POSIX 上跑)。

uv run pytest                          # 全部
uv run pytest tests/test_trace.py      # 单个文件
uv run pytest -k "cluster"             # 按名字筛
uv run pytest -x -q                    # 第一个失败就停
uv run pytest -m "not slow"            # 跳过起真实端口的用例

slow 标记的是起真实 gRPC 端口的用例,比纯单元测试慢一个数量级。

写测试的几条约定:

  • 测行为,不测实现。 断言"配错了会报错",而不是"调用了某个内部函数"。
  • 一个测试一件事,名字说清楚断言的是什么。
  • 随机/时间相关的测试要真的稳。 压缩启发式那个测试早期是个 5% 概率翻车的硬币—— 阈值 10% 撞上随机字节的期望控制字符占比 10.9%。发现这种"偶尔红"的测试, 修的是判据本身,不是加重试。
  • 测试里碰私有成员是允许的reportPrivateUsagetests/ 下关掉了)—— 要盯住内部协作者的行为(错误映射、请求参数拼装)就得碰。

排查时的坑

这几条踩过不止一次:

  • pkill -f "xxx" 会匹配到它自己的命令行。ps -eo pid,etimes,cmd | grep "[x]xx" 拿 PID 再 kill;找监听进程用 ss -lptn "sport = :9528"
  • handle_errorsocketserver.BaseServer 的方法,不是 handler 的。 写在 handler 上是个静默的 no-op。
  • SQLite 的 auto_vacuum PRAGMA 只在建库时生效,已有的库改不了(要 VACUUM 重建)。
  • schema 迁移必须在建索引之前跑,否则新索引引用的列还不存在。
  • Web 管理端是表单登录 + 会话 cookie,不是 HTTP Basic。 拿 Basic auth 去探路由会 每条都拿到登录页(字节数还都一样),看起来像"所有路由返回同一内容"的假 bug。
  • /nodes 会跳到 /config?tab=cluster;没配集群时 /deploy 也回落到同一页。 两者内容相同是预期行为。

提交规范

Conventional Commits:

feat(cluster): 支持服务端转发
fix(browser): 复用浏览器实例前检查连接存活
docs: 补充集群鉴权说明
refactor(web)!: 拆分 HTTP 层与页面业务逻辑

! 表示破坏性变更,正文里要写清楚怎么迁移

提 PR

  1. master 开分支
  2. 写代码 + 测试
  3. 四条门禁全绿
  4. Conventional Commits
  5. PR 说明里写清楚为什么,不只是改了什么

改了行为的话记得同步:README.mdconfigs/default_config.toml 里的注释 (那是配置项的唯一权威来源)、以及本 wiki。

发布

  1. pyproject.tomlversion
  2. 四条门禁全绿
  3. 合并到 master
  4. 打标签并推:
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

推标签会触发 release.yml

build ──→ publish-pypi ──→ github-release
  │            │
  │            └─ environment: pypi,配了必需审批人,
  │               上传前停下来等人工确认
  │
  └─ 重跑全部门禁 + uv build + 校验标签与 pyproject 版本号一致

两处刻意的设计:

  • PyPI 上传前有人工审批门。 PyPI 同一版本号永远不能重传,只能 yank—— 这一步不可撤销,值得停一下。
  • 不设 skip-existing 正式发布时重复版本号应当失败并让人知道, 而不是静默跳过让你以为发出去了。

发布流程里还有一道泄漏文件检查,确认测试文件没被打进 wheel。它只认真正的测试 目录和 pytest 命名约定——早先用 "test" in name 这种子串匹配,包内合法存在的 test.py 会让发版直接失败。

注释风格

代码里的注释解释的是为什么这么写,尤其是"为什么不用那个看起来更自然的写法"。 一个默认值的选择、一处不重试的判断、一个显式关掉的 gRPC 选项——这些不写下来, 下一个人(包括三个月后的你)会把它改回去,然后重新踩一遍。

下一步

Clone this wiki locally