erikwang2013 / etcd
PHP etcd v3 client over HTTP (full API) and gRPC (unary RPCs), covering KV, Watch, Lease, Auth, Cluster, Maintenance, Election and Lock. Runs on plain PHP: no framework, no PSR-18 implementation and no ext-curl required. Laravel, Hyperf, ThinkPHP, Webman, Yii2 and Yii3 adapters included.
Requires
- php: >=8.0
Requires (Dev)
- google/protobuf: ^3.25 || ^4.0
- phpunit/phpunit: ^11.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
Suggests
- ext-curl: Fastest HTTP driver (default when loaded). Without it the client falls back to the stream wrapper.
- ext-grpc: Enables the gRPC transport (transport=grpc): unary RPCs over messages generated from etcd's protos. Without it that transport throws a ConnectionException on first use and the default HTTP transport is used instead.
- ext-protobuf: Runtime for the generated protobuf messages the gRPC transport sends. google/protobuf ships the same classes if the extension cannot be installed.
- google/protobuf: Same runtime as ext-protobuf, in pure PHP; install one of the two to use transport=grpc.
- grpc/grpc: Provides the Grpc\BaseStub and Grpc\Channel classes the gRPC transport extends; install it together with ext-grpc.
- psr/http-client: For the PSR-18 transport path via HttpTransport::setHttpClient(). Without it the client uses ext-curl, or PHP's stream wrapper when ext-curl is absent.
- psr/http-factory: PSR-17 request/stream factories, used together with psr/http-client.
- yiisoft/config: Yii3 apps: the plugin merges src/Adapter/Yii3/config into the params and di groups, so EtcdClient is injectable with no wiring.
Provides
None
Conflicts
None
Replaces
None
README
简体中文 · English · 한국어 · Русский · Deutsch · Français · Español · Português · हिन्दी · العربية · বাংলা · Bahasa Indonesia · 日本語
Etchy · 头顶三节点 Raft 集群、鼻尖挂着 k/v 与 rev 的小象 ——
守着你的配置和租约:掉线自己重连,过期自己清理。
PHP etcd v3 客户端 —— 双模传输(HTTP 全功能 / gRPC 一元 RPC),覆盖 etcd v3 全部 API(KV / Watch / Lease / Auth / Cluster / Maintenance / Election / Lock),开箱适配 Laravel / Hyperf / ThinkPHP / Webman / Yii2 / Yii3。
要求
- PHP >= 8.1
- etcd v3.x 服务端
- HTTP 通道任选其一即可:ext-curl、PHP 流封装(allow_url_fopen)、或自备 PSR-18 客户端 —— 自动选择,都不可用时给出明确提示
安装
composer require erikwang2013/etcd
裸 PHP(无框架)
不需要框架、不需要 PSR-18 实现、也不需要 ext-curl —— 加载了 ext-curl 就用它,缺失时自动回退到 PHP 自带的流封装。
<?php require __DIR__ . '/vendor/autoload.php'; // 你的 autoload use Erikwang2013\Etcd\EtcdClient; $etcd = new EtcdClient(['endpoints' => ['127.0.0.1:2379']]); $etcd->kv()->put('/app/config', '{"debug":true}'); echo $etcd->kv()->getOrFail('/app/config')['value'], "\n"; // 当前实际使用哪条通道:curl / stream / none var_dump(Erikwang2013\Etcd\Transport\HttpTransport::detectDriver());
强制指定通道用 'driver' => 'stream'(默认 auto);三条通道都不可用时,请求抛出可捕获的 ConnectionException 并说明如何启用。
快速开始
use Erikwang2013\Etcd\EtcdClient; $etcd = new EtcdClient(['endpoints' => ['127.0.0.1:2379']]); // 写入 $etcd->kv()->put('/app/config', '{"debug":true}'); // 读取 $result = $etcd->kv()->get('/app/config'); print_r($result['kvs'][0]); // ['key' => '/app/config', 'value' => '{"debug":true}', ...] // 找不到时抛异常 $kv = $etcd->kv()->getOrFail('/app/config'); // 前缀扫描 $all = $etcd->kv()->getByPrefix('/app/'); echo "共 {$all['count']} 条\n"; // 删除 $etcd->kv()->delete('/app/config'); $etcd->kv()->deleteByPrefix('/cache/'); // 带租约写入(60 秒后自动删除) $lease = $etcd->lease()->grant(60); $etcd->kv()->put('/session/123', 'active', ['lease' => $lease['ID']]); // 续约 $etcd->lease()->keepAlive($lease['ID']);
配置
$etcd = new EtcdClient([ 'endpoints' => ['192.168.1.10:2379', '192.168.1.11:2379'], // 多节点 'transport' => 'auto', // auto(默认)| http | grpc 'driver' => 'auto', // auto(默认)| curl | stream 'scheme' => 'http', // http(默认)| https 'timeout' => 5.0, // 秒 'retry' => 3, // 连接失败重试次数 'auth' => [ // 可选;凭据换 token 需要 https 'user' => 'root', 'password' => 'secret', ], ]);
环境变量
不传配置时自动读取环境变量:
| 变量 | 默认值 | 说明 |
|---|---|---|
ETCD_ENDPOINTS |
127.0.0.1:2379 |
逗号分隔的多节点地址 |
ETCD_TRANSPORT |
auto |
auto / http / grpc |
ETCD_TIMEOUT |
5.0 |
请求超时(秒) |
ETCD_SCHEME |
http |
http / https |
ETCD_RETRY |
2 |
连接重试次数 |
ETCD_DRIVER |
auto |
HTTP 通道:auto / curl / stream |
ETCD_USER |
— | etcd 用户名 |
ETCD_PASSWORD |
— | etcd 密码 |
API 参考
KV — 键值操作
// 写入 $etcd->kv()->put('key', 'value', [ 'lease' => 12345, // 绑定租约 ID 'prevKv' => true, // 返回写入前的旧值 'ignoreValue' => false, 'ignoreLease' => false, ]); // 读取单键 $etcd->kv()->get('/exact/key'); // 找不到即抛异常 $kv = $etcd->kv()->getOrFail('/exact/key'); // 前缀扫描 $etcd->kv()->getByPrefix('/prefix/'); // 范围查询(完整参数) $etcd->kv()->get('/start', [ 'rangeEnd' => '/startz', // 范围结束 key 'limit' => 100, // 最大返回条数 'revision' => 42, // 快照版本号 'sortOrder' => 'ascend', // none | ascend | descend 'sortTarget' => 'key', // key | version | create | mod | value 'serializable'=> true, // 跳过 Raft 共识(更快,可能过期) 'keysOnly' => true, // 只返回 key,不返回 value 'countOnly' => false, // 只返回计数 'minModRevision' => 100, // 只要修改版本 >= 100 的 'maxModRevision' => 200, 'minCreateRevision' => 100, // 按创建版本过滤 'maxCreateRevision' => 200, ]); // 删除 $etcd->kv()->delete('/key'); $etcd->kv()->deleteByPrefix('/prefix/'); $etcd->kv()->delete('/key', ['prevKv' => true]); // 同时返回被删的值 // 事务(原子 CAS) $etcd->kv()->txn( compare: [ ['result' => 0, 'target' => 3, 'key' => '/counter', 'value' => '100'] ], success: [ ['request_put' => ['key' => '/counter', 'value' => '101']] ], failure: [ ['request_put' => ['key' => '/counter', 'value' => '1']] ] ); // 嵌套事务:分支里可以再放事务 $etcd->kv()->txn( compare: [['result' => 0, 'target' => 3, 'key' => '/lock', 'value' => 'free']], success: [[ 'request_put' => ['key' => '/lock', 'value' => 'mine'], 'request_txn' => [ // 内层事务 'compare' => [['result' => 0, 'target' => 1, 'key' => '/lock', 'create_revision' => 0]], 'success' => [['request_put' => ['key' => '/log', 'value' => 'acquired']]], 'failure' => [], ], ]], failure: [] ); // 压缩历史版本(释放存储空间) $etcd->kv()->compact(1000);
比较目标(target)常量: 0=VERSION, 1=CREATE, 2=MOD, 3=VALUE, 4=LEASE
比较结果(result)常量: 0=EQUAL, 1=GREATER, 2=LESS, 3=NOT_EQUAL
Watch — 变更监听
// 监听单个 key(阻塞模式,建议在协程/独立进程中运行) $etcd->watch()->watch('/config/key', function (array $events) { foreach ($events as $event) { // $event: ['type' => 'PUT'|'DELETE', 'kv' => [...], 'prev_kv' => [...]|null] echo "{$event['type']} {$event['kv']['key']} = {$event['kv']['value']}\n"; } }); // 监听前缀下所有 key 的变更 $etcd->watch()->watchPrefix('/config/', $callback, [ 'startRevision' => 100, // 从指定版本开始 'prevKv' => true, // DELETE 事件返回原值 'progressNotify'=> true, // 定期发送空事件(心跳) ]);
停止监听: watch() 会一直阻塞,给它一个 WatchHandle 就能从外部停(长驻进程按 SIGTERM 收尾的常规需求):
use Erikwang2013\Etcd\Support\WatchHandle; $handle = new WatchHandle(); pcntl_async_signals(true); pcntl_signal(SIGTERM, fn() => $handle->cancel()); $etcd->watch()->watchPrefix('/config/', $onEvent, ['handle' => $handle]);
cancel() 之后 watch() 正常返回(不抛异常,不需要 catch)。空闲的 key 上也会在一秒内退出——curl 驱动靠 cURL 的周期回调发现,stream 驱动靠 200ms 的读超时空闲周期。
断线重连: Watch 连接断开时自动从 lastRevision + 1 续订(start_revision 是闭区间语义,
用原值续订会重放最后一个事件)。故障转移不丢事件,也不重复投递。
重试策略: 只有连接根本没建立(拒绝连接 / DNS 失败)才会重试,且只读接口(range、status、memberlist 等) 额外容忍 5xx 与超时。写操作遇到 5xx 或读超时不重试——那可能已经生效,重放会让 CAS 这类请求被应用两次, 甚至拿到一个"自信的错答案"(重试看到自己第一次写入的结果,报告 CAS 失败而它其实赢了)。
Lease — 租约
// 创建租约 $lease = $etcd->lease()->grant(300); // 300 秒 TTL $lease = $etcd->lease()->grant(300, 99999); // 指定租约 ID // 续约(单次) $result = $etcd->lease()->keepAlive($lease['ID']); echo "TTL 剩余: {$result['TTL']} 秒"; // 查看租约状态 $info = $etcd->lease()->timeToLive($lease['ID']); $info = $etcd->lease()->timeToLive($lease['ID'], true); // 含绑定的 key 列表 // 列出所有活跃租约 $leases = $etcd->lease()->list(); // 撤销租约(绑定的所有 key 立即删除) $etcd->lease()->revoke($lease['ID']);
典型场景: 服务注册时创建租约 + 写入 key,定时调用 keepAlive() 心跳续约;服务停止后租约到期自动清理。
Auth — 认证与权限
$auth = $etcd->auth(); // === 用户管理 === $auth->user()->add('alice', 'password123'); // 创建用户 $auth->user()->get('alice'); // 查看用户及其角色 $auth->user()->list(); // 列出所有用户 $auth->user()->changePassword('alice', 'newpass'); // 修改密码 $auth->user()->grantRole('alice', 'admin'); // 授权角色 $auth->user()->revokeRole('alice', 'admin'); // 撤销角色 $auth->user()->delete('alice'); // 删除用户 // === 角色管理 === $auth->role()->add('reader'); // 创建角色 $auth->role()->get('reader'); // 查看角色权限 $auth->role()->list(); // 列出所有角色 // 授予权限(permType: 0=READ, 1=WRITE, 2=READWRITE) $auth->role()->grantPermission('reader', 0, '/data/', "\0"); // 对 /data/ 前缀的读权限 $auth->role()->grantPermission('writer', 2, '/data/', "\0"); // 读写权限 $auth->role()->revokePermission('reader', '/data/', "\0"); // 撤销权限 $auth->role()->delete('reader'); // === 认证开关 === $auth->enable(); // 开启认证 $auth->disable(); // 关闭认证 $status = $auth->status(); // ['enabled' => true, 'authRevision' => 5]
认证是怎么发生的: etcd v3 不接受 HTTP Basic —— 它要求先用凭据换取 token
(POST /v3/auth/authenticate),随后以裸 token 发送 Authorization: <token>(加 Bearer 前缀同样会被拒)。
配置了 auth.user / auth.password 后,客户端会自动完成这一步并缓存 token,401 时自动重新认证一次,
无需手工调用。也可以自己换:
$token = $etcd->auth()->authenticate('root', 'secret'); // 换到的 token 会被后续请求复用
发送凭据需要 scheme => 'https':明文 http 下构造函数会直接拒绝(避免密码裸奔)。
Cluster — 集群管理
// 查看集群成员 $members = $etcd->cluster()->memberList(); // 添加成员 $etcd->cluster()->memberAdd(['http://node3:2380']); // 添加 Voting 成员 $etcd->cluster()->memberAdd(['http://node4:2380'], true); // 添加 Learner 成员 // 修改成员 peer URL $etcd->cluster()->memberUpdate(123456, ['http://newnode:2380']); // Learner 提升为 Voter $etcd->cluster()->memberPromote(789012); // 移除成员 $etcd->cluster()->memberRemove(345678);
Election — leader 选举
// 竞选:拿到租约后参选;当选才返回,输着的人会等(用 $timeout 兜底) $lease = $etcd->lease()->grant(30); $leader = $etcd->election()->campaign('/my-election', 'node-a', $lease['ID'], 5.0); // → ['name' => ..., 'key' => ..., 'rev' => ..., 'lease' => ...] // 当前 leader(无人当选返回 null) $current = $etcd->election()->leader('/my-election'); // 让位 $etcd->election()->resign($leader);
campaign() 在 HTTP 网关上是一个缓冲响应——当选之前不返回,所以用 $timeout 约束等待。需要长期跟踪 leader 变化用 observe()。
注意(实测的静默陷阱): proclaim() / resign() 需要完整的 leader 描述数组(name、key、rev 三者齐全)。少任何一个,etcd 会返回 HTTP 200 却什么都不做——看起来"释放成功",leader 其实还在。所以这两个方法会在发送前校验描述符,不完整直接抛异常;请一律使用 campaign() / leader() 的返回值,不要自己拼。
其它实测行为: 请求中途失败的竞选会被服务端撤回(所以 acquire() 超时可以安全地报告失败,不会变成"隐藏的持有者");无人当选时 leader() 返回 null(服务端回 500 election: no leader,属正常状态而非错误)。
Lock — 分布式锁
$lock = $etcd->lock()->acquire('/my-lock', ttl: 30, timeout: 5.0); // ... 临界区 ... $etcd->lock()->release($lock);
这是建在 Election 之上的客户端实现,不是服务端锁。 etcd 3.5 的 HTTP 网关不暴露 /v3/lock/*(实测 404),所以互斥由「Election 竞选 + 租约」提供——与 etcd 自家 Go 的 concurrency 包同一做法。持有者被 SIGKILL 时,租约到期后锁自动释放,无需人工清理。
Maintenance — 运维
// 查看节点状态 $status = $etcd->maintenance()->status(); // ['version' => '3.5.0', 'dbSize' => 24576, 'leader' => 123, 'raftIndex' => 1000, ...] // 告警管理 $alarms = $etcd->maintenance()->alarm(); // 查看告警 $etcd->maintenance()->alarm(action: 2, alarm: 1); // 清除 NOSPACE 告警 // 碎片整理(回收存储空间) $etcd->maintenance()->defragment(); // KV 哈希校验 $hash = $etcd->maintenance()->hash(); // 获取快照(返回二进制数据,写入文件即可) $snapshot = $etcd->maintenance()->snapshot(); file_put_contents('/backup/etcd-snapshot.db', $snapshot); // 大库请用流式落盘:不把整个数据库读进内存 $bytes = $etcd->maintenance()->snapshotTo('/backup/etcd-snapshot.db'); // 先写临时文件、校验末尾 32 字节的 sha256 摘要通过后才改名, // 所以中断不会留下一个"看着像备份"的残file;返回写入字节数。
传输模式
| 模式 | 状态 | 依赖 | 适用场景 |
|---|---|---|---|
| HTTP | 可用 | ext-curl / 流封装 / PSR-18 任选其一 | 零扩展依赖,即刻可用 |
| gRPC | 一元 RPC | ext-grpc + grpc/grpc + 生成的 protobuf 消息 | 高性能;流式与 Election 需走 HTTP |
| auto | 默认 | — | 目前等同 http(见下) |
auto 与 http 等价:gRPC 目前只覆盖一元 RPC,watch / snapshot 这类流式调用以及 Election 都还必须走 HTTP,自动切过去只会让装了扩展的用户突然少一半功能。显式传 'transport' => 'grpc' 才会选中它。
gRPC 传输的现状(请注意):
- 已实现:一元 RPC(
send())——从 etcd v3.5 的rpc.proto用protoc生成消息类,请求体按类型化消息组装,字段名/类型由 proto 保证。 - 未实现:watch、snapshot 等流式调用,以及
/v3/election/*(那是另一个 proto,v3electionpb)——这些路径会被按名字拒绝并说明原因,不会静默失败。 - 未端到端验证:本项目的开发环境没有
ext-grpc,所以信道打开、凭据 metadata、_simpleRequest、超时与状态码映射都是照 grpc_php_plugin 的输出模式手写、未跑过真实调用。消息生成与请求组装有测试,网络往返没有。要走 gRPC 请自行验证后再上生产。
手动配置 PSR-18 HTTP 客户端
use Erikwang2013\Etcd\Transport\HttpTransport; use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; $transport = new HttpTransport(['127.0.0.1:2379'], ['timeout' => 3.0]); $transport->setHttpClient( new Client(['timeout' => 3]), new HttpFactory(), new HttpFactory() );
框架集成
Laravel
安装即用。composer.json 的 extra.laravel 自动发现 ServiceProvider 和 Facade。
// Facade 方式 use Etcd; Etcd::kv()->put('/foo', 'bar'); $val = Etcd::kv()->get('/foo'); // 依赖注入方式 use Erikwang2013\Etcd\EtcdClient; class MyService { public function __construct(private EtcdClient $etcd) {} public function work(): void { $this->etcd->kv()->put('/key', 'value'); } }
发布配置文件:
php artisan vendor:publish --tag=etcd-config
# → config/etcd.php
.env 配置:
ETCD_ENDPOINTS=10.0.0.1:2379,10.0.0.2:2379 ETCD_USER=root ETCD_PASSWORD=secret
Hyperf
安装即用。Hyperf 自动发现 ConfigProvider。
use Erikwang2013\Etcd\EtcdClient; use Hyperf\Di\Annotation\Inject; class MyService { #[Inject] private EtcdClient $etcd; public function work(): void { $this->etcd->kv()->put('/key', 'value'); } } // 或者直接 make $etcd = make(EtcdClient::class);
发布配置:
php bin/hyperf.php vendor:publish erikwang2013/etcd
# → config/autoload/etcd.php
ThinkPHP
- 安装后,在
app/service.php中注册:
return [ Erikwang2013\Etcd\Adapter\ThinkPHP\Service::class, ];
- 创建
config/etcd.php配置文件。
使用:
// Facade 方式 use think\facade\Etcd; Etcd::kv()->put('/key', 'value'); // 容器方式 app('etcd')->kv()->get('/key');
Webman
安装即用,无需额外配置。
use Erikwang2013\Etcd\EtcdClient; $etcd = EtcdClient::instance(); $etcd->kv()->put('/key', 'value');
如需自定义配置,编辑 plugin/erikwang2013/etcd/config/etcd.php。
Yii2
安装即用。在应用配置里注册组件,extra.bootstrap 会自动把 EtcdClient 绑进 DI 容器,构造函数注入拿到的就是应用组件里那个实例(用 --no-plugins 装的把 Erikwang2013\Etcd\Adapter\Yii\Bootstrap::class 加进 bootstrap 数组)。
// config/web.php return [ 'components' => [ 'etcd' => [ 'class' => Erikwang2013\Etcd\Adapter\Yii\Component::class, 'options' => [ // 省略则读取 ETCD_* 环境变量 'endpoints' => ['10.0.0.1:2379'], 'timeout' => 3.0, ], ], ], ];
use Erikwang2013\Etcd\EtcdClient; // 组件方式 Yii::$app->etcd->kv()->put('/key', 'value'); $val = Yii::$app->etcd->kv()->get('/key'); // 依赖注入方式 class MyService { public function __construct(private EtcdClient $etcd) {} public function work(): void { $this->etcd->kv()->put('/key', 'value'); } }
Yii3
安装即用。包里的 config-plugin 声明会被 yiisoft/config 合并进 params 与 di 两组,容器里就有 EtcdClient 了(yiisoft/di 只保留共享实例,注入多少次都是同一个客户端)。
// config/common/params.php —— 不写就用 config/etcd.php 的默认值(ETCD_* 环境变量); // 写了就是整键替换,用到的字段要一起写上 return [ 'erikwang2013/etcd' => [ 'endpoints' => ['10.0.0.1:2379'], 'timeout' => 3.0, ], ];
use Erikwang2013\Etcd\EtcdClient; // 任何 action / service:容器注入即可 class MyService { public function __construct(private EtcdClient $etcd) {} public function work(): void { $this->etcd->kv()->put('/key', 'value'); } }
异常处理
use Erikwang2013\Etcd\Exception\{ EtcdException, ConnectionException, AuthException, KeyNotFoundException, }; try { $etcd->kv()->put('/key', 'value'); } catch (ConnectionException $e) { // etcd 节点无法连接(网络故障、宕机) } catch (AuthException $e) { // 认证失败(用户名密码错误) } catch (KeyNotFoundException $e) { // getOrFail() 时 key 不存在 } catch (EtcdException $e) { // 其他 etcd 服务端错误 }
项目结构
erikwang2013/etcd/
├── composer.json # 包定义:PSR-4 自动加载 + Laravel / Hyperf / Yii2 / Yii3 自动发现
├── phpunit.xml.dist # PHPUnit 配置(unit / integration 两套套件)
├── protos/ # etcd v3.5 上游 proto + 生成脚本 + 生成产物(gRPC 用)
├── .github/workflows/ci.yml # 合并前门禁:单测矩阵 / 语法底线 / 集成 / i18n 文档
├── config/etcd.php # 默认配置,供各框架发布(读取 ETCD_* 环境变量)
├── .github/workflows/release.yml # 打 tag 时自动发布
├── scripts/i18n/ # 文档工具:词条目录、设计图生成、翻译校验
├── docs/ # 文档与设计图
│ ├── design-cn.md # 设计文档
│ ├── i18n/ # 13 种语言的 README 与本地化设计图
│ ├── pet.svg # 项目宠物 Etchy
│ ├── architecture.svg # 架构设计图
│ ├── features.svg # 功能设计图
│ └── lifecycle.svg # 生命周期图
├── src/
│ ├── EtcdClient.php # 顶层门面 + 单例:八大子系统访问器
│ ├── Mascot.php # 项目宠物 Etchy 的取值入口(svg / dataUri / path)
│ ├── Install.php # Webman 插件钩子(WEBMAN_PLUGIN)
│ ├── Transport/ # 传输层
│ │ ├── TransportInterface.php # 传输抽象:send / sendRaw / watch
│ │ ├── TransportSelector.php # auto / http / grpc 自动选择
│ │ ├── HttpTransport.php # HTTP JSON 传输(完整可用)
│ │ ├── GrpcTransport.php # gRPC 传输(一元 RPC;流式拒绝并说明)
│ │ └── GrpcStub.php # Grpc\BaseStub 子类(独立文件,缺扩展时不加载)
│ ├── Kv/KvClient.php # KV 读写 / 前缀扫描 / 事务 / 压缩
│ ├── Watch/WatchClient.php # Watch 变更监听 + 断线续订
│ ├── Lease/LeaseClient.php # Lease 租约 grant / keepAlive / revoke
│ ├── Auth/ # Auth 认证授权
│ │ ├── AuthClient.php # 认证开关与状态
│ │ ├── UserClient.php # 用户 CRUD + 角色绑定
│ │ └── RoleClient.php # 角色 CRUD + 权限
│ ├── Cluster/ClusterClient.php # Cluster 集群成员管理
│ ├── Election/ # Election 选举(campaign / leader / observe / resign)
│ ├── Lock/LockClient.php # Lock 分布式锁(建在 Election 之上,网关无 /v3/lock/*)
│ ├── Maintenance/ # Maintenance 运维:status / alarm / defrag / snapshot
│ ├── Exception/ # 异常层次
│ ├── Support/ # KeyValue / Int64 共用解码,WatchHandle 取消句柄
│ └── Adapter/ # 框架适配器
│ ├── Laravel/ # ServiceProvider + Facade
│ ├── Hyperf/ # ConfigProvider
│ ├── ThinkPHP/ # Service + Facade
│ ├── Yii/ # Component + Bootstrap
│ ├── Yii3/ # config-plugin(params + di)
│ └── Webman/ # Plugin
└── tests/
├── Unit/ # 单元测试(逐客户端 / 传输 / 适配器 / 消息类)
├── Integration/ # 集成测试(对接真实 etcd)
└── Support/ # FakeTransport、PSR HTTP 桩
测试
composer install vendor/bin/phpunit --no-coverage tests/Unit # 单元测试(内存桩) # 集成测试:自带一个忠实于真实网关的假网关(chunked 分帧 + {"result":…} 信封 + int64 字符串) php -d zend.assertions=1 -d assert.exception=1 tests/transport_test.php
对真实集群验证(推荐):给同一个套件一个真 etcd 地址,它会跑同一批用例,并断言假网关与真网关的行为一致(分帧、信封、int64 编码):
docker run -d --name etcd -p 2379:2379 quay.io/coreos/etcd:v3.5.17 \ /usr/local/bin/etcd --name n1 --data-dir /d \ --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://127.0.0.1:2379 \ --listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://127.0.0.1:2380 \ --initial-cluster n1=http://127.0.0.1:2380 ETCD_REAL=127.0.0.1:2379 php -d zend.assertions=1 -d assert.exception=1 tests/transport_test.php
为什么值得这么跑:本项目曾有一批缺陷(watch 完全不可用、9 个无参方法 400、keepAlive 必抛…) 在 40+ 个测试全绿的情况下存活,原因正是测试桩与真实网关在分帧与信封两方面都不同。 那批问题已修复,而这个差分模式就是防止同类问题再次溜过去的机制。
文档与设计图的一致性也由脚本守住:
python3 scripts/i18n/check_translations.py # 13 份译本:切换器、链接、词条、围栏结构 python3 scripts/i18n/build_diagrams.py --check --all # 13 语言设计图:逐条量宽 python3 scripts/i18n/sync_zh_readme.py --check # zh 副本与根 README 同步
CI(.github/workflows/ci.yml)会跑上面全部内容:单测 PHP 8.2/8.3 矩阵、PHP 8.0/8.1 的语法底线、
集成(含对服务容器里真实 etcd 的差分)、以及这三条文档校验。
架构与设计图
三张图按「结构 → 能力 → 时序」组织,可点击单独查看:
| 图 | 回答的问题 | 文件 |
|---|---|---|
| 架构设计 | 分成哪几层、依赖朝哪走、错误怎么分流 | docs/architecture.svg |
| 功能设计 | 每个子系统提供哪些方法、有哪些行为约定 | docs/features.svg |
| 生命周期 | 一次请求 / 一条监听 / 一个租约 各自怎么走完 | docs/lifecycle.svg |
架构设计
功能设计
生命周期
在代码里用 Etchy
图形随包发布,Mascot 是唯一取值入口,做管理面板 / 状态页时不必再拷一份:
use Erikwang2013\Etcd\Mascot; echo Mascot::svg(); // SVG 源码,直接内联 echo '<img src="' . Mascot::dataUri() . '" alt="Etchy">'; // data URI,不依赖 web 目录 copy(Mascot::path(), __DIR__ . '/public/etcd.svg'); // 或自行落到静态目录
Laravel 下可直接发布到 public/:
php artisan vendor:publish --tag=etcd-assets
# → public/vendor/etcd/pet.svg
开源不易,欢迎支持 / Support This Project
| 微信 / WeChat | 支付宝 / Alipay |
![]() |
![]() |
License
MIT — Copyright (c) 2026 erik erik@erik.xyz

