From ffc8a7e44e5dfd27abff444ac87f618983592f53 Mon Sep 17 00:00:00 2001 From: JanHuang <384099566@qq.com> Date: Thu, 29 Oct 2026 16:22:14 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20FastD=20?= =?UTF-8?q?=E5=BE=AE=E6=9C=8D=E5=8A=A1=E9=9B=86=E6=88=90=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - etcd v3 HTTP gateway 客户端 - 服务注册、解析与负载均衡 - etcd 配置源 - 按服务名解析的 PSR-18 客户端 - W3C traceparent 传播 - 重试、熔断、进程内限流 - 单元测试与 CI --- .github/workflows/ci.yml | 31 +++ .gitignore | 3 + LICENSE | 21 +++ README.md | 168 ++++++++++++++++- composer.json | 50 +++++ phpunit.xml.dist | 12 ++ src/Config/ConfigSourceInterface.php | 13 ++ src/Config/EtcdConfigSource.php | 72 +++++++ src/Discovery/EtcdResolver.php | 77 ++++++++ src/Discovery/EtcdServiceRegistry.php | 70 +++++++ .../LoadBalancer/LoadBalancerInterface.php | 15 ++ src/Discovery/LoadBalancer/RandomBalancer.php | 19 ++ .../LoadBalancer/RoundRobinBalancer.php | 24 +++ .../LoadBalancer/WeightedRandomBalancer.php | 35 ++++ src/Discovery/ResolverInterface.php | 13 ++ src/Discovery/ServiceInstance.php | 78 ++++++++ src/Discovery/ServiceLease.php | 15 ++ src/Discovery/ServiceRegistryInterface.php | 14 ++ src/Etcd/EtcdClient.php | 178 ++++++++++++++++++ src/Etcd/EtcdKeyValue.php | 44 +++++ src/Exception/CircuitOpenException.php | 9 + src/Exception/DiscoveryException.php | 9 + src/Exception/EtcdException.php | 9 + src/Exception/IntegrationException.php | 11 ++ src/Exception/ServiceUnavailableException.php | 9 + src/Governance/CircuitBreaker.php | 69 +++++++ src/Governance/CircuitBreakerClient.php | 43 +++++ src/Governance/InMemoryRateLimiter.php | 54 ++++++ src/Governance/RateLimitMiddleware.php | 41 ++++ src/Governance/RateLimiterInterface.php | 10 + src/Governance/RetryClient.php | 53 ++++++ src/Governance/RetryPolicy.php | 53 ++++++ src/HttpClient/ServiceClient.php | 55 ++++++ src/Tracing/TraceClient.php | 30 +++ src/Tracing/TraceContext.php | 73 +++++++ src/Tracing/TracingMiddleware.php | 38 ++++ tests/Config/EtcdConfigSourceTest.php | 51 +++++ tests/Discovery/DiscoveryTest.php | 111 +++++++++++ tests/Etcd/EtcdClientTest.php | 64 +++++++ tests/Governance/GovernanceTest.php | 113 +++++++++++ tests/HttpClient/ServiceClientTest.php | 49 +++++ tests/Support/FakeClient.php | 40 ++++ tests/Support/FakeHandler.php | 26 +++ tests/Support/FakeResolver.php | 27 +++ tests/Tracing/TracingTest.php | 68 +++++++ 45 files changed, 2065 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 composer.json create mode 100644 phpunit.xml.dist create mode 100644 src/Config/ConfigSourceInterface.php create mode 100644 src/Config/EtcdConfigSource.php create mode 100644 src/Discovery/EtcdResolver.php create mode 100644 src/Discovery/EtcdServiceRegistry.php create mode 100644 src/Discovery/LoadBalancer/LoadBalancerInterface.php create mode 100644 src/Discovery/LoadBalancer/RandomBalancer.php create mode 100644 src/Discovery/LoadBalancer/RoundRobinBalancer.php create mode 100644 src/Discovery/LoadBalancer/WeightedRandomBalancer.php create mode 100644 src/Discovery/ResolverInterface.php create mode 100644 src/Discovery/ServiceInstance.php create mode 100644 src/Discovery/ServiceLease.php create mode 100644 src/Discovery/ServiceRegistryInterface.php create mode 100644 src/Etcd/EtcdClient.php create mode 100644 src/Etcd/EtcdKeyValue.php create mode 100644 src/Exception/CircuitOpenException.php create mode 100644 src/Exception/DiscoveryException.php create mode 100644 src/Exception/EtcdException.php create mode 100644 src/Exception/IntegrationException.php create mode 100644 src/Exception/ServiceUnavailableException.php create mode 100644 src/Governance/CircuitBreaker.php create mode 100644 src/Governance/CircuitBreakerClient.php create mode 100644 src/Governance/InMemoryRateLimiter.php create mode 100644 src/Governance/RateLimitMiddleware.php create mode 100644 src/Governance/RateLimiterInterface.php create mode 100644 src/Governance/RetryClient.php create mode 100644 src/Governance/RetryPolicy.php create mode 100644 src/HttpClient/ServiceClient.php create mode 100644 src/Tracing/TraceClient.php create mode 100644 src/Tracing/TraceContext.php create mode 100644 src/Tracing/TracingMiddleware.php create mode 100644 tests/Config/EtcdConfigSourceTest.php create mode 100644 tests/Discovery/DiscoveryTest.php create mode 100644 tests/Etcd/EtcdClientTest.php create mode 100644 tests/Governance/GovernanceTest.php create mode 100644 tests/HttpClient/ServiceClientTest.php create mode 100644 tests/Support/FakeClient.php create mode 100644 tests/Support/FakeHandler.php create mode 100644 tests/Support/FakeResolver.php create mode 100644 tests/Tracing/TracingTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9371961 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + - name: Validate composer.json + run: composer validate --no-check-publish --no-check-lock + - name: Install dependencies + run: composer update --prefer-dist --no-progress --no-interaction + - name: Run tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ede8ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/.phpunit.cache/ +/composer.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..940595e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) FastD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ff700ed..c16d349 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,166 @@ -# integrations -Optional FastD microservice integrations: etcd discovery/config, service clients, tracing and governance. +# fastd/integrations + +FastD 的可选微服务集成包。 + +定位是 **集成层**,不是微服务治理平台: + +- FastD 负责适配、装配、中间件和客户端包装 +- etcd、OpenTelemetry、限流/熔断算法仍由外部实现 +- 不把注册中心、RPC、网关或分布式事务塞进 FastD 核心 + +## 模块 + +| 模块 | 作用 | +|---|---| +| `Etcd` | etcd v3 HTTP gateway 客户端 | +| `Discovery` | 服务注册、服务解析、实例负载均衡 | +| `Config` | 从 etcd 读取配置中心键值 | +| `HttpClient` | 按服务名解析实例的 PSR-18 客户端 | +| `Tracing` | W3C `traceparent` 提取、生成与下游传播 | +| `Governance` | 重试、熔断、令牌桶限流 | + +## 安装 + +```bash +composer require fastd/integrations +``` + +本包只依赖 PSR 接口。`fastd/http` 仅作为开发测试依赖,用于构造 PSR-7 请求/响应。 + +## 配置示例 + +```php +use FastD\Http\Factory; +use FastD\Integrations\Discovery\EtcdResolver; +use FastD\Integrations\Discovery\LoadBalancer\RoundRobinBalancer; +use FastD\Integrations\Etcd\EtcdClient; +use FastD\Integrations\HttpClient\ServiceClient; + +$factory = new Factory(); + +$etcd = new EtcdClient( + httpClient: $psr18Client, + requestFactory: $factory, + streamFactory: $factory, + baseUri: 'http://127.0.0.1:2379', +); + +$resolver = new EtcdResolver($etcd, prefix: '/fastd/services'); +$client = new ServiceClient($psr18Client, $resolver, new RoundRobinBalancer()); + +// 请求 URI 的 host 作为服务名 +$request = $factory->createRequest('GET', 'http://user-service/users/1'); +$response = $client->sendRequest($request); +``` + +## 服务注册 + +```php +use FastD\Integrations\Discovery\EtcdServiceRegistry; +use FastD\Integrations\Discovery\ServiceInstance; + +$registry = new EtcdServiceRegistry($etcd, prefix: '/fastd/services', ttl: 15); + +$lease = $registry->register(new ServiceInstance( + name: 'user-service', + host: '10.0.0.12', + port: 9527, + metadata: ['version' => '1.0.0'], +)); + +// 定时续约 +$registry->heartbeat($lease); + +// 进程退出 +$registry->deregister($lease); +``` + +etcd key 结构: + +```text +/fastd/services/{service}/{instanceId} +``` + +value 为 JSON: + +```json +{"host":"10.0.0.12","port":9527,"weight":100,"metadata":{"version":"1.0.0"}} +``` + +## 配置中心 + +```php +use FastD\Integrations\Config\EtcdConfigSource; + +$source = new EtcdConfigSource($etcd, prefix: '/fastd/config'); +$config = $source->load('user-service'); +``` + +etcd key: + +```text +/fastd/config/user-service/database/host +``` + +会转换成: + +```php +['database' => ['host' => '...']] +``` + +## 链路传播 + +在 FastD 中把中间件挂到全局中间件栈: + +```php +use FastD\Integrations\Tracing\TracingMiddleware; + +$container->get('matcher')->push(new TracingMiddleware()); +``` + +入口读取或生成 `traceparent`,写入请求属性 `fastd.trace_context`,并返回 `X-Trace-Id`。下游调用使用: + +```php +use FastD\Integrations\Tracing\TraceClient; + +$client = new TraceClient($psr18Client, $traceContext); +``` + +`TraceClient` 只负责 W3C 上下文传播,不导出 Span。Span 采集/导出请接入 OpenTelemetry SDK。 + +## 治理 + +```php +use FastD\Integrations\Governance\CircuitBreaker; +use FastD\Integrations\Governance\CircuitBreakerClient; +use FastD\Integrations\Governance\RetryClient; +use FastD\Integrations\Governance\RetryPolicy; + +$client = new RetryClient( + new CircuitBreakerClient($psr18Client, new CircuitBreaker()), + new RetryPolicy(maxAttempts: 3), +); +``` + +- 重试只对幂等方法生效 +- 熔断和限流状态默认进程内;多实例共享需替换存储实现 +- 生产环境需设置超时,避免重试放大故障 + +## 边界 + +本包不提供: + +- 服务注册中心本身 +- gRPC/Thrift 协议 +- 分布式事务 +- 完整 OpenTelemetry SDK +- 全局限流存储 + +这些应由 etcd、OpenTelemetry、网关或独立库负责。 + +## 测试 + +```bash +composer install +vendor/bin/phpunit +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9ca8fcf --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "fastd/integrations", + "type": "library", + "license": "MIT", + "description": "Optional microservice integrations for FastD: etcd discovery/config, service clients, W3C tracing and client-side governance.", + "keywords": [ + "fastd", + "microservice", + "etcd", + "service-discovery", + "tracing", + "circuit-breaker", + "rate-limit" + ], + "homepage": "https://github.com/fastdlabs/integrations", + "authors": [ + { + "name": "janhuang", + "email": "bboyjanhuang@gmail.com" + } + ], + "require": { + "php": ">=8.4.1", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "fastd/http": "^8.2", + "phpunit/phpunit": "^13.4" + }, + "autoload": { + "psr-4": { + "FastD\\Integrations\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "FastD\\Integrations\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" + }, + "config": { + "sort-packages": true + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d0d7053 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,12 @@ + + + + + tests + + + diff --git a/src/Config/ConfigSourceInterface.php b/src/Config/ConfigSourceInterface.php new file mode 100644 index 0000000..9c736b6 --- /dev/null +++ b/src/Config/ConfigSourceInterface.php @@ -0,0 +1,13 @@ + + */ + public function load(string $service): array; +} diff --git a/src/Config/EtcdConfigSource.php b/src/Config/EtcdConfigSource.php new file mode 100644 index 0000000..ae8a0b4 --- /dev/null +++ b/src/Config/EtcdConfigSource.php @@ -0,0 +1,72 @@ + ['host' => '...']] + */ +final class EtcdConfigSource implements ConfigSourceInterface +{ + public function __construct( + private readonly EtcdClient $client, + private readonly string $prefix = '/fastd/config', + ) { + } + + public function load(string $service): array + { + $prefix = sprintf( + '%s/%s/', + trim($this->prefix, '/'), + trim($service, '/'), + ); + + $config = []; + + foreach ($this->client->getPrefix($prefix) as $keyValue) { + $path = substr($keyValue->key, strlen($prefix)); + + if ($path === false || $path === '') { + continue; + } + + $this->assign($config, explode('/', trim($path, '/')), $keyValue->value); + } + + return $config; + } + + /** + * @param array $config + * @param string[] $path + */ + private function assign(array &$config, array $path, string $value): void + { + $cursor = &$config; + + foreach ($path as $index => $segment) { + if ($segment === '') { + continue; + } + + if ($index === array_key_last($path)) { + $cursor[$segment] = $value; + break; + } + + if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) { + $cursor[$segment] = []; + } + + $cursor = &$cursor[$segment]; + } + } +} diff --git a/src/Discovery/EtcdResolver.php b/src/Discovery/EtcdResolver.php new file mode 100644 index 0000000..8cd0eb7 --- /dev/null +++ b/src/Discovery/EtcdResolver.php @@ -0,0 +1,77 @@ + */ + private array $cache = []; + + public function __construct( + private readonly EtcdClient $client, + private readonly string $prefix = '/fastd/services', + private readonly int $cacheTtl = 5, + private readonly ?\Closure $clock = null, + ) { + } + + public function resolve(string $service): array + { + $now = $this->now(); + $cached = $this->cache[$service] ?? null; + + if ($cached !== null && $cached['expires'] > $now) { + return $cached['instances']; + } + + $instances = $this->fetch($service); + $this->cache[$service] = [ + 'expires' => $now + max(0, $this->cacheTtl), + 'instances' => $instances, + ]; + + return $instances; + } + + public function refresh(string $service): array + { + unset($this->cache[$service]); + + return $this->resolve($service); + } + + /** + * @return ServiceInstance[] + */ + private function fetch(string $service): array + { + $prefix = sprintf( + '%s/%s/', + trim($this->prefix, '/'), + trim($service, '/'), + ); + + $instances = []; + + foreach ($this->client->getPrefix($prefix) as $keyValue) { + $id = basename($keyValue->key); + + try { + $instances[] = ServiceInstance::fromJson($service, $keyValue->value, $id); + } catch (\Throwable) { + continue; + } + } + + return $instances; + } + + private function now(): int + { + return $this->clock !== null ? (int) ($this->clock)() : time(); + } +} diff --git a/src/Discovery/EtcdServiceRegistry.php b/src/Discovery/EtcdServiceRegistry.php new file mode 100644 index 0000000..c455f82 --- /dev/null +++ b/src/Discovery/EtcdServiceRegistry.php @@ -0,0 +1,70 @@ +id !== '' + ? $instance->id + : bin2hex(random_bytes(8)); + + $registered = new ServiceInstance( + name: $instance->name, + host: $instance->host, + port: $instance->port, + weight: $instance->weight, + metadata: $instance->metadata, + id: $id, + ); + + $leaseId = $this->client->grantLease($this->ttl); + + if ($leaseId <= 0) { + throw new \RuntimeException('Unable to grant an etcd lease.'); + } + + $key = $this->key($instance->name, $id); + $this->client->put($key, $registered->toJson(), $leaseId); + + return new ServiceLease($registered, $key, $leaseId); + } + + public function deregister(ServiceLease $lease): void + { + $this->client->delete($lease->key); + + if ($lease->leaseId > 0) { + $this->client->revokeLease($lease->leaseId); + } + } + + public function heartbeat(ServiceLease $lease): void + { + if ($lease->leaseId > 0) { + $this->client->keepAlive($lease->leaseId); + } + } + + private function key(string $service, string $id): string + { + return sprintf( + '/%s/%s/%s', + trim($this->prefix, '/'), + trim($service, '/'), + $id, + ); + } +} diff --git a/src/Discovery/LoadBalancer/LoadBalancerInterface.php b/src/Discovery/LoadBalancer/LoadBalancerInterface.php new file mode 100644 index 0000000..fce9dd1 --- /dev/null +++ b/src/Discovery/LoadBalancer/LoadBalancerInterface.php @@ -0,0 +1,15 @@ +counter % count($instances); + $this->counter++; + + return $instances[$index]; + } +} diff --git a/src/Discovery/LoadBalancer/WeightedRandomBalancer.php b/src/Discovery/LoadBalancer/WeightedRandomBalancer.php new file mode 100644 index 0000000..4d44445 --- /dev/null +++ b/src/Discovery/LoadBalancer/WeightedRandomBalancer.php @@ -0,0 +1,35 @@ +weight); + } + + $target = random_int(1, $total); + + foreach ($instances as $instance) { + $target -= max(1, $instance->weight); + + if ($target <= 0) { + return $instance; + } + } + + return $instances[array_key_last($instances)]; + } +} diff --git a/src/Discovery/ResolverInterface.php b/src/Discovery/ResolverInterface.php new file mode 100644 index 0000000..0ae7c31 --- /dev/null +++ b/src/Discovery/ResolverInterface.php @@ -0,0 +1,13 @@ + $metadata + */ + public function __construct( + public string $name, + public string $host, + public int $port, + public int $weight = 100, + public array $metadata = [], + public string $id = '', + ) { + } + + public function endpoint(string $scheme = 'http'): string + { + $host = str_contains($this->host, ':') && !str_starts_with($this->host, '[') + ? '[' . $this->host . ']' + : $this->host; + + return sprintf('%s://%s:%d', $scheme, $host, $this->port); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'host' => $this->host, + 'port' => $this->port, + 'weight' => $this->weight, + 'metadata' => $this->metadata, + ]; + } + + /** + * @throws JsonException + */ + public function toJson(): string + { + return json_encode($this->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + /** + * @throws JsonException + */ + public static function fromJson(string $name, string $json, string $id = ''): self + { + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + if (!is_array($data) || !isset($data['host'], $data['port'])) { + throw new DiscoveryException(sprintf( + 'Invalid service instance payload for "%s".', + $name, + )); + } + + return new self( + name: $name, + host: (string) $data['host'], + port: (int) $data['port'], + weight: max(1, (int) ($data['weight'] ?? 100)), + metadata: is_array($data['metadata'] ?? null) ? $data['metadata'] : [], + id: $id, + ); + } +} diff --git a/src/Discovery/ServiceLease.php b/src/Discovery/ServiceLease.php new file mode 100644 index 0000000..d05a43a --- /dev/null +++ b/src/Discovery/ServiceLease.php @@ -0,0 +1,15 @@ + */ + private readonly array $headers = [], + ) { + } + + public function put(string $key, string $value, int $leaseId = 0): void + { + $payload = [ + 'key' => base64_encode($key), + 'value' => base64_encode($value), + ]; + + if ($leaseId > 0) { + $payload['lease'] = $leaseId; + } + + $this->request('/v3/kv/put', $payload); + } + + /** + * @return EtcdKeyValue[] + */ + public function get(string $key, string $rangeEnd = ''): array + { + return $this->range($key, $rangeEnd); + } + + /** + * @return EtcdKeyValue[] + */ + public function getPrefix(string $prefix): array + { + return $this->range($prefix, self::prefixEnd($prefix)); + } + + public function delete(string $key): void + { + $this->request('/v3/kv/deleterange', [ + 'key' => base64_encode($key), + ]); + } + + public function deletePrefix(string $prefix): void + { + $this->request('/v3/kv/deleterange', [ + 'key' => base64_encode($prefix), + 'range_end' => base64_encode(self::prefixEnd($prefix)), + ]); + } + + public function grantLease(int $ttl): int + { + $data = $this->request('/v3/lease/grant', ['TTL' => $ttl]); + + return (int) ($data['ID'] ?? 0); + } + + public function keepAlive(int $leaseId): void + { + $this->request('/v3/lease/keepalive', ['ID' => $leaseId]); + } + + public function revokeLease(int $leaseId): void + { + $this->request('/v3/lease/revoke', ['ID' => $leaseId]); + } + + /** + * @return EtcdKeyValue[] + */ + private function range(string $key, string $rangeEnd): array + { + $payload = ['key' => base64_encode($key)]; + + if ($rangeEnd !== '') { + $payload['range_end'] = base64_encode($rangeEnd); + } + + $data = $this->request('/v3/kv/range', $payload); + $kvs = $data['kvs'] ?? []; + + if (!is_array($kvs)) { + throw new EtcdException('Invalid etcd range response.'); + } + + return array_values(array_map( + static fn (array $item): EtcdKeyValue => EtcdKeyValue::fromArray($item), + array_filter($kvs, 'is_array'), + )); + } + + /** + * @param array $payload + * @return array + */ + private function request(string $path, array $payload): array + { + $uri = rtrim($this->baseUri, '/') . $path; + + $request = $this->requestFactory->createRequest('POST', $uri) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode( + $payload, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES, + ))); + + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $response = $this->httpClient->sendRequest($request); + $body = (string) $response->getBody(); + + if ($response->getStatusCode() >= 400) { + throw new EtcdException(sprintf( + 'etcd request failed: HTTP %d %s', + $response->getStatusCode(), + $body, + )); + } + + if ($body === '') { + return []; + } + + $data = json_decode($body, true); + + if (!is_array($data)) { + throw new EtcdException('Invalid etcd JSON response: ' . $body); + } + + return $data; + } + + public static function prefixEnd(string $prefix): string + { + if ($prefix === '') { + return "\0"; + } + + for ($i = strlen($prefix) - 1; $i >= 0; $i--) { + $byte = ord($prefix[$i]); + + if ($byte < 255) { + $prefix[$i] = chr($byte + 1); + + return substr($prefix, 0, $i + 1); + } + } + + return "\0"; + } +} diff --git a/src/Etcd/EtcdKeyValue.php b/src/Etcd/EtcdKeyValue.php new file mode 100644 index 0000000..cbd20ed --- /dev/null +++ b/src/Etcd/EtcdKeyValue.php @@ -0,0 +1,44 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + key: self::decode($data['key'] ?? ''), + value: self::decode($data['value'] ?? ''), + lease: (int) ($data['lease'] ?? 0), + createRevision: (int) ($data['create_revision'] ?? 0), + modRevision: (int) ($data['mod_revision'] ?? 0), + version: (int) ($data['version'] ?? 0), + ); + } + + private static function decode(mixed $value): string + { + if (!is_string($value)) { + return ''; + } + + $decoded = base64_decode($value, true); + + return $decoded === false ? '' : $decoded; + } +} diff --git a/src/Exception/CircuitOpenException.php b/src/Exception/CircuitOpenException.php new file mode 100644 index 0000000..4bfb569 --- /dev/null +++ b/src/Exception/CircuitOpenException.php @@ -0,0 +1,9 @@ +clock = $clock ?? static fn (): int => time(); + } + + public function allow(): bool + { + $this->transition(); + + return $this->state !== self::OPEN; + } + + public function success(): void + { + $this->failures = 0; + $this->state = self::CLOSED; + $this->openedAt = 0; + } + + public function failure(): void + { + $this->failures++; + + if ($this->state === self::HALF_OPEN || $this->failures >= $this->failureThreshold) { + $this->state = self::OPEN; + $this->openedAt = ($this->clock)(); + } + } + + public function state(): string + { + $this->transition(); + + return $this->state; + } + + private function transition(): void + { + if ($this->state !== self::OPEN) { + return; + } + + if (($this->clock)() - $this->openedAt >= $this->cooldownSeconds) { + $this->state = self::HALF_OPEN; + } + } +} diff --git a/src/Governance/CircuitBreakerClient.php b/src/Governance/CircuitBreakerClient.php new file mode 100644 index 0000000..aa18180 --- /dev/null +++ b/src/Governance/CircuitBreakerClient.php @@ -0,0 +1,43 @@ +breaker->allow()) { + throw new CircuitOpenException('Circuit breaker is open.'); + } + + try { + $response = $this->client->sendRequest($request); + } catch (Throwable $throwable) { + $this->breaker->failure(); + + throw $throwable; + } + + if ($response->getStatusCode() >= 500) { + $this->breaker->failure(); + } else { + $this->breaker->success(); + } + + return $response; + } +} diff --git a/src/Governance/InMemoryRateLimiter.php b/src/Governance/InMemoryRateLimiter.php new file mode 100644 index 0000000..f235543 --- /dev/null +++ b/src/Governance/InMemoryRateLimiter.php @@ -0,0 +1,54 @@ + */ + private array $buckets = []; + + /** @var \Closure(): int */ + private readonly \Closure $clock; + + public function __construct( + private readonly int $capacity = 60, + private readonly int $refillPerSecond = 60, + ?\Closure $clock = null, + ) { + $this->clock = $clock ?? static fn (): int => time(); + } + + public function attempt(string $key, int $tokens = 1): bool + { + $now = ($this->clock)(); + $bucket = $this->buckets[$key] ?? [ + 'tokens' => (float) $this->capacity, + 'updated' => $now, + ]; + + $elapsed = max(0, $now - $bucket['updated']); + $bucket['tokens'] = min( + (float) $this->capacity, + $bucket['tokens'] + $elapsed * $this->refillPerSecond, + ); + $bucket['updated'] = $now; + + if ($bucket['tokens'] < $tokens) { + $this->buckets[$key] = $bucket; + + return false; + } + + $bucket['tokens'] -= $tokens; + $this->buckets[$key] = $bucket; + + return true; + } +} diff --git a/src/Governance/RateLimitMiddleware.php b/src/Governance/RateLimitMiddleware.php new file mode 100644 index 0000000..56ed384 --- /dev/null +++ b/src/Governance/RateLimitMiddleware.php @@ -0,0 +1,41 @@ +keyResolver = $keyResolver ?? static function (ServerRequestInterface $request): string { + return $request->getHeaderLine('X-Api-Key') + ?: $request->getHeaderLine('X-Forwarded-For') + ?: 'anonymous'; + }; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $key = ($this->keyResolver)($request); + + if (!$this->limiter->attempt($key)) { + return $this->responseFactory->createResponse(429) + ->withHeader('Retry-After', '1'); + } + + return $handler->handle($request); + } +} diff --git a/src/Governance/RateLimiterInterface.php b/src/Governance/RateLimiterInterface.php new file mode 100644 index 0000000..7b342dd --- /dev/null +++ b/src/Governance/RateLimiterInterface.php @@ -0,0 +1,10 @@ +sleeper = $sleeper ?? static function (int $milliseconds): void { + usleep($milliseconds * 1000); + }; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $attempt = 0; + + while (true) { + $attempt++; + $response = null; + $error = null; + + try { + $response = $this->client->sendRequest($request); + } catch (Throwable $throwable) { + $error = $throwable; + } + + if (!$this->policy->shouldRetry($request, $response, $error, $attempt)) { + if ($error !== null) { + throw $error; + } + + return $response; + } + + ($this->sleeper)($this->policy->delayMs($attempt)); + } + } +} diff --git a/src/Governance/RetryPolicy.php b/src/Governance/RetryPolicy.php new file mode 100644 index 0000000..eb1e9f8 --- /dev/null +++ b/src/Governance/RetryPolicy.php @@ -0,0 +1,53 @@ += $this->maxAttempts) { + return false; + } + + if (!in_array(strtoupper($request->getMethod()), $this->retryMethods, true)) { + return false; + } + + if ($response !== null) { + return in_array($response->getStatusCode(), $this->retryStatusCodes, true); + } + + return $error !== null; + } + + public function delayMs(int $attempt): int + { + $delay = $this->baseDelayMs * (2 ** max(0, $attempt - 1)); + + return min($delay, $this->maxDelayMs); + } +} diff --git a/src/HttpClient/ServiceClient.php b/src/HttpClient/ServiceClient.php new file mode 100644 index 0000000..9f779e2 --- /dev/null +++ b/src/HttpClient/ServiceClient.php @@ -0,0 +1,55 @@ +getUri()->getHost(); + $instances = $this->resolver->resolve($service); + + if ($instances === []) { + throw new ServiceUnavailableException(sprintf( + 'No service instance available for "%s".', + $service, + )); + } + + $instance = $this->loadBalancer->choose($instances); + + if ($instance === null) { + throw new ServiceUnavailableException(sprintf( + 'No service instance selected for "%s".', + $service, + )); + } + + $uri = $request->getUri() + ->withHost($instance->host) + ->withPort($instance->port); + + return $this->client->sendRequest($request->withUri($uri)); + } +} diff --git a/src/Tracing/TraceClient.php b/src/Tracing/TraceClient.php new file mode 100644 index 0000000..0ff5ca0 --- /dev/null +++ b/src/Tracing/TraceClient.php @@ -0,0 +1,30 @@ +getHeaderLine('traceparent') === '') { + $request = $request->withHeader('traceparent', $this->context->traceparent()); + } + + return $this->client->sendRequest($request); + } +} diff --git a/src/Tracing/TraceContext.php b/src/Tracing/TraceContext.php new file mode 100644 index 0000000..d087162 --- /dev/null +++ b/src/Tracing/TraceContext.php @@ -0,0 +1,73 @@ +traceId, + spanId: bin2hex(random_bytes(8)), + sampled: $this->sampled, + ); + } + + public function traceparent(): string + { + return sprintf( + '00-%s-%s-%02x', + $this->traceId, + $this->spanId, + $this->sampled ? 1 : 0, + ); + } +} diff --git a/src/Tracing/TracingMiddleware.php b/src/Tracing/TracingMiddleware.php new file mode 100644 index 0000000..06fb947 --- /dev/null +++ b/src/Tracing/TracingMiddleware.php @@ -0,0 +1,38 @@ +trustIncoming + ? $request->getHeaderLine('traceparent') + : null; + + $context = TraceContext::fromTraceparent($incoming !== '' ? $incoming : null); + + $response = $handler->handle( + $request->withAttribute(TraceContext::ATTRIBUTE, $context), + ); + + return $response->withHeader('X-Trace-Id', $context->traceId); + } +} diff --git a/tests/Config/EtcdConfigSourceTest.php b/tests/Config/EtcdConfigSourceTest.php new file mode 100644 index 0000000..c2e333e --- /dev/null +++ b/tests/Config/EtcdConfigSourceTest.php @@ -0,0 +1,51 @@ + [ + [ + 'key' => base64_encode('/fastd/config/user/database/host'), + 'value' => base64_encode('db.local'), + ], + [ + 'key' => base64_encode('/fastd/config/user/database/port'), + 'value' => base64_encode('3306'), + ], + [ + 'key' => base64_encode('/fastd/config/user/name'), + 'value' => base64_encode('user-service'), + ], + ], + ]), + ]); + + $source = new EtcdConfigSource( + new EtcdClient($http, $factory, $factory), + '/fastd/config', + ); + + self::assertSame([ + 'database' => [ + 'host' => 'db.local', + 'port' => '3306', + ], + 'name' => 'user-service', + ], $source->load('user')); + } +} diff --git a/tests/Discovery/DiscoveryTest.php b/tests/Discovery/DiscoveryTest.php new file mode 100644 index 0000000..41cb63e --- /dev/null +++ b/tests/Discovery/DiscoveryTest.php @@ -0,0 +1,111 @@ + 9]), + new Json(200, []), + new Json(200, []), + new Json(200, []), + new Json(200, []), + ]); + $registry = new EtcdServiceRegistry( + new EtcdClient($http, $factory, $factory), + '/fastd/services', + 15, + ); + + $lease = $registry->register(new ServiceInstance( + name: 'user', + host: '10.0.0.1', + port: 9527, + metadata: ['version' => '1.0'], + )); + + self::assertSame(9, $lease->leaseId); + self::assertStringContainsString('/fastd/services/user/', $lease->key); + + $put = json_decode((string) $http->requests[1]->getBody(), true); + $payload = json_decode(base64_decode($put['value']), true); + self::assertSame('10.0.0.1', $payload['host']); + self::assertSame(9527, $payload['port']); + + $registry->heartbeat($lease); + $registry->deregister($lease); + self::assertCount(5, $http->requests); + } + + public function testResolverBuildsInstancesAndCaches(): void + { + $factory = new Factory(); + $response = new Json(200, [ + 'kvs' => [ + [ + 'key' => base64_encode('/fastd/services/user/a'), + 'value' => base64_encode('{"host":"10.0.0.1","port":9527,"weight":100}'), + ], + [ + 'key' => base64_encode('/fastd/services/user/b'), + 'value' => base64_encode('{"host":"10.0.0.2","port":9528,"weight":200}'), + ], + ], + ]); + $http = new FakeClient([$response, $response]); + + $now = 100; + $resolver = new EtcdResolver( + new EtcdClient($http, $factory, $factory), + cacheTtl: 30, + clock: static function () use (&$now): int { + return $now; + }, + ); + + $instances = $resolver->resolve('user'); + self::assertCount(2, $instances); + self::assertSame('a', $instances[0]->id); + self::assertSame(200, $instances[1]->weight); + + $resolver->resolve('user'); + self::assertCount(1, $http->requests); + + $now += 31; + $resolver->resolve('user'); + self::assertCount(2, $http->requests); + } + + public function testLoadBalancersChooseInstances(): void + { + $instances = [ + new ServiceInstance('user', '10.0.0.1', 1), + new ServiceInstance('user', '10.0.0.2', 2), + ]; + + $rr = new RoundRobinBalancer(); + self::assertSame($instances[0], $rr->choose($instances)); + self::assertSame($instances[1], $rr->choose($instances)); + self::assertSame($instances[0], $rr->choose($instances)); + + $weighted = new WeightedRandomBalancer(); + self::assertContains($weighted->choose($instances), $instances); + self::assertNull($weighted->choose([])); + } +} diff --git a/tests/Etcd/EtcdClientTest.php b/tests/Etcd/EtcdClientTest.php new file mode 100644 index 0000000..18ec2f5 --- /dev/null +++ b/tests/Etcd/EtcdClientTest.php @@ -0,0 +1,64 @@ + [ + [ + 'key' => base64_encode('/fastd/services/user/a'), + 'value' => base64_encode('{"host":"127.0.0.1"}'), + ], + ], + ]), + ]); + + $client = new EtcdClient($http, $factory, $factory, 'http://etcd:2379'); + $items = $client->getPrefix('/fastd/services/user/'); + + self::assertCount(1, $items); + self::assertSame('/fastd/services/user/a', $items[0]->key); + self::assertSame('{"host":"127.0.0.1"}', $items[0]->value); + self::assertStringContainsString('/v3/kv/range', (string) $http->requests[0]->getUri()); + } + + public function testPutGrantAndKeepAliveUseEtcdGatewayPayloads(): void + { + $factory = new Factory(); + $http = new FakeClient([ + new Json(200, ['ID' => 42]), + new Json(200, []), + new Json(200, []), + ]); + + $client = new EtcdClient($http, $factory, $factory, 'http://etcd:2379'); + $lease = $client->grantLease(15); + $client->put('/fastd/services/a', '{"host":"10.0.0.1"}', $lease); + $client->keepAlive($lease); + + self::assertSame(42, $lease); + + $put = json_decode((string) $http->requests[1]->getBody(), true); + self::assertSame(base64_encode('/fastd/services/a'), $put['key']); + self::assertSame(42, $put['lease']); + } + + public function testPrefixEndHandlesNormalAndFullBytePrefix(): void + { + self::assertSame('/b', EtcdClient::prefixEnd('/a')); + self::assertSame("\0", EtcdClient::prefixEnd("\xff")); + } +} diff --git a/tests/Governance/GovernanceTest.php b/tests/Governance/GovernanceTest.php new file mode 100644 index 0000000..1f857ea --- /dev/null +++ b/tests/Governance/GovernanceTest.php @@ -0,0 +1,113 @@ + true]), + ]); + $delays = []; + $client = new RetryClient( + $http, + new RetryPolicy(maxAttempts: 2, baseDelayMs: 25), + static function (int $delay) use (&$delays): void { + $delays[] = $delay; + }, + ); + + $response = $client->sendRequest($factory->createRequest('GET', 'http://user/')); + + self::assertSame(200, $response->getStatusCode()); + self::assertCount(2, $http->requests); + self::assertSame([25], $delays); + } + + public function testCircuitBreakerOpensAndHalfOpens(): void + { + $now = 100; + $breaker = new CircuitBreaker( + failureThreshold: 2, + cooldownSeconds: 30, + clock: static function () use (&$now): int { + return $now; + }, + ); + + self::assertTrue($breaker->allow()); + $breaker->failure(); + $breaker->failure(); + self::assertSame(CircuitBreaker::OPEN, $breaker->state()); + self::assertFalse($breaker->allow()); + + $now += 31; + self::assertSame(CircuitBreaker::HALF_OPEN, $breaker->state()); + self::assertTrue($breaker->allow()); + $breaker->success(); + self::assertSame(CircuitBreaker::CLOSED, $breaker->state()); + } + + public function testCircuitBreakerClientRejectsOpenCircuit(): void + { + $now = 0; + $breaker = new CircuitBreaker(1, 60, static function () use (&$now): int { + return $now; + }); + $breaker->failure(); + + $client = new CircuitBreakerClient(new FakeClient(), $breaker); + + $this->expectException(CircuitOpenException::class); + $client->sendRequest((new Factory())->createRequest('GET', 'http://user/')); + } + + public function testInMemoryRateLimiterRefills(): void + { + $now = 0; + $limiter = new InMemoryRateLimiter(2, 1, static function () use (&$now): int { + return $now; + }); + + self::assertTrue($limiter->attempt('a')); + self::assertTrue($limiter->attempt('a')); + self::assertFalse($limiter->attempt('a')); + + $now += 1; + self::assertTrue($limiter->attempt('a')); + self::assertTrue($limiter->attempt('b')); + } + + public function testRateLimitMiddlewareReturns429(): void + { + $factory = new Factory(); + $limiter = new InMemoryRateLimiter(1, 1); + $handler = new FakeHandler(new Json(200, [])); + $middleware = new RateLimitMiddleware($limiter, $factory); + + $request = $factory->createServerRequest('GET', '/'); + self::assertSame(200, $middleware->process($request, $handler)->getStatusCode()); + + $response = $middleware->process($request, $handler); + self::assertSame(429, $response->getStatusCode()); + self::assertSame('1', $response->getHeaderLine('Retry-After')); + } +} diff --git a/tests/HttpClient/ServiceClientTest.php b/tests/HttpClient/ServiceClientTest.php new file mode 100644 index 0000000..13f8fa1 --- /dev/null +++ b/tests/HttpClient/ServiceClientTest.php @@ -0,0 +1,49 @@ + true])]); + $resolver = new FakeResolver(); + $resolver->set('user', [ + new ServiceInstance('user', '10.0.0.1', 9527), + ]); + + $client = new ServiceClient($http, $resolver, new RoundRobinBalancer()); + $response = $client->sendRequest($factory->createRequest('GET', 'http://user/users/1?x=1')); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('10.0.0.1', $http->requests[0]->getUri()->getHost()); + self::assertSame(9527, $http->requests[0]->getUri()->getPort()); + self::assertSame('/users/1', $http->requests[0]->getUri()->getPath()); + self::assertSame('x=1', $http->requests[0]->getUri()->getQuery()); + } + + public function testThrowsWhenNoInstanceAvailable(): void + { + $client = new ServiceClient( + new FakeClient(), + new FakeResolver(), + new RoundRobinBalancer(), + ); + + $this->expectException(ServiceUnavailableException::class); + $client->sendRequest((new Factory())->createRequest('GET', 'http://missing/')); + } +} diff --git a/tests/Support/FakeClient.php b/tests/Support/FakeClient.php new file mode 100644 index 0000000..ff5e8e2 --- /dev/null +++ b/tests/Support/FakeClient.php @@ -0,0 +1,40 @@ + $responses + */ + public function __construct(private array $responses = []) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + + $next = array_shift($this->responses); + + if ($next instanceof Throwable) { + throw $next; + } + + if (!$next instanceof ResponseInterface) { + throw new \RuntimeException('No fake response configured.'); + } + + return $next; + } +} diff --git a/tests/Support/FakeHandler.php b/tests/Support/FakeHandler.php new file mode 100644 index 0000000..3638cae --- /dev/null +++ b/tests/Support/FakeHandler.php @@ -0,0 +1,26 @@ +requests[] = $request; + + return $this->response; + } +} diff --git a/tests/Support/FakeResolver.php b/tests/Support/FakeResolver.php new file mode 100644 index 0000000..b48d962 --- /dev/null +++ b/tests/Support/FakeResolver.php @@ -0,0 +1,27 @@ + */ + public array $services = []; + + /** + * @param ServiceInstance[] $instances + */ + public function set(string $service, array $instances): void + { + $this->services[$service] = $instances; + } + + public function resolve(string $service): array + { + return $this->services[$service] ?? []; + } +} diff --git a/tests/Tracing/TracingTest.php b/tests/Tracing/TracingTest.php new file mode 100644 index 0000000..59d86da --- /dev/null +++ b/tests/Tracing/TracingTest.php @@ -0,0 +1,68 @@ +traceId); + self::assertSame('00f067aa0ba902b7', $context->spanId); + self::assertTrue($context->sampled); + + $child = $context->child(); + self::assertSame($context->traceId, $child->traceId); + self::assertNotSame($context->spanId, $child->spanId); + self::assertStringStartsWith('00-', $child->traceparent()); + } + + public function testTracingMiddlewareAddsAttributeAndResponseHeader(): void + { + $factory = new Factory(); + $handler = new FakeHandler(new Json(200, [])); + $middleware = new TracingMiddleware(); + + $request = $factory->createServerRequest('GET', '/') + ->withHeader('traceparent', '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'); + + $response = $middleware->process($request, $handler); + + self::assertInstanceOf( + TraceContext::class, + $handler->requests[0]->getAttribute(TraceContext::ATTRIBUTE), + ); + self::assertSame( + '4bf92f3577b34da6a3ce929d0e0e4736', + $response->getHeaderLine('X-Trace-Id'), + ); + } + + public function testTraceClientInjectsTraceparent(): void + { + $factory = new Factory(); + $http = new FakeClient([new Json(200, [])]); + $context = TraceContext::fromTraceparent( + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + ); + $client = new TraceClient($http, $context); + + $client->sendRequest($factory->createRequest('GET', 'http://user/')); + + self::assertSame( + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + $http->requests[0]->getHeaderLine('traceparent'), + ); + } +} From 86648320e75d2c40c6dc4de021f5498cfeecce03 Mon Sep 17 00:00:00 2001 From: JanHuang <384099566@qq.com> Date: Thu, 29 Oct 2026 16:29:39 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20FastD=20Servic?= =?UTF-8?q?eProvider=20=E4=B8=8E=E5=AE=8C=E6=95=B4=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IntegrationsServiceProvider 读取 config/integrations.php - 注册 etcd、服务发现、服务客户端、配置源 - 按配置装配 tracing、retry、circuit breaker、rate limit - 增加示例配置与 Provider 测试 - README 补充安装、配置、注册、调用、治理和限流示例 --- README.md | 295 ++++++++++++++---- composer.json | 3 +- examples/config/integrations.php | 47 +++ .../IntegrationsServiceProvider.php | 214 +++++++++++++ .../IntegrationsServiceProviderTest.php | 88 ++++++ 5 files changed, 593 insertions(+), 54 deletions(-) create mode 100644 examples/config/integrations.php create mode 100644 src/ServiceProvider/IntegrationsServiceProvider.php create mode 100644 tests/ServiceProvider/IntegrationsServiceProviderTest.php diff --git a/README.md b/README.md index c16d349..8a9fca7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ FastD 的可选微服务集成包。 | `HttpClient` | 按服务名解析实例的 PSR-18 客户端 | | `Tracing` | W3C `traceparent` 提取、生成与下游传播 | | `Governance` | 重试、熔断、令牌桶限流 | +| `ServiceProvider` | FastD 容器装配、中间件注册、配置读取 | ## 安装 @@ -25,53 +26,164 @@ FastD 的可选微服务集成包。 composer require fastd/integrations ``` -本包只依赖 PSR 接口。`fastd/http` 仅作为开发测试依赖,用于构造 PSR-7 请求/响应。 +> 当前功能分支为 `feat/initial-integrations`,正式发布前使用 VCS 方式引用。 -## 配置示例 +如果包尚未发布到 Packagist,可临时使用 VCS。合并到 master 后可将版本改为 `dev-master`: + +```json +{ + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/fastdlabs/integrations" + } + ], + "require": { + "fastd/integrations": "dev-feat/initial-integrations" + } +} +``` + +## FastD 集成 + +### 1. 配置文件 + +新增 `config/integrations.php`: + +```php + [ + 'base_uri' => getenv('ETCD_ENDPOINT') ?: 'http://127.0.0.1:2379', + 'headers' => [], + 'services_prefix' => '/fastd/services', + 'config_prefix' => '/fastd/config', + 'cache_ttl' => 5, + 'lease_ttl' => 15, + 'load_balancer' => 'round_robin', // round_robin | random | weighted_random + ], + + 'tracing' => [ + 'enabled' => true, + 'trust_incoming' => true, + ], + + 'governance' => [ + 'retry' => [ + 'enabled' => false, + 'max_attempts' => 3, + 'base_delay_ms' => 50, + 'max_delay_ms' => 1000, + ], + 'circuit_breaker' => [ + 'enabled' => false, + 'failure_threshold' => 5, + 'cooldown_seconds' => 30, + ], + 'rate_limit' => [ + 'enabled' => false, + 'capacity' => 60, + 'refill_per_second' => 60, + ], + ], + + // 当前服务实例;Provider 只注册为服务,不自动注册到 etcd。 + 'service' => [ + 'name' => 'user-service', + 'host' => '127.0.0.1', + 'port' => 9527, + 'weight' => 100, + 'metadata' => [], + ], +]; +``` + +Provider 的配置来源: + +1. 容器中的 `integrations.config` 数组; +2. 应用根目录的 `config/integrations.php`。 + +如果没有配置 `etcd.base_uri`,Provider 直接跳过,不影响应用启动。 + +### 2. 注册 ServiceProvider + +`config/service.php`: + +```php +return [ + \FastD\Integrations\ServiceProvider\IntegrationsServiceProvider::class, +]; +``` + +注册后容器中可使用: + +| 服务 ID | 类型 | +|---|---| +| `integrations.etcd` | `EtcdClient` | +| `integrations.discovery.resolver` | `EtcdResolver` | +| `integrations.discovery.registry` | `EtcdServiceRegistry` | +| `integrations.http.service_client` | 已按配置包装的 PSR-18 Client | +| `integrations.config.source` | `EtcdConfigSource` | +| `integrations.tracing.middleware` | `TracingMiddleware`(启用时) | +| `integrations.governance.rate_limiter` | `InMemoryRateLimiter`(启用时) | +| `integrations.governance.rate_limit_middleware` | `RateLimitMiddleware`(启用时) | +| `integrations.service_instance` | `ServiceInstance`(配置 service 时) | + +`matcher` 已存在时,Provider 会自动把 tracing / rate limit 中间件 push 到全局中间件栈。 + +### 3. 服务间调用 ```php use FastD\Http\Factory; -use FastD\Integrations\Discovery\EtcdResolver; -use FastD\Integrations\Discovery\LoadBalancer\RoundRobinBalancer; -use FastD\Integrations\Etcd\EtcdClient; -use FastD\Integrations\HttpClient\ServiceClient; +use FastD\Integrations\Tracing\TraceClient; +use FastD\Integrations\Tracing\TraceContext; $factory = new Factory(); +$client = container()->get('integrations.http.service_client'); -$etcd = new EtcdClient( - httpClient: $psr18Client, - requestFactory: $factory, - streamFactory: $factory, - baseUri: 'http://127.0.0.1:2379', -); - -$resolver = new EtcdResolver($etcd, prefix: '/fastd/services'); -$client = new ServiceClient($psr18Client, $resolver, new RoundRobinBalancer()); +// 当前进入的 PSR-7 ServerRequest +$context = $incomingRequest->getAttribute(TraceContext::ATTRIBUTE) + ?? TraceContext::create(); -// 请求 URI 的 host 作为服务名 +// host 是服务名,不是真实域名 $request = $factory->createRequest('GET', 'http://user-service/users/1'); -$response = $client->sendRequest($request); + +$traceClient = new TraceClient($client, $context); +$response = $traceClient->sendRequest($request); ``` -## 服务注册 +`ServiceClient` 的行为: + +```text +http://user-service/users/1 + → Resolver 从 /fastd/services/user-service 读取实例 + → LoadBalancer 选择一个实例 + → 替换为 http://10.0.0.12:9527/users/1 + → 交给底层 PSR-18 Client +``` + +### 4. 注册当前服务 + +Provider 只在容器中创建 `service_instance`,不会自动注册,避免服务尚未监听端口就提前注册。 + +在服务启动完成后的生命周期中执行: ```php -use FastD\Integrations\Discovery\EtcdServiceRegistry; -use FastD\Integrations\Discovery\ServiceInstance; +use Swoole\Timer; -$registry = new EtcdServiceRegistry($etcd, prefix: '/fastd/services', ttl: 15); +$registry = container()->get('integrations.discovery.registry'); +$instance = container()->get('integrations.service_instance'); -$lease = $registry->register(new ServiceInstance( - name: 'user-service', - host: '10.0.0.12', - port: 9527, - metadata: ['version' => '1.0.0'], -)); +$lease = $registry->register($instance); -// 定时续约 -$registry->heartbeat($lease); +Timer::tick(5000, static function () use ($registry, $lease): void { + $registry->heartbeat($lease); +}); -// 进程退出 +// 进程退出时 $registry->deregister($lease); ``` @@ -81,54 +193,84 @@ etcd key 结构: /fastd/services/{service}/{instanceId} ``` -value 为 JSON: +value: ```json -{"host":"10.0.0.12","port":9527,"weight":100,"metadata":{"version":"1.0.0"}} +{ + "host": "10.0.0.12", + "port": 9527, + "weight": 100, + "metadata": { + "version": "1.0.0" + } +} ``` -## 配置中心 +### 5. 配置中心 ```php -use FastD\Integrations\Config\EtcdConfigSource; +$source = container()->get('integrations.config.source'); -$source = new EtcdConfigSource($etcd, prefix: '/fastd/config'); $config = $source->load('user-service'); ``` -etcd key: +etcd: ```text -/fastd/config/user-service/database/host +/fastd/config/user-service/database/host = db.local +/fastd/config/user-service/database/port = 3306 ``` -会转换成: +结果: ```php -['database' => ['host' => '...']] +[ + 'database' => [ + 'host' => 'db.local', + 'port' => '3306', + ], +] ``` -## 链路传播 - -在 FastD 中把中间件挂到全局中间件栈: +本包只读取和转换配置,不自动替换 FastD 本地配置。需要时在业务 Provider 中合并: ```php -use FastD\Integrations\Tracing\TracingMiddleware; - -$container->get('matcher')->push(new TracingMiddleware()); +$container->add('remote_config', $config); ``` -入口读取或生成 `traceparent`,写入请求属性 `fastd.trace_context`,并返回 `X-Trace-Id`。下游调用使用: +### 6. 链路传播 + +`TracingMiddleware` 已由 Provider 挂载时,可直接读取请求上下文: ```php -use FastD\Integrations\Tracing\TraceClient; +use FastD\Integrations\Tracing\TraceContext; -$client = new TraceClient($psr18Client, $traceContext); +$context = $incomingRequest->getAttribute(TraceContext::ATTRIBUTE); ``` -`TraceClient` 只负责 W3C 上下文传播,不导出 Span。Span 采集/导出请接入 OpenTelemetry SDK。 +下游调用使用 `TraceClient` 注入 `traceparent`。 + +本包只做 W3C 上下文传播,不导出 Span。Span 采集/导出请接入 OpenTelemetry SDK。 + +### 7. 重试与熔断 -## 治理 +在 `config/integrations.php` 开启后,`integrations.http.service_client` 会自动包装: + +```php +'governance' => [ + 'retry' => [ + 'enabled' => true, + 'max_attempts' => 3, + ], + 'circuit_breaker' => [ + 'enabled' => true, + 'failure_threshold' => 5, + 'cooldown_seconds' => 30, + ], +], +``` + +也可以手动组合: ```php use FastD\Integrations\Governance\CircuitBreaker; @@ -142,9 +284,54 @@ $client = new RetryClient( ); ``` -- 重试只对幂等方法生效 -- 熔断和限流状态默认进程内;多实例共享需替换存储实现 -- 生产环境需设置超时,避免重试放大故障 +默认只重试 `GET`、`HEAD`、`OPTIONS`、`PUT`、`DELETE`。`POST` 需要业务确认幂等后再加入重试策略。 + +### 8. 限流 + +开启: + +```php +'governance' => [ + 'rate_limit' => [ + 'enabled' => true, + 'capacity' => 60, + 'refill_per_second' => 60, + ], +], +``` + +Provider 会创建 `RateLimitMiddleware` 并 push 到 `matcher`。默认按 `X-Api-Key` / `X-Forwarded-For` 限流。 + +进程内令牌桶只适合单 worker 或演示;多实例全局限流请替换为 Redis 或网关实现。 + +## 独立使用 + +不使用 FastD Provider 时,也可以直接组合组件: + +```php +use FastD\Http\Factory; +use FastD\Http\Request\Client; +use FastD\Integrations\Discovery\EtcdResolver; +use FastD\Integrations\Discovery\LoadBalancer\RoundRobinBalancer; +use FastD\Integrations\Etcd\EtcdClient; +use FastD\Integrations\HttpClient\ServiceClient; + +$factory = new Factory(); +$psr18Client = new Client(); + +$etcd = new EtcdClient( + httpClient: $psr18Client, + requestFactory: $factory, + streamFactory: $factory, + baseUri: 'http://127.0.0.1:2379', +); + +$resolver = new EtcdResolver($etcd, prefix: '/fastd/services'); +$client = new ServiceClient($psr18Client, $resolver, new RoundRobinBalancer()); + +$request = $factory->createRequest('GET', 'http://user-service/users/1'); +$response = $client->sendRequest($request); +``` ## 边界 @@ -164,3 +351,5 @@ $client = new RetryClient( composer install vendor/bin/phpunit ``` + +当前单元测试使用 fake PSR-18 客户端,不依赖真实 etcd。生产接入前应增加真实 etcd 的集成测试。 diff --git a/composer.json b/composer.json index 9ca8fcf..59b82fb 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,8 @@ } ], "require": { + "fastd/container": "^8.1", + "fastd/http": "^8.2", "php": ">=8.4.1", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", @@ -28,7 +30,6 @@ "psr/http-server-middleware": "^1.0" }, "require-dev": { - "fastd/http": "^8.2", "phpunit/phpunit": "^13.4" }, "autoload": { diff --git a/examples/config/integrations.php b/examples/config/integrations.php new file mode 100644 index 0000000..633e81a --- /dev/null +++ b/examples/config/integrations.php @@ -0,0 +1,47 @@ + [ + 'base_uri' => getenv('ETCD_ENDPOINT') ?: 'http://127.0.0.1:2379', + 'headers' => [], + 'services_prefix' => '/fastd/services', + 'config_prefix' => '/fastd/config', + 'cache_ttl' => 5, + 'lease_ttl' => 15, + 'load_balancer' => 'round_robin', + ], + + 'tracing' => [ + 'enabled' => true, + 'trust_incoming' => true, + ], + + 'governance' => [ + 'retry' => [ + 'enabled' => false, + 'max_attempts' => 3, + 'base_delay_ms' => 50, + 'max_delay_ms' => 1000, + ], + 'circuit_breaker' => [ + 'enabled' => false, + 'failure_threshold' => 5, + 'cooldown_seconds' => 30, + ], + 'rate_limit' => [ + 'enabled' => false, + 'capacity' => 60, + 'refill_per_second' => 60, + ], + ], + + 'service' => [ + 'name' => 'user-service', + 'host' => '127.0.0.1', + 'port' => 9527, + 'weight' => 100, + 'metadata' => [], + ], +]; diff --git a/src/ServiceProvider/IntegrationsServiceProvider.php b/src/ServiceProvider/IntegrationsServiceProvider.php new file mode 100644 index 0000000..fdebc32 --- /dev/null +++ b/src/ServiceProvider/IntegrationsServiceProvider.php @@ -0,0 +1,214 @@ +config($container); + $etcdConfig = (array) ($config['etcd'] ?? []); + + if ($etcdConfig === [] || empty($etcdConfig['base_uri'])) { + return; + } + + $factory = new Factory(); + $httpClient = new Client(); + + $etcd = new EtcdClient( + httpClient: $httpClient, + requestFactory: $factory, + streamFactory: $factory, + baseUri: (string) $etcdConfig['base_uri'], + headers: (array) ($etcdConfig['headers'] ?? []), + ); + + $resolver = new EtcdResolver( + client: $etcd, + prefix: (string) ($etcdConfig['services_prefix'] ?? '/fastd/services'), + cacheTtl: (int) ($etcdConfig['cache_ttl'] ?? 5), + ); + + $registry = new EtcdServiceRegistry( + client: $etcd, + prefix: (string) ($etcdConfig['services_prefix'] ?? '/fastd/services'), + ttl: (int) ($etcdConfig['lease_ttl'] ?? 15), + ); + + $container->add('integrations.etcd', $etcd); + $container->add('integrations.discovery.resolver', $resolver); + $container->add('integrations.discovery.registry', $registry); + + $container->add( + 'integrations.http.service_client', + $this->serviceClient($httpClient, $resolver, $config), + ); + + $container->add( + 'integrations.config.source', + new EtcdConfigSource( + $etcd, + (string) ($etcdConfig['config_prefix'] ?? '/fastd/config'), + ), + ); + + $this->registerTracing($container, $config); + $this->registerRateLimit($container, $config); + + if (isset($config['service']) && is_array($config['service']) && $config['service'] !== []) { + $service = $config['service']; + $container->add('integrations.service_instance', new ServiceInstance( + name: (string) ($service['name'] ?? ''), + host: (string) ($service['host'] ?? '127.0.0.1'), + port: (int) ($service['port'] ?? 0), + weight: (int) ($service['weight'] ?? 100), + metadata: (array) ($service['metadata'] ?? []), + )); + } + } + + /** + * @param array $config + */ + private function serviceClient( + ClientInterface $httpClient, + EtcdResolver $resolver, + array $config, + ): ClientInterface { + $balancer = match ((string) ($config['etcd']['load_balancer'] ?? 'round_robin')) { + 'random' => new RandomBalancer(), + 'weighted_random' => new WeightedRandomBalancer(), + default => new RoundRobinBalancer(), + }; + + $client = new ServiceClient($httpClient, $resolver, $balancer); + + $governance = (array) ($config['governance'] ?? []); + + if (!empty($governance['circuit_breaker']['enabled'])) { + $breaker = (array) $governance['circuit_breaker']; + $client = new CircuitBreakerClient($client, new CircuitBreaker( + failureThreshold: (int) ($breaker['failure_threshold'] ?? 5), + cooldownSeconds: (int) ($breaker['cooldown_seconds'] ?? 30), + )); + } + + if (!empty($governance['retry']['enabled'])) { + $retry = (array) $governance['retry']; + $client = new RetryClient($client, new RetryPolicy( + maxAttempts: (int) ($retry['max_attempts'] ?? 3), + baseDelayMs: (int) ($retry['base_delay_ms'] ?? 50), + maxDelayMs: (int) ($retry['max_delay_ms'] ?? 1000), + )); + } + + return $client; + } + + /** + * @param array $config + */ + private function registerTracing(Container $container, array $config): void + { + $tracing = (array) ($config['tracing'] ?? []); + + if (empty($tracing['enabled'])) { + return; + } + + $middleware = new TracingMiddleware((bool) ($tracing['trust_incoming'] ?? true)); + $container->add('integrations.tracing.middleware', $middleware); + + if ($container->has('matcher')) { + $container->get('matcher')->push($middleware); + } + } + + /** + * @param array $config + */ + private function registerRateLimit(Container $container, array $config): void + { + $governance = (array) ($config['governance'] ?? []); + $rateLimit = (array) ($governance['rate_limit'] ?? []); + + if (empty($rateLimit['enabled'])) { + return; + } + + $limiter = new InMemoryRateLimiter( + capacity: (int) ($rateLimit['capacity'] ?? 60), + refillPerSecond: (int) ($rateLimit['refill_per_second'] ?? 60), + ); + + $rateLimitMiddleware = new RateLimitMiddleware($limiter, new Factory()); + $container->add('integrations.governance.rate_limiter', $limiter); + $container->add('integrations.governance.rate_limit_middleware', $rateLimitMiddleware); + + if ($container->has('matcher')) { + $container->get('matcher')->push($rateLimitMiddleware); + } + } + + /** + * @return array + */ + private function config(Container $container): array + { + try { + if ($container->has('integrations.config')) { + $config = $container->get('integrations.config'); + + return is_array($config) ? $config : []; + } + } catch (NotFoundExceptionInterface|ContainerExceptionInterface) { + // 继续回退到配置文件。 + } + + $file = getcwd() . '/config/integrations.php'; + + if (is_file($file)) { + $config = require $file; + + return is_array($config) ? $config : []; + } + + return []; + } +} diff --git a/tests/ServiceProvider/IntegrationsServiceProviderTest.php b/tests/ServiceProvider/IntegrationsServiceProviderTest.php new file mode 100644 index 0000000..551131a --- /dev/null +++ b/tests/ServiceProvider/IntegrationsServiceProviderTest.php @@ -0,0 +1,88 @@ +add('integrations.config', [ + 'etcd' => [ + 'base_uri' => 'http://127.0.0.1:2379', + 'services_prefix' => '/fastd/services', + 'config_prefix' => '/fastd/config', + ], + 'tracing' => ['enabled' => false], + 'service' => [ + 'name' => 'user-service', + 'host' => '127.0.0.1', + 'port' => 9527, + ], + ]); + + (new IntegrationsServiceProvider())->register($container); + + self::assertTrue($container->has('integrations.etcd')); + self::assertTrue($container->has('integrations.discovery.resolver')); + self::assertTrue($container->has('integrations.discovery.registry')); + self::assertTrue($container->has('integrations.config.source')); + self::assertTrue($container->has('integrations.http.service_client')); + self::assertInstanceOf( + ClientInterface::class, + $container->get('integrations.http.service_client'), + ); + self::assertInstanceOf( + ServiceInstance::class, + $container->get('integrations.service_instance'), + ); + } + + public function testRegistersTracingAndRateLimitMiddlewareOnMatcher(): void + { + $matcher = new class { + /** @var object[] */ + public array $middleware = []; + + public function push(object $middleware): void + { + $this->middleware[] = $middleware; + } + }; + + $container = new Container(); + $container->add('integrations.config', [ + 'etcd' => ['base_uri' => 'http://127.0.0.1:2379'], + 'tracing' => ['enabled' => true], + 'governance' => [ + 'rate_limit' => ['enabled' => true, 'capacity' => 10, 'refill_per_second' => 10], + ], + ]); + $container->add('matcher', $matcher); + + (new IntegrationsServiceProvider())->register($container); + + self::assertTrue($container->has('integrations.tracing.middleware')); + self::assertTrue($container->has('integrations.governance.rate_limit_middleware')); + self::assertCount(2, $matcher->middleware); + } + + public function testSkipsWhenEtcdIsNotConfigured(): void + { + $container = new Container(); + $container->add('integrations.config', []); + + (new IntegrationsServiceProvider())->register($container); + + self::assertFalse($container->has('integrations.etcd')); + self::assertFalse($container->has('integrations.http.service_client')); + } +} From edd217154e0e91ab338a83cb9a978f3eef4ca732 Mon Sep 17 00:00:00 2001 From: JanHuang <384099566@qq.com> Date: Thu, 29 Oct 2026 16:30:54 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E8=A7=A3=E8=80=A6=20Provider=20?= =?UTF-8?q?=E7=9A=84=20tracing/rate=20limit=20=E8=A3=85=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tracing 与 rate limit 不再依赖 etcd 配置 - 补充 Provider 测试与示例配置说明 --- README.md | 4 +-- .../IntegrationsServiceProvider.php | 7 +++-- .../IntegrationsServiceProviderTest.php | 29 +++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8a9fca7..e9f6886 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ composer require fastd/integrations ### 1. 配置文件 -新增 `config/integrations.php`: +新增 `config/integrations.php`(完整示例见 `examples/config/integrations.php`): ```php config($container); + + $this->registerTracing($container, $config); + $this->registerRateLimit($container, $config); + $etcdConfig = (array) ($config['etcd'] ?? []); if ($etcdConfig === [] || empty($etcdConfig['base_uri'])) { @@ -88,9 +92,6 @@ public function register(Container $container): void ), ); - $this->registerTracing($container, $config); - $this->registerRateLimit($container, $config); - if (isset($config['service']) && is_array($config['service']) && $config['service'] !== []) { $service = $config['service']; $container->add('integrations.service_instance', new ServiceInstance( diff --git a/tests/ServiceProvider/IntegrationsServiceProviderTest.php b/tests/ServiceProvider/IntegrationsServiceProviderTest.php index 551131a..69ca8c1 100644 --- a/tests/ServiceProvider/IntegrationsServiceProviderTest.php +++ b/tests/ServiceProvider/IntegrationsServiceProviderTest.php @@ -75,6 +75,35 @@ public function push(object $middleware): void self::assertCount(2, $matcher->middleware); } + public function testTracingAndRateLimitDoNotRequireEtcd(): void + { + $matcher = new class { + /** @var object[] */ + public array $middleware = []; + + public function push(object $middleware): void + { + $this->middleware[] = $middleware; + } + }; + + $container = new Container(); + $container->add('integrations.config', [ + 'tracing' => ['enabled' => true], + 'governance' => [ + 'rate_limit' => ['enabled' => true], + ], + ]); + $container->add('matcher', $matcher); + + (new IntegrationsServiceProvider())->register($container); + + self::assertFalse($container->has('integrations.etcd')); + self::assertTrue($container->has('integrations.tracing.middleware')); + self::assertTrue($container->has('integrations.governance.rate_limit_middleware')); + self::assertCount(2, $matcher->middleware); + } + public function testSkipsWhenEtcdIsNotConfigured(): void { $container = new Container();