mirror of
https://devops.lemonos.cn/lawson/FendxPHP.git
synced 2026-06-15 23:12:49 +08:00
feat(database): 添加用户角色权限系统及相关监控功能
- 创建用户表(users)包含基本信息和认证字段 - 创建角色表(roles)用于权限控制 - 创建权限表(permissions)定义系统权限 - 创建用户角色关联表(user_roles)建立用户与角色关系 - 创建角色权限关联表(role_permissions)建立角色与权限关系 - 创建迁移记录表(migrations)追踪数据库变更 - 添加AdminController提供管理员面板功能 - 实现系统监控、配置管理、缓存清理等功能 - 添加AOP切面编程支持的各种通知类型 - 实现告警管理AlertManager支持多渠道告警 - 添加文档注解接口规范
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\CircuitBreaker;
|
||||
|
||||
use Fendx\Service\CircuitBreaker\State\CircuitState;
|
||||
use Fendx\Service\CircuitBreaker\State\ClosedState;
|
||||
use Fendx\Service\CircuitBreaker\State\OpenState;
|
||||
use Fendx\Service\CircuitBreaker\State\HalfOpenState;
|
||||
use Fendx\Service\CircuitBreaker\Storage\CircuitStorage;
|
||||
use Fendx\Service\CircuitBreaker\Monitor\CircuitMonitor;
|
||||
|
||||
class CircuitBreaker
|
||||
{
|
||||
protected string $name;
|
||||
protected array $config;
|
||||
protected CircuitState $state;
|
||||
protected CircuitStorage $storage;
|
||||
protected CircuitMonitor $monitor;
|
||||
protected array $statistics = [];
|
||||
protected float $lastStateChange = 0;
|
||||
protected int $failureCount = 0;
|
||||
protected int $successCount = 0;
|
||||
protected float $lastFailureTime = 0;
|
||||
|
||||
public function __construct(string $name, array $config = [])
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->storage = new CircuitStorage($this->config);
|
||||
$this->monitor = new CircuitMonitor($this->config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute function with circuit breaker protection.
|
||||
*/
|
||||
public function call(callable $function, ...$args)
|
||||
{
|
||||
if (!$this->canExecute()) {
|
||||
throw new CircuitBreakerOpenException("Circuit breaker '{$this->name}' is open");
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$result = $function(...$args);
|
||||
$duration = microtime(true) - $startTime;
|
||||
|
||||
$this->onSuccess($duration);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$duration = microtime(true) - $startTime;
|
||||
|
||||
$this->onFailure($e, $duration);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute function with fallback.
|
||||
*/
|
||||
public function callWithFallback(callable $function, callable $fallback, ...$args)
|
||||
{
|
||||
try {
|
||||
return $this->call($function, ...$args);
|
||||
} catch (CircuitBreakerOpenException $e) {
|
||||
return $fallback(...$args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit breaker allows execution.
|
||||
*/
|
||||
public function canExecute(): bool
|
||||
{
|
||||
return $this->state->canExecute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state.
|
||||
*/
|
||||
public function getState(): string
|
||||
{
|
||||
return $this->state->getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker name.
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force open circuit breaker.
|
||||
*/
|
||||
public function forceOpen(): void
|
||||
{
|
||||
$this->transitionTo(new OpenState($this->config));
|
||||
$this->logInfo("Circuit breaker '{$this->name}' forced open");
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close circuit breaker.
|
||||
*/
|
||||
public function forceClose(): void
|
||||
{
|
||||
$this->transitionTo(new ClosedState($this->config));
|
||||
$this->logInfo("Circuit breaker '{$this->name}' forced closed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset circuit breaker to initial state.
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->failureCount = 0;
|
||||
$this->successCount = 0;
|
||||
$this->lastFailureTime = 0;
|
||||
$this->statistics = [];
|
||||
|
||||
$this->transitionTo(new ClosedState($this->config));
|
||||
|
||||
$this->logInfo("Circuit breaker '{$this->name}' reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return array_merge($this->statistics, [
|
||||
'name' => $this->name,
|
||||
'state' => $this->getState(),
|
||||
'failure_count' => $this->failureCount,
|
||||
'success_count' => $this->successCount,
|
||||
'last_failure_time' => $this->lastFailureTime,
|
||||
'last_state_change' => $this->lastStateChange,
|
||||
'uptime_percentage' => $this->calculateUptimePercentage(),
|
||||
'average_response_time' => $this->calculateAverageResponseTime(),
|
||||
'error_rate' => $this->calculateErrorRate()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed status.
|
||||
*/
|
||||
public function getStatus(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'state' => $this->getState(),
|
||||
'config' => $this->config,
|
||||
'statistics' => $this->getStatistics(),
|
||||
'state_details' => $this->state->getDetails(),
|
||||
'monitoring' => $this->monitor->getStatus()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration.
|
||||
*/
|
||||
public function updateConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
$this->state->updateConfig($this->config);
|
||||
|
||||
$this->logInfo("Configuration updated for circuit breaker '{$this->name}'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable circuit breaker.
|
||||
*/
|
||||
public function setEnabled(bool $enabled): void
|
||||
{
|
||||
$this->config['enabled'] = $enabled;
|
||||
|
||||
if (!$enabled) {
|
||||
$this->forceClose();
|
||||
}
|
||||
|
||||
$this->logInfo("Circuit breaker '{$this->name}' " . ($enabled ? 'enabled' : 'disabled'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit breaker is enabled.
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->config['enabled'] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful execution.
|
||||
*/
|
||||
protected function onSuccess(float $duration): void
|
||||
{
|
||||
$this->successCount++;
|
||||
$this->recordExecution(true, $duration);
|
||||
|
||||
$this->state->onSuccess();
|
||||
|
||||
$this->monitor->recordSuccess($duration);
|
||||
|
||||
// Persist state
|
||||
$this->persistState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed execution.
|
||||
*/
|
||||
protected function onFailure(\Exception $exception, float $duration): void
|
||||
{
|
||||
$this->failureCount++;
|
||||
$this->lastFailureTime = microtime(true);
|
||||
|
||||
$this->recordExecution(false, $duration, $exception);
|
||||
|
||||
$this->state->onFailure();
|
||||
|
||||
$this->monitor->recordFailure($exception, $duration);
|
||||
|
||||
// Persist state
|
||||
$this->persistState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to new state.
|
||||
*/
|
||||
protected function transitionTo(CircuitState $newState): void
|
||||
{
|
||||
$previousState = $this->getState();
|
||||
|
||||
$this->state = $newState;
|
||||
$this->lastStateChange = microtime(true);
|
||||
|
||||
// Record state transition
|
||||
$this->recordStateTransition($previousState, $newState->getName());
|
||||
|
||||
// Notify monitor
|
||||
$this->monitor->recordStateTransition($previousState, $newState->getName());
|
||||
|
||||
$this->logInfo("State transition: {$previousState} -> {$newState->getName()}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record execution statistics.
|
||||
*/
|
||||
protected function recordExecution(bool $success, float $duration, \Exception $exception = null): void
|
||||
{
|
||||
$timestamp = microtime(true);
|
||||
|
||||
$this->statistics['executions'][] = [
|
||||
'timestamp' => $timestamp,
|
||||
'success' => $success,
|
||||
'duration' => $duration,
|
||||
'exception' => $exception ? [
|
||||
'class' => get_class($exception),
|
||||
'message' => $exception->getMessage()
|
||||
] : null
|
||||
];
|
||||
|
||||
// Limit execution history
|
||||
$maxHistory = $this->config['max_execution_history'] ?? 1000;
|
||||
if (count($this->statistics['executions']) > $maxHistory) {
|
||||
$this->statistics['executions'] = array_slice(
|
||||
$this->statistics['executions'],
|
||||
-$maxHistory
|
||||
);
|
||||
}
|
||||
|
||||
// Update aggregates
|
||||
$this->updateAggregates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record state transition.
|
||||
*/
|
||||
protected function recordStateTransition(string $from, string $to): void
|
||||
{
|
||||
if (!isset($this->statistics['state_transitions'])) {
|
||||
$this->statistics['state_transitions'] = [];
|
||||
}
|
||||
|
||||
$this->statistics['state_transitions'][] = [
|
||||
'timestamp' => microtime(true),
|
||||
'from' => $from,
|
||||
'to' => $to
|
||||
];
|
||||
|
||||
// Limit transition history
|
||||
$maxHistory = $this->config['max_transition_history'] ?? 100;
|
||||
if (count($this->statistics['state_transitions']) > $maxHistory) {
|
||||
$this->statistics['state_transitions'] = array_slice(
|
||||
$this->statistics['state_transitions'],
|
||||
-$maxHistory
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update aggregate statistics.
|
||||
*/
|
||||
protected function updateAggregates(): void
|
||||
{
|
||||
$executions = $this->statistics['executions'] ?? [];
|
||||
|
||||
if (empty($executions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recentExecutions = array_slice($executions, -$this->config['window_size']);
|
||||
|
||||
$totalDuration = 0;
|
||||
$successCount = 0;
|
||||
|
||||
foreach ($recentExecutions as $execution) {
|
||||
$totalDuration += $execution['duration'];
|
||||
if ($execution['success']) {
|
||||
$successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->statistics['recent_average_duration'] = $totalDuration / count($recentExecutions);
|
||||
$this->statistics['recent_success_rate'] = ($successCount / count($recentExecutions)) * 100;
|
||||
$this->statistics['recent_error_rate'] = 100 - $this->statistics['recent_success_rate'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate uptime percentage.
|
||||
*/
|
||||
protected function calculateUptimePercentage(): float
|
||||
{
|
||||
$executions = $this->statistics['executions'] ?? [];
|
||||
|
||||
if (empty($executions)) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
$successCount = 0;
|
||||
foreach ($executions as $execution) {
|
||||
if ($execution['success']) {
|
||||
$successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return ($successCount / count($executions)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average response time.
|
||||
*/
|
||||
protected function calculateAverageResponseTime(): float
|
||||
{
|
||||
$executions = $this->statistics['executions'] ?? [];
|
||||
|
||||
if (empty($executions)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$totalDuration = 0;
|
||||
foreach ($executions as $execution) {
|
||||
$totalDuration += $execution['duration'];
|
||||
}
|
||||
|
||||
return $totalDuration / count($executions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate error rate.
|
||||
*/
|
||||
protected function calculateErrorRate(): float
|
||||
{
|
||||
return 100.0 - $this->calculateUptimePercentage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist circuit breaker state.
|
||||
*/
|
||||
protected function persistState(): void
|
||||
{
|
||||
if (!$this->config['persist_state']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = [
|
||||
'name' => $this->name,
|
||||
'state' => $this->getState(),
|
||||
'failure_count' => $this->failureCount,
|
||||
'success_count' => $this->successCount,
|
||||
'last_failure_time' => $this->lastFailureTime,
|
||||
'last_state_change' => $this->lastStateChange,
|
||||
'statistics' => $this->statistics
|
||||
];
|
||||
|
||||
$this->storage->save($this->name, $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load circuit breaker state.
|
||||
*/
|
||||
protected function loadState(): void
|
||||
{
|
||||
if (!$this->config['persist_state']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = $this->storage->load($this->name);
|
||||
|
||||
if (!$state) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->failureCount = $state['failure_count'] ?? 0;
|
||||
$this->successCount = $state['success_count'] ?? 0;
|
||||
$this->lastFailureTime = $state['last_failure_time'] ?? 0;
|
||||
$this->lastStateChange = $state['last_state_change'] ?? 0;
|
||||
$this->statistics = $state['statistics'] ?? [];
|
||||
|
||||
// Restore state
|
||||
switch ($state['state']) {
|
||||
case 'open':
|
||||
$this->state = new OpenState($this->config);
|
||||
break;
|
||||
case 'half_open':
|
||||
$this->state = new HalfOpenState($this->config);
|
||||
break;
|
||||
default:
|
||||
$this->state = new ClosedState($this->config);
|
||||
break;
|
||||
}
|
||||
|
||||
$this->logInfo("State loaded for circuit breaker '{$this->name}': {$state['state']}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize circuit breaker.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Load persisted state
|
||||
$this->loadState();
|
||||
|
||||
// Initialize state if not loaded
|
||||
if (!isset($this->state)) {
|
||||
$this->state = new ClosedState($this->config);
|
||||
$this->lastStateChange = microtime(true);
|
||||
}
|
||||
|
||||
// Start monitoring
|
||||
$this->monitor->start($this->name);
|
||||
|
||||
$this->logInfo("Circuit breaker '{$this->name}' initialized in {$this->getState()} state");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[CircuitBreaker:{$this->name}] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message.
|
||||
*/
|
||||
protected function logWarning(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[CircuitBreaker:{$this->name}] WARNING: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'failure_threshold' => 5,
|
||||
'success_threshold' => 3,
|
||||
'timeout' => 60,
|
||||
'half_open_max_calls' => 3,
|
||||
'window_size' => 100,
|
||||
'enabled' => true,
|
||||
'persist_state' => true,
|
||||
'logging_enabled' => true,
|
||||
'max_execution_history' => 1000,
|
||||
'max_transition_history' => 100,
|
||||
'monitoring' => [
|
||||
'enabled' => true,
|
||||
'metrics_interval' => 60
|
||||
],
|
||||
'storage' => [
|
||||
'type' => 'file',
|
||||
'path' => __DIR__ . '/../../../storage/circuit_breaker'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create circuit breaker instance.
|
||||
*/
|
||||
public static function create(string $name, array $config = []): self
|
||||
{
|
||||
return new self($name, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for high availability.
|
||||
*/
|
||||
public static function forHighAvailability(string $name): self
|
||||
{
|
||||
return new self($name, [
|
||||
'failure_threshold' => 3,
|
||||
'success_threshold' => 2,
|
||||
'timeout' => 30,
|
||||
'half_open_max_calls' => 5,
|
||||
'window_size' => 50
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(string $name): self
|
||||
{
|
||||
return new self($name, [
|
||||
'failure_threshold' => 10,
|
||||
'success_threshold' => 5,
|
||||
'timeout' => 120,
|
||||
'persist_state' => false,
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for testing.
|
||||
*/
|
||||
public static function forTesting(string $name): self
|
||||
{
|
||||
return new self($name, [
|
||||
'failure_threshold' => 2,
|
||||
'success_threshold' => 1,
|
||||
'timeout' => 5,
|
||||
'persist_state' => false,
|
||||
'logging_enabled' => false
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker open exception.
|
||||
*/
|
||||
class CircuitBreakerOpenException extends \Exception
|
||||
{
|
||||
public function __construct(string $message = "Circuit breaker is open", int $code = 0, \Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\CircuitBreaker\Detector;
|
||||
|
||||
use Fendx\Service\CircuitBreaker\Detector\Strategy\ErrorRateStrategy;
|
||||
use Fendx\Service\CircuitBreaker\Detector\Strategy\ResponseTimeStrategy;
|
||||
use Fendx\Service\CircuitBreaker\Detector\Strategy\TimeoutStrategy;
|
||||
use Fendx\Service\CircuitBreaker\Detector\Strategy\CustomStrategy;
|
||||
|
||||
class FailureDetector
|
||||
{
|
||||
protected array $strategies = [];
|
||||
protected array $config = [];
|
||||
protected array $metrics = [];
|
||||
protected array $detectionHistory = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->initializeStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect failure based on metrics.
|
||||
*/
|
||||
public function detectFailure(array $metrics): array
|
||||
{
|
||||
$results = [];
|
||||
$overallFailure = false;
|
||||
$failureReasons = [];
|
||||
|
||||
foreach ($this->strategies as $name => $strategy) {
|
||||
$strategyResult = $strategy->detect($metrics);
|
||||
|
||||
$results[$name] = $strategyResult;
|
||||
|
||||
if ($strategyResult['failure']) {
|
||||
$overallFailure = true;
|
||||
$failureReasons[] = $strategyResult['reason'];
|
||||
}
|
||||
}
|
||||
|
||||
$detection = [
|
||||
'timestamp' => microtime(true),
|
||||
'failure' => $overallFailure,
|
||||
'reasons' => $failureReasons,
|
||||
'strategies' => $results,
|
||||
'metrics' => $metrics
|
||||
];
|
||||
|
||||
// Record detection
|
||||
$this->recordDetection($detection);
|
||||
|
||||
return $detection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom detection strategy.
|
||||
*/
|
||||
public function addStrategy(string $name, callable $detector): void
|
||||
{
|
||||
$this->strategies[$name] = new CustomStrategy($detector);
|
||||
|
||||
$this->logInfo("Added custom detection strategy: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove detection strategy.
|
||||
*/
|
||||
public function removeStrategy(string $name): bool
|
||||
{
|
||||
if (!isset($this->strategies[$name])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($this->strategies[$name]);
|
||||
|
||||
$this->logInfo("Removed detection strategy: {$name}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all strategies.
|
||||
*/
|
||||
public function getStrategies(): array
|
||||
{
|
||||
return array_keys($this->strategies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable strategy.
|
||||
*/
|
||||
public function setStrategyEnabled(string $name, bool $enabled): bool
|
||||
{
|
||||
if (!isset($this->strategies[$name])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->strategies[$name]->setEnabled($enabled);
|
||||
|
||||
$this->logInfo("Strategy '{$name}' " . ($enabled ? 'enabled' : 'disabled'));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update strategy configuration.
|
||||
*/
|
||||
public function updateStrategyConfig(string $name, array $config): bool
|
||||
{
|
||||
if (!isset($this->strategies[$name])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->strategies[$name]->updateConfig($config);
|
||||
|
||||
$this->logInfo("Configuration updated for strategy: {$name}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record metrics for analysis.
|
||||
*/
|
||||
public function recordMetrics(array $metrics): void
|
||||
{
|
||||
$timestamp = microtime(true);
|
||||
|
||||
$this->metrics[] = array_merge($metrics, [
|
||||
'timestamp' => $timestamp
|
||||
]);
|
||||
|
||||
// Limit metrics history
|
||||
$maxHistory = $this->config['max_metrics_history'] ?? 10000;
|
||||
if (count($this->metrics) > $maxHistory) {
|
||||
$this->metrics = array_slice($this->metrics, -$maxHistory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent metrics.
|
||||
*/
|
||||
public function getRecentMetrics(int $windowSize = null): array
|
||||
{
|
||||
$windowSize = $windowSize ?? $this->config['window_size'] ?? 100;
|
||||
|
||||
return array_slice($this->metrics, -$windowSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics in time range.
|
||||
*/
|
||||
public function getMetricsInRange(float $startTime, float $endTime): array
|
||||
{
|
||||
return array_filter($this->metrics, function($metric) use ($startTime, $endTime) {
|
||||
return $metric['timestamp'] >= $startTime && $metric['timestamp'] <= $endTime;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate aggregated metrics.
|
||||
*/
|
||||
public function calculateAggregates(array $metrics = null): array
|
||||
{
|
||||
$metrics = $metrics ?? $this->getRecentMetrics();
|
||||
|
||||
if (empty($metrics)) {
|
||||
return [
|
||||
'count' => 0,
|
||||
'success_rate' => 0,
|
||||
'error_rate' => 0,
|
||||
'average_response_time' => 0,
|
||||
'min_response_time' => 0,
|
||||
'max_response_time' => 0,
|
||||
'timeout_rate' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$totalCount = count($metrics);
|
||||
$successCount = 0;
|
||||
$errorCount = 0;
|
||||
$timeoutCount = 0;
|
||||
$totalResponseTime = 0;
|
||||
$minResponseTime = PHP_FLOAT_MAX;
|
||||
$maxResponseTime = 0;
|
||||
|
||||
foreach ($metrics as $metric) {
|
||||
$success = $metric['success'] ?? false;
|
||||
$responseTime = $metric['response_time'] ?? 0;
|
||||
$timeout = $metric['timeout'] ?? false;
|
||||
|
||||
if ($success) {
|
||||
$successCount++;
|
||||
} else {
|
||||
$errorCount++;
|
||||
}
|
||||
|
||||
if ($timeout) {
|
||||
$timeoutCount++;
|
||||
}
|
||||
|
||||
$totalResponseTime += $responseTime;
|
||||
$minResponseTime = min($minResponseTime, $responseTime);
|
||||
$maxResponseTime = max($maxResponseTime, $responseTime);
|
||||
}
|
||||
|
||||
return [
|
||||
'count' => $totalCount,
|
||||
'success_rate' => ($successCount / $totalCount) * 100,
|
||||
'error_rate' => ($errorCount / $totalCount) * 100,
|
||||
'timeout_rate' => ($timeoutCount / $totalCount) * 100,
|
||||
'average_response_time' => $totalResponseTime / $totalCount,
|
||||
'min_response_time' => $minResponseTime === PHP_FLOAT_MAX ? 0 : $minResponseTime,
|
||||
'max_response_time' => $maxResponseTime,
|
||||
'success_count' => $successCount,
|
||||
'error_count' => $errorCount,
|
||||
'timeout_count' => $timeoutCount
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detection statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$aggregates = $this->calculateAggregates();
|
||||
|
||||
$detectionStats = [
|
||||
'total_detections' => count($this->detectionHistory),
|
||||
'failure_detections' => 0,
|
||||
'strategy_performance' => []
|
||||
];
|
||||
|
||||
foreach ($this->detectionHistory as $detection) {
|
||||
if ($detection['failure']) {
|
||||
$detectionStats['failure_detections']++;
|
||||
}
|
||||
|
||||
foreach ($detection['strategies'] as $strategyName => $result) {
|
||||
if (!isset($detectionStats['strategy_performance'][$strategyName])) {
|
||||
$detectionStats['strategy_performance'][$strategyName] = [
|
||||
'detections' => 0,
|
||||
'failures' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$detectionStats['strategy_performance'][$strategyName]['detections']++;
|
||||
|
||||
if ($result['failure']) {
|
||||
$detectionStats['strategy_performance'][$strategyName]['failures']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate strategy effectiveness
|
||||
foreach ($detectionStats['strategy_performance'] as $strategyName => &$stats) {
|
||||
$stats['failure_rate'] = $stats['detections'] > 0 ?
|
||||
($stats['failures'] / $stats['detections']) * 100 : 0;
|
||||
}
|
||||
|
||||
return array_merge($aggregates, $detectionStats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detection history.
|
||||
*/
|
||||
public function getDetectionHistory(int $limit = 100): array
|
||||
{
|
||||
return array_slice($this->detectionHistory, -$limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze failure patterns.
|
||||
*/
|
||||
public function analyzeFailurePatterns(): array
|
||||
{
|
||||
$patterns = [];
|
||||
|
||||
// Time-based patterns
|
||||
$hourlyFailures = [];
|
||||
$dailyFailures = [];
|
||||
|
||||
foreach ($this->detectionHistory as $detection) {
|
||||
if (!$detection['failure']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$timestamp = $detection['timestamp'];
|
||||
$hour = date('H', (int) $timestamp);
|
||||
$day = date('Y-m-d', (int) $timestamp);
|
||||
|
||||
$hourlyFailures[$hour] = ($hourlyFailures[$hour] ?? 0) + 1;
|
||||
$dailyFailures[$day] = ($dailyFailures[$day] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Strategy correlation
|
||||
$strategyCorrelations = [];
|
||||
|
||||
foreach ($this->detectionHistory as $detection) {
|
||||
foreach ($detection['strategies'] as $strategyName => $result) {
|
||||
if (!isset($strategyCorrelations[$strategyName])) {
|
||||
$strategyCorrelations[$strategyName] = [
|
||||
'total' => 0,
|
||||
'failures' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$strategyCorrelations[$strategyName]['total']++;
|
||||
|
||||
if ($result['failure']) {
|
||||
$strategyCorrelations[$strategyName]['failures']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate correlation rates
|
||||
foreach ($strategyCorrelations as $strategyName => &$correlation) {
|
||||
$correlation['correlation_rate'] = $correlation['total'] > 0 ?
|
||||
($correlation['failures'] / $correlation['total']) * 100 : 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'hourly_pattern' => $hourlyFailures,
|
||||
'daily_pattern' => $dailyFailures,
|
||||
'strategy_correlations' => $strategyCorrelations,
|
||||
'peak_failure_hour' => $this->findPeakHour($hourlyFailures),
|
||||
'most_correlated_strategy' => $this->findMostCorrelatedStrategy($strategyCorrelations)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict failure probability.
|
||||
*/
|
||||
public function predictFailureProbability(): float
|
||||
{
|
||||
$recentMetrics = $this->getRecentMetrics($this->config['prediction_window'] ?? 50);
|
||||
|
||||
if (count($recentMetrics) < $this->config['min_prediction_samples'] ?? 10) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$aggregates = $this->calculateAggregates($recentMetrics);
|
||||
|
||||
// Simple probability calculation based on recent trends
|
||||
$errorRate = $aggregates['error_rate'];
|
||||
$timeoutRate = $aggregates['timeout_rate'];
|
||||
|
||||
// Weight factors
|
||||
$errorWeight = $this->config['error_rate_weight'] ?? 0.6;
|
||||
$timeoutWeight = $this->config['timeout_rate_weight'] ?? 0.4;
|
||||
|
||||
$probability = ($errorRate * $errorWeight) + ($timeoutRate * $timeoutWeight);
|
||||
|
||||
// Apply trend analysis
|
||||
$trendFactor = $this->calculateTrendFactor($recentMetrics);
|
||||
$probability *= $trendFactor;
|
||||
|
||||
return min(100.0, max(0.0, $probability));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health score.
|
||||
*/
|
||||
public function getHealthScore(): float
|
||||
{
|
||||
$failureProbability = $this->predictFailureProbability();
|
||||
|
||||
// Convert failure probability to health score (0-100)
|
||||
$healthScore = 100.0 - $failureProbability;
|
||||
|
||||
// Apply stability factor
|
||||
$stabilityFactor = $this->calculateStabilityFactor();
|
||||
$healthScore *= $stabilityFactor;
|
||||
|
||||
return min(100.0, max(0.0, $healthScore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset detector state.
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->metrics = [];
|
||||
$this->detectionHistory = [];
|
||||
|
||||
$this->logInfo("Failure detector reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Export detector configuration and data.
|
||||
*/
|
||||
public function exportData(): array
|
||||
{
|
||||
return [
|
||||
'config' => $this->config,
|
||||
'strategies' => $this->getStrategyConfigs(),
|
||||
'metrics' => $this->metrics,
|
||||
'detection_history' => $this->detectionHistory,
|
||||
'statistics' => $this->getStatistics(),
|
||||
'exported_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record detection result.
|
||||
*/
|
||||
protected function recordDetection(array $detection): void
|
||||
{
|
||||
$this->detectionHistory[] = $detection;
|
||||
|
||||
// Limit history size
|
||||
$maxHistory = $this->config['max_detection_history'] ?? 1000;
|
||||
if (count($this->detectionHistory) > $maxHistory) {
|
||||
$this->detectionHistory = array_slice($this->detectionHistory, -$maxHistory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize built-in strategies.
|
||||
*/
|
||||
protected function initializeStrategies(): void
|
||||
{
|
||||
if ($this->config['strategies']['error_rate']['enabled'] ?? true) {
|
||||
$this->strategies['error_rate'] = new ErrorRateStrategy(
|
||||
$this->config['strategies']['error_rate'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->config['strategies']['response_time']['enabled'] ?? true) {
|
||||
$this->strategies['response_time'] = new ResponseTimeStrategy(
|
||||
$this->config['strategies']['response_time'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->config['strategies']['timeout']['enabled'] ?? true) {
|
||||
$this->strategies['timeout'] = new TimeoutStrategy(
|
||||
$this->config['strategies']['timeout'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strategy configurations.
|
||||
*/
|
||||
protected function getStrategyConfigs(): array
|
||||
{
|
||||
$configs = [];
|
||||
|
||||
foreach ($this->strategies as $name => $strategy) {
|
||||
$configs[$name] = $strategy->getConfig();
|
||||
}
|
||||
|
||||
return $configs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find peak failure hour.
|
||||
*/
|
||||
protected function findPeakHour(array $hourlyFailures): ?int
|
||||
{
|
||||
if (empty($hourlyFailures)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_keys($hourlyFailures, max($hourlyFailures))[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find most correlated strategy.
|
||||
*/
|
||||
protected function findMostCorrelatedStrategy(array $correlations): ?string
|
||||
{
|
||||
if (empty($correlations)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$maxCorrelation = 0;
|
||||
$mostCorrelated = null;
|
||||
|
||||
foreach ($correlations as $strategy => $data) {
|
||||
if ($data['correlation_rate'] > $maxCorrelation) {
|
||||
$maxCorrelation = $data['correlation_rate'];
|
||||
$mostCorrelated = $strategy;
|
||||
}
|
||||
}
|
||||
|
||||
return $mostCorrelated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate trend factor.
|
||||
*/
|
||||
protected function calculateTrendFactor(array $recentMetrics): float
|
||||
{
|
||||
if (count($recentMetrics) < 10) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Split into two halves
|
||||
$mid = count($recentMetrics) / 2;
|
||||
$firstHalf = array_slice($recentMetrics, 0, $mid);
|
||||
$secondHalf = array_slice($recentMetrics, $mid);
|
||||
|
||||
$firstHalfAggregates = $this->calculateAggregates($firstHalf);
|
||||
$secondHalfAggregates = $this->calculateAggregates($secondHalf);
|
||||
|
||||
// Compare error rates
|
||||
$errorRateChange = $secondHalfAggregates['error_rate'] - $firstHalfAggregates['error_rate'];
|
||||
|
||||
// Convert to factor (1.0 = no change, >1.0 = worsening, <1.0 = improving)
|
||||
$factor = 1.0 + ($errorRateChange / 100.0);
|
||||
|
||||
return max(0.5, min(2.0, $factor));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stability factor.
|
||||
*/
|
||||
protected function calculateStabilityFactor(): float
|
||||
{
|
||||
if (count($this->detectionHistory) < 10) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$recentDetections = array_slice($this->detectionHistory, -20);
|
||||
$fluctuations = 0;
|
||||
|
||||
for ($i = 1; $i < count($recentDetections); $i++) {
|
||||
$current = $recentDetections[$i]['failure'];
|
||||
$previous = $recentDetections[$i - 1]['failure'];
|
||||
|
||||
if ($current !== $previous) {
|
||||
$fluctuations++;
|
||||
}
|
||||
}
|
||||
|
||||
$fluctuationRate = $fluctuations / (count($recentDetections) - 1);
|
||||
|
||||
// Convert fluctuation rate to stability factor
|
||||
return max(0.7, 1.0 - ($fluctuationRate * 0.5));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[FailureDetector] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'window_size' => 100,
|
||||
'prediction_window' => 50,
|
||||
'min_prediction_samples' => 10,
|
||||
'max_metrics_history' => 10000,
|
||||
'max_detection_history' => 1000,
|
||||
'error_rate_weight' => 0.6,
|
||||
'timeout_rate_weight' => 0.4,
|
||||
'logging_enabled' => true,
|
||||
'strategies' => [
|
||||
'error_rate' => [
|
||||
'enabled' => true,
|
||||
'threshold' => 50.0,
|
||||
'window_size' => 20
|
||||
],
|
||||
'response_time' => [
|
||||
'enabled' => true,
|
||||
'threshold' => 5000.0,
|
||||
'window_size' => 10
|
||||
],
|
||||
'timeout' => [
|
||||
'enabled' => true,
|
||||
'threshold' => 10.0,
|
||||
'window_size' => 30
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
|
||||
// Reinitialize strategies
|
||||
$this->initializeStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create failure detector instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'window_size' => 200,
|
||||
'prediction_window' => 100,
|
||||
'min_prediction_samples' => 20,
|
||||
'strategies' => [
|
||||
'error_rate' => [
|
||||
'threshold' => 30.0,
|
||||
'window_size' => 50
|
||||
],
|
||||
'response_time' => [
|
||||
'threshold' => 2000.0,
|
||||
'window_size' => 25
|
||||
],
|
||||
'timeout' => [
|
||||
'threshold' => 5.0,
|
||||
'window_size' => 40
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'window_size' => 50,
|
||||
'prediction_window' => 25,
|
||||
'min_prediction_samples' => 5,
|
||||
'logging_enabled' => true,
|
||||
'strategies' => [
|
||||
'error_rate' => [
|
||||
'threshold' => 70.0
|
||||
],
|
||||
'response_time' => [
|
||||
'threshold' => 10000.0
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\CircuitBreaker\Monitor;
|
||||
|
||||
use Fendx\Service\CircuitBreaker\Monitor\Collector\MetricsCollector;
|
||||
use Fendx\Service\CircuitBreaker\Monitor\Analyzer\StateAnalyzer;
|
||||
use Fendx\Service\CircuitBreaker\Monitor\Alert\AlertManager;
|
||||
|
||||
class CircuitMonitor
|
||||
{
|
||||
protected array $config = [];
|
||||
protected MetricsCollector $collector;
|
||||
protected StateAnalyzer $analyzer;
|
||||
protected AlertManager $alertManager;
|
||||
protected array $circuitStates = [];
|
||||
protected array $monitoringData = [];
|
||||
protected array $alerts = [];
|
||||
protected bool $isRunning = false;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->collector = new MetricsCollector($this->config);
|
||||
$this->analyzer = new StateAnalyzer($this->config);
|
||||
$this->alertManager = new AlertManager($this->config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring for a circuit breaker.
|
||||
*/
|
||||
public function start(string $circuitBreakerName): void
|
||||
{
|
||||
$this->circuitStates[$circuitBreakerName] = [
|
||||
'name' => $circuitBreakerName,
|
||||
'started_at' => microtime(true),
|
||||
'last_update' => microtime(true),
|
||||
'state_transitions' => [],
|
||||
'metrics' => [],
|
||||
'alerts' => []
|
||||
];
|
||||
|
||||
if (!$this->isRunning) {
|
||||
$this->isRunning = true;
|
||||
$this->startMonitoringLoop();
|
||||
}
|
||||
|
||||
$this->logInfo("Started monitoring for circuit breaker: {$circuitBreakerName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring for a circuit breaker.
|
||||
*/
|
||||
public function stop(string $circuitBreakerName): void
|
||||
{
|
||||
if (!isset($this->circuitStates[$circuitBreakerName])) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->circuitStates[$circuitBreakerName]);
|
||||
|
||||
if (empty($this->circuitStates)) {
|
||||
$this->isRunning = false;
|
||||
}
|
||||
|
||||
$this->logInfo("Stopped monitoring for circuit breaker: {$circuitBreakerName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record state transition.
|
||||
*/
|
||||
public function recordStateTransition(string $circuitBreakerName, string $fromState, string $toState): void
|
||||
{
|
||||
if (!isset($this->circuitStates[$circuitBreakerName])) {
|
||||
$this->start($circuitBreakerName);
|
||||
}
|
||||
|
||||
$transition = [
|
||||
'timestamp' => microtime(true),
|
||||
'from' => $fromState,
|
||||
'to' => $toState,
|
||||
'duration' => 0
|
||||
];
|
||||
|
||||
$this->circuitStates[$circuitBreakerName]['state_transitions'][] = $transition;
|
||||
$this->circuitStates[$circuitBreakerName]['last_update'] = microtime(true);
|
||||
|
||||
// Analyze transition
|
||||
$this->analyzeTransition($circuitBreakerName, $transition);
|
||||
|
||||
// Check for alerts
|
||||
$this->checkTransitionAlerts($circuitBreakerName, $transition);
|
||||
|
||||
$this->logInfo("State transition recorded for {$circuitBreakerName}: {$fromState} -> {$toState}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record successful execution.
|
||||
*/
|
||||
public function recordSuccess(string $circuitBreakerName, float $duration): void
|
||||
{
|
||||
if (!isset($this->circuitStates[$circuitBreakerName])) {
|
||||
$this->start($circuitBreakerName);
|
||||
}
|
||||
|
||||
$metric = [
|
||||
'timestamp' => microtime(true),
|
||||
'type' => 'success',
|
||||
'duration' => $duration
|
||||
];
|
||||
|
||||
$this->circuitStates[$circuitBreakerName]['metrics'][] = $metric;
|
||||
$this->circuitStates[$circuitBreakerName]['last_update'] = microtime(true);
|
||||
|
||||
// Collect metrics
|
||||
$this->collector->record($circuitBreakerName, $metric);
|
||||
|
||||
// Check performance alerts
|
||||
$this->checkPerformanceAlerts($circuitBreakerName, $metric);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record failed execution.
|
||||
*/
|
||||
public function recordFailure(string $circuitBreakerName, \Exception $exception, float $duration): void
|
||||
{
|
||||
if (!isset($this->circuitStates[$circuitBreakerName])) {
|
||||
$this->start($circuitBreakerName);
|
||||
}
|
||||
|
||||
$metric = [
|
||||
'timestamp' => microtime(true),
|
||||
'type' => 'failure',
|
||||
'duration' => $duration,
|
||||
'exception' => [
|
||||
'class' => get_class($exception),
|
||||
'message' => $exception->getMessage(),
|
||||
'code' => $exception->getCode()
|
||||
]
|
||||
];
|
||||
|
||||
$this->circuitStates[$circuitBreakerName]['metrics'][] = $metric;
|
||||
$this->circuitStates[$circuitBreakerName]['last_update'] = microtime(true);
|
||||
|
||||
// Collect metrics
|
||||
$this->collector->record($circuitBreakerName, $metric);
|
||||
|
||||
// Check failure alerts
|
||||
$this->checkFailureAlerts($circuitBreakerName, $metric);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monitoring status.
|
||||
*/
|
||||
public function getStatus(): array
|
||||
{
|
||||
return [
|
||||
'is_running' => $this->isRunning,
|
||||
'monitored_circuits' => count($this->circuitStates),
|
||||
'circuit_names' => array_keys($this->circuitStates),
|
||||
'total_metrics' => $this->getTotalMetricsCount(),
|
||||
'total_transitions' => $this->getTotalTransitionsCount(),
|
||||
'active_alerts' => count($this->alerts)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker monitoring data.
|
||||
*/
|
||||
public function getCircuitData(string $circuitBreakerName): ?array
|
||||
{
|
||||
if (!isset($this->circuitStates[$circuitBreakerName])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$circuitData = $this->circuitStates[$circuitBreakerName];
|
||||
|
||||
// Add calculated metrics
|
||||
$circuitData['calculated_metrics'] = $this->calculateCircuitMetrics($circuitBreakerName);
|
||||
|
||||
// Add analysis results
|
||||
$circuitData['analysis'] = $this->analyzer->analyze($circuitBreakerName, $circuitData);
|
||||
|
||||
return $circuitData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all circuit breaker data.
|
||||
*/
|
||||
public function getAllCircuitData(): array
|
||||
{
|
||||
$allData = [];
|
||||
|
||||
foreach (array_keys($this->circuitStates) as $circuitName) {
|
||||
$allData[$circuitName] = $this->getCircuitData($circuitName);
|
||||
}
|
||||
|
||||
return $allData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monitoring dashboard data.
|
||||
*/
|
||||
public function getDashboardData(): array
|
||||
{
|
||||
$dashboard = [
|
||||
'overview' => [
|
||||
'total_circuits' => count($this->circuitStates),
|
||||
'healthy_circuits' => 0,
|
||||
'degraded_circuits' => 0,
|
||||
'failed_circuits' => 0,
|
||||
'total_requests' => 0,
|
||||
'success_rate' => 0,
|
||||
'average_response_time' => 0
|
||||
],
|
||||
'circuits' => [],
|
||||
'alerts' => $this->getRecentAlerts(10),
|
||||
'trends' => $this->getTrends()
|
||||
];
|
||||
|
||||
foreach ($this->circuitStates as $circuitName => $circuitData) {
|
||||
$metrics = $this->calculateCircuitMetrics($circuitName);
|
||||
$analysis = $this->analyzer->analyze($circuitName, $circuitData);
|
||||
|
||||
$circuitInfo = [
|
||||
'name' => $circuitName,
|
||||
'status' => $analysis['health_status'],
|
||||
'success_rate' => $metrics['success_rate'],
|
||||
'average_response_time' => $metrics['average_response_time'],
|
||||
'last_state_change' => $this->getLastStateChange($circuitName),
|
||||
'uptime_percentage' => $metrics['uptime_percentage'],
|
||||
'error_rate' => $metrics['error_rate']
|
||||
];
|
||||
|
||||
$dashboard['circuits'][] = $circuitInfo;
|
||||
|
||||
// Update overview
|
||||
switch ($analysis['health_status']) {
|
||||
case 'healthy':
|
||||
$dashboard['overview']['healthy_circuits']++;
|
||||
break;
|
||||
case 'degraded':
|
||||
$dashboard['overview']['degraded_circuits']++;
|
||||
break;
|
||||
case 'failed':
|
||||
$dashboard['overview']['failed_circuits']++;
|
||||
break;
|
||||
}
|
||||
|
||||
$dashboard['overview']['total_requests'] += $metrics['total_requests'];
|
||||
}
|
||||
|
||||
// Calculate overview averages
|
||||
if (count($this->circuitStates) > 0) {
|
||||
$totalSuccessRate = 0;
|
||||
$totalResponseTime = 0;
|
||||
|
||||
foreach ($dashboard['circuits'] as $circuit) {
|
||||
$totalSuccessRate += $circuit['success_rate'];
|
||||
$totalResponseTime += $circuit['average_response_time'];
|
||||
}
|
||||
|
||||
$dashboard['overview']['success_rate'] = $totalSuccessRate / count($dashboard['circuits']);
|
||||
$dashboard['overview']['average_response_time'] = $totalResponseTime / count($dashboard['circuits']);
|
||||
}
|
||||
|
||||
return $dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent alerts.
|
||||
*/
|
||||
public function getRecentAlerts(int $limit = 50): array
|
||||
{
|
||||
$alerts = $this->alertManager->getRecentAlerts($limit);
|
||||
|
||||
// Add circuit-specific alerts
|
||||
foreach ($this->circuitStates as $circuitName => $circuitData) {
|
||||
$circuitAlerts = $circuitData['alerts'] ?? [];
|
||||
foreach ($circuitAlerts as $alert) {
|
||||
$alerts[] = array_merge($alert, ['circuit' => $circuitName]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
usort($alerts, function($a, $b) {
|
||||
return $b['timestamp'] <=> $a['timestamp'];
|
||||
});
|
||||
|
||||
return array_slice($alerts, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trends data.
|
||||
*/
|
||||
public function getTrends(): array
|
||||
{
|
||||
$trends = [
|
||||
'success_rate_trend' => [],
|
||||
'response_time_trend' => [],
|
||||
'error_rate_trend' => [],
|
||||
'state_change_frequency' => []
|
||||
];
|
||||
|
||||
foreach ($this->circuitStates as $circuitName => $circuitData) {
|
||||
$circuitTrends = $this->calculateCircuitTrends($circuitName);
|
||||
|
||||
foreach ($trends as $trendType => &$trendData) {
|
||||
if (isset($circuitTrends[$trendType])) {
|
||||
$trendData[$circuitName] = $circuitTrends[$trendType];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $trends;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance report.
|
||||
*/
|
||||
public function getPerformanceReport(string $circuitBreakerName = null): array
|
||||
{
|
||||
if ($circuitBreakerName) {
|
||||
return $this->generateCircuitReport($circuitBreakerName);
|
||||
}
|
||||
|
||||
$report = [
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'period' => 'last_24_hours',
|
||||
'summary' => $this->generateSummaryReport(),
|
||||
'circuits' => []
|
||||
];
|
||||
|
||||
foreach (array_keys($this->circuitStates) as $circuitName) {
|
||||
$report['circuits'][$circuitName] = $this->generateCircuitReport($circuitName);
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set alert threshold.
|
||||
*/
|
||||
public function setAlertThreshold(string $metric, float $value): void
|
||||
{
|
||||
$this->alertManager->setThreshold($metric, $value);
|
||||
|
||||
$this->logInfo("Alert threshold set for {$metric}: {$value}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable alert.
|
||||
*/
|
||||
public function setAlertEnabled(string $alertType, bool $enabled): void
|
||||
{
|
||||
$this->alertManager->setEnabled($alertType, $enabled);
|
||||
|
||||
$this->logInfo("Alert '{$alertType}' " . ($enabled ? 'enabled' : 'disabled'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear monitoring data.
|
||||
*/
|
||||
public function clearData(string $circuitBreakerName = null): void
|
||||
{
|
||||
if ($circuitBreakerName) {
|
||||
if (isset($this->circuitStates[$circuitBreakerName])) {
|
||||
$this->circuitStates[$circuitBreakerName]['metrics'] = [];
|
||||
$this->circuitStates[$circuitBreakerName]['state_transitions'] = [];
|
||||
$this->circuitStates[$circuitBreakerName]['alerts'] = [];
|
||||
}
|
||||
} else {
|
||||
foreach ($this->circuitStates as $circuitName => &$circuitData) {
|
||||
$circuitData['metrics'] = [];
|
||||
$circuitData['state_transitions'] = [];
|
||||
$circuitData['alerts'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Monitoring data cleared" . ($circuitBreakerName ? " for {$circuitBreakerName}" : ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export monitoring data.
|
||||
*/
|
||||
public function exportData(string $format = 'json'): string
|
||||
{
|
||||
$data = [
|
||||
'config' => $this->config,
|
||||
'circuit_states' => $this->circuitStates,
|
||||
'alerts' => $this->alerts,
|
||||
'dashboard' => $this->getDashboardData(),
|
||||
'exported_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
case 'php':
|
||||
return '<?php return ' . var_export($data, true) . ';';
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported export format: {$format}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate circuit metrics.
|
||||
*/
|
||||
protected function calculateCircuitMetrics(string $circuitBreakerName): array
|
||||
{
|
||||
if (!isset($this->circuitStates[$circuitBreakerName])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$metrics = $this->circuitStates[$circuitBreakerName]['metrics'];
|
||||
|
||||
if (empty($metrics)) {
|
||||
return [
|
||||
'total_requests' => 0,
|
||||
'success_count' => 0,
|
||||
'failure_count' => 0,
|
||||
'success_rate' => 100,
|
||||
'error_rate' => 0,
|
||||
'average_response_time' => 0,
|
||||
'min_response_time' => 0,
|
||||
'max_response_time' => 0,
|
||||
'uptime_percentage' => 100
|
||||
];
|
||||
}
|
||||
|
||||
$totalRequests = count($metrics);
|
||||
$successCount = 0;
|
||||
$failureCount = 0;
|
||||
$totalResponseTime = 0;
|
||||
$minResponseTime = PHP_FLOAT_MAX;
|
||||
$maxResponseTime = 0;
|
||||
|
||||
foreach ($metrics as $metric) {
|
||||
$responseTime = $metric['duration'];
|
||||
|
||||
if ($metric['type'] === 'success') {
|
||||
$successCount++;
|
||||
} else {
|
||||
$failureCount++;
|
||||
}
|
||||
|
||||
$totalResponseTime += $responseTime;
|
||||
$minResponseTime = min($minResponseTime, $responseTime);
|
||||
$maxResponseTime = max($maxResponseTime, $responseTime);
|
||||
}
|
||||
|
||||
return [
|
||||
'total_requests' => $totalRequests,
|
||||
'success_count' => $successCount,
|
||||
'failure_count' => $failureCount,
|
||||
'success_rate' => ($successCount / $totalRequests) * 100,
|
||||
'error_rate' => ($failureCount / $totalRequests) * 100,
|
||||
'average_response_time' => $totalResponseTime / $totalRequests,
|
||||
'min_response_time' => $minResponseTime === PHP_FLOAT_MAX ? 0 : $minResponseTime,
|
||||
'max_response_time' => $maxResponseTime,
|
||||
'uptime_percentage' => ($successCount / $totalRequests) * 100
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate circuit trends.
|
||||
*/
|
||||
protected function calculateCircuitTrends(string $circuitBreakerName): array
|
||||
{
|
||||
if (!isset($this->circuitStates[$circuitBreakerName])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$metrics = $this->circuitStates[$circuitBreakerName]['metrics'];
|
||||
|
||||
// Group metrics by time windows (e.g., hourly)
|
||||
$timeWindows = [];
|
||||
$windowSize = 3600; // 1 hour
|
||||
|
||||
foreach ($metrics as $metric) {
|
||||
$window = floor($metric['timestamp'] / $windowSize) * $windowSize;
|
||||
|
||||
if (!isset($timeWindows[$window])) {
|
||||
$timeWindows[$window] = [];
|
||||
}
|
||||
|
||||
$timeWindows[$window][] = $metric;
|
||||
}
|
||||
|
||||
// Calculate trends for each window
|
||||
$trends = [
|
||||
'success_rate_trend' => [],
|
||||
'response_time_trend' => [],
|
||||
'error_rate_trend' => []
|
||||
];
|
||||
|
||||
ksort($timeWindows);
|
||||
|
||||
foreach ($timeWindows as $window => $windowMetrics) {
|
||||
$successCount = 0;
|
||||
$totalResponseTime = 0;
|
||||
|
||||
foreach ($windowMetrics as $metric) {
|
||||
if ($metric['type'] === 'success') {
|
||||
$successCount++;
|
||||
}
|
||||
$totalResponseTime += $metric['duration'];
|
||||
}
|
||||
|
||||
$totalCount = count($windowMetrics);
|
||||
$successRate = ($successCount / $totalCount) * 100;
|
||||
$averageResponseTime = $totalResponseTime / $totalCount;
|
||||
$errorRate = 100 - $successRate;
|
||||
|
||||
$trends['success_rate_trend'][] = [
|
||||
'timestamp' => $window,
|
||||
'value' => $successRate
|
||||
];
|
||||
|
||||
$trends['response_time_trend'][] = [
|
||||
'timestamp' => $window,
|
||||
'value' => $averageResponseTime
|
||||
];
|
||||
|
||||
$trends['error_rate_trend'][] = [
|
||||
'timestamp' => $window,
|
||||
'value' => $errorRate
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate state change frequency
|
||||
$transitions = $this->circuitStates[$circuitBreakerName]['state_transitions'];
|
||||
$trends['state_change_frequency'] = count($transitions);
|
||||
|
||||
return $trends;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze state transition.
|
||||
*/
|
||||
protected function analyzeTransition(string $circuitBreakerName, array $transition): void
|
||||
{
|
||||
$analysis = [
|
||||
'circuit' => $circuitBreakerName,
|
||||
'transition' => $transition,
|
||||
'analysis' => []
|
||||
];
|
||||
|
||||
// Analyze transition patterns
|
||||
$transitions = $this->circuitStates[$circuitBreakerName]['state_transitions'];
|
||||
|
||||
if (count($transitions) > 1) {
|
||||
$previousTransition = $transitions[count($transitions) - 2];
|
||||
$timeSincePrevious = $transition['timestamp'] - $previousTransition['timestamp'];
|
||||
|
||||
$analysis['analysis']['time_since_previous'] = $timeSincePrevious;
|
||||
|
||||
// Check for rapid transitions
|
||||
if ($timeSincePrevious < 60) { // Less than 1 minute
|
||||
$analysis['analysis']['rapid_transition'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Store analysis
|
||||
$this->monitoringData[] = $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check transition alerts.
|
||||
*/
|
||||
protected function checkTransitionAlerts(string $circuitBreakerName, array $transition): void
|
||||
{
|
||||
// Check for frequent state changes
|
||||
$transitions = $this->circuitStates[$circuitBreakerName]['state_transitions'];
|
||||
|
||||
if (count($transitions) >= 10) { // 10 transitions in monitoring period
|
||||
$this->createAlert($circuitBreakerName, 'frequent_state_changes', [
|
||||
'transition_count' => count($transitions),
|
||||
'latest_transition' => $transition
|
||||
]);
|
||||
}
|
||||
|
||||
// Check for unwanted transitions (e.g., closed -> open)
|
||||
if ($transition['from'] === 'closed' && $transition['to'] === 'open') {
|
||||
$this->createAlert($circuitBreakerName, 'circuit_opened', [
|
||||
'transition' => $transition
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check performance alerts.
|
||||
*/
|
||||
protected function checkPerformanceAlerts(string $circuitBreakerName, array $metric): void
|
||||
{
|
||||
// Check response time threshold
|
||||
$responseTimeThreshold = $this->config['alerts']['response_time_threshold'] ?? 5000; // 5 seconds
|
||||
|
||||
if ($metric['duration'] > $responseTimeThreshold) {
|
||||
$this->createAlert($circuitBreakerName, 'slow_response', [
|
||||
'duration' => $metric['duration'],
|
||||
'threshold' => $responseTimeThreshold
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check failure alerts.
|
||||
*/
|
||||
protected function checkFailureAlerts(string $circuitBreakerName, array $metric): void
|
||||
{
|
||||
// Check error rate threshold
|
||||
$metrics = $this->circuitStates[$circuitBreakerName]['metrics'];
|
||||
$recentMetrics = array_slice($metrics, -100); // Last 100 requests
|
||||
|
||||
if (count($recentMetrics) >= 10) {
|
||||
$failureCount = 0;
|
||||
foreach ($recentMetrics as $recentMetric) {
|
||||
if ($recentMetric['type'] === 'failure') {
|
||||
$failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$errorRate = ($failureCount / count($recentMetrics)) * 100;
|
||||
$errorRateThreshold = $this->config['alerts']['error_rate_threshold'] ?? 50; // 50%
|
||||
|
||||
if ($errorRate > $errorRateThreshold) {
|
||||
$this->createAlert($circuitBreakerName, 'high_error_rate', [
|
||||
'error_rate' => $errorRate,
|
||||
'threshold' => $errorRateThreshold,
|
||||
'sample_size' => count($recentMetrics)
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create alert.
|
||||
*/
|
||||
protected function createAlert(string $circuitBreakerName, string $type, array $data): void
|
||||
{
|
||||
$alert = [
|
||||
'id' => uniqid('alert_'),
|
||||
'circuit' => $circuitBreakerName,
|
||||
'type' => $type,
|
||||
'timestamp' => microtime(true),
|
||||
'data' => $data,
|
||||
'acknowledged' => false
|
||||
];
|
||||
|
||||
$this->alerts[] = $alert;
|
||||
$this->circuitStates[$circuitBreakerName]['alerts'][] = $alert;
|
||||
|
||||
// Send to alert manager
|
||||
$this->alertManager->process($alert);
|
||||
|
||||
$this->logInfo("Alert created for {$circuitBreakerName}: {$type}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last state change.
|
||||
*/
|
||||
protected function getLastStateChange(string $circuitBreakerName): ?float
|
||||
{
|
||||
if (!isset($this->circuitStates[$circuitBreakerName])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$transitions = $this->circuitStates[$circuitBreakerName]['state_transitions'];
|
||||
|
||||
if (empty($transitions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lastTransition = end($transitions);
|
||||
return $lastTransition['timestamp'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate circuit report.
|
||||
*/
|
||||
protected function generateCircuitReport(string $circuitBreakerName): array
|
||||
{
|
||||
$circuitData = $this->getCircuitData($circuitBreakerName);
|
||||
|
||||
return [
|
||||
'circuit' => $circuitBreakerName,
|
||||
'period' => [
|
||||
'start' => $circuitData['started_at'],
|
||||
'end' => microtime(true)
|
||||
],
|
||||
'metrics' => $circuitData['calculated_metrics'],
|
||||
'analysis' => $circuitData['analysis'],
|
||||
'state_transitions' => $circuitData['state_transitions'],
|
||||
'alerts' => $circuitData['alerts']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate summary report.
|
||||
*/
|
||||
protected function generateSummaryReport(): array
|
||||
{
|
||||
$summary = [
|
||||
'total_circuits' => count($this->circuitStates),
|
||||
'healthy_circuits' => 0,
|
||||
'degraded_circuits' => 0,
|
||||
'failed_circuits' => 0,
|
||||
'total_requests' => 0,
|
||||
'total_successes' => 0,
|
||||
'total_failures' => 0,
|
||||
'overall_success_rate' => 0,
|
||||
'overall_average_response_time' => 0
|
||||
];
|
||||
|
||||
foreach ($this->circuitStates as $circuitName => $circuitData) {
|
||||
$metrics = $this->calculateCircuitMetrics($circuitName);
|
||||
$analysis = $this->analyzer->analyze($circuitName, $circuitData);
|
||||
|
||||
switch ($analysis['health_status']) {
|
||||
case 'healthy':
|
||||
$summary['healthy_circuits']++;
|
||||
break;
|
||||
case 'degraded':
|
||||
$summary['degraded_circuits']++;
|
||||
break;
|
||||
case 'failed':
|
||||
$summary['failed_circuits']++;
|
||||
break;
|
||||
}
|
||||
|
||||
$summary['total_requests'] += $metrics['total_requests'];
|
||||
$summary['total_successes'] += $metrics['success_count'];
|
||||
$summary['total_failures'] += $metrics['failure_count'];
|
||||
}
|
||||
|
||||
if ($summary['total_requests'] > 0) {
|
||||
$summary['overall_success_rate'] = ($summary['total_successes'] / $summary['total_requests']) * 100;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total metrics count.
|
||||
*/
|
||||
protected function getTotalMetricsCount(): int
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->circuitStates as $circuitData) {
|
||||
$total += count($circuitData['metrics']);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total transitions count.
|
||||
*/
|
||||
protected function getTotalTransitionsCount(): int
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->circuitStates as $circuitData) {
|
||||
$total += count($circuitData['state_transitions']);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring loop.
|
||||
*/
|
||||
protected function startMonitoringLoop(): void
|
||||
{
|
||||
// This would typically run as a background process
|
||||
// For now, we'll just log that it would start
|
||||
$this->logInfo("Monitoring loop started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[CircuitMonitor] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'metrics_retention' => 86400, // 24 hours
|
||||
'alerts_retention' => 604800, // 7 days
|
||||
'monitoring_interval' => 60, // 1 minute
|
||||
'logging_enabled' => true,
|
||||
'alerts' => [
|
||||
'response_time_threshold' => 5000, // 5 seconds
|
||||
'error_rate_threshold' => 50, // 50%
|
||||
'state_change_threshold' => 10, // 10 transitions
|
||||
'enabled' => true
|
||||
],
|
||||
'dashboard' => [
|
||||
'refresh_interval' => 30, // 30 seconds
|
||||
'trend_window_size' => 24 // 24 hours
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create circuit monitor instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'metrics_retention' => 604800, // 7 days
|
||||
'alerts_retention' => 2592000, // 30 days
|
||||
'monitoring_interval' => 30, // 30 seconds
|
||||
'logging_enabled' => false,
|
||||
'alerts' => [
|
||||
'response_time_threshold' => 2000, // 2 seconds
|
||||
'error_rate_threshold' => 30, // 30%
|
||||
'enabled' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'metrics_retention' => 3600, // 1 hour
|
||||
'alerts_retention' => 86400, // 24 hours
|
||||
'monitoring_interval' => 10, // 10 seconds
|
||||
'logging_enabled' => true,
|
||||
'alerts' => [
|
||||
'response_time_threshold' => 10000, // 10 seconds
|
||||
'error_rate_threshold' => 70, // 70%
|
||||
'enabled' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\CircuitBreaker\Recovery;
|
||||
|
||||
use Fendx\Service\CircuitBreaker\Recovery\Strategy\ExponentialBackoffStrategy;
|
||||
use Fendx\Service\CircuitBreaker\Recovery\Strategy\LinearBackoffStrategy;
|
||||
use Fendx\Service\CircuitBreaker\Recovery\Strategy\FixedIntervalStrategy;
|
||||
use Fendx\Service\CircuitBreaker\Recovery\Strategy\AdaptiveStrategy;
|
||||
|
||||
class AutoRecovery
|
||||
{
|
||||
protected array $config = [];
|
||||
protected array $strategies = [];
|
||||
protected array $recoveryHistory = [];
|
||||
protected array $activeRecoveries = [];
|
||||
protected array $recoveryMetrics = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->initializeStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto recovery for a circuit breaker.
|
||||
*/
|
||||
public function startRecovery(string $circuitBreakerName, array $context = []): string
|
||||
{
|
||||
$recoveryId = $this->generateRecoveryId($circuitBreakerName);
|
||||
|
||||
$recovery = [
|
||||
'id' => $recoveryId,
|
||||
'circuit_breaker' => $circuitBreakerName,
|
||||
'strategy' => $context['strategy'] ?? $this->config['default_strategy'],
|
||||
'started_at' => microtime(true),
|
||||
'attempts' => 0,
|
||||
'max_attempts' => $context['max_attempts'] ?? $this->config['max_attempts'],
|
||||
'context' => $context,
|
||||
'status' => 'active',
|
||||
'next_attempt' => null,
|
||||
'last_attempt' => null,
|
||||
'success_count' => 0,
|
||||
'failure_count' => 0
|
||||
];
|
||||
|
||||
$this->activeRecoveries[$recoveryId] = $recovery;
|
||||
|
||||
// Schedule first attempt
|
||||
$this->scheduleNextAttempt($recoveryId);
|
||||
|
||||
$this->logInfo("Started auto recovery for {$circuitBreakerName} (ID: {$recoveryId})");
|
||||
|
||||
return $recoveryId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto recovery.
|
||||
*/
|
||||
public function stopRecovery(string $recoveryId): bool
|
||||
{
|
||||
if (!isset($this->activeRecoveries[$recoveryId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$recovery = $this->activeRecoveries[$recoveryId];
|
||||
$recovery['status'] = 'stopped';
|
||||
$recovery['stopped_at'] = microtime(true);
|
||||
|
||||
// Move to history
|
||||
$this->recoveryHistory[] = $recovery;
|
||||
unset($this->activeRecoveries[$recoveryId]);
|
||||
|
||||
$this->logInfo("Stopped recovery {$recoveryId} for {$recovery['circuit_breaker']}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record recovery attempt result.
|
||||
*/
|
||||
public function recordAttempt(string $recoveryId, bool $success, array $result = []): void
|
||||
{
|
||||
if (!isset($this->activeRecoveries[$recoveryId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recovery = &$this->activeRecoveries[$recoveryId];
|
||||
$recovery['attempts']++;
|
||||
$recovery['last_attempt'] = microtime(true);
|
||||
|
||||
if ($success) {
|
||||
$recovery['success_count']++;
|
||||
|
||||
// Check if recovery is complete
|
||||
if ($this->isRecoveryComplete($recovery)) {
|
||||
$this->completeRecovery($recoveryId, $result);
|
||||
} else {
|
||||
// Schedule next attempt
|
||||
$this->scheduleNextAttempt($recoveryId);
|
||||
}
|
||||
} else {
|
||||
$recovery['failure_count']++;
|
||||
|
||||
// Check if max attempts reached
|
||||
if ($recovery['attempts'] >= $recovery['max_attempts']) {
|
||||
$this->failRecovery($recoveryId, $result);
|
||||
} else {
|
||||
// Schedule next attempt with backoff
|
||||
$this->scheduleNextAttempt($recoveryId);
|
||||
}
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
$this->recordRecoveryMetrics($recoveryId, $success, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active recoveries.
|
||||
*/
|
||||
public function getActiveRecoveries(): array
|
||||
{
|
||||
return $this->activeRecoveries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recovery history.
|
||||
*/
|
||||
public function getRecoveryHistory(int $limit = 100): array
|
||||
{
|
||||
return array_slice($this->recoveryHistory, -$limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recovery by ID.
|
||||
*/
|
||||
public function getRecovery(string $recoveryId): ?array
|
||||
{
|
||||
return $this->activeRecoveries[$recoveryId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recoveries for circuit breaker.
|
||||
*/
|
||||
public function getRecoveriesForCircuit(string $circuitBreakerName): array
|
||||
{
|
||||
$recoveries = [];
|
||||
|
||||
foreach ($this->activeRecoveries as $recoveryId => $recovery) {
|
||||
if ($recovery['circuit_breaker'] === $circuitBreakerName) {
|
||||
$recoveries[$recoveryId] = $recovery;
|
||||
}
|
||||
}
|
||||
|
||||
return $recoveries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recovery statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'active_recoveries' => count($this->activeRecoveries),
|
||||
'total_recoveries' => count($this->recoveryHistory) + count($this->activeRecoveries),
|
||||
'completed_recoveries' => 0,
|
||||
'failed_recoveries' => 0,
|
||||
'stopped_recoveries' => 0,
|
||||
'success_rate' => 0,
|
||||
'average_attempts' => 0,
|
||||
'average_duration' => 0,
|
||||
'strategy_performance' => []
|
||||
];
|
||||
|
||||
$totalAttempts = 0;
|
||||
$totalDuration = 0;
|
||||
$completedCount = 0;
|
||||
|
||||
// Analyze history
|
||||
foreach ($this->recoveryHistory as $recovery) {
|
||||
switch ($recovery['status']) {
|
||||
case 'completed':
|
||||
$stats['completed_recoveries']++;
|
||||
$completedCount++;
|
||||
break;
|
||||
case 'failed':
|
||||
$stats['failed_recoveries']++;
|
||||
break;
|
||||
case 'stopped':
|
||||
$stats['stopped_recoveries']++;
|
||||
break;
|
||||
}
|
||||
|
||||
$totalAttempts += $recovery['attempts'];
|
||||
|
||||
if (isset($recovery['completed_at']) || isset($recovery['failed_at'])) {
|
||||
$endTime = $recovery['completed_at'] ?? $recovery['failed_at'];
|
||||
$duration = $endTime - $recovery['started_at'];
|
||||
$totalDuration += $duration;
|
||||
}
|
||||
|
||||
// Strategy performance
|
||||
$strategy = $recovery['strategy'];
|
||||
if (!isset($stats['strategy_performance'][$strategy])) {
|
||||
$stats['strategy_performance'][$strategy] = [
|
||||
'total' => 0,
|
||||
'completed' => 0,
|
||||
'failed' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$stats['strategy_performance'][$strategy]['total']++;
|
||||
|
||||
if ($recovery['status'] === 'completed') {
|
||||
$stats['strategy_performance'][$strategy]['completed']++;
|
||||
} elseif ($recovery['status'] === 'failed') {
|
||||
$stats['strategy_performance'][$strategy]['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages and rates
|
||||
if ($stats['total_recoveries'] > 0) {
|
||||
$stats['success_rate'] = ($stats['completed_recoveries'] / $stats['total_recoveries']) * 100;
|
||||
}
|
||||
|
||||
if ($completedCount > 0) {
|
||||
$stats['average_attempts'] = $totalAttempts / $completedCount;
|
||||
$stats['average_duration'] = $totalDuration / $completedCount;
|
||||
}
|
||||
|
||||
// Calculate strategy success rates
|
||||
foreach ($stats['strategy_performance'] as $strategy => &$performance) {
|
||||
if ($performance['total'] > 0) {
|
||||
$performance['success_rate'] = ($performance['completed'] / $performance['total']) * 100;
|
||||
} else {
|
||||
$performance['success_rate'] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recovery metrics.
|
||||
*/
|
||||
public function getRecoveryMetrics(string $circuitBreakerName = null): array
|
||||
{
|
||||
if ($circuitBreakerName) {
|
||||
return $this->recoveryMetrics[$circuitBreakerName] ?? [];
|
||||
}
|
||||
|
||||
return $this->recoveryMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recovery should be triggered.
|
||||
*/
|
||||
public function shouldTriggerRecovery(string $circuitBreakerName, array $context = []): bool
|
||||
{
|
||||
// Check if there's already an active recovery
|
||||
$activeRecoveries = $this->getRecoveriesForCircuit($circuitBreakerName);
|
||||
if (!empty($activeRecoveries)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cooldown period
|
||||
$lastRecovery = $this->getLastRecovery($circuitBreakerName);
|
||||
if ($lastRecovery && isset($lastRecovery['completed_at'])) {
|
||||
$timeSinceLastRecovery = microtime(true) - $lastRecovery['completed_at'];
|
||||
$cooldownPeriod = $context['cooldown_period'] ?? $this->config['cooldown_period'];
|
||||
|
||||
if ($timeSinceLastRecovery < $cooldownPeriod) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check failure conditions
|
||||
return $this->checkFailureConditions($circuitBreakerName, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger automatic recovery if conditions are met.
|
||||
*/
|
||||
public function triggerAutoRecovery(string $circuitBreakerName, array $context = []): ?string
|
||||
{
|
||||
if (!$this->shouldTriggerRecovery($circuitBreakerName, $context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Select best strategy
|
||||
$strategy = $this->selectBestStrategy($circuitBreakerName, $context);
|
||||
$context['strategy'] = $strategy;
|
||||
|
||||
return $this->startRecovery($circuitBreakerName, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom recovery strategy.
|
||||
*/
|
||||
public function addStrategy(string $name, callable $strategy): void
|
||||
{
|
||||
$this->strategies[$name] = $strategy;
|
||||
|
||||
$this->logInfo("Added custom recovery strategy: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next attempt time for recovery.
|
||||
*/
|
||||
public function getNextAttemptTime(string $recoveryId): ?float
|
||||
{
|
||||
if (!isset($this->activeRecoveries[$recoveryId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->activeRecoveries[$recoveryId]['next_attempt'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until next attempt.
|
||||
*/
|
||||
public function getTimeUntilNextAttempt(string $recoveryId): ?float
|
||||
{
|
||||
$nextAttempt = $this->getNextAttemptTime($recoveryId);
|
||||
|
||||
if ($nextAttempt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max(0, $nextAttempt - microtime(true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all recoveries for a circuit breaker.
|
||||
*/
|
||||
public function cancelAllRecoveries(string $circuitBreakerName): int
|
||||
{
|
||||
$cancelled = 0;
|
||||
|
||||
foreach ($this->activeRecoveries as $recoveryId => $recovery) {
|
||||
if ($recovery['circuit_breaker'] === $circuitBreakerName) {
|
||||
$this->stopRecovery($recoveryId);
|
||||
$cancelled++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Cancelled {$cancelled} recoveries for {$circuitBreakerName}");
|
||||
|
||||
return $cancelled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all recovery state.
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
// Stop all active recoveries
|
||||
foreach (array_keys($this->activeRecoveries) as $recoveryId) {
|
||||
$this->stopRecovery($recoveryId);
|
||||
}
|
||||
|
||||
$this->recoveryHistory = [];
|
||||
$this->recoveryMetrics = [];
|
||||
|
||||
$this->logInfo("Auto recovery reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Export recovery data.
|
||||
*/
|
||||
public function exportData(): array
|
||||
{
|
||||
return [
|
||||
'config' => $this->config,
|
||||
'active_recoveries' => $this->activeRecoveries,
|
||||
'recovery_history' => $this->recoveryHistory,
|
||||
'recovery_metrics' => $this->recoveryMetrics,
|
||||
'statistics' => $this->getStatistics(),
|
||||
'exported_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next recovery attempt.
|
||||
*/
|
||||
protected function scheduleNextAttempt(string $recoveryId): void
|
||||
{
|
||||
if (!isset($this->activeRecoveries[$recoveryId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recovery = &$this->activeRecoveries[$recoveryId];
|
||||
$strategy = $recovery['strategy'];
|
||||
|
||||
if (!isset($this->strategies[$strategy])) {
|
||||
$this->logError("Unknown recovery strategy: {$strategy}");
|
||||
return;
|
||||
}
|
||||
|
||||
$delay = $this->strategies[$strategy]($recovery['attempts'], $recovery['context']);
|
||||
$recovery['next_attempt'] = microtime(true) + $delay;
|
||||
|
||||
$this->logInfo("Scheduled next attempt for {$recoveryId} in {$delay} seconds");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recovery is complete.
|
||||
*/
|
||||
protected function isRecoveryComplete(array $recovery): bool
|
||||
{
|
||||
$requiredSuccesses = $recovery['context']['required_successes'] ??
|
||||
$this->config['required_successes'];
|
||||
|
||||
return $recovery['success_count'] >= $requiredSuccesses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete recovery successfully.
|
||||
*/
|
||||
protected function completeRecovery(string $recoveryId, array $result): void
|
||||
{
|
||||
if (!isset($this->activeRecoveries[$recoveryId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recovery = &$this->activeRecoveries[$recoveryId];
|
||||
$recovery['status'] = 'completed';
|
||||
$recovery['completed_at'] = microtime(true);
|
||||
$recovery['result'] = $result;
|
||||
|
||||
// Move to history
|
||||
$this->recoveryHistory[] = $recovery;
|
||||
unset($this->activeRecoveries[$recoveryId]);
|
||||
|
||||
$this->logInfo("Recovery {$recoveryId} completed successfully for {$recovery['circuit_breaker']}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark recovery as failed.
|
||||
*/
|
||||
protected function failRecovery(string $recoveryId, array $result): void
|
||||
{
|
||||
if (!isset($this->activeRecoveries[$recoveryId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recovery = &$this->activeRecoveries[$recoveryId];
|
||||
$recovery['status'] = 'failed';
|
||||
$recovery['failed_at'] = microtime(true);
|
||||
$recovery['result'] = $result;
|
||||
|
||||
// Move to history
|
||||
$this->recoveryHistory[] = $recovery;
|
||||
unset($this->activeRecoveries[$recoveryId]);
|
||||
|
||||
$this->logInfo("Recovery {$recoveryId} failed for {$recovery['circuit_breaker']}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record recovery metrics.
|
||||
*/
|
||||
protected function recordRecoveryMetrics(string $recoveryId, bool $success, array $result): void
|
||||
{
|
||||
if (!isset($this->activeRecoveries[$recoveryId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recovery = $this->activeRecoveries[$recoveryId];
|
||||
$circuitBreakerName = $recovery['circuit_breaker'];
|
||||
|
||||
if (!isset($this->recoveryMetrics[$circuitBreakerName])) {
|
||||
$this->recoveryMetrics[$circuitBreakerName] = [
|
||||
'total_attempts' => 0,
|
||||
'successful_attempts' => 0,
|
||||
'failed_attempts' => 0,
|
||||
'last_attempt' => null,
|
||||
'average_attempt_duration' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$metrics = &$this->recoveryMetrics[$circuitBreakerName];
|
||||
$metrics['total_attempts']++;
|
||||
$metrics['last_attempt'] = microtime(true);
|
||||
|
||||
if ($success) {
|
||||
$metrics['successful_attempts']++;
|
||||
} else {
|
||||
$metrics['failed_attempts']++;
|
||||
}
|
||||
|
||||
// Update average duration if provided
|
||||
if (isset($result['duration'])) {
|
||||
$currentAvg = $metrics['average_attempt_duration'];
|
||||
$totalAttempts = $metrics['total_attempts'];
|
||||
$metrics['average_attempt_duration'] =
|
||||
(($currentAvg * ($totalAttempts - 1)) + $result['duration']) / $totalAttempts;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last recovery for circuit breaker.
|
||||
*/
|
||||
protected function getLastRecovery(string $circuitBreakerName): ?array
|
||||
{
|
||||
// Check active recoveries first
|
||||
foreach ($this->activeRecoveries as $recovery) {
|
||||
if ($recovery['circuit_breaker'] === $circuitBreakerName) {
|
||||
return $recovery;
|
||||
}
|
||||
}
|
||||
|
||||
// Check history
|
||||
for ($i = count($this->recoveryHistory) - 1; $i >= 0; $i--) {
|
||||
if ($this->recoveryHistory[$i]['circuit_breaker'] === $circuitBreakerName) {
|
||||
return $this->recoveryHistory[$i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check failure conditions.
|
||||
*/
|
||||
protected function checkFailureConditions(string $circuitBreakerName, array $context): bool
|
||||
{
|
||||
$metrics = $this->recoveryMetrics[$circuitBreakerName] ?? [];
|
||||
|
||||
// Check failure rate
|
||||
$failureRateThreshold = $context['failure_rate_threshold'] ?? 80.0;
|
||||
if ($metrics['total_attempts'] > 0) {
|
||||
$failureRate = ($metrics['failed_attempts'] / $metrics['total_attempts']) * 100;
|
||||
if ($failureRate < $failureRateThreshold) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check minimum attempts
|
||||
$minAttempts = $context['min_attempts'] ?? $this->config['min_attempts'];
|
||||
if ($metrics['total_attempts'] < $minAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select best recovery strategy.
|
||||
*/
|
||||
protected function selectBestStrategy(string $circuitBreakerName, array $context): string
|
||||
{
|
||||
$metrics = $this->recoveryMetrics[$circuitBreakerName] ?? [];
|
||||
|
||||
// Analyze past performance
|
||||
$strategyPerformance = [];
|
||||
|
||||
foreach ($this->recoveryHistory as $recovery) {
|
||||
if ($recovery['circuit_breaker'] === $circuitBreakerName) {
|
||||
$strategy = $recovery['strategy'];
|
||||
if (!isset($strategyPerformance[$strategy])) {
|
||||
$strategyPerformance[$strategy] = [
|
||||
'total' => 0,
|
||||
'successful' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$strategyPerformance[$strategy]['total']++;
|
||||
if ($recovery['status'] === 'completed') {
|
||||
$strategyPerformance[$strategy]['successful']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select strategy with best success rate
|
||||
$bestStrategy = $this->config['default_strategy'];
|
||||
$bestSuccessRate = 0;
|
||||
|
||||
foreach ($strategyPerformance as $strategy => $performance) {
|
||||
if ($performance['total'] > 0) {
|
||||
$successRate = ($performance['successful'] / $performance['total']) * 100;
|
||||
if ($successRate > $bestSuccessRate) {
|
||||
$bestSuccessRate = $successRate;
|
||||
$bestStrategy = $strategy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $bestStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique recovery ID.
|
||||
*/
|
||||
protected function generateRecoveryId(string $circuitBreakerName): string
|
||||
{
|
||||
return $circuitBreakerName . '_' . uniqid() . '_' . time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize built-in strategies.
|
||||
*/
|
||||
protected function initializeStrategies(): void
|
||||
{
|
||||
$this->strategies['exponential_backoff'] = new ExponentialBackoffStrategy($this->config);
|
||||
$this->strategies['linear_backoff'] = new LinearBackoffStrategy($this->config);
|
||||
$this->strategies['fixed_interval'] = new FixedIntervalStrategy($this->config);
|
||||
$this->strategies['adaptive'] = new AdaptiveStrategy($this->config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message.
|
||||
*/
|
||||
protected function logError(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[AutoRecovery] ERROR: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[AutoRecovery] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'default_strategy' => 'exponential_backoff',
|
||||
'max_attempts' => 5,
|
||||
'required_successes' => 3,
|
||||
'cooldown_period' => 300, // 5 minutes
|
||||
'min_attempts' => 3,
|
||||
'logging_enabled' => true,
|
||||
'strategies' => [
|
||||
'exponential_backoff' => [
|
||||
'base_delay' => 1.0,
|
||||
'max_delay' => 300.0,
|
||||
'multiplier' => 2.0
|
||||
],
|
||||
'linear_backoff' => [
|
||||
'base_delay' => 5.0,
|
||||
'max_delay' => 300.0,
|
||||
'increment' => 10.0
|
||||
],
|
||||
'fixed_interval' => [
|
||||
'delay' => 30.0
|
||||
],
|
||||
'adaptive' => [
|
||||
'min_delay' => 1.0,
|
||||
'max_delay' => 300.0,
|
||||
'success_factor' => 0.8,
|
||||
'failure_factor' => 1.5
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
$this->initializeStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auto recovery instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'max_attempts' => 10,
|
||||
'required_successes' => 5,
|
||||
'cooldown_period' => 600, // 10 minutes
|
||||
'min_attempts' => 5,
|
||||
'logging_enabled' => false
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'max_attempts' => 3,
|
||||
'required_successes' => 2,
|
||||
'cooldown_period' => 60, // 1 minute
|
||||
'min_attempts' => 2,
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\CloudNative;
|
||||
|
||||
/**
|
||||
* Kubernetes Operator
|
||||
* 自动化部署和运维 FendxPHP 应用
|
||||
*/
|
||||
class KubernetesOperator
|
||||
{
|
||||
private array $config;
|
||||
private string $namespace;
|
||||
private array $resources = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge([
|
||||
'app_name' => 'fendx-php',
|
||||
'namespace' => 'fendx',
|
||||
'replicas' => 3,
|
||||
'image' => 'fendx/php:latest',
|
||||
'port' => 9000,
|
||||
'resources' => [
|
||||
'requests' => [
|
||||
'cpu' => '100m',
|
||||
'memory' => '128Mi',
|
||||
],
|
||||
'limits' => [
|
||||
'cpu' => '500m',
|
||||
'memory' => '512Mi',
|
||||
],
|
||||
],
|
||||
'auto_scaling' => [
|
||||
'enabled' => true,
|
||||
'min_replicas' => 2,
|
||||
'max_replicas' => 10,
|
||||
'cpu_threshold' => 70,
|
||||
'memory_threshold' => 80,
|
||||
],
|
||||
], $config);
|
||||
|
||||
$this->namespace = $this->config['namespace'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 部署应用
|
||||
*/
|
||||
public function deploy(): void
|
||||
{
|
||||
$this->createNamespace();
|
||||
$this->createConfigMaps();
|
||||
$this->createSecrets();
|
||||
$this->createDeployment();
|
||||
$this->createService();
|
||||
$this->createIngress();
|
||||
|
||||
if ($this->config['auto_scaling']['enabled']) {
|
||||
$this->configureHPA();
|
||||
}
|
||||
|
||||
$this->configureRollingUpdate();
|
||||
$this->configureHealthChecks();
|
||||
$this->createServiceMonitor();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建命名空间
|
||||
*/
|
||||
private function createNamespace(): void
|
||||
{
|
||||
$namespace = [
|
||||
'apiVersion' => 'v1',
|
||||
'kind' => 'Namespace',
|
||||
'metadata' => [
|
||||
'name' => $this->namespace,
|
||||
'labels' => [
|
||||
'name' => $this->namespace,
|
||||
'istio-injection' => 'enabled',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyResource('namespace.yaml', $namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建配置映射
|
||||
*/
|
||||
private function createConfigMaps(): void
|
||||
{
|
||||
$configMap = [
|
||||
'apiVersion' => 'v1',
|
||||
'kind' => 'ConfigMap',
|
||||
'metadata' => [
|
||||
'name' => 'fendx-php-config',
|
||||
'namespace' => $this->namespace,
|
||||
],
|
||||
'data' => [
|
||||
'app.php' => $this->generateAppConfig(),
|
||||
'database.php' => $this->generateDatabaseConfig(),
|
||||
'cache.php' => $this->generateCacheConfig(),
|
||||
'logging.php' => $this->generateLoggingConfig(),
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyResource('configmap.yaml', $configMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建密钥
|
||||
*/
|
||||
private function createSecrets(): void
|
||||
{
|
||||
$secret = [
|
||||
'apiVersion' => 'v1',
|
||||
'kind' => 'Secret',
|
||||
'metadata' => [
|
||||
'name' => 'fendx-php-secrets',
|
||||
'namespace' => $this->namespace,
|
||||
],
|
||||
'type' => 'Opaque',
|
||||
'data' => [
|
||||
'database-password' => base64_encode($this->config['database']['password'] ?? 'password'),
|
||||
'jwt-secret' => base64_encode($this->config['jwt']['secret'] ?? 'your-secret-key'),
|
||||
'redis-password' => base64_encode($this->config['redis']['password'] ?? ''),
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyResource('secret.yaml', $secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建部署
|
||||
*/
|
||||
private function createDeployment(): void
|
||||
{
|
||||
$deployment = [
|
||||
'apiVersion' => 'apps/v1',
|
||||
'kind' => 'Deployment',
|
||||
'metadata' => [
|
||||
'name' => $this->config['app_name'],
|
||||
'namespace' => $this->namespace,
|
||||
'labels' => [
|
||||
'app' => $this->config['app_name'],
|
||||
'version' => 'v1',
|
||||
],
|
||||
],
|
||||
'spec' => [
|
||||
'replicas' => $this->config['replicas'],
|
||||
'selector' => [
|
||||
'matchLabels' => [
|
||||
'app' => $this->config['app_name'],
|
||||
],
|
||||
],
|
||||
'template' => [
|
||||
'metadata' => [
|
||||
'labels' => [
|
||||
'app' => $this->config['app_name'],
|
||||
'version' => 'v1',
|
||||
],
|
||||
'annotations' => [
|
||||
'prometheus.io/scrape' => 'true',
|
||||
'prometheus.io/port' => '9100',
|
||||
'prometheus.io/path' => '/metrics',
|
||||
],
|
||||
],
|
||||
'spec' => [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $this->config['app_name'],
|
||||
'image' => $this->config['image'],
|
||||
'ports' => [
|
||||
[
|
||||
'containerPort' => $this->config['port'],
|
||||
'protocol' => 'TCP',
|
||||
],
|
||||
[
|
||||
'containerPort' => 9100,
|
||||
'protocol' => 'TCP',
|
||||
'name' => 'metrics',
|
||||
],
|
||||
],
|
||||
'env' => $this->generateEnvironmentVariables(),
|
||||
'resources' => $this->config['resources'],
|
||||
'volumeMounts' => [
|
||||
[
|
||||
'name' => 'config-volume',
|
||||
'mountPath' => '/app/config',
|
||||
'readOnly' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'cache-volume',
|
||||
'mountPath' => '/app/runtime/cache',
|
||||
],
|
||||
[
|
||||
'name' => 'logs-volume',
|
||||
'mountPath' => '/app/runtime/logs',
|
||||
],
|
||||
],
|
||||
'livenessProbe' => [
|
||||
'httpGet' => [
|
||||
'path' => '/health',
|
||||
'port' => $this->config['port'],
|
||||
],
|
||||
'initialDelaySeconds' => 30,
|
||||
'periodSeconds' => 10,
|
||||
'timeoutSeconds' => 5,
|
||||
'failureThreshold' => 3,
|
||||
],
|
||||
'readinessProbe' => [
|
||||
'httpGet' => [
|
||||
'path' => '/ready',
|
||||
'port' => $this->config['port'],
|
||||
],
|
||||
'initialDelaySeconds' => 5,
|
||||
'periodSeconds' => 5,
|
||||
'timeoutSeconds' => 3,
|
||||
'failureThreshold' => 3,
|
||||
],
|
||||
'startupProbe' => [
|
||||
'httpGet' => [
|
||||
'path' => '/startup',
|
||||
'port' => $this->config['port'],
|
||||
],
|
||||
'initialDelaySeconds' => 10,
|
||||
'periodSeconds' => 10,
|
||||
'timeoutSeconds' => 5,
|
||||
'failureThreshold' => 30,
|
||||
],
|
||||
],
|
||||
],
|
||||
'volumes' => [
|
||||
[
|
||||
'name' => 'config-volume',
|
||||
'configMap' => [
|
||||
'name' => 'fendx-php-config',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'cache-volume',
|
||||
'emptyDir' => [
|
||||
'sizeLimit' => '1Gi',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'logs-volume',
|
||||
'emptyDir' => [
|
||||
'sizeLimit' => '500Mi',
|
||||
],
|
||||
],
|
||||
],
|
||||
'affinity' => [
|
||||
'podAntiAffinity' => [
|
||||
'preferredDuringSchedulingIgnoredDuringExecution' => [
|
||||
[
|
||||
'weight' => 100,
|
||||
'podAffinityTerm' => [
|
||||
'labelSelector' => [
|
||||
'matchExpressions' => [
|
||||
[
|
||||
'key' => 'app',
|
||||
'operator' => 'In',
|
||||
'values' => [$this->config['app_name']],
|
||||
],
|
||||
],
|
||||
],
|
||||
'topologyKey' => 'kubernetes.io/hostname',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'tolerations' => [
|
||||
[
|
||||
'key' => 'node-role.kubernetes.io/master',
|
||||
'operator' => 'Exists',
|
||||
'effect' => 'NoSchedule',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'strategy' => [
|
||||
'type' => 'RollingUpdate',
|
||||
'rollingUpdate' => [
|
||||
'maxUnavailable' => '25%',
|
||||
'maxSurge' => '25%',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyResource('deployment.yaml', $deployment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建服务
|
||||
*/
|
||||
private function createService(): void
|
||||
{
|
||||
$service = [
|
||||
'apiVersion' => 'v1',
|
||||
'kind' => 'Service',
|
||||
'metadata' => [
|
||||
'name' => $this->config['app_name'],
|
||||
'namespace' => $this->namespace,
|
||||
'labels' => [
|
||||
'app' => $this->config['app_name'],
|
||||
],
|
||||
'annotations' => [
|
||||
'prometheus.io/scrape' => 'true',
|
||||
'prometheus.io/port' => '9100',
|
||||
],
|
||||
],
|
||||
'spec' => [
|
||||
'selector' => [
|
||||
'app' => $this->config['app_name'],
|
||||
],
|
||||
'ports' => [
|
||||
[
|
||||
'name' => 'http',
|
||||
'protocol' => 'TCP',
|
||||
'port' => 80,
|
||||
'targetPort' => $this->config['port'],
|
||||
],
|
||||
[
|
||||
'name' => 'metrics',
|
||||
'protocol' => 'TCP',
|
||||
'port' => 9100,
|
||||
'targetPort' => 9100,
|
||||
],
|
||||
],
|
||||
'type' => 'ClusterIP',
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyResource('service.yaml', $service);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建入口
|
||||
*/
|
||||
private function createIngress(): void
|
||||
{
|
||||
$ingress = [
|
||||
'apiVersion' => 'networking.k8s.io/v1',
|
||||
'kind' => 'Ingress',
|
||||
'metadata' => [
|
||||
'name' => $this->config['app_name'],
|
||||
'namespace' => $this->namespace,
|
||||
'annotations' => [
|
||||
'kubernetes.io/ingress.class' => 'nginx',
|
||||
'nginx.ingress.kubernetes.io/rewrite-target' => '/',
|
||||
'nginx.ingress.kubernetes.io/ssl-redirect' => 'true',
|
||||
'nginx.ingress.kubernetes.io/use-regex' => 'true',
|
||||
'nginx.ingress.kubernetes.io/rate-limit' => '100',
|
||||
'nginx.ingress.kubernetes.io/rate-limit-window' => '1m',
|
||||
'cert-manager.io/cluster-issuer' => 'letsencrypt-prod',
|
||||
],
|
||||
],
|
||||
'spec' => [
|
||||
'tls' => [
|
||||
[
|
||||
'hosts' => ['fendx.example.com'],
|
||||
'secretName' => 'fendx-tls',
|
||||
],
|
||||
],
|
||||
'rules' => [
|
||||
[
|
||||
'host' => 'fendx.example.com',
|
||||
'http' => [
|
||||
'paths' => [
|
||||
[
|
||||
'path' => '/',
|
||||
'pathType' => 'Prefix',
|
||||
'backend' => [
|
||||
'service' => [
|
||||
'name' => $this->config['app_name'],
|
||||
'port' => [
|
||||
'number' => 80,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyResource('ingress.yaml', $ingress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置水平自动扩缩容
|
||||
*/
|
||||
private function configureHPA(): void
|
||||
{
|
||||
$hpa = [
|
||||
'apiVersion' => 'autoscaling/v2',
|
||||
'kind' => 'HorizontalPodAutoscaler',
|
||||
'metadata' => [
|
||||
'name' => $this->config['app_name'] . '-hpa',
|
||||
'namespace' => $this->namespace,
|
||||
],
|
||||
'spec' => [
|
||||
'scaleTargetRef' => [
|
||||
'apiVersion' => 'apps/v1',
|
||||
'kind' => 'Deployment',
|
||||
'name' => $this->config['app_name'],
|
||||
],
|
||||
'minReplicas' => $this->config['auto_scaling']['min_replicas'],
|
||||
'maxReplicas' => $this->config['auto_scaling']['max_replicas'],
|
||||
'metrics' => [
|
||||
[
|
||||
'type' => 'Resource',
|
||||
'resource' => [
|
||||
'name' => 'cpu',
|
||||
'target' => [
|
||||
'type' => 'Utilization',
|
||||
'averageUtilization' => $this->config['auto_scaling']['cpu_threshold'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'Resource',
|
||||
'resource' => [
|
||||
'name' => 'memory',
|
||||
'target' => [
|
||||
'type' => 'Utilization',
|
||||
'averageUtilization' => $this->config['auto_scaling']['memory_threshold'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'behavior' => [
|
||||
'scaleUp' => [
|
||||
'stabilizationWindowSeconds' => 60,
|
||||
'policies' => [
|
||||
[
|
||||
'type' => 'Percent',
|
||||
'value' => 100,
|
||||
'periodSeconds' => 15,
|
||||
],
|
||||
],
|
||||
],
|
||||
'scaleDown' => [
|
||||
'stabilizationWindowSeconds' => 300,
|
||||
'policies' => [
|
||||
[
|
||||
'type' => 'Percent',
|
||||
'value' => 10,
|
||||
'periodSeconds' => 60,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyResource('hpa.yaml', $hpa);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置滚动更新策略
|
||||
*/
|
||||
private function configureRollingUpdate(): void
|
||||
{
|
||||
// 滚动更新策略已在 Deployment 中配置
|
||||
// 这里可以添加额外的配置,如暂停、恢复等
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置健康检查
|
||||
*/
|
||||
private function configureHealthChecks(): void
|
||||
{
|
||||
// 健康检查已在 Deployment 中配置
|
||||
// 这里可以添加额外的健康检查配置
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建服务监控
|
||||
*/
|
||||
private function createServiceMonitor(): void
|
||||
{
|
||||
$serviceMonitor = [
|
||||
'apiVersion' => 'monitoring.coreos.com/v1',
|
||||
'kind' => 'ServiceMonitor',
|
||||
'metadata' => [
|
||||
'name' => $this->config['app_name'],
|
||||
'namespace' => $this->namespace,
|
||||
'labels' => [
|
||||
'app' => $this->config['app_name'],
|
||||
],
|
||||
],
|
||||
'spec' => [
|
||||
'selector' => [
|
||||
'matchLabels' => [
|
||||
'app' => $this->config['app_name'],
|
||||
],
|
||||
],
|
||||
'endpoints' => [
|
||||
[
|
||||
'port' => 'metrics',
|
||||
'interval' => '30s',
|
||||
'path' => '/metrics',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyResource('servicemonitor.yaml', $serviceMonitor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成环境变量
|
||||
*/
|
||||
private function generateEnvironmentVariables(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'APP_ENV', 'value' => 'production'],
|
||||
['name' => 'APP_DEBUG', 'value' => 'false'],
|
||||
['name' => 'DB_HOST', 'value' => 'mysql-service'],
|
||||
['name' => 'DB_PORT', 'value' => '3306'],
|
||||
['name' => 'DB_DATABASE', 'value' => 'fendx_php'],
|
||||
['name' => 'DB_USERNAME', 'value' => 'fendx'],
|
||||
['name' => 'DB_PASSWORD', 'valueFrom' => ['secretKeyRef' => ['name' => 'fendx-php-secrets', 'key' => 'database-password']]],
|
||||
['name' => 'REDIS_HOST', 'value' => 'redis-service'],
|
||||
['name' => 'REDIS_PORT', 'value' => '6379'],
|
||||
['name' => 'REDIS_PASSWORD', 'valueFrom' => ['secretKeyRef' => ['name' => 'fendx-php-secrets', 'key' => 'redis-password']]],
|
||||
['name' => 'JWT_SECRET', 'valueFrom' => ['secretKeyRef' => ['name' => 'fendx-php-secrets', 'key' => 'jwt-secret']]],
|
||||
['name' => 'LOG_LEVEL', 'value' => 'info'],
|
||||
['name' => 'TRACE_ID_HEADER', 'value' => 'X-Trace-Id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成应用配置
|
||||
*/
|
||||
private function generateAppConfig(): string
|
||||
{
|
||||
return "<?php\nreturn [\n";
|
||||
$config = [
|
||||
'name' => 'FendxPHP',
|
||||
'env' => 'production',
|
||||
'debug' => false,
|
||||
'url' => 'https://fendx.example.com',
|
||||
'timezone' => 'UTC',
|
||||
];
|
||||
|
||||
foreach ($config as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$configStr .= " '{$key}' => '{$value}',\n";
|
||||
} else {
|
||||
$configStr .= " '{$key}' => " . var_export($value, true) . ",\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $configStr . "];\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成数据库配置
|
||||
*/
|
||||
private function generateDatabaseConfig(): string
|
||||
{
|
||||
return "<?php\nreturn [\n";
|
||||
$config = [
|
||||
'default' => 'mysql',
|
||||
'connections' => [
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'host' => '${DB_HOST}',
|
||||
'port' => '${DB_PORT}',
|
||||
'database' => '${DB_DATABASE}',
|
||||
'username' => '${DB_USERNAME}',
|
||||
'password' => '${DB_PASSWORD}',
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'prefix' => '',
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->arrayToPhp($config) . "];\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成缓存配置
|
||||
*/
|
||||
private function generateCacheConfig(): string
|
||||
{
|
||||
return "<?php\nreturn [\n";
|
||||
$config = [
|
||||
'default' => 'redis',
|
||||
'stores' => [
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'cache',
|
||||
],
|
||||
],
|
||||
'redis' => [
|
||||
'client' => 'phpredis',
|
||||
'options' => [
|
||||
'cluster' => 'redis',
|
||||
'prefix' => 'fendx_cache:',
|
||||
],
|
||||
'default' => [
|
||||
'url' => '${REDIS_HOST}:${REDIS_PORT}',
|
||||
'password' => '${REDIS_PASSWORD}',
|
||||
'database' => 0,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->arrayToPhp($config) . "];\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成日志配置
|
||||
*/
|
||||
private function generateLoggingConfig(): string
|
||||
{
|
||||
return "<?php\nreturn [\n";
|
||||
$config = [
|
||||
'default' => 'stack',
|
||||
'channels' => [
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => ['single', 'daily'],
|
||||
],
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => '/app/runtime/logs/app.log',
|
||||
'level' => '${LOG_LEVEL}',
|
||||
],
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => '/app/runtime/logs/app.log',
|
||||
'level' => '${LOG_LEVEL}',
|
||||
'days' => 14,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->arrayToPhp($config) . "];\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* 数组转 PHP 代码
|
||||
*/
|
||||
private function arrayToPhp(array $array, int $indent = 1): string
|
||||
{
|
||||
$result = '';
|
||||
$spaces = str_repeat(' ', $indent);
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$result .= "{$spaces}'{$key}' => [\n";
|
||||
$result .= $this->arrayToPhp($value, $indent + 1);
|
||||
$result .= "{$spaces}],\n";
|
||||
} elseif (is_string($value)) {
|
||||
$result .= "{$spaces}'{$key}' => '{$value}',\n";
|
||||
} else {
|
||||
$result .= "{$spaces}'{$key}' => " . var_export($value, true) . ",\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用资源
|
||||
*/
|
||||
private function applyResource(string $filename, array $resource): void
|
||||
{
|
||||
$yaml = $this->arrayToYaml($resource);
|
||||
$path = runtime_path("k8s/{$this->namespace}/{$filename}");
|
||||
|
||||
$this->ensureDirectory(dirname($path));
|
||||
file_put_contents($path, $yaml);
|
||||
|
||||
$this->resources[] = $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数组转 YAML
|
||||
*/
|
||||
private function arrayToYaml(array $array): string
|
||||
{
|
||||
// 简化的 YAML 转换,实际项目中建议使用 symfony/yaml
|
||||
return json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
private function ensureDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 部署到 Kubernetes
|
||||
*/
|
||||
public function deployToKubernetes(): bool
|
||||
{
|
||||
foreach ($this->resources as $resource) {
|
||||
$output = shell_exec("kubectl apply -f {$resource} 2>&1");
|
||||
if (strpos($output, 'error') !== false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部署状态
|
||||
*/
|
||||
public function getDeploymentStatus(): array
|
||||
{
|
||||
$output = shell_exec("kubectl get deployment {$this->config['app_name']} -n {$this->namespace} -o json 2>&1");
|
||||
$deployment = json_decode($output, true);
|
||||
|
||||
if (!$deployment) {
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'found',
|
||||
'replicas' => $deployment['spec']['replicas'] ?? 0,
|
||||
'ready_replicas' => $deployment['status']['readyReplicas'] ?? 0,
|
||||
'available_replicas' => $deployment['status']['availableReplicas'] ?? 0,
|
||||
'updated_replicas' => $deployment['status']['updatedReplicas'] ?? 0,
|
||||
'conditions' => $deployment['status']['conditions'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩缩容
|
||||
*/
|
||||
public function scale(int $replicas): bool
|
||||
{
|
||||
$output = shell_exec("kubectl scale deployment {$this->config['app_name']} --replicas={$replicas} -n {$this->namespace} 2>&1");
|
||||
return strpos($output, 'error') === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动更新
|
||||
*/
|
||||
public function rollingUpdate(string $image): bool
|
||||
{
|
||||
$output = shell_exec("kubectl set image deployment/{$this->config['app_name']} {$this->config['app_name']}={$image} -n {$this->namespace} 2>&1");
|
||||
return strpos($output, 'error') === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志
|
||||
*/
|
||||
public function getLogs(int $lines = 100): string
|
||||
{
|
||||
return shell_exec("kubectl logs deployment/{$this->config['app_name']} -n {$this->namespace} --tail={$lines} 2>&1");
|
||||
}
|
||||
}
|
||||
762
fendx-framework/fendx-service/src/Config/ConfigCenter.php
Normal file
762
fendx-framework/fendx-service/src/Config/ConfigCenter.php
Normal file
@@ -0,0 +1,762 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Config;
|
||||
|
||||
use Fendx\Service\Config\Provider\ConfigProvider;
|
||||
use Fendx\Service\Config\Provider\ConsulProvider;
|
||||
use Fendx\Service\Config\Provider\EtcdProvider;
|
||||
use Fendx\Service\Config\Provider\RedisProvider;
|
||||
use Fendx\Service\Config\Storage\ConfigStorage;
|
||||
use Fendx\Service\Config\Watcher\ConfigWatcher;
|
||||
use Fendx\Service\Config\Encryption\ConfigEncryption;
|
||||
|
||||
class ConfigCenter
|
||||
{
|
||||
protected ConfigProvider $provider;
|
||||
protected ConfigStorage $storage;
|
||||
protected ConfigWatcher $watcher;
|
||||
protected ConfigEncryption $encryption;
|
||||
protected array $config = [];
|
||||
protected array $cache = [];
|
||||
protected array $watchers = [];
|
||||
protected array $changeHistory = [];
|
||||
protected bool $initialized = false;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->encryption = new ConfigEncryption($this->config['encryption'] ?? []);
|
||||
$this->storage = new ConfigStorage($this->config['storage'] ?? []);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value.
|
||||
*/
|
||||
public function get(string $key, $default = null, string $namespace = 'default')
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
$cacheKey = $this->getCacheKey($key, $namespace);
|
||||
if (isset($this->cache[$cacheKey])) {
|
||||
return $this->cache[$cacheKey]['value'];
|
||||
}
|
||||
|
||||
// Get from storage
|
||||
$value = $this->storage->get($key, $namespace);
|
||||
|
||||
if ($value === null) {
|
||||
// Try to get from remote provider
|
||||
$value = $this->provider->get($key, $namespace);
|
||||
|
||||
if ($value !== null) {
|
||||
// Cache and store locally
|
||||
$this->cache[$cacheKey] = [
|
||||
'value' => $value,
|
||||
'timestamp' => microtime(true),
|
||||
'source' => 'remote'
|
||||
];
|
||||
|
||||
$this->storage->set($key, $value, $namespace);
|
||||
}
|
||||
} else {
|
||||
// Update cache
|
||||
$this->cache[$cacheKey] = [
|
||||
'value' => $value,
|
||||
'timestamp' => microtime(true),
|
||||
'source' => 'local'
|
||||
];
|
||||
}
|
||||
|
||||
return $value ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration value.
|
||||
*/
|
||||
public function set(string $key, $value, string $namespace = 'default'): bool
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
// Encrypt if needed
|
||||
if ($this->shouldEncrypt($key, $namespace)) {
|
||||
$value = $this->encryption->encrypt($value);
|
||||
}
|
||||
|
||||
// Set to remote provider
|
||||
$success = $this->provider->set($key, $value, $namespace);
|
||||
|
||||
if ($success) {
|
||||
// Update local storage and cache
|
||||
$this->storage->set($key, $value, $namespace);
|
||||
|
||||
$cacheKey = $this->getCacheKey($key, $namespace);
|
||||
$this->cache[$cacheKey] = [
|
||||
'value' => $value,
|
||||
'timestamp' => microtime(true),
|
||||
'source' => 'local'
|
||||
];
|
||||
|
||||
// Record change
|
||||
$this->recordChange($key, $value, $namespace, 'set');
|
||||
|
||||
// Notify watchers
|
||||
$this->notifyWatchers($key, $value, $namespace);
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete configuration value.
|
||||
*/
|
||||
public function delete(string $key, string $namespace = 'default'): bool
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
// Delete from remote provider
|
||||
$success = $this->provider->delete($key, $namespace);
|
||||
|
||||
if ($success) {
|
||||
// Remove from local storage and cache
|
||||
$this->storage->delete($key, $namespace);
|
||||
|
||||
$cacheKey = $this->getCacheKey($key, $namespace);
|
||||
unset($this->cache[$cacheKey]);
|
||||
|
||||
// Record change
|
||||
$this->recordChange($key, null, $namespace, 'delete');
|
||||
|
||||
// Notify watchers
|
||||
$this->notifyWatchers($key, null, $namespace);
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configuration in namespace.
|
||||
*/
|
||||
public function getAll(string $namespace = 'default'): array
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
// Get from cache first
|
||||
$namespaceCache = [];
|
||||
$cachePrefix = $namespace . ':';
|
||||
|
||||
foreach ($this->cache as $cacheKey => $data) {
|
||||
if (strpos($cacheKey, $cachePrefix) === 0) {
|
||||
$key = substr($cacheKey, strlen($cachePrefix));
|
||||
$namespaceCache[$key] = $data['value'];
|
||||
}
|
||||
}
|
||||
|
||||
// Get from storage
|
||||
$storageData = $this->storage->getAll($namespace);
|
||||
$namespaceCache = array_merge($namespaceCache, $storageData);
|
||||
|
||||
// Get from remote provider if needed
|
||||
$remoteData = $this->provider->getAll($namespace);
|
||||
$namespaceCache = array_merge($namespaceCache, $remoteData);
|
||||
|
||||
// Decrypt values if needed
|
||||
foreach ($namespaceCache as $key => $value) {
|
||||
if ($this->shouldEncrypt($key, $namespace)) {
|
||||
try {
|
||||
$namespaceCache[$key] = $this->encryption->decrypt($value);
|
||||
} catch (\Exception $e) {
|
||||
// Keep original value if decryption fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $namespaceCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple configuration values.
|
||||
*/
|
||||
public function setMultiple(array $configs, string $namespace = 'default'): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($configs as $key => $value) {
|
||||
$results[$key] = $this->set($key, $value, $namespace);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for configuration changes.
|
||||
*/
|
||||
public function watch(string $key, callable $callback, string $namespace = 'default'): string
|
||||
{
|
||||
$watchId = uniqid('watch_');
|
||||
|
||||
$this->watchers[$watchId] = [
|
||||
'key' => $key,
|
||||
'namespace' => $namespace,
|
||||
'callback' => $callback,
|
||||
'created_at' => microtime(true)
|
||||
];
|
||||
|
||||
// Start watching if not already started
|
||||
$this->watcher->watch($key, $namespace, function($newKey, $newValue, $changeNamespace) use ($watchId) {
|
||||
$this->handleWatchChange($watchId, $newKey, $newValue, $changeNamespace);
|
||||
});
|
||||
|
||||
return $watchId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching.
|
||||
*/
|
||||
public function stopWatching(string $watchId): bool
|
||||
{
|
||||
if (!isset($this->watchers[$watchId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$watcher = $this->watchers[$watchId];
|
||||
$this->watcher->unwatch($watcher['key'], $watcher['namespace']);
|
||||
|
||||
unset($this->watchers[$watchId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active watchers.
|
||||
*/
|
||||
public function getWatchers(): array
|
||||
{
|
||||
return $this->watchers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh configuration from remote.
|
||||
*/
|
||||
public function refresh(string $key = null, string $namespace = 'default'): void
|
||||
{
|
||||
if ($key) {
|
||||
// Refresh specific key
|
||||
$value = $this->provider->get($key, $namespace);
|
||||
|
||||
if ($value !== null) {
|
||||
$this->storage->set($key, $value, $namespace);
|
||||
|
||||
$cacheKey = $this->getCacheKey($key, $namespace);
|
||||
$this->cache[$cacheKey] = [
|
||||
'value' => $value,
|
||||
'timestamp' => microtime(true),
|
||||
'source' => 'refresh'
|
||||
];
|
||||
|
||||
$this->notifyWatchers($key, $value, $namespace);
|
||||
}
|
||||
} else {
|
||||
// Refresh all
|
||||
$remoteData = $this->provider->getAll($namespace);
|
||||
|
||||
foreach ($remoteData as $remoteKey => $value) {
|
||||
$this->storage->set($remoteKey, $value, $namespace);
|
||||
|
||||
$cacheKey = $this->getCacheKey($remoteKey, $namespace);
|
||||
$this->cache[$cacheKey] = [
|
||||
'value' => $value,
|
||||
'timestamp' => microtime(true),
|
||||
'source' => 'refresh'
|
||||
];
|
||||
|
||||
$this->notifyWatchers($remoteKey, $value, $namespace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'initialized' => $this->initialized,
|
||||
'provider' => get_class($this->provider),
|
||||
'cache_size' => count($this->cache),
|
||||
'watchers_count' => count($this->watchers),
|
||||
'change_history_count' => count($this->changeHistory),
|
||||
'storage_stats' => $this->storage->getStatistics(),
|
||||
'provider_stats' => $this->provider->getStatistics()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get change history.
|
||||
*/
|
||||
public function getChangeHistory(int $limit = 100): array
|
||||
{
|
||||
return array_slice($this->changeHistory, -$limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export configuration.
|
||||
*/
|
||||
public function export(string $namespace = 'default', bool $includeEncrypted = false): array
|
||||
{
|
||||
$configs = $this->getAll($namespace);
|
||||
|
||||
if (!$includeEncrypted) {
|
||||
// Filter out encrypted values
|
||||
$configs = array_filter($configs, function($value, $key) use ($namespace) {
|
||||
return !$this->shouldEncrypt($key, $namespace);
|
||||
}, ARRAY_FILTER_USE_BOTH);
|
||||
}
|
||||
|
||||
return [
|
||||
'namespace' => $namespace,
|
||||
'configs' => $configs,
|
||||
'exported_at' => date('Y-m-d H:i:s'),
|
||||
'version' => $this->config['version'] ?? '1.0'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Import configuration.
|
||||
*/
|
||||
public function import(array $data, string $namespace = 'default'): array
|
||||
{
|
||||
if (!isset($data['configs'])) {
|
||||
throw new \InvalidArgumentException('Invalid import data format');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($data['configs'] as $key => $value) {
|
||||
$results[$key] = $this->set($key, $value, $namespace);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup configuration.
|
||||
*/
|
||||
public function backup(string $path, string $namespace = 'default'): bool
|
||||
{
|
||||
$data = $this->export($namespace, true);
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$result = file_put_contents($path, $json);
|
||||
|
||||
if ($result !== false) {
|
||||
$this->logInfo("Configuration backed up to: {$path}");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore configuration from backup.
|
||||
*/
|
||||
public function restore(string $path, string $namespace = 'default'): bool
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$json = file_get_contents($path);
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($json, true);
|
||||
$this->import($data, $namespace);
|
||||
|
||||
$this->logInfo("Configuration restored from: {$path}");
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logError("Failed to restore configuration: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache.
|
||||
*/
|
||||
public function clearCache(string $key = null, string $namespace = 'default'): void
|
||||
{
|
||||
if ($key) {
|
||||
$cacheKey = $this->getCacheKey($key, $namespace);
|
||||
unset($this->cache[$cacheKey]);
|
||||
} else {
|
||||
// Clear all cache for namespace
|
||||
$cachePrefix = $namespace . ':';
|
||||
|
||||
foreach ($this->cache as $cacheKey => $data) {
|
||||
if (strpos($cacheKey, $cachePrefix) === 0) {
|
||||
unset($this->cache[$cacheKey]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Cache cleared" . ($key ? " for key: {$key}" : " for namespace: {$namespace}"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists.
|
||||
*/
|
||||
public function has(string $key, string $namespace = 'default'): bool
|
||||
{
|
||||
return $this->get($key, null, $namespace) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration with environment variable fallback.
|
||||
*/
|
||||
public function getWithEnvFallback(string $key, $default = null, string $envVar = null, string $namespace = 'default')
|
||||
{
|
||||
$value = $this->get($key, null, $namespace);
|
||||
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$envVar = $envVar ?: strtoupper(str_replace('.', '_', $key));
|
||||
$envValue = getenv($envVar);
|
||||
|
||||
return $envValue !== false ? $envValue : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch configuration provider.
|
||||
*/
|
||||
public function switchProvider(string $providerType, array $providerConfig = []): void
|
||||
{
|
||||
switch ($providerType) {
|
||||
case 'consul':
|
||||
$this->provider = new ConsulProvider($providerConfig);
|
||||
break;
|
||||
case 'etcd':
|
||||
$this->provider = new EtcdProvider($providerConfig);
|
||||
break;
|
||||
case 'redis':
|
||||
$this->provider = new RedisProvider($providerConfig);
|
||||
break;
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported provider type: {$providerType}");
|
||||
}
|
||||
|
||||
$this->logInfo("Switched to provider: {$providerType}");
|
||||
|
||||
// Reinitialize watcher
|
||||
$this->watcher = new ConfigWatcher($this->provider, $this->config['watcher'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current provider.
|
||||
*/
|
||||
public function getProvider(): ConfigProvider
|
||||
{
|
||||
return $this->provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize config center.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
if ($this->initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize provider
|
||||
$providerType = $this->config['provider']['type'] ?? 'consul';
|
||||
$providerConfig = $this->config['provider']['config'] ?? [];
|
||||
|
||||
switch ($providerType) {
|
||||
case 'consul':
|
||||
$this->provider = new ConsulProvider($providerConfig);
|
||||
break;
|
||||
case 'etcd':
|
||||
$this->provider = new EtcdProvider($providerConfig);
|
||||
break;
|
||||
case 'redis':
|
||||
$this->provider = new RedisProvider($providerConfig);
|
||||
break;
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported provider type: {$providerType}");
|
||||
}
|
||||
|
||||
// Initialize watcher
|
||||
$this->watcher = new ConfigWatcher($this->provider, $this->config['watcher'] ?? []);
|
||||
|
||||
// Load initial configuration
|
||||
$this->loadInitialConfig();
|
||||
|
||||
$this->initialized = true;
|
||||
|
||||
$this->logInfo("Config center initialized with provider: {$providerType}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initial configuration.
|
||||
*/
|
||||
protected function loadInitialConfig(): void
|
||||
{
|
||||
$namespaces = $this->config['namespaces'] ?? ['default'];
|
||||
|
||||
foreach ($namespaces as $namespace) {
|
||||
$configs = $this->provider->getAll($namespace);
|
||||
|
||||
foreach ($configs as $key => $value) {
|
||||
$this->storage->set($key, $value, $namespace);
|
||||
|
||||
$cacheKey = $this->getCacheKey($key, $namespace);
|
||||
$this->cache[$cacheKey] = [
|
||||
'value' => $value,
|
||||
'timestamp' => microtime(true),
|
||||
'source' => 'initial'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle watcher change.
|
||||
*/
|
||||
protected function handleWatchChange(string $watchId, string $key, $value, string $namespace): void
|
||||
{
|
||||
if (!isset($this->watchers[$watchId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$watcher = $this->watchers[$watchId];
|
||||
|
||||
// Update cache and storage
|
||||
if ($value !== null) {
|
||||
$this->storage->set($key, $value, $namespace);
|
||||
|
||||
$cacheKey = $this->getCacheKey($key, $namespace);
|
||||
$this->cache[$cacheKey] = [
|
||||
'value' => $value,
|
||||
'timestamp' => microtime(true),
|
||||
'source' => 'watcher'
|
||||
];
|
||||
} else {
|
||||
$this->storage->delete($key, $namespace);
|
||||
|
||||
$cacheKey = $this->getCacheKey($key, $namespace);
|
||||
unset($this->cache[$cacheKey]);
|
||||
}
|
||||
|
||||
// Record change
|
||||
$this->recordChange($key, $value, $namespace, 'watcher');
|
||||
|
||||
// Call callback
|
||||
try {
|
||||
call_user_func($watcher['callback'], $key, $value, $namespace);
|
||||
} catch (\Exception $e) {
|
||||
$this->logError("Watcher callback error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all watchers for a key.
|
||||
*/
|
||||
protected function notifyWatchers(string $key, $value, string $namespace): void
|
||||
{
|
||||
foreach ($this->watchers as $watchId => $watcher) {
|
||||
if ($watcher['key'] === $key && $watcher['namespace'] === $namespace) {
|
||||
try {
|
||||
call_user_func($watcher['callback'], $key, $value, $namespace);
|
||||
} catch (\Exception $e) {
|
||||
$this->logError("Watcher callback error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record configuration change.
|
||||
*/
|
||||
protected function recordChange(string $key, $value, string $namespace, string $source): void
|
||||
{
|
||||
$this->changeHistory[] = [
|
||||
'key' => $key,
|
||||
'namespace' => $namespace,
|
||||
'value' => $value,
|
||||
'source' => $source,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Limit history size
|
||||
$maxHistory = $this->config['max_change_history'] ?? 1000;
|
||||
if (count($this->changeHistory) > $maxHistory) {
|
||||
$this->changeHistory = array_slice($this->changeHistory, -$maxHistory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key should be encrypted.
|
||||
*/
|
||||
protected function shouldEncrypt(string $key, string $namespace): bool
|
||||
{
|
||||
$encryptPatterns = $this->config['encryption']['patterns'] ?? [];
|
||||
|
||||
foreach ($encryptPatterns as $pattern) {
|
||||
if (fnmatch($pattern, $key) || fnmatch($pattern, "{$namespace}.{$key}")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key.
|
||||
*/
|
||||
protected function getCacheKey(string $key, string $namespace): string
|
||||
{
|
||||
return $namespace . ':' . $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[ConfigCenter] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message.
|
||||
*/
|
||||
protected function logError(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[ConfigCenter] ERROR: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'provider' => [
|
||||
'type' => 'consul',
|
||||
'config' => [
|
||||
'host' => 'localhost',
|
||||
'port' => 8500
|
||||
]
|
||||
],
|
||||
'storage' => [
|
||||
'type' => 'file',
|
||||
'path' => __DIR__ . '/../../../storage/config'
|
||||
],
|
||||
'encryption' => [
|
||||
'enabled' => false,
|
||||
'key' => null,
|
||||
'patterns' => [
|
||||
'*.password',
|
||||
'*.secret',
|
||||
'*.key',
|
||||
'*.token'
|
||||
]
|
||||
],
|
||||
'watcher' => [
|
||||
'enabled' => true,
|
||||
'poll_interval' => 30
|
||||
],
|
||||
'namespaces' => ['default'],
|
||||
'max_change_history' => 1000,
|
||||
'cache_ttl' => 300,
|
||||
'logging_enabled' => true,
|
||||
'version' => '1.0'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create config center instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'provider' => [
|
||||
'type' => 'redis',
|
||||
'config' => [
|
||||
'host' => 'localhost',
|
||||
'port' => 6379
|
||||
]
|
||||
],
|
||||
'encryption' => [
|
||||
'enabled' => false
|
||||
],
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'provider' => [
|
||||
'type' => 'consul',
|
||||
'config' => [
|
||||
'host' => 'consul.example.com',
|
||||
'port' => 8500,
|
||||
'token' => getenv('CONSUL_TOKEN')
|
||||
]
|
||||
],
|
||||
'encryption' => [
|
||||
'enabled' => true,
|
||||
'key' => getenv('CONFIG_ENCRYPTION_KEY')
|
||||
],
|
||||
'logging_enabled' => false
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Config\Encryption;
|
||||
|
||||
use Fendx\Service\Config\Encryption\Key\KeyManager;
|
||||
use Fendx\Service\Config\Encryption\Cipher\CipherManager;
|
||||
use Fendx\Service\Config\Encryption\Provider\EncryptionProvider;
|
||||
|
||||
class ConfigEncryption
|
||||
{
|
||||
protected KeyManager $keyManager;
|
||||
protected CipherManager $cipherManager;
|
||||
protected array $config = [];
|
||||
protected array $encryptionCache = [];
|
||||
protected array $decryptionCache = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->keyManager = new KeyManager($this->config['key_management'] ?? []);
|
||||
$this->cipherManager = new CipherManager($this->config['cipher'] ?? []);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt configuration value.
|
||||
*/
|
||||
public function encrypt($value, string $keyId = null): string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
$cacheKey = $this->getCacheKey($value, $keyId);
|
||||
if (isset($this->encryptionCache[$cacheKey])) {
|
||||
return $this->encryptionCache[$cacheKey];
|
||||
}
|
||||
|
||||
try {
|
||||
// Get encryption key
|
||||
$keyId = $keyId ?? $this->config['default_key_id'] ?? 'default';
|
||||
$encryptionKey = $this->keyManager->getKey($keyId);
|
||||
|
||||
if (!$encryptionKey) {
|
||||
throw new \RuntimeException("Encryption key not found: {$keyId}");
|
||||
}
|
||||
|
||||
// Serialize value if needed
|
||||
$serialized = $this->serializeValue($value);
|
||||
|
||||
// Encrypt
|
||||
$cipher = $this->config['cipher']['algorithm'] ?? 'aes-256-gcm';
|
||||
$encrypted = $this->cipherManager->encrypt($serialized, $encryptionKey, $cipher);
|
||||
|
||||
// Add metadata
|
||||
$result = [
|
||||
'encrypted' => true,
|
||||
'algorithm' => $cipher,
|
||||
'key_id' => $keyId,
|
||||
'data' => base64_encode($encrypted['data']),
|
||||
'iv' => base64_encode($encrypted['iv']),
|
||||
'tag' => isset($encrypted['tag']) ? base64_encode($encrypted['tag']) : null,
|
||||
'timestamp' => microtime(true),
|
||||
'version' => $this->config['version'] ?? '1.0'
|
||||
];
|
||||
|
||||
$encryptedValue = json_encode($result);
|
||||
|
||||
// Cache result
|
||||
$this->encryptionCache[$cacheKey] = $encryptedValue;
|
||||
|
||||
// Limit cache size
|
||||
$this->limitCacheSize($this->encryptionCache);
|
||||
|
||||
$this->logDebug("Encrypted value with key: {$keyId}");
|
||||
|
||||
return $encryptedValue;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logError("Encryption failed: " . $e->getMessage());
|
||||
throw new \RuntimeException("Encryption failed: " . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt configuration value.
|
||||
*/
|
||||
public function decrypt(string $encryptedValue)
|
||||
{
|
||||
if ($encryptedValue === null || $encryptedValue === '') {
|
||||
return $encryptedValue;
|
||||
}
|
||||
|
||||
// Check if value is encrypted
|
||||
if (!$this->isEncrypted($encryptedValue)) {
|
||||
return $encryptedValue;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (isset($this->decryptionCache[$encryptedValue])) {
|
||||
return $this->decryptionCache[$encryptedValue];
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse encrypted data
|
||||
$data = json_decode($encryptedValue, true);
|
||||
if (!$data || !isset($data['encrypted']) || !$data['encrypted']) {
|
||||
throw new \InvalidArgumentException("Invalid encrypted data format");
|
||||
}
|
||||
|
||||
// Get decryption key
|
||||
$keyId = $data['key_id'] ?? 'default';
|
||||
$encryptionKey = $this->keyManager->getKey($keyId);
|
||||
|
||||
if (!$encryptionKey) {
|
||||
throw new \RuntimeException("Decryption key not found: {$keyId}");
|
||||
}
|
||||
|
||||
// Prepare decryption data
|
||||
$encrypted = [
|
||||
'data' => base64_decode($data['data']),
|
||||
'iv' => base64_decode($data['iv']),
|
||||
'tag' => isset($data['tag']) ? base64_decode($data['tag']) : null
|
||||
];
|
||||
|
||||
// Decrypt
|
||||
$cipher = $data['algorithm'] ?? 'aes-256-gcm';
|
||||
$decrypted = $this->cipherManager->decrypt($encrypted, $encryptionKey, $cipher);
|
||||
|
||||
// Deserialize value
|
||||
$value = $this->deserializeValue($decrypted);
|
||||
|
||||
// Cache result
|
||||
$this->decryptionCache[$encryptedValue] = $value;
|
||||
|
||||
// Limit cache size
|
||||
$this->limitCacheSize($this->decryptionCache);
|
||||
|
||||
$this->logDebug("Decrypted value with key: {$keyId}");
|
||||
|
||||
return $value;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logError("Decryption failed: " . $e->getMessage());
|
||||
throw new \RuntimeException("Decryption failed: " . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is encrypted.
|
||||
*/
|
||||
public function isEncrypted($value): bool
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($value, true);
|
||||
return $data && isset($data['encrypted']) && $data['encrypted'] === true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encrypt value with different key.
|
||||
*/
|
||||
public function reEncrypt($value, string $newKeyId, string $oldKeyId = null): string
|
||||
{
|
||||
// Decrypt first if already encrypted
|
||||
if ($this->isEncrypted($value)) {
|
||||
$decrypted = $this->decrypt($value);
|
||||
} else {
|
||||
$decrypted = $value;
|
||||
}
|
||||
|
||||
// Encrypt with new key
|
||||
return $this->encrypt($decrypted, $newKeyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch encrypt values.
|
||||
*/
|
||||
public function encryptBatch(array $values, string $keyId = null): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
try {
|
||||
$results[$key] = [
|
||||
'success' => true,
|
||||
'value' => $this->encrypt($value, $keyId)
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$results[$key] = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch decrypt values.
|
||||
*/
|
||||
public function decryptBatch(array $encryptedValues): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($encryptedValues as $key => $value) {
|
||||
try {
|
||||
$results[$key] = [
|
||||
'success' => true,
|
||||
'value' => $this->decrypt($value)
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$results[$key] = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate encryption key.
|
||||
*/
|
||||
public function rotateKey(string $oldKeyId, string $newKeyId): array
|
||||
{
|
||||
$results = [
|
||||
'rotated' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => []
|
||||
];
|
||||
|
||||
// This would typically scan all configurations and re-encrypt
|
||||
// For now, we'll just return a placeholder result
|
||||
|
||||
$this->logInfo("Key rotation initiated from {$oldKeyId} to {$newKeyId}");
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encryption metadata.
|
||||
*/
|
||||
public function getEncryptionInfo(string $encryptedValue): ?array
|
||||
{
|
||||
if (!$this->isEncrypted($encryptedValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($encryptedValue, true);
|
||||
|
||||
return [
|
||||
'algorithm' => $data['algorithm'] ?? 'unknown',
|
||||
'key_id' => $data['key_id'] ?? 'unknown',
|
||||
'timestamp' => $data['timestamp'] ?? 0,
|
||||
'version' => $data['version'] ?? 'unknown'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate encrypted value.
|
||||
*/
|
||||
public function validateEncryptedValue(string $encryptedValue): array
|
||||
{
|
||||
$result = [
|
||||
'valid' => false,
|
||||
'errors' => []
|
||||
];
|
||||
|
||||
try {
|
||||
if (!$this->isEncrypted($encryptedValue)) {
|
||||
$result['errors'][] = 'Value is not encrypted';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Try to decrypt
|
||||
$this->decrypt($encryptedValue);
|
||||
|
||||
$result['valid'] = true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$result['errors'][] = $e->getMessage();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encryption statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'encryption_cache_size' => count($this->encryptionCache),
|
||||
'decryption_cache_size' => count($this->decryptionCache),
|
||||
'key_stats' => $this->keyManager->getStatistics(),
|
||||
'cipher_stats' => $this->cipherManager->getStatistics(),
|
||||
'supported_algorithms' => $this->cipherManager->getSupportedAlgorithms()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear caches.
|
||||
*/
|
||||
public function clearCaches(): void
|
||||
{
|
||||
$this->encryptionCache = [];
|
||||
$this->decryptionCache = [];
|
||||
|
||||
$this->logInfo("Encryption caches cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encryption/decryption.
|
||||
*/
|
||||
public function test($value, string $keyId = null): array
|
||||
{
|
||||
$result = [
|
||||
'original' => $value,
|
||||
'encrypted' => null,
|
||||
'decrypted' => null,
|
||||
'success' => false,
|
||||
'error' => null
|
||||
];
|
||||
|
||||
try {
|
||||
// Encrypt
|
||||
$encrypted = $this->encrypt($value, $keyId);
|
||||
$result['encrypted'] = $encrypted;
|
||||
|
||||
// Decrypt
|
||||
$decrypted = $this->decrypt($encrypted);
|
||||
$result['decrypted'] = $decrypted;
|
||||
|
||||
// Verify
|
||||
$result['success'] = $this->equals($value, $decrypted);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$result['error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom encryption provider.
|
||||
*/
|
||||
public function addProvider(string $name, EncryptionProvider $provider): void
|
||||
{
|
||||
$this->cipherManager->addProvider($name, $provider);
|
||||
|
||||
$this->logInfo("Added encryption provider: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available algorithms.
|
||||
*/
|
||||
public function getAvailableAlgorithms(): array
|
||||
{
|
||||
return $this->cipherManager->getSupportedAlgorithms();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize encryption system.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Initialize key manager
|
||||
$this->keyManager->initialize();
|
||||
|
||||
// Initialize cipher manager
|
||||
$this->cipherManager->initialize();
|
||||
|
||||
// Validate configuration
|
||||
$this->validateConfiguration();
|
||||
|
||||
$this->logInfo("Config encryption initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration.
|
||||
*/
|
||||
protected function validateConfiguration(): void
|
||||
{
|
||||
// Check if default key exists
|
||||
$defaultKeyId = $this->config['default_key_id'] ?? 'default';
|
||||
$defaultKey = $this->keyManager->getKey($defaultKeyId);
|
||||
|
||||
if (!$defaultKey) {
|
||||
throw new \RuntimeException("Default encryption key not found: {$defaultKeyId}");
|
||||
}
|
||||
|
||||
// Check algorithm support
|
||||
$algorithm = $this->config['cipher']['algorithm'] ?? 'aes-256-gcm';
|
||||
if (!$this->cipherManager->isSupported($algorithm)) {
|
||||
throw new \RuntimeException("Unsupported encryption algorithm: {$algorithm}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize value for encryption.
|
||||
*/
|
||||
protected function serializeValue($value): string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return serialize($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize value after decryption.
|
||||
*/
|
||||
protected function deserializeValue(string $value)
|
||||
{
|
||||
// Try to unserialize first
|
||||
$unserialized = @unserialize($value);
|
||||
|
||||
if ($unserialized !== false || $value === serialize(false)) {
|
||||
return $unserialized;
|
||||
}
|
||||
|
||||
// Return as string if unserialization fails
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key.
|
||||
*/
|
||||
protected function getCacheKey($value, string $keyId = null): string
|
||||
{
|
||||
return md5(serialize($value) . ($keyId ?? 'default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit cache size.
|
||||
*/
|
||||
protected function limitCacheSize(array &$cache): void
|
||||
{
|
||||
$maxSize = $this->config['cache_size'] ?? 1000;
|
||||
|
||||
if (count($cache) > $maxSize) {
|
||||
// Remove oldest entries
|
||||
$cache = array_slice($cache, -$maxSize, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare values for equality.
|
||||
*/
|
||||
protected function equals($value1, $value2): bool
|
||||
{
|
||||
if (is_string($value1) && is_string($value2)) {
|
||||
return $value1 === $value2;
|
||||
}
|
||||
|
||||
return serialize($value1) === serialize($value2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message.
|
||||
*/
|
||||
protected function logDebug(string $message): void
|
||||
{
|
||||
if ($this->config['debug_enabled']) {
|
||||
error_log("[ConfigEncryption] DEBUG: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message.
|
||||
*/
|
||||
protected function logError(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[ConfigEncryption] ERROR: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[ConfigEncryption] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'default_key_id' => 'default',
|
||||
'cache_size' => 1000,
|
||||
'logging_enabled' => true,
|
||||
'debug_enabled' => false,
|
||||
'key_management' => [
|
||||
'storage_type' => 'file',
|
||||
'storage_path' => __DIR__ . '/../../../storage/encryption_keys',
|
||||
'key_rotation_days' => 90
|
||||
],
|
||||
'cipher' => [
|
||||
'algorithm' => 'aes-256-gcm',
|
||||
'providers' => ['openssl']
|
||||
],
|
||||
'version' => '1.0'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create config encryption instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'debug_enabled' => true,
|
||||
'cache_size' => 100,
|
||||
'key_management' => [
|
||||
'storage_type' => 'file',
|
||||
'key_rotation_days' => 30
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'debug_enabled' => false,
|
||||
'cache_size' => 5000,
|
||||
'key_management' => [
|
||||
'storage_type' => 'vault',
|
||||
'key_rotation_days' => 90
|
||||
],
|
||||
'cipher' => [
|
||||
'algorithm' => 'aes-256-gcm'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Config\Updater;
|
||||
|
||||
use Fendx\Service\Config\Updater\Strategy\ImmediateStrategy;
|
||||
use Fendx\Service\Config\Updater\Strategy\BatchStrategy;
|
||||
use Fendx\Service\Config\Updater\Strategy\ScheduledStrategy;
|
||||
use Fendx\Service\Config\Updater\Validator\ConfigValidator;
|
||||
use Fendx\Service\Config\Updater\Notifier\ChangeNotifier;
|
||||
|
||||
class DynamicUpdater
|
||||
{
|
||||
protected array $config = [];
|
||||
protected array $strategies = [];
|
||||
protected ConfigValidator $validator;
|
||||
protected ChangeNotifier $notifier;
|
||||
protected array $pendingUpdates = [];
|
||||
protected array $updateHistory = [];
|
||||
protected array $subscribers = [];
|
||||
protected bool $isRunning = false;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->validator = new ConfigValidator($this->config['validation'] ?? []);
|
||||
$this->notifier = new ChangeNotifier($this->config['notification'] ?? []);
|
||||
|
||||
$this->initializeStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register configuration update.
|
||||
*/
|
||||
public function registerUpdate(string $key, $newValue, array $options = []): string
|
||||
{
|
||||
$updateId = uniqid('update_');
|
||||
|
||||
$update = [
|
||||
'id' => $updateId,
|
||||
'key' => $key,
|
||||
'new_value' => $newValue,
|
||||
'old_value' => $options['old_value'] ?? null,
|
||||
'namespace' => $options['namespace'] ?? 'default',
|
||||
'strategy' => $options['strategy'] ?? $this->config['default_strategy'],
|
||||
'priority' => $options['priority'] ?? 0,
|
||||
'metadata' => $options['metadata'] ?? [],
|
||||
'created_at' => microtime(true),
|
||||
'status' => 'pending',
|
||||
'validation_result' => null,
|
||||
'applied_at' => null,
|
||||
'error' => null
|
||||
];
|
||||
|
||||
// Validate update
|
||||
$validation = $this->validator->validate($key, $newValue, $options['namespace'] ?? 'default');
|
||||
$update['validation_result'] = $validation;
|
||||
|
||||
if (!$validation['valid']) {
|
||||
$update['status'] = 'failed';
|
||||
$update['error'] = 'Validation failed: ' . implode(', ', $validation['errors']);
|
||||
}
|
||||
|
||||
$this->pendingUpdates[$updateId] = $update;
|
||||
|
||||
// Process immediately if strategy requires
|
||||
if ($update['strategy'] === 'immediate') {
|
||||
$this->processUpdate($updateId);
|
||||
}
|
||||
|
||||
$this->logInfo("Registered update {$updateId} for key: {$key}");
|
||||
|
||||
return $updateId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process update.
|
||||
*/
|
||||
public function processUpdate(string $updateId): bool
|
||||
{
|
||||
if (!isset($this->pendingUpdates[$updateId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$update = &$this->pendingUpdates[$updateId];
|
||||
|
||||
if ($update['status'] !== 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get strategy
|
||||
$strategy = $this->strategies[$update['strategy']] ?? null;
|
||||
if (!$strategy) {
|
||||
throw new \InvalidArgumentException("Unknown strategy: {$update['strategy']}");
|
||||
}
|
||||
|
||||
// Execute strategy
|
||||
$result = $strategy->execute($update);
|
||||
|
||||
if ($result['success']) {
|
||||
$update['status'] = 'applied';
|
||||
$update['applied_at'] = microtime(true);
|
||||
|
||||
// Move to history
|
||||
$this->updateHistory[] = $update;
|
||||
unset($this->pendingUpdates[$updateId]);
|
||||
|
||||
// Notify subscribers
|
||||
$this->notifySubscribers($update);
|
||||
|
||||
// Send notifications
|
||||
$this->notifier->notifyChange($update);
|
||||
|
||||
$this->logInfo("Update {$updateId} applied successfully");
|
||||
|
||||
} else {
|
||||
$update['status'] = 'failed';
|
||||
$update['error'] = $result['error'] ?? 'Unknown error';
|
||||
|
||||
$this->logError("Update {$updateId} failed: {$update['error']}");
|
||||
}
|
||||
|
||||
return $result['success'];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$update['status'] = 'failed';
|
||||
$update['error'] = $e->getMessage();
|
||||
|
||||
$this->logError("Update {$updateId} exception: " . $e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to configuration changes.
|
||||
*/
|
||||
public function subscribe(string $pattern, callable $callback, array $options = []): string
|
||||
{
|
||||
$subscriptionId = uniqid('sub_');
|
||||
|
||||
$this->subscribers[$subscriptionId] = [
|
||||
'id' => $subscriptionId,
|
||||
'pattern' => $pattern,
|
||||
'callback' => $callback,
|
||||
'options' => array_merge([
|
||||
'namespace' => 'default',
|
||||
'priority' => 0,
|
||||
'async' => false
|
||||
], $options),
|
||||
'created_at' => microtime(true)
|
||||
];
|
||||
|
||||
$this->logInfo("Added subscription {$subscriptionId} for pattern: {$pattern}");
|
||||
|
||||
return $subscriptionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from configuration changes.
|
||||
*/
|
||||
public function unsubscribe(string $subscriptionId): bool
|
||||
{
|
||||
if (!isset($this->subscribers[$subscriptionId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($this->subscribers[$subscriptionId]);
|
||||
|
||||
$this->logInfo("Removed subscription {$subscriptionId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending updates.
|
||||
*/
|
||||
public function getPendingUpdates(): array
|
||||
{
|
||||
return $this->pendingUpdates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get update history.
|
||||
*/
|
||||
public function getUpdateHistory(int $limit = 100): array
|
||||
{
|
||||
return array_slice($this->updateHistory, -$limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get update by ID.
|
||||
*/
|
||||
public function getUpdate(string $updateId): ?array
|
||||
{
|
||||
return $this->pendingUpdates[$updateId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending update.
|
||||
*/
|
||||
public function cancelUpdate(string $updateId): bool
|
||||
{
|
||||
if (!isset($this->pendingUpdates[$updateId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$update = $this->pendingUpdates[$updateId];
|
||||
|
||||
if ($update['status'] !== 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$update['status'] = 'cancelled';
|
||||
$update['cancelled_at'] = microtime(true);
|
||||
|
||||
// Move to history
|
||||
$this->updateHistory[] = $update;
|
||||
unset($this->pendingUpdates[$updateId]);
|
||||
|
||||
$this->logInfo("Cancelled update {$updateId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed update.
|
||||
*/
|
||||
public function retryUpdate(string $updateId): bool
|
||||
{
|
||||
// Look in history for failed update
|
||||
foreach ($this->updateHistory as $update) {
|
||||
if ($update['id'] === $updateId && $update['status'] === 'failed') {
|
||||
// Reset to pending
|
||||
$update['status'] = 'pending';
|
||||
$update['error'] = null;
|
||||
$update['retry_count'] = ($update['retry_count'] ?? 0) + 1;
|
||||
|
||||
$this->pendingUpdates[$updateId] = $update;
|
||||
|
||||
// Remove from history
|
||||
$this->updateHistory = array_filter($this->updateHistory, function($u) use ($updateId) {
|
||||
return $u['id'] !== $updateId;
|
||||
});
|
||||
|
||||
return $this->processUpdate($updateId);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending updates.
|
||||
*/
|
||||
public function processAllPending(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
// Sort by priority and creation time
|
||||
$sortedUpdates = $this->pendingUpdates;
|
||||
uasort($sortedUpdates, function($a, $b) {
|
||||
if ($a['priority'] !== $b['priority']) {
|
||||
return $b['priority'] <=> $a['priority'];
|
||||
}
|
||||
return $a['created_at'] <=> $b['created_at'];
|
||||
});
|
||||
|
||||
foreach ($sortedUpdates as $updateId => $update) {
|
||||
$results[$updateId] = $this->processUpdate($updateId);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get update statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'pending_updates' => count($this->pendingUpdates),
|
||||
'total_updates' => count($this->updateHistory) + count($this->pendingUpdates),
|
||||
'applied_updates' => 0,
|
||||
'failed_updates' => 0,
|
||||
'cancelled_updates' => 0,
|
||||
'subscribers_count' => count($this->subscribers),
|
||||
'strategy_performance' => []
|
||||
];
|
||||
|
||||
// Analyze history
|
||||
foreach ($this->updateHistory as $update) {
|
||||
switch ($update['status']) {
|
||||
case 'applied':
|
||||
$stats['applied_updates']++;
|
||||
break;
|
||||
case 'failed':
|
||||
$stats['failed_updates']++;
|
||||
break;
|
||||
case 'cancelled':
|
||||
$stats['cancelled_updates']++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Strategy performance
|
||||
$strategy = $update['strategy'];
|
||||
if (!isset($stats['strategy_performance'][$strategy])) {
|
||||
$stats['strategy_performance'][$strategy] = [
|
||||
'total' => 0,
|
||||
'applied' => 0,
|
||||
'failed' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$stats['strategy_performance'][$strategy]['total']++;
|
||||
|
||||
if ($update['status'] === 'applied') {
|
||||
$stats['strategy_performance'][$strategy]['applied']++;
|
||||
} elseif ($update['status'] === 'failed') {
|
||||
$stats['strategy_performance'][$strategy]['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate success rates
|
||||
foreach ($stats['strategy_performance'] as $strategy => &$performance) {
|
||||
if ($performance['total'] > 0) {
|
||||
$performance['success_rate'] = ($performance['applied'] / $performance['total']) * 100;
|
||||
} else {
|
||||
$performance['success_rate'] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start dynamic updater.
|
||||
*/
|
||||
public function start(): void
|
||||
{
|
||||
if ($this->isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isRunning = true;
|
||||
|
||||
// Start background processing if configured
|
||||
if ($this->config['background_processing']) {
|
||||
$this->startBackgroundProcessing();
|
||||
}
|
||||
|
||||
$this->logInfo("Dynamic updater started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop dynamic updater.
|
||||
*/
|
||||
public function stop(): void
|
||||
{
|
||||
if (!$this->isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isRunning = false;
|
||||
|
||||
$this->logInfo("Dynamic updater stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom update strategy.
|
||||
*/
|
||||
public function addStrategy(string $name, callable $strategy): void
|
||||
{
|
||||
$this->strategies[$name] = $strategy;
|
||||
|
||||
$this->logInfo("Added custom strategy: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all strategies.
|
||||
*/
|
||||
public function getStrategies(): array
|
||||
{
|
||||
return array_keys($this->strategies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscribers.
|
||||
*/
|
||||
public function getSubscribers(): array
|
||||
{
|
||||
return $this->subscribers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration before update.
|
||||
*/
|
||||
public function validateConfig(string $key, $value, string $namespace = 'default'): array
|
||||
{
|
||||
return $this->validator->validate($key, $value, $namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export update data.
|
||||
*/
|
||||
public function exportData(): array
|
||||
{
|
||||
return [
|
||||
'config' => $this->config,
|
||||
'pending_updates' => $this->pendingUpdates,
|
||||
'update_history' => $this->updateHistory,
|
||||
'subscribers' => $this->subscribers,
|
||||
'statistics' => $this->getStatistics(),
|
||||
'exported_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify subscribers of change.
|
||||
*/
|
||||
protected function notifySubscribers(array $update): void
|
||||
{
|
||||
foreach ($this->subscribers as $subscriptionId => $subscription) {
|
||||
if ($this->matchesPattern($update['key'], $subscription['pattern']) &&
|
||||
$update['namespace'] === $subscription['options']['namespace']) {
|
||||
|
||||
try {
|
||||
if ($subscription['options']['async']) {
|
||||
// Async notification
|
||||
$this->notifyAsync($subscription, $update);
|
||||
} else {
|
||||
// Sync notification
|
||||
call_user_func($subscription['callback'], $update);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logError("Subscriber notification error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key matches pattern.
|
||||
*/
|
||||
protected function matchesPattern(string $key, string $pattern): bool
|
||||
{
|
||||
// Simple pattern matching (can be extended)
|
||||
if ($pattern === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strpos($pattern, '*') !== false) {
|
||||
return fnmatch($pattern, $key);
|
||||
}
|
||||
|
||||
return $key === $pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify subscriber asynchronously.
|
||||
*/
|
||||
protected function notifyAsync(array $subscription, array $update): void
|
||||
{
|
||||
// This would typically use a queue or background process
|
||||
// For now, we'll just log it
|
||||
$this->logInfo("Async notification scheduled for subscription {$subscription['id']}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize built-in strategies.
|
||||
*/
|
||||
protected function initializeStrategies(): void
|
||||
{
|
||||
$this->strategies['immediate'] = new ImmediateStrategy($this->config);
|
||||
$this->strategies['batch'] = new BatchStrategy($this->config);
|
||||
$this->strategies['scheduled'] = new ScheduledStrategy($this->config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background processing.
|
||||
*/
|
||||
protected function startBackgroundProcessing(): void
|
||||
{
|
||||
// This would typically run as a background process
|
||||
// For now, we'll just log that it would start
|
||||
$this->logInfo("Background processing started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[DynamicUpdater] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message.
|
||||
*/
|
||||
protected function logError(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[DynamicUpdater] ERROR: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'default_strategy' => 'immediate',
|
||||
'background_processing' => true,
|
||||
'processing_interval' => 5, // seconds
|
||||
'batch_size' => 10,
|
||||
'max_retry_attempts' => 3,
|
||||
'retry_delay' => 30, // seconds
|
||||
'logging_enabled' => true,
|
||||
'validation' => [
|
||||
'enabled' => true,
|
||||
'strict_mode' => false
|
||||
],
|
||||
'notification' => [
|
||||
'enabled' => true,
|
||||
'channels' => ['log']
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dynamic updater instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'default_strategy' => 'immediate',
|
||||
'background_processing' => false,
|
||||
'validation' => [
|
||||
'strict_mode' => false
|
||||
],
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'default_strategy' => 'batch',
|
||||
'background_processing' => true,
|
||||
'processing_interval' => 10,
|
||||
'batch_size' => 50,
|
||||
'validation' => [
|
||||
'strict_mode' => true
|
||||
],
|
||||
'logging_enabled' => false
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Config\Version;
|
||||
|
||||
use Fendx\Service\Config\Version\Storage\VersionStorage;
|
||||
use Fendx\Service\Config\Version\Comparator\VersionComparator;
|
||||
use Fendx\Service\Config\Version\Merger\ConfigMerger;
|
||||
use Fendx\Service\Config\Version\Rollback\RollbackManager;
|
||||
|
||||
class VersionManager
|
||||
{
|
||||
protected VersionStorage $storage;
|
||||
protected VersionComparator $comparator;
|
||||
protected ConfigMerger $merger;
|
||||
protected RollbackManager $rollbackManager;
|
||||
protected array $config = [];
|
||||
protected array $versions = [];
|
||||
protected array $branches = [];
|
||||
protected array $tags = [];
|
||||
protected string $currentBranch = 'main';
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->storage = new VersionStorage($this->config['storage'] ?? []);
|
||||
$this->comparator = new VersionComparator($this->config['comparison'] ?? []);
|
||||
$this->merger = new ConfigMerger($this->config['merger'] ?? []);
|
||||
$this->rollbackManager = new RollbackManager($this->config['rollback'] ?? []);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new configuration version.
|
||||
*/
|
||||
public function createVersion(array $configs, string $message = '', array $metadata = []): string
|
||||
{
|
||||
$versionId = $this->generateVersionId();
|
||||
|
||||
$version = [
|
||||
'id' => $versionId,
|
||||
'branch' => $this->currentBranch,
|
||||
'configs' => $configs,
|
||||
'message' => $message,
|
||||
'metadata' => array_merge([
|
||||
'author' => $this->config['default_author'] ?? 'system',
|
||||
'timestamp' => microtime(true),
|
||||
'parent' => $this->getCurrentVersionId()
|
||||
], $metadata),
|
||||
'created_at' => microtime(true),
|
||||
'size' => strlen(serialize($configs))
|
||||
];
|
||||
|
||||
// Calculate version hash
|
||||
$version['hash'] = $this->calculateHash($version);
|
||||
|
||||
// Store version
|
||||
$this->storage->save($versionId, $version);
|
||||
$this->versions[$versionId] = $version;
|
||||
|
||||
// Update branch head
|
||||
$this->branches[$this->currentBranch]['head'] = $versionId;
|
||||
$this->branches[$this->currentBranch]['updated_at'] = microtime(true);
|
||||
|
||||
$this->logInfo("Created version {$versionId} on branch {$this->currentBranch}");
|
||||
|
||||
return $versionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version by ID.
|
||||
*/
|
||||
public function getVersion(string $versionId): ?array
|
||||
{
|
||||
if (!isset($this->versions[$versionId])) {
|
||||
$version = $this->storage->load($versionId);
|
||||
if ($version) {
|
||||
$this->versions[$versionId] = $version;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->versions[$versionId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version.
|
||||
*/
|
||||
public function getCurrentVersion(): ?array
|
||||
{
|
||||
$currentVersionId = $this->getCurrentVersionId();
|
||||
return $currentVersionId ? $this->getVersion($currentVersionId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version ID.
|
||||
*/
|
||||
public function getCurrentVersionId(): ?string
|
||||
{
|
||||
return $this->branches[$this->currentBranch]['head'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version history.
|
||||
*/
|
||||
public function getVersionHistory(string $branch = null, int $limit = 50): array
|
||||
{
|
||||
$branch = $branch ?? $this->currentBranch;
|
||||
$history = [];
|
||||
|
||||
// Start from branch head
|
||||
$versionId = $this->branches[$branch]['head'] ?? null;
|
||||
|
||||
while ($versionId && count($history) < $limit) {
|
||||
$version = $this->getVersion($versionId);
|
||||
if (!$version) {
|
||||
break;
|
||||
}
|
||||
|
||||
$history[] = $version;
|
||||
$versionId = $version['metadata']['parent'] ?? null;
|
||||
}
|
||||
|
||||
return $history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions.
|
||||
*/
|
||||
public function compareVersions(string $versionId1, string $versionId2): array
|
||||
{
|
||||
$version1 = $this->getVersion($versionId1);
|
||||
$version2 = $this->getVersion($versionId2);
|
||||
|
||||
if (!$version1 || !$version2) {
|
||||
throw new \InvalidArgumentException("One or both versions not found");
|
||||
}
|
||||
|
||||
return $this->comparator->compare($version1['configs'], $version2['configs']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create branch.
|
||||
*/
|
||||
public function createBranch(string $branchName, string $fromVersionId = null): bool
|
||||
{
|
||||
if (isset($this->branches[$branchName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fromVersionId = $fromVersionId ?? $this->getCurrentVersionId();
|
||||
|
||||
$this->branches[$branchName] = [
|
||||
'name' => $branchName,
|
||||
'head' => $fromVersionId,
|
||||
'created_at' => microtime(true),
|
||||
'updated_at' => microtime(true),
|
||||
'created_from' => $fromVersionId
|
||||
];
|
||||
|
||||
$this->logInfo("Created branch {$branchName} from version {$fromVersionId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to branch.
|
||||
*/
|
||||
public function switchBranch(string $branchName): bool
|
||||
{
|
||||
if (!isset($this->branches[$branchName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->currentBranch = $branchName;
|
||||
|
||||
$this->logInfo("Switched to branch {$branchName}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current branch.
|
||||
*/
|
||||
public function getCurrentBranch(): string
|
||||
{
|
||||
return $this->currentBranch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all branches.
|
||||
*/
|
||||
public function getBranches(): array
|
||||
{
|
||||
return $this->branches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete branch.
|
||||
*/
|
||||
public function deleteBranch(string $branchName): bool
|
||||
{
|
||||
if ($branchName === $this->currentBranch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isset($this->branches[$branchName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($this->branches[$branchName]);
|
||||
|
||||
$this->logInfo("Deleted branch {$branchName}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge branch.
|
||||
*/
|
||||
public function mergeBranch(string $sourceBranch, string $targetBranch = null, array $options = []): array
|
||||
{
|
||||
$targetBranch = $targetBranch ?? $this->currentBranch;
|
||||
|
||||
if (!isset($this->branches[$sourceBranch]) || !isset($this->branches[$targetBranch])) {
|
||||
throw new \InvalidArgumentException("Branch not found");
|
||||
}
|
||||
|
||||
$sourceVersionId = $this->branches[$sourceBranch]['head'];
|
||||
$targetVersionId = $this->branches[$targetBranch]['head'];
|
||||
|
||||
$sourceVersion = $this->getVersion($sourceVersionId);
|
||||
$targetVersion = $this->getVersion($targetVersionId);
|
||||
|
||||
if (!$sourceVersion || !$targetVersion) {
|
||||
throw new \InvalidArgumentException("Version not found");
|
||||
}
|
||||
|
||||
// Find common ancestor
|
||||
$ancestorId = $this->findCommonAncestor($sourceVersionId, $targetVersionId);
|
||||
$ancestorVersion = $ancestorId ? $this->getVersion($ancestorId) : null;
|
||||
|
||||
// Merge configurations
|
||||
$mergeResult = $this->merger->merge(
|
||||
$ancestorVersion['configs'] ?? [],
|
||||
$targetVersion['configs'],
|
||||
$sourceVersion['configs'],
|
||||
$options
|
||||
);
|
||||
|
||||
if (!$mergeResult['success']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'conflicts' => $mergeResult['conflicts'] ?? [],
|
||||
'message' => 'Merge conflicts detected'
|
||||
];
|
||||
}
|
||||
|
||||
// Create merge commit
|
||||
$mergeVersionId = $this->createVersion(
|
||||
$mergeResult['configs'],
|
||||
"Merge branch {$sourceBranch} into {$targetBranch}",
|
||||
array_merge($options['metadata'] ?? [], [
|
||||
'merge' => true,
|
||||
'source_branch' => $sourceBranch,
|
||||
'target_branch' => $targetBranch,
|
||||
'merge_author' => $this->config['default_author'] ?? 'system'
|
||||
])
|
||||
);
|
||||
|
||||
// Update target branch
|
||||
$this->branches[$targetBranch]['head'] = $mergeVersionId;
|
||||
$this->branches[$targetBranch]['updated_at'] = microtime(true);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'version_id' => $mergeVersionId,
|
||||
'message' => 'Branch merged successfully'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tag.
|
||||
*/
|
||||
public function createTag(string $tagName, string $versionId, array $metadata = []): bool
|
||||
{
|
||||
$version = $this->getVersion($versionId);
|
||||
if (!$version) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($this->tags[$tagName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->tags[$tagName] = [
|
||||
'name' => $tagName,
|
||||
'version_id' => $versionId,
|
||||
'metadata' => array_merge([
|
||||
'created_at' => microtime(true),
|
||||
'author' => $this->config['default_author'] ?? 'system'
|
||||
], $metadata)
|
||||
];
|
||||
|
||||
$this->logInfo("Created tag {$tagName} for version {$versionId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag.
|
||||
*/
|
||||
public function getTag(string $tagName): ?array
|
||||
{
|
||||
return $this->tags[$tagName] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags.
|
||||
*/
|
||||
public function getTags(): array
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete tag.
|
||||
*/
|
||||
public function deleteTag(string $tagName): bool
|
||||
{
|
||||
if (!isset($this->tags[$tagName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($this->tags[$tagName]);
|
||||
|
||||
$this->logInfo("Deleted tag {$tagName}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to version.
|
||||
*/
|
||||
public function rollback(string $versionId, array $options = []): bool
|
||||
{
|
||||
return $this->rollbackManager->rollback($versionId, $this, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rollback history.
|
||||
*/
|
||||
public function getRollbackHistory(int $limit = 50): array
|
||||
{
|
||||
return $this->rollbackManager->getHistory($limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version diff.
|
||||
*/
|
||||
public function getDiff(string $fromVersionId, string $toVersionId): array
|
||||
{
|
||||
$fromVersion = $this->getVersion($fromVersionId);
|
||||
$toVersion = $this->getVersion($toVersionId);
|
||||
|
||||
if (!$fromVersion || !$toVersion) {
|
||||
throw new \InvalidArgumentException("Version not found");
|
||||
}
|
||||
|
||||
return $this->comparator->diff($fromVersion['configs'], $toVersion['configs']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search versions.
|
||||
*/
|
||||
public function searchVersions(array $criteria): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->versions as $versionId => $version) {
|
||||
if ($this->matchesCriteria($version, $criteria)) {
|
||||
$results[$versionId] = $version;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'total_versions' => count($this->versions),
|
||||
'total_branches' => count($this->branches),
|
||||
'total_tags' => count($this->tags),
|
||||
'current_branch' => $this->currentBranch,
|
||||
'current_version' => $this->getCurrentVersionId(),
|
||||
'branch_stats' => [],
|
||||
'storage_stats' => $this->storage->getStatistics()
|
||||
];
|
||||
|
||||
// Branch statistics
|
||||
foreach ($this->branches as $branchName => $branch) {
|
||||
$branchVersions = $this->getVersionHistory($branchName, 1000);
|
||||
|
||||
$stats['branch_stats'][$branchName] = [
|
||||
'name' => $branchName,
|
||||
'versions_count' => count($branchVersions),
|
||||
'head_version' => $branch['head'],
|
||||
'created_at' => $branch['created_at'],
|
||||
'updated_at' => $branch['updated_at']
|
||||
];
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export version data.
|
||||
*/
|
||||
public function exportData(string $format = 'json'): string
|
||||
{
|
||||
$data = [
|
||||
'versions' => $this->versions,
|
||||
'branches' => $this->branches,
|
||||
'tags' => $this->tags,
|
||||
'current_branch' => $this->currentBranch,
|
||||
'statistics' => $this->getStatistics(),
|
||||
'exported_at' => date('Y-m-d H:i:s'),
|
||||
'version' => $this->config['version'] ?? '1.0'
|
||||
];
|
||||
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
case 'php':
|
||||
return '<?php return ' . var_export($data, true) . ';';
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported export format: {$format}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import version data.
|
||||
*/
|
||||
public function importData(string $data, string $format = 'json'): void
|
||||
{
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
$imported = json_decode($data, true);
|
||||
break;
|
||||
case 'php':
|
||||
$imported = include 'data://text/plain;base64,' . base64_encode($data);
|
||||
break;
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported import format: {$format}");
|
||||
}
|
||||
|
||||
if (!$imported) {
|
||||
throw new \InvalidArgumentException("Invalid import data");
|
||||
}
|
||||
|
||||
if (isset($imported['versions'])) {
|
||||
foreach ($imported['versions'] as $versionId => $version) {
|
||||
$this->storage->save($versionId, $version);
|
||||
$this->versions[$versionId] = $version;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($imported['branches'])) {
|
||||
$this->branches = $imported['branches'];
|
||||
}
|
||||
|
||||
if (isset($imported['tags'])) {
|
||||
$this->tags = $imported['tags'];
|
||||
}
|
||||
|
||||
if (isset($imported['current_branch'])) {
|
||||
$this->currentBranch = $imported['current_branch'];
|
||||
}
|
||||
|
||||
$this->logInfo("Version data imported successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old versions.
|
||||
*/
|
||||
public function cleanup(int $maxVersions = 100): array
|
||||
{
|
||||
$cleaned = [];
|
||||
|
||||
foreach ($this->branches as $branchName => $branch) {
|
||||
$history = $this->getVersionHistory($branchName, $maxVersions + 50);
|
||||
|
||||
if (count($history) > $maxVersions) {
|
||||
$toRemove = array_slice($history, $maxVersions);
|
||||
|
||||
foreach ($toRemove as $version) {
|
||||
$this->storage->delete($version['id']);
|
||||
unset($this->versions[$version['id']);
|
||||
$cleaned[] = $version['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Cleaned up " . count($cleaned) . " old versions");
|
||||
|
||||
return $cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize version manager.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Load existing data
|
||||
$this->loadData();
|
||||
|
||||
// Create default branch if not exists
|
||||
if (!isset($this->branches['main'])) {
|
||||
$this->createBranch('main');
|
||||
}
|
||||
|
||||
$this->logInfo("Version manager initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing data.
|
||||
*/
|
||||
protected function loadData(): void
|
||||
{
|
||||
// Load branches
|
||||
$this->branches = $this->storage->loadBranches();
|
||||
|
||||
// Load tags
|
||||
$this->tags = $this->storage->loadTags();
|
||||
|
||||
// Load recent versions
|
||||
$recentVersions = $this->storage->loadRecentVersions(1000);
|
||||
foreach ($recentVersions as $versionId => $version) {
|
||||
$this->versions[$versionId] = $version;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find common ancestor of two versions.
|
||||
*/
|
||||
protected function findCommonAncestor(string $versionId1, string $versionId2): ?string
|
||||
{
|
||||
$ancestors1 = $this->getAncestors($versionId1);
|
||||
$ancestors2 = $this->getAncestors($versionId2);
|
||||
|
||||
foreach ($ancestors1 as $ancestorId) {
|
||||
if (in_array($ancestorId, $ancestors2)) {
|
||||
return $ancestorId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ancestors of a version.
|
||||
*/
|
||||
protected function getAncestors(string $versionId): array
|
||||
{
|
||||
$ancestors = [];
|
||||
$currentId = $versionId;
|
||||
|
||||
while ($currentId) {
|
||||
$version = $this->getVersion($currentId);
|
||||
if (!$version) {
|
||||
break;
|
||||
}
|
||||
|
||||
$parentId = $version['metadata']['parent'] ?? null;
|
||||
if ($parentId) {
|
||||
$ancestors[] = $parentId;
|
||||
}
|
||||
|
||||
$currentId = $parentId;
|
||||
}
|
||||
|
||||
return $ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if version matches search criteria.
|
||||
*/
|
||||
protected function matchesCriteria(array $version, array $criteria): bool
|
||||
{
|
||||
foreach ($criteria as $key => $value) {
|
||||
switch ($key) {
|
||||
case 'branch':
|
||||
if ($version['branch'] !== $value) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'author':
|
||||
if (($version['metadata']['author'] ?? '') !== $value) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'message':
|
||||
if (stripos($version['message'], $value) === false) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'date_from':
|
||||
if ($version['created_at'] < $value) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'date_to':
|
||||
if ($version['created_at'] > $value) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate version ID.
|
||||
*/
|
||||
protected function generateVersionId(): string
|
||||
{
|
||||
return uniqid('v_') . '_' . time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate version hash.
|
||||
*/
|
||||
protected function calculateHash(array $version): string
|
||||
{
|
||||
$data = serialize($version['configs']);
|
||||
return hash('sha256', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[VersionManager] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'default_author' => 'system',
|
||||
'max_versions_per_branch' => 1000,
|
||||
'auto_cleanup' => false,
|
||||
'logging_enabled' => true,
|
||||
'storage' => [
|
||||
'type' => 'file',
|
||||
'path' => __DIR__ . '/../../../storage/config_versions'
|
||||
],
|
||||
'comparison' => [
|
||||
'ignore_whitespace' => false,
|
||||
'case_sensitive' => true
|
||||
],
|
||||
'merger' => [
|
||||
'conflict_resolution' => 'manual',
|
||||
'auto_merge' => true
|
||||
],
|
||||
'rollback' => [
|
||||
'create_backup' => true,
|
||||
'max_rollback_history' => 50
|
||||
],
|
||||
'version' => '1.0'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create version manager instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'max_versions_per_branch' => 100,
|
||||
'auto_cleanup' => true,
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'max_versions_per_branch' => 1000,
|
||||
'auto_cleanup' => false,
|
||||
'logging_enabled' => false,
|
||||
'storage' => [
|
||||
'type' => 'database',
|
||||
'connection' => 'config_versions'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
685
fendx-framework/fendx-service/src/Discovery/ServiceDiscovery.php
Normal file
685
fendx-framework/fendx-service/src/Discovery/ServiceDiscovery.php
Normal file
@@ -0,0 +1,685 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Discovery;
|
||||
|
||||
use Fendx\Service\Discovery\Resolver\ServiceResolver;
|
||||
use Fendx\Service\Discovery\Cache\DiscoveryCache;
|
||||
use Fendx\Service\Discovery\Watcher\ServiceWatcher;
|
||||
use Fendx\Service\Discovery\LoadBalancer\LoadBalancer;
|
||||
|
||||
class ServiceDiscovery
|
||||
{
|
||||
protected ServiceResolver $resolver;
|
||||
protected DiscoveryCache $cache;
|
||||
protected ServiceWatcher $watcher;
|
||||
protected LoadBalancer $loadBalancer;
|
||||
protected array $config = [];
|
||||
protected array $discoveredServices = [];
|
||||
protected array $serviceEndpoints = [];
|
||||
protected array $watchers = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->resolver = new ServiceResolver($this->config);
|
||||
$this->cache = new DiscoveryCache($this->config);
|
||||
$this->watcher = new ServiceWatcher($this->config);
|
||||
$this->loadBalancer = new LoadBalancer($this->config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover service instances.
|
||||
*/
|
||||
public function discover(string $serviceName, array $options = []): array
|
||||
{
|
||||
$cacheKey = $this->generateCacheKey($serviceName, $options);
|
||||
|
||||
// Check cache first
|
||||
if ($this->config['cache_enabled']) {
|
||||
$cached = $this->cache->get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$this->logDebug("Service discovered from cache: {$serviceName}");
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Discover from resolver
|
||||
$instances = $this->resolver->resolve($serviceName, $options);
|
||||
|
||||
// Filter and validate instances
|
||||
$validInstances = $this->filterValidInstances($instances);
|
||||
|
||||
// Cache results
|
||||
if ($this->config['cache_enabled'] && !empty($validInstances)) {
|
||||
$ttl = $options['cache_ttl'] ?? $this->config['default_cache_ttl'];
|
||||
$this->cache->set($cacheKey, $validInstances, $ttl);
|
||||
}
|
||||
|
||||
$this->discoveredServices[$serviceName] = $validInstances;
|
||||
$this->logInfo("Discovered " . count($validInstances) . " instances for service: {$serviceName}");
|
||||
|
||||
return $validInstances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single service instance.
|
||||
*/
|
||||
public function getInstance(string $serviceName, array $options = []): ?array
|
||||
{
|
||||
$instances = $this->discover($serviceName, $options);
|
||||
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use load balancer to select instance
|
||||
$strategy = $options['load_balancing'] ?? $this->config['default_load_balancing'];
|
||||
|
||||
return $this->loadBalancer->select($instances, $strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service URL.
|
||||
*/
|
||||
public function getServiceUrl(string $serviceName, array $options = []): ?string
|
||||
{
|
||||
$instance = $this->getInstance($serviceName, $options);
|
||||
|
||||
if (!$instance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildServiceUrl($instance, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover multiple services.
|
||||
*/
|
||||
public function discoverMultiple(array $serviceNames, array $options = []): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($serviceNames as $serviceName) {
|
||||
$results[$serviceName] = $this->discover($serviceName, $options);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for service changes.
|
||||
*/
|
||||
public function watch(string $serviceName, callable $callback, array $options = []): string
|
||||
{
|
||||
$watchId = $this->generateWatchId($serviceName);
|
||||
|
||||
$this->watchers[$watchId] = [
|
||||
'service_name' => $serviceName,
|
||||
'callback' => $callback,
|
||||
'options' => $options,
|
||||
'last_instances' => $this->discover($serviceName, $options),
|
||||
'created_at' => time()
|
||||
];
|
||||
|
||||
$this->watcher->startWatching($serviceName, $callback, $options);
|
||||
|
||||
$this->logInfo("Started watching service: {$serviceName} ({$watchId})");
|
||||
|
||||
return $watchId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching service.
|
||||
*/
|
||||
public function stopWatching(string $watchId): bool
|
||||
{
|
||||
if (!isset($this->watchers[$watchId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$watch = $this->watchers[$watchId];
|
||||
$this->watcher->stopWatching($watch['service_name'], $watch['callback']);
|
||||
|
||||
unset($this->watchers[$watchId]);
|
||||
|
||||
$this->logInfo("Stopped watching service: {$watch['service_name']} ({$watchId})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all discovered services.
|
||||
*/
|
||||
public function getDiscoveredServices(): array
|
||||
{
|
||||
return $this->discoveredServices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh service discovery.
|
||||
*/
|
||||
public function refresh(string $serviceName = null): void
|
||||
{
|
||||
if ($serviceName) {
|
||||
// Clear cache for specific service
|
||||
$this->clearServiceCache($serviceName);
|
||||
// Rediscover
|
||||
$this->discover($serviceName);
|
||||
$this->logInfo("Refreshed service: {$serviceName}");
|
||||
} else {
|
||||
// Clear all cache
|
||||
$this->cache->clear();
|
||||
// Rediscover all services
|
||||
$this->discoveredServices = [];
|
||||
$this->logInfo("Refreshed all services");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add service endpoint.
|
||||
*/
|
||||
public function addEndpoint(string $serviceName, array $endpoint): void
|
||||
{
|
||||
if (!isset($this->serviceEndpoints[$serviceName])) {
|
||||
$this->serviceEndpoints[$serviceName] = [];
|
||||
}
|
||||
|
||||
$this->serviceEndpoints[$serviceName][] = $endpoint;
|
||||
|
||||
// Clear cache to force rediscovery
|
||||
$this->clearServiceCache($serviceName);
|
||||
|
||||
$this->logInfo("Added endpoint for service: {$serviceName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove service endpoint.
|
||||
*/
|
||||
public function removeEndpoint(string $serviceName, string $endpointId): bool
|
||||
{
|
||||
if (!isset($this->serviceEndpoints[$serviceName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->serviceEndpoints[$serviceName] as $key => $endpoint) {
|
||||
if ($endpoint['id'] === $endpointId) {
|
||||
unset($this->serviceEndpoints[$serviceName][$key]);
|
||||
$this->serviceEndpoints[$serviceName] = array_values($this->serviceEndpoints[$serviceName]);
|
||||
|
||||
// Clear cache to force rediscovery
|
||||
$this->clearServiceCache($serviceName);
|
||||
|
||||
$this->logInfo("Removed endpoint from service: {$serviceName}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service endpoints.
|
||||
*/
|
||||
public function getEndpoints(string $serviceName): array
|
||||
{
|
||||
return $this->serviceEndpoints[$serviceName] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if service is available.
|
||||
*/
|
||||
public function isAvailable(string $serviceName, array $options = []): bool
|
||||
{
|
||||
$instances = $this->discover($serviceName, $options);
|
||||
|
||||
if (empty($instances)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any instance is healthy
|
||||
foreach ($instances as $instance) {
|
||||
if ($this->isInstanceHealthy($instance)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service health status.
|
||||
*/
|
||||
public function getHealthStatus(string $serviceName): array
|
||||
{
|
||||
$instances = $this->discover($serviceName);
|
||||
|
||||
if (empty($instances)) {
|
||||
return [
|
||||
'service' => $serviceName,
|
||||
'status' => 'unknown',
|
||||
'instances' => 0,
|
||||
'healthy_instances' => 0,
|
||||
'unhealthy_instances' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$healthyCount = 0;
|
||||
$instanceStatuses = [];
|
||||
|
||||
foreach ($instances as $instance) {
|
||||
$isHealthy = $this->isInstanceHealthy($instance);
|
||||
if ($isHealthy) {
|
||||
$healthyCount++;
|
||||
}
|
||||
|
||||
$instanceStatuses[] = [
|
||||
'id' => $instance['id'],
|
||||
'host' => $instance['host'],
|
||||
'port' => $instance['port'],
|
||||
'healthy' => $isHealthy,
|
||||
'last_check' => time()
|
||||
];
|
||||
}
|
||||
|
||||
$status = $healthyCount === count($instances) ? 'healthy' :
|
||||
($healthyCount > 0 ? 'degraded' : 'unhealthy');
|
||||
|
||||
return [
|
||||
'service' => $serviceName,
|
||||
'status' => $status,
|
||||
'instances' => count($instances),
|
||||
'healthy_instances' => $healthyCount,
|
||||
'unhealthy_instances' => count($instances) - $healthyCount,
|
||||
'instance_details' => $instanceStatuses
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discovery statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$totalServices = count($this->discoveredServices);
|
||||
$totalInstances = 0;
|
||||
$healthyInstances = 0;
|
||||
$cacheStats = $this->cache->getStatistics();
|
||||
|
||||
foreach ($this->discoveredServices as $serviceName => $instances) {
|
||||
$totalInstances += count($instances);
|
||||
|
||||
foreach ($instances as $instance) {
|
||||
if ($this->isInstanceHealthy($instance)) {
|
||||
$healthyInstances++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_services' => $totalServices,
|
||||
'total_instances' => $totalInstances,
|
||||
'healthy_instances' => $healthyInstances,
|
||||
'unhealthy_instances' => $totalInstances - $healthyInstances,
|
||||
'health_percentage' => $totalInstances > 0 ? ($healthyInstances / $totalInstances) * 100 : 0,
|
||||
'active_watchers' => count($this->watchers),
|
||||
'cache_stats' => $cacheStats,
|
||||
'endpoints' => array_sum(array_map('count', $this->serviceEndpoints))
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set service priority.
|
||||
*/
|
||||
public function setPriority(string $serviceName, array $priorities): void
|
||||
{
|
||||
$this->loadBalancer->setPriorities($serviceName, $priorities);
|
||||
|
||||
// Clear cache to apply new priorities
|
||||
$this->clearServiceCache($serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service priority.
|
||||
*/
|
||||
public function getPriority(string $serviceName): array
|
||||
{
|
||||
return $this->loadBalancer->getPriorities($serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable service discovery.
|
||||
*/
|
||||
public function setEnabled(bool $enabled): void
|
||||
{
|
||||
$this->config['enabled'] = $enabled;
|
||||
|
||||
if (!$enabled) {
|
||||
// Stop all watchers
|
||||
foreach ($this->watchers as $watchId => $watch) {
|
||||
$this->stopWatching($watchId);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Service discovery " . ($enabled ? 'enabled' : 'disabled'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if discovery is enabled.
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->config['enabled'] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear service cache.
|
||||
*/
|
||||
protected function clearServiceCache(string $serviceName): void
|
||||
{
|
||||
if ($this->config['cache_enabled']) {
|
||||
// Clear all cache keys for this service
|
||||
$pattern = $this->generateCacheKey($serviceName);
|
||||
$this->cache->clearPattern($pattern);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter valid instances.
|
||||
*/
|
||||
protected function filterValidInstances(array $instances): array
|
||||
{
|
||||
return array_filter($instances, function ($instance) {
|
||||
// Check required fields
|
||||
if (!isset($instance['host']) || !isset($instance['port'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if enabled
|
||||
if (isset($instance['enabled']) && !$instance['enabled']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check health if required
|
||||
if ($this->config['check_health'] && !$this->isInstanceHealthy($instance)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if instance is healthy.
|
||||
*/
|
||||
protected function isInstanceHealthy(array $instance): bool
|
||||
{
|
||||
// If instance has health status, use it
|
||||
if (isset($instance['healthy'])) {
|
||||
return $instance['healthy'];
|
||||
}
|
||||
|
||||
// Otherwise, perform health check
|
||||
return $this->performHealthCheck($instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform health check on instance.
|
||||
*/
|
||||
protected function performHealthCheck(array $instance): bool
|
||||
{
|
||||
$timeout = $this->config['health_check_timeout'] ?? 5;
|
||||
|
||||
try {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => $timeout,
|
||||
'method' => 'GET'
|
||||
]
|
||||
]);
|
||||
|
||||
$url = $this->buildServiceUrl($instance) . '/health';
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
|
||||
if ($response === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to parse JSON response
|
||||
$data = json_decode($response, true);
|
||||
if ($data && isset($data['status'])) {
|
||||
return $data['status'] === 'healthy' || $data['status'] === 'ok';
|
||||
}
|
||||
|
||||
// If no JSON, consider any response as healthy
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build service URL.
|
||||
*/
|
||||
protected function buildServiceUrl(array $instance, array $options = []): string
|
||||
{
|
||||
$protocol = $options['protocol'] ?? $instance['protocol'] ?? 'http';
|
||||
$host = $instance['host'];
|
||||
$port = $instance['port'];
|
||||
$path = $options['path'] ?? $instance['path'] ?? '/';
|
||||
|
||||
$url = "{$protocol}://{$host}";
|
||||
|
||||
// Add port if not default
|
||||
if (($protocol === 'http' && $port != 80) || ($protocol === 'https' && $port != 443)) {
|
||||
$url .= ":{$port}";
|
||||
}
|
||||
|
||||
$url .= $path;
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key.
|
||||
*/
|
||||
protected function generateCacheKey(string $serviceName, array $options = []): string
|
||||
{
|
||||
$key = "service:{$serviceName}";
|
||||
|
||||
if (!empty($options)) {
|
||||
ksort($options);
|
||||
$key .= ':' . md5(serialize($options));
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watch ID.
|
||||
*/
|
||||
protected function generateWatchId(string $serviceName): string
|
||||
{
|
||||
return $serviceName . '_' . uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize discovery.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Initialize resolver
|
||||
$this->resolver->initialize();
|
||||
|
||||
// Initialize cache
|
||||
if ($this->config['cache_enabled']) {
|
||||
$this->cache->initialize();
|
||||
}
|
||||
|
||||
// Initialize watcher
|
||||
$this->watcher->initialize();
|
||||
|
||||
// Start background tasks
|
||||
if ($this->config['background_refresh']) {
|
||||
$this->startBackgroundRefresh();
|
||||
}
|
||||
|
||||
$this->logInfo("Service discovery initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background refresh.
|
||||
*/
|
||||
protected function startBackgroundRefresh(): void
|
||||
{
|
||||
// This would typically be run as a background process
|
||||
// For now, we'll just log that it would start
|
||||
$this->logInfo("Background refresh started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[ServiceDiscovery] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message.
|
||||
*/
|
||||
protected function logDebug(string $message): void
|
||||
{
|
||||
if ($this->config['debug_enabled']) {
|
||||
error_log("[ServiceDiscovery] DEBUG: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => true,
|
||||
'cache_enabled' => true,
|
||||
'default_cache_ttl' => 60,
|
||||
'check_health' => true,
|
||||
'health_check_timeout' => 5,
|
||||
'default_load_balancing' => 'round_robin',
|
||||
'background_refresh' => true,
|
||||
'refresh_interval' => 30,
|
||||
'logging_enabled' => true,
|
||||
'debug_enabled' => false,
|
||||
'resolver' => [
|
||||
'type' => 'consul',
|
||||
'host' => 'localhost',
|
||||
'port' => 8500
|
||||
],
|
||||
'cache' => [
|
||||
'type' => 'redis',
|
||||
'host' => 'localhost',
|
||||
'port' => 6379,
|
||||
'prefix' => 'discovery'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create discovery instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for Consul.
|
||||
*/
|
||||
public static function forConsul(string $host = 'localhost', int $port = 8500): self
|
||||
{
|
||||
return new self([
|
||||
'resolver' => [
|
||||
'type' => 'consul',
|
||||
'host' => $host,
|
||||
'port' => $port
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for Eureka.
|
||||
*/
|
||||
public static function forEureka(string $host = 'localhost', int $port = 8761): self
|
||||
{
|
||||
return new self([
|
||||
'resolver' => [
|
||||
'type' => 'eureka',
|
||||
'host' => $host,
|
||||
'port' => $port
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for Kubernetes.
|
||||
*/
|
||||
public static function forKubernetes(): self
|
||||
{
|
||||
return new self([
|
||||
'resolver' => [
|
||||
'type' => 'kubernetes',
|
||||
'in_cluster' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'cache_enabled' => false,
|
||||
'check_health' => false,
|
||||
'background_refresh' => false,
|
||||
'debug_enabled' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'cache_enabled' => true,
|
||||
'default_cache_ttl' => 300,
|
||||
'check_health' => true,
|
||||
'health_check_timeout' => 3,
|
||||
'background_refresh' => true,
|
||||
'refresh_interval' => 60,
|
||||
'logging_enabled' => false
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,771 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Documentation;
|
||||
|
||||
use Fendx\Service\Documentation\Comment\PhpCommentParser;
|
||||
use Fendx\Service\Documentation\Comment\CommentAnalyzer;
|
||||
use Fendx\Service\Documentation\Comment\CoverageCalculator;
|
||||
use Fendx\Service\Documentation\Comment\QualityAssessor;
|
||||
use Fendx\Service\Documentation\Reporter\CommentReporter;
|
||||
|
||||
class CommentCoverageChecker
|
||||
{
|
||||
protected array $config = [];
|
||||
protected PhpCommentParser $commentParser;
|
||||
protected CommentAnalyzer $commentAnalyzer;
|
||||
protected CoverageCalculator $coverageCalculator;
|
||||
protected QualityAssessor $qualityAssessor;
|
||||
protected CommentReporter $reporter;
|
||||
protected array $coverageResults = [];
|
||||
protected array $fileMetrics = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->commentParser = new PhpCommentParser($this->config['comment_parser'] ?? []);
|
||||
$this->commentAnalyzer = new CommentAnalyzer($this->config['comment_analyzer'] ?? []);
|
||||
$this->coverageCalculator = new CoverageCalculator($this->config['coverage_calculator'] ?? []);
|
||||
$this->qualityAssessor = new QualityAssessor($this->config['quality_assessor'] ?? []);
|
||||
$this->reporter = new CommentReporter($this->config['reporter'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check comment coverage for a project.
|
||||
*/
|
||||
public function checkCommentCoverage(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['coverage_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'comment_coverage',
|
||||
'project_path' => $projectPath,
|
||||
'files_analyzed' => 0,
|
||||
'total_classes' => 0,
|
||||
'total_methods' => 0,
|
||||
'total_properties' => 0,
|
||||
'commented_classes' => 0,
|
||||
'commented_methods' => 0,
|
||||
'commented_properties' => 0,
|
||||
'class_coverage' => 0,
|
||||
'method_coverage' => 0,
|
||||
'property_coverage' => 0,
|
||||
'overall_coverage' => 0,
|
||||
'quality_score' => 0,
|
||||
'uncommented_items' => [],
|
||||
'quality_issues' => [],
|
||||
'recommendations' => [],
|
||||
'check_duration' => 0,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Find PHP files
|
||||
$files = $this->findPhpFiles($projectPath, $checkConfig);
|
||||
$result['files_analyzed'] = count($files);
|
||||
|
||||
$totalMetrics = [
|
||||
'classes' => ['total' => 0, 'commented' => 0],
|
||||
'methods' => ['total' => 0, 'commented' => 0],
|
||||
'properties' => ['total' => 0, 'commented' => 0]
|
||||
];
|
||||
|
||||
$allUncommented = [];
|
||||
$allQualityIssues = [];
|
||||
$fileMetrics = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$fileResult = $this->analyzeFileComments($file, $checkConfig);
|
||||
|
||||
// Aggregate metrics
|
||||
foreach (['classes', 'methods', 'properties'] as $type) {
|
||||
$totalMetrics[$type]['total'] += $fileResult[$type]['total'];
|
||||
$totalMetrics[$type]['commented'] += $fileResult[$type]['commented'];
|
||||
}
|
||||
|
||||
if (!empty($fileResult['uncommented'])) {
|
||||
$allUncommented = array_merge($allUncommented, $fileResult['uncommented']);
|
||||
}
|
||||
|
||||
if (!empty($fileResult['quality_issues'])) {
|
||||
$allQualityIssues = array_merge($allQualityIssues, $fileResult['quality_issues']);
|
||||
}
|
||||
|
||||
$fileMetrics[$file] = $fileResult;
|
||||
}
|
||||
|
||||
$result['total_classes'] = $totalMetrics['classes']['total'];
|
||||
$result['total_methods'] = $totalMetrics['methods']['total'];
|
||||
$result['total_properties'] = $totalMetrics['properties']['total'];
|
||||
$result['commented_classes'] = $totalMetrics['classes']['commented'];
|
||||
$result['commented_methods'] = $totalMetrics['methods']['commented'];
|
||||
$result['commented_properties'] = $totalMetrics['properties']['commented'];
|
||||
|
||||
// Calculate coverage percentages
|
||||
$result['class_coverage'] = $this->calculateCoverage($totalMetrics['classes']);
|
||||
$result['method_coverage'] = $this->calculateCoverage($totalMetrics['methods']);
|
||||
$result['property_coverage'] = $this->calculateCoverage($totalMetrics['properties']);
|
||||
$result['overall_coverage'] = round(($result['class_coverage'] + $result['method_coverage'] + $result['property_coverage']) / 3, 2);
|
||||
|
||||
$result['uncommented_items'] = $allUncommented;
|
||||
$result['quality_issues'] = $allQualityIssues;
|
||||
$result['quality_score'] = $this->calculateQualityScore($result);
|
||||
$result['recommendations'] = $this->generateCoverageRecommendations($result);
|
||||
|
||||
$result['check_duration'] = microtime(true) - $startTime;
|
||||
$this->fileMetrics = $fileMetrics;
|
||||
|
||||
// Store result
|
||||
$this->coverageResults[] = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check comment quality for specific files.
|
||||
*/
|
||||
public function checkCommentQuality(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['quality_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'comment_quality',
|
||||
'project_path' => $projectPath,
|
||||
'files_analyzed' => 0,
|
||||
'quality_score' => 0,
|
||||
'quality_distribution' => [
|
||||
'excellent' => 0,
|
||||
'good' => 0,
|
||||
'fair' => 0,
|
||||
'poor' => 0
|
||||
],
|
||||
'quality_issues' => [],
|
||||
'best_practices_violations' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$files = $this->findPhpFiles($projectPath, $checkConfig);
|
||||
$result['files_analyzed'] = count($files);
|
||||
|
||||
$allQualityIssues = [];
|
||||
$allViolations = [];
|
||||
$qualityScores = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$qualityResult = $this->assessCommentQuality($file, $checkConfig);
|
||||
|
||||
$qualityScores[] = $qualityResult['quality_score'];
|
||||
|
||||
if (!empty($qualityResult['issues'])) {
|
||||
$allQualityIssues = array_merge($allQualityIssues, $qualityResult['issues']);
|
||||
}
|
||||
|
||||
if (!empty($qualityResult['violations'])) {
|
||||
$allViolations = array_merge($allViolations, $qualityResult['violations']);
|
||||
}
|
||||
}
|
||||
|
||||
$result['quality_score'] = count($qualityScores) > 0 ?
|
||||
round(array_sum($qualityScores) / count($qualityScores), 2) : 0;
|
||||
|
||||
// Calculate quality distribution
|
||||
foreach ($qualityScores as $score) {
|
||||
if ($score >= 90) {
|
||||
$result['quality_distribution']['excellent']++;
|
||||
} elseif ($score >= 75) {
|
||||
$result['quality_distribution']['good']++;
|
||||
} elseif ($score >= 60) {
|
||||
$result['quality_distribution']['fair']++;
|
||||
} else {
|
||||
$result['quality_distribution']['poor']++;
|
||||
}
|
||||
}
|
||||
|
||||
$result['quality_issues'] = $allQualityIssues;
|
||||
$result['best_practices_violations'] = $allViolations;
|
||||
$result['recommendations'] = $this->generateQualityRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check documentation standards compliance.
|
||||
*/
|
||||
public function checkDocumentationStandards(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['standards_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'documentation_standards',
|
||||
'project_path' => $projectPath,
|
||||
'files_analyzed' => 0,
|
||||
'standards_compliant' => 0,
|
||||
'compliance_rate' => 0,
|
||||
'standards_violations' => [],
|
||||
'compliance_issues' => [
|
||||
'missing_doc_blocks' => 0,
|
||||
'incomplete_doc_blocks' => 0,
|
||||
'invalid_format' => 0,
|
||||
'missing_examples' => 0
|
||||
],
|
||||
'compliance_score' => 0,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$files = $this->findPhpFiles($projectPath, $checkConfig);
|
||||
$result['files_analyzed'] = count($files);
|
||||
|
||||
$compliantFiles = 0;
|
||||
$allViolations = [];
|
||||
$complianceIssues = [
|
||||
'missing_doc_blocks' => 0,
|
||||
'incomplete_doc_blocks' => 0,
|
||||
'invalid_format' => 0,
|
||||
'missing_examples' => 0
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$standardsResult = $this->checkFileStandards($file, $checkConfig);
|
||||
|
||||
if ($standardsResult['compliant']) {
|
||||
$compliantFiles++;
|
||||
}
|
||||
|
||||
if (!empty($standardsResult['violations'])) {
|
||||
$allViolations = array_merge($allViolations, $standardsResult['violations']);
|
||||
}
|
||||
|
||||
// Aggregate compliance issues
|
||||
foreach ($complianceIssues as $issue => $count) {
|
||||
$complianceIssues[$issue] += $standardsResult['compliance_issues'][$issue] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
$result['standards_compliant'] = $compliantFiles;
|
||||
$result['compliance_rate'] = $result['files_analyzed'] > 0 ?
|
||||
round(($compliantFiles / $result['files_analyzed']) * 100, 2) : 0;
|
||||
$result['standards_violations'] = $allViolations;
|
||||
$result['compliance_issues'] = $complianceIssues;
|
||||
$result['compliance_score'] = $this->calculateComplianceScore($result);
|
||||
$result['recommendations'] = $this->generateStandardsRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate comment coverage report.
|
||||
*/
|
||||
public function generateCoverageReport(array $results, string $format = 'html'): string
|
||||
{
|
||||
return $this->reporter->generate($results, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coverage statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'total_checks' => count($this->coverageResults),
|
||||
'files_analyzed' => $this->countTotalFilesAnalyzed(),
|
||||
'average_coverage' => $this->calculateAverageCoverage(),
|
||||
'quality_scores' => $this->getQualityScores(),
|
||||
'common_issues' => $this->getCommonIssues(),
|
||||
'coverage_trends' => $this->getCoverageTrends()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear coverage results.
|
||||
*/
|
||||
public function clearResults(): void
|
||||
{
|
||||
$this->coverageResults = [];
|
||||
$this->fileMetrics = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find PHP files.
|
||||
*/
|
||||
protected function findPhpFiles(string $path, array $config): array
|
||||
{
|
||||
$files = [];
|
||||
$exclude = $config['exclude'] ?? ['vendor', 'node_modules', '.git', 'tests'];
|
||||
$include = $config['include'] ?? ['*.php'];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($path)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = $file->getPathname();
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
|
||||
if ($extension === 'php') {
|
||||
$excluded = false;
|
||||
foreach ($exclude as $pattern) {
|
||||
if (strpos($filePath, $pattern) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$excluded) {
|
||||
$files[] = $filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze file comments.
|
||||
*/
|
||||
protected function analyzeFileComments(string $file, array $config): array
|
||||
{
|
||||
$content = file_get_contents($file);
|
||||
$parsedCode = $this->commentParser->parseFile($content);
|
||||
|
||||
$result = [
|
||||
'file' => $file,
|
||||
'classes' => ['total' => 0, 'commented' => 0],
|
||||
'methods' => ['total' => 0, 'commented' => 0],
|
||||
'properties' => ['total' => 0, 'commented' => 0],
|
||||
'uncommented' => [],
|
||||
'quality_issues' => []
|
||||
];
|
||||
|
||||
// Analyze classes
|
||||
foreach ($parsedCode['classes'] as $class) {
|
||||
$result['classes']['total']++;
|
||||
|
||||
if ($class['has_comment']) {
|
||||
$result['classes']['commented']++;
|
||||
} else {
|
||||
$result['uncommented'][] = [
|
||||
'type' => 'class',
|
||||
'name' => $class['name'],
|
||||
'file' => $file,
|
||||
'line' => $class['line']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze methods
|
||||
foreach ($parsedCode['methods'] as $method) {
|
||||
$result['methods']['total']++;
|
||||
|
||||
if ($method['has_comment']) {
|
||||
$result['methods']['commented']++;
|
||||
} else {
|
||||
$result['uncommented'][] = [
|
||||
'type' => 'method',
|
||||
'name' => $method['name'],
|
||||
'class' => $method['class'] ?? 'global',
|
||||
'file' => $file,
|
||||
'line' => $method['line']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze properties
|
||||
foreach ($parsedCode['properties'] as $property) {
|
||||
$result['properties']['total']++;
|
||||
|
||||
if ($property['has_comment']) {
|
||||
$result['properties']['commented']++;
|
||||
} else {
|
||||
$result['uncommented'][] = [
|
||||
'type' => 'property',
|
||||
'name' => $property['name'],
|
||||
'class' => $property['class'] ?? 'global',
|
||||
'file' => $file,
|
||||
'line' => $property['line']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check quality issues
|
||||
$result['quality_issues'] = $this->commentAnalyzer->analyzeQuality($parsedCode, $config);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess comment quality.
|
||||
*/
|
||||
protected function assessCommentQuality(string $file, array $config): array
|
||||
{
|
||||
$content = file_get_contents($file);
|
||||
$parsedCode = $this->commentParser->parseFile($content);
|
||||
|
||||
$qualityResult = $this->qualityAssessor->assess($parsedCode, $config);
|
||||
|
||||
return [
|
||||
'file' => $file,
|
||||
'quality_score' => $qualityResult['score'],
|
||||
'issues' => $qualityResult['issues'],
|
||||
'violations' => $qualityResult['violations']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check file standards.
|
||||
*/
|
||||
protected function checkFileStandards(string $file, array $config): array
|
||||
{
|
||||
$content = file_get_contents($file);
|
||||
$parsedCode = $this->commentParser->parseFile($content);
|
||||
|
||||
$standardsResult = $this->commentAnalyzer->checkStandards($parsedCode, $config);
|
||||
|
||||
return [
|
||||
'file' => $file,
|
||||
'compliant' => $standardsResult['compliant'],
|
||||
'violations' => $standardsResult['violations'],
|
||||
'compliance_issues' => $standardsResult['issues']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate coverage percentage.
|
||||
*/
|
||||
protected function calculateCoverage(array $metrics): float
|
||||
{
|
||||
if ($metrics['total'] === 0) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
return round(($metrics['commented'] / $metrics['total']) * 100, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate quality score.
|
||||
*/
|
||||
protected function calculateQualityScore(array $result): int
|
||||
{
|
||||
$score = 0;
|
||||
|
||||
// Coverage score (60%)
|
||||
$coverageScore = $result['overall_coverage'];
|
||||
$score += $coverageScore * 0.6;
|
||||
|
||||
// Quality issues penalty (20%)
|
||||
$issueCount = count($result['quality_issues']);
|
||||
$qualityPenalty = min(20, $issueCount * 2);
|
||||
$score += (20 - $qualityPenalty);
|
||||
|
||||
// Balance score (20%)
|
||||
$balanceScore = $this->calculateBalanceScore($result);
|
||||
$score += $balanceScore * 0.2;
|
||||
|
||||
return (int) round($score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate balance score.
|
||||
*/
|
||||
protected function calculateBalanceScore(array $result): int
|
||||
{
|
||||
$classCoverage = $result['class_coverage'];
|
||||
$methodCoverage = $result['method_coverage'];
|
||||
$propertyCoverage = $result['property_coverage'];
|
||||
|
||||
// Calculate standard deviation to measure balance
|
||||
$mean = ($classCoverage + $methodCoverage + $propertyCoverage) / 3;
|
||||
$variance = pow($classCoverage - $mean, 2) +
|
||||
pow($methodCoverage - $mean, 2) +
|
||||
pow($propertyCoverage - $mean, 2);
|
||||
$stdDev = sqrt($variance / 3);
|
||||
|
||||
// Lower standard deviation = better balance
|
||||
return max(0, 100 - ($stdDev * 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate compliance score.
|
||||
*/
|
||||
protected function calculateComplianceScore(array $result): int
|
||||
{
|
||||
return (int) $result['compliance_rate'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate coverage recommendations.
|
||||
*/
|
||||
protected function generateCoverageRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['class_coverage'] < 80) {
|
||||
$recommendations[] = 'Add class-level documentation to improve coverage';
|
||||
}
|
||||
|
||||
if ($result['method_coverage'] < 70) {
|
||||
$recommendations[] = 'Document public and protected methods for better API documentation';
|
||||
}
|
||||
|
||||
if ($result['property_coverage'] < 60) {
|
||||
$recommendations[] = 'Add property comments for better code understanding';
|
||||
}
|
||||
|
||||
if (!empty($result['uncommented_items'])) {
|
||||
$recommendations[] = 'Focus on documenting ' . count($result['uncommented_items']) .
|
||||
' uncommented code elements';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Establish coding standards for documentation';
|
||||
$recommendations[] = 'Use automated tools to enforce comment coverage';
|
||||
$recommendations[] = 'Include documentation in code review process';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate quality recommendations.
|
||||
*/
|
||||
protected function generateQualityRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['quality_score'] < 70) {
|
||||
$recommendations[] = 'Improve comment quality and completeness';
|
||||
}
|
||||
|
||||
if (!empty($result['best_practices_violations'])) {
|
||||
$recommendations[] = 'Address best practices violations in comments';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Use standardized comment formats (PHPDoc)';
|
||||
$recommendations[] = 'Include examples in complex method documentation';
|
||||
$recommendations[] = 'Keep comments up-to-date with code changes';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate standards recommendations.
|
||||
*/
|
||||
protected function generateStandardsRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['compliance_rate'] < 80) {
|
||||
$recommendations[] = 'Improve documentation standards compliance';
|
||||
}
|
||||
|
||||
foreach ($result['compliance_issues'] as $issue => $count) {
|
||||
if ($count > 0) {
|
||||
switch ($issue) {
|
||||
case 'missing_doc_blocks':
|
||||
$recommendations[] = 'Add missing PHPDoc blocks';
|
||||
break;
|
||||
case 'incomplete_doc_blocks':
|
||||
$recommendations[] = 'Complete incomplete PHPDoc blocks';
|
||||
break;
|
||||
case 'invalid_format':
|
||||
$recommendations[] = 'Fix invalid PHPDoc format';
|
||||
break;
|
||||
case 'missing_examples':
|
||||
$recommendations[] = 'Add examples to complex methods';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$recommendations[] = 'Use automated tools to check documentation standards';
|
||||
$recommendations[] = 'Include documentation standards in coding guidelines';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total files analyzed.
|
||||
*/
|
||||
protected function countTotalFilesAnalyzed(): int
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($this->coverageResults as $result) {
|
||||
$total += $result['files_analyzed'] ?? 0;
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average coverage.
|
||||
*/
|
||||
protected function calculateAverageCoverage(): float
|
||||
{
|
||||
if (empty($this->coverageResults)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalCoverage = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->coverageResults as $result) {
|
||||
if (isset($result['overall_coverage'])) {
|
||||
$totalCoverage += $result['overall_coverage'];
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count > 0 ? $totalCoverage / $count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality scores.
|
||||
*/
|
||||
protected function getQualityScores(): array
|
||||
{
|
||||
$scores = [];
|
||||
foreach ($this->coverageResults as $result) {
|
||||
if (isset($result['quality_score'])) {
|
||||
$scores[] = $result['quality_score'];
|
||||
}
|
||||
}
|
||||
return $scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common issues.
|
||||
*/
|
||||
protected function getCommonIssues(): array
|
||||
{
|
||||
$issues = [];
|
||||
foreach ($this->coverageResults as $result) {
|
||||
if (isset($result['quality_issues'])) {
|
||||
foreach ($result['quality_issues'] as $issue) {
|
||||
$type = $issue['type'] ?? 'unknown';
|
||||
$issues[$type] = ($issues[$type] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coverage trends.
|
||||
*/
|
||||
protected function getCoverageTrends(): array
|
||||
{
|
||||
if (count($this->coverageResults) < 2) {
|
||||
return ['trend' => 'insufficient_data'];
|
||||
}
|
||||
|
||||
$recent = array_slice($this->coverageResults, -5);
|
||||
$coverages = array_column($recent, 'overall_coverage');
|
||||
|
||||
if (count($coverages) < 2) {
|
||||
return ['trend' => 'insufficient_data'];
|
||||
}
|
||||
|
||||
$first = $coverages[0];
|
||||
$last = end($coverages);
|
||||
|
||||
if ($last > $first + 5) {
|
||||
return ['trend' => 'improving', 'change' => $last - $first];
|
||||
} elseif ($last < $first - 5) {
|
||||
return ['trend' => 'declining', 'change' => $first - $last];
|
||||
} else {
|
||||
return ['trend' => 'stable', 'change' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'coverage_check' => [
|
||||
'exclude' => ['vendor', 'node_modules', '.git', 'tests'],
|
||||
'include' => ['*.php'],
|
||||
'min_coverage_threshold' => 70
|
||||
],
|
||||
'quality_check' => [
|
||||
'min_quality_score' => 75,
|
||||
'check_examples' => true,
|
||||
'check_format' => true
|
||||
],
|
||||
'standards_check' => [
|
||||
'require_phpdoc' => true,
|
||||
'check_parameter_types' => true,
|
||||
'check_return_types' => true
|
||||
],
|
||||
'comment_parser' => [],
|
||||
'comment_analyzer' => [],
|
||||
'coverage_calculator' => [],
|
||||
'quality_assessor' => [],
|
||||
'reporter' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create comment coverage checker instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'coverage_check' => [
|
||||
'min_coverage_threshold' => 50,
|
||||
'exclude' => ['vendor', 'node_modules', '.git']
|
||||
],
|
||||
'quality_check' => [
|
||||
'min_quality_score' => 60
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'coverage_check' => [
|
||||
'min_coverage_threshold' => 80,
|
||||
'exclude' => ['vendor', 'node_modules', '.git', 'tests']
|
||||
],
|
||||
'quality_check' => [
|
||||
'min_quality_score' => 85,
|
||||
'strict_mode' => true
|
||||
],
|
||||
'standards_check' => [
|
||||
'strict_mode' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,756 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Documentation;
|
||||
|
||||
use Fendx\Service\Documentation\Manual\ManualParser;
|
||||
use Fendx\Service\Documentation\Manual\ContentValidator;
|
||||
use Fendx\Service\Documentation\Manual\StructureAnalyzer;
|
||||
use Fendx\Service\Documentation\Manual\ExampleValidator;
|
||||
use Fendx\Service\Documentation\Reporter\ManualReporter;
|
||||
|
||||
class UserManualChecker
|
||||
{
|
||||
protected array $config = [];
|
||||
protected ManualParser $manualParser;
|
||||
protected ContentValidator $contentValidator;
|
||||
protected StructureAnalyzer $structureAnalyzer;
|
||||
protected ExampleValidator $exampleValidator;
|
||||
protected ManualReporter $reporter;
|
||||
protected array $manualResults = [];
|
||||
protected array $manualSections = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->manualParser = new ManualParser($this->config['manual_parser'] ?? []);
|
||||
$this->contentValidator = new ContentValidator($this->config['content_validator'] ?? []);
|
||||
$this->structureAnalyzer = new StructureAnalyzer($this->config['structure_analyzer'] ?? []);
|
||||
$this->exampleValidator = new ExampleValidator($this->config['example_validator'] ?? []);
|
||||
$this->reporter = new ManualReporter($this->config['reporter'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check user manual completeness.
|
||||
*/
|
||||
public function checkUserManual(string $manualPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['manual_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'user_manual',
|
||||
'manual_path' => $manualPath,
|
||||
'manual_exists' => false,
|
||||
'format' => '',
|
||||
'sections_found' => 0,
|
||||
'sections_complete' => 0,
|
||||
'expected_sections' => $checkConfig['expected_sections'] ?? [],
|
||||
'missing_sections' => [],
|
||||
'incomplete_sections' => [],
|
||||
'content_quality_score' => 0,
|
||||
'structure_score' => 0,
|
||||
'overall_score' => 0,
|
||||
'issues' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
if (!file_exists($manualPath)) {
|
||||
$result['issues'][] = [
|
||||
'type' => 'manual_not_found',
|
||||
'severity' => 'critical',
|
||||
'message' => "User manual not found at: {$manualPath}",
|
||||
'recommendation' => 'Create a comprehensive user manual'
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['manual_exists'] = true;
|
||||
$result['format'] = $this->detectManualFormat($manualPath);
|
||||
|
||||
// Parse manual
|
||||
$parsedManual = $this->manualParser->parse($manualPath, $checkConfig);
|
||||
|
||||
// Analyze structure
|
||||
$structureAnalysis = $this->structureAnalyzer->analyze($parsedManual, $checkConfig);
|
||||
|
||||
// Validate content
|
||||
$contentValidation = $this->contentValidator->validate($parsedManual, $checkConfig);
|
||||
|
||||
// Validate examples
|
||||
$exampleValidation = $this->exampleValidator->validate($parsedManual, $checkConfig);
|
||||
|
||||
$result['sections_found'] = $structureAnalysis['sections_found'];
|
||||
$result['sections_complete'] = $structureAnalysis['sections_complete'];
|
||||
$result['missing_sections'] = $structureAnalysis['missing_sections'];
|
||||
$result['incomplete_sections'] = $structureAnalysis['incomplete_sections'];
|
||||
$result['content_quality_score'] = $contentValidation['quality_score'];
|
||||
$result['structure_score'] = $structureAnalysis['structure_score'];
|
||||
|
||||
// Calculate overall score
|
||||
$result['overall_score'] = $this->calculateOverallScore($result);
|
||||
|
||||
// Aggregate issues
|
||||
$result['issues'] = array_merge(
|
||||
$structureAnalysis['issues'],
|
||||
$contentValidation['issues'],
|
||||
$exampleValidation['issues']
|
||||
);
|
||||
|
||||
$result['recommendations'] = $this->generateManualRecommendations($result);
|
||||
|
||||
// Store result
|
||||
$this->manualResults[] = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check multiple manual formats.
|
||||
*/
|
||||
public function checkManualFormats(array $manualPaths, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['formats_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'manual_formats',
|
||||
'manuals_checked' => count($manualPaths),
|
||||
'formats_found' => [],
|
||||
'format_compatibility' => [],
|
||||
'cross_reference_issues' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$formatResults = [];
|
||||
$allSections = [];
|
||||
|
||||
foreach ($manualPaths as $path) {
|
||||
if (file_exists($path)) {
|
||||
$format = $this->detectManualFormat($path);
|
||||
$result['formats_found'][] = $format;
|
||||
|
||||
$manualCheck = $this->checkUserManual($path, $checkConfig);
|
||||
$formatResults[$format] = $manualCheck;
|
||||
|
||||
// Collect sections for cross-reference
|
||||
if (isset($manualCheck['sections_found'])) {
|
||||
$allSections[$format] = $manualCheck['sections_found'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check cross-reference consistency
|
||||
$crossReferenceIssues = $this->checkCrossReferences($allSections, $checkConfig);
|
||||
$result['cross_reference_issues'] = $crossReferenceIssues;
|
||||
|
||||
// Check format compatibility
|
||||
$compatibilityIssues = $this->checkFormatCompatibility($formatResults, $checkConfig);
|
||||
$result['format_compatibility'] = $compatibilityIssues;
|
||||
|
||||
$result['recommendations'] = $this->generateFormatRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check manual examples and tutorials.
|
||||
*/
|
||||
public function checkManualExamples(string $manualPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['examples_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'manual_examples',
|
||||
'manual_path' => $manualPath,
|
||||
'examples_found' => 0,
|
||||
'examples_valid' => 0,
|
||||
'examples_working' => 0,
|
||||
'tutorial_sections' => 0,
|
||||
'code_examples' => 0,
|
||||
'example_issues' => [],
|
||||
'quality_score' => 0,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
if (!file_exists($manualPath)) {
|
||||
$result['example_issues'][] = [
|
||||
'type' => 'manual_not_found',
|
||||
'severity' => 'critical',
|
||||
'message' => "Manual not found: {$manualPath}"
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
$parsedManual = $this->manualParser->parse($manualPath, $checkConfig);
|
||||
$exampleAnalysis = $this->exampleValidator->analyzeExamples($parsedManual, $checkConfig);
|
||||
|
||||
$result['examples_found'] = $exampleAnalysis['examples_found'];
|
||||
$result['examples_valid'] = $exampleAnalysis['examples_valid'];
|
||||
$result['examples_working'] = $exampleAnalysis['examples_working'];
|
||||
$result['tutorial_sections'] = $exampleAnalysis['tutorial_sections'];
|
||||
$result['code_examples'] = $exampleAnalysis['code_examples'];
|
||||
$result['example_issues'] = $exampleAnalysis['issues'];
|
||||
$result['quality_score'] = $exampleAnalysis['quality_score'];
|
||||
$result['recommendations'] = $this->generateExampleRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check manual accessibility and usability.
|
||||
*/
|
||||
public function checkManualUsability(string $manualPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['usability_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'manual_usability',
|
||||
'manual_path' => $manualPath,
|
||||
'readability_score' => 0,
|
||||
'navigation_score' => 0,
|
||||
'searchability_score' => 0,
|
||||
'accessibility_score' => 0,
|
||||
'usability_issues' => [],
|
||||
'accessibility_issues' => [],
|
||||
'improvement_suggestions' => [],
|
||||
'overall_usability_score' => 0,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
if (!file_exists($manualPath)) {
|
||||
$result['usability_issues'][] = [
|
||||
'type' => 'manual_not_found',
|
||||
'severity' => 'critical',
|
||||
'message' => "Manual not found: {$manualPath}"
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
$parsedManual = $this->manualParser->parse($manualPath, $checkConfig);
|
||||
|
||||
// Check readability
|
||||
$readabilityResult = $this->contentValidator->checkReadability($parsedManual, $checkConfig);
|
||||
$result['readability_score'] = $readabilityResult['score'];
|
||||
|
||||
// Check navigation
|
||||
$navigationResult = $this->structureAnalyzer->checkNavigation($parsedManual, $checkConfig);
|
||||
$result['navigation_score'] = $navigationResult['score'];
|
||||
|
||||
// Check searchability
|
||||
$searchabilityResult = $this->contentValidator->checkSearchability($parsedManual, $checkConfig);
|
||||
$result['searchability_score'] = $searchabilityResult['score'];
|
||||
|
||||
// Check accessibility
|
||||
$accessibilityResult = $this->contentValidator->checkAccessibility($parsedManual, $checkConfig);
|
||||
$result['accessibility_score'] = $accessibilityResult['score'];
|
||||
$result['accessibility_issues'] = $accessibilityResult['issues'];
|
||||
|
||||
// Aggregate usability issues
|
||||
$result['usability_issues'] = array_merge(
|
||||
$readabilityResult['issues'],
|
||||
$navigationResult['issues'],
|
||||
$searchabilityResult['issues']
|
||||
);
|
||||
|
||||
// Calculate overall usability score
|
||||
$result['overall_usability_score'] = $this->calculateUsabilityScore($result);
|
||||
$result['improvement_suggestions'] = $this->generateUsabilitySuggestions($result);
|
||||
$result['recommendations'] = $this->generateUsabilityRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate comprehensive manual report.
|
||||
*/
|
||||
public function generateManualReport(array $results, string $format = 'html'): string
|
||||
{
|
||||
return $this->reporter->generate($results, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get manual statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'total_manuals_checked' => count($this->manualResults),
|
||||
'average_score' => $this->calculateAverageScore(),
|
||||
'common_issues' => $this->getCommonIssues(),
|
||||
'section_coverage' => $this->getSectionCoverage(),
|
||||
'quality_trends' => $this->getQualityTrends()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear manual results.
|
||||
*/
|
||||
public function clearResults(): void
|
||||
{
|
||||
$this->manualResults = [];
|
||||
$this->manualSections = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect manual format.
|
||||
*/
|
||||
protected function detectManualFormat(string $manualPath): string
|
||||
{
|
||||
$extension = strtolower(pathinfo($manualPath, PATHINFO_EXTENSION));
|
||||
|
||||
switch ($extension) {
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
return 'markdown';
|
||||
case 'txt':
|
||||
return 'text';
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'html';
|
||||
case 'pdf':
|
||||
return 'pdf';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'word';
|
||||
default:
|
||||
// Try to detect by content
|
||||
$content = file_get_contents($manualPath);
|
||||
if (strpos($content, '#') === 0) {
|
||||
return 'markdown';
|
||||
} elseif (strpos($content, '<html') !== false) {
|
||||
return 'html';
|
||||
} else {
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check cross-references between formats.
|
||||
*/
|
||||
protected function checkCrossReferences(array $allSections, array $config): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
if (count($allSections) < 2) {
|
||||
return $issues;
|
||||
}
|
||||
|
||||
$formats = array_keys($allSections);
|
||||
$referenceFormats = $config['reference_formats'] ?? $formats;
|
||||
|
||||
foreach ($referenceFormats as $format) {
|
||||
if (!isset($allSections[$format])) {
|
||||
$issues[] = [
|
||||
'type' => 'missing_format_reference',
|
||||
'severity' => 'medium',
|
||||
'message' => "Reference format '{$format}' not found",
|
||||
'format' => $format
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check section consistency
|
||||
$sectionCounts = array_map('count', $allSections);
|
||||
$maxSections = max($sectionCounts);
|
||||
$minSections = min($sectionCounts);
|
||||
|
||||
if (($maxSections - $minSections) > 2) {
|
||||
$issues[] = [
|
||||
'type' => 'section_count_inconsistency',
|
||||
'severity' => 'low',
|
||||
'message' => 'Significant difference in section counts between formats',
|
||||
'max_sections' => $maxSections,
|
||||
'min_sections' => $minSections
|
||||
];
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check format compatibility.
|
||||
*/
|
||||
protected function checkFormatCompatibility(array $formatResults, array $config): array
|
||||
{
|
||||
$compatibility = [];
|
||||
|
||||
foreach ($formatResults as $format => $result) {
|
||||
$compatibility[$format] = [
|
||||
'score' => $result['overall_score'] ?? 0,
|
||||
'compatible' => ($result['overall_score'] ?? 0) >= ($config['min_compatibility_score'] ?? 70)
|
||||
];
|
||||
}
|
||||
|
||||
return $compatibility;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall score.
|
||||
*/
|
||||
protected function calculateOverallScore(array $result): int
|
||||
{
|
||||
$structureWeight = 0.4;
|
||||
$contentWeight = 0.6;
|
||||
|
||||
$structureScore = $result['structure_score'] ?? 0;
|
||||
$contentScore = $result['content_quality_score'] ?? 0;
|
||||
|
||||
return (int) round(($structureScore * $structureWeight) + ($contentScore * $contentWeight));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate usability score.
|
||||
*/
|
||||
protected function calculateUsabilityScore(array $result): int
|
||||
{
|
||||
$weights = [
|
||||
'readability' => 0.3,
|
||||
'navigation' => 0.3,
|
||||
'searchability' => 0.2,
|
||||
'accessibility' => 0.2
|
||||
];
|
||||
|
||||
$score = 0;
|
||||
$score += ($result['readability_score'] ?? 0) * $weights['readability'];
|
||||
$score += ($result['navigation_score'] ?? 0) * $weights['navigation'];
|
||||
$score += ($result['searchability_score'] ?? 0) * $weights['searchability'];
|
||||
$score += ($result['accessibility_score'] ?? 0) * $weights['accessibility'];
|
||||
|
||||
return (int) round($score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate manual recommendations.
|
||||
*/
|
||||
protected function generateManualRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!$result['manual_exists']) {
|
||||
$recommendations[] = 'Create a comprehensive user manual';
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
if (!empty($result['missing_sections'])) {
|
||||
$recommendations[] = 'Add missing sections: ' . implode(', ', $result['missing_sections']);
|
||||
}
|
||||
|
||||
if (!empty($result['incomplete_sections'])) {
|
||||
$recommendations[] = 'Complete incomplete sections: ' . implode(', ', $result['incomplete_sections']);
|
||||
}
|
||||
|
||||
if ($result['content_quality_score'] < 70) {
|
||||
$recommendations[] = 'Improve content quality and completeness';
|
||||
}
|
||||
|
||||
if ($result['structure_score'] < 70) {
|
||||
$recommendations[] = 'Improve manual structure and organization';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Include practical examples and tutorials';
|
||||
$recommendations[] = 'Add troubleshooting section';
|
||||
$recommendations[] = 'Keep manual updated with feature changes';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate format recommendations.
|
||||
*/
|
||||
protected function generateFormatRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!empty($result['cross_reference_issues'])) {
|
||||
$recommendations[] = 'Fix cross-reference issues between manual formats';
|
||||
}
|
||||
|
||||
if (!empty($result['format_compatibility'])) {
|
||||
$incompatibleFormats = array_filter($result['format_compatibility'], fn($f) => !$f['compatible']);
|
||||
if (!empty($incompatibleFormats)) {
|
||||
$formats = array_keys($incompatibleFormats);
|
||||
$recommendations[] = 'Improve compatibility for formats: ' . implode(', ', $formats);
|
||||
}
|
||||
}
|
||||
|
||||
$recommendations[] = 'Maintain consistency across all manual formats';
|
||||
$recommendations[] = 'Consider providing multiple format options for users';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate example recommendations.
|
||||
*/
|
||||
protected function generateExampleRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['examples_found'] === 0) {
|
||||
$recommendations[] = 'Add code examples to the manual';
|
||||
} else {
|
||||
$workingRate = $result['examples_found'] > 0 ?
|
||||
($result['examples_working'] / $result['examples_found']) * 100 : 0;
|
||||
|
||||
if ($workingRate < 80) {
|
||||
$recommendations[] = 'Fix non-working code examples';
|
||||
}
|
||||
|
||||
if ($result['tutorial_sections'] < 3) {
|
||||
$recommendations[] = 'Add more tutorial sections';
|
||||
}
|
||||
}
|
||||
|
||||
if ($result['quality_score'] < 70) {
|
||||
$recommendations[] = 'Improve example quality and explanations';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Test all examples regularly';
|
||||
$recommendations[] = 'Include both simple and advanced examples';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate usability suggestions.
|
||||
*/
|
||||
protected function generateUsabilitySuggestions(array $result): array
|
||||
{
|
||||
$suggestions = [];
|
||||
|
||||
if ($result['readability_score'] < 70) {
|
||||
$suggestions[] = 'Improve text readability with shorter sentences and simpler language';
|
||||
}
|
||||
|
||||
if ($result['navigation_score'] < 70) {
|
||||
$suggestions[] = 'Improve navigation with better table of contents and internal links';
|
||||
}
|
||||
|
||||
if ($result['searchability_score'] < 70) {
|
||||
$suggestions[] = 'Improve searchability with better keywords and index';
|
||||
}
|
||||
|
||||
if ($result['accessibility_score'] < 70) {
|
||||
$suggestions[] = 'Improve accessibility with proper headings and alt text';
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate usability recommendations.
|
||||
*/
|
||||
protected function generateUsabilityRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['overall_usability_score'] < 70) {
|
||||
$recommendations[] = 'Overall usability needs improvement';
|
||||
}
|
||||
|
||||
$recommendations = array_merge($recommendations, $result['improvement_suggestions']);
|
||||
|
||||
$recommendations[] = 'Test manual usability with actual users';
|
||||
$recommendations[] = 'Include quick start guide for beginners';
|
||||
$recommendations[] = 'Add FAQ section for common questions';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average score.
|
||||
*/
|
||||
protected function calculateAverageScore(): float
|
||||
{
|
||||
if (empty($this->manualResults)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalScore = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->manualResults as $result) {
|
||||
if (isset($result['overall_score'])) {
|
||||
$totalScore += $result['overall_score'];
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count > 0 ? $totalScore / $count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common issues.
|
||||
*/
|
||||
protected function getCommonIssues(): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
foreach ($this->manualResults as $result) {
|
||||
if (isset($result['issues'])) {
|
||||
foreach ($result['issues'] as $issue) {
|
||||
$type = $issue['type'] ?? 'unknown';
|
||||
$issues[$type] = ($issues[$type] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get section coverage.
|
||||
*/
|
||||
protected function getSectionCoverage(): array
|
||||
{
|
||||
$coverage = [];
|
||||
|
||||
foreach ($this->manualResults as $result) {
|
||||
if (isset($result['sections_found']) && isset($result['sections_complete'])) {
|
||||
$coverage[] = [
|
||||
'found' => $result['sections_found'],
|
||||
'complete' => $result['sections_complete'],
|
||||
'rate' => $result['sections_found'] > 0 ?
|
||||
round(($result['sections_complete'] / $result['sections_found']) * 100, 2) : 0
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $coverage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality trends.
|
||||
*/
|
||||
protected function getQualityTrends(): array
|
||||
{
|
||||
if (count($this->manualResults) < 2) {
|
||||
return ['trend' => 'insufficient_data'];
|
||||
}
|
||||
|
||||
$recent = array_slice($this->manualResults, -5);
|
||||
$scores = array_column($recent, 'overall_score');
|
||||
|
||||
if (count($scores) < 2) {
|
||||
return ['trend' => 'insufficient_data'];
|
||||
}
|
||||
|
||||
$first = $scores[0];
|
||||
$last = end($scores);
|
||||
|
||||
if ($last > $first + 5) {
|
||||
return ['trend' => 'improving', 'change' => $last - $first];
|
||||
} elseif ($last < $first - 5) {
|
||||
return ['trend' => 'declining', 'change' => $first - $last];
|
||||
} else {
|
||||
return ['trend' => 'stable', 'change' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'manual_check' => [
|
||||
'expected_sections' => [
|
||||
'Introduction', 'Installation', 'Getting Started', 'API Reference',
|
||||
'Examples', 'Tutorials', 'Troubleshooting', 'FAQ', 'Contributing'
|
||||
],
|
||||
'min_section_length' => 100
|
||||
],
|
||||
'formats_check' => [
|
||||
'reference_formats' => ['markdown', 'html', 'pdf'],
|
||||
'min_compatibility_score' => 70
|
||||
],
|
||||
'examples_check' => [
|
||||
'require_examples' => true,
|
||||
'test_syntax' => true,
|
||||
'min_examples' => 5
|
||||
],
|
||||
'usability_check' => [
|
||||
'check_readability' => true,
|
||||
'check_navigation' => true,
|
||||
'check_searchability' => true,
|
||||
'check_accessibility' => true
|
||||
],
|
||||
'manual_parser' => [],
|
||||
'content_validator' => [],
|
||||
'structure_analyzer' => [],
|
||||
'example_validator' => [],
|
||||
'reporter' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user manual checker instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'manual_check' => [
|
||||
'expected_sections' => [
|
||||
'Introduction', 'Installation', 'Getting Started'
|
||||
],
|
||||
'min_section_length' => 50
|
||||
],
|
||||
'examples_check' => [
|
||||
'min_examples' => 2
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'manual_check' => [
|
||||
'expected_sections' => [
|
||||
'Introduction', 'Installation', 'Getting Started', 'API Reference',
|
||||
'Examples', 'Tutorials', 'Troubleshooting', 'FAQ', 'Contributing',
|
||||
'Changelog', 'License'
|
||||
],
|
||||
'min_section_length' => 200
|
||||
],
|
||||
'examples_check' => [
|
||||
'min_examples' => 10,
|
||||
'test_syntax' => true,
|
||||
'validate_examples' => true
|
||||
],
|
||||
'usability_check' => [
|
||||
'strict_accessibility' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
629
fendx-framework/fendx-service/src/Health/HealthChecker.php
Normal file
629
fendx-framework/fendx-service/src/Health/HealthChecker.php
Normal file
@@ -0,0 +1,629 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Health;
|
||||
|
||||
use Fendx\Service\Health\Checker\HttpHealthChecker;
|
||||
use Fendx\Service\Health\Checker\TcpHealthChecker;
|
||||
use Fendx\Service\Health\Checker\CustomHealthChecker;
|
||||
use Fendx\Service\Health\Storage\HealthStorage;
|
||||
use Fendx\Service\Health\Notifier\HealthNotifier;
|
||||
|
||||
class HealthChecker
|
||||
{
|
||||
protected HttpHealthChecker $httpChecker;
|
||||
protected TcpHealthChecker $tcpChecker;
|
||||
protected CustomHealthChecker $customChecker;
|
||||
protected HealthStorage $storage;
|
||||
protected HealthNotifier $notifier;
|
||||
protected array $config = [];
|
||||
protected array $monitoredServices = [];
|
||||
protected array $healthStatuses = [];
|
||||
protected array $checkHistory = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->httpChecker = new HttpHealthChecker($this->config);
|
||||
$this->tcpChecker = new TcpHealthChecker($this->config);
|
||||
$this->customChecker = new CustomHealthChecker($this->config);
|
||||
$this->storage = new HealthStorage($this->config);
|
||||
$this->notifier = new HealthNotifier($this->config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring a service.
|
||||
*/
|
||||
public function startMonitoring(string $serviceId, array $healthConfig): void
|
||||
{
|
||||
$this->monitoredServices[$serviceId] = [
|
||||
'id' => $serviceId,
|
||||
'config' => $healthConfig,
|
||||
'status' => 'unknown',
|
||||
'last_check' => null,
|
||||
'last_success' => null,
|
||||
'last_failure' => null,
|
||||
'consecutive_failures' => 0,
|
||||
'consecutive_successes' => 0,
|
||||
'total_checks' => 0,
|
||||
'total_failures' => 0,
|
||||
'total_successes' => 0,
|
||||
'created_at' => time(),
|
||||
'updated_at' => time()
|
||||
];
|
||||
|
||||
// Perform initial health check
|
||||
$this->checkHealth($serviceId);
|
||||
|
||||
$this->logInfo("Started health monitoring for service: {$serviceId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring a service.
|
||||
*/
|
||||
public function stopMonitoring(string $serviceId): void
|
||||
{
|
||||
if (isset($this->monitoredServices[$serviceId])) {
|
||||
unset($this->monitoredServices[$serviceId]);
|
||||
unset($this->healthStatuses[$serviceId]);
|
||||
|
||||
$this->logInfo("Stopped health monitoring for service: {$serviceId}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check health of a specific service.
|
||||
*/
|
||||
public function checkHealth(string $serviceId): array
|
||||
{
|
||||
if (!isset($this->monitoredServices[$serviceId])) {
|
||||
throw new \InvalidArgumentException("Service not being monitored: {$serviceId}");
|
||||
}
|
||||
|
||||
$service = &$this->monitoredServices[$serviceId];
|
||||
$config = $service['config'];
|
||||
|
||||
$startTime = microtime(true);
|
||||
$result = $this->performHealthCheck($config);
|
||||
$duration = microtime(true) - $startTime;
|
||||
|
||||
// Update service statistics
|
||||
$service['last_check'] = time();
|
||||
$service['total_checks']++;
|
||||
$service['updated_at'] = time();
|
||||
|
||||
if ($result['healthy']) {
|
||||
$service['status'] = 'healthy';
|
||||
$service['last_success'] = time();
|
||||
$service['consecutive_successes']++;
|
||||
$service['consecutive_failures'] = 0;
|
||||
$service['total_successes']++;
|
||||
} else {
|
||||
$service['status'] = 'unhealthy';
|
||||
$service['last_failure'] = time();
|
||||
$service['consecutive_failures']++;
|
||||
$service['consecutive_successes'] = 0;
|
||||
$service['total_failures']++;
|
||||
}
|
||||
|
||||
// Store health status
|
||||
$this->healthStatuses[$serviceId] = array_merge($result, [
|
||||
'service_id' => $serviceId,
|
||||
'check_duration' => $duration,
|
||||
'timestamp' => time()
|
||||
]);
|
||||
|
||||
// Store in persistent storage
|
||||
$this->storage->storeHealthCheck($serviceId, $this->healthStatuses[$serviceId]);
|
||||
|
||||
// Add to history
|
||||
$this->addToHistory($serviceId, $this->healthStatuses[$serviceId]);
|
||||
|
||||
// Send notification if needed
|
||||
$this->handleHealthChange($serviceId, $service, $result);
|
||||
|
||||
return $this->healthStatuses[$serviceId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check health of all monitored services.
|
||||
*/
|
||||
public function checkAllHealth(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach (array_keys($this->monitoredServices) as $serviceId) {
|
||||
try {
|
||||
$results[$serviceId] = $this->checkHealth($serviceId);
|
||||
} catch (\Exception $e) {
|
||||
$results[$serviceId] = [
|
||||
'service_id' => $serviceId,
|
||||
'healthy' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'timestamp' => time()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health status of a service.
|
||||
*/
|
||||
public function getHealthStatus(string $serviceId): ?array
|
||||
{
|
||||
return $this->healthStatuses[$serviceId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if service is healthy.
|
||||
*/
|
||||
public function isHealthy(string $serviceId): bool
|
||||
{
|
||||
$status = $this->getHealthStatus($serviceId);
|
||||
|
||||
if (!$status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $status['healthy'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all monitored services.
|
||||
*/
|
||||
public function getMonitoredServices(): array
|
||||
{
|
||||
return $this->monitoredServices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service health statistics.
|
||||
*/
|
||||
public function getServiceStatistics(string $serviceId): array
|
||||
{
|
||||
if (!isset($this->monitoredServices[$serviceId])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$service = $this->monitoredServices[$serviceId];
|
||||
$uptime = $this->calculateUptime($serviceId);
|
||||
|
||||
return [
|
||||
'service_id' => $serviceId,
|
||||
'status' => $service['status'],
|
||||
'total_checks' => $service['total_checks'],
|
||||
'total_successes' => $service['total_successes'],
|
||||
'total_failures' => $service['total_failures'],
|
||||
'success_rate' => $service['total_checks'] > 0 ?
|
||||
($service['total_successes'] / $service['total_checks']) * 100 : 0,
|
||||
'consecutive_successes' => $service['consecutive_successes'],
|
||||
'consecutive_failures' => $service['consecutive_failures'],
|
||||
'last_check' => $service['last_check'],
|
||||
'last_success' => $service['last_success'],
|
||||
'last_failure' => $service['last_failure'],
|
||||
'uptime_percentage' => $uptime,
|
||||
'monitored_since' => $service['created_at']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall health statistics.
|
||||
*/
|
||||
public function getOverallStatistics(): array
|
||||
{
|
||||
$totalServices = count($this->monitoredServices);
|
||||
$healthyServices = 0;
|
||||
$unhealthyServices = 0;
|
||||
$totalChecks = 0;
|
||||
$totalSuccesses = 0;
|
||||
$totalFailures = 0;
|
||||
|
||||
foreach ($this->monitoredServices as $service) {
|
||||
if ($service['status'] === 'healthy') {
|
||||
$healthyServices++;
|
||||
} else {
|
||||
$unhealthyServices++;
|
||||
}
|
||||
|
||||
$totalChecks += $service['total_checks'];
|
||||
$totalSuccesses += $service['total_successes'];
|
||||
$totalFailures += $service['total_failures'];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_services' => $totalServices,
|
||||
'healthy_services' => $healthyServices,
|
||||
'unhealthy_services' => $unhealthyServices,
|
||||
'unknown_services' => $totalServices - $healthyServices - $unhealthyServices,
|
||||
'health_percentage' => $totalServices > 0 ? ($healthyServices / $totalServices) * 100 : 0,
|
||||
'total_checks' => $totalChecks,
|
||||
'total_successes' => $totalSuccesses,
|
||||
'total_failures' => $totalFailures,
|
||||
'overall_success_rate' => $totalChecks > 0 ?
|
||||
($totalSuccesses / $totalChecks) * 100 : 0
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health history for a service.
|
||||
*/
|
||||
public function getHealthHistory(string $serviceId, int $limit = 100): array
|
||||
{
|
||||
if (!isset($this->checkHistory[$serviceId])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$history = $this->checkHistory[$serviceId];
|
||||
usort($history, function ($a, $b) {
|
||||
return $b['timestamp'] <=> $a['timestamp'];
|
||||
});
|
||||
|
||||
return array_slice($history, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get services with consecutive failures.
|
||||
*/
|
||||
public function getServicesWithFailures(int $threshold = 3): array
|
||||
{
|
||||
$services = [];
|
||||
|
||||
foreach ($this->monitoredServices as $serviceId => $service) {
|
||||
if ($service['consecutive_failures'] >= $threshold) {
|
||||
$services[$serviceId] = $service;
|
||||
}
|
||||
}
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get services that need attention.
|
||||
*/
|
||||
public function getServicesNeedingAttention(): array
|
||||
{
|
||||
$services = [];
|
||||
$failureThreshold = $this->config['failure_threshold'] ?? 3;
|
||||
|
||||
foreach ($this->monitoredServices as $serviceId => $service) {
|
||||
$needsAttention = false;
|
||||
$reason = [];
|
||||
|
||||
if ($service['consecutive_failures'] >= $failureThreshold) {
|
||||
$needsAttention = true;
|
||||
$reason[] = "Consecutive failures: {$service['consecutive_failures']}";
|
||||
}
|
||||
|
||||
if ($service['status'] === 'unhealthy') {
|
||||
$needsAttention = true;
|
||||
$reason[] = "Currently unhealthy";
|
||||
}
|
||||
|
||||
// Check if service hasn't been checked recently
|
||||
$timeSinceLastCheck = time() - $service['last_check'];
|
||||
$staleThreshold = $this->config['stale_threshold'] ?? 300;
|
||||
|
||||
if ($timeSinceLastCheck > $staleThreshold) {
|
||||
$needsAttention = true;
|
||||
$reason[] = "Last check was {$timeSinceLastCheck} seconds ago";
|
||||
}
|
||||
|
||||
if ($needsAttention) {
|
||||
$services[$serviceId] = array_merge($service, [
|
||||
'attention_reasons' => $reason
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset service statistics.
|
||||
*/
|
||||
public function resetStatistics(string $serviceId): bool
|
||||
{
|
||||
if (!isset($this->monitoredServices[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$service = &$this->monitoredServices[$serviceId];
|
||||
$service['consecutive_failures'] = 0;
|
||||
$service['consecutive_successes'] = 0;
|
||||
$service['total_checks'] = 0;
|
||||
$service['total_failures'] = 0;
|
||||
$service['total_successes'] = 0;
|
||||
$service['updated_at'] = time();
|
||||
|
||||
// Clear history
|
||||
unset($this->checkHistory[$serviceId]);
|
||||
|
||||
$this->logInfo("Reset statistics for service: {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable service monitoring.
|
||||
*/
|
||||
public function setMonitoringEnabled(string $serviceId, bool $enabled): bool
|
||||
{
|
||||
if (!isset($this->monitoredServices[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->monitoredServices[$serviceId]['enabled'] = $enabled;
|
||||
$this->monitoredServices[$serviceId]['updated_at'] = time();
|
||||
|
||||
$this->logInfo("Monitoring " . ($enabled ? 'enabled' : 'disabled') . " for service: {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update health check configuration.
|
||||
*/
|
||||
public function updateHealthConfig(string $serviceId, array $config): bool
|
||||
{
|
||||
if (!isset($this->monitoredServices[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->monitoredServices[$serviceId]['config'] = array_merge(
|
||||
$this->monitoredServices[$serviceId]['config'],
|
||||
$config
|
||||
);
|
||||
$this->monitoredServices[$serviceId]['updated_at'] = time();
|
||||
|
||||
// Perform health check with new config
|
||||
$this->checkHealth($serviceId);
|
||||
|
||||
$this->logInfo("Updated health config for service: {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform health check based on configuration.
|
||||
*/
|
||||
protected function performHealthCheck(array $config): array
|
||||
{
|
||||
$type = $config['type'] ?? 'http';
|
||||
|
||||
switch ($type) {
|
||||
case 'http':
|
||||
return $this->httpChecker->check($config);
|
||||
case 'tcp':
|
||||
return $this->tcpChecker->check($config);
|
||||
case 'custom':
|
||||
return $this->customChecker->check($config);
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported health check type: {$type}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add check to history.
|
||||
*/
|
||||
protected function addToHistory(string $serviceId, array $result): void
|
||||
{
|
||||
if (!isset($this->checkHistory[$serviceId])) {
|
||||
$this->checkHistory[$serviceId] = [];
|
||||
}
|
||||
|
||||
$this->checkHistory[$serviceId][] = $result;
|
||||
|
||||
// Limit history size
|
||||
$maxHistory = $this->config['max_history'] ?? 1000;
|
||||
if (count($this->checkHistory[$serviceId]) > $maxHistory) {
|
||||
$this->checkHistory[$serviceId] = array_slice(
|
||||
$this->checkHistory[$serviceId],
|
||||
-$maxHistory
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle health status changes.
|
||||
*/
|
||||
protected function handleHealthChange(string $serviceId, array $service, array $result): void
|
||||
{
|
||||
$previousStatus = $service['status'];
|
||||
$currentStatus = $result['healthy'] ? 'healthy' : 'unhealthy';
|
||||
|
||||
// Status changed
|
||||
if ($previousStatus !== $currentStatus) {
|
||||
$this->logInfo("Service {$serviceId} status changed: {$previousStatus} -> {$currentStatus}");
|
||||
|
||||
// Send notification
|
||||
$this->notifier->notifyStatusChange($serviceId, $previousStatus, $currentStatus, $result);
|
||||
}
|
||||
|
||||
// Check for consecutive failures threshold
|
||||
$failureThreshold = $this->config['failure_threshold'] ?? 3;
|
||||
if ($service['consecutive_failures'] === $failureThreshold) {
|
||||
$this->notifier->notifyFailureThreshold($serviceId, $failureThreshold, $result);
|
||||
}
|
||||
|
||||
// Check for recovery
|
||||
$recoveryThreshold = $this->config['recovery_threshold'] ?? 3;
|
||||
if ($service['consecutive_successes'] === $recoveryThreshold &&
|
||||
$service['consecutive_failures'] > 0) {
|
||||
$this->notifier->notifyRecovery($serviceId, $service['consecutive_failures'], $result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate service uptime.
|
||||
*/
|
||||
protected function calculateUptime(string $serviceId): float
|
||||
{
|
||||
if (!isset($this->checkHistory[$serviceId]) || empty($this->checkHistory[$serviceId])) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$history = $this->checkHistory[$serviceId];
|
||||
$healthyCount = 0;
|
||||
|
||||
foreach ($history as $check) {
|
||||
if ($check['healthy']) {
|
||||
$healthyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return ($healthyCount / count($history)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize health checker.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Load existing services from storage
|
||||
$this->loadMonitoredServices();
|
||||
|
||||
// Start periodic health checks
|
||||
if ($this->config['periodic_checks']) {
|
||||
$this->startPeriodicChecks();
|
||||
}
|
||||
|
||||
$this->logInfo("Health checker initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load monitored services from storage.
|
||||
*/
|
||||
protected function loadMonitoredServices(): void
|
||||
{
|
||||
$services = $this->storage->loadMonitoredServices();
|
||||
|
||||
foreach ($services as $service) {
|
||||
$this->monitoredServices[$service['id']] = $service;
|
||||
}
|
||||
|
||||
$this->logInfo("Loaded " . count($services) . " monitored services from storage");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic health checks.
|
||||
*/
|
||||
protected function startPeriodicChecks(): void
|
||||
{
|
||||
// This would typically be run as a background process
|
||||
// For now, we'll just log that it would start
|
||||
$this->logInfo("Periodic health checks started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[HealthChecker] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'periodic_checks' => true,
|
||||
'check_interval' => 30,
|
||||
'failure_threshold' => 3,
|
||||
'recovery_threshold' => 3,
|
||||
'stale_threshold' => 300,
|
||||
'max_history' => 1000,
|
||||
'logging_enabled' => true,
|
||||
'notifications' => [
|
||||
'enabled' => true,
|
||||
'channels' => ['email', 'slack'],
|
||||
'on_status_change' => true,
|
||||
'on_failure_threshold' => true,
|
||||
'on_recovery' => true
|
||||
],
|
||||
'storage' => [
|
||||
'type' => 'file',
|
||||
'path' => __DIR__ . '/../../../storage/health'
|
||||
],
|
||||
'http' => [
|
||||
'timeout' => 5,
|
||||
'follow_redirects' => true,
|
||||
'verify_ssl' => true
|
||||
],
|
||||
'tcp' => [
|
||||
'timeout' => 5
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create health checker instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'periodic_checks' => false,
|
||||
'check_interval' => 10,
|
||||
'failure_threshold' => 2,
|
||||
'logging_enabled' => true,
|
||||
'notifications' => [
|
||||
'enabled' => false
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'periodic_checks' => true,
|
||||
'check_interval' => 30,
|
||||
'failure_threshold' => 3,
|
||||
'recovery_threshold' => 3,
|
||||
'stale_threshold' => 180,
|
||||
'logging_enabled' => false,
|
||||
'notifications' => [
|
||||
'enabled' => true,
|
||||
'channels' => ['email', 'slack', 'webhook']
|
||||
],
|
||||
'storage' => [
|
||||
'type' => 'redis',
|
||||
'host' => 'localhost',
|
||||
'port' => 6379
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\LoadBalancer\Failover;
|
||||
|
||||
use Fendx\Service\LoadBalancer\Failover\Detector\FailureDetector;
|
||||
use Fendx\Service\LoadBalancer\Failover\Strategy\FailoverStrategy;
|
||||
use Fendx\Service\LoadBalancer\Failover\Recovery\RecoveryManager;
|
||||
use Fendx\Service\LoadBalancer\Failover\Storage\FailoverStorage;
|
||||
|
||||
class FailoverManager
|
||||
{
|
||||
protected FailureDetector $detector;
|
||||
protected FailoverStrategy $strategy;
|
||||
protected RecoveryManager $recovery;
|
||||
protected FailoverStorage $storage;
|
||||
protected array $config = [];
|
||||
protected array $services = [];
|
||||
protected array $failoverStatus = [];
|
||||
protected array $circuitBreakers = [];
|
||||
protected array $failureHistory = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->detector = new FailureDetector($this->config);
|
||||
$this->strategy = new FailoverStrategy($this->config);
|
||||
$this->recovery = new RecoveryManager($this->config);
|
||||
$this->storage = new FailoverStorage($this->config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle service failure and perform failover.
|
||||
*/
|
||||
public function handleFailure(string $serviceId, array $context = []): ?array
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
throw new \InvalidArgumentException("Service not registered: {$serviceId}");
|
||||
}
|
||||
|
||||
$service = $this->services[$serviceId];
|
||||
|
||||
// Record failure
|
||||
$this->recordFailure($serviceId, $context);
|
||||
|
||||
// Check if failover should be triggered
|
||||
if ($this->shouldTriggerFailover($serviceId)) {
|
||||
$this->triggerFailover($serviceId);
|
||||
}
|
||||
|
||||
// Get failover target
|
||||
$target = $this->getFailoverTarget($serviceId);
|
||||
|
||||
if ($target) {
|
||||
$this->logInfo("Failover triggered for {$serviceId} -> {$target['id']}");
|
||||
return $target;
|
||||
}
|
||||
|
||||
$this->logWarning("No failover target available for {$serviceId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register service for failover monitoring.
|
||||
*/
|
||||
public function registerService(string $serviceId, array $serviceConfig): void
|
||||
{
|
||||
$this->services[$serviceId] = array_merge([
|
||||
'id' => $serviceId,
|
||||
'failover_enabled' => true,
|
||||
'failover_targets' => [],
|
||||
'circuit_breaker_enabled' => true,
|
||||
'failure_threshold' => $this->config['default_failure_threshold'],
|
||||
'recovery_threshold' => $this->config['default_recovery_threshold'],
|
||||
'timeout' => $this->config['default_timeout'],
|
||||
'retry_attempts' => $this->config['default_retry_attempts']
|
||||
], $serviceConfig);
|
||||
|
||||
// Initialize circuit breaker if enabled
|
||||
if ($serviceConfig['circuit_breaker_enabled'] ?? true) {
|
||||
$this->initializeCircuitBreaker($serviceId);
|
||||
}
|
||||
|
||||
// Initialize failover status
|
||||
$this->failoverStatus[$serviceId] = [
|
||||
'status' => 'active',
|
||||
'failures' => 0,
|
||||
'consecutive_failures' => 0,
|
||||
'last_failure' => null,
|
||||
'last_success' => time(),
|
||||
'current_target' => null,
|
||||
'circuit_breaker_state' => 'closed'
|
||||
];
|
||||
|
||||
$this->logInfo("Service registered for failover: {$serviceId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister service from failover monitoring.
|
||||
*/
|
||||
public function unregisterService(string $serviceId): void
|
||||
{
|
||||
unset($this->services[$serviceId]);
|
||||
unset($this->failoverStatus[$serviceId]);
|
||||
unset($this->circuitBreakers[$serviceId]);
|
||||
|
||||
$this->logInfo("Service unregistered from failover: {$serviceId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record successful service call.
|
||||
*/
|
||||
public function recordSuccess(string $serviceId, array $context = []): void
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = &$this->failoverStatus[$serviceId];
|
||||
$status['consecutive_failures'] = 0;
|
||||
$status['last_success'] = time();
|
||||
|
||||
// Update circuit breaker
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
$this->circuitBreakers[$serviceId]->recordSuccess();
|
||||
}
|
||||
|
||||
// Check if service can recover
|
||||
if ($status['status'] === 'failed_over') {
|
||||
$this->attemptRecovery($serviceId);
|
||||
}
|
||||
|
||||
$this->logDebug("Success recorded for {$serviceId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if service is available.
|
||||
*/
|
||||
public function isServiceAvailable(string $serviceId): bool
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = $this->failoverStatus[$serviceId];
|
||||
|
||||
// Check circuit breaker
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
if (!$this->circuitBreakers[$serviceId]->isOpen()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check failover status
|
||||
return $status['status'] !== 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get failover target for service.
|
||||
*/
|
||||
public function getFailoverTarget(string $serviceId): ?array
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$service = $this->services[$serviceId];
|
||||
$targets = $service['failover_targets'] ?? [];
|
||||
|
||||
if (empty($targets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter available targets
|
||||
$availableTargets = array_filter($targets, function($target) {
|
||||
return $this->isServiceAvailable($target['id']);
|
||||
});
|
||||
|
||||
if (empty($availableTargets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Select target using strategy
|
||||
return $this->strategy->selectTarget($availableTargets, $serviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service failover status.
|
||||
*/
|
||||
public function getFailoverStatus(string $serviceId): ?array
|
||||
{
|
||||
if (!isset($this->failoverStatus[$serviceId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = $this->failoverStatus[$serviceId];
|
||||
|
||||
// Add circuit breaker status
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
$status['circuit_breaker'] = $this->circuitBreakers[$serviceId]->getStatus();
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all failover statuses.
|
||||
*/
|
||||
public function getAllFailoverStatuses(): array
|
||||
{
|
||||
$statuses = [];
|
||||
|
||||
foreach ($this->failoverStatus as $serviceId => $status) {
|
||||
$statuses[$serviceId] = $this->getFailoverStatus($serviceId);
|
||||
}
|
||||
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get failure history for service.
|
||||
*/
|
||||
public function getFailureHistory(string $serviceId, int $limit = 100): array
|
||||
{
|
||||
$history = $this->failureHistory[$serviceId] ?? [];
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
usort($history, function($a, $b) {
|
||||
return $b['timestamp'] <=> $a['timestamp'];
|
||||
});
|
||||
|
||||
return array_slice($history, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get failover statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$totalServices = count($this->services);
|
||||
$activeServices = 0;
|
||||
$failedServices = 0;
|
||||
$failedOverServices = 0;
|
||||
$totalFailures = 0;
|
||||
$circuitBreakerStats = ['closed' => 0, 'open' => 0, 'half_open' => 0];
|
||||
|
||||
foreach ($this->failoverStatus as $serviceId => $status) {
|
||||
switch ($status['status']) {
|
||||
case 'active':
|
||||
$activeServices++;
|
||||
break;
|
||||
case 'failed':
|
||||
$failedServices++;
|
||||
break;
|
||||
case 'failed_over':
|
||||
$failedOverServices++;
|
||||
break;
|
||||
}
|
||||
|
||||
$totalFailures += $status['failures'];
|
||||
|
||||
// Circuit breaker stats
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
$cbState = $this->circuitBreakers[$serviceId]->getState();
|
||||
$circuitBreakerStats[$cbState]++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_services' => $totalServices,
|
||||
'active_services' => $activeServices,
|
||||
'failed_services' => $failedServices,
|
||||
'failed_over_services' => $failedOverServices,
|
||||
'total_failures' => $totalFailures,
|
||||
'circuit_breaker_stats' => $circuitBreakerStats,
|
||||
'availability_percentage' => $totalServices > 0 ?
|
||||
(($activeServices + $failedOverServices) / $totalServices) * 100 : 0
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger failover for service.
|
||||
*/
|
||||
public function triggerFailover(string $serviceId): bool
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = &$this->failoverStatus[$serviceId];
|
||||
$status['status'] = 'failed_over';
|
||||
$status['current_target'] = $this->getFailoverTarget($serviceId);
|
||||
|
||||
// Open circuit breaker
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
$this->circuitBreakers[$serviceId]->open();
|
||||
}
|
||||
|
||||
$this->logInfo("Manual failover triggered for {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually recover service from failover.
|
||||
*/
|
||||
public function recoverService(string $serviceId): bool
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = &$this->failoverStatus[$serviceId];
|
||||
$status['status'] = 'active';
|
||||
$status['consecutive_failures'] = 0;
|
||||
$status['current_target'] = null;
|
||||
|
||||
// Close circuit breaker
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
$this->circuitBreakers[$serviceId]->close();
|
||||
}
|
||||
|
||||
$this->logInfo("Service recovered from failover: {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add failover target to service.
|
||||
*/
|
||||
public function addFailoverTarget(string $serviceId, array $target): void
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
throw new \InvalidArgumentException("Service not registered: {$serviceId}");
|
||||
}
|
||||
|
||||
$this->services[$serviceId]['failover_targets'][] = $target;
|
||||
|
||||
$this->logInfo("Failover target added to {$serviceId}: {$target['id']}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove failover target from service.
|
||||
*/
|
||||
public function removeFailoverTarget(string $serviceId, string $targetId): bool
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$targets = $this->services[$serviceId]['failover_targets'];
|
||||
$filtered = array_filter($targets, function($target) use ($targetId) {
|
||||
return $target['id'] !== $targetId;
|
||||
});
|
||||
|
||||
if (count($filtered) === count($targets)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->services[$serviceId]['failover_targets'] = array_values($filtered);
|
||||
|
||||
$this->logInfo("Failover target removed from {$serviceId}: {$targetId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test failover configuration.
|
||||
*/
|
||||
public function testFailover(string $serviceId): array
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
throw new \InvalidArgumentException("Service not registered: {$serviceId}");
|
||||
}
|
||||
|
||||
$results = [
|
||||
'service_id' => $serviceId,
|
||||
'timestamp' => time(),
|
||||
'tests' => []
|
||||
];
|
||||
|
||||
// Test failover targets
|
||||
$targets = $this->services[$serviceId]['failover_targets'] ?? [];
|
||||
$availableTargets = [];
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$isAvailable = $this->isServiceAvailable($target['id']);
|
||||
$availableTargets[] = [
|
||||
'target_id' => $target['id'],
|
||||
'available' => $isAvailable,
|
||||
'response_time' => $this->testResponseTime($target)
|
||||
];
|
||||
}
|
||||
|
||||
$results['tests']['failover_targets'] = $availableTargets;
|
||||
|
||||
// Test circuit breaker
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
$results['tests']['circuit_breaker'] = $this->circuitBreakers[$serviceId]->getStatus();
|
||||
}
|
||||
|
||||
// Test failure detection
|
||||
$results['tests']['failure_detection'] = [
|
||||
'threshold' => $this->services[$serviceId]['failure_threshold'],
|
||||
'current_failures' => $this->failoverStatus[$serviceId]['consecutive_failures']
|
||||
];
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export failover configuration.
|
||||
*/
|
||||
public function exportConfiguration(string $format = 'json'): string
|
||||
{
|
||||
$data = [
|
||||
'services' => $this->services,
|
||||
'failover_status' => $this->failoverStatus,
|
||||
'failure_history' => $this->failureHistory,
|
||||
'statistics' => $this->getStatistics(),
|
||||
'exported_at' => date('Y-m-d H:i:s'),
|
||||
'version' => $this->config['version'] ?? '1.0'
|
||||
];
|
||||
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
case 'php':
|
||||
return '<?php return ' . var_export($data, true) . ';';
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported export format: {$format}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failover state for service.
|
||||
*/
|
||||
public function resetFailoverState(string $serviceId): bool
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reset status
|
||||
$this->failoverStatus[$serviceId] = [
|
||||
'status' => 'active',
|
||||
'failures' => 0,
|
||||
'consecutive_failures' => 0,
|
||||
'last_failure' => null,
|
||||
'last_success' => time(),
|
||||
'current_target' => null,
|
||||
'circuit_breaker_state' => 'closed'
|
||||
];
|
||||
|
||||
// Reset circuit breaker
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
$this->circuitBreakers[$serviceId]->reset();
|
||||
}
|
||||
|
||||
// Clear failure history
|
||||
unset($this->failureHistory[$serviceId]);
|
||||
|
||||
$this->logInfo("Failover state reset for {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if failover should be triggered.
|
||||
*/
|
||||
protected function shouldTriggerFailover(string $serviceId): bool
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$service = $this->services[$serviceId];
|
||||
$status = $this->failoverStatus[$serviceId];
|
||||
|
||||
// Check if failover is enabled
|
||||
if (!$service['failover_enabled']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check failure threshold
|
||||
if ($status['consecutive_failures'] >= $service['failure_threshold']) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check circuit breaker
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
return $this->circuitBreakers[$serviceId]->shouldTrip();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record service failure.
|
||||
*/
|
||||
protected function recordFailure(string $serviceId, array $context): void
|
||||
{
|
||||
$status = &$this->failoverStatus[$serviceId];
|
||||
$status['failures']++;
|
||||
$status['consecutive_failures']++;
|
||||
$status['last_failure'] = time();
|
||||
|
||||
// Update circuit breaker
|
||||
if (isset($this->circuitBreakers[$serviceId])) {
|
||||
$this->circuitBreakers[$serviceId]->recordFailure();
|
||||
}
|
||||
|
||||
// Add to failure history
|
||||
$this->failureHistory[$serviceId][] = [
|
||||
'timestamp' => time(),
|
||||
'context' => $context,
|
||||
'consecutive_failures' => $status['consecutive_failures']
|
||||
];
|
||||
|
||||
// Limit history size
|
||||
$maxHistory = $this->config['max_failure_history'] ?? 1000;
|
||||
if (count($this->failureHistory[$serviceId]) > $maxHistory) {
|
||||
$this->failureHistory[$serviceId] = array_slice(
|
||||
$this->failureHistory[$serviceId],
|
||||
-$maxHistory
|
||||
);
|
||||
}
|
||||
|
||||
$this->logDebug("Failure recorded for {$serviceId}: {$status['consecutive_failures']} consecutive");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize circuit breaker for service.
|
||||
*/
|
||||
protected function initializeCircuitBreaker(string $serviceId): void
|
||||
{
|
||||
$service = $this->services[$serviceId];
|
||||
|
||||
$this->circuitBreakers[$serviceId] = new CircuitBreaker([
|
||||
'failure_threshold' => $service['failure_threshold'],
|
||||
'recovery_threshold' => $service['recovery_threshold'],
|
||||
'timeout' => $service['timeout']
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover service.
|
||||
*/
|
||||
protected function attemptRecovery(string $serviceId): void
|
||||
{
|
||||
$status = $this->failoverStatus[$serviceId];
|
||||
$service = $this->services[$serviceId];
|
||||
|
||||
// Check recovery threshold
|
||||
if ($status['consecutive_failures'] === 0 &&
|
||||
(time() - $status['last_failure']) > $service['recovery_threshold']) {
|
||||
|
||||
$this->recoverService($serviceId);
|
||||
$this->logInfo("Automatic recovery successful for {$serviceId}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test response time for target.
|
||||
*/
|
||||
protected function testResponseTime(array $target): float
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
// Simple ping test - in real implementation, this would be more sophisticated
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => 5,
|
||||
'method' => 'GET'
|
||||
]
|
||||
]);
|
||||
|
||||
$url = ($target['protocol'] ?? 'http') . '://' . $target['host'] . ':' . $target['port'] . '/health';
|
||||
@file_get_contents($url, false, $context);
|
||||
|
||||
return microtime(true) - $startTime;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return PHP_FLOAT_MAX;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize failover manager.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Load existing configuration from storage
|
||||
$this->loadConfiguration();
|
||||
|
||||
// Start background tasks
|
||||
if ($this->config['auto_recovery']) {
|
||||
$this->startAutoRecovery();
|
||||
}
|
||||
|
||||
$this->logInfo("Failover manager initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from storage.
|
||||
*/
|
||||
protected function loadConfiguration(): void
|
||||
{
|
||||
$config = $this->storage->loadConfiguration();
|
||||
|
||||
if (isset($config['services'])) {
|
||||
$this->services = $config['services'];
|
||||
}
|
||||
|
||||
if (isset($config['failover_status'])) {
|
||||
$this->failoverStatus = $config['failover_status'];
|
||||
}
|
||||
|
||||
$this->logInfo("Configuration loaded from storage");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-recovery task.
|
||||
*/
|
||||
protected function startAutoRecovery(): void
|
||||
{
|
||||
// This would typically be run as a background process
|
||||
// For now, we'll just log that it would start
|
||||
$this->logInfo("Auto-recovery task started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[FailoverManager] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message.
|
||||
*/
|
||||
protected function logWarning(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[FailoverManager] WARNING: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message.
|
||||
*/
|
||||
protected function logDebug(string $message): void
|
||||
{
|
||||
if ($this->config['debug_enabled']) {
|
||||
error_log("[FailoverManager] DEBUG: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'default_failure_threshold' => 3,
|
||||
'default_recovery_threshold' => 60,
|
||||
'default_timeout' => 30,
|
||||
'default_retry_attempts' => 3,
|
||||
'auto_recovery' => true,
|
||||
'recovery_check_interval' => 60,
|
||||
'max_failure_history' => 1000,
|
||||
'logging_enabled' => true,
|
||||
'debug_enabled' => false,
|
||||
'storage' => [
|
||||
'type' => 'file',
|
||||
'path' => __DIR__ . '/../../../storage/failover'
|
||||
],
|
||||
'circuit_breaker' => [
|
||||
'enabled' => true,
|
||||
'open_timeout' => 60,
|
||||
'half_open_max_calls' => 3
|
||||
],
|
||||
'version' => '1.0'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create failover manager instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for high availability.
|
||||
*/
|
||||
public static function forHighAvailability(): self
|
||||
{
|
||||
return new self([
|
||||
'default_failure_threshold' => 2,
|
||||
'default_recovery_threshold' => 30,
|
||||
'auto_recovery' => true,
|
||||
'recovery_check_interval' => 30,
|
||||
'circuit_breaker' => [
|
||||
'enabled' => true,
|
||||
'open_timeout' => 30,
|
||||
'half_open_max_calls' => 5
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'default_failure_threshold' => 5,
|
||||
'default_recovery_threshold' => 120,
|
||||
'auto_recovery' => false,
|
||||
'logging_enabled' => true,
|
||||
'debug_enabled' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple Circuit Breaker implementation.
|
||||
*/
|
||||
class CircuitBreaker
|
||||
{
|
||||
protected array $config;
|
||||
protected string $state = 'closed'; // closed, open, half_open
|
||||
protected int $failures = 0;
|
||||
protected int $successes = 0;
|
||||
protected float $lastFailureTime = 0;
|
||||
protected float $lastStateChange = 0;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge([
|
||||
'failure_threshold' => 5,
|
||||
'recovery_threshold' => 60,
|
||||
'timeout' => 30,
|
||||
'half_open_max_calls' => 3
|
||||
], $config);
|
||||
}
|
||||
|
||||
public function recordFailure(): void
|
||||
{
|
||||
$this->failures++;
|
||||
$this->lastFailureTime = microtime(true);
|
||||
|
||||
if ($this->state === 'closed' && $this->failures >= $this->config['failure_threshold']) {
|
||||
$this->open();
|
||||
} elseif ($this->state === 'half_open') {
|
||||
$this->open();
|
||||
}
|
||||
}
|
||||
|
||||
public function recordSuccess(): void
|
||||
{
|
||||
$this->successes++;
|
||||
|
||||
if ($this->state === 'half_open' && $this->successes >= $this->config['half_open_max_calls']) {
|
||||
$this->close();
|
||||
}
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
if ($this->state === 'open') {
|
||||
// Check if timeout has passed
|
||||
if (microtime(true) - $this->lastStateChange > $this->config['timeout']) {
|
||||
$this->halfOpen();
|
||||
return true; // Still not fully open
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->state !== 'open';
|
||||
}
|
||||
|
||||
public function shouldTrip(): bool
|
||||
{
|
||||
return $this->failures >= $this->config['failure_threshold'];
|
||||
}
|
||||
|
||||
public function open(): void
|
||||
{
|
||||
$this->state = 'open';
|
||||
$this->lastStateChange = microtime(true);
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
$this->state = 'closed';
|
||||
$this->failures = 0;
|
||||
$this->successes = 0;
|
||||
$this->lastStateChange = microtime(true);
|
||||
}
|
||||
|
||||
public function halfOpen(): void
|
||||
{
|
||||
$this->state = 'half_open';
|
||||
$this->successes = 0;
|
||||
$this->lastStateChange = microtime(true);
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->close();
|
||||
}
|
||||
|
||||
public function getState(): string
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
public function getStatus(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->state,
|
||||
'failures' => $this->failures,
|
||||
'successes' => $this->successes,
|
||||
'last_failure_time' => $this->lastFailureTime,
|
||||
'last_state_change' => $this->lastStateChange
|
||||
];
|
||||
}
|
||||
}
|
||||
678
fendx-framework/fendx-service/src/LoadBalancer/LoadBalancer.php
Normal file
678
fendx-framework/fendx-service/src/LoadBalancer/LoadBalancer.php
Normal file
@@ -0,0 +1,678 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\LoadBalancer;
|
||||
|
||||
use Fendx\Service\LoadBalancer\Algorithm\RoundRobinAlgorithm;
|
||||
use Fendx\Service\LoadBalancer\Algorithm\WeightedRoundRobinAlgorithm;
|
||||
use Fendx\Service\LoadBalancer\Algorithm\LeastConnectionsAlgorithm;
|
||||
use Fendx\Service\LoadBalancer\Algorithm\RandomAlgorithm;
|
||||
use Fendx\Service\LoadBalancer\Algorithm\HashAlgorithm;
|
||||
use Fendx\Service\LoadBalancer\Strategy\LoadBalancingStrategy;
|
||||
use Fendx\Service\LoadBalancer\Health\HealthAwareBalancer;
|
||||
|
||||
class LoadBalancer
|
||||
{
|
||||
protected RoundRobinAlgorithm $roundRobin;
|
||||
protected WeightedRoundRobinAlgorithm $weightedRoundRobin;
|
||||
protected LeastConnectionsAlgorithm $leastConnections;
|
||||
protected RandomAlgorithm $random;
|
||||
protected HashAlgorithm $hash;
|
||||
protected LoadBalancingStrategy $strategy;
|
||||
protected HealthAwareBalancer $healthAware;
|
||||
protected array $config = [];
|
||||
protected array $services = [];
|
||||
protected array $weights = [];
|
||||
protected array $connections = [];
|
||||
protected array $statistics = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->roundRobin = new RoundRobinAlgorithm();
|
||||
$this->weightedRoundRobin = new WeightedRoundRobinAlgorithm();
|
||||
$this->leastConnections = new LeastConnectionsAlgorithm();
|
||||
$this->random = new RandomAlgorithm();
|
||||
$this->hash = new HashAlgorithm();
|
||||
$this->strategy = new LoadBalancingStrategy($this->config);
|
||||
$this->healthAware = new HealthAwareBalancer($this->config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select service instance using specified algorithm.
|
||||
*/
|
||||
public function select(array $instances, string $algorithm = 'round_robin', array $options = []): ?array
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter healthy instances if health checking is enabled
|
||||
if ($this->config['health_aware']) {
|
||||
$instances = $this->healthAware->filterHealthy($instances);
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
$instances = $this->applyFilters($instances, $options);
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Select instance using algorithm
|
||||
$selected = $this->selectByAlgorithm($instances, $algorithm, $options);
|
||||
|
||||
if ($selected) {
|
||||
// Update statistics
|
||||
$this->updateStatistics($selected['id'], $algorithm);
|
||||
|
||||
// Update connection count if tracking is enabled
|
||||
if ($this->config['track_connections']) {
|
||||
$this->incrementConnections($selected['id']);
|
||||
}
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select service instance with session affinity.
|
||||
*/
|
||||
public function selectWithAffinity(array $instances, string $sessionId, string $algorithm = 'round_robin'): ?array
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find existing session mapping
|
||||
$mappedInstanceId = $this->getSessionMapping($sessionId);
|
||||
|
||||
if ($mappedInstanceId) {
|
||||
foreach ($instances as $instance) {
|
||||
if ($instance['id'] === $mappedInstanceId && $this->isInstanceAvailable($instance)) {
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select new instance and map session
|
||||
$selected = $this->select($instances, $algorithm);
|
||||
|
||||
if ($selected) {
|
||||
$this->setSessionMapping($sessionId, $selected['id']);
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select service instance using consistent hashing.
|
||||
*/
|
||||
public function selectByHash(array $instances, string $key): ?array
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hash->select($instances, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select multiple service instances.
|
||||
*/
|
||||
public function selectMultiple(array $instances, int $count, string $algorithm = 'round_robin'): array
|
||||
{
|
||||
if (empty($instances) || $count <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$selected = [];
|
||||
$availableInstances = $instances;
|
||||
|
||||
for ($i = 0; $i < $count && !empty($availableInstances); $i++) {
|
||||
$instance = $this->select($availableInstances, $algorithm);
|
||||
|
||||
if ($instance) {
|
||||
$selected[] = $instance;
|
||||
|
||||
// Remove selected instance from available pool
|
||||
$availableInstances = array_filter($availableInstances, function($inst) use ($instance) {
|
||||
return $inst['id'] !== $instance['id'];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set service weight.
|
||||
*/
|
||||
public function setWeight(string $serviceId, int $weight): void
|
||||
{
|
||||
if ($weight < 1) {
|
||||
throw new \InvalidArgumentException("Weight must be at least 1");
|
||||
}
|
||||
|
||||
$this->weights[$serviceId] = $weight;
|
||||
|
||||
// Update weighted round robin algorithm
|
||||
$this->weightedRoundRobin->setWeight($serviceId, $weight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service weight.
|
||||
*/
|
||||
public function getWeight(string $serviceId): int
|
||||
{
|
||||
return $this->weights[$serviceId] ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple service weights.
|
||||
*/
|
||||
public function setWeights(array $weights): void
|
||||
{
|
||||
foreach ($weights as $serviceId => $weight) {
|
||||
$this->setWeight($serviceId, $weight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all weights.
|
||||
*/
|
||||
public function getWeights(): array
|
||||
{
|
||||
return $this->weights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set service priority.
|
||||
*/
|
||||
public function setPriority(string $serviceId, int $priority): void
|
||||
{
|
||||
if (!isset($this->services[$serviceId])) {
|
||||
$this->services[$serviceId] = [];
|
||||
}
|
||||
|
||||
$this->services[$serviceId]['priority'] = $priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service priority.
|
||||
*/
|
||||
public function getPriority(string $serviceId): int
|
||||
{
|
||||
return $this->services[$serviceId]['priority'] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection count for service.
|
||||
*/
|
||||
public function getConnections(string $serviceId): int
|
||||
{
|
||||
return $this->connections[$serviceId] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment connection count.
|
||||
*/
|
||||
public function incrementConnections(string $serviceId): void
|
||||
{
|
||||
$this->connections[$serviceId] = ($this->connections[$serviceId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement connection count.
|
||||
*/
|
||||
public function decrementConnections(string $serviceId): void
|
||||
{
|
||||
if (isset($this->connections[$serviceId])) {
|
||||
$this->connections[$serviceId] = max(0, $this->connections[$serviceId] - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset connection count.
|
||||
*/
|
||||
public function resetConnections(string $serviceId): void
|
||||
{
|
||||
$this->connections[$serviceId] = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get load balancing statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$totalSelections = 0;
|
||||
$algorithmStats = [];
|
||||
|
||||
foreach ($this->statistics as $serviceId => $stats) {
|
||||
$totalSelections += $stats['total_selections'];
|
||||
|
||||
foreach ($stats['algorithms'] as $algorithm => $count) {
|
||||
$algorithmStats[$algorithm] = ($algorithmStats[$algorithm] ?? 0) + $count;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_selections' => $totalSelections,
|
||||
'algorithm_distribution' => $algorithmStats,
|
||||
'service_statistics' => $this->statistics,
|
||||
'current_connections' => $this->connections,
|
||||
'weights' => $this->weights,
|
||||
'health_aware' => $this->config['health_aware'],
|
||||
'track_connections' => $this->config['track_connections']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service-specific statistics.
|
||||
*/
|
||||
public function getServiceStatistics(string $serviceId): array
|
||||
{
|
||||
return $this->statistics[$serviceId] ?? [
|
||||
'total_selections' => 0,
|
||||
'algorithms' => [],
|
||||
'first_selected' => null,
|
||||
'last_selected' => null
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset statistics.
|
||||
*/
|
||||
public function resetStatistics(): void
|
||||
{
|
||||
$this->statistics = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset service statistics.
|
||||
*/
|
||||
public function resetServiceStatistics(string $serviceId): void
|
||||
{
|
||||
unset($this->statistics[$serviceId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable health awareness.
|
||||
*/
|
||||
public function setHealthAware(bool $enabled): void
|
||||
{
|
||||
$this->config['health_aware'] = $enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if health awareness is enabled.
|
||||
*/
|
||||
public function isHealthAware(): bool
|
||||
{
|
||||
return $this->config['health_aware'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable connection tracking.
|
||||
*/
|
||||
public function setTrackConnections(bool $enabled): void
|
||||
{
|
||||
$this->config['track_connections'] = $enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connection tracking is enabled.
|
||||
*/
|
||||
public function isTrackingConnections(): bool
|
||||
{
|
||||
return $this->config['track_connections'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark instance as healthy/unhealthy.
|
||||
*/
|
||||
public function setInstanceHealth(string $serviceId, bool $healthy): void
|
||||
{
|
||||
$this->healthAware->setHealth($serviceId, $healthy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if instance is healthy.
|
||||
*/
|
||||
public function isInstanceHealthy(string $serviceId): bool
|
||||
{
|
||||
return $this->healthAware->isHealthy($serviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all healthy instances.
|
||||
*/
|
||||
public function getHealthyInstances(array $instances): array
|
||||
{
|
||||
return $this->healthAware->filterHealthy($instances);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set session mapping for affinity.
|
||||
*/
|
||||
protected function setSessionMapping(string $sessionId, string $serviceId): void
|
||||
{
|
||||
if (!$this->config['session_affinity']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$_SESSION['load_balancer_affinity'][$sessionId] = $serviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session mapping for affinity.
|
||||
*/
|
||||
protected function getSessionMapping(string $sessionId): ?string
|
||||
{
|
||||
if (!$this->config['session_affinity']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $_SESSION['load_balancer_affinity'][$sessionId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if instance is available.
|
||||
*/
|
||||
protected function isInstanceAvailable(array $instance): bool
|
||||
{
|
||||
// Check if enabled
|
||||
if (isset($instance['enabled']) && !$instance['enabled']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check health if health awareness is enabled
|
||||
if ($this->config['health_aware'] && !$this->healthAware->isHealthy($instance['id'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check connection limit if configured
|
||||
if (isset($instance['max_connections']) &&
|
||||
$this->getConnections($instance['id']) >= $instance['max_connections']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to instances.
|
||||
*/
|
||||
protected function applyFilters(array $instances, array $options): array
|
||||
{
|
||||
$filtered = $instances;
|
||||
|
||||
// Filter by tags
|
||||
if (isset($options['tags']) && !empty($options['tags'])) {
|
||||
$filtered = array_filter($filtered, function($instance) use ($options) {
|
||||
$instanceTags = $instance['tags'] ?? [];
|
||||
return count(array_intersect($options['tags'], $instanceTags)) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by region
|
||||
if (isset($options['region'])) {
|
||||
$filtered = array_filter($filtered, function($instance) use ($options) {
|
||||
return ($instance['region'] ?? null) === $options['region'];
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by zone
|
||||
if (isset($options['zone'])) {
|
||||
$filtered = array_filter($filtered, function($instance) use ($options) {
|
||||
return ($instance['zone'] ?? null) === $options['zone'];
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by protocol
|
||||
if (isset($options['protocol'])) {
|
||||
$filtered = array_filter($filtered, function($instance) use ($options) {
|
||||
return ($instance['protocol'] ?? 'http') === $options['protocol'];
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by minimum weight
|
||||
if (isset($options['min_weight'])) {
|
||||
$filtered = array_filter($filtered, function($instance) use ($options) {
|
||||
$weight = $this->getWeight($instance['id']);
|
||||
return $weight >= $options['min_weight'];
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by priority
|
||||
if (isset($options['priority'])) {
|
||||
$filtered = array_filter($filtered, function($instance) use ($options) {
|
||||
$priority = $this->getPriority($instance['id']);
|
||||
return $priority >= $options['priority'];
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority if specified
|
||||
if (isset($options['sort_by_priority']) && $options['sort_by_priority']) {
|
||||
usort($filtered, function($a, $b) {
|
||||
$priorityA = $this->getPriority($a['id']);
|
||||
$priorityB = $this->getPriority($b['id']);
|
||||
return $priorityB <=> $priorityA;
|
||||
});
|
||||
}
|
||||
|
||||
return array_values($filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select instance by algorithm.
|
||||
*/
|
||||
protected function selectByAlgorithm(array $instances, string $algorithm, array $options = []): ?array
|
||||
{
|
||||
switch ($algorithm) {
|
||||
case 'round_robin':
|
||||
return $this->roundRobin->select($instances);
|
||||
case 'weighted_round_robin':
|
||||
return $this->weightedRoundRobin->select($instances, $this->weights);
|
||||
case 'least_connections':
|
||||
return $this->leastConnections->select($instances, $this->connections);
|
||||
case 'random':
|
||||
return $this->random->select($instances);
|
||||
case 'hash':
|
||||
$key = $options['hash_key'] ?? 'default';
|
||||
return $this->hash->select($instances, $key);
|
||||
case 'weighted_random':
|
||||
return $this->selectWeightedRandom($instances);
|
||||
case 'ip_hash':
|
||||
$clientIp = $options['client_ip'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
return $this->hash->select($instances, $clientIp);
|
||||
case 'uri_hash':
|
||||
$uri = $options['uri'] ?? $_SERVER['REQUEST_URI'] ?? '/';
|
||||
return $this->hash->select($instances, $uri);
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unknown load balancing algorithm: {$algorithm}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select instance using weighted random algorithm.
|
||||
*/
|
||||
protected function selectWeightedRandom(array $instances): ?array
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$totalWeight = 0;
|
||||
$weightedInstances = [];
|
||||
|
||||
foreach ($instances as $instance) {
|
||||
$weight = $this->getWeight($instance['id']);
|
||||
$totalWeight += $weight;
|
||||
|
||||
for ($i = 0; $i < $weight; $i++) {
|
||||
$weightedInstances[] = $instance;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($weightedInstances)) {
|
||||
return $instances[0];
|
||||
}
|
||||
|
||||
return $weightedInstances[array_rand($weightedInstances)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update selection statistics.
|
||||
*/
|
||||
protected function updateStatistics(string $serviceId, string $algorithm): void
|
||||
{
|
||||
if (!isset($this->statistics[$serviceId])) {
|
||||
$this->statistics[$serviceId] = [
|
||||
'total_selections' => 0,
|
||||
'algorithms' => [],
|
||||
'first_selected' => time(),
|
||||
'last_selected' => null
|
||||
];
|
||||
}
|
||||
|
||||
$this->statistics[$serviceId]['total_selections']++;
|
||||
$this->statistics[$serviceId]['algorithms'][$algorithm] =
|
||||
($this->statistics[$serviceId]['algorithms'][$algorithm] ?? 0) + 1;
|
||||
$this->statistics[$serviceId]['last_selected'] = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize load balancer.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Initialize session for affinity
|
||||
if ($this->config['session_affinity'] && session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Initialize health aware balancer
|
||||
$this->healthAware->initialize();
|
||||
|
||||
$this->logInfo("Load balancer initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[LoadBalancer] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'default_algorithm' => 'round_robin',
|
||||
'health_aware' => true,
|
||||
'track_connections' => true,
|
||||
'session_affinity' => false,
|
||||
'session_timeout' => 1800, // 30 minutes
|
||||
'logging_enabled' => true,
|
||||
'health_check' => [
|
||||
'enabled' => true,
|
||||
'interval' => 30,
|
||||
'timeout' => 5,
|
||||
'unhealthy_threshold' => 3,
|
||||
'healthy_threshold' => 2
|
||||
],
|
||||
'algorithms' => [
|
||||
'round_robin' => true,
|
||||
'weighted_round_robin' => true,
|
||||
'least_connections' => true,
|
||||
'random' => true,
|
||||
'hash' => true,
|
||||
'weighted_random' => true,
|
||||
'ip_hash' => true,
|
||||
'uri_hash' => true
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create load balancer instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for high availability.
|
||||
*/
|
||||
public static function forHighAvailability(): self
|
||||
{
|
||||
return new self([
|
||||
'default_algorithm' => 'least_connections',
|
||||
'health_aware' => true,
|
||||
'track_connections' => true,
|
||||
'session_affinity' => true,
|
||||
'health_check' => [
|
||||
'enabled' => true,
|
||||
'interval' => 10,
|
||||
'timeout' => 3,
|
||||
'unhealthy_threshold' => 2,
|
||||
'healthy_threshold' => 2
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for performance.
|
||||
*/
|
||||
public static function forPerformance(): self
|
||||
{
|
||||
return new self([
|
||||
'default_algorithm' => 'round_robin',
|
||||
'health_aware' => false,
|
||||
'track_connections' => false,
|
||||
'session_affinity' => false,
|
||||
'logging_enabled' => false
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'default_algorithm' => 'random',
|
||||
'health_aware' => false,
|
||||
'track_connections' => true,
|
||||
'session_affinity' => false,
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\LoadBalancer;
|
||||
|
||||
/**
|
||||
* 智能负载均衡器
|
||||
* 支持多种负载均衡策略和自适应优化
|
||||
*/
|
||||
class SmartLoadBalancer
|
||||
{
|
||||
private array $strategies = [
|
||||
'round_robin' => RoundRobinStrategy::class,
|
||||
'weighted_round_robin' => WeightedRoundRobinStrategy::class,
|
||||
'least_connections' => LeastConnectionsStrategy::class,
|
||||
'least_response_time' => LeastResponseTimeStrategy::class,
|
||||
'consistent_hash' => ConsistentHashStrategy::class,
|
||||
'adaptive' => AdaptiveStrategy::class,
|
||||
'maglev' => MaglevStrategy::class,
|
||||
'ring_hash' => RingHashStrategy::class,
|
||||
];
|
||||
|
||||
private LoadBalanceStrategy $currentStrategy;
|
||||
private HealthChecker $healthChecker;
|
||||
private MetricsCollector $metrics;
|
||||
private array $instances = [];
|
||||
private array $instanceStats = [];
|
||||
|
||||
public function __construct(
|
||||
private string $strategy = 'adaptive',
|
||||
array $config = []
|
||||
) {
|
||||
$this->currentStrategy = new $this->strategies[$strategy]();
|
||||
$this->healthChecker = new HealthChecker($config['health'] ?? []);
|
||||
$this->metrics = new MetricsCollector($config['metrics'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择实例
|
||||
*/
|
||||
public function select(array $instances, ?string $key = null): ?Instance
|
||||
{
|
||||
$this->instances = $this->filterHealthyInstances($instances);
|
||||
|
||||
if (empty($this->instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$selected = $this->currentStrategy->select($this->instances, $key);
|
||||
|
||||
if ($selected) {
|
||||
$this->recordSelection($selected);
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加实例
|
||||
*/
|
||||
public function addInstance(Instance $instance): void
|
||||
{
|
||||
$this->instances[] = $instance;
|
||||
$this->instanceStats[$instance->getId()] = [
|
||||
'requests' => 0,
|
||||
'failures' => 0,
|
||||
'total_response_time' => 0.0,
|
||||
'last_used' => 0,
|
||||
'created_at' => time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除实例
|
||||
*/
|
||||
public function removeInstance(string $instanceId): void
|
||||
{
|
||||
$this->instances = array_filter(
|
||||
$this->instances,
|
||||
fn($instance) => $instance->getId() !== $instanceId
|
||||
);
|
||||
unset($this->instanceStats[$instanceId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新实例统计
|
||||
*/
|
||||
public function updateInstanceStats(string $instanceId, float $responseTime, bool $success): void
|
||||
{
|
||||
if (!isset($this->instanceStats[$instanceId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stats = &$this->instanceStats[$instanceId];
|
||||
$stats['requests']++;
|
||||
$stats['last_used'] = time();
|
||||
|
||||
if ($success) {
|
||||
$stats['total_response_time'] += $responseTime;
|
||||
} else {
|
||||
$stats['failures']++;
|
||||
}
|
||||
|
||||
// 自适应策略优化
|
||||
if ($this->strategy === 'adaptive') {
|
||||
$this->optimizeStrategy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤健康实例
|
||||
*/
|
||||
private function filterHealthyInstances(array $instances): array
|
||||
{
|
||||
return array_filter($instances, function($instance) {
|
||||
return $this->healthChecker->isHealthy($instance);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录选择
|
||||
*/
|
||||
private function recordSelection(Instance $instance): void
|
||||
{
|
||||
$this->metrics->increment('load_balancer.selections', [
|
||||
'instance' => $instance->getId(),
|
||||
'strategy' => $this->strategy,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应策略优化
|
||||
*/
|
||||
private function optimizeStrategy(): void
|
||||
{
|
||||
$performance = $this->calculateStrategyPerformance();
|
||||
|
||||
// 根据性能指标动态调整策略
|
||||
if ($performance['avg_response_time'] > 1000) {
|
||||
// 响应时间过长,切换到最快响应策略
|
||||
$this->switchStrategy('least_response_time');
|
||||
} elseif ($performance['failure_rate'] > 0.05) {
|
||||
// 失败率高,切换到最少连接策略
|
||||
$this->switchStrategy('least_connections');
|
||||
} elseif ($performance['imbalance_score'] > 0.3) {
|
||||
// 负载不均衡,切换到加权轮询
|
||||
$this->switchStrategy('weighted_round_robin');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算策略性能
|
||||
*/
|
||||
private function calculateStrategyPerformance(): array
|
||||
{
|
||||
$totalRequests = array_sum(array_column($this->instanceStats, 'requests'));
|
||||
$totalFailures = array_sum(array_column($this->instanceStats, 'failures'));
|
||||
$totalResponseTime = array_sum(array_column($this->instanceStats, 'total_response_time'));
|
||||
|
||||
$successRequests = $totalRequests - $totalFailures;
|
||||
$avgResponseTime = $successRequests > 0 ? $totalResponseTime / $successRequests : 0;
|
||||
$failureRate = $totalRequests > 0 ? $totalFailures / $totalRequests : 0;
|
||||
|
||||
// 计算负载不均衡分数
|
||||
$requestsPerInstance = array_column($this->instanceStats, 'requests');
|
||||
$avgRequests = array_sum($requestsPerInstance) / count($requestsPerInstance);
|
||||
$variance = array_sum(array_map(
|
||||
fn($r) => pow($r - $avgRequests, 2),
|
||||
$requestsPerInstance
|
||||
)) / count($requestsPerInstance);
|
||||
$imbalanceScore = sqrt($variance) / $avgRequests;
|
||||
|
||||
return [
|
||||
'avg_response_time' => $avgResponseTime,
|
||||
'failure_rate' => $failureRate,
|
||||
'imbalance_score' => $imbalanceScore,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换策略
|
||||
*/
|
||||
private function switchStrategy(string $newStrategy): void
|
||||
{
|
||||
if ($newStrategy !== $this->strategy && isset($this->strategies[$newStrategy])) {
|
||||
$this->strategy = $newStrategy;
|
||||
$this->currentStrategy = new $this->strategies[$newStrategy]();
|
||||
|
||||
$this->metrics->increment('load_balancer.strategy_switch', [
|
||||
'from' => $this->strategy,
|
||||
'to' => $newStrategy,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例统计
|
||||
*/
|
||||
public function getInstanceStats(): array
|
||||
{
|
||||
return $this->instanceStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取负载均衡器状态
|
||||
*/
|
||||
public function getStatus(): array
|
||||
{
|
||||
return [
|
||||
'strategy' => $this->strategy,
|
||||
'total_instances' => count($this->instances),
|
||||
'healthy_instances' => count($this->filterHealthyInstances($this->instances)),
|
||||
'performance' => $this->calculateStrategyPerformance(),
|
||||
'metrics' => $this->metrics->getSummary(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 负载均衡策略接口
|
||||
*/
|
||||
interface LoadBalanceStrategy
|
||||
{
|
||||
public function select(array $instances, ?string $key = null): ?Instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应负载均衡策略
|
||||
*/
|
||||
class AdaptiveStrategy implements LoadBalanceStrategy
|
||||
{
|
||||
private LeastResponseTimeStrategy $responseTimeStrategy;
|
||||
private LeastConnectionsStrategy $connectionsStrategy;
|
||||
private WeightedRoundRobinStrategy $weightedStrategy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->responseTimeStrategy = new LeastResponseTimeStrategy();
|
||||
$this->connectionsStrategy = new LeastConnectionsStrategy();
|
||||
$this->weightedStrategy = new WeightedRoundRobinStrategy();
|
||||
}
|
||||
|
||||
public function select(array $instances, ?string $key = null): ?Instance
|
||||
{
|
||||
// 根据当前系统状态选择最优策略
|
||||
$systemLoad = $this->getSystemLoad();
|
||||
|
||||
if ($systemLoad['cpu'] > 80) {
|
||||
// CPU 高负载,使用最少连接策略
|
||||
return $this->connectionsStrategy->select($instances, $key);
|
||||
} elseif ($systemLoad['memory'] > 80) {
|
||||
// 内存高负载,使用最快响应策略
|
||||
return $this->responseTimeStrategy->select($instances, $key);
|
||||
} else {
|
||||
// 正常负载,使用加权轮询
|
||||
return $this->weightedStrategy->select($instances, $key);
|
||||
}
|
||||
}
|
||||
|
||||
private function getSystemLoad(): array
|
||||
{
|
||||
return [
|
||||
'cpu' => sys_getloadavg()[0] ?? 0,
|
||||
'memory' => memory_get_usage() / memory_get_peak_usage() * 100,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maglev 哈希策略
|
||||
* Google 开发的一致性哈希算法,适合大规模分布式系统
|
||||
*/
|
||||
class MaglevStrategy implements LoadBalanceStrategy
|
||||
{
|
||||
private array $lookupTable = [];
|
||||
private int $tableSize = 65537;
|
||||
|
||||
public function select(array $instances, ?string $key = null): ?Instance
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($this->lookupTable)) {
|
||||
$this->buildLookupTable($instances);
|
||||
}
|
||||
|
||||
if ($key === null) {
|
||||
$key = (string) random_int(0, PHP_INT_MAX);
|
||||
}
|
||||
|
||||
$hash = crc32($key);
|
||||
$index = $hash % $this->tableSize;
|
||||
|
||||
return $this->lookupTable[$index] ?? null;
|
||||
}
|
||||
|
||||
private function buildLookupTable(array $instances): void
|
||||
{
|
||||
$this->lookupTable = [];
|
||||
$permutation = [];
|
||||
|
||||
foreach ($instances as $i => $instance) {
|
||||
$offset = crc32($instance->getId() . 'offset') % $this->tableSize;
|
||||
$skip = crc32($instance->getId() . 'skip') % (count($instances) - 1) + 1;
|
||||
|
||||
$permutation[$i] = [];
|
||||
for ($j = 0; $j < $this->tableSize; $j++) {
|
||||
$permutation[$i][$j] = ($offset + $j * $skip) % $this->tableSize;
|
||||
}
|
||||
}
|
||||
|
||||
// 填充查找表
|
||||
for ($j = 0; $j < $this->tableSize; $j++) {
|
||||
foreach ($instances as $i => $instance) {
|
||||
$candidate = $permutation[$i][$j];
|
||||
if (!isset($this->lookupTable[$candidate])) {
|
||||
$this->lookupTable[$candidate] = $instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 环形哈希策略
|
||||
*/
|
||||
class RingHashStrategy implements LoadBalanceStrategy
|
||||
{
|
||||
private int $virtualNodes = 150;
|
||||
|
||||
public function select(array $instances, ?string $key = null): ?Instance
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ring = $this->buildRing($instances);
|
||||
|
||||
if ($key === null) {
|
||||
$key = (string) random_int(0, PHP_INT_MAX);
|
||||
}
|
||||
|
||||
$hash = crc32($key);
|
||||
|
||||
// 顺时针查找第一个节点
|
||||
foreach ($ring as $nodeHash => $instance) {
|
||||
if ($nodeHash >= $hash) {
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
|
||||
// 环形查找,返回第一个节点
|
||||
return reset($ring);
|
||||
}
|
||||
|
||||
private function buildRing(array $instances): array
|
||||
{
|
||||
$ring = [];
|
||||
|
||||
foreach ($instances as $instance) {
|
||||
for ($i = 0; $i < $this->virtualNodes; $i++) {
|
||||
$virtualKey = $instance->getId() . ':' . $i;
|
||||
$hash = crc32($virtualKey);
|
||||
$ring[$hash] = $instance;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($ring);
|
||||
return $ring;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查器
|
||||
*/
|
||||
class HealthChecker
|
||||
{
|
||||
private array $healthStatus = [];
|
||||
private int $checkInterval = 30;
|
||||
private int $timeout = 5;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->checkInterval = $config['interval'] ?? 30;
|
||||
$this->timeout = $config['timeout'] ?? 5;
|
||||
}
|
||||
|
||||
public function isHealthy(Instance $instance): bool
|
||||
{
|
||||
$instanceId = $instance->getId();
|
||||
|
||||
if (!isset($this->healthStatus[$instanceId])) {
|
||||
$this->healthStatus[$instanceId] = $this->performHealthCheck($instance);
|
||||
}
|
||||
|
||||
return $this->healthStatus[$instanceId]['healthy'];
|
||||
}
|
||||
|
||||
private function performHealthCheck(Instance $instance): array
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$healthy = false;
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => $this->timeout,
|
||||
'method' => 'GET',
|
||||
],
|
||||
]);
|
||||
|
||||
$url = $instance->getHealthUrl();
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
|
||||
if ($response !== false && strpos($response, 'OK') !== false) {
|
||||
$healthy = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
return [
|
||||
'healthy' => $healthy,
|
||||
'response_time' => microtime(true) - $startTime,
|
||||
'last_check' => time(),
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 指标收集器
|
||||
*/
|
||||
class MetricsCollector
|
||||
{
|
||||
private array $counters = [];
|
||||
private array $gauges = [];
|
||||
private array $histograms = [];
|
||||
|
||||
public function increment(string $name, array $tags = []): void
|
||||
{
|
||||
$key = $this->buildKey($name, $tags);
|
||||
$this->counters[$key] = ($this->counters[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
public function gauge(string $name, float $value, array $tags = []): void
|
||||
{
|
||||
$key = $this->buildKey($name, $tags);
|
||||
$this->gauges[$key] = $value;
|
||||
}
|
||||
|
||||
public function histogram(string $name, float $value, array $tags = []): void
|
||||
{
|
||||
$key = $this->buildKey($name, $tags);
|
||||
if (!isset($this->histograms[$key])) {
|
||||
$this->histograms[$key] = [];
|
||||
}
|
||||
$this->histograms[$key][] = $value;
|
||||
}
|
||||
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'counters' => $this->counters,
|
||||
'gauges' => $this->gauges,
|
||||
'histograms' => array_map(
|
||||
fn($values) => [
|
||||
'count' => count($values),
|
||||
'sum' => array_sum($values),
|
||||
'avg' => array_sum($values) / count($values),
|
||||
'min' => min($values),
|
||||
'max' => max($values),
|
||||
],
|
||||
$this->histograms
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildKey(string $name, array $tags): string
|
||||
{
|
||||
if (empty($tags)) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
ksort($tags);
|
||||
$tagString = implode(',', array_map(
|
||||
fn($k, $v) => "$k=$v",
|
||||
array_keys($tags),
|
||||
$tags
|
||||
));
|
||||
|
||||
return $name . '{' . $tagString . '}';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例类
|
||||
*/
|
||||
class Instance
|
||||
{
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private string $host,
|
||||
private int $port,
|
||||
private int $weight = 1,
|
||||
private array $metadata = []
|
||||
) {}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHost(): string
|
||||
{
|
||||
return $this->host;
|
||||
}
|
||||
|
||||
public function getPort(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
public function getWeight(): int
|
||||
{
|
||||
return $this->weight;
|
||||
}
|
||||
|
||||
public function getMetadata(): array
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
public function getHealthUrl(): string
|
||||
{
|
||||
return "http://{$this->host}:{$this->port}/health";
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return "{$this->host}:{$this->port}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\LoadBalancer\Strategy;
|
||||
|
||||
use Fendx\Service\LoadBalancer\Strategy\Router\TrafficRouter;
|
||||
use Fendx\Service\LoadBalancer\Strategy\Analyzer\TrafficAnalyzer;
|
||||
use Fendx\Service\LoadBalancer\Strategy\Balancer\TrafficBalancer;
|
||||
use Fendx\Service\LoadBalancer\Strategy\Monitor\TrafficMonitor;
|
||||
|
||||
class TrafficDistributionStrategy
|
||||
{
|
||||
protected TrafficRouter $router;
|
||||
protected TrafficAnalyzer $analyzer;
|
||||
protected TrafficBalancer $balancer;
|
||||
protected TrafficMonitor $monitor;
|
||||
protected array $config = [];
|
||||
protected array $rules = [];
|
||||
protected array $trafficStats = [];
|
||||
protected array $distributionHistory = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->router = new TrafficRouter($this->config);
|
||||
$this->analyzer = new TrafficAnalyzer($this->config);
|
||||
$this->balancer = new TrafficBalancer($this->config);
|
||||
$this->monitor = new TrafficMonitor($this->config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute traffic to services based on strategy.
|
||||
*/
|
||||
public function distributeTraffic(array $instances, array $request, string $strategy = 'weighted'): ?array
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Analyze request
|
||||
$requestAnalysis = $this->analyzer->analyzeRequest($request);
|
||||
|
||||
// Apply routing rules
|
||||
$filteredInstances = $this->applyRoutingRules($instances, $requestAnalysis);
|
||||
|
||||
if (empty($filteredInstances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Select instance based on strategy
|
||||
$selectedInstance = $this->selectInstance($filteredInstances, $requestAnalysis, $strategy);
|
||||
|
||||
if ($selectedInstance) {
|
||||
// Record traffic distribution
|
||||
$this->recordTrafficDistribution($selectedInstance['id'], $requestAnalysis, $strategy);
|
||||
|
||||
// Update monitoring
|
||||
$this->monitor->recordRequest($selectedInstance['id'], $requestAnalysis);
|
||||
}
|
||||
|
||||
return $selectedInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add traffic distribution rule.
|
||||
*/
|
||||
public function addRule(string $name, array $rule): void
|
||||
{
|
||||
$this->validateRule($rule);
|
||||
|
||||
$this->rules[$name] = array_merge([
|
||||
'name' => $name,
|
||||
'enabled' => true,
|
||||
'priority' => 100,
|
||||
'conditions' => [],
|
||||
'actions' => [],
|
||||
'weight' => 1,
|
||||
'created_at' => time(),
|
||||
'updated_at' => time()
|
||||
], $rule);
|
||||
|
||||
// Sort rules by priority
|
||||
uasort($this->rules, function($a, $b) {
|
||||
return $a['priority'] <=> $b['priority'];
|
||||
});
|
||||
|
||||
$this->logInfo("Traffic distribution rule added: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove traffic distribution rule.
|
||||
*/
|
||||
public function removeRule(string $name): bool
|
||||
{
|
||||
if (!isset($this->rules[$name])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($this->rules[$name]);
|
||||
|
||||
$this->logInfo("Traffic distribution rule removed: {$name}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update traffic distribution rule.
|
||||
*/
|
||||
public function updateRule(string $name, array $updates): bool
|
||||
{
|
||||
if (!isset($this->rules[$name])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->rules[$name] = array_merge($this->rules[$name], $updates);
|
||||
$this->rules[$name]['updated_at'] = time();
|
||||
|
||||
$this->logInfo("Traffic distribution rule updated: {$name}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rules.
|
||||
*/
|
||||
public function getRules(): array
|
||||
{
|
||||
return $this->rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule by name.
|
||||
*/
|
||||
public function getRule(string $name): ?array
|
||||
{
|
||||
return $this->rules[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable rule.
|
||||
*/
|
||||
public function setRuleEnabled(string $name, bool $enabled): bool
|
||||
{
|
||||
if (!isset($this->rules[$name])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->rules[$name]['enabled'] = $enabled;
|
||||
$this->rules[$name]['updated_at'] = time();
|
||||
|
||||
$this->logInfo("Rule " . ($enabled ? 'enabled' : 'disabled') . ": {$name}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set traffic weights for services.
|
||||
*/
|
||||
public function setTrafficWeights(array $weights): void
|
||||
{
|
||||
$this->balancer->setWeights($weights);
|
||||
|
||||
$this->logInfo("Traffic weights updated for " . count($weights) . " services");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get traffic weights.
|
||||
*/
|
||||
public function getTrafficWeights(): array
|
||||
{
|
||||
return $this->balancer->getWeights();
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute traffic by percentage.
|
||||
*/
|
||||
public function distributeByPercentage(array $instances, array $percentages): ?array
|
||||
{
|
||||
if (empty($instances) || empty($percentages)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate percentages sum to 100
|
||||
$totalPercentage = array_sum($percentages);
|
||||
if (abs($totalPercentage - 100) > 0.01) {
|
||||
throw new \InvalidArgumentException("Percentages must sum to 100, got: {$totalPercentage}");
|
||||
}
|
||||
|
||||
// Select instance based on percentage distribution
|
||||
return $this->balancer->selectByPercentage($instances, $percentages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute traffic by geographic location.
|
||||
*/
|
||||
public function distributeByGeography(array $instances, string $clientLocation): ?array
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find closest instances
|
||||
$closestInstances = $this->findClosestInstances($instances, $clientLocation);
|
||||
|
||||
if (empty($closestInstances)) {
|
||||
// Fallback to any instance
|
||||
return $instances[0];
|
||||
}
|
||||
|
||||
// Select from closest instances using weighted random
|
||||
return $this->balancer->selectWeightedRandom($closestInstances);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute traffic by user segment.
|
||||
*/
|
||||
public function distributeBySegment(array $instances, string $userSegment): ?array
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter instances by segment
|
||||
$segmentInstances = array_filter($instances, function($instance) use ($userSegment) {
|
||||
$segments = $instance['segments'] ?? [];
|
||||
return in_array($userSegment, $segments) || in_array('all', $segments);
|
||||
});
|
||||
|
||||
if (empty($segmentInstances)) {
|
||||
// Fallback to instances without segment restriction
|
||||
$segmentInstances = array_filter($instances, function($instance) {
|
||||
$segments = $instance['segments'] ?? [];
|
||||
return empty($segments) || in_array('all', $segments);
|
||||
});
|
||||
}
|
||||
|
||||
if (empty($segmentInstances)) {
|
||||
return $instances[0]; // Ultimate fallback
|
||||
}
|
||||
|
||||
return $this->balancer->selectRoundRobin(array_values($segmentInstances));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute traffic by time of day.
|
||||
*/
|
||||
public function distributeByTime(array $instances, \DateTime $dateTime = null): ?array
|
||||
{
|
||||
$dateTime = $dateTime ?? new \DateTime();
|
||||
$hour = (int) $dateTime->format('H');
|
||||
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter instances by time availability
|
||||
$availableInstances = array_filter($instances, function($instance) use ($hour) {
|
||||
$availability = $instance['availability'] ?? [];
|
||||
|
||||
if (empty($availability)) {
|
||||
return true; // Always available
|
||||
}
|
||||
|
||||
foreach ($availability as $period) {
|
||||
if ($hour >= $period['start'] && $hour < $period['end']) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (empty($availableInstances)) {
|
||||
return $instances[0]; // Fallback
|
||||
}
|
||||
|
||||
return $this->balancer->selectWeightedRandom(array_values($availableInstances));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute traffic by load capacity.
|
||||
*/
|
||||
public function distributeByCapacity(array $instances): ?array
|
||||
{
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate available capacity for each instance
|
||||
$capacityScores = [];
|
||||
|
||||
foreach ($instances as $instance) {
|
||||
$maxCapacity = $instance['max_capacity'] ?? 100;
|
||||
$currentLoad = $instance['current_load'] ?? 0;
|
||||
$availableCapacity = max(0, $maxCapacity - $currentLoad);
|
||||
|
||||
$capacityScores[$instance['id']] = $availableCapacity;
|
||||
}
|
||||
|
||||
// Select instance with most available capacity
|
||||
$maxCapacity = max($capacityScores);
|
||||
$bestInstanceIds = array_keys($capacityScores, $maxCapacity);
|
||||
|
||||
if (count($bestInstanceIds) === 1) {
|
||||
$bestInstanceId = $bestInstanceIds[0];
|
||||
} else {
|
||||
// Multiple instances with same capacity, use round-robin
|
||||
$bestInstanceId = $bestInstanceIds[array_rand($bestInstanceIds)];
|
||||
}
|
||||
|
||||
foreach ($instances as $instance) {
|
||||
if ($instance['id'] === $bestInstanceId) {
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get traffic distribution statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$totalRequests = 0;
|
||||
$serviceStats = [];
|
||||
$ruleStats = [];
|
||||
|
||||
// Calculate service statistics
|
||||
foreach ($this->trafficStats as $serviceId => $stats) {
|
||||
$requests = $stats['total_requests'] ?? 0;
|
||||
$totalRequests += $requests;
|
||||
|
||||
$serviceStats[$serviceId] = [
|
||||
'total_requests' => $requests,
|
||||
'percentage' => 0, // Will be calculated below
|
||||
'average_response_time' => $this->calculateAverageResponseTime($serviceId),
|
||||
'error_rate' => $this->calculateErrorRate($serviceId),
|
||||
'last_request' => $stats['last_request'] ?? null
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
if ($totalRequests > 0) {
|
||||
foreach ($serviceStats as $serviceId => &$stats) {
|
||||
$stats['percentage'] = ($stats['total_requests'] / $totalRequests) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate rule statistics
|
||||
foreach ($this->rules as $ruleName => $rule) {
|
||||
$ruleStats[$ruleName] = [
|
||||
'enabled' => $rule['enabled'],
|
||||
'priority' => $rule['priority'],
|
||||
'match_count' => $rule['match_count'] ?? 0,
|
||||
'last_matched' => $rule['last_matched'] ?? null
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_requests' => $totalRequests,
|
||||
'service_statistics' => $serviceStats,
|
||||
'rule_statistics' => $ruleStats,
|
||||
'active_rules' => count(array_filter($this->rules, fn($r) => $r['enabled'])),
|
||||
'distribution_history' => array_slice($this->distributionHistory, -10)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get traffic distribution history.
|
||||
*/
|
||||
public function getDistributionHistory(int $limit = 100): array
|
||||
{
|
||||
return array_slice($this->distributionHistory, -$limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time traffic metrics.
|
||||
*/
|
||||
public function getRealTimeMetrics(): array
|
||||
{
|
||||
return [
|
||||
'current_rps' => $this->monitor->getCurrentRPS(),
|
||||
'active_connections' => $this->monitor->getActiveConnections(),
|
||||
'average_response_time' => $this->monitor->getAverageResponseTime(),
|
||||
'error_rate' => $this->monitor->getErrorRate(),
|
||||
'top_services' => $this->monitor->getTopServices(10),
|
||||
'geographic_distribution' => $this->monitor->getGeographicDistribution(),
|
||||
'user_segment_distribution' => $this->monitor->getSegmentDistribution()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize traffic distribution.
|
||||
*/
|
||||
public function optimizeDistribution(): array
|
||||
{
|
||||
$recommendations = [];
|
||||
$currentStats = $this->getStatistics();
|
||||
|
||||
// Analyze service performance
|
||||
foreach ($currentStats['service_statistics'] as $serviceId => $stats) {
|
||||
if ($stats['error_rate'] > 5) {
|
||||
$recommendations[] = [
|
||||
'type' => 'high_error_rate',
|
||||
'service_id' => $serviceId,
|
||||
'message' => "Service {$serviceId} has high error rate: {$stats['error_rate']}%",
|
||||
'action' => 'consider_reducing_weight_or_failing_over'
|
||||
];
|
||||
}
|
||||
|
||||
if ($stats['average_response_time'] > 1000) { // 1 second
|
||||
$recommendations[] = [
|
||||
'type' => 'slow_response',
|
||||
'service_id' => $serviceId,
|
||||
'message' => "Service {$serviceId} has slow response time: {$stats['average_response_time']}ms",
|
||||
'action' => 'consider_reducing_weight'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze rule effectiveness
|
||||
foreach ($currentStats['rule_statistics'] as $ruleName => $stats) {
|
||||
if ($stats['enabled'] && $stats['match_count'] === 0) {
|
||||
$recommendations[] = [
|
||||
'type' => 'unused_rule',
|
||||
'rule_name' => $ruleName,
|
||||
'message' => "Rule {$ruleName} is enabled but never matches",
|
||||
'action' => 'review_or_disable_rule'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest weight adjustments
|
||||
$weightSuggestions = $this->suggestWeightAdjustments();
|
||||
$recommendations = array_merge($recommendations, $weightSuggestions);
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset traffic statistics.
|
||||
*/
|
||||
public function resetStatistics(): void
|
||||
{
|
||||
$this->trafficStats = [];
|
||||
$this->distributionHistory = [];
|
||||
|
||||
foreach ($this->rules as &$rule) {
|
||||
$rule['match_count'] = 0;
|
||||
$rule['last_matched'] = null;
|
||||
}
|
||||
|
||||
$this->logInfo("Traffic statistics reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Export traffic configuration.
|
||||
*/
|
||||
public function exportConfiguration(string $format = 'json'): string
|
||||
{
|
||||
$data = [
|
||||
'rules' => $this->rules,
|
||||
'traffic_weights' => $this->getTrafficWeights(),
|
||||
'statistics' => $this->getStatistics(),
|
||||
'distribution_history' => $this->distributionHistory,
|
||||
'exported_at' => date('Y-m-d H:i:s'),
|
||||
'version' => $this->config['version'] ?? '1.0'
|
||||
];
|
||||
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
case 'php':
|
||||
return '<?php return ' . var_export($data, true) . ';';
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported export format: {$format}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply routing rules to instances.
|
||||
*/
|
||||
protected function applyRoutingRules(array $instances, array $requestAnalysis): array
|
||||
{
|
||||
$filteredInstances = $instances;
|
||||
|
||||
foreach ($this->rules as $rule) {
|
||||
if (!$rule['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->matchesRuleConditions($rule['conditions'], $requestAnalysis)) {
|
||||
$rule['match_count'] = ($rule['match_count'] ?? 0) + 1;
|
||||
$rule['last_matched'] = time();
|
||||
|
||||
// Apply rule actions
|
||||
$filteredInstances = $this->applyRuleActions($filteredInstances, $rule['actions']);
|
||||
|
||||
// If rule has stop_processing flag, break
|
||||
if ($rule['stop_processing'] ?? false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $filteredInstances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request matches rule conditions.
|
||||
*/
|
||||
protected function matchesRuleConditions(array $conditions, array $requestAnalysis): bool
|
||||
{
|
||||
foreach ($conditions as $condition) {
|
||||
if (!$this->matchesCondition($condition, $requestAnalysis)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if single condition matches.
|
||||
*/
|
||||
protected function matchesCondition(array $condition, array $requestAnalysis): bool
|
||||
{
|
||||
$field = $condition['field'];
|
||||
$operator = $condition['operator'];
|
||||
$value = $condition['value'];
|
||||
$requestValue = $this->getRequestValue($requestAnalysis, $field);
|
||||
|
||||
switch ($operator) {
|
||||
case 'equals':
|
||||
return $requestValue === $value;
|
||||
case 'not_equals':
|
||||
return $requestValue !== $value;
|
||||
case 'in':
|
||||
return in_array($requestValue, (array) $value);
|
||||
case 'not_in':
|
||||
return !in_array($requestValue, (array) $value);
|
||||
case 'contains':
|
||||
return is_string($requestValue) && strpos($requestValue, $value) !== false;
|
||||
case 'starts_with':
|
||||
return is_string($requestValue) && strpos($requestValue, $value) === 0;
|
||||
case 'ends_with':
|
||||
return is_string($requestValue) && substr($requestValue, -strlen($value)) === $value;
|
||||
case 'greater_than':
|
||||
return $requestValue > $value;
|
||||
case 'less_than':
|
||||
return $requestValue < $value;
|
||||
case 'between':
|
||||
return $requestValue >= $value[0] && $requestValue <= $value[1];
|
||||
case 'regex':
|
||||
return preg_match($value, (string) $requestValue) === 1;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value from request analysis.
|
||||
*/
|
||||
protected function getRequestValue(array $requestAnalysis, string $field)
|
||||
{
|
||||
$fields = explode('.', $field);
|
||||
$value = $requestAnalysis;
|
||||
|
||||
foreach ($fields as $f) {
|
||||
if (!is_array($value) || !array_key_exists($f, $value)) {
|
||||
return null;
|
||||
}
|
||||
$value = $value[$f];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply rule actions to instances.
|
||||
*/
|
||||
protected function applyRuleActions(array $instances, array $actions): array
|
||||
{
|
||||
$filteredInstances = $instances;
|
||||
|
||||
foreach ($actions as $action) {
|
||||
switch ($action['type']) {
|
||||
case 'filter':
|
||||
$filteredInstances = $this->filterInstances($filteredInstances, $action['conditions']);
|
||||
break;
|
||||
case 'set_weight':
|
||||
$filteredInstances = $this->setInstanceWeights($filteredInstances, $action['weights']);
|
||||
break;
|
||||
case 'prioritize':
|
||||
$filteredInstances = $this->prioritizeInstances($filteredInstances, $action['criteria']);
|
||||
break;
|
||||
case 'exclude':
|
||||
$filteredInstances = $this->excludeInstances($filteredInstances, $action['instances']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $filteredInstances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select instance based on strategy.
|
||||
*/
|
||||
protected function selectInstance(array $instances, array $requestAnalysis, string $strategy): ?array
|
||||
{
|
||||
switch ($strategy) {
|
||||
case 'weighted':
|
||||
return $this->balancer->selectWeightedRandom($instances);
|
||||
case 'round_robin':
|
||||
return $this->balancer->selectRoundRobin($instances);
|
||||
case 'least_connections':
|
||||
return $this->balancer->selectLeastConnections($instances);
|
||||
case 'response_time':
|
||||
return $this->balancer->selectByResponseTime($instances);
|
||||
case 'geographic':
|
||||
$location = $requestAnalysis['client']['location'] ?? 'unknown';
|
||||
return $this->distributeByGeography($instances, $location);
|
||||
case 'segment':
|
||||
$segment = $requestAnalysis['user']['segment'] ?? 'default';
|
||||
return $this->distributeBySegment($instances, $segment);
|
||||
case 'capacity':
|
||||
return $this->distributeByCapacity($instances);
|
||||
default:
|
||||
return $instances[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find closest instances by geography.
|
||||
*/
|
||||
protected function findClosestInstances(array $instances, string $clientLocation): array
|
||||
{
|
||||
// This would use a proper geolocation service in production
|
||||
// For now, return instances with location matching
|
||||
return array_filter($instances, function($instance) use ($clientLocation) {
|
||||
$instanceLocation = $instance['location'] ?? 'unknown';
|
||||
return $instanceLocation === $clientLocation || $instanceLocation === 'global';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record traffic distribution.
|
||||
*/
|
||||
protected function recordTrafficDistribution(string $serviceId, array $requestAnalysis, string $strategy): void
|
||||
{
|
||||
// Update service stats
|
||||
if (!isset($this->trafficStats[$serviceId])) {
|
||||
$this->trafficStats[$serviceId] = [
|
||||
'total_requests' => 0,
|
||||
'requests_by_strategy' => [],
|
||||
'response_times' => [],
|
||||
'errors' => 0,
|
||||
'last_request' => null
|
||||
];
|
||||
}
|
||||
|
||||
$this->trafficStats[$serviceId]['total_requests']++;
|
||||
$this->trafficStats[$serviceId]['requests_by_strategy'][$strategy] =
|
||||
($this->trafficStats[$serviceId]['requests_by_strategy'][$strategy] ?? 0) + 1;
|
||||
$this->trafficStats[$serviceId]['last_request'] = time();
|
||||
|
||||
// Add to distribution history
|
||||
$this->distributionHistory[] = [
|
||||
'timestamp' => time(),
|
||||
'service_id' => $serviceId,
|
||||
'strategy' => $strategy,
|
||||
'request_analysis' => $requestAnalysis
|
||||
];
|
||||
|
||||
// Limit history size
|
||||
if (count($this->distributionHistory) > 1000) {
|
||||
$this->distributionHistory = array_slice($this->distributionHistory, -1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average response time for service.
|
||||
*/
|
||||
protected function calculateAverageResponseTime(string $serviceId): float
|
||||
{
|
||||
$stats = $this->trafficStats[$serviceId] ?? [];
|
||||
$responseTimes = $stats['response_times'] ?? [];
|
||||
|
||||
if (empty($responseTimes)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return array_sum($responseTimes) / count($responseTimes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate error rate for service.
|
||||
*/
|
||||
protected function calculateErrorRate(string $serviceId): float
|
||||
{
|
||||
$stats = $this->trafficStats[$serviceId] ?? [];
|
||||
$totalRequests = $stats['total_requests'] ?? 0;
|
||||
$errors = $stats['errors'] ?? 0;
|
||||
|
||||
if ($totalRequests === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return ($errors / $totalRequests) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest weight adjustments.
|
||||
*/
|
||||
protected function suggestWeightAdjustments(): array
|
||||
{
|
||||
$suggestions = [];
|
||||
$stats = $this->getStatistics();
|
||||
|
||||
foreach ($stats['service_statistics'] as $serviceId => $serviceStats) {
|
||||
if ($serviceStats['error_rate'] > 2) {
|
||||
$suggestions[] = [
|
||||
'type' => 'weight_adjustment',
|
||||
'service_id' => $serviceId,
|
||||
'current_weight' => $this->balancer->getWeight($serviceId),
|
||||
'suggested_weight' => max(1, (int) ($this->balancer->getWeight($serviceId) * 0.5)),
|
||||
'reason' => 'High error rate detected'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate rule configuration.
|
||||
*/
|
||||
protected function validateRule(array $rule): void
|
||||
{
|
||||
if (empty($rule['conditions'])) {
|
||||
throw new \InvalidArgumentException("Rule must have at least one condition");
|
||||
}
|
||||
|
||||
if (empty($rule['actions'])) {
|
||||
throw new \InvalidArgumentException("Rule must have at least one action");
|
||||
}
|
||||
|
||||
foreach ($rule['conditions'] as $condition) {
|
||||
if (!isset($condition['field']) || !isset($condition['operator']) || !isset($condition['value'])) {
|
||||
throw new \InvalidArgumentException("Condition must have field, operator, and value");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize traffic distribution strategy.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Load configuration from storage
|
||||
$this->loadConfiguration();
|
||||
|
||||
// Start monitoring
|
||||
$this->monitor->start();
|
||||
|
||||
$this->logInfo("Traffic distribution strategy initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from storage.
|
||||
*/
|
||||
protected function loadConfiguration(): void
|
||||
{
|
||||
// This would load from persistent storage in production
|
||||
$this->logInfo("Configuration loaded");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[TrafficDistributionStrategy] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'default_strategy' => 'weighted',
|
||||
'enable_monitoring' => true,
|
||||
'monitoring_interval' => 60,
|
||||
'history_retention' => 1000,
|
||||
'logging_enabled' => true,
|
||||
'optimization_enabled' => true,
|
||||
'optimization_interval' => 300,
|
||||
'version' => '1.0'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create traffic distribution strategy instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for high traffic.
|
||||
*/
|
||||
public static function forHighTraffic(): self
|
||||
{
|
||||
return new self([
|
||||
'default_strategy' => 'capacity',
|
||||
'enable_monitoring' => true,
|
||||
'monitoring_interval' => 30,
|
||||
'optimization_enabled' => true,
|
||||
'optimization_interval' => 120
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for global distribution.
|
||||
*/
|
||||
public static function forGlobalDistribution(): self
|
||||
{
|
||||
return new self([
|
||||
'default_strategy' => 'geographic',
|
||||
'enable_monitoring' => true,
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\LoadBalancer\Weight;
|
||||
|
||||
use Fendx\Service\LoadBalancer\Weight\Calculator\WeightCalculator;
|
||||
use Fendx\Service\LoadBalancer\Weight\Storage\WeightStorage;
|
||||
use Fendx\Service\LoadBalancer\Weight\Adjuster\WeightAdjuster;
|
||||
|
||||
class WeightManager
|
||||
{
|
||||
protected WeightCalculator $calculator;
|
||||
protected WeightStorage $storage;
|
||||
protected WeightAdjuster $adjuster;
|
||||
protected array $config = [];
|
||||
protected array $weights = [];
|
||||
protected array $metadata = [];
|
||||
protected array $adjustmentHistory = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->calculator = new WeightCalculator($this->config);
|
||||
$this->storage = new WeightStorage($this->config);
|
||||
$this->adjuster = new WeightAdjuster($this->config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set service weight.
|
||||
*/
|
||||
public function setWeight(string $serviceId, int $weight, array $metadata = []): void
|
||||
{
|
||||
if ($weight < $this->config['min_weight'] || $weight > $this->config['max_weight']) {
|
||||
throw new \InvalidArgumentException("Weight must be between {$this->config['min_weight']} and {$this->config['max_weight']}");
|
||||
}
|
||||
|
||||
$previousWeight = $this->weights[$serviceId] ?? 1;
|
||||
|
||||
$this->weights[$serviceId] = $weight;
|
||||
$this->metadata[$serviceId] = array_merge($this->metadata[$serviceId] ?? [], $metadata);
|
||||
$this->metadata[$serviceId]['updated_at'] = time();
|
||||
|
||||
// Persist to storage
|
||||
$this->storage->store($serviceId, $weight, $this->metadata[$serviceId]);
|
||||
|
||||
// Record adjustment
|
||||
$this->recordAdjustment($serviceId, $previousWeight, $weight, 'manual', $metadata);
|
||||
|
||||
$this->logInfo("Weight set for service {$serviceId}: {$weight} (was {$previousWeight})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service weight.
|
||||
*/
|
||||
public function getWeight(string $serviceId): int
|
||||
{
|
||||
return $this->weights[$serviceId] ?? $this->config['default_weight'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all weights.
|
||||
*/
|
||||
public function getWeights(): array
|
||||
{
|
||||
return $this->weights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service metadata.
|
||||
*/
|
||||
public function getMetadata(string $serviceId): array
|
||||
{
|
||||
return $this->metadata[$serviceId] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple weights.
|
||||
*/
|
||||
public function setWeights(array $weights): void
|
||||
{
|
||||
foreach ($weights as $serviceId => $data) {
|
||||
if (is_array($data)) {
|
||||
$this->setWeight($serviceId, $data['weight'], $data['metadata'] ?? []);
|
||||
} else {
|
||||
$this->setWeight($serviceId, $data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove service weight.
|
||||
*/
|
||||
public function removeWeight(string $serviceId): bool
|
||||
{
|
||||
if (!isset($this->weights[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$previousWeight = $this->weights[$serviceId];
|
||||
unset($this->weights[$serviceId]);
|
||||
unset($this->metadata[$serviceId]);
|
||||
|
||||
// Remove from storage
|
||||
$this->storage->remove($serviceId);
|
||||
|
||||
// Record adjustment
|
||||
$this->recordAdjustment($serviceId, $previousWeight, 0, 'removed');
|
||||
|
||||
$this->logInfo("Weight removed for service: {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust weight by percentage.
|
||||
*/
|
||||
public function adjustWeightByPercentage(string $serviceId, float $percentage, string $reason = ''): void
|
||||
{
|
||||
$currentWeight = $this->getWeight($serviceId);
|
||||
$adjustment = (int) round($currentWeight * ($percentage / 100));
|
||||
$newWeight = max($this->config['min_weight'],
|
||||
min($this->config['max_weight'], $currentWeight + $adjustment));
|
||||
|
||||
$this->setWeight($serviceId, $newWeight, [
|
||||
'adjustment_type' => 'percentage',
|
||||
'percentage' => $percentage,
|
||||
'reason' => $reason
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust weight by absolute value.
|
||||
*/
|
||||
public function adjustWeightByValue(string $serviceId, int $adjustment, string $reason = ''): void
|
||||
{
|
||||
$currentWeight = $this->getWeight($serviceId);
|
||||
$newWeight = max($this->config['min_weight'],
|
||||
min($this->config['max_weight'], $currentWeight + $adjustment));
|
||||
|
||||
$this->setWeight($serviceId, $newWeight, [
|
||||
'adjustment_type' => 'absolute',
|
||||
'adjustment' => $adjustment,
|
||||
'reason' => $reason
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-adjust weights based on performance metrics.
|
||||
*/
|
||||
public function autoAdjustWeights(array $metrics): array
|
||||
{
|
||||
$adjustments = [];
|
||||
|
||||
foreach ($metrics as $serviceId => $serviceMetrics) {
|
||||
$currentWeight = $this->getWeight($serviceId);
|
||||
$recommendedWeight = $this->calculator->calculateOptimalWeight($serviceMetrics, $currentWeight);
|
||||
|
||||
if ($recommendedWeight !== $currentWeight) {
|
||||
$this->setWeight($serviceId, $recommendedWeight, [
|
||||
'adjustment_type' => 'auto',
|
||||
'metrics' => $serviceMetrics,
|
||||
'previous_weight' => $currentWeight
|
||||
]);
|
||||
|
||||
$adjustments[$serviceId] = [
|
||||
'previous' => $currentWeight,
|
||||
'new' => $recommendedWeight,
|
||||
'change' => $recommendedWeight - $currentWeight
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Auto-adjusted weights for " . count($adjustments) . " services");
|
||||
|
||||
return $adjustments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance weights across services.
|
||||
*/
|
||||
public function balanceWeights(array $serviceIds, int $totalWeight = null): array
|
||||
{
|
||||
$totalWeight = $totalWeight ?? $this->config['default_total_weight'];
|
||||
$serviceCount = count($serviceIds);
|
||||
|
||||
if ($serviceCount === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$baseWeight = (int) floor($totalWeight / $serviceCount);
|
||||
$remainder = $totalWeight % $serviceCount;
|
||||
|
||||
$balancedWeights = [];
|
||||
$currentWeights = [];
|
||||
|
||||
// Get current weights for reference
|
||||
foreach ($serviceIds as $i => $serviceId) {
|
||||
$currentWeights[$serviceId] = $this->getWeight($serviceId);
|
||||
$balancedWeights[$serviceId] = $baseWeight + ($i < $remainder ? 1 : 0);
|
||||
}
|
||||
|
||||
// Apply balanced weights
|
||||
foreach ($balancedWeights as $serviceId => $weight) {
|
||||
$this->setWeight($serviceId, $weight, [
|
||||
'adjustment_type' => 'balanced',
|
||||
'previous_weight' => $currentWeights[$serviceId],
|
||||
'total_weight' => $totalWeight
|
||||
]);
|
||||
}
|
||||
|
||||
$this->logInfo("Balanced weights for {$serviceCount} services (total: {$totalWeight})");
|
||||
|
||||
return $balancedWeights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize weights to a specific total.
|
||||
*/
|
||||
public function normalizeWeights(array $serviceIds, int $targetTotal = 100): array
|
||||
{
|
||||
$currentTotal = 0;
|
||||
$currentWeights = [];
|
||||
|
||||
foreach ($serviceIds as $serviceId) {
|
||||
$weight = $this->getWeight($serviceId);
|
||||
$currentWeights[$serviceId] = $weight;
|
||||
$currentTotal += $weight;
|
||||
}
|
||||
|
||||
if ($currentTotal === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalizedWeights = [];
|
||||
$distributed = 0;
|
||||
|
||||
foreach ($serviceIds as $i => $serviceId) {
|
||||
$ratio = $currentWeights[$serviceId] / $currentTotal;
|
||||
$normalizedWeight = (int) round($ratio * $targetTotal);
|
||||
|
||||
// Ensure minimum weight
|
||||
$normalizedWeight = max($this->config['min_weight'], $normalizedWeight);
|
||||
|
||||
$normalizedWeights[$serviceId] = $normalizedWeight;
|
||||
$distributed += $normalizedWeight;
|
||||
}
|
||||
|
||||
// Distribute remainder
|
||||
$remainder = $targetTotal - $distributed;
|
||||
if ($remainder > 0) {
|
||||
$serviceIds = array_values($serviceIds);
|
||||
for ($i = 0; $i < $remainder && $i < count($serviceIds); $i++) {
|
||||
$serviceId = $serviceIds[$i];
|
||||
$normalizedWeights[$serviceId]++;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply normalized weights
|
||||
foreach ($normalizedWeights as $serviceId => $weight) {
|
||||
$this->setWeight($serviceId, $weight, [
|
||||
'adjustment_type' => 'normalized',
|
||||
'previous_weight' => $currentWeights[$serviceId],
|
||||
'target_total' => $targetTotal
|
||||
]);
|
||||
}
|
||||
|
||||
$this->logInfo("Normalized weights for " . count($serviceIds) . " services to total {$targetTotal}");
|
||||
|
||||
return $normalizedWeights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weight distribution.
|
||||
*/
|
||||
public function getWeightDistribution(array $serviceIds = null): array
|
||||
{
|
||||
$serviceIds = $serviceIds ?? array_keys($this->weights);
|
||||
$distribution = [];
|
||||
$totalWeight = 0;
|
||||
|
||||
foreach ($serviceIds as $serviceId) {
|
||||
$weight = $this->getWeight($serviceId);
|
||||
$distribution[$serviceId] = $weight;
|
||||
$totalWeight += $weight;
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
$percentages = [];
|
||||
foreach ($distribution as $serviceId => $weight) {
|
||||
$percentages[$serviceId] = $totalWeight > 0 ? ($weight / $totalWeight) * 100 : 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'weights' => $distribution,
|
||||
'percentages' => $percentages,
|
||||
'total_weight' => $totalWeight,
|
||||
'service_count' => count($distribution)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weight statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
if (empty($this->weights)) {
|
||||
return [
|
||||
'total_services' => 0,
|
||||
'total_weight' => 0,
|
||||
'average_weight' => 0,
|
||||
'min_weight' => 0,
|
||||
'max_weight' => 0,
|
||||
'weight_distribution' => []
|
||||
];
|
||||
}
|
||||
|
||||
$weights = array_values($this->weights);
|
||||
$totalWeight = array_sum($weights);
|
||||
$averageWeight = $totalWeight / count($weights);
|
||||
$minWeight = min($weights);
|
||||
$maxWeight = max($weights);
|
||||
|
||||
// Weight distribution ranges
|
||||
$distribution = [
|
||||
'1-10' => 0,
|
||||
'11-50' => 0,
|
||||
'51-100' => 0,
|
||||
'101-500' => 0,
|
||||
'500+' => 0
|
||||
];
|
||||
|
||||
foreach ($weights as $weight) {
|
||||
if ($weight <= 10) $distribution['1-10']++;
|
||||
elseif ($weight <= 50) $distribution['11-50']++;
|
||||
elseif ($weight <= 100) $distribution['51-100']++;
|
||||
elseif ($weight <= 500) $distribution['101-500']++;
|
||||
else $distribution['500+']++;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_services' => count($this->weights),
|
||||
'total_weight' => $totalWeight,
|
||||
'average_weight' => $averageWeight,
|
||||
'min_weight' => $minWeight,
|
||||
'max_weight' => $maxWeight,
|
||||
'weight_distribution' => $distribution,
|
||||
'adjustment_history_count' => count($this->adjustmentHistory)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get adjustment history.
|
||||
*/
|
||||
public function getAdjustmentHistory(string $serviceId = null, int $limit = 100): array
|
||||
{
|
||||
$history = $this->adjustmentHistory;
|
||||
|
||||
if ($serviceId) {
|
||||
$history = array_filter($history, function($record) use ($serviceId) {
|
||||
return $record['service_id'] === $serviceId;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
usort($history, function($a, $b) {
|
||||
return $b['timestamp'] <=> $a['timestamp'];
|
||||
});
|
||||
|
||||
return array_slice($history, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export weights.
|
||||
*/
|
||||
public function exportWeights(string $format = 'json'): string
|
||||
{
|
||||
$data = [
|
||||
'weights' => $this->weights,
|
||||
'metadata' => $this->metadata,
|
||||
'adjustment_history' => $this->adjustmentHistory,
|
||||
'statistics' => $this->getStatistics(),
|
||||
'exported_at' => date('Y-m-d H:i:s'),
|
||||
'version' => $this->config['version'] ?? '1.0'
|
||||
];
|
||||
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
case 'php':
|
||||
return '<?php return ' . var_export($data, true) . ';';
|
||||
case 'csv':
|
||||
return $this->exportToCSV($data);
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported export format: {$format}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import weights.
|
||||
*/
|
||||
public function importWeights(string $data, string $format = 'json'): void
|
||||
{
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
$imported = json_decode($data, true);
|
||||
break;
|
||||
case 'php':
|
||||
$imported = include 'data://text/plain;base64,' . base64_encode($data);
|
||||
break;
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported import format: {$format}");
|
||||
}
|
||||
|
||||
if (!$imported) {
|
||||
throw new \InvalidArgumentException("Invalid import data");
|
||||
}
|
||||
|
||||
if (isset($imported['weights'])) {
|
||||
foreach ($imported['weights'] as $serviceId => $weight) {
|
||||
$metadata = $imported['metadata'][$serviceId] ?? [];
|
||||
$this->setWeight($serviceId, $weight, $metadata);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($imported['adjustment_history'])) {
|
||||
$this->adjustmentHistory = array_merge($this->adjustmentHistory, $imported['adjustment_history']);
|
||||
}
|
||||
|
||||
$this->logInfo("Weights imported successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all weights.
|
||||
*/
|
||||
public function resetWeights(): void
|
||||
{
|
||||
$this->weights = [];
|
||||
$this->metadata = [];
|
||||
$this->adjustmentHistory = [];
|
||||
$this->storage->clear();
|
||||
|
||||
$this->logInfo("All weights reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate weight configuration.
|
||||
*/
|
||||
public function validateConfiguration(): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
// Check weight ranges
|
||||
foreach ($this->weights as $serviceId => $weight) {
|
||||
if ($weight < $this->config['min_weight']) {
|
||||
$errors[] = "Weight for {$serviceId} ({$weight}) is below minimum ({$this->config['min_weight']})";
|
||||
}
|
||||
|
||||
if ($weight > $this->config['max_weight']) {
|
||||
$errors[] = "Weight for {$serviceId} ({$weight}) is above maximum ({$this->config['max_weight']})";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for zero weights
|
||||
$zeroWeights = array_filter($this->weights, fn($w) => $w === 0);
|
||||
if (!empty($zeroWeights)) {
|
||||
$warnings[] = count($zeroWeights) . " services have zero weight and will not receive traffic";
|
||||
}
|
||||
|
||||
// Check weight distribution
|
||||
$stats = $this->getStatistics();
|
||||
if ($stats['total_weight'] === 0) {
|
||||
$errors[] = "Total weight is zero - no traffic will be distributed";
|
||||
}
|
||||
|
||||
// Check for extreme weight ratios
|
||||
if ($stats['max_weight'] > 0 && $stats['min_weight'] > 0) {
|
||||
$ratio = $stats['max_weight'] / $stats['min_weight'];
|
||||
if ($ratio > 100) {
|
||||
$warnings[] = "Extreme weight ratio detected ({$ratio}:1) - consider rebalancing";
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record weight adjustment.
|
||||
*/
|
||||
protected function recordAdjustment(string $serviceId, int $previousWeight, int $newWeight, string $type, array $metadata = []): void
|
||||
{
|
||||
$this->adjustmentHistory[] = [
|
||||
'service_id' => $serviceId,
|
||||
'previous_weight' => $previousWeight,
|
||||
'new_weight' => $newWeight,
|
||||
'change' => $newWeight - $previousWeight,
|
||||
'type' => $type,
|
||||
'metadata' => $metadata,
|
||||
'timestamp' => time()
|
||||
];
|
||||
|
||||
// Limit history size
|
||||
$maxHistory = $this->config['max_adjustment_history'] ?? 1000;
|
||||
if (count($this->adjustmentHistory) > $maxHistory) {
|
||||
$this->adjustmentHistory = array_slice($this->adjustmentHistory, -$maxHistory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export to CSV format.
|
||||
*/
|
||||
protected function exportToCSV(array $data): string
|
||||
{
|
||||
$csv = "Service ID,Weight,Metadata,Updated At\n";
|
||||
|
||||
foreach ($data['weights'] as $serviceId => $weight) {
|
||||
$metadata = json_encode($data['metadata'][$serviceId] ?? []);
|
||||
$updatedAt = $data['metadata'][$serviceId]['updated_at'] ?? '';
|
||||
$csv .= "{$serviceId},{$weight},\"{$metadata}\",{$updatedAt}\n";
|
||||
}
|
||||
|
||||
return $csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize weight manager.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Load existing weights from storage
|
||||
$this->loadWeights();
|
||||
|
||||
// Start background tasks if configured
|
||||
if ($this->config['auto_adjust']) {
|
||||
$this->startAutoAdjustment();
|
||||
}
|
||||
|
||||
$this->logInfo("Weight manager initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load weights from storage.
|
||||
*/
|
||||
protected function loadWeights(): void
|
||||
{
|
||||
$stored = $this->storage->loadAll();
|
||||
|
||||
foreach ($stored as $serviceId => $data) {
|
||||
$this->weights[$serviceId] = $data['weight'];
|
||||
$this->metadata[$serviceId] = $data['metadata'] ?? [];
|
||||
}
|
||||
|
||||
$this->logInfo("Loaded " . count($stored) . " weights from storage");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-adjustment task.
|
||||
*/
|
||||
protected function startAutoAdjustment(): void
|
||||
{
|
||||
// This would typically be run as a background process
|
||||
// For now, we'll just log that it would start
|
||||
$this->logInfo("Auto-adjustment task started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[WeightManager] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'min_weight' => 1,
|
||||
'max_weight' => 1000,
|
||||
'default_weight' => 1,
|
||||
'default_total_weight' => 100,
|
||||
'auto_adjust' => false,
|
||||
'adjustment_interval' => 300, // 5 minutes
|
||||
'max_adjustment_history' => 1000,
|
||||
'logging_enabled' => true,
|
||||
'storage' => [
|
||||
'type' => 'file',
|
||||
'path' => __DIR__ . '/../../../storage/weights'
|
||||
],
|
||||
'calculator' => [
|
||||
'response_time_weight' => 0.4,
|
||||
'error_rate_weight' => 0.3,
|
||||
'throughput_weight' => 0.3
|
||||
],
|
||||
'version' => '1.0'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create weight manager instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'min_weight' => 1,
|
||||
'max_weight' => 100,
|
||||
'default_weight' => 10,
|
||||
'auto_adjust' => true,
|
||||
'adjustment_interval' => 300,
|
||||
'logging_enabled' => false,
|
||||
'storage' => [
|
||||
'type' => 'redis',
|
||||
'host' => 'localhost',
|
||||
'port' => 6379
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'min_weight' => 1,
|
||||
'max_weight' => 10,
|
||||
'default_weight' => 1,
|
||||
'auto_adjust' => false,
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
813
fendx-framework/fendx-service/src/Metadata/MetadataManager.php
Normal file
813
fendx-framework/fendx-service/src/Metadata/MetadataManager.php
Normal file
@@ -0,0 +1,813 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Metadata;
|
||||
|
||||
use Fendx\Service\Metadata\Storage\MetadataStorage;
|
||||
use Fendx\Service\Metadata\Validator\MetadataValidator;
|
||||
use Fendx\Service\Metadata\Serializer\MetadataSerializer;
|
||||
|
||||
class MetadataManager
|
||||
{
|
||||
protected MetadataStorage $storage;
|
||||
protected MetadataValidator $validator;
|
||||
protected MetadataSerializer $serializer;
|
||||
protected array $config = [];
|
||||
protected array $metadata = [];
|
||||
protected array $schemas = [];
|
||||
protected array $connections = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->storage = new MetadataStorage($this->config);
|
||||
$this->validator = new MetadataValidator($this->config);
|
||||
$this->serializer = new MetadataSerializer($this->config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metadata for a service.
|
||||
*/
|
||||
public function setMetadata(string $serviceId, array $metadata): bool
|
||||
{
|
||||
// Validate metadata
|
||||
$validation = $this->validator->validate($metadata);
|
||||
if (!$validation['valid']) {
|
||||
throw new \InvalidArgumentException("Invalid metadata: " . implode(', ', $validation['errors']));
|
||||
}
|
||||
|
||||
// Add timestamps
|
||||
$metadata['created_at'] = time();
|
||||
$metadata['updated_at'] = time();
|
||||
|
||||
// Store metadata
|
||||
$this->metadata[$serviceId] = $metadata;
|
||||
|
||||
// Persist to storage
|
||||
$this->storage->store($serviceId, $metadata);
|
||||
|
||||
$this->logInfo("Metadata set for service: {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a service.
|
||||
*/
|
||||
public function getMetadata(string $serviceId): ?array
|
||||
{
|
||||
if (!isset($this->metadata[$serviceId])) {
|
||||
// Try to load from storage
|
||||
$loaded = $this->storage->load($serviceId);
|
||||
if ($loaded) {
|
||||
$this->metadata[$serviceId] = $loaded;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->metadata[$serviceId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metadata for a service.
|
||||
*/
|
||||
public function updateMetadata(string $serviceId, array $updates): bool
|
||||
{
|
||||
$existing = $this->getMetadata($serviceId);
|
||||
if (!$existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Merge updates
|
||||
$updated = array_merge($existing, $updates);
|
||||
$updated['updated_at'] = time();
|
||||
|
||||
// Validate updated metadata
|
||||
$validation = $this->validator->validate($updated);
|
||||
if (!$validation['valid']) {
|
||||
throw new \InvalidArgumentException("Invalid metadata updates: " . implode(', ', $validation['errors']));
|
||||
}
|
||||
|
||||
// Store updated metadata
|
||||
$this->metadata[$serviceId] = $updated;
|
||||
|
||||
// Persist to storage
|
||||
$this->storage->store($serviceId, $updated);
|
||||
|
||||
$this->logInfo("Metadata updated for service: {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete metadata for a service.
|
||||
*/
|
||||
public function deleteMetadata(string $serviceId): bool
|
||||
{
|
||||
if (!isset($this->metadata[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($this->metadata[$serviceId]);
|
||||
|
||||
// Remove from storage
|
||||
$this->storage->delete($serviceId);
|
||||
|
||||
$this->logInfo("Metadata deleted for service: {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific metadata field.
|
||||
*/
|
||||
public function getField(string $serviceId, string $field, $default = null)
|
||||
{
|
||||
$metadata = $this->getMetadata($serviceId);
|
||||
|
||||
if (!$metadata) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $this->getNestedValue($metadata, $field, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set specific metadata field.
|
||||
*/
|
||||
public function setField(string $serviceId, string $field, $value): bool
|
||||
{
|
||||
$metadata = $this->getMetadata($serviceId) ?? [];
|
||||
$this->setNestedValue($metadata, $field, $value);
|
||||
|
||||
return $this->setMetadata($serviceId, $metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific metadata field.
|
||||
*/
|
||||
public function updateField(string $serviceId, string $field, $value): bool
|
||||
{
|
||||
return $this->setField($serviceId, $field, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific metadata field.
|
||||
*/
|
||||
public function deleteField(string $serviceId, string $field): bool
|
||||
{
|
||||
$metadata = $this->getMetadata($serviceId);
|
||||
if (!$metadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->unsetNestedValue($metadata, $field);
|
||||
|
||||
return $this->setMetadata($serviceId, $metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search services by metadata criteria.
|
||||
*/
|
||||
public function search(array $criteria): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->metadata as $serviceId => $metadata) {
|
||||
if ($this->matchesCriteria($metadata, $criteria)) {
|
||||
$results[$serviceId] = $metadata;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find services by tags.
|
||||
*/
|
||||
public function findByTags(array $tags): array
|
||||
{
|
||||
return $this->search(['tags' => ['$all' => $tags]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find services by version.
|
||||
*/
|
||||
public function findByVersion(string $version): array
|
||||
{
|
||||
return $this->search(['version' => $version]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find services by environment.
|
||||
*/
|
||||
public function findByEnvironment(string $environment): array
|
||||
{
|
||||
return $this->search(['environment' => $environment]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find services by custom attribute.
|
||||
*/
|
||||
public function findByAttribute(string $attribute, $value): array
|
||||
{
|
||||
return $this->search([$attribute => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metadata.
|
||||
*/
|
||||
public function getAllMetadata(): array
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$totalServices = count($this->metadata);
|
||||
$totalFields = 0;
|
||||
$fieldCounts = [];
|
||||
$tagCounts = [];
|
||||
$versionCounts = [];
|
||||
$environmentCounts = [];
|
||||
|
||||
foreach ($this->metadata as $serviceId => $metadata) {
|
||||
$totalFields += count($metadata);
|
||||
|
||||
// Count field usage
|
||||
foreach (array_keys($metadata) as $field) {
|
||||
$fieldCounts[$field] = ($fieldCounts[$field] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Count tags
|
||||
if (isset($metadata['tags']) && is_array($metadata['tags'])) {
|
||||
foreach ($metadata['tags'] as $tag) {
|
||||
$tagCounts[$tag] = ($tagCounts[$tag] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Count versions
|
||||
if (isset($metadata['version'])) {
|
||||
$version = $metadata['version'];
|
||||
$versionCounts[$version] = ($versionCounts[$version] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Count environments
|
||||
if (isset($metadata['environment'])) {
|
||||
$env = $metadata['environment'];
|
||||
$environmentCounts[$env] = ($environmentCounts[$env] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_services' => $totalServices,
|
||||
'total_fields' => $totalFields,
|
||||
'average_fields_per_service' => $totalServices > 0 ? $totalFields / $totalServices : 0,
|
||||
'field_counts' => $fieldCounts,
|
||||
'tag_counts' => $tagCounts,
|
||||
'version_counts' => $versionCounts,
|
||||
'environment_counts' => $environmentCounts,
|
||||
'most_common_fields' => $this->getTopItems($fieldCounts, 10),
|
||||
'most_common_tags' => $this->getTopItems($tagCounts, 10)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register metadata schema.
|
||||
*/
|
||||
public function registerSchema(string $name, array $schema): void
|
||||
{
|
||||
$this->schemas[$name] = $schema;
|
||||
$this->logInfo("Registered metadata schema: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate metadata against schema.
|
||||
*/
|
||||
public function validateAgainstSchema(string $serviceId, string $schemaName): array
|
||||
{
|
||||
$metadata = $this->getMetadata($serviceId);
|
||||
if (!$metadata) {
|
||||
return ['valid' => false, 'errors' => ['Service not found']];
|
||||
}
|
||||
|
||||
if (!isset($this->schemas[$schemaName])) {
|
||||
return ['valid' => false, 'errors' => ['Schema not found: ' . $schemaName]];
|
||||
}
|
||||
|
||||
return $this->validator->validateAgainstSchema($metadata, $this->schemas[$schemaName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered schemas.
|
||||
*/
|
||||
public function getSchemas(): array
|
||||
{
|
||||
return $this->schemas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export metadata.
|
||||
*/
|
||||
public function export(string $format = 'json'): string
|
||||
{
|
||||
$data = [
|
||||
'metadata' => $this->metadata,
|
||||
'schemas' => $this->schemas,
|
||||
'exported_at' => date('Y-m-d H:i:s'),
|
||||
'version' => $this->config['version'] ?? '1.0'
|
||||
];
|
||||
|
||||
return $this->serializer->serialize($data, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import metadata.
|
||||
*/
|
||||
public function import(string $data, string $format = 'json'): void
|
||||
{
|
||||
$imported = $this->serializer->deserialize($data, $format);
|
||||
|
||||
if (!$imported) {
|
||||
throw new \InvalidArgumentException("Invalid import data");
|
||||
}
|
||||
|
||||
if (isset($imported['metadata'])) {
|
||||
foreach ($imported['metadata'] as $serviceId => $metadata) {
|
||||
$this->setMetadata($serviceId, $metadata);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($imported['schemas'])) {
|
||||
foreach ($imported['schemas'] as $name => $schema) {
|
||||
$this->registerSchema($name, $schema);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Metadata imported successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Track service connections.
|
||||
*/
|
||||
public function trackConnection(string $serviceId, string $targetServiceId, array $connectionData = []): void
|
||||
{
|
||||
if (!isset($this->connections[$serviceId])) {
|
||||
$this->connections[$serviceId] = [];
|
||||
}
|
||||
|
||||
$connectionId = $this->generateConnectionId($serviceId, $targetServiceId);
|
||||
|
||||
$this->connections[$serviceId][$connectionId] = [
|
||||
'id' => $connectionId,
|
||||
'source_service' => $serviceId,
|
||||
'target_service' => $targetServiceId,
|
||||
'connection_data' => $connectionData,
|
||||
'created_at' => time(),
|
||||
'last_used' => time(),
|
||||
'usage_count' => 1
|
||||
];
|
||||
|
||||
// Update connection usage
|
||||
$this->updateConnectionUsage($connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service connections.
|
||||
*/
|
||||
public function getConnections(string $serviceId): array
|
||||
{
|
||||
return $this->connections[$serviceId] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection count for service.
|
||||
*/
|
||||
public function getConnectionCount(string $serviceId): int
|
||||
{
|
||||
return count($this->getConnections($serviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection usage.
|
||||
*/
|
||||
public function updateConnectionUsage(string $connectionId): void
|
||||
{
|
||||
foreach ($this->connections as $serviceId => $connections) {
|
||||
if (isset($connections[$connectionId])) {
|
||||
$this->connections[$serviceId][$connectionId]['last_used'] = time();
|
||||
$this->connections[$serviceId][$connectionId]['usage_count']++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics.
|
||||
*/
|
||||
public function getConnectionStatistics(): array
|
||||
{
|
||||
$totalConnections = 0;
|
||||
$serviceConnections = [];
|
||||
$mostConnectedServices = [];
|
||||
|
||||
foreach ($this->connections as $serviceId => $connections) {
|
||||
$count = count($connections);
|
||||
$totalConnections += $count;
|
||||
$serviceConnections[$serviceId] = $count;
|
||||
}
|
||||
|
||||
// Sort by connection count
|
||||
arsort($serviceConnections);
|
||||
$mostConnectedServices = array_slice($serviceConnections, 0, 10, true);
|
||||
|
||||
return [
|
||||
'total_connections' => $totalConnections,
|
||||
'services_with_connections' => count($this->connections),
|
||||
'service_connections' => $serviceConnections,
|
||||
'most_connected_services' => $mostConnectedServices,
|
||||
'average_connections_per_service' => count($this->connections) > 0 ?
|
||||
$totalConnections / count($this->connections) : 0
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old metadata.
|
||||
*/
|
||||
public function cleanup(int $maxAge = 86400): array
|
||||
{
|
||||
$now = time();
|
||||
$removed = [];
|
||||
|
||||
foreach ($this->metadata as $serviceId => $metadata) {
|
||||
if (isset($metadata['updated_at']) && ($now - $metadata['updated_at']) > $maxAge) {
|
||||
$this->deleteMetadata($serviceId);
|
||||
$removed[] = $serviceId;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Cleaned up " . count($removed) . " old metadata entries");
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup metadata.
|
||||
*/
|
||||
public function backup(string $path): bool
|
||||
{
|
||||
$data = $this->export('json');
|
||||
$result = file_put_contents($path, $data);
|
||||
|
||||
if ($result !== false) {
|
||||
$this->logInfo("Metadata backed up to: {$path}");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore metadata from backup.
|
||||
*/
|
||||
public function restore(string $path): bool
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = file_get_contents($path);
|
||||
if ($data === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->import($data, 'json');
|
||||
$this->logInfo("Metadata restored from: {$path}");
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
$this->logError("Failed to restore metadata: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metadata matches criteria.
|
||||
*/
|
||||
protected function matchesCriteria(array $metadata, array $criteria): bool
|
||||
{
|
||||
foreach ($criteria as $field => $expected) {
|
||||
$value = $this->getNestedValue($metadata, $field);
|
||||
|
||||
if (!$this->matchesValue($value, $expected)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value matches expected criteria.
|
||||
*/
|
||||
protected function matchesValue($value, $expected): bool
|
||||
{
|
||||
if (is_array($expected)) {
|
||||
// Handle operators
|
||||
if (isset($expected['$eq'])) {
|
||||
return $value === $expected['$eq'];
|
||||
}
|
||||
if (isset($expected['$ne'])) {
|
||||
return $value !== $expected['$ne'];
|
||||
}
|
||||
if (isset($expected['$gt'])) {
|
||||
return $value > $expected['$gt'];
|
||||
}
|
||||
if (isset($expected['$gte'])) {
|
||||
return $value >= $expected['$gte'];
|
||||
}
|
||||
if (isset($expected['$lt'])) {
|
||||
return $value < $expected['$lt'];
|
||||
}
|
||||
if (isset($expected['$lte'])) {
|
||||
return $value <= $expected['$lte'];
|
||||
}
|
||||
if (isset($expected['$in'])) {
|
||||
return in_array($value, $expected['$in']);
|
||||
}
|
||||
if (isset($expected['$nin'])) {
|
||||
return !in_array($value, $expected['$nin']);
|
||||
}
|
||||
if (isset($expected['$all'])) {
|
||||
return is_array($value) && count(array_intersect($value, $expected['$all'])) === count($expected['$all']);
|
||||
}
|
||||
if (isset($expected['$exists'])) {
|
||||
$hasValue = $value !== null;
|
||||
return $expected['$exists'] ? $hasValue : !$hasValue;
|
||||
}
|
||||
if (isset($expected['$regex'])) {
|
||||
return preg_match($expected['$regex'], (string) $value) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $value === $expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value from array.
|
||||
*/
|
||||
protected function getNestedValue(array $array, string $key, $default = null)
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$value = $array;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !array_key_exists($k, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set nested value in array.
|
||||
*/
|
||||
protected function setNestedValue(array &$array, string $key, $value): void
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$current = &$array;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($current[$k]) || !is_array($current[$k])) {
|
||||
$current[$k] = [];
|
||||
}
|
||||
$current = &$current[$k];
|
||||
}
|
||||
|
||||
$current = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unset nested value in array.
|
||||
*/
|
||||
protected function unsetNestedValue(array &$array, string $key): void
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$lastKey = array_pop($keys);
|
||||
$current = &$array;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($current[$k]) || !is_array($current[$k])) {
|
||||
return;
|
||||
}
|
||||
$current = &$current[$k];
|
||||
}
|
||||
|
||||
unset($current[$lastKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top items from array.
|
||||
*/
|
||||
protected function getTopItems(array $items, int $limit): array
|
||||
{
|
||||
arsort($items);
|
||||
return array_slice($items, 0, $limit, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate connection ID.
|
||||
*/
|
||||
protected function generateConnectionId(string $source, string $target): string
|
||||
{
|
||||
return $source . '->' . $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize metadata manager.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Load existing metadata from storage
|
||||
$this->loadMetadata();
|
||||
|
||||
// Load connections from storage
|
||||
$this->loadConnections();
|
||||
|
||||
// Register default schemas
|
||||
$this->registerDefaultSchemas();
|
||||
|
||||
$this->logInfo("Metadata manager initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load metadata from storage.
|
||||
*/
|
||||
protected function loadMetadata(): void
|
||||
{
|
||||
$metadata = $this->storage->loadAll();
|
||||
$this->metadata = $metadata;
|
||||
|
||||
$this->logInfo("Loaded " . count($metadata) . " metadata entries from storage");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load connections from storage.
|
||||
*/
|
||||
protected function loadConnections(): void
|
||||
{
|
||||
$connections = $this->storage->loadConnections();
|
||||
$this->connections = $connections;
|
||||
|
||||
$this->logInfo("Loaded " . count($connections) . " service connections from storage");
|
||||
}
|
||||
|
||||
/**
|
||||
* Register default schemas.
|
||||
*/
|
||||
protected function registerDefaultSchemas(): void
|
||||
{
|
||||
// Basic service schema
|
||||
$this->registerSchema('basic', [
|
||||
'type' => 'object',
|
||||
'required' => ['name', 'version'],
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
'version' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'environment' => ['type' => 'string'],
|
||||
'owner' => ['type' => 'string'],
|
||||
'contact' => ['type' => 'string']
|
||||
]
|
||||
]);
|
||||
|
||||
// Extended service schema
|
||||
$this->registerSchema('extended', [
|
||||
'type' => 'object',
|
||||
'required' => ['name', 'version', 'environment'],
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
'version' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'environment' => ['type' => 'string', 'enum' => ['development', 'staging', 'production']],
|
||||
'owner' => ['type' => 'string'],
|
||||
'contact' => ['type' => 'string'],
|
||||
'repository' => ['type' => 'string'],
|
||||
'documentation' => ['type' => 'string'],
|
||||
'dependencies' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'metrics' => ['type' => 'object'],
|
||||
'health_check' => ['type' => 'object']
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[MetadataManager] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message.
|
||||
*/
|
||||
protected function logError(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[MetadataManager] ERROR: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'logging_enabled' => true,
|
||||
'auto_backup' => true,
|
||||
'backup_interval' => 86400, // 24 hours
|
||||
'max_backups' => 7,
|
||||
'storage' => [
|
||||
'type' => 'file',
|
||||
'path' => __DIR__ . '/../../../storage/metadata'
|
||||
],
|
||||
'validation' => [
|
||||
'strict' => true,
|
||||
'required_fields' => ['name', 'version'],
|
||||
'max_field_count' => 100,
|
||||
'max_field_length' => 1000
|
||||
],
|
||||
'version' => '1.0'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create metadata manager instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'logging_enabled' => true,
|
||||
'auto_backup' => false,
|
||||
'validation' => [
|
||||
'strict' => false
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'logging_enabled' => false,
|
||||
'auto_backup' => true,
|
||||
'backup_interval' => 86400,
|
||||
'validation' => [
|
||||
'strict' => true
|
||||
],
|
||||
'storage' => [
|
||||
'type' => 'redis',
|
||||
'host' => 'localhost',
|
||||
'port' => 6379
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
1204
fendx-framework/fendx-service/src/Performance/ConcurrencyTester.php
Normal file
1204
fendx-framework/fendx-service/src/Performance/ConcurrencyTester.php
Normal file
File diff suppressed because it is too large
Load Diff
1305
fendx-framework/fendx-service/src/Performance/DatabaseOptimizer.php
Normal file
1305
fendx-framework/fendx-service/src/Performance/DatabaseOptimizer.php
Normal file
File diff suppressed because it is too large
Load Diff
1279
fendx-framework/fendx-service/src/Performance/MemoryOptimizer.php
Normal file
1279
fendx-framework/fendx-service/src/Performance/MemoryOptimizer.php
Normal file
File diff suppressed because it is too large
Load Diff
1140
fendx-framework/fendx-service/src/Performance/ResponseTimeTester.php
Normal file
1140
fendx-framework/fendx-service/src/Performance/ResponseTimeTester.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,624 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Quality\Code;
|
||||
|
||||
use Fendx\Service\Quality\Code\Standard\PSR12Standard;
|
||||
use Fendx\Service\Quality\Code\Standard\CustomStandard;
|
||||
use Fendx\Service\Quality\Code\Formatter\CodeFormatter;
|
||||
use Fendx\Service\Quality\Code\Reporter\StyleReporter;
|
||||
|
||||
class CodeStyleChecker
|
||||
{
|
||||
protected array $config = [];
|
||||
protected array $standards = [];
|
||||
protected CodeFormatter $formatter;
|
||||
protected StyleReporter $reporter;
|
||||
protected array $checkResults = [];
|
||||
protected array $fileCache = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->formatter = new CodeFormatter($this->config['formatter'] ?? []);
|
||||
$this->reporter = new StyleReporter($this->config['reporter'] ?? []);
|
||||
|
||||
$this->initializeStandards();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check code style for a file.
|
||||
*/
|
||||
public function checkFile(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
throw new \InvalidArgumentException("File not found: {$filePath}");
|
||||
}
|
||||
|
||||
// Check cache
|
||||
$cacheKey = $this->getCacheKey($filePath);
|
||||
if (isset($this->fileCache[$cacheKey])) {
|
||||
return $this->fileCache[$cacheKey];
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException("Failed to read file: {$filePath}");
|
||||
}
|
||||
|
||||
$result = [
|
||||
'file' => $filePath,
|
||||
'size' => strlen($content),
|
||||
'lines' => substr_count($content, "\n") + 1,
|
||||
'checks' => [],
|
||||
'errors' => 0,
|
||||
'warnings' => 0,
|
||||
'info' => 0,
|
||||
'score' => 100,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Apply all standards
|
||||
foreach ($this->standards as $name => $standard) {
|
||||
$checkResult = $standard->check($content, $filePath);
|
||||
|
||||
$result['checks'][$name] = $checkResult;
|
||||
$result['errors'] += $checkResult['errors'];
|
||||
$result['warnings'] += $checkResult['warnings'];
|
||||
$result['info'] += $checkResult['info'];
|
||||
|
||||
// Add detailed issues
|
||||
if (!empty($checkResult['issues'])) {
|
||||
foreach ($checkResult['issues'] as $issue) {
|
||||
$issue['standard'] = $name;
|
||||
$result['issues'][] = $issue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
$totalIssues = $result['errors'] + $result['warnings'] + $result['info'];
|
||||
if ($totalIssues > 0) {
|
||||
$errorWeight = $this->config['scoring']['error_weight'] ?? 10;
|
||||
$warningWeight = $this->config['scoring']['warning_weight'] ?? 3;
|
||||
$infoWeight = $this->config['scoring']['info_weight'] ?? 1;
|
||||
|
||||
$weightedScore = ($result['errors'] * $errorWeight) +
|
||||
($result['warnings'] * $warningWeight) +
|
||||
($result['info'] * $infoWeight);
|
||||
|
||||
$result['score'] = max(0, 100 - $weightedScore);
|
||||
}
|
||||
|
||||
// Cache result
|
||||
$this->fileCache[$cacheKey] = $result;
|
||||
$this->limitCacheSize();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check code style for directory.
|
||||
*/
|
||||
public function checkDirectory(string $directory, array $options = []): array
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
throw new \InvalidArgumentException("Directory not found: {$directory}");
|
||||
}
|
||||
|
||||
$results = [
|
||||
'directory' => $directory,
|
||||
'files_checked' => 0,
|
||||
'total_files' => 0,
|
||||
'total_errors' => 0,
|
||||
'total_warnings' => 0,
|
||||
'total_info' => 0,
|
||||
'average_score' => 0,
|
||||
'files' => [],
|
||||
'summary' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Find PHP files
|
||||
$files = $this->findPhpFiles($directory, $options);
|
||||
$results['total_files'] = count($files);
|
||||
|
||||
if (empty($files)) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
$totalScore = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$fileResult = $this->checkFile($file);
|
||||
|
||||
$results['files'][$file] = $fileResult;
|
||||
$results['files_checked']++;
|
||||
$results['total_errors'] += $fileResult['errors'];
|
||||
$results['total_warnings'] += $fileResult['warnings'];
|
||||
$results['total_info'] += $fileResult['info'];
|
||||
$totalScore += $fileResult['score'];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$results['files'][$file] = [
|
||||
'file' => $file,
|
||||
'error' => $e->getMessage(),
|
||||
'score' => 0
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($results['files_checked'] > 0) {
|
||||
$results['average_score'] = $totalScore / $results['files_checked'];
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
$results['summary'] = $this->generateDirectorySummary($results);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix code style issues.
|
||||
*/
|
||||
public function fixFile(string $filePath, array $options = []): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
throw new \InvalidArgumentException("File not found: {$filePath}");
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException("Failed to read file: {$filePath}");
|
||||
}
|
||||
|
||||
$originalContent = $content;
|
||||
$fixes = [];
|
||||
|
||||
// Apply fixes from all standards
|
||||
foreach ($this->standards as $name => $standard) {
|
||||
$fixResult = $standard->fix($content, $filePath, $options);
|
||||
|
||||
if ($fixResult['fixed']) {
|
||||
$content = $fixResult['content'];
|
||||
$fixes[$name] = $fixResult['fixes'];
|
||||
}
|
||||
}
|
||||
|
||||
$result = [
|
||||
'file' => $filePath,
|
||||
'fixed' => $content !== $originalContent,
|
||||
'original_size' => strlen($originalContent),
|
||||
'fixed_size' => strlen($content),
|
||||
'fixes' => $fixes,
|
||||
'total_fixes' => array_sum(array_map('count', $fixes))
|
||||
];
|
||||
|
||||
if ($result['fixed'] && ($options['save'] ?? false)) {
|
||||
if (file_put_contents($filePath, $content) === false) {
|
||||
throw new \RuntimeException("Failed to write file: {$filePath}");
|
||||
}
|
||||
$result['saved'] = true;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix code style issues in directory.
|
||||
*/
|
||||
public function fixDirectory(string $directory, array $options = []): array
|
||||
{
|
||||
$results = [
|
||||
'directory' => $directory,
|
||||
'files_processed' => 0,
|
||||
'files_fixed' => 0,
|
||||
'total_fixes' => 0,
|
||||
'files' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$files = $this->findPhpFiles($directory, $options);
|
||||
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$fileResult = $this->fixFile($file, $options);
|
||||
|
||||
$results['files'][$file] = $fileResult;
|
||||
$results['files_processed']++;
|
||||
|
||||
if ($fileResult['fixed']) {
|
||||
$results['files_fixed']++;
|
||||
$results['total_fixes'] += $fileResult['total_fixes'];
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$results['files'][$file] = [
|
||||
'file' => $file,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get code style report.
|
||||
*/
|
||||
public function getReport(array $results, string $format = 'text'): string
|
||||
{
|
||||
return $this->reporter->generate($results, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom standard.
|
||||
*/
|
||||
public function addStandard(string $name, object $standard): void
|
||||
{
|
||||
$this->standards[$name] = $standard;
|
||||
|
||||
$this->logInfo("Added code style standard: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove standard.
|
||||
*/
|
||||
public function removeStandard(string $name): bool
|
||||
{
|
||||
if (!isset($this->standards[$name])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($this->standards[$name]);
|
||||
|
||||
$this->logInfo("Removed code style standard: {$name}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available standards.
|
||||
*/
|
||||
public function getStandards(): array
|
||||
{
|
||||
return array_keys($this->standards);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable standard.
|
||||
*/
|
||||
public function setStandardEnabled(string $name, bool $enabled): void
|
||||
{
|
||||
if (isset($this->standards[$name])) {
|
||||
$this->standards[$name]->setEnabled($enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get check statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'standards_count' => count($this->standards),
|
||||
'cache_size' => count($this->fileCache),
|
||||
'enabled_standards' => 0,
|
||||
'total_checks_run' => 0,
|
||||
'average_check_time' => 0
|
||||
];
|
||||
|
||||
foreach ($this->standards as $name => $standard) {
|
||||
if ($standard->isEnabled()) {
|
||||
$stats['enabled_standards']++;
|
||||
}
|
||||
$stats['total_checks_run'] += $standard->getCheckCount();
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache.
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->fileCache = [];
|
||||
|
||||
$this->logInfo("Code style checker cache cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration.
|
||||
*/
|
||||
public function validateConfig(): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Check required configuration
|
||||
$required = ['standards', 'scoring'];
|
||||
foreach ($required as $key) {
|
||||
if (!isset($this->config[$key])) {
|
||||
$issues[] = "Missing required configuration: {$key}";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate standards
|
||||
foreach ($this->config['standards'] as $name => $config) {
|
||||
if (!isset($config['enabled']) || !is_bool($config['enabled'])) {
|
||||
$issues[] = "Invalid enabled setting for standard: {$name}";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scoring
|
||||
if (isset($this->config['scoring'])) {
|
||||
$scoring = $this->config['scoring'];
|
||||
foreach (['error_weight', 'warning_weight', 'info_weight'] as $key) {
|
||||
if (isset($scoring[$key]) && (!is_numeric($scoring[$key]) || $scoring[$key] < 0)) {
|
||||
$issues[] = "Invalid scoring weight: {$key}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find PHP files in directory.
|
||||
*/
|
||||
protected function findPhpFiles(string $directory, array $options = []): array
|
||||
{
|
||||
$files = [];
|
||||
$extensions = $options['extensions'] ?? ['php'];
|
||||
$exclude = $options['exclude'] ?? ['vendor', 'node_modules', '.git'];
|
||||
$recursive = $options['recursive'] ?? true;
|
||||
|
||||
$iterator = $recursive ?
|
||||
new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) :
|
||||
new \DirectoryIterator($directory);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = $file->getFilename();
|
||||
$filePath = $file->getPathname();
|
||||
|
||||
// Check extension
|
||||
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, $extensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check exclude patterns
|
||||
$excluded = false;
|
||||
foreach ($exclude as $pattern) {
|
||||
if (strpos($filePath, $pattern) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$excluded) {
|
||||
$files[] = $filePath;
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate directory summary.
|
||||
*/
|
||||
protected function generateDirectorySummary(array $results): array
|
||||
{
|
||||
$summary = [
|
||||
'grade' => 'A',
|
||||
'status' => 'excellent',
|
||||
'recommendations' => []
|
||||
];
|
||||
|
||||
$score = $results['average_score'];
|
||||
|
||||
// Determine grade
|
||||
if ($score >= 90) {
|
||||
$summary['grade'] = 'A';
|
||||
$summary['status'] = 'excellent';
|
||||
} elseif ($score >= 80) {
|
||||
$summary['grade'] = 'B';
|
||||
$summary['status'] = 'good';
|
||||
} elseif ($score >= 70) {
|
||||
$summary['grade'] = 'C';
|
||||
$summary['status'] = 'fair';
|
||||
} elseif ($score >= 60) {
|
||||
$summary['grade'] = 'D';
|
||||
$summary['status'] = 'poor';
|
||||
} else {
|
||||
$summary['grade'] = 'F';
|
||||
$summary['status'] = 'failing';
|
||||
}
|
||||
|
||||
// Generate recommendations
|
||||
if ($results['total_errors'] > 0) {
|
||||
$summary['recommendations'][] = "Fix {$results['total_errors']} error(s) immediately";
|
||||
}
|
||||
|
||||
if ($results['total_warnings'] > 10) {
|
||||
$summary['recommendations'][] = "Address {$results['total_warnings']} warning(s) to improve code quality";
|
||||
}
|
||||
|
||||
if ($results['average_score'] < 80) {
|
||||
$summary['recommendations'][] = "Consider running auto-fix to resolve common style issues";
|
||||
}
|
||||
|
||||
if ($results['files_checked'] < $results['total_files']) {
|
||||
$summary['recommendations'][] = "Some files could not be checked due to errors";
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key for file.
|
||||
*/
|
||||
protected function getCacheKey(string $filePath): string
|
||||
{
|
||||
$mtime = filemtime($filePath);
|
||||
return md5($filePath . $mtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit cache size.
|
||||
*/
|
||||
protected function limitCacheSize(): void
|
||||
{
|
||||
$maxSize = $this->config['cache_size'] ?? 1000;
|
||||
|
||||
if (count($this->fileCache) > $maxSize) {
|
||||
$this->fileCache = array_slice($this->fileCache, -$maxSize, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize standards.
|
||||
*/
|
||||
protected function initializeStandards(): void
|
||||
{
|
||||
// Add PSR-12 standard
|
||||
$this->standards['psr12'] = new PSR12Standard($this->config['standards']['psr12'] ?? []);
|
||||
|
||||
// Add custom standard
|
||||
$this->standards['custom'] = new CustomStandard($this->config['standards']['custom'] ?? []);
|
||||
|
||||
$this->logInfo("Initialized " . count($this->standards) . " code style standards");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[CodeStyleChecker] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'standards' => [
|
||||
'psr12' => [
|
||||
'enabled' => true,
|
||||
'strict' => false
|
||||
],
|
||||
'custom' => [
|
||||
'enabled' => true,
|
||||
'rules' => []
|
||||
]
|
||||
],
|
||||
'scoring' => [
|
||||
'error_weight' => 10,
|
||||
'warning_weight' => 3,
|
||||
'info_weight' => 1
|
||||
],
|
||||
'formatter' => [
|
||||
'indent_size' => 4,
|
||||
'indent_style' => 'space',
|
||||
'line_ending' => "\n"
|
||||
],
|
||||
'reporter' => [
|
||||
'format' => 'text',
|
||||
'include_details' => true
|
||||
],
|
||||
'cache_size' => 1000,
|
||||
'logging_enabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create code style checker instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'standards' => [
|
||||
'psr12' => [
|
||||
'enabled' => true,
|
||||
'strict' => false
|
||||
],
|
||||
'custom' => [
|
||||
'enabled' => true,
|
||||
'rules' => [
|
||||
'max_line_length' => 120,
|
||||
'require_docblocks' => false
|
||||
]
|
||||
]
|
||||
],
|
||||
'scoring' => [
|
||||
'error_weight' => 5,
|
||||
'warning_weight' => 2,
|
||||
'info_weight' => 1
|
||||
],
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'standards' => [
|
||||
'psr12' => [
|
||||
'enabled' => true,
|
||||
'strict' => true
|
||||
],
|
||||
'custom' => [
|
||||
'enabled' => true,
|
||||
'rules' => [
|
||||
'max_line_length' => 100,
|
||||
'require_docblocks' => true,
|
||||
'require_type_hints' => true
|
||||
]
|
||||
]
|
||||
],
|
||||
'scoring' => [
|
||||
'error_weight' => 10,
|
||||
'warning_weight' => 5,
|
||||
'info_weight' => 2
|
||||
],
|
||||
'logging_enabled' => false
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Quality\Code;
|
||||
|
||||
use Fendx\Service\Quality\Code\Complexity\CyclomaticComplexity;
|
||||
use Fendx\Service\Quality\Code\Complexity\CognitiveComplexity;
|
||||
use Fendx\Service\Quality\Code\Complexity\HalsteadComplexity;
|
||||
use Fendx\Service\Quality\Code\Parser\CodeParser;
|
||||
|
||||
class ComplexityDetector
|
||||
{
|
||||
protected array $config = [];
|
||||
protected CodeParser $parser;
|
||||
protected CyclomaticComplexity $cyclomaticAnalyzer;
|
||||
protected CognitiveComplexity $cognitiveAnalyzer;
|
||||
protected HalsteadComplexity $halsteadAnalyzer;
|
||||
protected array $complexityCache = [];
|
||||
protected array $thresholds = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->parser = new CodeParser($this->config['parser'] ?? []);
|
||||
$this->cyclomaticAnalyzer = new CyclomaticComplexity($this->config['cyclomatic'] ?? []);
|
||||
$this->cognitiveAnalyzer = new CognitiveComplexity($this->config['cognitive'] ?? []);
|
||||
$this->halsteadAnalyzer = new HalsteadComplexity($this->config['halstead'] ?? []);
|
||||
|
||||
$this->initializeThresholds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze complexity of a file.
|
||||
*/
|
||||
public function analyzeFile(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
throw new \InvalidArgumentException("File not found: {$filePath}");
|
||||
}
|
||||
|
||||
// Check cache
|
||||
$cacheKey = $this->getCacheKey($filePath);
|
||||
if (isset($this->complexityCache[$cacheKey])) {
|
||||
return $this->complexityCache[$cacheKey];
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException("Failed to read file: {$filePath}");
|
||||
}
|
||||
|
||||
// Parse code
|
||||
$ast = $this->parser->parse($content, $filePath);
|
||||
|
||||
$result = [
|
||||
'file' => $filePath,
|
||||
'lines' => substr_count($content, "\n") + 1,
|
||||
'complexity' => [
|
||||
'cyclomatic' => 0,
|
||||
'cognitive' => 0,
|
||||
'halstead' => [
|
||||
'vocabulary' => 0,
|
||||
'length' => 0,
|
||||
'volume' => 0,
|
||||
'difficulty' => 0,
|
||||
'effort' => 0,
|
||||
'time' => 0,
|
||||
'bugs' => 0
|
||||
]
|
||||
],
|
||||
'methods' => [],
|
||||
'classes' => [],
|
||||
'functions' => [],
|
||||
'overall_score' => 0,
|
||||
'risk_level' => 'low',
|
||||
'recommendations' => [],
|
||||
'threshold_violations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Analyze overall complexity
|
||||
$result['complexity']['cyclomatic'] = $this->cyclomaticAnalyzer->analyze($ast);
|
||||
$result['complexity']['cognitive'] = $this->cognitiveAnalyzer->analyze($ast);
|
||||
$result['complexity']['halstead'] = $this->halsteadAnalyzer->analyze($ast);
|
||||
|
||||
// Analyze individual methods
|
||||
$result['methods'] = $this->analyzeMethods($ast);
|
||||
|
||||
// Analyze classes
|
||||
$result['classes'] = $this->analyzeClasses($ast);
|
||||
|
||||
// Analyze functions
|
||||
$result['functions'] = $this->analyzeFunctions($ast);
|
||||
|
||||
// Calculate overall score
|
||||
$result['overall_score'] = $this->calculateOverallScore($result);
|
||||
|
||||
// Determine risk level
|
||||
$result['risk_level'] = $this->determineRiskLevel($result);
|
||||
|
||||
// Generate recommendations
|
||||
$result['recommendations'] = $this->generateRecommendations($result);
|
||||
|
||||
// Check threshold violations
|
||||
$result['threshold_violations'] = $this->checkThresholdViolations($result);
|
||||
|
||||
// Cache result
|
||||
$this->complexityCache[$cacheKey] = $result;
|
||||
$this->limitCacheSize();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze complexity of directory.
|
||||
*/
|
||||
public function analyzeDirectory(string $directory, array $options = []): array
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
throw new \InvalidArgumentException("Directory not found: {$directory}");
|
||||
}
|
||||
|
||||
$results = [
|
||||
'directory' => $directory,
|
||||
'files_analyzed' => 0,
|
||||
'total_files' => 0,
|
||||
'complexity_summary' => [
|
||||
'average_cyclomatic' => 0,
|
||||
'average_cognitive' => 0,
|
||||
'max_cyclomatic' => 0,
|
||||
'max_cognitive' => 0,
|
||||
'total_methods' => 0,
|
||||
'complex_methods' => 0,
|
||||
'very_complex_methods' => 0
|
||||
],
|
||||
'risk_distribution' => [
|
||||
'low' => 0,
|
||||
'medium' => 0,
|
||||
'high' => 0,
|
||||
'very_high' => 0
|
||||
],
|
||||
'files' => [],
|
||||
'hotspots' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Find PHP files
|
||||
$files = $this->findPhpFiles($directory, $options);
|
||||
$results['total_files'] = count($files);
|
||||
|
||||
if (empty($files)) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
$totalCyclomatic = 0;
|
||||
$totalCognitive = 0;
|
||||
$maxCyclomatic = 0;
|
||||
$maxCognitive = 0;
|
||||
$totalMethods = 0;
|
||||
$complexMethods = 0;
|
||||
$veryComplexMethods = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$fileResult = $this->analyzeFile($file);
|
||||
|
||||
$results['files'][$file] = $fileResult;
|
||||
$results['files_analyzed']++;
|
||||
|
||||
// Update summary
|
||||
$totalCyclomatic += $fileResult['complexity']['cyclomatic'];
|
||||
$totalCognitive += $fileResult['complexity']['cognitive'];
|
||||
$maxCyclomatic = max($maxCyclomatic, $fileResult['complexity']['cyclomatic']);
|
||||
$maxCognitive = max($maxCognitive, $fileResult['complexity']['cognitive']);
|
||||
|
||||
// Count methods
|
||||
$totalMethods += count($fileResult['methods']);
|
||||
foreach ($fileResult['methods'] as $method) {
|
||||
if ($method['complexity']['cyclomatic'] > 10) {
|
||||
$complexMethods++;
|
||||
}
|
||||
if ($method['complexity']['cyclomatic'] > 20) {
|
||||
$veryComplexMethods++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update risk distribution
|
||||
$riskLevel = $fileResult['risk_level'];
|
||||
$results['risk_distribution'][$riskLevel]++;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$results['files'][$file] = [
|
||||
'file' => $file,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
if ($results['files_analyzed'] > 0) {
|
||||
$results['complexity_summary']['average_cyclomatic'] = $totalCyclomatic / $results['files_analyzed'];
|
||||
$results['complexity_summary']['average_cognitive'] = $totalCognitive / $results['files_analyzed'];
|
||||
}
|
||||
|
||||
$results['complexity_summary']['max_cyclomatic'] = $maxCyclomatic;
|
||||
$results['complexity_summary']['max_cognitive'] = $maxCognitive;
|
||||
$results['complexity_summary']['total_methods'] = $totalMethods;
|
||||
$results['complexity_summary']['complex_methods'] = $complexMethods;
|
||||
$results['complexity_summary']['very_complex_methods'] = $veryComplexMethods;
|
||||
|
||||
// Identify hotspots
|
||||
$results['hotspots'] = $this->identifyHotspots($results['files']);
|
||||
|
||||
// Generate recommendations
|
||||
$results['recommendations'] = $this->generateDirectoryRecommendations($results);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze specific method complexity.
|
||||
*/
|
||||
public function analyzeMethod(string $filePath, string $methodName): array
|
||||
{
|
||||
$fileResult = $this->analyzeFile($filePath);
|
||||
|
||||
// Find the method
|
||||
foreach ($fileResult['methods'] as $method) {
|
||||
if ($method['name'] === $methodName) {
|
||||
return $method;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Method not found: {$methodName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare complexity between files.
|
||||
*/
|
||||
public function compareComplexity(array $filePaths): array
|
||||
{
|
||||
$comparison = [
|
||||
'files' => [],
|
||||
'rankings' => [
|
||||
'most_complex' => [],
|
||||
'least_complex' => [],
|
||||
'largest' => [],
|
||||
'smallest' => []
|
||||
],
|
||||
'statistics' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$fileData = [];
|
||||
|
||||
foreach ($filePaths as $filePath) {
|
||||
try {
|
||||
$result = $this->analyzeFile($filePath);
|
||||
$fileData[$filePath] = $result;
|
||||
$comparison['files'][$filePath] = [
|
||||
'cyclomatic' => $result['complexity']['cyclomatic'],
|
||||
'cognitive' => $result['complexity']['cognitive'],
|
||||
'lines' => $result['lines'],
|
||||
'methods' => count($result['methods']),
|
||||
'overall_score' => $result['overall_score']
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$comparison['files'][$filePath] = [
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate rankings
|
||||
$comparison['rankings'] = $this->generateRankings($fileData);
|
||||
|
||||
// Calculate statistics
|
||||
$comparison['statistics'] = $this->calculateComparisonStatistics($fileData);
|
||||
|
||||
return $comparison;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track complexity trends over time.
|
||||
*/
|
||||
public function trackTrends(string $directory, array $historicalData = []): array
|
||||
{
|
||||
$currentData = $this->analyzeDirectory($directory);
|
||||
|
||||
$trends = [
|
||||
'current' => $currentData,
|
||||
'historical' => $historicalData,
|
||||
'trends' => [],
|
||||
'projections' => [],
|
||||
'insights' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
if (!empty($historicalData)) {
|
||||
$trends['trends'] = $this->calculateTrends($historicalData, $currentData);
|
||||
$trends['projections'] = $this->generateProjections($historicalData, $currentData);
|
||||
$trends['insights'] = $this->generateTrendInsights($trends['trends']);
|
||||
}
|
||||
|
||||
return $trends;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complexity report.
|
||||
*/
|
||||
public function getReport(array $results, string $format = 'html'): string
|
||||
{
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
return json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
case 'text':
|
||||
return $this->generateTextReport($results);
|
||||
case 'html':
|
||||
return $this->generateHtmlReport($results);
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported report format: {$format}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom thresholds.
|
||||
*/
|
||||
public function setThresholds(array $thresholds): void
|
||||
{
|
||||
$this->thresholds = array_merge($this->thresholds, $thresholds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current thresholds.
|
||||
*/
|
||||
public function getThresholds(): array
|
||||
{
|
||||
return $this->thresholds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complexity statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'files_analyzed' => count($this->complexityCache),
|
||||
'cache_size' => count($this->complexityCache),
|
||||
'threshold_violations' => $this->countThresholdViolations(),
|
||||
'average_complexity' => $this->calculateAverageComplexity()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache.
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->complexityCache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze methods in AST.
|
||||
*/
|
||||
protected function analyzeMethods($ast): array
|
||||
{
|
||||
$methods = [];
|
||||
|
||||
// This would extract methods from AST and analyze each
|
||||
// For now, return empty array
|
||||
|
||||
return $methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze classes in AST.
|
||||
*/
|
||||
protected function analyzeClasses($ast): array
|
||||
{
|
||||
$classes = [];
|
||||
|
||||
// This would extract classes from AST and analyze each
|
||||
// For now, return empty array
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze functions in AST.
|
||||
*/
|
||||
protected function analyzeFunctions($ast): array
|
||||
{
|
||||
$functions = [];
|
||||
|
||||
// This would extract functions from AST and analyze each
|
||||
// For now, return empty array
|
||||
|
||||
return $functions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall complexity score.
|
||||
*/
|
||||
protected function calculateOverallScore(array $result): int
|
||||
{
|
||||
$score = 100;
|
||||
|
||||
// Deduct points based on cyclomatic complexity
|
||||
$cyclomatic = $result['complexity']['cyclomatic'];
|
||||
if ($cyclomatic > 10) {
|
||||
$score -= min(30, ($cyclomatic - 10) * 2);
|
||||
}
|
||||
|
||||
// Deduct points based on cognitive complexity
|
||||
$cognitive = $result['complexity']['cognitive'];
|
||||
if ($cognitive > 15) {
|
||||
$score -= min(25, ($cognitive - 15) * 1.5);
|
||||
}
|
||||
|
||||
// Deduct points for complex methods
|
||||
$complexMethods = 0;
|
||||
foreach ($result['methods'] as $method) {
|
||||
if ($method['complexity']['cyclomatic'] > 15) {
|
||||
$complexMethods++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($complexMethods > 0) {
|
||||
$score -= min(20, $complexMethods * 5);
|
||||
}
|
||||
|
||||
// Bonus for low complexity
|
||||
if ($cyclomatic <= 5 && $cognitive <= 8) {
|
||||
$score += 10;
|
||||
}
|
||||
|
||||
return max(0, min(100, $score));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine risk level.
|
||||
*/
|
||||
protected function determineRiskLevel(array $result): string
|
||||
{
|
||||
$cyclomatic = $result['complexity']['cyclomatic'];
|
||||
$cognitive = $result['complexity']['cognitive'];
|
||||
|
||||
if ($cyclomatic > 20 || $cognitive > 30) {
|
||||
return 'very_high';
|
||||
} elseif ($cyclomatic > 15 || $cognitive > 20) {
|
||||
return 'high';
|
||||
} elseif ($cyclomatic > 10 || $cognitive > 15) {
|
||||
return 'medium';
|
||||
} else {
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recommendations.
|
||||
*/
|
||||
protected function generateRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
$cyclomatic = $result['complexity']['cyclomatic'];
|
||||
$cognitive = $result['complexity']['cognitive'];
|
||||
|
||||
if ($cyclomatic > 20) {
|
||||
$recommendations[] = "Very high cyclomatic complexity ({$cyclomatic}). Consider breaking down into smaller methods.";
|
||||
} elseif ($cyclomatic > 15) {
|
||||
$recommendations[] = "High cyclomatic complexity ({$cyclomatic}). Look for opportunities to simplify logic.";
|
||||
}
|
||||
|
||||
if ($cognitive > 30) {
|
||||
$recommendations[] = "Very high cognitive complexity ({$cognitive}). Reduce nesting and simplify control flow.";
|
||||
} elseif ($cognitive > 20) {
|
||||
$recommendations[] = "High cognitive complexity ({$cognitive}). Consider early returns and guard clauses.";
|
||||
}
|
||||
|
||||
// Check for complex methods
|
||||
$complexMethods = array_filter($result['methods'], function($method) {
|
||||
return $method['complexity']['cyclomatic'] > 10;
|
||||
});
|
||||
|
||||
if (!empty($complexMethods)) {
|
||||
$recommendations[] = count($complexMethods) . " method(s) have high complexity. Consider refactoring.";
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check threshold violations.
|
||||
*/
|
||||
protected function checkThresholdViolations(array $result): array
|
||||
{
|
||||
$violations = [];
|
||||
|
||||
foreach ($this->thresholds as $metric => $threshold) {
|
||||
$value = $this->getMetricValue($result, $metric);
|
||||
|
||||
if ($value > $threshold) {
|
||||
$violations[] = [
|
||||
'metric' => $metric,
|
||||
'value' => $value,
|
||||
'threshold' => $threshold,
|
||||
'severity' => $this->getViolationSeverity($value, $threshold)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find PHP files.
|
||||
*/
|
||||
protected function findPhpFiles(string $directory, array $options = []): array
|
||||
{
|
||||
$files = [];
|
||||
$extensions = $options['extensions'] ?? ['php'];
|
||||
$exclude = $options['exclude'] ?? ['vendor', 'node_modules', '.git'];
|
||||
$recursive = $options['recursive'] ?? true;
|
||||
|
||||
$iterator = $recursive ?
|
||||
new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) :
|
||||
new \DirectoryIterator($directory);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = $file->getFilename();
|
||||
$filePath = $file->getPathname();
|
||||
|
||||
// Check extension
|
||||
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, $extensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check exclude patterns
|
||||
$excluded = false;
|
||||
foreach ($exclude as $pattern) {
|
||||
if (strpos($filePath, $pattern) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$excluded) {
|
||||
$files[] = $filePath;
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify complexity hotspots.
|
||||
*/
|
||||
protected function identifyHotspots(array $files): array
|
||||
{
|
||||
$hotspots = [
|
||||
'most_complex_files' => [],
|
||||
'most_complex_methods' => [],
|
||||
'large_files' => []
|
||||
];
|
||||
|
||||
$fileComplexity = [];
|
||||
$methodComplexity = [];
|
||||
$fileSizes = [];
|
||||
|
||||
foreach ($files as $filePath => $fileData) {
|
||||
if (isset($fileData['error'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileComplexity[$filePath] = $fileData['complexity']['cyclomatic'];
|
||||
$fileSizes[$filePath] = $fileData['lines'];
|
||||
|
||||
foreach ($fileData['methods'] as $method) {
|
||||
$methodComplexity[] = [
|
||||
'file' => $filePath,
|
||||
'method' => $method['name'],
|
||||
'complexity' => $method['complexity']['cyclomatic']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort and get top files
|
||||
arsort($fileComplexity);
|
||||
$hotspots['most_complex_files'] = array_slice($fileComplexity, 0, 10, true);
|
||||
|
||||
// Sort and get top methods
|
||||
usort($methodComplexity, function($a, $b) {
|
||||
return $b['complexity'] <=> $a['complexity'];
|
||||
});
|
||||
$hotspots['most_complex_methods'] = array_slice($methodComplexity, 0, 10);
|
||||
|
||||
// Sort and get largest files
|
||||
arsort($fileSizes);
|
||||
$hotspots['large_files'] = array_slice($fileSizes, 0, 10, true);
|
||||
|
||||
return $hotspots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate directory recommendations.
|
||||
*/
|
||||
protected function generateDirectoryRecommendations(array $results): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
$summary = $results['complexity_summary'];
|
||||
|
||||
if ($summary['average_cyclomatic'] > 15) {
|
||||
$recommendations[] = "Average cyclomatic complexity is high ({$summary['average_cyclomatic']}). Consider code refactoring.";
|
||||
}
|
||||
|
||||
if ($summary['average_cognitive'] > 20) {
|
||||
$recommendations[] = "Average cognitive complexity is high ({$summary['average_cognitive']}). Simplify control flow.";
|
||||
}
|
||||
|
||||
if ($summary['complex_methods'] > $summary['total_methods'] * 0.2) {
|
||||
$recommendations[] = "More than 20% of methods are complex. Focus on refactoring complex methods.";
|
||||
}
|
||||
|
||||
if ($summary['very_complex_methods'] > 0) {
|
||||
$recommendations[] = "{$summary['very_complex_methods']} method(s) are very complex. Immediate attention required.";
|
||||
}
|
||||
|
||||
$highRiskFiles = $results['risk_distribution']['high'] + $results['risk_distribution']['very_high'];
|
||||
if ($highRiskFiles > $results['files_analyzed'] * 0.3) {
|
||||
$recommendations[] = "More than 30% of files have high complexity risk.";
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate rankings for comparison.
|
||||
*/
|
||||
protected function generateRankings(array $fileData): array
|
||||
{
|
||||
$rankings = [];
|
||||
|
||||
// Most complex
|
||||
uasort($fileData, function($a, $b) {
|
||||
return ($b['cyclomatic'] ?? 0) <=> ($a['cyclomatic'] ?? 0);
|
||||
});
|
||||
$rankings['most_complex'] = array_slice($fileData, 0, 5, true);
|
||||
|
||||
// Least complex
|
||||
uasort($fileData, function($a, $b) {
|
||||
return ($a['cyclomatic'] ?? PHP_INT_MAX) <=> ($b['cyclomatic'] ?? PHP_INT_MAX);
|
||||
});
|
||||
$rankings['least_complex'] = array_slice($fileData, 0, 5, true);
|
||||
|
||||
// Largest
|
||||
uasort($fileData, function($a, $b) {
|
||||
return ($b['lines'] ?? 0) <=> ($a['lines'] ?? 0);
|
||||
});
|
||||
$rankings['largest'] = array_slice($fileData, 0, 5, true);
|
||||
|
||||
// Smallest
|
||||
uasort($fileData, function($a, $b) {
|
||||
return ($a['lines'] ?? PHP_INT_MAX) <=> ($b['lines'] ?? PHP_INT_MAX);
|
||||
});
|
||||
$rankings['smallest'] = array_slice($fileData, 0, 5, true);
|
||||
|
||||
return $rankings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate comparison statistics.
|
||||
*/
|
||||
protected function calculateComparisonStatistics(array $fileData): array
|
||||
{
|
||||
$stats = [
|
||||
'total_files' => count($fileData),
|
||||
'average_cyclomatic' => 0,
|
||||
'average_cognitive' => 0,
|
||||
'average_lines' => 0,
|
||||
'max_cyclomatic' => 0,
|
||||
'min_cyclomatic' => PHP_INT_MAX,
|
||||
'complexity_distribution' => [
|
||||
'low' => 0, 'medium' => 0, 'high' => 0, 'very_high' => 0
|
||||
]
|
||||
];
|
||||
|
||||
$totalCyclomatic = 0;
|
||||
$totalCognitive = 0;
|
||||
$totalLines = 0;
|
||||
$validFiles = 0;
|
||||
|
||||
foreach ($fileData as $data) {
|
||||
if (isset($data['error'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cyclomatic = $data['cyclomatic'] ?? 0;
|
||||
$cognitive = $data['cognitive'] ?? 0;
|
||||
$lines = $data['lines'] ?? 0;
|
||||
|
||||
$totalCyclomatic += $cyclomatic;
|
||||
$totalCognitive += $cognitive;
|
||||
$totalLines += $lines;
|
||||
$validFiles++;
|
||||
|
||||
$stats['max_cyclomatic'] = max($stats['max_cyclomatic'], $cyclomatic);
|
||||
$stats['min_cyclomatic'] = min($stats['min_cyclomatic'], $cyclomatic);
|
||||
|
||||
// Complexity distribution
|
||||
if ($cyclomatic <= 5) {
|
||||
$stats['complexity_distribution']['low']++;
|
||||
} elseif ($cyclomatic <= 10) {
|
||||
$stats['complexity_distribution']['medium']++;
|
||||
} elseif ($cyclomatic <= 20) {
|
||||
$stats['complexity_distribution']['high']++;
|
||||
} else {
|
||||
$stats['complexity_distribution']['very_high']++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($validFiles > 0) {
|
||||
$stats['average_cyclomatic'] = $totalCyclomatic / $validFiles;
|
||||
$stats['average_cognitive'] = $totalCognitive / $validFiles;
|
||||
$stats['average_lines'] = $totalLines / $validFiles;
|
||||
}
|
||||
|
||||
if ($stats['min_cyclomatic'] === PHP_INT_MAX) {
|
||||
$stats['min_cyclomatic'] = 0;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text report.
|
||||
*/
|
||||
protected function generateTextReport(array $results): string
|
||||
{
|
||||
$report = "Complexity Analysis Report\n";
|
||||
$report .= "========================\n\n";
|
||||
|
||||
if (isset($results['directory'])) {
|
||||
// Directory report
|
||||
$report .= "Directory: {$results['directory']}\n";
|
||||
$report .= "Files analyzed: {$results['files_analyzed']}/{$results['total_files']}\n\n";
|
||||
|
||||
$summary = $results['complexity_summary'];
|
||||
$report .= "Summary:\n";
|
||||
$report .= "- Average cyclomatic complexity: " . number_format($summary['average_cyclomatic'], 2) . "\n";
|
||||
$report .= "- Average cognitive complexity: " . number_format($summary['average_cognitive'], 2) . "\n";
|
||||
$report .= "- Maximum cyclomatic complexity: {$summary['max_cyclomatic']}\n";
|
||||
$report .= "- Maximum cognitive complexity: {$summary['max_cognitive']}\n";
|
||||
$report .= "- Total methods: {$summary['total_methods']}\n";
|
||||
$report .= "- Complex methods: {$summary['complex_methods']}\n\n";
|
||||
|
||||
} else {
|
||||
// File report
|
||||
$report .= "File: {$results['file']}\n";
|
||||
$report .= "Lines: {$results['lines']}\n";
|
||||
$report .= "Cyclomatic complexity: {$results['complexity']['cyclomatic']}\n";
|
||||
$report .= "Cognitive complexity: {$results['complexity']['cognitive']}\n";
|
||||
$report .= "Overall score: {$results['overall_score']}\n";
|
||||
$report .= "Risk level: {$results['risk_level']}\n\n";
|
||||
|
||||
if (!empty($results['recommendations'])) {
|
||||
$report .= "Recommendations:\n";
|
||||
foreach ($results['recommendations'] as $rec) {
|
||||
$report .= "- {$rec}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML report.
|
||||
*/
|
||||
protected function generateHtmlReport(array $results): string
|
||||
{
|
||||
$html = "<html><head><title>Complexity Analysis Report</title>";
|
||||
$html .= "<style>body{font-family:Arial,sans-serif;margin:20px;}";
|
||||
$html .= ".header{background:#f5f5f5;padding:10px;border-radius:5px;}";
|
||||
$html .= ".metric{margin:10px 0;}";
|
||||
$html .= ".high{color:#d32f2f;}.medium{color:#f57c00;}.low{color:#388e3c;}</style></head><body>";
|
||||
|
||||
$html .= "<div class='header'><h1>Complexity Analysis Report</h1>";
|
||||
|
||||
if (isset($results['directory'])) {
|
||||
$html .= "<p>Directory: <strong>{$results['directory']}</strong></p>";
|
||||
$html .= "<p>Files analyzed: <strong>{$results['files_analyzed']}/{$results['total_files']}</strong></p>";
|
||||
} else {
|
||||
$html .= "<p>File: <strong>{$results['file']}</strong></p>";
|
||||
$html .= "<p>Lines: <strong>{$results['lines']}</strong></p>";
|
||||
}
|
||||
|
||||
$html .= "</div>";
|
||||
|
||||
// Add metrics and recommendations
|
||||
// ... (detailed HTML generation)
|
||||
|
||||
$html .= "</body></html>";
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metric value from result.
|
||||
*/
|
||||
protected function getMetricValue(array $result, string $metric): float
|
||||
{
|
||||
switch ($metric) {
|
||||
case 'cyclomatic':
|
||||
return $result['complexity']['cyclomatic'];
|
||||
case 'cognitive':
|
||||
return $result['complexity']['cognitive'];
|
||||
case 'lines':
|
||||
return $result['lines'];
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get violation severity.
|
||||
*/
|
||||
protected function getViolationSeverity(float $value, float $threshold): string
|
||||
{
|
||||
$ratio = $value / $threshold;
|
||||
|
||||
if ($ratio >= 2) {
|
||||
return 'critical';
|
||||
} elseif ($ratio >= 1.5) {
|
||||
return 'high';
|
||||
} elseif ($ratio >= 1.2) {
|
||||
return 'medium';
|
||||
} else {
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key.
|
||||
*/
|
||||
protected function getCacheKey(string $filePath): string
|
||||
{
|
||||
$mtime = filemtime($filePath);
|
||||
return md5($filePath . $mtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit cache size.
|
||||
*/
|
||||
protected function limitCacheSize(): void
|
||||
{
|
||||
$maxSize = $this->config['cache_size'] ?? 1000;
|
||||
|
||||
if (count($this->complexityCache) > $maxSize) {
|
||||
$this->complexityCache = array_slice($this->complexityCache, -$maxSize, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize thresholds.
|
||||
*/
|
||||
protected function initializeThresholds(): void
|
||||
{
|
||||
$this->thresholds = [
|
||||
'cyclomatic' => 10,
|
||||
'cognitive' => 15,
|
||||
'lines' => 500,
|
||||
'method_cyclomatic' => 10,
|
||||
'method_cognitive' => 15
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count threshold violations.
|
||||
*/
|
||||
protected function countThresholdViolations(): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->complexityCache as $result) {
|
||||
$count += count($result['threshold_violations'] ?? []);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average complexity.
|
||||
*/
|
||||
protected function calculateAverageComplexity(): float
|
||||
{
|
||||
if (empty($this->complexityCache)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->complexityCache as $result) {
|
||||
$total += $result['complexity']['cyclomatic'];
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count > 0 ? $total / $count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'cyclomatic' => [
|
||||
'enabled' => true,
|
||||
'threshold' => 10
|
||||
],
|
||||
'cognitive' => [
|
||||
'enabled' => true,
|
||||
'threshold' => 15
|
||||
],
|
||||
'halstead' => [
|
||||
'enabled' => false
|
||||
],
|
||||
'parser' => [
|
||||
'tolerant' => true
|
||||
],
|
||||
'cache_size' => 1000
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create complexity detector instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'cyclomatic' => [
|
||||
'threshold' => 15 // More lenient for development
|
||||
],
|
||||
'cognitive' => [
|
||||
'threshold' => 20
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'cyclomatic' => [
|
||||
'threshold' => 8 // Stricter for production
|
||||
],
|
||||
'cognitive' => [
|
||||
'threshold' => 12
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Quality\Code;
|
||||
|
||||
use Fendx\Service\Quality\Code\Duplicate\Tokenizer;
|
||||
use Fendx\Service\Quality\Code\Duplicate\HashGenerator;
|
||||
use Fendx\Service\Quality\Code\Duplicate\SimilarityCalculator;
|
||||
use Fendx\Service\Quality\Code\Parser\CodeParser;
|
||||
|
||||
class DuplicateDetector
|
||||
{
|
||||
protected array $config = [];
|
||||
protected Tokenizer $tokenizer;
|
||||
protected HashGenerator $hashGenerator;
|
||||
protected SimilarityCalculator $similarityCalculator;
|
||||
protected CodeParser $parser;
|
||||
protected array $duplicateCache = [];
|
||||
protected array $hashIndex = [];
|
||||
protected array $fileHashes = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->tokenizer = new Tokenizer($this->config['tokenizer'] ?? []);
|
||||
$this->hashGenerator = new HashGenerator($this->config['hash'] ?? []);
|
||||
$this->similarityCalculator = new SimilarityCalculator($this->config['similarity'] ?? []);
|
||||
$this->parser = new CodeParser($this->config['parser'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find duplicates in a file.
|
||||
*/
|
||||
public function findDuplicatesInFile(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
throw new \InvalidArgumentException("File not found: {$filePath}");
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException("Failed to read file: {$filePath}");
|
||||
}
|
||||
|
||||
return $this->analyzeFile($filePath, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find duplicates across multiple files.
|
||||
*/
|
||||
public function findDuplicatesInFiles(array $filePaths): array
|
||||
{
|
||||
$results = [
|
||||
'files_analyzed' => 0,
|
||||
'total_files' => count($filePaths),
|
||||
'duplicates' => [],
|
||||
'duplicate_groups' => [],
|
||||
'statistics' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Process all files and build hash index
|
||||
foreach ($filePaths as $filePath) {
|
||||
try {
|
||||
$fileResult = $this->findDuplicatesInFile($filePath);
|
||||
$results['files_analyzed']++;
|
||||
|
||||
// Add to global results
|
||||
if (!empty($fileResult['duplicates'])) {
|
||||
$results['duplicates'] = array_merge($results['duplicates'], $fileResult['duplicates']);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$results['errors'][] = [
|
||||
'file' => $filePath,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Group duplicates by similarity
|
||||
$results['duplicate_groups'] = $this->groupDuplicates($results['duplicates']);
|
||||
|
||||
// Calculate statistics
|
||||
$results['statistics'] = $this->calculateStatistics($results);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find duplicates in directory.
|
||||
*/
|
||||
public function findDuplicatesInDirectory(string $directory, array $options = []): array
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
throw new \InvalidArgumentException("Directory not found: {$directory}");
|
||||
}
|
||||
|
||||
$filePaths = $this->findPhpFiles($directory, $options);
|
||||
|
||||
return $this->findDuplicatesInFiles($filePaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect similar code blocks.
|
||||
*/
|
||||
public function detectSimilarBlocks(string $filePath, float $threshold = 0.8): array
|
||||
{
|
||||
$fileResult = $this->findDuplicatesInFile($filePath);
|
||||
|
||||
$similarBlocks = [];
|
||||
|
||||
foreach ($fileResult['blocks'] as $block) {
|
||||
if ($block['similarity'] >= $threshold) {
|
||||
$similarBlocks[] = $block;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'file' => $filePath,
|
||||
'threshold' => $threshold,
|
||||
'similar_blocks' => $similarBlocks,
|
||||
'total_blocks' => count($similarBlocks),
|
||||
'average_similarity' => $this->calculateAverageSimilarity($similarBlocks)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two files for duplicates.
|
||||
*/
|
||||
public function compareFiles(string $file1, string $file2): array
|
||||
{
|
||||
$result1 = $this->findDuplicatesInFile($file1);
|
||||
$result2 = $this->findDuplicatesInFile($file2);
|
||||
|
||||
$comparison = [
|
||||
'file1' => $file1,
|
||||
'file2' => $file2,
|
||||
'similar_blocks' => [],
|
||||
'overall_similarity' => 0,
|
||||
'duplicate_percentage' => 0,
|
||||
'recommendations' => []
|
||||
];
|
||||
|
||||
// Find similar blocks between files
|
||||
foreach ($result1['blocks'] as $block1) {
|
||||
foreach ($result2['blocks'] as $block2) {
|
||||
$similarity = $this->similarityCalculator->calculate(
|
||||
$block1['tokens'],
|
||||
$block2['tokens']
|
||||
);
|
||||
|
||||
if ($similarity >= ($this->config['similarity_threshold'] ?? 0.7)) {
|
||||
$comparison['similar_blocks'][] = [
|
||||
'block1' => $block1,
|
||||
'block2' => $block2,
|
||||
'similarity' => $similarity,
|
||||
'type' => $this->determineDuplicationType($block1, $block2)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall metrics
|
||||
$comparison['overall_similarity'] = $this->calculateFileSimilarity($result1, $result2);
|
||||
$comparison['duplicate_percentage'] = $this->calculateDuplicatePercentage($comparison);
|
||||
|
||||
// Generate recommendations
|
||||
$comparison['recommendations'] = $this->generateComparisonRecommendations($comparison);
|
||||
|
||||
return $comparison;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find duplicate methods across codebase.
|
||||
*/
|
||||
public function findDuplicateMethods(string $directory): array
|
||||
{
|
||||
$filePaths = $this->findPhpFiles($directory);
|
||||
|
||||
$allMethods = [];
|
||||
|
||||
foreach ($filePaths as $filePath) {
|
||||
try {
|
||||
$methods = $this->extractMethods($filePath);
|
||||
foreach ($methods as $method) {
|
||||
$method['file'] = $filePath;
|
||||
$allMethods[] = $method;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Skip files that can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
$duplicateGroups = [];
|
||||
|
||||
// Compare methods for duplicates
|
||||
for ($i = 0; $i < count($allMethods); $i++) {
|
||||
for ($j = $i + 1; $j < count($allMethods); $j++) {
|
||||
$method1 = $allMethods[$i];
|
||||
$method2 = $allMethods[$j];
|
||||
|
||||
$similarity = $this->similarityCalculator->calculate(
|
||||
$method1['tokens'],
|
||||
$method2['tokens']
|
||||
);
|
||||
|
||||
if ($similarity >= ($this->config['method_similarity_threshold'] ?? 0.8)) {
|
||||
$duplicateGroups[] = [
|
||||
'methods' => [$method1, $method2],
|
||||
'similarity' => $similarity,
|
||||
'recommendation' => 'Extract common logic to shared method or trait'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_methods' => count($allMethods),
|
||||
'duplicate_groups' => $duplicateGroups,
|
||||
'duplicate_percentage' => count($duplicateGroups) / max(1, count($allMethods)) * 100
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect copy-paste programming.
|
||||
*/
|
||||
public function detectCopyPaste(string $directory, int $minLines = 5): array
|
||||
{
|
||||
$filePaths = $this->findPhpFiles($directory);
|
||||
|
||||
$codeBlocks = [];
|
||||
|
||||
// Extract code blocks from all files
|
||||
foreach ($filePaths as $filePath) {
|
||||
try {
|
||||
$blocks = $this->extractCodeBlocks($filePath, $minLines);
|
||||
foreach ($blocks as $block) {
|
||||
$block['file'] = $filePath;
|
||||
$codeBlocks[] = $block;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Skip files that can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
$copyPasteGroups = [];
|
||||
|
||||
// Find identical or very similar blocks
|
||||
for ($i = 0; $i < count($codeBlocks); $i++) {
|
||||
for ($j = $i + 1; $j < count($codeBlocks); $j++) {
|
||||
$block1 = $codeBlocks[$i];
|
||||
$block2 = $codeBlocks[$j];
|
||||
|
||||
// Skip blocks from the same file
|
||||
if ($block1['file'] === $block2['file']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$similarity = $this->similarityCalculator->calculate(
|
||||
$block1['tokens'],
|
||||
$block2['tokens']
|
||||
);
|
||||
|
||||
if ($similarity >= ($this->config['copy_paste_threshold'] ?? 0.9)) {
|
||||
$copyPasteGroups[] = [
|
||||
'blocks' => [$block1, $block2],
|
||||
'similarity' => $similarity,
|
||||
'type' => $similarity >= 0.98 ? 'identical' : 'very_similar',
|
||||
'recommendation' => 'Extract to shared function or class'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_blocks' => count($codeBlocks),
|
||||
'copy_paste_groups' => $copyPasteGroups,
|
||||
'copy_paste_percentage' => count($copyPasteGroups) / max(1, count($codeBlocks)) * 100,
|
||||
'most_common_patterns' => $this->analyzeCommonPatterns($copyPasteGroups)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duplication report.
|
||||
*/
|
||||
public function getReport(array $results, string $format = 'html'): string
|
||||
{
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
return json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
case 'text':
|
||||
return $this->generateTextReport($results);
|
||||
case 'html':
|
||||
return $this->generateHtmlReport($results);
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported report format: {$format}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set similarity threshold.
|
||||
*/
|
||||
public function setSimilarityThreshold(float $threshold): void
|
||||
{
|
||||
$this->config['similarity_threshold'] = max(0, min(1, $threshold));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get similarity threshold.
|
||||
*/
|
||||
public function getSimilarityThreshold(): float
|
||||
{
|
||||
return $this->config['similarity_threshold'] ?? 0.7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detection statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'files_processed' => count($this->fileHashes),
|
||||
'hash_index_size' => count($this->hashIndex),
|
||||
'cache_size' => count($this->duplicateCache),
|
||||
'total_duplicates_found' => $this->countTotalDuplicates()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache and indexes.
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->duplicateCache = [];
|
||||
$this->hashIndex = [];
|
||||
$this->fileHashes = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze file for duplicates.
|
||||
*/
|
||||
protected function analyzeFile(string $filePath, string $content): array
|
||||
{
|
||||
// Check cache
|
||||
$cacheKey = $this->getCacheKey($filePath, $content);
|
||||
if (isset($this->duplicateCache[$cacheKey])) {
|
||||
return $this->duplicateCache[$cacheKey];
|
||||
}
|
||||
|
||||
$result = [
|
||||
'file' => $filePath,
|
||||
'size' => strlen($content),
|
||||
'lines' => substr_count($content, "\n") + 1,
|
||||
'blocks' => [],
|
||||
'duplicates' => [],
|
||||
'duplication_percentage' => 0,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Tokenize content
|
||||
$tokens = $this->tokenizer->tokenize($content);
|
||||
|
||||
// Split into blocks
|
||||
$blocks = $this->splitIntoBlocks($tokens, $filePath);
|
||||
$result['blocks'] = $blocks;
|
||||
|
||||
// Generate hashes for blocks
|
||||
foreach ($blocks as $block) {
|
||||
$hash = $this->hashGenerator->generate($block['tokens']);
|
||||
$block['hash'] = $hash;
|
||||
|
||||
// Add to hash index
|
||||
if (!isset($this->hashIndex[$hash])) {
|
||||
$this->hashIndex[$hash] = [];
|
||||
}
|
||||
$this->hashIndex[$hash][] = $block;
|
||||
}
|
||||
|
||||
// Find duplicates using hash index
|
||||
$result['duplicates'] = $this->findBlockDuplicates($blocks);
|
||||
|
||||
// Calculate duplication percentage
|
||||
$result['duplication_percentage'] = $this->calculateDuplicationPercentage($result);
|
||||
|
||||
// Cache result
|
||||
$this->duplicateCache[$cacheKey] = $result;
|
||||
$this->limitCacheSize();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split tokens into blocks.
|
||||
*/
|
||||
protected function splitIntoBlocks(array $tokens, string $filePath): array
|
||||
{
|
||||
$blocks = [];
|
||||
$blockSize = $this->config['block_size'] ?? 10;
|
||||
$minBlockSize = $this->config['min_block_size'] ?? 5;
|
||||
|
||||
for ($i = 0; $i < count($tokens); $i += $blockSize) {
|
||||
$blockTokens = array_slice($tokens, $i, $blockSize);
|
||||
|
||||
if (count($blockTokens) >= $minBlockSize) {
|
||||
$blocks[] = [
|
||||
'start_line' => $this->getLineNumber($tokens, $i),
|
||||
'end_line' => $this->getLineNumber($tokens, $i + count($blockTokens) - 1),
|
||||
'tokens' => $blockTokens,
|
||||
'size' => count($blockTokens)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find duplicates for blocks.
|
||||
*/
|
||||
protected function findBlockDuplicates(array $blocks): array
|
||||
{
|
||||
$duplicates = [];
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
$hash = $block['hash'];
|
||||
|
||||
if (isset($this->hashIndex[$hash]) && count($this->hashIndex[$hash]) > 1) {
|
||||
// Found potential duplicates
|
||||
foreach ($this->hashIndex[$hash] as $otherBlock) {
|
||||
if ($otherBlock !== $block) {
|
||||
$similarity = $this->similarityCalculator->calculate(
|
||||
$block['tokens'],
|
||||
$otherBlock['tokens']
|
||||
);
|
||||
|
||||
if ($similarity >= $this->getSimilarityThreshold()) {
|
||||
$duplicates[] = [
|
||||
'block' => $block,
|
||||
'duplicate' => $otherBlock,
|
||||
'similarity' => $similarity,
|
||||
'type' => $this->determineDuplicationType($block, $otherBlock)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $duplicates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group duplicates by similarity.
|
||||
*/
|
||||
protected function groupDuplicates(array $duplicates): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($duplicates as $duplicate) {
|
||||
$key = $duplicate['block']['hash'];
|
||||
|
||||
if (!isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'hash' => $key,
|
||||
'blocks' => [],
|
||||
'files' => [],
|
||||
'average_similarity' => 0,
|
||||
'type' => 'unknown'
|
||||
];
|
||||
}
|
||||
|
||||
$groups[$key]['blocks'][] = $duplicate['block'];
|
||||
$groups[$key]['blocks'][] = $duplicate['duplicate'];
|
||||
|
||||
$file1 = $duplicate['block']['file'] ?? 'unknown';
|
||||
$file2 = $duplicate['duplicate']['file'] ?? 'unknown';
|
||||
|
||||
if (!in_array($file1, $groups[$key]['files'])) {
|
||||
$groups[$key]['files'][] = $file1;
|
||||
}
|
||||
if (!in_array($file2, $groups[$key]['files'])) {
|
||||
$groups[$key]['files'][] = $file2;
|
||||
}
|
||||
|
||||
$groups[$key]['average_similarity'] += $duplicate['similarity'];
|
||||
$groups[$key]['type'] = $duplicate['type'];
|
||||
}
|
||||
|
||||
// Calculate average similarities
|
||||
foreach ($groups as &$group) {
|
||||
if (!empty($group['blocks'])) {
|
||||
$group['average_similarity'] /= count($group['blocks']) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate statistics.
|
||||
*/
|
||||
protected function calculateStatistics(array $results): array
|
||||
{
|
||||
$stats = [
|
||||
'total_duplicates' => count($results['duplicates']),
|
||||
'duplicate_groups' => count($results['duplicate_groups']),
|
||||
'files_with_duplicates' => 0,
|
||||
'average_similarity' => 0,
|
||||
'duplication_distribution' => [
|
||||
'low' => 0, 'medium' => 0, 'high' => 0, 'very_high' => 0
|
||||
]
|
||||
];
|
||||
|
||||
$filesWithDuplicates = [];
|
||||
$totalSimilarity = 0;
|
||||
|
||||
foreach ($results['duplicates'] as $duplicate) {
|
||||
$file1 = $duplicate['block']['file'] ?? 'unknown';
|
||||
$file2 = $duplicate['duplicate']['file'] ?? 'unknown';
|
||||
|
||||
$filesWithDuplicates[] = $file1;
|
||||
$filesWithDuplicates[] = $file2;
|
||||
$totalSimilarity += $duplicate['similarity'];
|
||||
|
||||
// Distribution
|
||||
$similarity = $duplicate['similarity'];
|
||||
if ($similarity >= 0.95) {
|
||||
$stats['duplication_distribution']['very_high']++;
|
||||
} elseif ($similarity >= 0.85) {
|
||||
$stats['duplication_distribution']['high']++;
|
||||
} elseif ($similarity >= 0.75) {
|
||||
$stats['duplication_distribution']['medium']++;
|
||||
} else {
|
||||
$stats['duplication_distribution']['low']++;
|
||||
}
|
||||
}
|
||||
|
||||
$stats['files_with_duplicates'] = count(array_unique($filesWithDuplicates));
|
||||
|
||||
if ($stats['total_duplicates'] > 0) {
|
||||
$stats['average_similarity'] = $totalSimilarity / $stats['total_duplicates'];
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract methods from file.
|
||||
*/
|
||||
protected function extractMethods(string $filePath): array
|
||||
{
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ast = $this->parser->parse($content, $filePath);
|
||||
$methods = [];
|
||||
|
||||
// This would extract methods from AST
|
||||
// For now, return empty array
|
||||
|
||||
return $methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract code blocks from file.
|
||||
*/
|
||||
protected function extractCodeBlocks(string $filePath, int $minLines): array
|
||||
{
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = $this->tokenizer->tokenize($content);
|
||||
$blocks = [];
|
||||
|
||||
// Extract blocks with minimum lines
|
||||
$lines = explode("\n", $content);
|
||||
for ($i = 0; $i < count($lines); $i++) {
|
||||
$blockLines = [];
|
||||
|
||||
for ($j = $i; $j < min($i + $minLines * 2, count($lines)); $j++) {
|
||||
$blockLines[] = $lines[$j];
|
||||
}
|
||||
|
||||
if (count($blockLines) >= $minLines) {
|
||||
$blockContent = implode("\n", $blockLines);
|
||||
$blockTokens = $this->tokenizer->tokenize($blockContent);
|
||||
|
||||
$blocks[] = [
|
||||
'start_line' => $i + 1,
|
||||
'end_line' => $i + count($blockLines),
|
||||
'content' => $blockContent,
|
||||
'tokens' => $blockTokens,
|
||||
'size' => count($blockTokens)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine duplication type.
|
||||
*/
|
||||
protected function determineDuplicationType(array $block1, array $block2): string
|
||||
{
|
||||
if ($block1['hash'] === $block2['hash']) {
|
||||
return 'identical';
|
||||
} elseif ($block1['size'] === $block2['size']) {
|
||||
return 'structural';
|
||||
} else {
|
||||
return 'partial';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate file similarity.
|
||||
*/
|
||||
protected function calculateFileSimilarity(array $result1, array $result2): float
|
||||
{
|
||||
if (empty($result1['blocks']) || empty($result2['blocks'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalSimilarity = 0;
|
||||
$comparisons = 0;
|
||||
|
||||
foreach ($result1['blocks'] as $block1) {
|
||||
foreach ($result2['blocks'] as $block2) {
|
||||
$similarity = $this->similarityCalculator->calculate(
|
||||
$block1['tokens'],
|
||||
$block2['tokens']
|
||||
);
|
||||
$totalSimilarity += $similarity;
|
||||
$comparisons++;
|
||||
}
|
||||
}
|
||||
|
||||
return $comparisons > 0 ? $totalSimilarity / $comparisons : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate duplicate percentage.
|
||||
*/
|
||||
protected function calculateDuplicatePercentage(array $result): float
|
||||
{
|
||||
if (empty($result['blocks'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$duplicateBlocks = 0;
|
||||
|
||||
foreach ($result['blocks'] as $block) {
|
||||
$hash = $block['hash'];
|
||||
if (isset($this->hashIndex[$hash]) && count($this->hashIndex[$hash]) > 1) {
|
||||
$duplicateBlocks++;
|
||||
}
|
||||
}
|
||||
|
||||
return ($duplicateBlocks / count($result['blocks'])) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate duplicate percentage for comparison.
|
||||
*/
|
||||
protected function calculateDuplicatePercentage(array $comparison): float
|
||||
{
|
||||
if (empty($comparison['similar_blocks'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return count($comparison['similar_blocks']) * 2; // Each similarity involves 2 blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average similarity.
|
||||
*/
|
||||
protected function calculateAverageSimilarity(array $blocks): float
|
||||
{
|
||||
if (empty($blocks)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($blocks as $block) {
|
||||
$total += $block['similarity'];
|
||||
}
|
||||
|
||||
return $total / count($blocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate comparison recommendations.
|
||||
*/
|
||||
protected function generateComparisonRecommendations(array $comparison): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($comparison['duplicate_percentage'] > 30) {
|
||||
$recommendations[] = "High duplication detected ({$comparison['duplicate_percentage']}%). Consider refactoring.";
|
||||
}
|
||||
|
||||
if ($comparison['overall_similarity'] > 0.8) {
|
||||
$recommendations[] = "Files are very similar. Consider merging or extracting common code.";
|
||||
}
|
||||
|
||||
foreach ($comparison['similar_blocks'] as $block) {
|
||||
if ($block['similarity'] > 0.9) {
|
||||
$recommendations[] = "Near-identical code blocks found. Extract to shared function.";
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze common patterns in copy-paste.
|
||||
*/
|
||||
protected function analyzeCommonPatterns(array $copyPasteGroups): array
|
||||
{
|
||||
$patterns = [];
|
||||
|
||||
// This would analyze common patterns in copy-paste groups
|
||||
// For now, return empty array
|
||||
|
||||
return $patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find PHP files.
|
||||
*/
|
||||
protected function findPhpFiles(string $directory, array $options = []): array
|
||||
{
|
||||
$files = [];
|
||||
$extensions = $options['extensions'] ?? ['php'];
|
||||
$exclude = $options['exclude'] ?? ['vendor', 'node_modules', '.git'];
|
||||
$recursive = $options['recursive'] ?? true;
|
||||
|
||||
$iterator = $recursive ?
|
||||
new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) :
|
||||
new \DirectoryIterator($directory);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = $file->getFilename();
|
||||
$filePath = $file->getPathname();
|
||||
|
||||
// Check extension
|
||||
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, $extensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check exclude patterns
|
||||
$excluded = false;
|
||||
foreach ($exclude as $pattern) {
|
||||
if (strpos($filePath, $pattern) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$excluded) {
|
||||
$files[] = $filePath;
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get line number from token position.
|
||||
*/
|
||||
protected function getLineNumber(array $tokens, int $position): int
|
||||
{
|
||||
if ($position >= count($tokens)) {
|
||||
return count($tokens);
|
||||
}
|
||||
|
||||
$line = 1;
|
||||
for ($i = 0; $i <= $position; $i++) {
|
||||
if (isset($tokens[$i]['type']) && $tokens[$i]['type'] === 'T_NEWLINE') {
|
||||
$line++;
|
||||
}
|
||||
}
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text report.
|
||||
*/
|
||||
protected function generateTextReport(array $results): string
|
||||
{
|
||||
$report = "Duplicate Code Detection Report\n";
|
||||
$report .= "================================\n\n";
|
||||
|
||||
if (isset($results['directory'])) {
|
||||
$report .= "Directory: {$results['directory']}\n";
|
||||
}
|
||||
|
||||
$report .= "Files analyzed: {$results['files_analyzed']}/{$results['total_files']}\n\n";
|
||||
|
||||
if (isset($results['statistics'])) {
|
||||
$stats = $results['statistics'];
|
||||
$report .= "Statistics:\n";
|
||||
$report .= "- Total duplicates: {$stats['total_duplicates']}\n";
|
||||
$report .= "- Duplicate groups: {$stats['duplicate_groups']}\n";
|
||||
$report .= "- Files with duplicates: {$stats['files_with_duplicates']}\n";
|
||||
$report .= "- Average similarity: " . number_format($stats['average_similarity'], 2) . "\n\n";
|
||||
|
||||
$report .= "Duplication distribution:\n";
|
||||
foreach ($stats['duplication_distribution'] as $level => $count) {
|
||||
$report .= "- {$level}: {$count}\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML report.
|
||||
*/
|
||||
protected function generateHtmlReport(array $results): string
|
||||
{
|
||||
$html = "<html><head><title>Duplicate Code Detection Report</title>";
|
||||
$html .= "<style>body{font-family:Arial,sans-serif;margin:20px;}";
|
||||
$html .= ".header{background:#f5f5f5;padding:10px;border-radius:5px;}";
|
||||
$html .= ".duplicate{background:#ffebee;margin:10px 0;padding:10px;border-radius:3px;}";
|
||||
$html .= ".similarity{font-weight:bold;color:#d32f2f;}</style></head><body>";
|
||||
|
||||
$html .= "<div class='header'><h1>Duplicate Code Detection Report</h1>";
|
||||
|
||||
if (isset($results['directory'])) {
|
||||
$html .= "<p>Directory: <strong>{$results['directory']}</strong></p>";
|
||||
}
|
||||
|
||||
$html .= "<p>Files analyzed: <strong>{$results['files_analyzed']}/{$results['total_files']}</strong></p>";
|
||||
$html .= "</div>";
|
||||
|
||||
// Add duplicate details
|
||||
// ... (detailed HTML generation)
|
||||
|
||||
$html .= "</body></html>";
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key.
|
||||
*/
|
||||
protected function getCacheKey(string $filePath, string $content): string
|
||||
{
|
||||
return md5($filePath . strlen($content) . filemtime($filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit cache size.
|
||||
*/
|
||||
protected function limitCacheSize(): void
|
||||
{
|
||||
$maxSize = $this->config['cache_size'] ?? 1000;
|
||||
|
||||
if (count($this->duplicateCache) > $maxSize) {
|
||||
$this->duplicateCache = array_slice($this->duplicateCache, -$maxSize, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total duplicates.
|
||||
*/
|
||||
protected function countTotalDuplicates(): int
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->duplicateCache as $result) {
|
||||
$total += count($result['duplicates'] ?? []);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'similarity_threshold' => 0.7,
|
||||
'method_similarity_threshold' => 0.8,
|
||||
'copy_paste_threshold' => 0.9,
|
||||
'block_size' => 10,
|
||||
'min_block_size' => 5,
|
||||
'tokenizer' => [
|
||||
'ignore_whitespace' => true,
|
||||
'ignore_comments' => true
|
||||
],
|
||||
'hash' => [
|
||||
'algorithm' => 'md5'
|
||||
],
|
||||
'similarity' => [
|
||||
'algorithm' => 'jaccard'
|
||||
],
|
||||
'parser' => [
|
||||
'tolerant' => true
|
||||
],
|
||||
'cache_size' => 1000
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create duplicate detector instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'similarity_threshold' => 0.6, // More lenient for development
|
||||
'block_size' => 8,
|
||||
'min_block_size' => 4
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'similarity_threshold' => 0.8, // Stricter for production
|
||||
'block_size' => 12,
|
||||
'min_block_size' => 6
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Quality\Code;
|
||||
|
||||
use Fendx\Service\Quality\Code\Analyzer\SecurityAnalyzer;
|
||||
use Fendx\Service\Quality\Code\Analyzer\PerformanceAnalyzer;
|
||||
use Fendx\Service\Quality\Code\Analyzer\MaintainabilityAnalyzer;
|
||||
use Fendx\Service\Quality\Code\Parser\CodeParser;
|
||||
use Fendx\Service\Quality\Code\Reporter\AnalysisReporter;
|
||||
|
||||
class StaticAnalyzer
|
||||
{
|
||||
protected array $config = [];
|
||||
protected array $analyzers = [];
|
||||
protected CodeParser $parser;
|
||||
protected AnalysisReporter $reporter;
|
||||
protected array $analysisCache = [];
|
||||
protected array $projectMetrics = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->parser = new CodeParser($this->config['parser'] ?? []);
|
||||
$this->reporter = new AnalysisReporter($this->config['reporter'] ?? []);
|
||||
|
||||
$this->initializeAnalyzers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a PHP file.
|
||||
*/
|
||||
public function analyzeFile(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
throw new \InvalidArgumentException("File not found: {$filePath}");
|
||||
}
|
||||
|
||||
// Check cache
|
||||
$cacheKey = $this->getCacheKey($filePath);
|
||||
if (isset($this->analysisCache[$cacheKey])) {
|
||||
return $this->analysisCache[$cacheKey];
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException("Failed to read file: {$filePath}");
|
||||
}
|
||||
|
||||
// Parse code
|
||||
$ast = $this->parser->parse($content, $filePath);
|
||||
|
||||
$result = [
|
||||
'file' => $filePath,
|
||||
'size' => strlen($content),
|
||||
'lines' => substr_count($content, "\n") + 1,
|
||||
'complexity' => 0,
|
||||
'maintainability_index' => 0,
|
||||
'security_issues' => [],
|
||||
'performance_issues' => [],
|
||||
'code_smells' => [],
|
||||
'metrics' => [],
|
||||
'violations' => [],
|
||||
'score' => 100,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Run all analyzers
|
||||
foreach ($this->analyzers as $name => $analyzer) {
|
||||
$analysisResult = $analyzer->analyze($ast, $content, $filePath);
|
||||
|
||||
$result['metrics'][$name] = $analysisResult['metrics'] ?? [];
|
||||
$result['violations'] = array_merge($result['violations'], $analysisResult['violations'] ?? []);
|
||||
|
||||
// Collect specific issues
|
||||
if ($name === 'security') {
|
||||
$result['security_issues'] = $analysisResult['issues'] ?? [];
|
||||
} elseif ($name === 'performance') {
|
||||
$result['performance_issues'] = $analysisResult['issues'] ?? [];
|
||||
} elseif ($name === 'maintainability') {
|
||||
$result['code_smells'] = $analysisResult['issues'] ?? [];
|
||||
$result['maintainability_index'] = $analysisResult['maintainability_index'] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall complexity
|
||||
$result['complexity'] = $this->calculateComplexity($result['metrics']);
|
||||
|
||||
// Calculate score
|
||||
$result['score'] = $this->calculateScore($result);
|
||||
|
||||
// Cache result
|
||||
$this->analysisCache[$cacheKey] = $result;
|
||||
$this->limitCacheSize();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a directory.
|
||||
*/
|
||||
public function analyzeDirectory(string $directory, array $options = []): array
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
throw new \InvalidArgumentException("Directory not found: {$directory}");
|
||||
}
|
||||
|
||||
$results = [
|
||||
'directory' => $directory,
|
||||
'files_analyzed' => 0,
|
||||
'total_files' => 0,
|
||||
'total_violations' => 0,
|
||||
'security_issues' => 0,
|
||||
'performance_issues' => 0,
|
||||
'code_smells' => 0,
|
||||
'average_complexity' => 0,
|
||||
'average_maintainability' => 0,
|
||||
'average_score' => 0,
|
||||
'files' => [],
|
||||
'summary' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Find PHP files
|
||||
$files = $this->findPhpFiles($directory, $options);
|
||||
$results['total_files'] = count($files);
|
||||
|
||||
if (empty($files)) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
$totalComplexity = 0;
|
||||
$totalMaintainability = 0;
|
||||
$totalScore = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$fileResult = $this->analyzeFile($file);
|
||||
|
||||
$results['files'][$file] = $fileResult;
|
||||
$results['files_analyzed']++;
|
||||
$results['total_violations'] += count($fileResult['violations']);
|
||||
$results['security_issues'] += count($fileResult['security_issues']);
|
||||
$results['performance_issues'] += count($fileResult['performance_issues']);
|
||||
$results['code_smells'] += count($fileResult['code_smells']);
|
||||
|
||||
$totalComplexity += $fileResult['complexity'];
|
||||
$totalMaintainability += $fileResult['maintainability_index'];
|
||||
$totalScore += $fileResult['score'];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$results['files'][$file] = [
|
||||
'file' => $file,
|
||||
'error' => $e->getMessage(),
|
||||
'score' => 0
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($results['files_analyzed'] > 0) {
|
||||
$results['average_complexity'] = $totalComplexity / $results['files_analyzed'];
|
||||
$results['average_maintainability'] = $totalMaintainability / $results['files_analyzed'];
|
||||
$results['average_score'] = $totalScore / $results['files_analyzed'];
|
||||
}
|
||||
|
||||
// Generate summary and recommendations
|
||||
$results['summary'] = $this->generateDirectorySummary($results);
|
||||
$results['recommendations'] = $this->generateRecommendations($results);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze project structure.
|
||||
*/
|
||||
public function analyzeProject(string $rootPath): array
|
||||
{
|
||||
if (!is_dir($rootPath)) {
|
||||
throw new \InvalidArgumentException("Project root not found: {$rootPath}");
|
||||
}
|
||||
|
||||
$analysis = [
|
||||
'project_root' => $rootPath,
|
||||
'structure' => [],
|
||||
'dependencies' => [],
|
||||
'architecture' => [],
|
||||
'metrics' => [],
|
||||
'hotspots' => [],
|
||||
'trends' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Analyze directory structure
|
||||
$analysis['structure'] = $this->analyzeProjectStructure($rootPath);
|
||||
|
||||
// Analyze dependencies
|
||||
$analysis['dependencies'] = $this->analyzeDependencies($rootPath);
|
||||
|
||||
// Analyze architecture
|
||||
$analysis['architecture'] = $this->analyzeArchitecture($rootPath);
|
||||
|
||||
// Calculate project metrics
|
||||
$analysis['metrics'] = $this->calculateProjectMetrics($rootPath);
|
||||
|
||||
// Identify hotspots
|
||||
$analysis['hotspots'] = $this->identifyHotspots($rootPath);
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect code smells.
|
||||
*/
|
||||
public function detectCodeSmells(string $filePath): array
|
||||
{
|
||||
$result = $this->analyzeFile($filePath);
|
||||
|
||||
return [
|
||||
'file' => $filePath,
|
||||
'code_smells' => $result['code_smells'],
|
||||
'total_smells' => count($result['code_smells']),
|
||||
'smell_types' => $this->categorizeCodeSmells($result['code_smells']),
|
||||
'recommendations' => $this->generateSmellRecommendations($result['code_smells'])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect security vulnerabilities.
|
||||
*/
|
||||
public function detectSecurityIssues(string $filePath): array
|
||||
{
|
||||
$result = $this->analyzeFile($filePath);
|
||||
|
||||
return [
|
||||
'file' => $filePath,
|
||||
'security_issues' => $result['security_issues'],
|
||||
'total_issues' => count($result['security_issues']),
|
||||
'severity_distribution' => $this->categorizeSecurityIssues($result['security_issues']),
|
||||
'recommendations' => $this->generateSecurityRecommendations($result['security_issues'])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze code complexity.
|
||||
*/
|
||||
public function analyzeComplexity(string $filePath): array
|
||||
{
|
||||
$result = $this->analyzeFile($filePath);
|
||||
|
||||
return [
|
||||
'file' => $filePath,
|
||||
'cyclomatic_complexity' => $result['metrics']['maintainability']['cyclomatic_complexity'] ?? 0,
|
||||
'cognitive_complexity' => $result['metrics']['maintainability']['cognitive_complexity'] ?? 0,
|
||||
'overall_complexity' => $result['complexity'],
|
||||
'complexity_distribution' => $this->analyzeComplexityDistribution($result),
|
||||
'recommendations' => $this->generateComplexityRecommendations($result)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analysis report.
|
||||
*/
|
||||
public function getReport(array $results, string $format = 'html'): string
|
||||
{
|
||||
return $this->reporter->generate($results, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom analyzer.
|
||||
*/
|
||||
public function addAnalyzer(string $name, object $analyzer): void
|
||||
{
|
||||
$this->analyzers[$name] = $analyzer;
|
||||
|
||||
$this->logInfo("Added static analyzer: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove analyzer.
|
||||
*/
|
||||
public function removeAnalyzer(string $name): bool
|
||||
{
|
||||
if (!isset($this->analyzers[$name])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($this->analyzers[$name]);
|
||||
|
||||
$this->logInfo("Removed static analyzer: {$name}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available analyzers.
|
||||
*/
|
||||
public function getAnalyzers(): array
|
||||
{
|
||||
return array_keys($this->analyzers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analysis statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'analyzers_count' => count($this->analyzers),
|
||||
'cache_size' => count($this->analysisCache),
|
||||
'files_analyzed' => 0,
|
||||
'total_violations_found' => 0,
|
||||
'average_analysis_time' => 0
|
||||
];
|
||||
|
||||
foreach ($this->analyzers as $name => $analyzer) {
|
||||
$analyzerStats = $analyzer->getStatistics();
|
||||
$stats['files_analyzed'] += $analyzerStats['files_analyzed'] ?? 0;
|
||||
$stats['total_violations_found'] += $analyzerStats['violations_found'] ?? 0;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache.
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->analysisCache = [];
|
||||
|
||||
$this->logInfo("Static analyzer cache cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate code complexity.
|
||||
*/
|
||||
protected function calculateComplexity(array $metrics): int
|
||||
{
|
||||
$complexity = 0;
|
||||
|
||||
if (isset($metrics['maintainability']['cyclomatic_complexity'])) {
|
||||
$complexity += $metrics['maintainability']['cyclomatic_complexity'];
|
||||
}
|
||||
|
||||
if (isset($metrics['maintainability']['cognitive_complexity'])) {
|
||||
$complexity += $metrics['maintainability']['cognitive_complexity'];
|
||||
}
|
||||
|
||||
return $complexity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall score.
|
||||
*/
|
||||
protected function calculateScore(array $result): int
|
||||
{
|
||||
$score = 100;
|
||||
|
||||
// Deduct points for violations
|
||||
$violationCount = count($result['violations']);
|
||||
if ($violationCount > 0) {
|
||||
$score -= min(50, $violationCount * 2); // Max 50 points deduction
|
||||
}
|
||||
|
||||
// Deduct points for high complexity
|
||||
if ($result['complexity'] > 10) {
|
||||
$score -= min(30, ($result['complexity'] - 10) * 2);
|
||||
}
|
||||
|
||||
// Deduct points for low maintainability
|
||||
if ($result['maintainability_index'] < 70) {
|
||||
$score -= min(20, (70 - $result['maintainability_index']) / 2);
|
||||
}
|
||||
|
||||
// Bonus for no security issues
|
||||
if (empty($result['security_issues'])) {
|
||||
$score += 5;
|
||||
}
|
||||
|
||||
return max(0, min(100, $score));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find PHP files in directory.
|
||||
*/
|
||||
protected function findPhpFiles(string $directory, array $options = []): array
|
||||
{
|
||||
$files = [];
|
||||
$extensions = $options['extensions'] ?? ['php'];
|
||||
$exclude = $options['exclude'] ?? ['vendor', 'node_modules', '.git', 'tests'];
|
||||
$recursive = $options['recursive'] ?? true;
|
||||
|
||||
$iterator = $recursive ?
|
||||
new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) :
|
||||
new \DirectoryIterator($directory);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = $file->getFilename();
|
||||
$filePath = $file->getPathname();
|
||||
|
||||
// Check extension
|
||||
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, $extensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check exclude patterns
|
||||
$excluded = false;
|
||||
foreach ($exclude as $pattern) {
|
||||
if (strpos($filePath, $pattern) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$excluded) {
|
||||
$files[] = $filePath;
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate directory summary.
|
||||
*/
|
||||
protected function generateDirectorySummary(array $results): array
|
||||
{
|
||||
return [
|
||||
'grade' => $this->calculateGrade($results['average_score']),
|
||||
'quality_level' => $this->getQualityLevel($results['average_score']),
|
||||
'complexity_level' => $this->getComplexityLevel($results['average_complexity']),
|
||||
'maintainability_level' => $this->getMaintainabilityLevel($results['average_maintainability']),
|
||||
'security_risk' => $this->getSecurityRisk($results['security_issues']),
|
||||
'performance_risk' => $this->getPerformanceRisk($results['performance_issues'])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recommendations.
|
||||
*/
|
||||
protected function generateRecommendations(array $results): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($results['security_issues'] > 0) {
|
||||
$recommendations[] = "Address {$results['security_issues']} security issue(s) immediately";
|
||||
}
|
||||
|
||||
if ($results['performance_issues'] > 5) {
|
||||
$recommendations[] = "Optimize {$results['performance_issues']} performance issue(s)";
|
||||
}
|
||||
|
||||
if ($results['code_smells'] > 10) {
|
||||
$recommendations[] = "Refactor {$results['code_smells']} code smell(s) to improve maintainability";
|
||||
}
|
||||
|
||||
if ($results['average_complexity'] > 15) {
|
||||
$recommendations[] = "Reduce average complexity (current: " .
|
||||
number_format($results['average_complexity'], 1) . ")";
|
||||
}
|
||||
|
||||
if ($results['average_maintainability'] < 70) {
|
||||
$recommendations[] = "Improve code maintainability (current: " .
|
||||
number_format($results['average_maintainability'], 1) . ")";
|
||||
}
|
||||
|
||||
if ($results['average_score'] < 80) {
|
||||
$recommendations[] = "Overall code quality needs improvement (score: " .
|
||||
number_format($results['average_score'], 1) . ")";
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze project structure.
|
||||
*/
|
||||
protected function analyzeProjectStructure(string $rootPath): array
|
||||
{
|
||||
$structure = [
|
||||
'directories' => [],
|
||||
'files' => [],
|
||||
'depth' => 0,
|
||||
'organization_score' => 0
|
||||
];
|
||||
|
||||
// This would analyze the directory structure
|
||||
// For now, return basic structure
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze dependencies.
|
||||
*/
|
||||
protected function analyzeDependencies(string $rootPath): array
|
||||
{
|
||||
return [
|
||||
'internal_dependencies' => [],
|
||||
'external_dependencies' => [],
|
||||
'dependency_graph' => [],
|
||||
'circular_dependencies' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze architecture.
|
||||
*/
|
||||
protected function analyzeArchitecture(string $rootPath): array
|
||||
{
|
||||
return [
|
||||
'layers' => [],
|
||||
'patterns' => [],
|
||||
'violations' => [],
|
||||
'coupling' => [],
|
||||
'cohesion' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project metrics.
|
||||
*/
|
||||
protected function calculateProjectMetrics(string $rootPath): array
|
||||
{
|
||||
return [
|
||||
'lines_of_code' => 0,
|
||||
'classes' => 0,
|
||||
'methods' => 0,
|
||||
'average_class_size' => 0,
|
||||
'average_method_size' => 0,
|
||||
'duplication_percentage' => 0
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify hotspots.
|
||||
*/
|
||||
protected function identifyHotspots(string $rootPath): array
|
||||
{
|
||||
return [
|
||||
'complex_files' => [],
|
||||
'large_files' => [],
|
||||
'frequently_changed' => [],
|
||||
'bug_prone' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize code smells.
|
||||
*/
|
||||
protected function categorizeCodeSmells(array $smells): array
|
||||
{
|
||||
$categories = [
|
||||
'design' => 0,
|
||||
'implementation' => 0,
|
||||
'naming' => 0,
|
||||
'formatting' => 0
|
||||
];
|
||||
|
||||
foreach ($smells as $smell) {
|
||||
$type = $smell['category'] ?? 'implementation';
|
||||
$categories[$type] = ($categories[$type] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize security issues.
|
||||
*/
|
||||
protected function categorizeSecurityIssues(array $issues): array
|
||||
{
|
||||
$severity = [
|
||||
'critical' => 0,
|
||||
'high' => 0,
|
||||
'medium' => 0,
|
||||
'low' => 0
|
||||
];
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$level = $issue['severity'] ?? 'medium';
|
||||
$severity[$level] = ($severity[$level] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $severity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze complexity distribution.
|
||||
*/
|
||||
protected function analyzeComplexityDistribution(array $result): array
|
||||
{
|
||||
return [
|
||||
'low' => 0,
|
||||
'medium' => 0,
|
||||
'high' => 0,
|
||||
'very_high' => 0
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate smell recommendations.
|
||||
*/
|
||||
protected function generateSmellRecommendations(array $smells): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
foreach ($smells as $smell) {
|
||||
$recommendations[] = $smell['recommendation'] ?? "Address code smell: {$smell['type']}";
|
||||
}
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate security recommendations.
|
||||
*/
|
||||
protected function generateSecurityRecommendations(array $issues): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$recommendations[] = $issue['recommendation'] ?? "Fix security issue: {$issue['type']}";
|
||||
}
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complexity recommendations.
|
||||
*/
|
||||
protected function generateComplexityRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['complexity'] > 20) {
|
||||
$recommendations[] = "Consider breaking down complex methods";
|
||||
}
|
||||
|
||||
if ($result['complexity'] > 10) {
|
||||
$recommendations[] = "Reduce cyclomatic complexity";
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate grade from score.
|
||||
*/
|
||||
protected function calculateGrade(float $score): string
|
||||
{
|
||||
if ($score >= 90) return 'A';
|
||||
if ($score >= 80) return 'B';
|
||||
if ($score >= 70) return 'C';
|
||||
if ($score >= 60) return 'D';
|
||||
return 'F';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality level from score.
|
||||
*/
|
||||
protected function getQualityLevel(float $score): string
|
||||
{
|
||||
if ($score >= 90) return 'excellent';
|
||||
if ($score >= 80) return 'good';
|
||||
if ($score >= 70) return 'fair';
|
||||
if ($score >= 60) return 'poor';
|
||||
return 'failing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complexity level.
|
||||
*/
|
||||
protected function getComplexityLevel(float $complexity): string
|
||||
{
|
||||
if ($complexity <= 5) return 'low';
|
||||
if ($complexity <= 10) return 'moderate';
|
||||
if ($complexity <= 20) return 'high';
|
||||
return 'very_high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintainability level.
|
||||
*/
|
||||
protected function getMaintainabilityLevel(float $index): string
|
||||
{
|
||||
if ($index >= 85) return 'excellent';
|
||||
if ($index >= 70) return 'good';
|
||||
if ($index >= 50) return 'moderate';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security risk level.
|
||||
*/
|
||||
protected function getSecurityRisk(int $issues): string
|
||||
{
|
||||
if ($issues === 0) return 'none';
|
||||
if ($issues <= 2) return 'low';
|
||||
if ($issues <= 5) return 'medium';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance risk level.
|
||||
*/
|
||||
protected function getPerformanceRisk(int $issues): string
|
||||
{
|
||||
if ($issues === 0) return 'none';
|
||||
if ($issues <= 3) return 'low';
|
||||
if ($issues <= 8) return 'medium';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key for file.
|
||||
*/
|
||||
protected function getCacheKey(string $filePath): string
|
||||
{
|
||||
$mtime = filemtime($filePath);
|
||||
return md5($filePath . $mtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit cache size.
|
||||
*/
|
||||
protected function limitCacheSize(): void
|
||||
{
|
||||
$maxSize = $this->config['cache_size'] ?? 1000;
|
||||
|
||||
if (count($this->analysisCache) > $maxSize) {
|
||||
$this->analysisCache = array_slice($this->analysisCache, -$maxSize, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize analyzers.
|
||||
*/
|
||||
protected function initializeAnalyzers(): void
|
||||
{
|
||||
$this->analyzers['security'] = new SecurityAnalyzer($this->config['analyzers']['security'] ?? []);
|
||||
$this->analyzers['performance'] = new PerformanceAnalyzer($this->config['analyzers']['performance'] ?? []);
|
||||
$this->analyzers['maintainability'] = new MaintainabilityAnalyzer($this->config['analyzers']['maintainability'] ?? []);
|
||||
|
||||
$this->logInfo("Initialized " . count($this->analyzers) . " static analyzers");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[StaticAnalyzer] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'analyzers' => [
|
||||
'security' => [
|
||||
'enabled' => true,
|
||||
'rules' => ['sql_injection', 'xss', 'csrf', 'path_traversal']
|
||||
],
|
||||
'performance' => [
|
||||
'enabled' => true,
|
||||
'rules' => ['slow_queries', 'memory_leaks', 'inefficient_loops']
|
||||
],
|
||||
'maintainability' => [
|
||||
'enabled' => true,
|
||||
'rules' => ['complexity', 'duplication', 'long_methods']
|
||||
]
|
||||
],
|
||||
'parser' => [
|
||||
'tolerant' => true,
|
||||
'track_positions' => true
|
||||
],
|
||||
'reporter' => [
|
||||
'format' => 'html',
|
||||
'include_metrics' => true
|
||||
],
|
||||
'cache_size' => 1000,
|
||||
'logging_enabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create static analyzer instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'analyzers' => [
|
||||
'security' => [
|
||||
'enabled' => true,
|
||||
'strict_mode' => false
|
||||
],
|
||||
'performance' => [
|
||||
'enabled' => true,
|
||||
'thresholds' => ['complexity' => 15, 'method_length' => 50]
|
||||
],
|
||||
'maintainability' => [
|
||||
'enabled' => true,
|
||||
'tolerance' => 'medium'
|
||||
]
|
||||
],
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'analyzers' => [
|
||||
'security' => [
|
||||
'enabled' => true,
|
||||
'strict_mode' => true
|
||||
],
|
||||
'performance' => [
|
||||
'enabled' => true,
|
||||
'thresholds' => ['complexity' => 10, 'method_length' => 30]
|
||||
],
|
||||
'maintainability' => [
|
||||
'enabled' => true,
|
||||
'tolerance' => 'low'
|
||||
]
|
||||
],
|
||||
'logging_enabled' => false
|
||||
]);
|
||||
}
|
||||
}
|
||||
789
fendx-framework/fendx-service/src/Registry/ServiceRegistry.php
Normal file
789
fendx-framework/fendx-service/src/Registry/ServiceRegistry.php
Normal file
@@ -0,0 +1,789 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Registry;
|
||||
|
||||
use Fendx\Service\Registry\Storage\RegistryStorage;
|
||||
use Fendx\Service\Registry\Health\HealthChecker;
|
||||
use Fendx\Service\Registry\Discovery\ServiceDiscovery;
|
||||
use Fendx\Service\Registry\Metadata\MetadataManager;
|
||||
|
||||
class ServiceRegistry
|
||||
{
|
||||
protected RegistryStorage $storage;
|
||||
protected HealthChecker $healthChecker;
|
||||
protected ServiceDiscovery $discovery;
|
||||
protected MetadataManager $metadataManager;
|
||||
protected array $config = [];
|
||||
protected array $services = [];
|
||||
protected array $instances = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->storage = new RegistryStorage($this->config);
|
||||
$this->healthChecker = new HealthChecker($this->config);
|
||||
$this->discovery = new ServiceDiscovery($this->config);
|
||||
$this->metadataManager = new MetadataManager($this->config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a service.
|
||||
*/
|
||||
public function registerService(string $serviceName, array $serviceConfig): bool
|
||||
{
|
||||
$this->validateServiceConfig($serviceConfig);
|
||||
|
||||
$service = [
|
||||
'name' => $serviceName,
|
||||
'id' => $serviceConfig['id'] ?? $this->generateServiceId($serviceName),
|
||||
'host' => $serviceConfig['host'],
|
||||
'port' => $serviceConfig['port'],
|
||||
'protocol' => $serviceConfig['protocol'] ?? 'http',
|
||||
'path' => $serviceConfig['path'] ?? '/',
|
||||
'metadata' => $serviceConfig['metadata'] ?? [],
|
||||
'tags' => $serviceConfig['tags'] ?? [],
|
||||
'weight' => $serviceConfig['weight'] ?? 1,
|
||||
'enabled' => $serviceConfig['enabled'] ?? true,
|
||||
'health_check' => $serviceConfig['health_check'] ?? null,
|
||||
'registered_at' => time(),
|
||||
'updated_at' => time(),
|
||||
'last_heartbeat' => time()
|
||||
];
|
||||
|
||||
// Store service
|
||||
$this->services[$serviceName][$service['id']] = $service;
|
||||
$this->instances[$service['id']] = $service;
|
||||
|
||||
// Persist to storage
|
||||
$this->storage->storeService($service);
|
||||
|
||||
// Start health checking if configured
|
||||
if ($service['health_check']) {
|
||||
$this->healthChecker->startMonitoring($service['id'], $service['health_check']);
|
||||
}
|
||||
|
||||
$this->logInfo("Service registered: {$serviceName} ({$service['id']})");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a service.
|
||||
*/
|
||||
public function unregisterService(string $serviceName, string $serviceId = null): bool
|
||||
{
|
||||
if ($serviceId === null) {
|
||||
// Remove all instances of the service
|
||||
if (isset($this->services[$serviceName])) {
|
||||
foreach ($this->services[$serviceName] as $id => $service) {
|
||||
$this->removeServiceInstance($id);
|
||||
}
|
||||
unset($this->services[$serviceName]);
|
||||
}
|
||||
} else {
|
||||
// Remove specific instance
|
||||
if (isset($this->services[$serviceName][$serviceId])) {
|
||||
$this->removeServiceInstance($serviceId);
|
||||
unset($this->services[$serviceName][$serviceId]);
|
||||
|
||||
// Remove service entry if no instances left
|
||||
if (empty($this->services[$serviceName])) {
|
||||
unset($this->services[$serviceName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Service unregistered: {$serviceName}" . ($serviceId ? " ({$serviceId})" : ""));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service instances.
|
||||
*/
|
||||
public function getServiceInstances(string $serviceName, array $filters = []): array
|
||||
{
|
||||
if (!isset($this->services[$serviceName])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$instances = $this->services[$serviceName];
|
||||
|
||||
// Apply filters
|
||||
if (!empty($filters)) {
|
||||
$instances = $this->filterInstances($instances, $filters);
|
||||
}
|
||||
|
||||
// Sort by weight and health
|
||||
uasort($instances, function ($a, $b) {
|
||||
// Healthy instances first
|
||||
$aHealthy = $this->healthChecker->isHealthy($a['id']);
|
||||
$bHealthy = $this->healthChecker->isHealthy($b['id']);
|
||||
|
||||
if ($aHealthy !== $bHealthy) {
|
||||
return $bHealthy <=> $aHealthy;
|
||||
}
|
||||
|
||||
// Then by weight
|
||||
return $b['weight'] <=> $a['weight'];
|
||||
});
|
||||
|
||||
return array_values($instances);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single service instance (for load balancing).
|
||||
*/
|
||||
public function getServiceInstance(string $serviceName, array $filters = []): ?array
|
||||
{
|
||||
$instances = $this->getServiceInstances($serviceName, $filters);
|
||||
|
||||
if (empty($instances)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load balancing strategy
|
||||
$strategy = $this->config['load_balancing_strategy'] ?? 'round_robin';
|
||||
|
||||
return match ($strategy) {
|
||||
'random' => $instances[array_rand($instances)],
|
||||
'round_robin' => $this->selectRoundRobin($serviceName, $instances),
|
||||
'weighted' => $this->selectWeighted($instances),
|
||||
'least_connections' => $this->selectLeastConnections($instances),
|
||||
default => $instances[0]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update service metadata.
|
||||
*/
|
||||
public function updateServiceMetadata(string $serviceId, array $metadata): bool
|
||||
{
|
||||
if (!isset($this->instances[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->instances[$serviceId]['metadata'] = array_merge(
|
||||
$this->instances[$serviceId]['metadata'],
|
||||
$metadata
|
||||
);
|
||||
$this->instances[$serviceId]['updated_at'] = time();
|
||||
|
||||
// Update in all service collections
|
||||
foreach ($this->services as $serviceName => $instances) {
|
||||
if (isset($instances[$serviceId])) {
|
||||
$this->services[$serviceName][$serviceId] = $this->instances[$serviceId];
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to storage
|
||||
$this->storage->updateService($serviceId, $this->instances[$serviceId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update service health status.
|
||||
*/
|
||||
public function updateServiceHealth(string $serviceId, bool $healthy, string $message = ''): void
|
||||
{
|
||||
if (!isset($this->instances[$serviceId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->instances[$serviceId]['healthy'] = $healthy;
|
||||
$this->instances[$serviceId]['health_message'] = $message;
|
||||
$this->instances[$serviceId]['updated_at'] = time();
|
||||
|
||||
// Update in all service collections
|
||||
foreach ($this->services as $serviceName => $instances) {
|
||||
if (isset($instances[$serviceId])) {
|
||||
$this->services[$serviceName][$serviceId] = $this->instances[$serviceId];
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to storage
|
||||
$this->storage->updateService($serviceId, $this->instances[$serviceId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat for service.
|
||||
*/
|
||||
public function heartbeat(string $serviceId): bool
|
||||
{
|
||||
if (!isset($this->instances[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->instances[$serviceId]['last_heartbeat'] = time();
|
||||
|
||||
// Update in all service collections
|
||||
foreach ($this->services as $serviceName => $instances) {
|
||||
if (isset($instances[$serviceId])) {
|
||||
$this->services[$serviceName][$serviceId] = $this->instances[$serviceId];
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to storage
|
||||
$this->storage->updateService($serviceId, $this->instances[$serviceId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered services.
|
||||
*/
|
||||
public function getAllServices(): array
|
||||
{
|
||||
$services = [];
|
||||
|
||||
foreach ($this->services as $serviceName => $instances) {
|
||||
$services[$serviceName] = [
|
||||
'name' => $serviceName,
|
||||
'instances' => count($instances),
|
||||
'healthy_instances' => count(array_filter($instances, function($instance) {
|
||||
return $this->healthChecker->isHealthy($instance['id']);
|
||||
})),
|
||||
'enabled_instances' => count(array_filter($instances, function($instance) {
|
||||
return $instance['enabled'];
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service details.
|
||||
*/
|
||||
public function getServiceDetails(string $serviceName): array
|
||||
{
|
||||
if (!isset($this->services[$serviceName])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$instances = $this->services[$serviceName];
|
||||
$healthyCount = 0;
|
||||
$enabledCount = 0;
|
||||
|
||||
foreach ($instances as $instance) {
|
||||
if ($this->healthChecker->isHealthy($instance['id'])) {
|
||||
$healthyCount++;
|
||||
}
|
||||
if ($instance['enabled']) {
|
||||
$enabledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $serviceName,
|
||||
'instances' => $instances,
|
||||
'total_instances' => count($instances),
|
||||
'healthy_instances' => $healthyCount,
|
||||
'enabled_instances' => $enabledCount,
|
||||
'tags' => array_unique(array_merge(...array_column($instances, 'tags')))
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find services by tags.
|
||||
*/
|
||||
public function findServicesByTags(array $tags): array
|
||||
{
|
||||
$matchingServices = [];
|
||||
|
||||
foreach ($this->services as $serviceName => $instances) {
|
||||
foreach ($instances as $instance) {
|
||||
if (count(array_intersect($tags, $instance['tags'])) > 0) {
|
||||
$matchingServices[$serviceName][] = $instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matchingServices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find services by metadata.
|
||||
*/
|
||||
public function findServicesByMetadata(array $metadata): array
|
||||
{
|
||||
$matchingServices = [];
|
||||
|
||||
foreach ($this->services as $serviceName => $instances) {
|
||||
foreach ($instances as $instance) {
|
||||
if ($this->matchesMetadata($instance['metadata'], $metadata)) {
|
||||
$matchingServices[$serviceName][] = $instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matchingServices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$totalServices = count($this->services);
|
||||
$totalInstances = count($this->instances);
|
||||
$healthyInstances = 0;
|
||||
$enabledInstances = 0;
|
||||
$servicesByTag = [];
|
||||
$servicesByProtocol = [];
|
||||
|
||||
foreach ($this->instances as $instance) {
|
||||
if ($this->healthChecker->isHealthy($instance['id'])) {
|
||||
$healthyInstances++;
|
||||
}
|
||||
if ($instance['enabled']) {
|
||||
$enabledInstances++;
|
||||
}
|
||||
|
||||
// Count by tags
|
||||
foreach ($instance['tags'] as $tag) {
|
||||
$servicesByTag[$tag] = ($servicesByTag[$tag] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Count by protocol
|
||||
$protocol = $instance['protocol'];
|
||||
$servicesByProtocol[$protocol] = ($servicesByProtocol[$protocol] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_services' => $totalServices,
|
||||
'total_instances' => $totalInstances,
|
||||
'healthy_instances' => $healthyInstances,
|
||||
'enabled_instances' => $enabledInstances,
|
||||
'unhealthy_instances' => $totalInstances - $healthyInstances,
|
||||
'disabled_instances' => $totalInstances - $enabledInstances,
|
||||
'health_percentage' => $totalInstances > 0 ? ($healthyInstances / $totalInstances) * 100 : 0,
|
||||
'services_by_tag' => $servicesByTag,
|
||||
'services_by_protocol' => $servicesByProtocol,
|
||||
'registry_uptime' => time() - $this->config['start_time']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale services.
|
||||
*/
|
||||
public function cleanupStaleServices(): array
|
||||
{
|
||||
$now = time();
|
||||
$staleThreshold = $this->config['stale_threshold'] ?? 300; // 5 minutes
|
||||
$removed = [];
|
||||
|
||||
foreach ($this->instances as $serviceId => $instance) {
|
||||
if ($now - $instance['last_heartbeat'] > $staleThreshold) {
|
||||
$serviceName = $this->findServiceNameById($serviceId);
|
||||
if ($serviceName) {
|
||||
$this->unregisterService($serviceName, $serviceId);
|
||||
$removed[] = $serviceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logInfo("Cleaned up " . count($removed) . " stale services");
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable service.
|
||||
*/
|
||||
public function enableService(string $serviceId, bool $enabled = true): bool
|
||||
{
|
||||
if (!isset($this->instances[$serviceId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->instances[$serviceId]['enabled'] = $enabled;
|
||||
$this->instances[$serviceId]['updated_at'] = time();
|
||||
|
||||
// Update in all service collections
|
||||
foreach ($this->services as $serviceName => $instances) {
|
||||
if (isset($instances[$serviceId])) {
|
||||
$this->services[$serviceName][$serviceId] = $this->instances[$serviceId];
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to storage
|
||||
$this->storage->updateService($serviceId, $this->instances[$serviceId]);
|
||||
|
||||
$this->logInfo("Service " . ($enabled ? 'enabled' : 'disabled') . ": {$serviceId}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable service.
|
||||
*/
|
||||
public function disableService(string $serviceId): bool
|
||||
{
|
||||
return $this->enableService($serviceId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service URL.
|
||||
*/
|
||||
public function getServiceUrl(string $serviceName, array $filters = []): ?string
|
||||
{
|
||||
$instance = $this->getServiceInstance($serviceName, $filters);
|
||||
|
||||
if (!$instance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildServiceUrl($instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build service URL from instance.
|
||||
*/
|
||||
protected function buildServiceUrl(array $instance): string
|
||||
{
|
||||
$protocol = $instance['protocol'];
|
||||
$host = $instance['host'];
|
||||
$port = $instance['port'];
|
||||
$path = $instance['path'];
|
||||
|
||||
$url = "{$protocol}://{$host}";
|
||||
|
||||
// Add port if not default
|
||||
if (($protocol === 'http' && $port != 80) || ($protocol === 'https' && $port != 443)) {
|
||||
$url .= ":{$port}";
|
||||
}
|
||||
|
||||
$url .= $path;
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter service instances.
|
||||
*/
|
||||
protected function filterInstances(array $instances, array $filters): array
|
||||
{
|
||||
return array_filter($instances, function ($instance) use ($filters) {
|
||||
// Filter by tags
|
||||
if (isset($filters['tags']) && !empty($filters['tags'])) {
|
||||
if (count(array_intersect($filters['tags'], $instance['tags'])) === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by protocol
|
||||
if (isset($filters['protocol']) && $instance['protocol'] !== $filters['protocol']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by enabled status
|
||||
if (isset($filters['enabled']) && $instance['enabled'] !== $filters['enabled']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by health status
|
||||
if (isset($filters['healthy'])) {
|
||||
$isHealthy = $this->healthChecker->isHealthy($instance['id']);
|
||||
if ($isHealthy !== $filters['healthy']) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by metadata
|
||||
if (isset($filters['metadata']) && !$this->matchesMetadata($instance['metadata'], $filters['metadata'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metadata matches filters.
|
||||
*/
|
||||
protected function matchesMetadata(array $metadata, array $filters): bool
|
||||
{
|
||||
foreach ($filters as $key => $value) {
|
||||
if (!isset($metadata[$key]) || $metadata[$key] !== $value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select instance using round-robin.
|
||||
*/
|
||||
protected function selectRoundRobin(string $serviceName, array $instances): array
|
||||
{
|
||||
static $counters = [];
|
||||
|
||||
if (!isset($counters[$serviceName])) {
|
||||
$counters[$serviceName] = 0;
|
||||
}
|
||||
|
||||
$index = $counters[$serviceName] % count($instances);
|
||||
$counters[$serviceName]++;
|
||||
|
||||
return $instances[$index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select instance using weighted random.
|
||||
*/
|
||||
protected function selectWeighted(array $instances): array
|
||||
{
|
||||
$totalWeight = array_sum(array_column($instances, 'weight'));
|
||||
|
||||
if ($totalWeight === 0) {
|
||||
return $instances[0];
|
||||
}
|
||||
|
||||
$random = mt_rand(1, $totalWeight);
|
||||
$currentWeight = 0;
|
||||
|
||||
foreach ($instances as $instance) {
|
||||
$currentWeight += $instance['weight'];
|
||||
if ($random <= $currentWeight) {
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
|
||||
return $instances[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select instance with least connections.
|
||||
*/
|
||||
protected function selectLeastConnections(array $instances): array
|
||||
{
|
||||
$minConnections = PHP_INT_MAX;
|
||||
$selected = $instances[0];
|
||||
|
||||
foreach ($instances as $instance) {
|
||||
$connections = $this->metadataManager->getConnections($instance['id']) ?? 0;
|
||||
if ($connections < $minConnections) {
|
||||
$minConnections = $connections;
|
||||
$selected = $instance;
|
||||
}
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate service ID.
|
||||
*/
|
||||
protected function generateServiceId(string $serviceName): string
|
||||
{
|
||||
return $serviceName . '_' . uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find service name by ID.
|
||||
*/
|
||||
protected function findServiceNameById(string $serviceId): ?string
|
||||
{
|
||||
foreach ($this->services as $serviceName => $instances) {
|
||||
if (isset($instances[$serviceId])) {
|
||||
return $serviceName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove service instance.
|
||||
*/
|
||||
protected function removeServiceInstance(string $serviceId): void
|
||||
{
|
||||
// Stop health checking
|
||||
$this->healthChecker->stopMonitoring($serviceId);
|
||||
|
||||
// Remove from storage
|
||||
$this->storage->removeService($serviceId);
|
||||
|
||||
// Remove from instances
|
||||
unset($this->instances[$serviceId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate service configuration.
|
||||
*/
|
||||
protected function validateServiceConfig(array $config): void
|
||||
{
|
||||
$required = ['host', 'port'];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (!isset($config[$field])) {
|
||||
throw new \InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_string($config['host'])) {
|
||||
throw new \InvalidArgumentException("Host must be a string");
|
||||
}
|
||||
|
||||
if (!is_int($config['port']) || $config['port'] < 1 || $config['port'] > 65535) {
|
||||
throw new \InvalidArgumentException("Port must be an integer between 1 and 65535");
|
||||
}
|
||||
|
||||
if (isset($config['protocol']) && !in_array($config['protocol'], ['http', 'https', 'tcp', 'udp'])) {
|
||||
throw new \InvalidArgumentException("Invalid protocol: {$config['protocol']}");
|
||||
}
|
||||
|
||||
if (isset($config['weight']) && (!is_int($config['weight']) || $config['weight'] < 1)) {
|
||||
throw new \InvalidArgumentException("Weight must be a positive integer");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize registry.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->config['start_time'] = time();
|
||||
|
||||
// Load existing services from storage
|
||||
$this->loadServicesFromStorage();
|
||||
|
||||
// Start health checking for existing services
|
||||
$this->startHealthChecking();
|
||||
|
||||
// Start cleanup task
|
||||
if ($this->config['auto_cleanup']) {
|
||||
$this->startCleanupTask();
|
||||
}
|
||||
|
||||
$this->logInfo("Service registry initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load services from storage.
|
||||
*/
|
||||
protected function loadServicesFromStorage(): void
|
||||
{
|
||||
$services = $this->storage->loadAllServices();
|
||||
|
||||
foreach ($services as $service) {
|
||||
$this->instances[$service['id']] = $service;
|
||||
$this->services[$service['name']][$service['id']] = $service;
|
||||
}
|
||||
|
||||
$this->logInfo("Loaded " . count($services) . " services from storage");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start health checking.
|
||||
*/
|
||||
protected function startHealthChecking(): void
|
||||
{
|
||||
foreach ($this->instances as $serviceId => $service) {
|
||||
if ($service['health_check']) {
|
||||
$this->healthChecker->startMonitoring($serviceId, $service['health_check']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start cleanup task.
|
||||
*/
|
||||
protected function startCleanupTask(): void
|
||||
{
|
||||
// This would typically be run as a background task
|
||||
// For now, we'll just log that it would start
|
||||
$this->logInfo("Cleanup task started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[ServiceRegistry] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'load_balancing_strategy' => 'round_robin',
|
||||
'health_check_interval' => 30,
|
||||
'stale_threshold' => 300,
|
||||
'auto_cleanup' => true,
|
||||
'cleanup_interval' => 60,
|
||||
'logging_enabled' => true,
|
||||
'storage' => [
|
||||
'type' => 'file',
|
||||
'path' => __DIR__ . '/../../../storage/registry'
|
||||
],
|
||||
'start_time' => time()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create registry instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'health_check_interval' => 10,
|
||||
'stale_threshold' => 60,
|
||||
'auto_cleanup' => true,
|
||||
'logging_enabled' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'health_check_interval' => 30,
|
||||
'stale_threshold' => 300,
|
||||
'auto_cleanup' => true,
|
||||
'logging_enabled' => false,
|
||||
'storage' => [
|
||||
'type' => 'redis',
|
||||
'host' => 'localhost',
|
||||
'port' => 6379
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
876
fendx-framework/fendx-service/src/Security/DependencyChecker.php
Normal file
876
fendx-framework/fendx-service/src/Security/DependencyChecker.php
Normal file
@@ -0,0 +1,876 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Security;
|
||||
|
||||
use Fendx\Service\Security\Dependency\ComposerAnalyzer;
|
||||
use Fendx\Service\Security\Dependency\PackageChecker;
|
||||
use Fendx\Service\Security\Dependency\VulnerabilityDatabase;
|
||||
use Fendx\Service\Security\Dependency\LicenseChecker;
|
||||
use Fendx\Service\Security\Reporter\DependencyReporter;
|
||||
|
||||
class DependencyChecker
|
||||
{
|
||||
protected array $config = [];
|
||||
protected ComposerAnalyzer $composerAnalyzer;
|
||||
protected PackageChecker $packageChecker;
|
||||
protected VulnerabilityDatabase $vulnerabilityDb;
|
||||
protected LicenseChecker $licenseChecker;
|
||||
protected DependencyReporter $reporter;
|
||||
protected array $checkResults = [];
|
||||
protected array $packageCache = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->composerAnalyzer = new ComposerAnalyzer($this->config['composer_analyzer'] ?? []);
|
||||
$this->packageChecker = new PackageChecker($this->config['package_checker'] ?? []);
|
||||
$this->vulnerabilityDb = new VulnerabilityDatabase($this->config['vulnerability_db'] ?? []);
|
||||
$this->licenseChecker = new LicenseChecker($this->config['license_checker'] ?? []);
|
||||
$this->reporter = new DependencyReporter($this->config['reporter'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check composer dependencies for security issues.
|
||||
*/
|
||||
public function checkComposerDependencies(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['composer_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'composer_dependencies',
|
||||
'project_path' => $projectPath,
|
||||
'total_packages' => 0,
|
||||
'vulnerable_packages' => 0,
|
||||
'outdated_packages' => 0,
|
||||
'license_issues' => 0,
|
||||
'packages' => [],
|
||||
'vulnerabilities' => [],
|
||||
'security_score' => 100,
|
||||
'recommendations' => [],
|
||||
'check_duration' => 0,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Analyze composer files
|
||||
$composerAnalysis = $this->composerAnalyzer->analyze($projectPath, $checkConfig);
|
||||
|
||||
if (!$composerAnalysis['valid']) {
|
||||
$result['error'] = $composerAnalysis['error'];
|
||||
return $result;
|
||||
}
|
||||
|
||||
$packages = $composerAnalysis['packages'];
|
||||
$result['total_packages'] = count($packages);
|
||||
|
||||
$allVulnerabilities = [];
|
||||
$packageResults = [];
|
||||
|
||||
foreach ($packages as $package => $packageInfo) {
|
||||
$packageResult = $this->checkSinglePackage($package, $packageInfo, $checkConfig);
|
||||
$packageResults[$package] = $packageResult;
|
||||
|
||||
// Collect vulnerabilities
|
||||
if (!empty($packageResult['vulnerabilities'])) {
|
||||
$allVulnerabilities = array_merge($allVulnerabilities, $packageResult['vulnerabilities']);
|
||||
}
|
||||
|
||||
// Count outdated packages
|
||||
if ($packageResult['outdated']) {
|
||||
$result['outdated_packages']++;
|
||||
}
|
||||
|
||||
// Count license issues
|
||||
if ($packageResult['license_issue']) {
|
||||
$result['license_issues']++;
|
||||
}
|
||||
}
|
||||
|
||||
$result['packages'] = $packageResults;
|
||||
$result['vulnerabilities'] = $this->analyzeVulnerabilities($allVulnerabilities);
|
||||
$result['vulnerable_packages'] = count(array_unique(array_column($allVulnerabilities, 'package')));
|
||||
|
||||
// Calculate security score
|
||||
$result['security_score'] = $this->calculateDependencySecurityScore($result);
|
||||
|
||||
// Generate recommendations
|
||||
$result['recommendations'] = $this->generateDependencyRecommendations($result);
|
||||
|
||||
$result['check_duration'] = microtime(true) - $startTime;
|
||||
|
||||
// Store result
|
||||
$this->checkResults[] = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check specific package for vulnerabilities.
|
||||
*/
|
||||
public function checkPackage(string $packageName, string $version, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['package_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'package' => $packageName,
|
||||
'version' => $version,
|
||||
'vulnerabilities' => [],
|
||||
'vulnerability_count' => 0,
|
||||
'severity_distribution' => [
|
||||
'critical' => 0,
|
||||
'high' => 0,
|
||||
'medium' => 0,
|
||||
'low' => 0
|
||||
],
|
||||
'outdated' => false,
|
||||
'latest_version' => $version,
|
||||
'license_compliant' => true,
|
||||
'license_issue' => null,
|
||||
'security_score' => 100,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Check cache first
|
||||
$cacheKey = $packageName . ':' . $version;
|
||||
if (isset($this->packageCache[$cacheKey])) {
|
||||
return $this->packageCache[$cacheKey];
|
||||
}
|
||||
|
||||
// Check for vulnerabilities
|
||||
$vulnerabilities = $this->vulnerabilityDb->checkPackage($packageName, $version);
|
||||
$result['vulnerabilities'] = $vulnerabilities;
|
||||
$result['vulnerability_count'] = count($vulnerabilities);
|
||||
|
||||
// Calculate severity distribution
|
||||
foreach ($vulnerabilities as $vuln) {
|
||||
$severity = $vuln['severity'];
|
||||
$result['severity_distribution'][$severity]++;
|
||||
}
|
||||
|
||||
// Check if package is outdated
|
||||
$latestVersion = $this->packageChecker->getLatestVersion($packageName);
|
||||
$result['latest_version'] = $latestVersion;
|
||||
$result['outdated'] = version_compare($version, $latestVersion, '<');
|
||||
|
||||
// Check license compliance
|
||||
$licenseCheck = $this->licenseChecker->checkLicense($packageName, $version, $checkConfig);
|
||||
$result['license_compliant'] = $licenseCheck['compliant'];
|
||||
$result['license_issue'] = $licenseCheck['issue'] ?? null;
|
||||
|
||||
// Calculate security score
|
||||
$result['security_score'] = $this->calculatePackageSecurityScore($result);
|
||||
|
||||
// Generate recommendations
|
||||
$result['recommendations'] = $this->generatePackageRecommendations($result);
|
||||
|
||||
// Cache result
|
||||
$this->packageCache[$cacheKey] = $result;
|
||||
$this->limitCacheSize();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for outdated dependencies.
|
||||
*/
|
||||
public function checkOutdatedDependencies(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['outdated_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'outdated_dependencies',
|
||||
'project_path' => $projectPath,
|
||||
'total_packages' => 0,
|
||||
'outdated_packages' => 0,
|
||||
'packages' => [],
|
||||
'update_recommendations' => [],
|
||||
'check_duration' => 0,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Get installed packages
|
||||
$packages = $this->composerAnalyzer->getInstalledPackages($projectPath);
|
||||
$result['total_packages'] = count($packages);
|
||||
|
||||
$outdatedPackages = [];
|
||||
|
||||
foreach ($packages as $package => $version) {
|
||||
$latestVersion = $this->packageChecker->getLatestVersion($package);
|
||||
|
||||
if (version_compare($version, $latestVersion, '<')) {
|
||||
$outdatedPackages[] = [
|
||||
'package' => $package,
|
||||
'current_version' => $version,
|
||||
'latest_version' => $latestVersion,
|
||||
'update_available' => true,
|
||||
'security_update' => $this->isSecurityUpdate($package, $version, $latestVersion),
|
||||
'breaking_changes' => $this->hasBreakingChanges($package, $version, $latestVersion)
|
||||
];
|
||||
$result['outdated_packages']++;
|
||||
}
|
||||
}
|
||||
|
||||
$result['packages'] = $outdatedPackages;
|
||||
$result['update_recommendations'] = $this->generateUpdateRecommendations($outdatedPackages);
|
||||
|
||||
$result['check_duration'] = microtime(true) - $startTime;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check license compliance.
|
||||
*/
|
||||
public function checkLicenseCompliance(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['license_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'license_compliance',
|
||||
'project_path' => $projectPath,
|
||||
'total_packages' => 0,
|
||||
'compliant_packages' => 0,
|
||||
'non_compliant_packages' => 0,
|
||||
'packages' => [],
|
||||
'license_summary' => [],
|
||||
'compliance_score' => 100,
|
||||
'recommendations' => [],
|
||||
'check_duration' => 0,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Get installed packages
|
||||
$packages = $this->composerAnalyzer->getInstalledPackages($projectPath);
|
||||
$result['total_packages'] = count($packages);
|
||||
|
||||
$licenseResults = [];
|
||||
$licenseSummary = [];
|
||||
|
||||
foreach ($packages as $package => $version) {
|
||||
$licenseCheck = $this->licenseChecker->checkLicense($package, $version, $checkConfig);
|
||||
|
||||
$licenseResults[$package] = [
|
||||
'package' => $package,
|
||||
'version' => $version,
|
||||
'license' => $licenseCheck['license'],
|
||||
'compliant' => $licenseCheck['compliant'],
|
||||
'issue' => $licenseCheck['issue'] ?? null,
|
||||
'risk_level' => $licenseCheck['risk_level'] ?? 'low'
|
||||
];
|
||||
|
||||
if ($licenseCheck['compliant']) {
|
||||
$result['compliant_packages']++;
|
||||
} else {
|
||||
$result['non_compliant_packages']++;
|
||||
}
|
||||
|
||||
// Update license summary
|
||||
$license = $licenseCheck['license'];
|
||||
if (!isset($licenseSummary[$license])) {
|
||||
$licenseSummary[$license] = [
|
||||
'count' => 0,
|
||||
'compliant' => 0,
|
||||
'non_compliant' => 0
|
||||
];
|
||||
}
|
||||
$licenseSummary[$license]['count']++;
|
||||
if ($licenseCheck['compliant']) {
|
||||
$licenseSummary[$license]['compliant']++;
|
||||
} else {
|
||||
$licenseSummary[$license]['non_compliant']++;
|
||||
}
|
||||
}
|
||||
|
||||
$result['packages'] = $licenseResults;
|
||||
$result['license_summary'] = $licenseSummary;
|
||||
$result['compliance_score'] = $this->calculateLicenseComplianceScore($result);
|
||||
$result['recommendations'] = $this->generateLicenseRecommendations($result);
|
||||
|
||||
$result['check_duration'] = microtime(true) - $startTime;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for supply chain security.
|
||||
*/
|
||||
public function checkSupplyChainSecurity(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['supply_chain_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'supply_chain_security',
|
||||
'project_path' => $projectPath,
|
||||
'total_packages' => 0,
|
||||
'risk_packages' => 0,
|
||||
'packages' => [],
|
||||
'supply_chain_risks' => [],
|
||||
'risk_score' => 0,
|
||||
'recommendations' => [],
|
||||
'check_duration' => 0,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Get installed packages
|
||||
$packages = $this->composerAnalyzer->getInstalledPackages($projectPath);
|
||||
$result['total_packages'] = count($packages);
|
||||
|
||||
$packageRisks = [];
|
||||
$supplyChainRisks = [];
|
||||
|
||||
foreach ($packages as $package => $version) {
|
||||
$riskAnalysis = $this->analyzePackageRisk($package, $version, $checkConfig);
|
||||
|
||||
$packageRisks[$package] = [
|
||||
'package' => $package,
|
||||
'version' => $version,
|
||||
'risk_score' => $riskAnalysis['risk_score'],
|
||||
'risk_factors' => $riskAnalysis['risk_factors'],
|
||||
'recommendations' => $riskAnalysis['recommendations']
|
||||
];
|
||||
|
||||
if ($riskAnalysis['risk_score'] > 50) {
|
||||
$result['risk_packages']++;
|
||||
$supplyChainRisks[] = [
|
||||
'package' => $package,
|
||||
'risk_type' => 'high_risk_package',
|
||||
'risk_score' => $riskAnalysis['risk_score'],
|
||||
'description' => "Package {$package} poses supply chain security risks"
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result['packages'] = $packageRisks;
|
||||
$result['supply_chain_risks'] = $supplyChainRisks;
|
||||
$result['risk_score'] = $this->calculateSupplyChainRiskScore($packageRisks);
|
||||
$result['recommendations'] = $this->generateSupplyChainRecommendations($result);
|
||||
|
||||
$result['check_duration'] = microtime(true) - $startTime;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate security report.
|
||||
*/
|
||||
public function getDependencyReport(array $results, string $format = 'html'): string
|
||||
{
|
||||
return $this->reporter->generate($results, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get check statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'total_checks' => count($this->checkResults),
|
||||
'packages_checked' => $this->countTotalPackagesChecked(),
|
||||
'vulnerabilities_found' => $this->countTotalVulnerabilities(),
|
||||
'average_security_score' => $this->calculateAverageSecurityScore(),
|
||||
'cache_size' => count($this->packageCache)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache and results.
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->packageCache = [];
|
||||
$this->checkResults = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check single package.
|
||||
*/
|
||||
protected function checkSinglePackage(string $package, array $packageInfo, array $config): array
|
||||
{
|
||||
return $this->checkPackage($package, $packageInfo['version'], $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze vulnerabilities.
|
||||
*/
|
||||
protected function analyzeVulnerabilities(array $vulnerabilities): array
|
||||
{
|
||||
$analyzed = [];
|
||||
|
||||
foreach ($vulnerabilities as $vuln) {
|
||||
$analyzed[] = [
|
||||
'package' => $vuln['package'],
|
||||
'version' => $vuln['version'],
|
||||
'advisory_id' => $vuln['advisory_id'],
|
||||
'title' => $vuln['title'],
|
||||
'severity' => $vuln['severity'],
|
||||
'cve_id' => $vuln['cve_id'] ?? null,
|
||||
'cvss_score' => $vuln['cvss_score'] ?? null,
|
||||
'description' => $vuln['description'],
|
||||
'affected_versions' => $vuln['affected_versions'] ?? [],
|
||||
'patched_versions' => $vuln['patched_versions'] ?? [],
|
||||
'recommendation' => $vuln['recommendation']
|
||||
];
|
||||
}
|
||||
|
||||
return $analyzed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate dependency security score.
|
||||
*/
|
||||
protected function calculateDependencySecurityScore(array $result): int
|
||||
{
|
||||
$score = 100;
|
||||
|
||||
// Deduct points for vulnerabilities
|
||||
foreach ($result['vulnerabilities'] as $vuln) {
|
||||
switch ($vuln['severity']) {
|
||||
case 'critical':
|
||||
$score -= 20;
|
||||
break;
|
||||
case 'high':
|
||||
$score -= 15;
|
||||
break;
|
||||
case 'medium':
|
||||
$score -= 8;
|
||||
break;
|
||||
case 'low':
|
||||
$score -= 3;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct points for outdated packages
|
||||
$score -= min(15, $result['outdated_packages'] * 2);
|
||||
|
||||
// Deduct points for license issues
|
||||
$score -= min(10, $result['license_issues'] * 3);
|
||||
|
||||
return max(0, $score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate package security score.
|
||||
*/
|
||||
protected function calculatePackageSecurityScore(array $packageResult): int
|
||||
{
|
||||
$score = 100;
|
||||
|
||||
// Deduct points for vulnerabilities
|
||||
foreach ($packageResult['vulnerabilities'] as $vuln) {
|
||||
switch ($vuln['severity']) {
|
||||
case 'critical':
|
||||
$score -= 30;
|
||||
break;
|
||||
case 'high':
|
||||
$score -= 20;
|
||||
break;
|
||||
case 'medium':
|
||||
$score -= 10;
|
||||
break;
|
||||
case 'low':
|
||||
$score -= 5;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct points for being outdated
|
||||
if ($packageResult['outdated']) {
|
||||
$score -= 10;
|
||||
}
|
||||
|
||||
// Deduct points for license issues
|
||||
if (!$packageResult['license_compliant']) {
|
||||
$score -= 15;
|
||||
}
|
||||
|
||||
return max(0, $score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate license compliance score.
|
||||
*/
|
||||
protected function calculateLicenseComplianceScore(array $result): int
|
||||
{
|
||||
if ($result['total_packages'] === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$complianceRate = $result['compliant_packages'] / $result['total_packages'];
|
||||
return (int) round($complianceRate * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate supply chain risk score.
|
||||
*/
|
||||
protected function calculateSupplyChainRiskScore(array $packageRisks): int
|
||||
{
|
||||
if (empty($packageRisks)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalRisk = 0;
|
||||
foreach ($packageRisks as $risk) {
|
||||
$totalRisk += $risk['risk_score'];
|
||||
}
|
||||
|
||||
return (int) round($totalRisk / count($packageRisks));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if update is security update.
|
||||
*/
|
||||
protected function isSecurityUpdate(string $package, string $currentVersion, string $latestVersion): bool
|
||||
{
|
||||
// Check if there are security vulnerabilities in current version
|
||||
$vulnerabilities = $this->vulnerabilityDb->checkPackage($package, $currentVersion);
|
||||
|
||||
foreach ($vulnerabilities as $vuln) {
|
||||
if (isset($vuln['patched_versions'])) {
|
||||
foreach ($vuln['patched_versions'] as $patchedVersion) {
|
||||
if (version_compare($latestVersion, $patchedVersion, '>=')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if update has breaking changes.
|
||||
*/
|
||||
protected function hasBreakingChanges(string $package, string $currentVersion, string $latestVersion): bool
|
||||
{
|
||||
// This would check package changelog or semantic versioning
|
||||
// For now, assume major version changes have breaking changes
|
||||
$currentMajor = explode('.', $currentVersion)[0];
|
||||
$latestMajor = explode('.', $latestVersion)[0];
|
||||
|
||||
return $currentMajor !== $latestMajor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze package risk.
|
||||
*/
|
||||
protected function analyzePackageRisk(string $package, string $version, array $config): array
|
||||
{
|
||||
$riskScore = 0;
|
||||
$riskFactors = [];
|
||||
$recommendations = [];
|
||||
|
||||
// Check for vulnerabilities
|
||||
$vulnerabilities = $this->vulnerabilityDb->checkPackage($package, $version);
|
||||
if (!empty($vulnerabilities)) {
|
||||
$riskScore += 30;
|
||||
$riskFactors[] = 'known_vulnerabilities';
|
||||
$recommendations[] = 'Update to patched version';
|
||||
}
|
||||
|
||||
// Check package popularity (less popular packages may be riskier)
|
||||
$popularity = $this->packageChecker->getPopularity($package);
|
||||
if ($popularity < 1000) {
|
||||
$riskScore += 15;
|
||||
$riskFactors[] = 'low_popularity';
|
||||
$recommendations[] = 'Consider more popular alternative';
|
||||
}
|
||||
|
||||
// Check maintenance status
|
||||
$lastUpdate = $this->packageChecker->getLastUpdate($package);
|
||||
$daysSinceUpdate = (time() - strtotime($lastUpdate)) / 86400;
|
||||
if ($daysSinceUpdate > 365) {
|
||||
$riskScore += 20;
|
||||
$riskFactors[] = 'inactive_maintenance';
|
||||
$recommendations[] = 'Package appears unmaintained';
|
||||
}
|
||||
|
||||
// Check number of dependencies
|
||||
$dependencies = $this->packageChecker->getDependencies($package, $version);
|
||||
if (count($dependencies) > 50) {
|
||||
$riskScore += 10;
|
||||
$riskFactors[] = 'high_dependency_count';
|
||||
$recommendations[] = 'Package has many dependencies increasing attack surface';
|
||||
}
|
||||
|
||||
return [
|
||||
'risk_score' => min(100, $riskScore),
|
||||
'risk_factors' => $riskFactors,
|
||||
'recommendations' => $recommendations
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dependency recommendations.
|
||||
*/
|
||||
protected function generateDependencyRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['vulnerable_packages'] > 0) {
|
||||
$recommendations[] = 'Update vulnerable packages to secure versions';
|
||||
}
|
||||
|
||||
if ($result['outdated_packages'] > 0) {
|
||||
$recommendations[] = 'Update outdated packages to latest stable versions';
|
||||
}
|
||||
|
||||
if ($result['license_issues'] > 0) {
|
||||
$recommendations[] = 'Review and resolve license compliance issues';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Implement automated dependency scanning in CI/CD';
|
||||
$recommendations[] = 'Subscribe to security advisories for used packages';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate package recommendations.
|
||||
*/
|
||||
protected function generatePackageRecommendations(array $packageResult): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!empty($packageResult['vulnerabilities'])) {
|
||||
$recommendations[] = 'Update to patched version to fix vulnerabilities';
|
||||
}
|
||||
|
||||
if ($packageResult['outdated']) {
|
||||
$recommendations[] = "Update from {$packageResult['version']} to {$packageResult['latest_version']}";
|
||||
}
|
||||
|
||||
if (!$packageResult['license_compliant']) {
|
||||
$recommendations[] = 'Review license compliance requirements';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate update recommendations.
|
||||
*/
|
||||
protected function generateUpdateRecommendations(array $outdatedPackages): array
|
||||
{
|
||||
$recommendations = [];
|
||||
$securityUpdates = [];
|
||||
$regularUpdates = [];
|
||||
|
||||
foreach ($outdatedPackages as $package) {
|
||||
if ($package['security_update']) {
|
||||
$securityUpdates[] = $package['package'];
|
||||
} else {
|
||||
$regularUpdates[] = $package['package'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($securityUpdates)) {
|
||||
$recommendations[] = 'URGENT: Update security packages: ' . implode(', ', $securityUpdates);
|
||||
}
|
||||
|
||||
if (!empty($regularUpdates)) {
|
||||
$recommendations[] = 'Update outdated packages: ' . implode(', ', array_slice($regularUpdates, 0, 10));
|
||||
if (count($regularUpdates) > 10) {
|
||||
$recommendations[] = '... and ' . (count($regularUpdates) - 10) . ' more packages';
|
||||
}
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate license recommendations.
|
||||
*/
|
||||
protected function generateLicenseRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['non_compliant_packages'] > 0) {
|
||||
$recommendations[] = 'Review non-compliant package licenses';
|
||||
$recommendations[] = 'Consider alternative packages with compatible licenses';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Document all package licenses in project documentation';
|
||||
$recommendations[] = 'Implement license checking in CI/CD pipeline';
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate supply chain recommendations.
|
||||
*/
|
||||
protected function generateSupplyChainRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['risk_packages'] > 0) {
|
||||
$recommendations[] = 'Review high-risk packages and consider alternatives';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Implement package signing verification';
|
||||
$recommendations[] = 'Use lock files to prevent dependency confusion attacks';
|
||||
$recommendations[] = 'Monitor package repositories for security updates';
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit cache size.
|
||||
*/
|
||||
protected function limitCacheSize(): void
|
||||
{
|
||||
$maxSize = $this->config['cache_size'] ?? 1000;
|
||||
|
||||
if (count($this->packageCache) > $maxSize) {
|
||||
$this->packageCache = array_slice($this->packageCache, -$maxSize, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total packages checked.
|
||||
*/
|
||||
protected function countTotalPackagesChecked(): int
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->checkResults as $result) {
|
||||
$total += $result['total_packages'] ?? 0;
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total vulnerabilities.
|
||||
*/
|
||||
protected function countTotalVulnerabilities(): int
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->checkResults as $result) {
|
||||
$total += $result['vulnerable_packages'] ?? 0;
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average security score.
|
||||
*/
|
||||
protected function calculateAverageSecurityScore(): float
|
||||
{
|
||||
if (empty($this->checkResults)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->checkResults as $result) {
|
||||
$scoreKey = $result['check_type'] === 'license_compliance' ? 'compliance_score' : 'security_score';
|
||||
$total += $result[$scoreKey] ?? 100;
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count > 0 ? $total / $count : 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'composer_check' => [
|
||||
'check_dev_dependencies' => false,
|
||||
'severity_threshold' => 'medium'
|
||||
],
|
||||
'package_check' => [
|
||||
'include_license_check' => true,
|
||||
'check_outdated' => true
|
||||
],
|
||||
'outdated_check' => [
|
||||
'include_pre_releases' => false
|
||||
],
|
||||
'license_check' => [
|
||||
'allowed_licenses' => ['MIT', 'Apache-2.0', 'BSD-3-Clause'],
|
||||
'forbidden_licenses' => ['GPL-3.0', 'AGPL-3.0']
|
||||
],
|
||||
'supply_chain_check' => [
|
||||
'check_popularity' => true,
|
||||
'check_maintenance' => true,
|
||||
'check_dependencies' => true
|
||||
],
|
||||
'cache_size' => 1000,
|
||||
'composer_analyzer' => [],
|
||||
'package_checker' => [],
|
||||
'vulnerability_db' => [],
|
||||
'license_checker' => [],
|
||||
'reporter' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dependency checker instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'composer_check' => [
|
||||
'severity_threshold' => 'high'
|
||||
],
|
||||
'license_check' => [
|
||||
'allowed_licenses' => ['MIT', 'Apache-2.0', 'BSD-3-Clause', 'GPL-3.0']
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'composer_check' => [
|
||||
'severity_threshold' => 'low',
|
||||
'check_dev_dependencies' => true
|
||||
],
|
||||
'supply_chain_check' => [
|
||||
'strict_mode' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
869
fendx-framework/fendx-service/src/Security/InputValidator.php
Normal file
869
fendx-framework/fendx-service/src/Security/InputValidator.php
Normal file
@@ -0,0 +1,869 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Security;
|
||||
|
||||
use Fendx\Service\Security\Validation\XssValidator;
|
||||
use Fendx\Service\Security\Validation\SqlInjectionValidator;
|
||||
use Fendx\Service\Security\Validation\PathTraversalValidator;
|
||||
use Fendx\Service\Security\Validation\CommandInjectionValidator;
|
||||
use Fendx\Service\Security\Validation\FileUploadValidator;
|
||||
use Fendx\Service\Security\Reporter\ValidationReporter;
|
||||
|
||||
class InputValidator
|
||||
{
|
||||
protected array $config = [];
|
||||
protected XssValidator $xssValidator;
|
||||
protected SqlInjectionValidator $sqlValidator;
|
||||
protected PathTraversalValidator $pathValidator;
|
||||
protected CommandInjectionValidator $commandValidator;
|
||||
protected FileUploadValidator $fileValidator;
|
||||
protected ValidationReporter $reporter;
|
||||
protected array $validationResults = [];
|
||||
protected array $testCases = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->xssValidator = new XssValidator($this->config['xss_validator'] ?? []);
|
||||
$this->sqlValidator = new SqlInjectionValidator($this->config['sql_validator'] ?? []);
|
||||
$this->pathValidator = new PathTraversalValidator($this->config['path_validator'] ?? []);
|
||||
$this->commandValidator = new CommandInjectionValidator($this->config['command_validator'] ?? []);
|
||||
$this->fileValidator = new FileUploadValidator($this->config['file_validator'] ?? []);
|
||||
$this->reporter = new ValidationReporter($this->config['reporter'] ?? []);
|
||||
|
||||
$this->initializeTestCases();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input against multiple security checks.
|
||||
*/
|
||||
public function validateInput(string $input, array $options = []): array
|
||||
{
|
||||
$validationConfig = array_merge($this->config['input_validation'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'input' => $input,
|
||||
'input_length' => strlen($input),
|
||||
'validation_type' => 'comprehensive',
|
||||
'vulnerabilities_found' => 0,
|
||||
'vulnerabilities' => [],
|
||||
'severity_distribution' => [
|
||||
'critical' => 0,
|
||||
'high' => 0,
|
||||
'medium' => 0,
|
||||
'low' => 0
|
||||
],
|
||||
'validation_passed' => true,
|
||||
'sanitized_input' => $input,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$vulnerabilities = [];
|
||||
$sanitizedInput = $input;
|
||||
|
||||
// XSS validation
|
||||
if ($validationConfig['check_xss'] ?? true) {
|
||||
$xssResult = $this->xssValidator->validate($input, $validationConfig);
|
||||
if (!$xssResult['safe']) {
|
||||
$vulnerabilities[] = [
|
||||
'type' => 'xss',
|
||||
'severity' => $xssResult['severity'],
|
||||
'description' => $xssResult['description'],
|
||||
'pattern_detected' => $xssResult['pattern'] ?? null,
|
||||
'recommendation' => $xssResult['recommendation']
|
||||
];
|
||||
$sanitizedInput = $xssResult['sanitized'] ?? $sanitizedInput;
|
||||
}
|
||||
}
|
||||
|
||||
// SQL Injection validation
|
||||
if ($validationConfig['check_sql_injection'] ?? true) {
|
||||
$sqlResult = $this->sqlValidator->validate($input, $validationConfig);
|
||||
if (!$sqlResult['safe']) {
|
||||
$vulnerabilities[] = [
|
||||
'type' => 'sql_injection',
|
||||
'severity' => $sqlResult['severity'],
|
||||
'description' => $sqlResult['description'],
|
||||
'pattern_detected' => $sqlResult['pattern'] ?? null,
|
||||
'recommendation' => $sqlResult['recommendation']
|
||||
];
|
||||
$sanitizedInput = $sqlResult['sanitized'] ?? $sanitizedInput;
|
||||
}
|
||||
}
|
||||
|
||||
// Path Traversal validation
|
||||
if ($validationConfig['check_path_traversal'] ?? true) {
|
||||
$pathResult = $this->pathValidator->validate($input, $validationConfig);
|
||||
if (!$pathResult['safe']) {
|
||||
$vulnerabilities[] = [
|
||||
'type' => 'path_traversal',
|
||||
'severity' => $pathResult['severity'],
|
||||
'description' => $pathResult['description'],
|
||||
'pattern_detected' => $pathResult['pattern'] ?? null,
|
||||
'recommendation' => $pathResult['recommendation']
|
||||
];
|
||||
$sanitizedInput = $pathResult['sanitized'] ?? $sanitizedInput;
|
||||
}
|
||||
}
|
||||
|
||||
// Command Injection validation
|
||||
if ($validationConfig['check_command_injection'] ?? true) {
|
||||
$commandResult = $this->commandValidator->validate($input, $validationConfig);
|
||||
if (!$commandResult['safe']) {
|
||||
$vulnerabilities[] = [
|
||||
'type' => 'command_injection',
|
||||
'severity' => $commandResult['severity'],
|
||||
'description' => $commandResult['description'],
|
||||
'pattern_detected' => $commandResult['pattern'] ?? null,
|
||||
'recommendation' => $commandResult['recommendation']
|
||||
];
|
||||
$sanitizedInput = $commandResult['sanitized'] ?? $sanitizedInput;
|
||||
}
|
||||
}
|
||||
|
||||
$result['vulnerabilities'] = $vulnerabilities;
|
||||
$result['vulnerabilities_found'] = count($vulnerabilities);
|
||||
$result['validation_passed'] = empty($vulnerabilities);
|
||||
$result['sanitized_input'] = $sanitizedInput;
|
||||
|
||||
// Calculate severity distribution
|
||||
foreach ($vulnerabilities as $vuln) {
|
||||
$severity = $vuln['severity'];
|
||||
$result['severity_distribution'][$severity]++;
|
||||
}
|
||||
|
||||
// Generate recommendations
|
||||
$result['recommendations'] = $this->generateValidationRecommendations($vulnerabilities);
|
||||
|
||||
// Store result
|
||||
$this->validationResults[] = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test input validation with predefined test cases.
|
||||
*/
|
||||
public function testInputValidation(array $options = []): array
|
||||
{
|
||||
$testConfig = array_merge($this->config['validation_testing'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'test_type' => 'input_validation_security',
|
||||
'test_cases_run' => 0,
|
||||
'vulnerabilities_detected' => 0,
|
||||
'test_results' => [],
|
||||
'vulnerability_types' => [],
|
||||
'security_score' => 100,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
};
|
||||
|
||||
$testResults = [];
|
||||
$allVulnerabilities = [];
|
||||
$vulnerabilityTypes = [];
|
||||
|
||||
foreach ($this->testCases as $testCase) {
|
||||
if (isset($testConfig['categories']) && !in_array($testCase['category'], $testConfig['categories'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$testResult = $this->runTestCase($testCase, $testConfig);
|
||||
$testResults[] = $testResult;
|
||||
|
||||
if (!$testResult['validation_passed']) {
|
||||
$allVulnerabilities = array_merge($allVulnerabilities, $testResult['vulnerabilities']);
|
||||
|
||||
foreach ($testResult['vulnerabilities'] as $vuln) {
|
||||
$vulnerabilityTypes[$vuln['type']] = ($vulnerabilityTypes[$vuln['type']] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result['test_cases_run'] = count($testResults);
|
||||
$result['vulnerabilities_detected'] = count($allVulnerabilities);
|
||||
$result['test_results'] = $testResults;
|
||||
$result['vulnerability_types'] = $vulnerabilityTypes;
|
||||
$result['security_score'] = $this->calculateTestSecurityScore($testResults);
|
||||
$result['recommendations'] = $this->generateTestRecommendations($allVulnerabilities);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file upload security.
|
||||
*/
|
||||
public function validateFileUpload(array $fileData, array $options = []): array
|
||||
{
|
||||
$validationConfig = array_merge($this->config['file_validation'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'validation_type' => 'file_upload_security',
|
||||
'file_info' => [
|
||||
'name' => $fileData['name'] ?? '',
|
||||
'size' => $fileData['size'] ?? 0,
|
||||
'type' => $fileData['type'] ?? '',
|
||||
'tmp_name' => $fileData['tmp_name'] ?? ''
|
||||
],
|
||||
'security_issues' => 0,
|
||||
'issues' => [],
|
||||
'validation_passed' => true,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$fileResult = $this->fileValidator->validate($fileData, $validationConfig);
|
||||
|
||||
$result['issues'] = $fileResult['issues'];
|
||||
$result['security_issues'] = count($fileResult['issues']);
|
||||
$result['validation_passed'] = $fileResult['safe'];
|
||||
$result['recommendations'] = $fileResult['recommendations'] ?? [];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API endpoint input validation.
|
||||
*/
|
||||
public function testApiEndpoint(string $endpoint, array $testPayloads, array $options = []): array
|
||||
{
|
||||
$testConfig = array_merge($this->config['api_testing'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'test_type' => 'api_input_validation',
|
||||
'endpoint' => $endpoint,
|
||||
'payloads_tested' => count($testPayloads),
|
||||
'vulnerabilities_found' => 0,
|
||||
'test_results' => [],
|
||||
'security_score' => 100,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$testResults = [];
|
||||
$allVulnerabilities = [];
|
||||
|
||||
foreach ($testPayloads as $payload) {
|
||||
$apiResult = $this->testApiPayload($endpoint, $payload, $testConfig);
|
||||
$testResults[] = $apiResult;
|
||||
|
||||
if (!$apiResult['validation_passed']) {
|
||||
$allVulnerabilities = array_merge($allVulnerabilities, $apiResult['vulnerabilities']);
|
||||
}
|
||||
}
|
||||
|
||||
$result['test_results'] = $testResults;
|
||||
$result['vulnerabilities_found'] = count($allVulnerabilities);
|
||||
$result['security_score'] = $this->calculateApiSecurityScore($testResults);
|
||||
$result['recommendations'] = $this->generateApiRecommendations($allVulnerabilities);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate form data security.
|
||||
*/
|
||||
public function validateFormData(array $formData, array $fieldRules = [], array $options = []): array
|
||||
{
|
||||
$validationConfig = array_merge($this->config['form_validation'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'validation_type' => 'form_data_security',
|
||||
'fields_validated' => count($formData),
|
||||
'vulnerabilities_found' => 0,
|
||||
'field_results' => [],
|
||||
'validation_passed' => true,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$fieldResults = [];
|
||||
$allVulnerabilities = [];
|
||||
|
||||
foreach ($formData as $fieldName => $fieldValue) {
|
||||
if (!is_string($fieldValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldConfig = $fieldRules[$fieldName] ?? [];
|
||||
$fieldValidation = array_merge($validationConfig, $fieldConfig);
|
||||
|
||||
$validationResult = $this->validateInput($fieldValue, $fieldValidation);
|
||||
$validationResult['field_name'] = $fieldName;
|
||||
|
||||
$fieldResults[$fieldName] = $validationResult;
|
||||
|
||||
if (!$validationResult['validation_passed']) {
|
||||
$allVulnerabilities = array_merge($allVulnerabilities, $validationResult['vulnerabilities']);
|
||||
}
|
||||
}
|
||||
|
||||
$result['field_results'] = $fieldResults;
|
||||
$result['vulnerabilities_found'] = count($allVulnerabilities);
|
||||
$result['validation_passed'] = empty($allVulnerabilities);
|
||||
$result['recommendations'] = $this->generateFormRecommendations($fieldResults);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate validation report.
|
||||
*/
|
||||
public function getValidationReport(array $results, string $format = 'html'): string
|
||||
{
|
||||
return $this->reporter->generate($results, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'total_validations' => count($this->validationResults),
|
||||
'vulnerabilities_detected' => $this->countTotalVulnerabilities(),
|
||||
'vulnerability_types' => $this->getVulnerabilityTypes(),
|
||||
'average_security_score' => $this->calculateAverageSecurityScore(),
|
||||
'test_cases_available' => count($this->testCases)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom test case.
|
||||
*/
|
||||
public function addTestCase(array $testCase): void
|
||||
{
|
||||
$this->testCases[] = $testCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear validation results.
|
||||
*/
|
||||
public function clearResults(): void
|
||||
{
|
||||
$this->validationResults = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run single test case.
|
||||
*/
|
||||
protected function runTestCase(array $testCase, array $config): array
|
||||
{
|
||||
$result = [
|
||||
'test_name' => $testCase['name'],
|
||||
'category' => $testCase['category'],
|
||||
'input' => $testCase['input'],
|
||||
'expected_result' => $testCase['expected'] ?? 'vulnerable',
|
||||
'validation_passed' => false,
|
||||
'vulnerabilities' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$validationResult = $this->validateInput($testCase['input'], $config);
|
||||
|
||||
$result['validation_passed'] = $validationResult['validation_passed'];
|
||||
$result['vulnerabilities'] = $validationResult['vulnerabilities'];
|
||||
|
||||
// Check if test result matches expectation
|
||||
if ($testCase['expected'] === 'safe') {
|
||||
$result['test_passed'] = $validationResult['validation_passed'];
|
||||
} else {
|
||||
$result['test_passed'] = !$validationResult['validation_passed'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API payload.
|
||||
*/
|
||||
protected function testApiPayload(string $endpoint, array $payload, array $config): array
|
||||
{
|
||||
$result = [
|
||||
'payload' => $payload,
|
||||
'validation_passed' => true,
|
||||
'vulnerabilities' => [],
|
||||
'response_status' => null,
|
||||
'response_body' => null,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
try {
|
||||
// Make API request
|
||||
$response = $this->makeApiRequest($endpoint, $payload, $config);
|
||||
|
||||
$result['response_status'] = $response['status_code'];
|
||||
$result['response_body'] = $response['body'];
|
||||
|
||||
// Validate response for security indicators
|
||||
if ($response['status_code'] >= 500) {
|
||||
$result['vulnerabilities'][] = [
|
||||
'type' => 'server_error',
|
||||
'severity' => 'medium',
|
||||
'description' => 'Server error may indicate injection attempt',
|
||||
'recommendation' => 'Review server logs for injection attempts'
|
||||
];
|
||||
$result['validation_passed'] = false;
|
||||
}
|
||||
|
||||
// Validate payload fields
|
||||
foreach ($payload as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$validationResult = $this->validateInput($value, $config);
|
||||
if (!$validationResult['validation_passed']) {
|
||||
$result['vulnerabilities'] = array_merge($result['vulnerabilities'], $validationResult['vulnerabilities']);
|
||||
$result['validation_passed'] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$result['vulnerabilities'][] = [
|
||||
'type' => 'request_error',
|
||||
'severity' => 'low',
|
||||
'description' => 'Request failed: ' . $e->getMessage(),
|
||||
'recommendation' => 'Check API endpoint configuration'
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API request.
|
||||
*/
|
||||
protected function makeApiRequest(string $endpoint, array $payload, array $config): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $endpoint,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json'
|
||||
],
|
||||
CURLOPT_TIMEOUT => $config['timeout'] ?? 30,
|
||||
CURLOPT_SSL_VERIFYPEER => $config['verify_ssl'] ?? true
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new \RuntimeException("API request failed: {$error}");
|
||||
}
|
||||
|
||||
return [
|
||||
'status_code' => $status,
|
||||
'body' => $response
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate test security score.
|
||||
*/
|
||||
protected function calculateTestSecurityScore(array $testResults): int
|
||||
{
|
||||
if (empty($testResults)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$totalScore = 0;
|
||||
$passedTests = 0;
|
||||
|
||||
foreach ($testResults as $test) {
|
||||
if ($test['test_passed'] ?? false) {
|
||||
$passedTests++;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) round(($passedTests / count($testResults)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate API security score.
|
||||
*/
|
||||
protected function calculateApiSecurityScore(array $testResults): int
|
||||
{
|
||||
if (empty($testResults)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$secureTests = 0;
|
||||
|
||||
foreach ($testResults as $test) {
|
||||
if ($test['validation_passed']) {
|
||||
$secureTests++;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) round(($secureTests / count($testResults)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate validation recommendations.
|
||||
*/
|
||||
protected function generateValidationRecommendations(array $vulnerabilities): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
foreach ($vulnerabilities as $vuln) {
|
||||
if (isset($vuln['recommendation'])) {
|
||||
$recommendations[] = $vuln['recommendation'];
|
||||
}
|
||||
}
|
||||
|
||||
// Add general recommendations based on vulnerability types
|
||||
$types = array_unique(array_column($vulnerabilities, 'type'));
|
||||
|
||||
if (in_array('xss', $types)) {
|
||||
$recommendations[] = 'Implement proper output encoding to prevent XSS';
|
||||
}
|
||||
|
||||
if (in_array('sql_injection', $types)) {
|
||||
$recommendations[] = 'Use parameterized queries to prevent SQL injection';
|
||||
}
|
||||
|
||||
if (in_array('path_traversal', $types)) {
|
||||
$recommendations[] = 'Validate and sanitize file paths to prevent directory traversal';
|
||||
}
|
||||
|
||||
if (in_array('command_injection', $types)) {
|
||||
$recommendations[] = 'Avoid executing user input as system commands';
|
||||
}
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate test recommendations.
|
||||
*/
|
||||
protected function generateTestRecommendations(array $vulnerabilities): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!empty($vulnerabilities)) {
|
||||
$recommendations[] = 'Implement comprehensive input validation';
|
||||
$recommendations[] = 'Use security-focused validation libraries';
|
||||
$recommendations[] = 'Regularly test input validation with security test cases';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Implement automated security testing in CI/CD pipeline';
|
||||
$recommendations[] = 'Train developers on secure coding practices';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate API recommendations.
|
||||
*/
|
||||
protected function generateApiRecommendations(array $vulnerabilities): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!empty($vulnerabilities)) {
|
||||
$recommendations[] = 'Implement API input validation middleware';
|
||||
$recommendations[] = 'Use API security testing tools';
|
||||
$recommendations[] = 'Implement rate limiting to prevent brute force attacks';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Use API gateway with security features';
|
||||
$recommendations[] = 'Implement proper API authentication and authorization';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate form recommendations.
|
||||
*/
|
||||
protected function generateFormRecommendations(array $fieldResults): array
|
||||
{
|
||||
$recommendations = [];
|
||||
$vulnerableFields = [];
|
||||
|
||||
foreach ($fieldResults as $fieldName => $fieldResult) {
|
||||
if (!$fieldResult['validation_passed']) {
|
||||
$vulnerableFields[] = $fieldName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($vulnerableFields)) {
|
||||
$recommendations[] = 'Fix validation issues in fields: ' . implode(', ', $vulnerableFields);
|
||||
$recommendations[] = 'Implement client-side and server-side validation';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Use CSRF protection for forms';
|
||||
$recommendations[] = 'Implement proper form field sanitization';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total vulnerabilities.
|
||||
*/
|
||||
protected function countTotalVulnerabilities(): int
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->validationResults as $result) {
|
||||
$total += $result['vulnerabilities_found'] ?? 0;
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vulnerability types.
|
||||
*/
|
||||
protected function getVulnerabilityTypes(): array
|
||||
{
|
||||
$types = [];
|
||||
|
||||
foreach ($this->validationResults as $result) {
|
||||
if (isset($result['vulnerabilities'])) {
|
||||
foreach ($result['vulnerabilities'] as $vuln) {
|
||||
$types[$vuln['type']] = ($types[$vuln['type']] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average security score.
|
||||
*/
|
||||
protected function calculateAverageSecurityScore(): float
|
||||
{
|
||||
if (empty($this->validationResults)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$totalScore = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->validationResults as $result) {
|
||||
if (isset($result['security_score'])) {
|
||||
$totalScore += $result['security_score'];
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count > 0 ? $totalScore / $count : 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test cases.
|
||||
*/
|
||||
protected function initializeTestCases(): void
|
||||
{
|
||||
$this->testCases = [
|
||||
// XSS test cases
|
||||
[
|
||||
'name' => 'Basic XSS Script Tag',
|
||||
'category' => 'xss',
|
||||
'input' => '<script>alert("XSS")</script>',
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
[
|
||||
'name' => 'XSS with Event Handler',
|
||||
'category' => 'xss',
|
||||
'input' => '<img src="x" onerror="alert(\'XSS\')">',
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
[
|
||||
'name' => 'XSS with JavaScript Protocol',
|
||||
'category' => 'xss',
|
||||
'input' => 'javascript:alert("XSS")',
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
|
||||
// SQL Injection test cases
|
||||
[
|
||||
'name' => 'Basic SQL Injection',
|
||||
'category' => 'sql_injection',
|
||||
'input' => "' OR '1'='1",
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
[
|
||||
'name' => 'SQL Injection with UNION',
|
||||
'category' => 'sql_injection',
|
||||
'input' => "' UNION SELECT * FROM users--",
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
[
|
||||
'name' => 'SQL Injection with Comment',
|
||||
'category' => 'sql_injection',
|
||||
'input' => "admin'--",
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
|
||||
// Path Traversal test cases
|
||||
[
|
||||
'name' => 'Basic Path Traversal',
|
||||
'category' => 'path_traversal',
|
||||
'input' => '../../../etc/passwd',
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
[
|
||||
'name' => 'Path Traversal with URL Encoding',
|
||||
'category' => 'path_traversal',
|
||||
'input' => '..%2F..%2F..%2Fetc%2Fpasswd',
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
[
|
||||
'name' => 'Path Traversal with Null Byte',
|
||||
'category' => 'path_traversal',
|
||||
'input' => '../../../etc/passwd%00',
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
|
||||
// Command Injection test cases
|
||||
[
|
||||
'name' => 'Basic Command Injection',
|
||||
'category' => 'command_injection',
|
||||
'input' => 'file.txt; ls -la',
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
[
|
||||
'name' => 'Command Injection with Pipe',
|
||||
'category' => 'command_injection',
|
||||
'input' => 'file.txt | cat /etc/passwd',
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
[
|
||||
'name' => 'Command Injection with Backticks',
|
||||
'category' => 'command_injection',
|
||||
'input' => '`cat /etc/passwd`',
|
||||
'expected' => 'vulnerable'
|
||||
],
|
||||
|
||||
// Safe inputs
|
||||
[
|
||||
'name' => 'Safe Text Input',
|
||||
'category' => 'safe',
|
||||
'input' => 'Hello, World!',
|
||||
'expected' => 'safe'
|
||||
],
|
||||
[
|
||||
'name' => 'Safe Email Input',
|
||||
'category' => 'safe',
|
||||
'input' => 'user@example.com',
|
||||
'expected' => 'safe'
|
||||
],
|
||||
[
|
||||
'name' => 'Safe Numeric Input',
|
||||
'category' => 'safe',
|
||||
'input' => '12345',
|
||||
'expected' => 'safe'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'input_validation' => [
|
||||
'check_xss' => true,
|
||||
'check_sql_injection' => true,
|
||||
'check_path_traversal' => true,
|
||||
'check_command_injection' => true,
|
||||
'sanitize_output' => true
|
||||
],
|
||||
'validation_testing' => [
|
||||
'categories' => ['xss', 'sql_injection', 'path_traversal', 'command_injection', 'safe']
|
||||
],
|
||||
'file_validation' => [
|
||||
'allowed_extensions' => ['jpg', 'png', 'gif', 'pdf', 'doc', 'docx'],
|
||||
'max_file_size' => 10 * 1024 * 1024, // 10MB
|
||||
'check_mime_type' => true,
|
||||
'scan_for_malware' => false
|
||||
],
|
||||
'api_testing' => [
|
||||
'timeout' => 30,
|
||||
'verify_ssl' => true,
|
||||
'follow_redirects' => true
|
||||
],
|
||||
'form_validation' => [
|
||||
'require_csrf_token' => true,
|
||||
'validate_all_fields' => true
|
||||
],
|
||||
'xss_validator' => [],
|
||||
'sql_validator' => [],
|
||||
'path_validator' => [],
|
||||
'command_validator' => [],
|
||||
'file_validator' => [],
|
||||
'reporter' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create input validator instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'input_validation' => [
|
||||
'sanitize_output' => false // Keep original input for debugging
|
||||
],
|
||||
'file_validation' => [
|
||||
'max_file_size' => 50 * 1024 * 1024 // 50MB for development
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'input_validation' => [
|
||||
'sanitize_output' => true,
|
||||
'strict_mode' => true
|
||||
],
|
||||
'file_validation' => [
|
||||
'max_file_size' => 5 * 1024 * 1024, // 5MB for production
|
||||
'scan_for_malware' => true
|
||||
],
|
||||
'validation_testing' => [
|
||||
'strict_mode' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Security;
|
||||
|
||||
use Fendx\Service\Security\Permission\RoleValidator;
|
||||
use Fendx\Service\Security\Permission\AccessControlValidator;
|
||||
use Fendx\Service\Security\Permission\AuthenticationValidator;
|
||||
use Fendx\Service\Security\Permission\AuthorizationValidator;
|
||||
use Fendx\Service\Security\Permission\SessionValidator;
|
||||
use Fendx\Service\Security\Reporter\PermissionReporter;
|
||||
|
||||
class PermissionValidator
|
||||
{
|
||||
protected array $config = [];
|
||||
protected RoleValidator $roleValidator;
|
||||
protected AccessControlValidator $accessValidator;
|
||||
protected AuthenticationValidator $authValidator;
|
||||
protected AuthorizationValidator $authorizationValidator;
|
||||
protected SessionValidator $sessionValidator;
|
||||
protected PermissionReporter $reporter;
|
||||
protected array $validationResults = [];
|
||||
protected array $userRoles = [];
|
||||
protected array $permissions = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->roleValidator = new RoleValidator($this->config['role_validator'] ?? []);
|
||||
$this->accessValidator = new AccessControlValidator($this->config['access_validator'] ?? []);
|
||||
$this->authValidator = new AuthenticationValidator($this->config['auth_validator'] ?? []);
|
||||
$this->authorizationValidator = new AuthorizationValidator($this->config['authorization_validator'] ?? []);
|
||||
$this->sessionValidator = new SessionValidator($this->config['session_validator'] ?? []);
|
||||
$this->reporter = new PermissionReporter($this->config['reporter'] ?? []);
|
||||
|
||||
$this->initializePermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user permissions.
|
||||
*/
|
||||
public function validatePermissions(int $userId, string $resource, string $action, array $options = []): array
|
||||
{
|
||||
$validationConfig = array_merge($this->config['permission_validation'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'validation_type' => 'user_permission',
|
||||
'user_id' => $userId,
|
||||
'resource' => $resource,
|
||||
'action' => $action,
|
||||
'access_granted' => false,
|
||||
'validation_steps' => [],
|
||||
'security_issues' => [],
|
||||
'user_roles' => [],
|
||||
'effective_permissions' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$validationSteps = [];
|
||||
$securityIssues = [];
|
||||
|
||||
// Step 1: Validate user authentication
|
||||
$authResult = $this->authValidator->validateUser($userId, $validationConfig);
|
||||
$validationSteps['authentication'] = $authResult;
|
||||
|
||||
if (!$authResult['authenticated']) {
|
||||
$result['access_granted'] = false;
|
||||
$securityIssues[] = [
|
||||
'type' => 'authentication_failed',
|
||||
'severity' => 'critical',
|
||||
'description' => $authResult['reason'] ?? 'User not authenticated',
|
||||
'recommendation' => 'Ensure proper user authentication before permission check'
|
||||
];
|
||||
}
|
||||
|
||||
// Step 2: Get user roles
|
||||
$userRoles = $this->roleValidator->getUserRoles($userId, $validationConfig);
|
||||
$result['user_roles'] = $userRoles;
|
||||
$validationSteps['roles'] = ['roles' => $userRoles, 'count' => count($userRoles)];
|
||||
|
||||
// Step 3: Validate session
|
||||
$sessionResult = $this->sessionValidator->validateSession($userId, $validationConfig);
|
||||
$validationSteps['session'] = $sessionResult;
|
||||
|
||||
if (!$sessionResult['valid']) {
|
||||
$securityIssues[] = [
|
||||
'type' => 'session_invalid',
|
||||
'severity' => 'high',
|
||||
'description' => $sessionResult['reason'] ?? 'Invalid session',
|
||||
'recommendation' => 'Implement proper session validation'
|
||||
];
|
||||
}
|
||||
|
||||
// Step 4: Check access control
|
||||
if ($authResult['authenticated'] && $sessionResult['valid']) {
|
||||
$accessResult = $this->accessValidator->checkAccess($userId, $resource, $action, $userRoles, $validationConfig);
|
||||
$validationSteps['access_control'] = $accessResult;
|
||||
$result['access_granted'] = $accessResult['granted'];
|
||||
$result['effective_permissions'] = $accessResult['effective_permissions'] ?? [];
|
||||
|
||||
if (!$accessResult['granted']) {
|
||||
$securityIssues[] = [
|
||||
'type' => 'access_denied',
|
||||
'severity' => 'medium',
|
||||
'description' => $accessResult['reason'] ?? 'Access denied',
|
||||
'recommendation' => 'Review user permissions and role assignments'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Validate authorization
|
||||
if ($result['access_granted']) {
|
||||
$authzResult = $this->authorizationValidator->validateAuthorization($userId, $resource, $action, $validationConfig);
|
||||
$validationSteps['authorization'] = $authzResult;
|
||||
|
||||
if (!$authzResult['authorized']) {
|
||||
$result['access_granted'] = false;
|
||||
$securityIssues[] = [
|
||||
'type' => 'authorization_failed',
|
||||
'severity' => 'high',
|
||||
'description' => $authzResult['reason'] ?? 'Authorization failed',
|
||||
'recommendation' => 'Review authorization policies and implementations'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result['validation_steps'] = $validationSteps;
|
||||
$result['security_issues'] = $securityIssues;
|
||||
$result['recommendations'] = $this->generatePermissionRecommendations($securityIssues);
|
||||
|
||||
// Store result
|
||||
$this->validationResults[] = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test role-based access control.
|
||||
*/
|
||||
public function testRoleBasedAccessControl(array $testCases, array $options = []): array
|
||||
{
|
||||
$testConfig = array_merge($this->config['rbac_testing'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'test_type' => 'role_based_access_control',
|
||||
'test_cases_run' => count($testCases),
|
||||
'tests_passed' => 0,
|
||||
'tests_failed' => 0,
|
||||
'security_issues' => [],
|
||||
'test_results' => [],
|
||||
'role_coverage' => [],
|
||||
'security_score' => 100,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$testResults = [];
|
||||
$securityIssues = [];
|
||||
$roleCoverage = [];
|
||||
$passedTests = 0;
|
||||
|
||||
foreach ($testCases as $testCase) {
|
||||
$testResult = $this->runRbacTestCase($testCase, $testConfig);
|
||||
$testResults[] = $testResult;
|
||||
|
||||
if ($testResult['test_passed']) {
|
||||
$passedTests++;
|
||||
} else {
|
||||
$securityIssues = array_merge($securityIssues, $testResult['security_issues']);
|
||||
}
|
||||
|
||||
// Track role coverage
|
||||
if (isset($testCase['role'])) {
|
||||
$role = $testCase['role'];
|
||||
if (!isset($roleCoverage[$role])) {
|
||||
$roleCoverage[$role] = ['tested' => 0, 'passed' => 0];
|
||||
}
|
||||
$roleCoverage[$role]['tested']++;
|
||||
if ($testResult['test_passed']) {
|
||||
$roleCoverage[$role]['passed']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result['tests_passed'] = $passedTests;
|
||||
$result['tests_failed'] = count($testCases) - $passedTests;
|
||||
$result['security_issues'] = $securityIssues;
|
||||
$result['test_results'] = $testResults;
|
||||
$result['role_coverage'] = $roleCoverage;
|
||||
$result['security_score'] = $this->calculateRbacSecurityScore($testResults);
|
||||
$result['recommendations'] = $this->generateRbacRecommendations($securityIssues, $roleCoverage);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API endpoint permissions.
|
||||
*/
|
||||
public function validateApiPermissions(string $endpoint, string $method, int $userId, array $options = []): array
|
||||
{
|
||||
$validationConfig = array_merge($this->config['api_permission_validation'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'validation_type' => 'api_permission',
|
||||
'endpoint' => $endpoint,
|
||||
'method' => $method,
|
||||
'user_id' => $userId,
|
||||
'access_granted' => false,
|
||||
'validation_steps' => [],
|
||||
'security_issues' => [],
|
||||
'rate_limit_status' => [],
|
||||
'cors_status' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
};
|
||||
|
||||
$validationSteps = [];
|
||||
$securityIssues = [];
|
||||
|
||||
// Step 1: Validate API authentication
|
||||
$apiAuthResult = $this->authValidator->validateApiAuthentication($endpoint, $method, $userId, $validationConfig);
|
||||
$validationSteps['api_authentication'] = $apiAuthResult;
|
||||
|
||||
if (!$apiAuthResult['authenticated']) {
|
||||
$securityIssues[] = [
|
||||
'type' => 'api_authentication_failed',
|
||||
'severity' => 'critical',
|
||||
'description' => $apiAuthResult['reason'] ?? 'API authentication failed',
|
||||
'recommendation' => 'Implement proper API authentication mechanisms'
|
||||
];
|
||||
}
|
||||
|
||||
// Step 2: Check rate limiting
|
||||
$rateLimitResult = $this->accessValidator->checkRateLimit($userId, $endpoint, $validationConfig);
|
||||
$validationSteps['rate_limit'] = $rateLimitResult;
|
||||
$result['rate_limit_status'] = $rateLimitResult;
|
||||
|
||||
if (!$rateLimitResult['allowed']) {
|
||||
$securityIssues[] = [
|
||||
'type' => 'rate_limit_exceeded',
|
||||
'severity' => 'medium',
|
||||
'description' => 'Rate limit exceeded',
|
||||
'recommendation' => 'Implement appropriate rate limiting for API endpoints'
|
||||
];
|
||||
}
|
||||
|
||||
// Step 3: Validate CORS
|
||||
$corsResult = $this->accessValidator->validateCors($endpoint, $method, $validationConfig);
|
||||
$validationSteps['cors'] = $corsResult;
|
||||
$result['cors_status'] = $corsResult;
|
||||
|
||||
if (!$corsResult['valid']) {
|
||||
$securityIssues[] = [
|
||||
'type' => 'cors_invalid',
|
||||
'severity' => 'low',
|
||||
'description' => $corsResult['reason'] ?? 'CORS validation failed',
|
||||
'recommendation' => 'Configure proper CORS policies'
|
||||
];
|
||||
}
|
||||
|
||||
// Step 4: Check endpoint permissions
|
||||
if ($apiAuthResult['authenticated'] && $rateLimitResult['allowed']) {
|
||||
$resource = $this->extractResourceFromEndpoint($endpoint);
|
||||
$permissionResult = $this->validatePermissions($userId, $resource, $method, $validationConfig);
|
||||
|
||||
$validationSteps['endpoint_permissions'] = $permissionResult;
|
||||
$result['access_granted'] = $permissionResult['access_granted'];
|
||||
|
||||
if (!$permissionResult['access_granted']) {
|
||||
$securityIssues = array_merge($securityIssues, $permissionResult['security_issues']);
|
||||
}
|
||||
}
|
||||
|
||||
$result['validation_steps'] = $validationSteps;
|
||||
$result['security_issues'] = $securityIssues;
|
||||
$result['recommendations'] = $this->generateApiPermissionRecommendations($securityIssues);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test privilege escalation vulnerabilities.
|
||||
*/
|
||||
public function testPrivilegeEscalation(array $testScenarios, array $options = []): array
|
||||
{
|
||||
$testConfig = array_merge($this->config['privilege_escalation_testing'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'test_type' => 'privilege_escalation',
|
||||
'scenarios_tested' => count($testScenarios),
|
||||
'vulnerabilities_found' => 0,
|
||||
'test_results' => [],
|
||||
'escalation_vectors' => [],
|
||||
'security_score' => 100,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$testResults = [];
|
||||
$escalationVectors = [];
|
||||
$vulnerabilitiesFound = 0;
|
||||
|
||||
foreach ($testScenarios as $scenario) {
|
||||
$testResult = $this->runPrivilegeEscalationTest($scenario, $testConfig);
|
||||
$testResults[] = $testResult;
|
||||
|
||||
if ($testResult['vulnerability_detected']) {
|
||||
$vulnerabilitiesFound++;
|
||||
$escalationVectors[] = [
|
||||
'scenario' => $scenario['name'],
|
||||
'vector' => $testResult['escalation_vector'],
|
||||
'severity' => $testResult['severity'],
|
||||
'description' => $testResult['description']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result['vulnerabilities_found'] = $vulnerabilitiesFound;
|
||||
$result['test_results'] = $testResults;
|
||||
$result['escalation_vectors'] = $escalationVectors;
|
||||
$result['security_score'] = $this->calculatePrivilegeEscalationScore($testResults);
|
||||
$result['recommendations'] = $this->generatePrivilegeEscalationRecommendations($escalationVectors);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session security.
|
||||
*/
|
||||
public function validateSessionSecurity(int $userId, string $sessionId, array $options = []): array
|
||||
{
|
||||
$validationConfig = array_merge($this->config['session_security_validation'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'validation_type' => 'session_security',
|
||||
'user_id' => $userId,
|
||||
'session_id' => $sessionId,
|
||||
'session_valid' => false,
|
||||
'security_issues' => [],
|
||||
'session_details' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$sessionResult = $this->sessionValidator->validateSessionSecurity($userId, $sessionId, $validationConfig);
|
||||
|
||||
$result['session_valid'] = $sessionResult['valid'];
|
||||
$result['session_details'] = $sessionResult['details'];
|
||||
$result['security_issues'] = $sessionResult['security_issues'];
|
||||
$result['recommendations'] = $this->generateSessionSecurityRecommendations($sessionResult['security_issues']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test multi-factor authentication.
|
||||
*/
|
||||
public function testMultiFactorAuthentication(array $testCases, array $options = []): array
|
||||
{
|
||||
$testConfig = array_merge($this->config['mfa_testing'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'test_type' => 'multi_factor_authentication',
|
||||
'test_cases_run' => count($testCases),
|
||||
'mfa_enforced' => true,
|
||||
'bypass_methods' => [],
|
||||
'security_issues' => [],
|
||||
'test_results' => [],
|
||||
'security_score' => 100,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
$testResults = [];
|
||||
$securityIssues = [];
|
||||
$bypassMethods = [];
|
||||
|
||||
foreach ($testCases as $testCase) {
|
||||
$testResult = $this->runMfaTestCase($testCase, $testConfig);
|
||||
$testResults[] = $testResult;
|
||||
|
||||
if (!$testResult['mfa_required']) {
|
||||
$result['mfa_enforced'] = false;
|
||||
$bypassMethods[] = $testCase['name'];
|
||||
}
|
||||
|
||||
if (!empty($testResult['security_issues'])) {
|
||||
$securityIssues = array_merge($securityIssues, $testResult['security_issues']);
|
||||
}
|
||||
}
|
||||
|
||||
$result['bypass_methods'] = $bypassMethods;
|
||||
$result['security_issues'] = $securityIssues;
|
||||
$result['test_results'] = $testResults;
|
||||
$result['security_score'] = $this->calculateMfaSecurityScore($testResults);
|
||||
$result['recommendations'] = $this->generateMfaRecommendations($securityIssues, $bypassMethods);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate permission validation report.
|
||||
*/
|
||||
public function getPermissionReport(array $results, string $format = 'html'): string
|
||||
{
|
||||
return $this->reporter->generate($results, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'total_validations' => count($this->validationResults),
|
||||
'access_granted_count' => $this->countAccessGranted(),
|
||||
'access_denied_count' => $this->countAccessDenied(),
|
||||
'security_issues_count' => $this->countSecurityIssues(),
|
||||
'average_security_score' => $this->calculateAverageSecurityScore()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear validation results.
|
||||
*/
|
||||
public function clearResults(): void
|
||||
{
|
||||
$this->validationResults = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run RBAC test case.
|
||||
*/
|
||||
protected function runRbacTestCase(array $testCase, array $config): array
|
||||
{
|
||||
$result = [
|
||||
'test_name' => $testCase['name'],
|
||||
'user_id' => $testCase['user_id'],
|
||||
'role' => $testCase['role'] ?? null,
|
||||
'resource' => $testCase['resource'],
|
||||
'action' => $testCase['action'],
|
||||
'expected_result' => $testCase['expected'] ?? 'deny',
|
||||
'test_passed' => false,
|
||||
'access_granted' => false,
|
||||
'security_issues' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
try {
|
||||
$validationResult = $this->validatePermissions(
|
||||
$testCase['user_id'],
|
||||
$testCase['resource'],
|
||||
$testCase['action'],
|
||||
$config
|
||||
);
|
||||
|
||||
$result['access_granted'] = $validationResult['access_granted'];
|
||||
$result['security_issues'] = $validationResult['security_issues'];
|
||||
|
||||
// Check if test result matches expectation
|
||||
$expectedAccess = $testCase['expected'] === 'allow';
|
||||
$result['test_passed'] = $result['access_granted'] === $expectedAccess;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$result['security_issues'][] = [
|
||||
'type' => 'test_error',
|
||||
'severity' => 'high',
|
||||
'description' => 'Test execution failed: ' . $e->getMessage(),
|
||||
'recommendation' => 'Review test configuration and implementation'
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run privilege escalation test.
|
||||
*/
|
||||
protected function runPrivilegeEscalationTest(array $scenario, array $config): array
|
||||
{
|
||||
$result = [
|
||||
'scenario_name' => $scenario['name'],
|
||||
'vulnerability_detected' => false,
|
||||
'escalation_vector' => '',
|
||||
'severity' => 'low',
|
||||
'description' => '',
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Simulate privilege escalation testing
|
||||
switch ($scenario['type']) {
|
||||
case 'role_manipulation':
|
||||
$result = $this->testRoleManipulation($scenario, $config);
|
||||
break;
|
||||
|
||||
case 'parameter_pollution':
|
||||
$result = $this->testParameterPollution($scenario, $config);
|
||||
break;
|
||||
|
||||
case 'session_fixation':
|
||||
$result = $this->testSessionFixation($scenario, $config);
|
||||
break;
|
||||
|
||||
case 'direct_object_reference':
|
||||
$result = $this->testDirectObjectReference($scenario, $config);
|
||||
break;
|
||||
|
||||
default:
|
||||
$result['description'] = 'Unknown escalation test type';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test role manipulation.
|
||||
*/
|
||||
protected function testRoleManipulation(array $scenario, array $config): array
|
||||
{
|
||||
// Mock test for role manipulation
|
||||
return [
|
||||
'scenario_name' => $scenario['name'],
|
||||
'vulnerability_detected' => false,
|
||||
'escalation_vector' => 'role_parameter_manipulation',
|
||||
'severity' => 'high',
|
||||
'description' => 'Test for role parameter manipulation vulnerabilities'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parameter pollution.
|
||||
*/
|
||||
protected function testParameterPollution(array $scenario, array $config): array
|
||||
{
|
||||
// Mock test for parameter pollution
|
||||
return [
|
||||
'scenario_name' => $scenario['name'],
|
||||
'vulnerability_detected' => false,
|
||||
'escalation_vector' => 'parameter_pollution',
|
||||
'severity' => 'medium',
|
||||
'description' => 'Test for HTTP parameter pollution vulnerabilities'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test session fixation.
|
||||
*/
|
||||
protected function testSessionFixation(array $scenario, array $config): array
|
||||
{
|
||||
// Mock test for session fixation
|
||||
return [
|
||||
'scenario_name' => $scenario['name'],
|
||||
'vulnerability_detected' => false,
|
||||
'escalation_vector' => 'session_fixation',
|
||||
'severity' => 'high',
|
||||
'description' => 'Test for session fixation vulnerabilities'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test direct object reference.
|
||||
*/
|
||||
protected function testDirectObjectReference(array $scenario, array $config): array
|
||||
{
|
||||
// Mock test for direct object reference
|
||||
return [
|
||||
'scenario_name' => $scenario['name'],
|
||||
'vulnerability_detected' => false,
|
||||
'escalation_vector' => 'insecure_direct_object_reference',
|
||||
'severity' => 'medium',
|
||||
'description' => 'Test for insecure direct object reference vulnerabilities'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run MFA test case.
|
||||
*/
|
||||
protected function runMfaTestCase(array $testCase, array $config): array
|
||||
{
|
||||
$result = [
|
||||
'test_name' => $testCase['name'],
|
||||
'user_id' => $testCase['user_id'],
|
||||
'mfa_required' => true,
|
||||
'bypass_possible' => false,
|
||||
'security_issues' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Mock MFA testing
|
||||
$mfaResult = $this->authValidator->validateMfa($testCase['user_id'], $config);
|
||||
|
||||
$result['mfa_required'] = $mfaResult['required'];
|
||||
$result['bypass_possible'] = $mfaResult['bypass_possible'];
|
||||
$result['security_issues'] = $mfaResult['security_issues'] ?? [];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract resource from endpoint.
|
||||
*/
|
||||
protected function extractResourceFromEndpoint(string $endpoint): string
|
||||
{
|
||||
// Simple extraction - in real implementation would be more sophisticated
|
||||
$parts = explode('/', trim($endpoint, '/'));
|
||||
return $parts[0] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate RBAC security score.
|
||||
*/
|
||||
protected function calculateRbacSecurityScore(array $testResults): int
|
||||
{
|
||||
if (empty($testResults)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$passedTests = 0;
|
||||
foreach ($testResults as $result) {
|
||||
if ($result['test_passed']) {
|
||||
$passedTests++;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) round(($passedTests / count($testResults)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate privilege escalation score.
|
||||
*/
|
||||
protected function calculatePrivilegeEscalationScore(array $testResults): int
|
||||
{
|
||||
if (empty($testResults)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$vulnerabilities = 0;
|
||||
foreach ($testResults as $result) {
|
||||
if ($result['vulnerability_detected']) {
|
||||
$vulnerabilities++;
|
||||
}
|
||||
}
|
||||
|
||||
$scoreDeduction = ($vulnerabilities / count($testResults)) * 100;
|
||||
return max(0, 100 - (int) $scoreDeduction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate MFA security score.
|
||||
*/
|
||||
protected function calculateMfaSecurityScore(array $testResults): int
|
||||
{
|
||||
if (empty($testResults)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$score = 100;
|
||||
foreach ($testResults as $result) {
|
||||
if (!$result['mfa_required']) {
|
||||
$score -= 30;
|
||||
}
|
||||
if ($result['bypass_possible']) {
|
||||
$score -= 20;
|
||||
}
|
||||
}
|
||||
|
||||
return max(0, $score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate permission recommendations.
|
||||
*/
|
||||
protected function generatePermissionRecommendations(array $securityIssues): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
foreach ($securityIssues as $issue) {
|
||||
if (isset($issue['recommendation'])) {
|
||||
$recommendations[] = $issue['recommendation'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate RBAC recommendations.
|
||||
*/
|
||||
protected function generateRbacRecommendations(array $securityIssues, array $roleCoverage): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!empty($securityIssues)) {
|
||||
$recommendations[] = 'Review and fix RBAC implementation issues';
|
||||
}
|
||||
|
||||
// Check role coverage
|
||||
foreach ($roleCoverage as $role => $coverage) {
|
||||
$passRate = $coverage['passed'] / $coverage['tested'];
|
||||
if ($passRate < 0.8) {
|
||||
$recommendations[] = "Improve role '{$role}' permission definitions and test coverage";
|
||||
}
|
||||
}
|
||||
|
||||
$recommendations[] = 'Implement regular RBAC security testing';
|
||||
$recommendations[] = 'Document role hierarchies and permission matrices';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate API permission recommendations.
|
||||
*/
|
||||
protected function generateApiPermissionRecommendations(array $securityIssues): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
foreach ($securityIssues as $issue) {
|
||||
if (isset($issue['recommendation'])) {
|
||||
$recommendations[] = $issue['recommendation'];
|
||||
}
|
||||
}
|
||||
|
||||
$recommendations[] = 'Implement API gateway with centralized permission checking';
|
||||
$recommendations[] = 'Use API keys and tokens for authentication';
|
||||
$recommendations[] = 'Implement proper API rate limiting and throttling';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate privilege escalation recommendations.
|
||||
*/
|
||||
protected function generatePrivilegeEscalationRecommendations(array $escalationVectors): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!empty($escalationVectors)) {
|
||||
$recommendations[] = 'Fix identified privilege escalation vulnerabilities';
|
||||
$recommendations[] = 'Implement proper input validation for role parameters';
|
||||
$recommendations[] = 'Use secure session management with session regeneration';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Implement principle of least privilege';
|
||||
$recommendations[] = 'Regularly audit user permissions and role assignments';
|
||||
$recommendations[] = 'Implement secure indirect object references';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate session security recommendations.
|
||||
*/
|
||||
protected function generateSessionSecurityRecommendations(array $securityIssues): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
foreach ($securityIssues as $issue) {
|
||||
if (isset($issue['recommendation'])) {
|
||||
$recommendations[] = $issue['recommendation'];
|
||||
}
|
||||
}
|
||||
|
||||
$recommendations[] = 'Use secure, random session IDs';
|
||||
$recommendations[] = 'Implement session timeout and expiration';
|
||||
$recommendations[] = 'Regenerate session IDs on privilege escalation';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate MFA recommendations.
|
||||
*/
|
||||
protected function generateMfaRecommendations(array $securityIssues, array $bypassMethods): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!empty($bypassMethods)) {
|
||||
$recommendations[] = 'Fix MFA bypass vulnerabilities in: ' . implode(', ', $bypassMethods);
|
||||
}
|
||||
|
||||
if (!empty($securityIssues)) {
|
||||
$recommendations[] = 'Address MFA implementation security issues';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Enforce MFA for all privileged operations';
|
||||
$recommendations[] = 'Implement multiple MFA methods (TOTP, SMS, hardware tokens)';
|
||||
$recommendations[] = 'Regularly test MFA bypass scenarios';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count access granted validations.
|
||||
*/
|
||||
protected function countAccessGranted(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->validationResults as $result) {
|
||||
if ($result['access_granted']) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count access denied validations.
|
||||
*/
|
||||
protected function countAccessDenied(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->validationResults as $result) {
|
||||
if (!$result['access_granted']) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count security issues.
|
||||
*/
|
||||
protected function countSecurityIssues(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->validationResults as $result) {
|
||||
$count += count($result['security_issues']);
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average security score.
|
||||
*/
|
||||
protected function calculateAverageSecurityScore(): float
|
||||
{
|
||||
if (empty($this->validationResults)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$totalScore = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->validationResults as $result) {
|
||||
if (isset($result['security_score'])) {
|
||||
$totalScore += $result['security_score'];
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count > 0 ? $totalScore / $count : 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize permissions.
|
||||
*/
|
||||
protected function initializePermissions(): void
|
||||
{
|
||||
// This would load permissions from database or configuration
|
||||
$this->permissions = [
|
||||
'admin' => ['*'],
|
||||
'user' => ['read:profile', 'update:profile'],
|
||||
'guest' => ['read:public']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'permission_validation' => [
|
||||
'strict_mode' => true,
|
||||
'cache_permissions' => true,
|
||||
'audit_access' => true
|
||||
],
|
||||
'rbac_testing' => [
|
||||
'test_all_roles' => true,
|
||||
'test_edge_cases' => true
|
||||
],
|
||||
'api_permission_validation' => [
|
||||
'validate_rate_limit' => true,
|
||||
'validate_cors' => true,
|
||||
'strict_api_mode' => true
|
||||
],
|
||||
'privilege_escalation_testing' => [
|
||||
'test_role_manipulation' => true,
|
||||
'test_parameter_pollution' => true,
|
||||
'test_session_fixation' => true,
|
||||
'test_direct_object_reference' => true
|
||||
],
|
||||
'session_security_validation' => [
|
||||
'validate_session_hijacking' => true,
|
||||
'validate_session_fixation' => true,
|
||||
'validate_session_timeout' => true
|
||||
],
|
||||
'mfa_testing' => [
|
||||
'test_backup_codes' => true,
|
||||
'test_recovery_methods' => true,
|
||||
'test_bypass_attempts' => true
|
||||
],
|
||||
'role_validator' => [],
|
||||
'access_validator' => [],
|
||||
'auth_validator' => [],
|
||||
'authorization_validator' => [],
|
||||
'session_validator' => [],
|
||||
'reporter' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create permission validator instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'permission_validation' => [
|
||||
'strict_mode' => false,
|
||||
'audit_access' => false
|
||||
],
|
||||
'rbac_testing' => [
|
||||
'test_edge_cases' => false
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'permission_validation' => [
|
||||
'strict_mode' => true,
|
||||
'cache_permissions' => true,
|
||||
'audit_access' => true
|
||||
],
|
||||
'privilege_escalation_testing' => [
|
||||
'comprehensive_testing' => true
|
||||
],
|
||||
'session_security_validation' => [
|
||||
'strict_session_validation' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
1155
fendx-framework/fendx-service/src/Security/VulnerabilityScanner.php
Normal file
1155
fendx-framework/fendx-service/src/Security/VulnerabilityScanner.php
Normal file
File diff suppressed because it is too large
Load Diff
642
fendx-framework/fendx-service/src/ServiceMesh/EnvoyProxy.php
Normal file
642
fendx-framework/fendx-service/src/ServiceMesh/EnvoyProxy.php
Normal file
@@ -0,0 +1,642 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\ServiceMesh;
|
||||
|
||||
/**
|
||||
* Envoy 代理配置管理
|
||||
*/
|
||||
class EnvoyProxy
|
||||
{
|
||||
private array $config;
|
||||
private array $listeners = [];
|
||||
private array $clusters = [];
|
||||
private array $routes = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge([
|
||||
'admin_port' => 9901,
|
||||
'stats_port' => 9902,
|
||||
'log_level' => 'info',
|
||||
'drain_time' => '30s',
|
||||
'parent_shutdown_time' => '60s',
|
||||
], $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Envoy 配置
|
||||
*/
|
||||
public function generateConfig(): array
|
||||
{
|
||||
return [
|
||||
'static_resources' => [
|
||||
'listeners' => $this->generateListeners(),
|
||||
'clusters' => $this->generateClusters(),
|
||||
'secrets' => $this->generateSecrets(),
|
||||
],
|
||||
'dynamic_resources' => [
|
||||
'lds_config' => [
|
||||
'ads' => [],
|
||||
],
|
||||
'cds_config' => [
|
||||
'ads' => [],
|
||||
],
|
||||
'ads_config' => [
|
||||
'api_type' => 'GRPC',
|
||||
'grpc_services' => [
|
||||
[
|
||||
'envoy_grpc' => [
|
||||
'cluster_name' => 'xds_cluster',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'admin' => [
|
||||
'access_log_path' => '/dev/null',
|
||||
'address' => [
|
||||
'socket_address' => [
|
||||
'address' => '0.0.0.0',
|
||||
'port_value' => $this->config['admin_port'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'layered_runtime' => [
|
||||
'layers' => [
|
||||
[
|
||||
'name' => 'static_layer_0',
|
||||
'static_layer' => [
|
||||
'overload' => [
|
||||
'global_downstream_max_connections' => [
|
||||
'value' => 50000,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成监听器配置
|
||||
*/
|
||||
private function generateListeners(): array
|
||||
{
|
||||
return [
|
||||
$this->createHttpListener(),
|
||||
$this->createHttpsListener(),
|
||||
$this->createMetricsListener(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 HTTP 监听器
|
||||
*/
|
||||
private function createHttpListener(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'listener_0',
|
||||
'address' => [
|
||||
'socket_address' => [
|
||||
'address' => '0.0.0.0',
|
||||
'port_value' => 8080,
|
||||
],
|
||||
],
|
||||
'filter_chains' => [
|
||||
[
|
||||
'filters' => [
|
||||
[
|
||||
'name' => 'envoy.filters.network.http_connection_manager',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager',
|
||||
'stat_prefix' => 'ingress_http',
|
||||
'route_config' => [
|
||||
'name' => 'local_route',
|
||||
'virtual_hosts' => [
|
||||
[
|
||||
'name' => 'local_service',
|
||||
'domains' => ['*'],
|
||||
'routes' => $this->routes,
|
||||
],
|
||||
],
|
||||
],
|
||||
'http_filters' => [
|
||||
[
|
||||
'name' => 'envoy.filters.http.router',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'envoy.filters.http.cors',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors',
|
||||
'cors_policy' => [
|
||||
'allow_origin_string_match' => [
|
||||
[
|
||||
'prefix' => '*',
|
||||
],
|
||||
],
|
||||
'allow_methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'allow_headers' => 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization',
|
||||
'expose_headers' => 'Content-Length,Content-Range',
|
||||
'max_age' => '1728000',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'envoy.filters.http.fault',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.filters.http.fault.v3.HTTPFault',
|
||||
'delay' => [
|
||||
'percentage' => [
|
||||
'numerator' => 0,
|
||||
'denominator' => 'HUNDRED',
|
||||
],
|
||||
'fixed_delay' => '5s',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'access_log' => [
|
||||
[
|
||||
'name' => 'envoy.access_loggers.file',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog',
|
||||
'path' => '/dev/stdout',
|
||||
'format' => '{"start_time":"%START_TIME%","method":"%REQ(:METHOD)%","x-envoy-origin-path":"%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%","protocol":"%PROTOCOL%","response_code":"%RESPONSE_CODE%","response_flags":"%RESPONSE_FLAGS%","bytes_received":"%BYTES_RECEIVED%","bytes_sent":"%BYTES_SENT%","duration":"%DURATION%","x-forwarded-for":"%REQ(X-FORWARDED-FOR)%","user-agent":"%REQ(USER-AGENT)%","x-request-id":"%REQ(X-REQUEST-ID)%",":authority":"%REQ(:AUTHORITY)%","upstream_host":"%UPSTREAM_HOST%","upstream_cluster":"%UPSTREAM_CLUSTER%","upstream_local_address":"%UPSTREAM_LOCAL_ADDRESS%","upstream_request_time_ms":"%UPSTREAM_REQUEST_TIME%","upstream_transport_failure_reason":"%UPSTREAM_TRANSPORT_FAILURE_REASON%","upstream_latency":"%UPSTREAM_LATENCY%","x-envoy-upstream-service-time":"%REQ(X-ENVOY-UPSTREAM-SERVICE-TIME)%"}%"\n',
|
||||
],
|
||||
],
|
||||
],
|
||||
'use_remote_address' => true,
|
||||
'xff_num_trusted_hops' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'traffic_direction' => 'OUTBOUND',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 HTTPS 监听器
|
||||
*/
|
||||
private function createHttpsListener(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'listener_1',
|
||||
'address' => [
|
||||
'socket_address' => [
|
||||
'address' => '0.0.0.0',
|
||||
'port_value' => 8443,
|
||||
],
|
||||
],
|
||||
'filter_chains' => [
|
||||
[
|
||||
'transport_socket' => [
|
||||
'name' => 'envoy.transport_sockets.tls',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext',
|
||||
'common_tls_context' => [
|
||||
'tls_certificates' => [
|
||||
[
|
||||
'certificate_chain' => [
|
||||
'filename' => '/etc/ssl/certs/server.crt',
|
||||
],
|
||||
'private_key' => [
|
||||
'filename' => '/etc/ssl/private/server.key',
|
||||
],
|
||||
],
|
||||
],
|
||||
'alpn_protocols' => ['h2', 'http/1.1'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'filters' => [
|
||||
[
|
||||
'name' => 'envoy.filters.network.http_connection_manager',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager',
|
||||
'stat_prefix' => 'ingress_https',
|
||||
'route_config' => [
|
||||
'name' => 'local_route_https',
|
||||
'virtual_hosts' => [
|
||||
[
|
||||
'name' => 'local_service_https',
|
||||
'domains' => ['*'],
|
||||
'routes' => $this->routes,
|
||||
],
|
||||
],
|
||||
],
|
||||
'http_filters' => [
|
||||
[
|
||||
'name' => 'envoy.filters.http.router',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'traffic_direction' => 'OUTBOUND',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建指标监听器
|
||||
*/
|
||||
private function createMetricsListener(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'listener_metrics',
|
||||
'address' => [
|
||||
'socket_address' => [
|
||||
'address' => '0.0.0.0',
|
||||
'port_value' => $this->config['stats_port'],
|
||||
],
|
||||
],
|
||||
'filter_chains' => [
|
||||
[
|
||||
'filters' => [
|
||||
[
|
||||
'name' => 'envoy.filters.network.http_connection_manager',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager',
|
||||
'stat_prefix' => 'stats',
|
||||
'route_config' => [
|
||||
'name' => 'stats_route',
|
||||
'virtual_hosts' => [
|
||||
[
|
||||
'name' => 'stats_service',
|
||||
'domains' => ['*'],
|
||||
'routes' => [
|
||||
[
|
||||
'match' => [
|
||||
'prefix' => '/',
|
||||
],
|
||||
'route' => [
|
||||
'cluster' => 'stats_service',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'http_filters' => [
|
||||
[
|
||||
'name' => 'envoy.filters.http.router',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.filters.http.router.v3.Router',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成集群配置
|
||||
*/
|
||||
private function generateClusters(): array
|
||||
{
|
||||
return [
|
||||
$this->createServiceCluster(),
|
||||
$this->createXdsCluster(),
|
||||
$this->createStatsCluster(),
|
||||
$this->createZipkinCluster(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建服务集群
|
||||
*/
|
||||
private function createServiceCluster(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'service_cluster',
|
||||
'type' => 'STRICT_DNS',
|
||||
'connect_timeout' => '5s',
|
||||
'load_assignment' => [
|
||||
'cluster_name' => 'service_cluster',
|
||||
'endpoints' => [
|
||||
[
|
||||
'lb_endpoints' => [
|
||||
[
|
||||
'endpoint' => [
|
||||
'address' => [
|
||||
'socket_address' => [
|
||||
'address' => 'fendx-php-service',
|
||||
'port_value' => 9000,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'type' => 'EDS',
|
||||
'eds_cluster_config' => [
|
||||
'eds_config' => [
|
||||
'ads' => [],
|
||||
],
|
||||
],
|
||||
'transport_socket' => [
|
||||
'name' => 'envoy.transport_sockets.tls',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext',
|
||||
'sni' => 'fendx-php-service',
|
||||
],
|
||||
],
|
||||
'common_lb_config' => [
|
||||
'consistent_hashing_lb_config' => [
|
||||
'use_source_address' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 XDS 集群
|
||||
*/
|
||||
private function createXdsCluster(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'xds_cluster',
|
||||
'type' => 'STRICT_DNS',
|
||||
'connect_timeout' => '5s',
|
||||
'load_assignment' => [
|
||||
'cluster_name' => 'xds_cluster',
|
||||
'endpoints' => [
|
||||
[
|
||||
'lb_endpoints' => [
|
||||
[
|
||||
'endpoint' => [
|
||||
'address' => [
|
||||
'socket_address' => [
|
||||
'address' => 'istiod',
|
||||
'port_value' => 15012,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'transport_socket' => [
|
||||
'name' => 'envoy.transport_sockets.tls',
|
||||
'typed_config' => [
|
||||
'@type' => 'type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext',
|
||||
'sni' => 'istiod',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建统计集群
|
||||
*/
|
||||
private function createStatsCluster(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'stats_service',
|
||||
'type' => 'STATIC',
|
||||
'connect_timeout' => '5s',
|
||||
'load_assignment' => [
|
||||
'cluster_name' => 'stats_service',
|
||||
'endpoints' => [
|
||||
[
|
||||
'lb_endpoints' => [
|
||||
[
|
||||
'endpoint' => [
|
||||
'address' => [
|
||||
'socket_address' => [
|
||||
'address' => '127.0.0.1',
|
||||
'port_value' => 9100,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Zipkin 集群
|
||||
*/
|
||||
private function createZipkinCluster(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'zipkin_cluster',
|
||||
'type' => 'STRICT_DNS',
|
||||
'connect_timeout' => '5s',
|
||||
'load_assignment' => [
|
||||
'cluster_name' => 'zipkin_cluster',
|
||||
'endpoints' => [
|
||||
[
|
||||
'lb_endpoints' => [
|
||||
[
|
||||
'endpoint' => [
|
||||
'address' => [
|
||||
'socket_address' => [
|
||||
'address' => 'zipkin',
|
||||
'port_value' => 9411,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成密钥配置
|
||||
*/
|
||||
private function generateSecrets(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => 'tls_cert',
|
||||
'tls_certificate' => [
|
||||
'certificate_chain' => [
|
||||
'filename' => '/etc/ssl/certs/server.crt',
|
||||
],
|
||||
'private_key' => [
|
||||
'filename' => '/etc/ssl/private/server.key',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置追踪
|
||||
*/
|
||||
public function configureTracing(array $config): void
|
||||
{
|
||||
$this->config['tracing'] = array_merge([
|
||||
'driver' => 'zipkin',
|
||||
'sample_rate' => 100,
|
||||
'service_name' => 'fendx-php',
|
||||
], $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加路由
|
||||
*/
|
||||
public function addRoute(array $route): void
|
||||
{
|
||||
$this->routes[] = $route;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加集群
|
||||
*/
|
||||
public function addCluster(array $cluster): void
|
||||
{
|
||||
$this->clusters[] = $cluster;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->generateConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置到文件
|
||||
*/
|
||||
public function saveConfig(string $path): void
|
||||
{
|
||||
$config = $this->generateConfig();
|
||||
$yaml = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$this->ensureDirectory(dirname($path));
|
||||
file_put_contents($path, $yaml);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
private function ensureDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 热重载配置
|
||||
*/
|
||||
public function reloadConfig(): bool
|
||||
{
|
||||
$adminUrl = "http://localhost:{$this->config['admin_port']}";
|
||||
|
||||
// 触发配置重载
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/json',
|
||||
'content' => '{}',
|
||||
],
|
||||
]);
|
||||
|
||||
$response = @file_get_contents("{$adminUrl}/config_dump", false, $context);
|
||||
|
||||
return $response !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
$adminUrl = "http://localhost:{$this->config['admin_port']}";
|
||||
$statsUrl = "{$adminUrl}/stats";
|
||||
|
||||
$stats = @file_get_contents($statsUrl);
|
||||
|
||||
if ($stats === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = explode("\n", $stats);
|
||||
$result = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (empty(trim($line))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(':', $line, 2);
|
||||
if (count($parts) === 2) {
|
||||
$key = trim($parts[0]);
|
||||
$value = trim($parts[1]);
|
||||
|
||||
// 解析嵌套的统计键
|
||||
$keys = explode('.', $key);
|
||||
$current = &$result;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($current[$k])) {
|
||||
$current[$k] = [];
|
||||
}
|
||||
$current = &$current[$k];
|
||||
}
|
||||
|
||||
$current = is_numeric($value) ? (int) $value : $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
public function healthCheck(): bool
|
||||
{
|
||||
$adminUrl = "http://localhost:{$this->config['admin_port']}";
|
||||
$healthUrl = "{$adminUrl}/clusters";
|
||||
|
||||
$response = @file_get_contents($healthUrl);
|
||||
|
||||
if ($response === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$clusters = json_decode($response, true);
|
||||
|
||||
// 检查所有集群是否健康
|
||||
foreach ($clusters['cluster_statuses'] ?? [] as $cluster) {
|
||||
if ($cluster['locality'][0]['priority'] === 0) {
|
||||
foreach ($cluster['locality'][0]['endpoints'] ?? [] as $endpoint) {
|
||||
if ($endpoint['health_status']['value'] !== 'HEALTHY') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\ServiceMesh;
|
||||
|
||||
/**
|
||||
* 服务网格管理器
|
||||
* 集成 Istio + Envoy 服务网格解决方案
|
||||
*/
|
||||
class ServiceMeshManager
|
||||
{
|
||||
private EnvoyProxy $envoy;
|
||||
private IstioConfig $istio;
|
||||
private array $config;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge([
|
||||
'enabled' => false,
|
||||
'istio_version' => '1.18.0',
|
||||
'envoy_version' => '1.26.0',
|
||||
'namespace' => 'fendx',
|
||||
'auto_injection' => true,
|
||||
], $config);
|
||||
|
||||
$this->envoy = new EnvoyProxy($this->config['envoy'] ?? []);
|
||||
$this->istio = new IstioConfig($this->config['istio'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用服务网格
|
||||
*/
|
||||
public function enableServiceMesh(): void
|
||||
{
|
||||
if (!$this->config['enabled']) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动注入 sidecar
|
||||
$this->injectSidecar();
|
||||
|
||||
// 配置流量管理
|
||||
$this->configureTrafficManagement();
|
||||
|
||||
// 启用安全策略
|
||||
$this->enableSecurityPolicies();
|
||||
|
||||
// 配置可观测性
|
||||
$this->configureObservability();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 Sidecar 代理
|
||||
*/
|
||||
private function injectSidecar(): void
|
||||
{
|
||||
if (!$this->config['auto_injection']) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Kubernetes 自动注入配置
|
||||
$this->createAutoInjectionConfig();
|
||||
|
||||
// Docker sidecar 模式配置
|
||||
$this->configureDockerSidecar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建自动注入配置
|
||||
*/
|
||||
private function createAutoInjectionConfig(): void
|
||||
{
|
||||
$config = [
|
||||
'apiVersion' => 'admissionregistration.k8s.io/v1',
|
||||
'kind' => 'MutatingWebhookConfiguration',
|
||||
'metadata' => [
|
||||
'name' => 'istio-sidecar-injector',
|
||||
'namespace' => $this->config['namespace'],
|
||||
],
|
||||
'webhooks' => [
|
||||
[
|
||||
'name' => 'sidecar-injector.istio.io',
|
||||
'clientConfig' => [
|
||||
'service' => [
|
||||
'name' => 'istiod',
|
||||
'namespace' => $this->config['namespace'],
|
||||
'path' => '/inject',
|
||||
],
|
||||
'caBundle' => $this->getIstioCaBundle(),
|
||||
],
|
||||
'rules' => [
|
||||
[
|
||||
'operations' => ['CREATE'],
|
||||
'resources' => ['pods'],
|
||||
],
|
||||
],
|
||||
'failurePolicy' => 'Fail',
|
||||
'matchPolicy' => 'Exact',
|
||||
'sideEffects' => 'None',
|
||||
'timeoutSeconds' => 10,
|
||||
'admissionReviewVersions' => ['v1', 'v1beta1'],
|
||||
'reinvocationPolicy' => 'Never',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyKubernetesConfig($config, 'sidecar-injector.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 Docker Sidecar
|
||||
*/
|
||||
private function configureDockerSidecar(): void
|
||||
{
|
||||
$dockerCompose = [
|
||||
'version' => '3.8',
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'fendx/php:latest',
|
||||
'environment' => [
|
||||
'ISTIO_META_CONFIG_NAMESPACE' => $this->config['namespace'],
|
||||
],
|
||||
'networks' => ['istio-mesh'],
|
||||
],
|
||||
'istio-proxy' => [
|
||||
'image' => "istio/proxyv2:{$this->config['istio_version']}",
|
||||
'command' => [
|
||||
'istio-proxy',
|
||||
'proxy',
|
||||
'sidecar',
|
||||
'--domain',
|
||||
'$(POD_NAMESPACE).svc.cluster.local',
|
||||
],
|
||||
'environment' => [
|
||||
'POD_NAME' => '$(HOSTNAME)',
|
||||
'POD_NAMESPACE' => $this->config['namespace'],
|
||||
'INSTANCE_IP' => '$(POD_IP)',
|
||||
],
|
||||
'volumes' => [
|
||||
'/var/run/secrets/istio:/var/run/secrets/istio:ro',
|
||||
'/var/lib/istio/data:/var/lib/istio/data',
|
||||
],
|
||||
'networks' => ['istio-mesh'],
|
||||
'depends_on' => ['app'],
|
||||
],
|
||||
],
|
||||
'networks' => [
|
||||
'istio-mesh' => [
|
||||
'external' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->saveDockerCompose($dockerCompose);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置流量管理
|
||||
*/
|
||||
private function configureTrafficManagement(): void
|
||||
{
|
||||
// 创建 VirtualService
|
||||
$this->createVirtualServices();
|
||||
|
||||
// 创建 DestinationRule
|
||||
$this->createDestinationRules();
|
||||
|
||||
// 创建 Gateway
|
||||
$this->createGateways();
|
||||
|
||||
// 创建 ServiceEntry
|
||||
$this->createServiceEntries();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建虚拟服务
|
||||
*/
|
||||
private function createVirtualServices(): void
|
||||
{
|
||||
$virtualService = [
|
||||
'apiVersion' => 'networking.istio.io/v1beta1',
|
||||
'kind' => 'VirtualService',
|
||||
'metadata' => [
|
||||
'name' => 'fendx-php-service',
|
||||
'namespace' => $this->config['namespace'],
|
||||
],
|
||||
'spec' => [
|
||||
'hosts' => ['fendx-php-service'],
|
||||
'gateways' => ['fendx-gateway'],
|
||||
'http' => [
|
||||
[
|
||||
'match' => [
|
||||
[
|
||||
'uri' => [
|
||||
'prefix' => '/api',
|
||||
],
|
||||
],
|
||||
],
|
||||
'route' => [
|
||||
[
|
||||
'destination' => [
|
||||
'host' => 'fendx-php-service',
|
||||
'subset' => 'v1',
|
||||
],
|
||||
'weight' => 90,
|
||||
],
|
||||
[
|
||||
'destination' => [
|
||||
'host' => 'fendx-php-service',
|
||||
'subset' => 'v2',
|
||||
],
|
||||
'weight' => 10,
|
||||
],
|
||||
],
|
||||
'fault' => [
|
||||
'delay' => [
|
||||
'percentage' => [
|
||||
'value' => 0.1,
|
||||
],
|
||||
'fixedDelay' => '5s',
|
||||
],
|
||||
],
|
||||
'retries' => [
|
||||
'attempts' => 3,
|
||||
'perTryTimeout' => '2s',
|
||||
'retryOn' => 'gateway-error,connect-failure,refused-stream',
|
||||
],
|
||||
'timeout' => '10s',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyKubernetesConfig($virtualService, 'virtual-service.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目标规则
|
||||
*/
|
||||
private function createDestinationRules(): void
|
||||
{
|
||||
$destinationRule = [
|
||||
'apiVersion' => 'networking.istio.io/v1beta1',
|
||||
'kind' => 'DestinationRule',
|
||||
'metadata' => [
|
||||
'name' => 'fendx-php-service',
|
||||
'namespace' => $this->config['namespace'],
|
||||
],
|
||||
'spec' => [
|
||||
'host' => 'fendx-php-service',
|
||||
'trafficPolicy' => [
|
||||
'loadBalancer' => [
|
||||
'simple' => 'LEAST_CONN',
|
||||
],
|
||||
'connectionPool' => [
|
||||
'tcp' => [
|
||||
'maxConnections' => 100,
|
||||
],
|
||||
'http' => [
|
||||
'http1MaxPendingRequests' => 50,
|
||||
'maxRequestsPerConnection' => 10,
|
||||
],
|
||||
],
|
||||
'circuitBreaker' => [
|
||||
'consecutiveErrors' => 3,
|
||||
'interval' => '30s',
|
||||
'baseEjectionTime' => '30s',
|
||||
'maxEjectionPercent' => 50,
|
||||
],
|
||||
],
|
||||
'subsets' => [
|
||||
[
|
||||
'name' => 'v1',
|
||||
'labels' => [
|
||||
'version' => 'v1',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'v2',
|
||||
'labels' => [
|
||||
'version' => 'v2',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyKubernetesConfig($destinationRule, 'destination-rule.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建网关
|
||||
*/
|
||||
private function createGateways(): void
|
||||
{
|
||||
$gateway = [
|
||||
'apiVersion' => 'networking.istio.io/v1beta1',
|
||||
'kind' => 'Gateway',
|
||||
'metadata' => [
|
||||
'name' => 'fendx-gateway',
|
||||
'namespace' => $this->config['namespace'],
|
||||
],
|
||||
'spec' => [
|
||||
'selector' => [
|
||||
'istio' => 'ingressgateway',
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'port' => [
|
||||
'number' => 80,
|
||||
'name' => 'http',
|
||||
'protocol' => 'HTTP',
|
||||
],
|
||||
'hosts' => ['*'],
|
||||
],
|
||||
[
|
||||
'port' => [
|
||||
'number' => 443,
|
||||
'name' => 'https',
|
||||
'protocol' => 'HTTPS',
|
||||
],
|
||||
'tls' => [
|
||||
'mode' => 'SIMPLE',
|
||||
'credentialName' => 'fendx-tls',
|
||||
],
|
||||
'hosts' => ['fendx.example.com'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyKubernetesConfig($gateway, 'gateway.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用安全策略
|
||||
*/
|
||||
private function enableSecurityPolicies(): void
|
||||
{
|
||||
// 创建 PeerAuthentication
|
||||
$this->createPeerAuthentication();
|
||||
|
||||
// 创建 AuthorizationPolicy
|
||||
$this->createAuthorizationPolicies();
|
||||
|
||||
// 创建 RequestAuthentication
|
||||
$this->createRequestAuthentication();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建对等认证策略
|
||||
*/
|
||||
private function createPeerAuthentication(): void
|
||||
{
|
||||
$peerAuth = [
|
||||
'apiVersion' => 'security.istio.io/v1beta1',
|
||||
'kind' => 'PeerAuthentication',
|
||||
'metadata' => [
|
||||
'name' => 'default',
|
||||
'namespace' => $this->config['namespace'],
|
||||
],
|
||||
'spec' => [
|
||||
'mtls' => [
|
||||
'mode' => 'STRICT',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyKubernetesConfig($peerAuth, 'peer-authentication.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建授权策略
|
||||
*/
|
||||
private function createAuthorizationPolicies(): void
|
||||
{
|
||||
$authPolicy = [
|
||||
'apiVersion' => 'security.istio.io/v1beta1',
|
||||
'kind' => 'AuthorizationPolicy',
|
||||
'metadata' => [
|
||||
'name' => 'fendx-authz',
|
||||
'namespace' => $this->config['namespace'],
|
||||
],
|
||||
'spec' => [
|
||||
'selector' => [
|
||||
'matchLabels' => [
|
||||
'app' => 'fendx-php',
|
||||
],
|
||||
],
|
||||
'rules' => [
|
||||
[
|
||||
'from' => [
|
||||
[
|
||||
'source' => [
|
||||
'principals' => ['cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'to' => [
|
||||
[
|
||||
'operation' => [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'paths' => ['/api/*'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyKubernetesConfig($authPolicy, 'authorization-policy.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置可观测性
|
||||
*/
|
||||
private function configureObservability(): void
|
||||
{
|
||||
// 创建 Telemetry 配置
|
||||
$this->createTelemetryConfig();
|
||||
|
||||
// 创建 ServiceMonitor
|
||||
$this->createServiceMonitors();
|
||||
|
||||
// 配置分布式追踪
|
||||
$this->configureTracing();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建遥测配置
|
||||
*/
|
||||
private function createTelemetryConfig(): void
|
||||
{
|
||||
$telemetry = [
|
||||
'apiVersion' => 'telemetry.istio.io/v1alpha1',
|
||||
'kind' => 'Telemetry',
|
||||
'metadata' => [
|
||||
'name' => 'default',
|
||||
'namespace' => $this->config['namespace'],
|
||||
],
|
||||
'spec' => [
|
||||
'tracing' => [
|
||||
[
|
||||
'randomSamplingPercentage' => 100,
|
||||
'customTags' => [
|
||||
'service_name' => [
|
||||
'literal' => [
|
||||
'value' => 'fendx-php',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'metrics' => [
|
||||
[
|
||||
'providers' => [
|
||||
[
|
||||
'name' => 'prometheus',
|
||||
],
|
||||
],
|
||||
'overrides' => [
|
||||
[
|
||||
'match' => [
|
||||
'metric' => 'ALL_METRICS',
|
||||
],
|
||||
'tagOverrides' => [
|
||||
'destination_service' => [
|
||||
'operation' => 'UPSERT',
|
||||
'value' => 'fendx-php-service',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'accessLogging' => [
|
||||
[
|
||||
'providers' => [
|
||||
[
|
||||
'name' => 'envoy',
|
||||
],
|
||||
],
|
||||
'filter' => [
|
||||
'expression' => 'response.code >= 400',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->applyKubernetesConfig($telemetry, 'telemetry.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置分布式追踪
|
||||
*/
|
||||
private function configureTracing(): void
|
||||
{
|
||||
$this->envoy->configureTracing([
|
||||
'driver' => 'zipkin',
|
||||
'config' => [
|
||||
'zipkin_address' => 'zipkin.istio-system:9411',
|
||||
'sample_rate' => 100,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Istio CA 证书
|
||||
*/
|
||||
private function getIstioCaBundle(): string
|
||||
{
|
||||
// 从 Kubernetes secret 获取 CA 证书
|
||||
$caCert = shell_exec('kubectl get secret istio-ca-secret -n istio-system -o jsonpath="{.data.ca\\.crt}"');
|
||||
return base64_decode($caCert ?: '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用 Kubernetes 配置
|
||||
*/
|
||||
private function applyKubernetesConfig(array $config, string $filename): void
|
||||
{
|
||||
$yaml = $this->arrayToYaml($config);
|
||||
$configPath = runtime_path('k8s/' . $filename);
|
||||
|
||||
$this->ensureDirectory(dirname($configPath));
|
||||
file_put_contents($configPath, $yaml);
|
||||
|
||||
// 自动应用到 Kubernetes 集群
|
||||
if ($this->config['auto_apply'] ?? false) {
|
||||
shell_exec("kubectl apply -f {$configPath}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Docker Compose 配置
|
||||
*/
|
||||
private function saveDockerCompose(array $config): void
|
||||
{
|
||||
$yaml = $this->arrayToYaml($config);
|
||||
$composePath = runtime_path('docker/docker-compose.mesh.yml');
|
||||
|
||||
$this->ensureDirectory(dirname($composePath));
|
||||
file_put_contents($composePath, $yaml);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
private function ensureDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数组转 YAML
|
||||
*/
|
||||
private function arrayToYaml(array $array): string
|
||||
{
|
||||
// 简化的 YAML 转换,实际项目中建议使用 symfony/yaml
|
||||
return json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务网格状态
|
||||
*/
|
||||
public function getStatus(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => $this->config['enabled'],
|
||||
'istio_version' => $this->config['istio_version'],
|
||||
'envoy_version' => $this->config['envoy_version'],
|
||||
'namespace' => $this->config['namespace'],
|
||||
'auto_injection' => $this->config['auto_injection'],
|
||||
'components' => [
|
||||
'istiod' => $this->checkIstiodStatus(),
|
||||
'ingress_gateway' => $this->checkIngressGatewayStatus(),
|
||||
'egress_gateway' => $this->checkEgressGatewayStatus(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Istiod 状态
|
||||
*/
|
||||
private function checkIstiodStatus(): array
|
||||
{
|
||||
$output = shell_exec("kubectl get pod -n {$this->config['namespace']} -l app=istiod -o json");
|
||||
$pods = json_decode($output, true);
|
||||
|
||||
return [
|
||||
'running' => count($pods['items'] ?? []),
|
||||
'ready' => count(array_filter($pods['items'] ?? [], fn($pod) =>
|
||||
$pod['status']['phase'] === 'Running' &&
|
||||
$pod['status']['containerStatuses'][0]['ready'] ?? false
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查入口网关状态
|
||||
*/
|
||||
private function checkIngressGatewayStatus(): array
|
||||
{
|
||||
$output = shell_exec("kubectl get pod -n {$this->config['namespace']} -l app=istio-ingressgateway -o json");
|
||||
$pods = json_decode($output, true);
|
||||
|
||||
return [
|
||||
'running' => count($pods['items'] ?? []),
|
||||
'ready' => count(array_filter($pods['items'] ?? [], fn($pod) =>
|
||||
$pod['status']['phase'] === 'Running' &&
|
||||
($pod['status']['containerStatuses'][0]['ready'] ?? false)
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查出口网关状态
|
||||
*/
|
||||
private function checkEgressGatewayStatus(): array
|
||||
{
|
||||
$output = shell_exec("kubectl get pod -n {$this->config['namespace']} -l app=istio-egressgateway -o json");
|
||||
$pods = json_decode($output, true);
|
||||
|
||||
return [
|
||||
'running' => count($pods['items'] ?? []),
|
||||
'ready' => count(array_filter($pods['items'] ?? [], fn($pod) =>
|
||||
$pod['status']['phase'] === 'Running' &&
|
||||
($pod['status']['containerStatuses'][0]['ready'] ?? false)
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Testing;
|
||||
|
||||
use Fendx\Service\Testing\Coverage\CoverageAnalyzer;
|
||||
use Fendx\Service\Testing\Coverage\UnitTestAnalyzer;
|
||||
use Fendx\Service\Testing\Coverage\IntegrationTestAnalyzer;
|
||||
use Fendx\Service\Testing\Coverage\ApiTestAnalyzer;
|
||||
use Fendx\Service\Testing\Coverage\E2ETestAnalyzer;
|
||||
use Fendx\Service\Testing\Reporter\CoverageReporter;
|
||||
|
||||
class TestCoverageChecker
|
||||
{
|
||||
protected array $config = [];
|
||||
protected CoverageAnalyzer $coverageAnalyzer;
|
||||
protected UnitTestAnalyzer $unitTestAnalyzer;
|
||||
protected IntegrationTestAnalyzer $integrationTestAnalyzer;
|
||||
protected ApiTestAnalyzer $apiTestAnalyzer;
|
||||
protected E2ETestAnalyzer $e2eTestAnalyzer;
|
||||
protected CoverageReporter $reporter;
|
||||
protected array $coverageResults = [];
|
||||
protected array $testMetrics = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->coverageAnalyzer = new CoverageAnalyzer($this->config['coverage_analyzer'] ?? []);
|
||||
$this->unitTestAnalyzer = new UnitTestAnalyzer($this->config['unit_test_analyzer'] ?? []);
|
||||
$this->integrationTestAnalyzer = new IntegrationTestAnalyzer($this->config['integration_test_analyzer'] ?? []);
|
||||
$this->apiTestAnalyzer = new ApiTestAnalyzer($this->config['api_test_analyzer'] ?? []);
|
||||
$this->e2eTestAnalyzer = new E2ETestAnalyzer($this->config['e2e_test_analyzer'] ?? []);
|
||||
$this->reporter = new CoverageReporter($this->config['reporter'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall test coverage.
|
||||
*/
|
||||
public function checkTestCoverage(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['coverage_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'test_coverage',
|
||||
'project_path' => $projectPath,
|
||||
'total_coverage' => 0,
|
||||
'line_coverage' => 0,
|
||||
'branch_coverage' => 0,
|
||||
'method_coverage' => 0,
|
||||
'class_coverage' => 0,
|
||||
'test_types' => [],
|
||||
'coverage_by_type' => [],
|
||||
'uncovered_code' => [],
|
||||
'quality_score' => 0,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Analyze different test types
|
||||
$unitCoverage = $this->checkUnitTestCoverage($projectPath, $checkConfig);
|
||||
$integrationCoverage = $this->checkIntegrationTestCoverage($projectPath, $checkConfig);
|
||||
$apiCoverage = $this->checkApiTestCoverage($projectPath, $checkConfig);
|
||||
$e2eCoverage = $this->checkE2ETestCoverage($projectPath, $checkConfig);
|
||||
|
||||
$result['test_types'] = [
|
||||
'unit_tests' => $unitCoverage['exists'],
|
||||
'integration_tests' => $integrationCoverage['exists'],
|
||||
'api_tests' => $apiCoverage['exists'],
|
||||
'e2e_tests' => $e2eCoverage['exists']
|
||||
];
|
||||
|
||||
$result['coverage_by_type'] = [
|
||||
'unit' => $unitCoverage,
|
||||
'integration' => $integrationCoverage,
|
||||
'api' => $apiCoverage,
|
||||
'e2e' => $e2eCoverage
|
||||
];
|
||||
|
||||
// Calculate overall coverage
|
||||
$coverages = array_filter([
|
||||
$unitCoverage['line_coverage'] ?? 0,
|
||||
$integrationCoverage['line_coverage'] ?? 0,
|
||||
$apiCoverage['line_coverage'] ?? 0,
|
||||
$e2eCoverage['line_coverage'] ?? 0
|
||||
]);
|
||||
|
||||
$result['total_coverage'] = count($coverages) > 0 ?
|
||||
round(array_sum($coverages) / count($coverages), 2) : 0;
|
||||
|
||||
// Get detailed coverage metrics
|
||||
$detailedCoverage = $this->coverageAnalyzer->analyze($projectPath, $checkConfig);
|
||||
$result['line_coverage'] = $detailedCoverage['line_coverage'] ?? 0;
|
||||
$result['branch_coverage'] = $detailedCoverage['branch_coverage'] ?? 0;
|
||||
$result['method_coverage'] = $detailedCoverage['method_coverage'] ?? 0;
|
||||
$result['class_coverage'] = $detailedCoverage['class_coverage'] ?? 0;
|
||||
$result['uncovered_code'] = $detailedCoverage['uncovered_files'] ?? [];
|
||||
|
||||
$result['quality_score'] = $this->calculateCoverageQualityScore($result);
|
||||
$result['recommendations'] = $this->generateCoverageRecommendations($result);
|
||||
|
||||
// Store result
|
||||
$this->coverageResults[] = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check unit test coverage.
|
||||
*/
|
||||
public function checkUnitTestCoverage(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['unit_test_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'unit_test_coverage',
|
||||
'project_path' => $projectPath,
|
||||
'exists' => false,
|
||||
'test_files_found' => 0,
|
||||
'test_classes_found' => 0,
|
||||
'test_methods_found' => 0,
|
||||
'assertions_found' => 0,
|
||||
'line_coverage' => 0,
|
||||
'method_coverage' => 0,
|
||||
'class_coverage' => 0,
|
||||
'coverage_details' => [],
|
||||
'quality_issues' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Find unit test files
|
||||
$testFiles = $this->findTestFiles($projectPath, 'unit', $checkConfig);
|
||||
$result['test_files_found'] = count($testFiles);
|
||||
$result['exists'] = !empty($testFiles);
|
||||
|
||||
if (empty($testFiles)) {
|
||||
$result['quality_issues'][] = [
|
||||
'type' => 'no_unit_tests',
|
||||
'severity' => 'critical',
|
||||
'message' => 'No unit test files found',
|
||||
'recommendation' => 'Create unit tests for core functionality'
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Analyze test files
|
||||
$testAnalysis = $this->unitTestAnalyzer->analyze($testFiles, $checkConfig);
|
||||
|
||||
$result['test_classes_found'] = $testAnalysis['test_classes'];
|
||||
$result['test_methods_found'] = $testAnalysis['test_methods'];
|
||||
$result['assertions_found'] = $testAnalysis['assertions'];
|
||||
$result['coverage_details'] = $testAnalysis['coverage_details'];
|
||||
$result['quality_issues'] = $testAnalysis['quality_issues'];
|
||||
|
||||
// Get coverage metrics
|
||||
$coverageMetrics = $this->coverageAnalyzer->analyzeUnitTests($projectPath, $checkConfig);
|
||||
$result['line_coverage'] = $coverageMetrics['line_coverage'] ?? 0;
|
||||
$result['method_coverage'] = $coverageMetrics['method_coverage'] ?? 0;
|
||||
$result['class_coverage'] = $coverageMetrics['class_coverage'] ?? 0;
|
||||
|
||||
$result['recommendations'] = $this->generateUnitTestRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check integration test coverage.
|
||||
*/
|
||||
public function checkIntegrationTestCoverage(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['integration_test_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'integration_test_coverage',
|
||||
'project_path' => $projectPath,
|
||||
'exists' => false,
|
||||
'test_files_found' => 0,
|
||||
'integration_scenarios' => 0,
|
||||
'database_tests' => 0,
|
||||
'cache_tests' => 0,
|
||||
'external_service_tests' => 0,
|
||||
'line_coverage' => 0,
|
||||
'coverage_details' => [],
|
||||
'quality_issues' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Find integration test files
|
||||
$testFiles = $this->findTestFiles($projectPath, 'integration', $checkConfig);
|
||||
$result['test_files_found'] = count($testFiles);
|
||||
$result['exists'] = !empty($testFiles);
|
||||
|
||||
if (empty($testFiles)) {
|
||||
$result['quality_issues'][] = [
|
||||
'type' => 'no_integration_tests',
|
||||
'severity' => 'high',
|
||||
'message' => 'No integration test files found',
|
||||
'recommendation' => 'Create integration tests for component interactions'
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Analyze test files
|
||||
$testAnalysis = $this->integrationTestAnalyzer->analyze($testFiles, $checkConfig);
|
||||
|
||||
$result['integration_scenarios'] = $testAnalysis['scenarios'];
|
||||
$result['database_tests'] = $testAnalysis['database_tests'];
|
||||
$result['cache_tests'] = $testAnalysis['cache_tests'];
|
||||
$result['external_service_tests'] = $testAnalysis['external_service_tests'];
|
||||
$result['coverage_details'] = $testAnalysis['coverage_details'];
|
||||
$result['quality_issues'] = $testAnalysis['quality_issues'];
|
||||
|
||||
// Get coverage metrics
|
||||
$coverageMetrics = $this->coverageAnalyzer->analyzeIntegrationTests($projectPath, $checkConfig);
|
||||
$result['line_coverage'] = $coverageMetrics['line_coverage'] ?? 0;
|
||||
|
||||
$result['recommendations'] = $this->generateIntegrationTestRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API test coverage.
|
||||
*/
|
||||
public function checkApiTestCoverage(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['api_test_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'api_test_coverage',
|
||||
'project_path' => $projectPath,
|
||||
'exists' => false,
|
||||
'test_files_found' => 0,
|
||||
'endpoints_tested' => 0,
|
||||
'http_methods_covered' => [],
|
||||
'status_codes_tested' => [],
|
||||
'request_response_tests' => 0,
|
||||
'authentication_tests' => 0,
|
||||
'line_coverage' => 0,
|
||||
'coverage_details' => [],
|
||||
'quality_issues' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Find API test files
|
||||
$testFiles = $this->findTestFiles($projectPath, 'api', $checkConfig);
|
||||
$result['test_files_found'] = count($testFiles);
|
||||
$result['exists'] = !empty($testFiles);
|
||||
|
||||
if (empty($testFiles)) {
|
||||
$result['quality_issues'][] = [
|
||||
'type' => 'no_api_tests',
|
||||
'severity' => 'high',
|
||||
'message' => 'No API test files found',
|
||||
'recommendation' => 'Create API tests for all endpoints'
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Analyze test files
|
||||
$testAnalysis = $this->apiTestAnalyzer->analyze($testFiles, $checkConfig);
|
||||
|
||||
$result['endpoints_tested'] = $testAnalysis['endpoints_tested'];
|
||||
$result['http_methods_covered'] = $testAnalysis['http_methods'];
|
||||
$result['status_codes_tested'] = $testAnalysis['status_codes'];
|
||||
$result['request_response_tests'] = $testAnalysis['request_response_tests'];
|
||||
$result['authentication_tests'] = $testAnalysis['authentication_tests'];
|
||||
$result['coverage_details'] = $testAnalysis['coverage_details'];
|
||||
$result['quality_issues'] = $testAnalysis['quality_issues'];
|
||||
|
||||
// Get coverage metrics
|
||||
$coverageMetrics = $this->coverageAnalyzer->analyzeApiTests($projectPath, $checkConfig);
|
||||
$result['line_coverage'] = $coverageMetrics['line_coverage'] ?? 0;
|
||||
|
||||
$result['recommendations'] = $this->generateApiTestRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check end-to-end test coverage.
|
||||
*/
|
||||
public function checkE2ETestCoverage(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['e2e_test_check'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'e2e_test_coverage',
|
||||
'project_path' => $projectPath,
|
||||
'exists' => false,
|
||||
'test_files_found' => 0,
|
||||
'user_workflows_tested' => 0,
|
||||
'browser_tests' => 0,
|
||||
'mobile_tests' => 0,
|
||||
'critical_paths_covered' => 0,
|
||||
'line_coverage' => 0,
|
||||
'coverage_details' => [],
|
||||
'quality_issues' => [],
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Find E2E test files
|
||||
$testFiles = $this->findTestFiles($projectPath, 'e2e', $checkConfig);
|
||||
$result['test_files_found'] = count($testFiles);
|
||||
$result['exists'] = !empty($testFiles);
|
||||
|
||||
if (empty($testFiles)) {
|
||||
$result['quality_issues'][] = [
|
||||
'type' => 'no_e2e_tests',
|
||||
'severity' => 'medium',
|
||||
'message' => 'No end-to-end test files found',
|
||||
'recommendation' => 'Create E2E tests for critical user workflows'
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Analyze test files
|
||||
$testAnalysis = $this->e2eTestAnalyzer->analyze($testFiles, $checkConfig);
|
||||
|
||||
$result['user_workflows_tested'] = $testAnalysis['workflows'];
|
||||
$result['browser_tests'] = $testAnalysis['browser_tests'];
|
||||
$result['mobile_tests'] = $testAnalysis['mobile_tests'];
|
||||
$result['critical_paths_covered'] = $testAnalysis['critical_paths'];
|
||||
$result['coverage_details'] = $testAnalysis['coverage_details'];
|
||||
$result['quality_issues'] = $testAnalysis['quality_issues'];
|
||||
|
||||
// Get coverage metrics
|
||||
$coverageMetrics = $this->coverageAnalyzer->analyzeE2ETests($projectPath, $checkConfig);
|
||||
$result['line_coverage'] = $coverageMetrics['line_coverage'] ?? 0;
|
||||
|
||||
$result['recommendations'] = $this->generateE2ETestRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze test quality metrics.
|
||||
*/
|
||||
public function analyzeTestQuality(string $projectPath, array $options = []): array
|
||||
{
|
||||
$checkConfig = array_merge($this->config['quality_analysis'] ?? [], $options);
|
||||
|
||||
$result = [
|
||||
'check_type' => 'test_quality_analysis',
|
||||
'project_path' => $projectPath,
|
||||
'test_files_analyzed' => 0,
|
||||
'quality_metrics' => [
|
||||
'test_complexity' => 0,
|
||||
'assertion_density' => 0,
|
||||
'test_isolation' => 0,
|
||||
'mock_usage' => 0,
|
||||
'data_driven_tests' => 0
|
||||
],
|
||||
'quality_issues' => [],
|
||||
'best_practices_violations' => [],
|
||||
'quality_score' => 0,
|
||||
'recommendations' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// Analyze all test types
|
||||
$unitTests = $this->findTestFiles($projectPath, 'unit', $checkConfig);
|
||||
$integrationTests = $this->findTestFiles($projectPath, 'integration', $checkConfig);
|
||||
$apiTests = $this->findTestFiles($projectPath, 'api', $checkConfig);
|
||||
$e2eTests = $this->findTestFiles($projectPath, 'e2e', $checkConfig);
|
||||
|
||||
$allTestFiles = array_merge($unitTests, $integrationTests, $apiTests, $e2eTests);
|
||||
$result['test_files_analyzed'] = count($allTestFiles);
|
||||
|
||||
if (empty($allTestFiles)) {
|
||||
$result['quality_issues'][] = [
|
||||
'type' => 'no_tests_found',
|
||||
'severity' => 'critical',
|
||||
'message' => 'No test files found in the project'
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Analyze test quality
|
||||
$qualityAnalysis = $this->coverageAnalyzer->analyzeQuality($allTestFiles, $checkConfig);
|
||||
|
||||
$result['quality_metrics'] = $qualityAnalysis['metrics'];
|
||||
$result['quality_issues'] = $qualityAnalysis['issues'];
|
||||
$result['best_practices_violations'] = $qualityAnalysis['violations'];
|
||||
$result['quality_score'] = $qualityAnalysis['score'];
|
||||
|
||||
$result['recommendations'] = $this->generateQualityRecommendations($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate comprehensive test coverage report.
|
||||
*/
|
||||
public function generateCoverageReport(array $results, string $format = 'html'): string
|
||||
{
|
||||
return $this->reporter->generate($results, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coverage statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'total_checks' => count($this->coverageResults),
|
||||
'average_coverage' => $this->calculateAverageCoverage(),
|
||||
'coverage_trends' => $this->getCoverageTrends(),
|
||||
'test_distribution' => $this->getTestDistribution(),
|
||||
'quality_scores' => $this->getQualityScores()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear coverage results.
|
||||
*/
|
||||
public function clearResults(): void
|
||||
{
|
||||
$this->coverageResults = [];
|
||||
$this->testMetrics = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find test files by type.
|
||||
*/
|
||||
protected function findTestFiles(string $projectPath, string $testType, array $config): array
|
||||
{
|
||||
$patterns = $config['file_patterns'][$testType] ?? $this->getDefaultPatterns($testType);
|
||||
$files = [];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($projectPath)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = $file->getPathname();
|
||||
$fileName = basename($filePath);
|
||||
|
||||
if (fnmatch($pattern, $fileName)) {
|
||||
$exclude = $config['exclude'] ?? ['vendor', 'node_modules'];
|
||||
$excluded = false;
|
||||
|
||||
foreach ($exclude as $excludePattern) {
|
||||
if (strpos($filePath, $excludePattern) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$excluded) {
|
||||
$files[] = $filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default file patterns for test types.
|
||||
*/
|
||||
protected function getDefaultPatterns(string $testType): array
|
||||
{
|
||||
$patterns = [
|
||||
'unit' => ['*Test.php', '*UnitTest.php', 'tests/unit/*Test.php'],
|
||||
'integration' => ['*IntegrationTest.php', 'tests/integration/*Test.php'],
|
||||
'api' => ['*ApiTest.php', '*EndpointTest.php', 'tests/api/*Test.php'],
|
||||
'e2e' => ['*E2ETest.php', '*EndToEndTest.php', 'tests/e2e/*Test.php', '*FeatureTest.php']
|
||||
];
|
||||
|
||||
return $patterns[$testType] ?? ['*Test.php'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate coverage quality score.
|
||||
*/
|
||||
protected function calculateCoverageQualityScore(array $result): int
|
||||
{
|
||||
$score = 0;
|
||||
|
||||
// Line coverage (40%)
|
||||
$score += ($result['line_coverage'] ?? 0) * 0.4;
|
||||
|
||||
// Branch coverage (20%)
|
||||
$score += ($result['branch_coverage'] ?? 0) * 0.2;
|
||||
|
||||
// Method coverage (20%)
|
||||
$score += ($result['method_coverage'] ?? 0) * 0.2;
|
||||
|
||||
// Class coverage (20%)
|
||||
$score += ($result['class_coverage'] ?? 0) * 0.2;
|
||||
|
||||
// Test type diversity bonus
|
||||
$testTypes = array_filter($result['test_types'] ?? []);
|
||||
$diversityBonus = (count($testTypes) / 4) * 10;
|
||||
$score += $diversityBonus;
|
||||
|
||||
return (int) min(100, round($score));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate coverage recommendations.
|
||||
*/
|
||||
protected function generateCoverageRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['line_coverage'] < 80) {
|
||||
$recommendations[] = 'Increase line coverage to at least 80%';
|
||||
}
|
||||
|
||||
if ($result['branch_coverage'] < 70) {
|
||||
$recommendations[] = 'Improve branch coverage by testing conditional logic';
|
||||
}
|
||||
|
||||
if ($result['method_coverage'] < 85) {
|
||||
$recommendations[] = 'Add tests for uncovered methods';
|
||||
}
|
||||
|
||||
if ($result['class_coverage'] < 80) {
|
||||
$recommendations[] = 'Ensure all classes have test coverage';
|
||||
}
|
||||
|
||||
// Check test type diversity
|
||||
$missingTypes = array_keys(array_filter($result['test_types'] ?? [], fn($v) => !$v));
|
||||
if (!empty($missingTypes)) {
|
||||
$typeNames = [
|
||||
'unit_tests' => 'unit tests',
|
||||
'integration_tests' => 'integration tests',
|
||||
'api_tests' => 'API tests',
|
||||
'e2e_tests' => 'end-to-end tests'
|
||||
];
|
||||
|
||||
$missingNames = array_map(fn($t) => $typeNames[$t] ?? $t, $missingTypes);
|
||||
$recommendations[] = 'Add missing test types: ' . implode(', ', $missingNames);
|
||||
}
|
||||
|
||||
if (!empty($result['uncovered_code'])) {
|
||||
$recommendations[] = 'Write tests for uncovered code files';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Set up automated coverage reporting in CI/CD';
|
||||
$recommendations[] = 'Establish coverage gates for pull requests';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unit test recommendations.
|
||||
*/
|
||||
protected function generateUnitTestRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!$result['exists']) {
|
||||
$recommendations[] = 'Create unit test suite for the project';
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
if ($result['line_coverage'] < 80) {
|
||||
$recommendations[] = 'Improve unit test line coverage';
|
||||
}
|
||||
|
||||
if ($result['test_methods_found'] < $result['test_classes_found'] * 3) {
|
||||
$recommendations[] = 'Add more test methods per test class';
|
||||
}
|
||||
|
||||
if ($result['assertions_found'] < $result['test_methods_found'] * 2) {
|
||||
$recommendations[] = 'Add more assertions to test methods';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Follow AAA pattern (Arrange, Act, Assert)';
|
||||
$recommendations[] = 'Use descriptive test method names';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate integration test recommendations.
|
||||
*/
|
||||
protected function generateIntegrationTestRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!$result['exists']) {
|
||||
$recommendations[] = 'Create integration test suite';
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
if ($result['database_tests'] === 0) {
|
||||
$recommendations[] = 'Add database integration tests';
|
||||
}
|
||||
|
||||
if ($result['cache_tests'] === 0) {
|
||||
$recommendations[] = 'Add cache integration tests';
|
||||
}
|
||||
|
||||
if ($result['external_service_tests'] === 0) {
|
||||
$recommendations[] = 'Add external service integration tests';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Use test containers for integration testing';
|
||||
$recommendations[] = 'Test error scenarios and edge cases';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate API test recommendations.
|
||||
*/
|
||||
protected function generateApiTestRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!$result['exists']) {
|
||||
$recommendations[] = 'Create API test suite';
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
$expectedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
||||
$missingMethods = array_diff($expectedMethods, $result['http_methods_covered']);
|
||||
if (!empty($missingMethods)) {
|
||||
$recommendations[] = 'Test missing HTTP methods: ' . implode(', ', $missingMethods);
|
||||
}
|
||||
|
||||
$expectedStatusCodes = [200, 201, 400, 401, 403, 404, 500];
|
||||
$missingCodes = array_diff($expectedStatusCodes, $result['status_codes_tested']);
|
||||
if (!empty($missingCodes)) {
|
||||
$recommendations[] = 'Test missing status codes: ' . implode(', ', $missingCodes);
|
||||
}
|
||||
|
||||
if ($result['authentication_tests'] === 0) {
|
||||
$recommendations[] = 'Add authentication and authorization tests';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Test API request validation';
|
||||
$recommendations[] = 'Include performance and load testing';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate E2E test recommendations.
|
||||
*/
|
||||
protected function generateE2ETestRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!$result['exists']) {
|
||||
$recommendations[] = 'Create end-to-end test suite';
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
if ($result['critical_paths_covered'] < 5) {
|
||||
$recommendations[] = 'Cover more critical user paths';
|
||||
}
|
||||
|
||||
if ($result['browser_tests'] === 0) {
|
||||
$recommendations[] = 'Add browser-based E2E tests';
|
||||
}
|
||||
|
||||
if ($result['mobile_tests'] === 0) {
|
||||
$recommendations[] = 'Consider mobile E2E testing';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Test cross-browser compatibility';
|
||||
$recommendations[] = 'Include accessibility testing in E2E tests';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate quality recommendations.
|
||||
*/
|
||||
protected function generateQualityRecommendations(array $result): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if ($result['quality_metrics']['test_complexity'] > 10) {
|
||||
$recommendations[] = 'Simplify complex test methods';
|
||||
}
|
||||
|
||||
if ($result['quality_metrics']['assertion_density'] < 2) {
|
||||
$recommendations[] = 'Increase assertion density in tests';
|
||||
}
|
||||
|
||||
if ($result['quality_metrics']['test_isolation'] < 80) {
|
||||
$recommendations[] = 'Improve test isolation and independence';
|
||||
}
|
||||
|
||||
if ($result['quality_metrics']['mock_usage'] < 50) {
|
||||
$recommendations[] = 'Use mocking for external dependencies';
|
||||
}
|
||||
|
||||
if (!empty($result['best_practices_violations'])) {
|
||||
$recommendations[] = 'Address test best practices violations';
|
||||
}
|
||||
|
||||
$recommendations[] = 'Implement test code review process';
|
||||
$recommendations[] = 'Use test-driven development (TDD) practices';
|
||||
|
||||
return array_unique($recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average coverage.
|
||||
*/
|
||||
protected function calculateAverageCoverage(): float
|
||||
{
|
||||
if (empty($this->coverageResults)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalCoverage = 0;
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->coverageResults as $result) {
|
||||
if (isset($result['total_coverage'])) {
|
||||
$totalCoverage += $result['total_coverage'];
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count > 0 ? $totalCoverage / $count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coverage trends.
|
||||
*/
|
||||
protected function getCoverageTrends(): array
|
||||
{
|
||||
if (count($this->coverageResults) < 2) {
|
||||
return ['trend' => 'insufficient_data'];
|
||||
}
|
||||
|
||||
$recent = array_slice($this->coverageResults, -5);
|
||||
$coverages = array_column($recent, 'total_coverage');
|
||||
|
||||
if (count($coverages) < 2) {
|
||||
return ['trend' => 'insufficient_data'];
|
||||
}
|
||||
|
||||
$first = $coverages[0];
|
||||
$last = end($coverages);
|
||||
|
||||
if ($last > $first + 2) {
|
||||
return ['trend' => 'improving', 'change' => $last - $first];
|
||||
} elseif ($last < $first - 2) {
|
||||
return ['trend' => 'declining', 'change' => $first - $last];
|
||||
} else {
|
||||
return ['trend' => 'stable', 'change' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test distribution.
|
||||
*/
|
||||
protected function getTestDistribution(): array
|
||||
{
|
||||
$distribution = [
|
||||
'unit' => 0,
|
||||
'integration' => 0,
|
||||
'api' => 0,
|
||||
'e2e' => 0
|
||||
];
|
||||
|
||||
foreach ($this->coverageResults as $result) {
|
||||
if (isset($result['test_types'])) {
|
||||
foreach ($result['test_types'] as $type => $exists) {
|
||||
if ($exists) {
|
||||
$distribution[$type]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $distribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality scores.
|
||||
*/
|
||||
protected function getQualityScores(): array
|
||||
{
|
||||
$scores = [];
|
||||
foreach ($this->coverageResults as $result) {
|
||||
if (isset($result['quality_score'])) {
|
||||
$scores[] = $result['quality_score'];
|
||||
}
|
||||
}
|
||||
return $scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'coverage_check' => [
|
||||
'min_line_coverage' => 80,
|
||||
'min_branch_coverage' => 70,
|
||||
'min_method_coverage' => 85,
|
||||
'min_class_coverage' => 80
|
||||
],
|
||||
'unit_test_check' => [
|
||||
'min_coverage' => 80,
|
||||
'min_assertions_per_test' => 2
|
||||
],
|
||||
'integration_test_check' => [
|
||||
'min_coverage' => 70,
|
||||
'require_database_tests' => true
|
||||
],
|
||||
'api_test_check' => [
|
||||
'min_coverage' => 75,
|
||||
'require_auth_tests' => true
|
||||
],
|
||||
'e2e_test_check' => [
|
||||
'min_coverage' => 60,
|
||||
'require_critical_paths' => true
|
||||
],
|
||||
'quality_analysis' => [
|
||||
'max_test_complexity' => 10,
|
||||
'min_assertion_density' => 2,
|
||||
'min_test_isolation' => 80
|
||||
],
|
||||
'file_patterns' => [
|
||||
'unit' => ['*Test.php', '*UnitTest.php', 'tests/unit/*Test.php'],
|
||||
'integration' => ['*IntegrationTest.php', 'tests/integration/*Test.php'],
|
||||
'api' => ['*ApiTest.php', '*EndpointTest.php', 'tests/api/*Test.php'],
|
||||
'e2e' => ['*E2ETest.php', '*EndToEndTest.php', 'tests/e2e/*Test.php', '*FeatureTest.php']
|
||||
],
|
||||
'exclude' => ['vendor', 'node_modules', '.git'],
|
||||
'coverage_analyzer' => [],
|
||||
'unit_test_analyzer' => [],
|
||||
'integration_test_analyzer' => [],
|
||||
'api_test_analyzer' => [],
|
||||
'e2e_test_analyzer' => [],
|
||||
'reporter' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test coverage checker instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'coverage_check' => [
|
||||
'min_line_coverage' => 60,
|
||||
'min_branch_coverage' => 50
|
||||
],
|
||||
'unit_test_check' => [
|
||||
'min_coverage' => 60
|
||||
],
|
||||
'quality_analysis' => [
|
||||
'max_test_complexity' => 15
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'coverage_check' => [
|
||||
'min_line_coverage' => 85,
|
||||
'min_branch_coverage' => 75,
|
||||
'min_method_coverage' => 90,
|
||||
'min_class_coverage' => 85
|
||||
],
|
||||
'unit_test_check' => [
|
||||
'min_coverage' => 85,
|
||||
'min_assertions_per_test' => 3
|
||||
],
|
||||
'integration_test_check' => [
|
||||
'min_coverage' => 80,
|
||||
'require_database_tests' => true
|
||||
],
|
||||
'api_test_check' => [
|
||||
'min_coverage' => 85,
|
||||
'require_auth_tests' => true
|
||||
],
|
||||
'quality_analysis' => [
|
||||
'max_test_complexity' => 8,
|
||||
'min_assertion_density' => 3,
|
||||
'min_test_isolation' => 90
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Tracing\Analyzer;
|
||||
|
||||
use Fendx\Service\Tracing\Span\Span;
|
||||
use Fendx\Service\Tracing\Analyzer\Metric\MetricCalculator;
|
||||
use Fendx\Service\Tracing\Analyzer\Pattern\PatternDetector;
|
||||
use Fendx\Service\Tracing\Analyzer\Anomaly\AnomalyDetector;
|
||||
|
||||
class PerformanceAnalyzer
|
||||
{
|
||||
protected MetricCalculator $metricCalculator;
|
||||
protected PatternDetector $patternDetector;
|
||||
protected AnomalyDetector $anomalyDetector;
|
||||
protected array $config = [];
|
||||
protected array $analysisCache = [];
|
||||
protected array $performanceMetrics = [];
|
||||
protected array $bottlenecks = [];
|
||||
protected array $recommendations = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->metricCalculator = new MetricCalculator($this->config['metrics'] ?? []);
|
||||
$this->patternDetector = new PatternDetector($this->config['patterns'] ?? []);
|
||||
$this->anomalyDetector = new AnomalyDetector($this->config['anomaly_detection'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze trace performance.
|
||||
*/
|
||||
public function analyzeTrace(array $spans): array
|
||||
{
|
||||
$traceId = $this->extractTraceId($spans);
|
||||
|
||||
// Check cache
|
||||
$cacheKey = 'trace_' . $traceId;
|
||||
if (isset($this->analysisCache[$cacheKey])) {
|
||||
return $this->analysisCache[$cacheKey];
|
||||
}
|
||||
|
||||
$analysis = [
|
||||
'trace_id' => $traceId,
|
||||
'span_count' => count($spans),
|
||||
'total_duration' => 0,
|
||||
'critical_path' => [],
|
||||
'bottlenecks' => [],
|
||||
'performance_score' => 0,
|
||||
'recommendations' => [],
|
||||
'metrics' => [],
|
||||
'patterns' => [],
|
||||
'anomalies' => []
|
||||
];
|
||||
|
||||
if (empty($spans)) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
// Calculate basic metrics
|
||||
$analysis['total_duration'] = $this->calculateTotalDuration($spans);
|
||||
$analysis['metrics'] = $this->metricCalculator->calculateTraceMetrics($spans);
|
||||
|
||||
// Find critical path
|
||||
$analysis['critical_path'] = $this->findCriticalPath($spans);
|
||||
|
||||
// Identify bottlenecks
|
||||
$analysis['bottlenecks'] = $this->identifyBottlenecks($spans);
|
||||
|
||||
// Detect patterns
|
||||
$analysis['patterns'] = $this->patternDetector->detectPatterns($spans);
|
||||
|
||||
// Detect anomalies
|
||||
$analysis['anomalies'] = $this->anomalyDetector->detectAnomalies($spans);
|
||||
|
||||
// Calculate performance score
|
||||
$analysis['performance_score'] = $this->calculatePerformanceScore($analysis);
|
||||
|
||||
// Generate recommendations
|
||||
$analysis['recommendations'] = $this->generateRecommendations($analysis);
|
||||
|
||||
// Cache result
|
||||
$this->analysisCache[$cacheKey] = $analysis;
|
||||
|
||||
// Limit cache size
|
||||
$this->limitCacheSize();
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze service performance.
|
||||
*/
|
||||
public function analyzeService(array $traces, string $serviceName): array
|
||||
{
|
||||
$serviceTraces = array_filter($traces, function($trace) use ($serviceName) {
|
||||
return $this->isServiceTrace($trace, $serviceName);
|
||||
});
|
||||
|
||||
$analysis = [
|
||||
'service_name' => $serviceName,
|
||||
'trace_count' => count($serviceTraces),
|
||||
'performance_trend' => [],
|
||||
'common_bottlenecks' => [],
|
||||
'service_health' => 'unknown',
|
||||
'sla_compliance' => 0,
|
||||
'error_rate' => 0,
|
||||
'average_response_time' => 0,
|
||||
'recommendations' => []
|
||||
];
|
||||
|
||||
if (empty($serviceTraces)) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
// Calculate service metrics
|
||||
$serviceMetrics = $this->calculateServiceMetrics($serviceTraces);
|
||||
$analysis = array_merge($analysis, $serviceMetrics);
|
||||
|
||||
// Analyze performance trend
|
||||
$analysis['performance_trend'] = $this->analyzePerformanceTrend($serviceTraces);
|
||||
|
||||
// Find common bottlenecks
|
||||
$analysis['common_bottlenecks'] = $this->findCommonBottlenecks($serviceTraces);
|
||||
|
||||
// Assess service health
|
||||
$analysis['service_health'] = $this->assessServiceHealth($analysis);
|
||||
|
||||
// Calculate SLA compliance
|
||||
$analysis['sla_compliance'] = $this->calculateSlaCompliance($serviceTraces);
|
||||
|
||||
// Generate service-specific recommendations
|
||||
$analysis['recommendations'] = $this->generateServiceRecommendations($analysis);
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze endpoint performance.
|
||||
*/
|
||||
public function analyzeEndpoint(array $traces, string $endpoint): array
|
||||
{
|
||||
$endpointTraces = array_filter($traces, function($trace) use ($endpoint) {
|
||||
return $this->isEndpointTrace($trace, $endpoint);
|
||||
});
|
||||
|
||||
$analysis = [
|
||||
'endpoint' => $endpoint,
|
||||
'trace_count' => count($endpointTraces),
|
||||
'performance_metrics' => [],
|
||||
'error_analysis' => [],
|
||||
'performance_distribution' => [],
|
||||
'optimization_opportunities' => []
|
||||
];
|
||||
|
||||
if (empty($endpointTraces)) {
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
// Calculate endpoint metrics
|
||||
$analysis['performance_metrics'] = $this->calculateEndpointMetrics($endpointTraces);
|
||||
|
||||
// Analyze errors
|
||||
$analysis['error_analysis'] = $this->analyzeEndpointErrors($endpointTraces);
|
||||
|
||||
// Analyze performance distribution
|
||||
$analysis['performance_distribution'] = $this->analyzePerformanceDistribution($endpointTraces);
|
||||
|
||||
// Find optimization opportunities
|
||||
$analysis['optimization_opportunities'] = $this->findOptimizationOpportunities($endpointTraces);
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare performance between time periods.
|
||||
*/
|
||||
public function comparePerformance(array $baselineTraces, array $comparisonTraces): array
|
||||
{
|
||||
$baselineMetrics = $this->calculateAggregatedMetrics($baselineTraces);
|
||||
$comparisonMetrics = $this->calculateAggregatedMetrics($comparisonTraces);
|
||||
|
||||
$comparison = [
|
||||
'baseline_period' => [
|
||||
'trace_count' => count($baselineTraces),
|
||||
'metrics' => $baselineMetrics
|
||||
],
|
||||
'comparison_period' => [
|
||||
'trace_count' => count($comparisonTraces),
|
||||
'metrics' => $comparisonMetrics
|
||||
],
|
||||
'performance_changes' => [],
|
||||
'significant_changes' => [],
|
||||
'trend_analysis' => []
|
||||
];
|
||||
|
||||
// Calculate performance changes
|
||||
$comparison['performance_changes'] = $this->calculatePerformanceChanges(
|
||||
$baselineMetrics,
|
||||
$comparisonMetrics
|
||||
);
|
||||
|
||||
// Identify significant changes
|
||||
$comparison['significant_changes'] = $this->identifySignificantChanges(
|
||||
$comparison['performance_changes']
|
||||
);
|
||||
|
||||
// Analyze trends
|
||||
$comparison['trend_analysis'] = $this->analyzeTrends($comparison['performance_changes']);
|
||||
|
||||
return $comparison;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect performance regressions.
|
||||
*/
|
||||
public function detectRegressions(array $currentTraces, array $historicalTraces): array
|
||||
{
|
||||
$currentMetrics = $this->calculateAggregatedMetrics($currentTraces);
|
||||
$historicalMetrics = $this->calculateAggregatedMetrics($historicalTraces);
|
||||
|
||||
$regressions = [
|
||||
'detected_regressions' => [],
|
||||
'severity_levels' => [],
|
||||
'affected_components' => [],
|
||||
'recommendations' => []
|
||||
];
|
||||
|
||||
// Check for regressions in key metrics
|
||||
$keyMetrics = ['average_duration', 'error_rate', 'throughput'];
|
||||
|
||||
foreach ($keyMetrics as $metric) {
|
||||
if (isset($currentMetrics[$metric]) && isset($historicalMetrics[$metric])) {
|
||||
$change = ($currentMetrics[$metric] - $historicalMetrics[$metric]) / $historicalMetrics[$metric];
|
||||
|
||||
// Define regression thresholds
|
||||
$thresholds = $this->config['regression_thresholds'][$metric] ?? [
|
||||
'warning' => 0.1, // 10% increase
|
||||
'critical' => 0.2 // 20% increase
|
||||
];
|
||||
|
||||
if ($change > $thresholds['critical']) {
|
||||
$severity = 'critical';
|
||||
} elseif ($change > $thresholds['warning']) {
|
||||
$severity = 'warning';
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
$regression = [
|
||||
'metric' => $metric,
|
||||
'current_value' => $currentMetrics[$metric],
|
||||
'historical_value' => $historicalMetrics[$metric],
|
||||
'change_percentage' => $change * 100,
|
||||
'severity' => $severity,
|
||||
'description' => $this->generateRegressionDescription($metric, $change)
|
||||
];
|
||||
|
||||
$regressions['detected_regressions'][] = $regression;
|
||||
$regressions['severity_levels'][$severity] = ($regressions['severity_levels'][$severity] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze affected components
|
||||
$regressions['affected_components'] = $this->analyzeAffectedComponents($currentTraces, $regressions['detected_regressions']);
|
||||
|
||||
// Generate recommendations
|
||||
$regressions['recommendations'] = $this->generateRegressionRecommendations($regressions);
|
||||
|
||||
return $regressions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance report.
|
||||
*/
|
||||
public function generatePerformanceReport(array $traces, array $options = []): array
|
||||
{
|
||||
$report = [
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'period' => $options['period'] ?? 'last_24_hours',
|
||||
'summary' => [],
|
||||
'service_analysis' => [],
|
||||
'endpoint_analysis' => [],
|
||||
'bottleneck_analysis' => [],
|
||||
'recommendations' => [],
|
||||
'trends' => []
|
||||
];
|
||||
|
||||
// Generate summary
|
||||
$report['summary'] = $this->generateReportSummary($traces);
|
||||
|
||||
// Analyze services
|
||||
$services = $this->extractServices($traces);
|
||||
foreach ($services as $service) {
|
||||
$report['service_analysis'][$service] = $this->analyzeService($traces, $service);
|
||||
}
|
||||
|
||||
// Analyze endpoints
|
||||
$endpoints = $this->extractEndpoints($traces);
|
||||
foreach ($endpoints as $endpoint) {
|
||||
$report['endpoint_analysis'][$endpoint] = $this->analyzeEndpoint($traces, $endpoint);
|
||||
}
|
||||
|
||||
// Analyze bottlenecks
|
||||
$report['bottleneck_analysis'] = $this->analyzeBottlenecks($traces);
|
||||
|
||||
// Generate overall recommendations
|
||||
$report['recommendations'] = $this->generateOverallRecommendations($report);
|
||||
|
||||
// Analyze trends
|
||||
$report['trends'] = $this->analyzeTrends($traces);
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance insights.
|
||||
*/
|
||||
public function getInsights(array $traces): array
|
||||
{
|
||||
$insights = [
|
||||
'key_findings' => [],
|
||||
'performance_issues' => [],
|
||||
'optimization_opportunities' => [],
|
||||
'risk_factors' => [],
|
||||
'success_factors' => []
|
||||
];
|
||||
|
||||
// Analyze all traces
|
||||
foreach ($traces as $trace) {
|
||||
$analysis = $this->analyzeTrace($trace);
|
||||
|
||||
// Extract key findings
|
||||
if ($analysis['performance_score'] < 70) {
|
||||
$insights['performance_issues'][] = [
|
||||
'trace_id' => $analysis['trace_id'],
|
||||
'score' => $analysis['performance_score'],
|
||||
'main_issues' => array_column($analysis['bottlenecks'], 'type')
|
||||
];
|
||||
}
|
||||
|
||||
// Extract optimization opportunities
|
||||
foreach ($analysis['recommendations'] as $recommendation) {
|
||||
if ($recommendation['priority'] === 'high') {
|
||||
$insights['optimization_opportunities'][] = $recommendation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identify risk factors
|
||||
$insights['risk_factors'] = $this->identifyRiskFactors($traces);
|
||||
|
||||
// Identify success factors
|
||||
$insights['success_factors'] = $this->identifySuccessFactors($traces);
|
||||
|
||||
return $insights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total duration of trace.
|
||||
*/
|
||||
protected function calculateTotalDuration(array $spans): float
|
||||
{
|
||||
$startTime = null;
|
||||
$endTime = null;
|
||||
|
||||
foreach ($spans as $span) {
|
||||
if ($startTime === null || $span->getStartTime() < $startTime) {
|
||||
$startTime = $span->getStartTime();
|
||||
}
|
||||
|
||||
if ($endTime === null || $span->getEndTime() > $endTime) {
|
||||
$endTime = $span->getEndTime();
|
||||
}
|
||||
}
|
||||
|
||||
return $endTime && $startTime ? $endTime - $startTime : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find critical path in trace.
|
||||
*/
|
||||
protected function findCriticalPath(array $spans): array
|
||||
{
|
||||
// Build span tree
|
||||
$spanTree = $this->buildSpanTree($spans);
|
||||
|
||||
// Find longest path
|
||||
$criticalPath = [];
|
||||
$maxDuration = 0;
|
||||
|
||||
foreach ($spanTree as $spanId => $children) {
|
||||
$path = $this->findLongestPath($spanId, $spanTree, $spans);
|
||||
$duration = $this->calculatePathDuration($path, $spans);
|
||||
|
||||
if ($duration > $maxDuration) {
|
||||
$maxDuration = $duration;
|
||||
$criticalPath = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $criticalPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify bottlenecks in trace.
|
||||
*/
|
||||
protected function identifyBottlenecks(array $spans): array
|
||||
{
|
||||
$bottlenecks = [];
|
||||
$thresholds = $this->config['bottleneck_thresholds'] ?? [
|
||||
'duration' => 1000, // 1 second
|
||||
'error_rate' => 0.05, // 5%
|
||||
'cpu_usage' => 80, // 80%
|
||||
'memory_usage' => 90 // 90%
|
||||
];
|
||||
|
||||
foreach ($spans as $span) {
|
||||
$duration = $span->getDuration() * 1000; // Convert to milliseconds
|
||||
$tags = $span->getTags();
|
||||
$status = $span->getStatus();
|
||||
|
||||
// Check duration bottleneck
|
||||
if ($duration > $thresholds['duration']) {
|
||||
$bottlenecks[] = [
|
||||
'type' => 'duration',
|
||||
'span_id' => $span->getContext()->getSpanId(),
|
||||
'operation' => $span->getOperationName(),
|
||||
'duration' => $duration,
|
||||
'threshold' => $thresholds['duration'],
|
||||
'severity' => $this->calculateSeverity($duration, $thresholds['duration'])
|
||||
];
|
||||
}
|
||||
|
||||
// Check error bottleneck
|
||||
if (isset($status['error']) && $status['error']) {
|
||||
$bottlenecks[] = [
|
||||
'type' => 'error',
|
||||
'span_id' => $span->getContext()->getSpanId(),
|
||||
'operation' => $span->getOperationName(),
|
||||
'error' => $status['message'] ?? 'Unknown error',
|
||||
'severity' => 'high'
|
||||
];
|
||||
}
|
||||
|
||||
// Check resource usage bottlenecks
|
||||
if (isset($tags['cpu_usage']) && $tags['cpu_usage'] > $thresholds['cpu_usage']) {
|
||||
$bottlenecks[] = [
|
||||
'type' => 'cpu_usage',
|
||||
'span_id' => $span->getContext()->getSpanId(),
|
||||
'operation' => $span->getOperationName(),
|
||||
'value' => $tags['cpu_usage'],
|
||||
'threshold' => $thresholds['cpu_usage'],
|
||||
'severity' => $this->calculateSeverity($tags['cpu_usage'], $thresholds['cpu_usage'])
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($tags['memory_usage']) && $tags['memory_usage'] > $thresholds['memory_usage']) {
|
||||
$bottlenecks[] = [
|
||||
'type' => 'memory_usage',
|
||||
'span_id' => $span->getContext()->getSpanId(),
|
||||
'operation' => $span->getOperationName(),
|
||||
'value' => $tags['memory_usage'],
|
||||
'threshold' => $thresholds['memory_usage'],
|
||||
'severity' => $this->calculateSeverity($tags['memory_usage'], $thresholds['memory_usage'])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by severity
|
||||
usort($bottlenecks, function($a, $b) {
|
||||
$severityOrder = ['critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1];
|
||||
return $severityOrder[$b['severity']] - $severityOrder[$a['severity']];
|
||||
});
|
||||
|
||||
return $bottlenecks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance score.
|
||||
*/
|
||||
protected function calculatePerformanceScore(array $analysis): float
|
||||
{
|
||||
$score = 100;
|
||||
|
||||
// Deduct points for bottlenecks
|
||||
foreach ($analysis['bottlenecks'] as $bottleneck) {
|
||||
switch ($bottleneck['severity']) {
|
||||
case 'critical':
|
||||
$score -= 20;
|
||||
break;
|
||||
case 'high':
|
||||
$score -= 10;
|
||||
break;
|
||||
case 'medium':
|
||||
$score -= 5;
|
||||
break;
|
||||
case 'low':
|
||||
$score -= 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct points for anomalies
|
||||
foreach ($analysis['anomalies'] as $anomaly) {
|
||||
$score -= 5;
|
||||
}
|
||||
|
||||
// Bonus for good patterns
|
||||
foreach ($analysis['patterns'] as $pattern) {
|
||||
if ($pattern['type'] === 'efficient') {
|
||||
$score += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return max(0, min(100, $score));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recommendations based on analysis.
|
||||
*/
|
||||
protected function generateRecommendations(array $analysis): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
// Generate recommendations for bottlenecks
|
||||
foreach ($analysis['bottlenecks'] as $bottleneck) {
|
||||
$recommendation = $this->generateBottleneckRecommendation($bottleneck);
|
||||
if ($recommendation) {
|
||||
$recommendations[] = $recommendation;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate recommendations for patterns
|
||||
foreach ($analysis['patterns'] as $pattern) {
|
||||
if ($pattern['type'] === 'inefficient') {
|
||||
$recommendations[] = [
|
||||
'type' => 'pattern_optimization',
|
||||
'priority' => 'medium',
|
||||
'description' => "Optimize inefficient pattern: {$pattern['description']}",
|
||||
'action' => $pattern['recommendation'] ?? 'Review and optimize the identified pattern'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate recommendations for anomalies
|
||||
foreach ($analysis['anomalies'] as $anomaly) {
|
||||
$recommendations[] = [
|
||||
'type' => 'anomaly_investigation',
|
||||
'priority' => 'high',
|
||||
'description' => "Investigate anomaly: {$anomaly['description']}",
|
||||
'action' => 'Analyze the root cause of the detected anomaly'
|
||||
];
|
||||
}
|
||||
|
||||
// Remove duplicates and sort by priority
|
||||
$recommendations = $this->deduplicateRecommendations($recommendations);
|
||||
usort($recommendations, function($a, $b) {
|
||||
$priorityOrder = ['high' => 3, 'medium' => 2, 'low' => 1];
|
||||
return $priorityOrder[$b['priority']] - $priorityOrder[$a['priority']];
|
||||
});
|
||||
|
||||
return array_slice($recommendations, 0, 10); // Limit to top 10 recommendations
|
||||
}
|
||||
|
||||
/**
|
||||
* Build span tree from spans.
|
||||
*/
|
||||
protected function buildSpanTree(array $spans): array
|
||||
{
|
||||
$tree = [];
|
||||
|
||||
foreach ($spans as $span) {
|
||||
$spanId = $span->getContext()->getSpanId();
|
||||
$parentContext = $span->getContext()->getParent();
|
||||
|
||||
if ($parentContext) {
|
||||
$parentId = $parentContext->getSpanId();
|
||||
$tree[$parentId][] = $spanId;
|
||||
} else {
|
||||
$tree[$spanId] = $tree[$spanId] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find longest path in span tree.
|
||||
*/
|
||||
protected function findLongestPath(string $spanId, array $tree, array $spans): array
|
||||
{
|
||||
$path = [$spanId];
|
||||
|
||||
if (isset($tree[$spanId])) {
|
||||
$longestChildPath = [];
|
||||
$maxDuration = 0;
|
||||
|
||||
foreach ($tree[$spanId] as $childId) {
|
||||
$childPath = $this->findLongestPath($childId, $tree, $spans);
|
||||
$duration = $this->calculatePathDuration($childPath, $spans);
|
||||
|
||||
if ($duration > $maxDuration) {
|
||||
$maxDuration = $duration;
|
||||
$longestChildPath = $childPath;
|
||||
}
|
||||
}
|
||||
|
||||
$path = array_merge($path, $longestChildPath);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate path duration.
|
||||
*/
|
||||
protected function calculatePathDuration(array $path, array $spans): float
|
||||
{
|
||||
$duration = 0;
|
||||
|
||||
foreach ($path as $spanId) {
|
||||
foreach ($spans as $span) {
|
||||
if ($span->getContext()->getSpanId() === $spanId) {
|
||||
$duration += $span->getDuration();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate severity based on value and threshold.
|
||||
*/
|
||||
protected function calculateSeverity(float $value, float $threshold): string
|
||||
{
|
||||
$ratio = $value / $threshold;
|
||||
|
||||
if ($ratio >= 3) {
|
||||
return 'critical';
|
||||
} elseif ($ratio >= 2) {
|
||||
return 'high';
|
||||
} elseif ($ratio >= 1.5) {
|
||||
return 'medium';
|
||||
} else {
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate bottleneck recommendation.
|
||||
*/
|
||||
protected function generateBottleneckRecommendation(array $bottleneck): ?array
|
||||
{
|
||||
$recommendations = [
|
||||
'duration' => [
|
||||
'description' => "Operation '{$bottleneck['operation']}' is taking too long",
|
||||
'action' => 'Optimize the operation logic, add caching, or consider async processing'
|
||||
],
|
||||
'error' => [
|
||||
'description' => "Operation '{$bottleneck['operation']}' is failing",
|
||||
'action' => 'Investigate and fix the root cause of the error'
|
||||
],
|
||||
'cpu_usage' => [
|
||||
'description' => "High CPU usage in '{$bottleneck['operation']}'",
|
||||
'action' => 'Optimize algorithms, reduce computational complexity, or scale horizontally'
|
||||
],
|
||||
'memory_usage' => [
|
||||
'description' => "High memory usage in '{$bottleneck['operation']}'",
|
||||
'action' => 'Optimize memory usage, implement memory pooling, or increase available memory'
|
||||
]
|
||||
];
|
||||
|
||||
$type = $bottleneck['type'];
|
||||
if (!isset($recommendations[$type])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'priority' => $bottleneck['severity'] === 'critical' ? 'high' : 'medium',
|
||||
'description' => $recommendations[$type]['description'],
|
||||
'action' => $recommendations[$type]['action'],
|
||||
'span_id' => $bottleneck['span_id'],
|
||||
'operation' => $bottleneck['operation']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate recommendations.
|
||||
*/
|
||||
protected function deduplicateRecommendations(array $recommendations): array
|
||||
{
|
||||
$unique = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($recommendations as $recommendation) {
|
||||
$key = $recommendation['type'] . ':' . $recommendation['description'];
|
||||
|
||||
if (!isset($seen[$key])) {
|
||||
$unique[] = $recommendation;
|
||||
$seen[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract trace ID from spans.
|
||||
*/
|
||||
protected function extractTraceId(array $spans): string
|
||||
{
|
||||
if (!empty($spans)) {
|
||||
return $spans[0]->getContext()->getTraceId();
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if trace belongs to service.
|
||||
*/
|
||||
protected function isServiceTrace(array $trace, string $serviceName): bool
|
||||
{
|
||||
foreach ($trace as $span) {
|
||||
$tags = $span->getTags();
|
||||
if (isset($tags['service.name']) && $tags['service.name'] === $serviceName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if trace belongs to endpoint.
|
||||
*/
|
||||
protected function isEndpointTrace(array $trace, string $endpoint): bool
|
||||
{
|
||||
foreach ($trace as $span) {
|
||||
$tags = $span->getTags();
|
||||
if (isset($tags['http.target']) && $tags['http.target'] === $endpoint) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit cache size.
|
||||
*/
|
||||
protected function limitCacheSize(): void
|
||||
{
|
||||
$maxSize = $this->config['cache_size'] ?? 1000;
|
||||
|
||||
if (count($this->analysisCache) > $maxSize) {
|
||||
$this->analysisCache = array_slice($this->analysisCache, -$maxSize, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'bottleneck_thresholds' => [
|
||||
'duration' => 1000, // 1 second
|
||||
'error_rate' => 0.05, // 5%
|
||||
'cpu_usage' => 80, // 80%
|
||||
'memory_usage' => 90 // 90%
|
||||
],
|
||||
'regression_thresholds' => [
|
||||
'average_duration' => ['warning' => 0.1, 'critical' => 0.2],
|
||||
'error_rate' => ['warning' => 0.05, 'critical' => 0.1],
|
||||
'throughput' => ['warning' => -0.1, 'critical' => -0.2]
|
||||
],
|
||||
'cache_size' => 1000,
|
||||
'metrics' => [],
|
||||
'patterns' => [],
|
||||
'anomaly_detection' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create performance analyzer instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'bottleneck_thresholds' => [
|
||||
'duration' => 500, // 500ms for development
|
||||
'error_rate' => 0.1 // 10% for development
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'bottleneck_thresholds' => [
|
||||
'duration' => 2000, // 2 seconds for production
|
||||
'error_rate' => 0.01 // 1% for production
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
597
fendx-framework/fendx-service/src/Tracing/DistributedTracer.php
Normal file
597
fendx-framework/fendx-service/src/Tracing/DistributedTracer.php
Normal file
@@ -0,0 +1,597 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Tracing;
|
||||
|
||||
use Fendx\Service\Tracing\Span\Span;
|
||||
use Fendx\Service\Tracing\Span\SpanContext;
|
||||
use Fendx\Service\Tracing\Sampler\Sampler;
|
||||
use Fendx\Service\Tracing\Exporter\TraceExporter;
|
||||
use Fendx\Service\Tracing\Propagator\TracePropagator;
|
||||
|
||||
class DistributedTracer
|
||||
{
|
||||
protected static ?DistributedTracer $instance = null;
|
||||
protected array $config = [];
|
||||
protected Sampler $sampler;
|
||||
protected TraceExporter $exporter;
|
||||
protected TracePropagator $propagator;
|
||||
protected array $spans = [];
|
||||
protected array $activeSpans = [];
|
||||
protected array $traceContexts = [];
|
||||
protected bool $enabled = true;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->sampler = new Sampler($this->config['sampling'] ?? []);
|
||||
$this->exporter = new TraceExporter($this->config['exporter'] ?? []);
|
||||
$this->propagator = new TracePropagator($this->config['propagation'] ?? []);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*/
|
||||
public static function getInstance(array $config = []): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self($config);
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new span.
|
||||
*/
|
||||
public function startSpan(string $operationName, array $options = []): Span
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
return new Span($operationName, new SpanContext(), $options);
|
||||
}
|
||||
|
||||
$parentContext = $options['parent'] ?? $this->getCurrentContext();
|
||||
$traceId = $parentContext ? $parentContext->getTraceId() : $this->generateTraceId();
|
||||
$spanId = $this->generateSpanId();
|
||||
|
||||
$context = new SpanContext($traceId, $spanId, $parentContext);
|
||||
|
||||
// Check sampling
|
||||
if (!$parentContext) {
|
||||
$sampled = $this->sampler->shouldSample($traceId, $operationName, $options);
|
||||
$context->setSampled($sampled);
|
||||
} else {
|
||||
$context->setSampled($parentContext->isSampled());
|
||||
}
|
||||
|
||||
$span = new Span($operationName, $context, $options);
|
||||
|
||||
// Set start time
|
||||
$span->setStartTime(microtime(true));
|
||||
|
||||
// Add to active spans
|
||||
$this->activeSpans[$spanId] = $span;
|
||||
|
||||
// Set as current span
|
||||
$this->traceContexts[$traceId]['current_span'] = $spanId;
|
||||
|
||||
// Add to spans collection
|
||||
$this->spans[$spanId] = $span;
|
||||
|
||||
$this->logDebug("Started span: {$operationName} (trace: {$traceId}, span: {$spanId})");
|
||||
|
||||
return $span;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish a span.
|
||||
*/
|
||||
public function finishSpan(Span $span, array $options = []): void
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$spanId = $span->getContext()->getSpanId();
|
||||
|
||||
if (!isset($this->activeSpans[$spanId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set end time
|
||||
$span->setEndTime(microtime(true));
|
||||
|
||||
// Calculate duration
|
||||
$duration = $span->getEndTime() - $span->getStartTime();
|
||||
$span->setDuration($duration);
|
||||
|
||||
// Remove from active spans
|
||||
unset($this->activeSpans[$spanId]);
|
||||
|
||||
// Update current span if this was the current one
|
||||
$traceId = $span->getContext()->getTraceId();
|
||||
if (isset($this->traceContexts[$traceId]['current_span']) &&
|
||||
$this->traceContexts[$traceId]['current_span'] === $spanId) {
|
||||
|
||||
// Set parent as current span if available
|
||||
$parentContext = $span->getContext()->getParent();
|
||||
if ($parentContext) {
|
||||
$this->traceContexts[$traceId]['current_span'] = $parentContext->getSpanId();
|
||||
} else {
|
||||
unset($this->traceContexts[$traceId]['current_span']);
|
||||
}
|
||||
}
|
||||
|
||||
// Export if configured
|
||||
if ($span->getContext()->isSampled()) {
|
||||
$this->exporter->export($span);
|
||||
}
|
||||
|
||||
$this->logDebug("Finished span: {$span->getOperationName()} (duration: " .
|
||||
number_format($duration * 1000, 2) . "ms)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current span.
|
||||
*/
|
||||
public function getCurrentSpan(): ?Span
|
||||
{
|
||||
$currentContext = $this->getCurrentContext();
|
||||
if (!$currentContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$spanId = $currentContext->getSpanId();
|
||||
return $this->spans[$spanId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current span context.
|
||||
*/
|
||||
public function getCurrentContext(): ?SpanContext
|
||||
{
|
||||
foreach ($this->traceContexts as $traceId => $context) {
|
||||
if (isset($context['current_span'])) {
|
||||
$spanId = $context['current_span'];
|
||||
if (isset($this->spans[$spanId])) {
|
||||
return $this->spans[$spanId]->getContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute function with tracing.
|
||||
*/
|
||||
public function trace(string $operationName, callable $callback, array $options = [])
|
||||
{
|
||||
$span = $this->startSpan($operationName, $options);
|
||||
|
||||
try {
|
||||
$result = $callback($span);
|
||||
|
||||
// Set success status
|
||||
$span->setStatus(['code' => 0, 'message' => 'OK']);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Set error status
|
||||
$span->setStatus([
|
||||
'code' => 1,
|
||||
'message' => $e->getMessage(),
|
||||
'error' => true
|
||||
]);
|
||||
|
||||
// Add error tags
|
||||
$span->setTag('error', true);
|
||||
$span->setTag('error.message', $e->getMessage());
|
||||
$span->setTag('error.type', get_class($e));
|
||||
|
||||
throw $e;
|
||||
|
||||
} finally {
|
||||
$this->finishSpan($span);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject trace context into headers.
|
||||
*/
|
||||
public function inject(SpanContext $context, array &$headers): void
|
||||
{
|
||||
$this->propagator->inject($context, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract trace context from headers.
|
||||
*/
|
||||
public function extract(array $headers): ?SpanContext
|
||||
{
|
||||
return $this->propagator->extract($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create child span from headers.
|
||||
*/
|
||||
public function startSpanFromHeaders(string $operationName, array $headers, array $options = []): Span
|
||||
{
|
||||
$parentContext = $this->extract($headers);
|
||||
|
||||
if ($parentContext) {
|
||||
$options['parent'] = $parentContext;
|
||||
}
|
||||
|
||||
return $this->startSpan($operationName, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add global tags.
|
||||
*/
|
||||
public function addGlobalTag(string $key, $value): void
|
||||
{
|
||||
$this->config['global_tags'][$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global tags.
|
||||
*/
|
||||
public function getGlobalTags(): array
|
||||
{
|
||||
return $this->config['global_tags'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get span by ID.
|
||||
*/
|
||||
public function getSpan(string $spanId): ?Span
|
||||
{
|
||||
return $this->spans[$spanId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all spans for a trace.
|
||||
*/
|
||||
public function getTraceSpans(string $traceId): array
|
||||
{
|
||||
$traceSpans = [];
|
||||
|
||||
foreach ($this->spans as $spanId => $span) {
|
||||
if ($span->getContext()->getTraceId() === $traceId) {
|
||||
$traceSpans[$spanId] = $span;
|
||||
}
|
||||
}
|
||||
|
||||
return $traceSpans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active spans.
|
||||
*/
|
||||
public function getActiveSpans(): array
|
||||
{
|
||||
return $this->activeSpans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all spans.
|
||||
*/
|
||||
public function getAllSpans(): array
|
||||
{
|
||||
return $this->spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear finished spans.
|
||||
*/
|
||||
public function clearFinishedSpans(): int
|
||||
{
|
||||
$cleared = 0;
|
||||
|
||||
foreach ($this->spans as $spanId => $span) {
|
||||
if ($span->isFinished()) {
|
||||
unset($this->spans[$spanId]);
|
||||
$cleared++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty trace contexts
|
||||
foreach ($this->traceContexts as $traceId => $context) {
|
||||
$hasSpans = false;
|
||||
foreach ($this->spans as $span) {
|
||||
if ($span->getContext()->getTraceId() === $traceId) {
|
||||
$hasSpans = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasSpans) {
|
||||
unset($this->traceContexts[$traceId]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logDebug("Cleared {$cleared} finished spans");
|
||||
|
||||
return $cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all spans.
|
||||
*/
|
||||
public function flush(): void
|
||||
{
|
||||
foreach ($this->spans as $span) {
|
||||
if ($span->getContext()->isSampled() && !$span->isFinished()) {
|
||||
$this->finishSpan($span);
|
||||
}
|
||||
}
|
||||
|
||||
$this->exporter->flush();
|
||||
|
||||
$this->logDebug("Flushed all spans");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tracer statistics.
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$totalSpans = count($this->spans);
|
||||
$activeSpans = count($this->activeSpans);
|
||||
$finishedSpans = $totalSpans - $activeSpans;
|
||||
|
||||
$sampledSpans = 0;
|
||||
foreach ($this->spans as $span) {
|
||||
if ($span->getContext()->isSampled()) {
|
||||
$sampledSpans++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $this->enabled,
|
||||
'total_spans' => $totalSpans,
|
||||
'active_spans' => $activeSpans,
|
||||
'finished_spans' => $finishedSpans,
|
||||
'sampled_spans' => $sampledSpans,
|
||||
'sampling_rate' => $totalSpans > 0 ? ($sampledSpans / $totalSpans) * 100 : 0,
|
||||
'trace_contexts' => count($this->traceContexts),
|
||||
'exporter_stats' => $this->exporter->getStatistics(),
|
||||
'sampler_stats' => $this->sampler->getStatistics()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable tracer.
|
||||
*/
|
||||
public function setEnabled(bool $enabled): void
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
|
||||
$this->logInfo("Tracer " . ($enabled ? 'enabled' : 'disabled'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tracer is enabled.
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set service name.
|
||||
*/
|
||||
public function setServiceName(string $serviceName): void
|
||||
{
|
||||
$this->config['service_name'] = $serviceName;
|
||||
$this->addGlobalTag('service.name', $serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service name.
|
||||
*/
|
||||
public function getServiceName(): string
|
||||
{
|
||||
return $this->config['service_name'] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set service version.
|
||||
*/
|
||||
public function setServiceVersion(string $version): void
|
||||
{
|
||||
$this->config['service_version'] = $version;
|
||||
$this->addGlobalTag('service.version', $version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service version.
|
||||
*/
|
||||
public function getServiceVersion(): string
|
||||
{
|
||||
return $this->config['service_version'] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add baggage item to context.
|
||||
*/
|
||||
public function setBaggageItem(string $key, string $value): void
|
||||
{
|
||||
$currentContext = $this->getCurrentContext();
|
||||
if ($currentContext) {
|
||||
$currentContext->setBaggageItem($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get baggage item from context.
|
||||
*/
|
||||
public function getBaggageItem(string $key): ?string
|
||||
{
|
||||
$currentContext = $this->getCurrentContext();
|
||||
return $currentContext ? $currentContext->getBaggageItem($key) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize tracer.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Set default service info
|
||||
if (!isset($this->config['service_name'])) {
|
||||
$this->setServiceName($this->config['service_name'] ?? 'fendx-app');
|
||||
}
|
||||
|
||||
if (!isset($this->config['service_version'])) {
|
||||
$this->setServiceVersion($this->config['service_version'] ?? '1.0.0');
|
||||
}
|
||||
|
||||
// Add default global tags
|
||||
$this->addGlobalTag('hostname', gethostname());
|
||||
$this->addGlobalTag('php.version', PHP_VERSION);
|
||||
|
||||
// Initialize components
|
||||
$this->sampler->initialize();
|
||||
$this->exporter->initialize();
|
||||
$this->propagator->initialize();
|
||||
|
||||
$this->logInfo("Distributed tracer initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate trace ID.
|
||||
*/
|
||||
protected function generateTraceId(): string
|
||||
{
|
||||
return uniqid('trace_', true) . '_' . bin2hex(random_bytes(8));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate span ID.
|
||||
*/
|
||||
protected function generateSpanId(): string
|
||||
{
|
||||
return uniqid('span_', true) . '_' . bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message.
|
||||
*/
|
||||
protected function logDebug(string $message): void
|
||||
{
|
||||
if ($this->config['debug_enabled']) {
|
||||
error_log("[DistributedTracer] DEBUG: {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[DistributedTracer] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'service_name' => 'fendx-app',
|
||||
'service_version' => '1.0.0',
|
||||
'enabled' => true,
|
||||
'debug_enabled' => false,
|
||||
'logging_enabled' => true,
|
||||
'global_tags' => [],
|
||||
'sampling' => [
|
||||
'type' => 'probabilistic',
|
||||
'param' => 0.1, // 10% sampling rate
|
||||
'max_traces_per_second' => 100
|
||||
],
|
||||
'exporter' => [
|
||||
'type' => 'jaeger',
|
||||
'endpoint' => 'http://localhost:14268/api/traces',
|
||||
'batch_size' => 100,
|
||||
'flush_interval' => 5
|
||||
],
|
||||
'propagation' => [
|
||||
'format' => 'trace-context'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset singleton instance.
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tracer instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'service_name' => 'fendx-app-dev',
|
||||
'debug_enabled' => true,
|
||||
'sampling' => [
|
||||
'type' => 'always_on'
|
||||
],
|
||||
'exporter' => [
|
||||
'type' => 'console'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'service_name' => 'fendx-app',
|
||||
'debug_enabled' => false,
|
||||
'sampling' => [
|
||||
'type' => 'probabilistic',
|
||||
'param' => 0.01 // 1% sampling rate
|
||||
],
|
||||
'exporter' => [
|
||||
'type' => 'jaeger',
|
||||
'endpoint' => getenv('JAEGER_ENDPOINT') ?: 'http://jaeger:14268/api/traces'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Tracing\Service;
|
||||
|
||||
use Fendx\Service\Tracing\DistributedTracer;
|
||||
use Fendx\Service\Tracing\Span\Span;
|
||||
use Fendx\Service\Tracing\Span\SpanContext;
|
||||
use Fendx\Service\Tracing\Service\Client\HttpClient;
|
||||
use Fendx\Service\Tracing\Service\Client\RpcClient;
|
||||
use Fendx\Service\Tracing\Service\Client\QueueClient;
|
||||
use Fendx\Service\Tracing\Service\Interceptor\CallInterceptor;
|
||||
|
||||
class ServiceTracer
|
||||
{
|
||||
protected DistributedTracer $tracer;
|
||||
protected array $config = [];
|
||||
protected array $clients = [];
|
||||
protected array $interceptors = [];
|
||||
protected array $serviceMetrics = [];
|
||||
|
||||
public function __construct(DistributedTracer $tracer, array $config = [])
|
||||
{
|
||||
$this->tracer = $tracer;
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace HTTP request.
|
||||
*/
|
||||
public function traceHttpRequest(string $method, string $url, array $headers = [], $body = null): array
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Extract incoming context
|
||||
$parentContext = $this->tracer->extract($headers);
|
||||
|
||||
// Start span for HTTP request
|
||||
$span = $this->tracer->startSpan('http.request', [
|
||||
'parent' => $parentContext,
|
||||
'tags' => [
|
||||
'http.method' => $method,
|
||||
'http.url' => $url,
|
||||
'component' => 'http-client'
|
||||
]
|
||||
]);
|
||||
|
||||
try {
|
||||
// Inject trace context into headers
|
||||
$this->tracer->inject($span->getContext(), $headers);
|
||||
|
||||
// Add request tags
|
||||
$span->setTag('http.method', $method);
|
||||
$span->setTag('http.url', $url);
|
||||
$span->setTag('http.scheme', parse_url($url, PHP_URL_SCHEME) ?? 'http');
|
||||
$span->setTag('http.host', parse_url($url, PHP_URL_HOST) ?? 'unknown');
|
||||
$span->setTag('http.target', parse_url($url, PHP_URL_PATH) ?? '/');
|
||||
|
||||
if (!empty($body)) {
|
||||
$span->setTag('http.request_content_length', strlen($body));
|
||||
}
|
||||
|
||||
// Execute HTTP request
|
||||
$client = $this->getHttpClient();
|
||||
$response = $client->request($method, $url, $headers, $body);
|
||||
|
||||
// Add response tags
|
||||
$span->setTag('http.status_code', $response['status_code']);
|
||||
$span->setTag('http.response_content_length', $response['content_length'] ?? 0);
|
||||
|
||||
// Set status based on response code
|
||||
if ($response['status_code'] >= 400) {
|
||||
$span->setStatus([
|
||||
'code' => 1,
|
||||
'message' => 'HTTP Error: ' . $response['status_code'],
|
||||
'error' => true
|
||||
]);
|
||||
$span->setTag('error', true);
|
||||
} else {
|
||||
$span->setStatus(['code' => 0, 'message' => 'OK']);
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
$this->recordHttpMetrics($method, $url, $response['status_code'], $startTime);
|
||||
|
||||
return $response;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Set error status
|
||||
$span->setStatus([
|
||||
'code' => 1,
|
||||
'message' => $e->getMessage(),
|
||||
'error' => true
|
||||
]);
|
||||
|
||||
$span->setTag('error', true);
|
||||
$span->setTag('error.message', $e->getMessage());
|
||||
$span->setTag('error.type', get_class($e));
|
||||
|
||||
// Record error metrics
|
||||
$this->recordErrorMetrics('http', $e);
|
||||
|
||||
throw $e;
|
||||
|
||||
} finally {
|
||||
$this->tracer->finishSpan($span);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace RPC call.
|
||||
*/
|
||||
public function traceRpcCall(string $service, string $method, array $params = [], array $metadata = []): mixed
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Extract incoming context
|
||||
$parentContext = $this->tracer->extract($metadata);
|
||||
|
||||
// Start span for RPC call
|
||||
$span = $this->tracer->startSpan('rpc.call', [
|
||||
'parent' => $parentContext,
|
||||
'tags' => [
|
||||
'rpc.service' => $service,
|
||||
'rpc.method' => $method,
|
||||
'component' => 'rpc-client'
|
||||
]
|
||||
]);
|
||||
|
||||
try {
|
||||
// Inject trace context into metadata
|
||||
$this->tracer->inject($span->getContext(), $metadata);
|
||||
|
||||
// Add RPC tags
|
||||
$span->setTag('rpc.service', $service);
|
||||
$span->setTag('rpc.method', $method);
|
||||
$span->setTag('rpc.system', $this->config['rpc_system'] ?? 'grpc');
|
||||
|
||||
// Execute RPC call
|
||||
$client = $this->getRpcClient();
|
||||
$result = $client->call($service, $method, $params, $metadata);
|
||||
|
||||
// Set success status
|
||||
$span->setStatus(['code' => 0, 'message' => 'OK']);
|
||||
|
||||
// Record metrics
|
||||
$this->recordRpcMetrics($service, $method, $startTime);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Set error status
|
||||
$span->setStatus([
|
||||
'code' => 1,
|
||||
'message' => $e->getMessage(),
|
||||
'error' => true
|
||||
]);
|
||||
|
||||
$span->setTag('error', true);
|
||||
$span->setTag('error.message', $e->getMessage());
|
||||
$span->setTag('error.type', get_class($e));
|
||||
|
||||
// Record error metrics
|
||||
$this->recordErrorMetrics('rpc', $e);
|
||||
|
||||
throw $e;
|
||||
|
||||
} finally {
|
||||
$this->tracer->finishSpan($span);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace database query.
|
||||
*/
|
||||
public function traceDatabaseQuery(string $query, array $params = [], string $database = 'default'): mixed
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Start span for database query
|
||||
$span = $this->tracer->startSpan('db.query', [
|
||||
'tags' => [
|
||||
'db.system' => $this->config['db_system'] ?? 'mysql',
|
||||
'db.name' => $database,
|
||||
'db.statement' => $this->sanitizeQuery($query),
|
||||
'component' => 'database'
|
||||
]
|
||||
]);
|
||||
|
||||
try {
|
||||
// Add database tags
|
||||
$span->setTag('db.system', $this->config['db_system'] ?? 'mysql');
|
||||
$span->setTag('db.name', $database);
|
||||
$span->setTag('db.statement', $this->sanitizeQuery($query));
|
||||
$span->setTag('db.user', $this->config['db_user'] ?? 'app');
|
||||
|
||||
// Execute query
|
||||
$result = $this->executeQuery($query, $params, $database);
|
||||
|
||||
// Set success status
|
||||
$span->setStatus(['code' => 0, 'message' => 'OK']);
|
||||
|
||||
// Add result tags
|
||||
if (isset($result['row_count'])) {
|
||||
$span->setTag('db.rows_affected', $result['row_count']);
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
$this->recordDatabaseMetrics($database, $query, $startTime);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Set error status
|
||||
$span->setStatus([
|
||||
'code' => 1,
|
||||
'message' => $e->getMessage(),
|
||||
'error' => true
|
||||
]);
|
||||
|
||||
$span->setTag('error', true);
|
||||
$span->setTag('error.message', $e->getMessage());
|
||||
$span->setTag('error.type', get_class($e));
|
||||
|
||||
// Record error metrics
|
||||
$this->recordErrorMetrics('database', $e);
|
||||
|
||||
throw $e;
|
||||
|
||||
} finally {
|
||||
$this->tracer->finishSpan($span);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace cache operation.
|
||||
*/
|
||||
public function traceCacheOperation(string $operation, string $key, $value = null, string $cache = 'default'): mixed
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Start span for cache operation
|
||||
$span = $this->tracer->startSpan('cache.operation', [
|
||||
'tags' => [
|
||||
'cache.system' => $this->config['cache_system'] ?? 'redis',
|
||||
'cache.name' => $cache,
|
||||
'cache.operation' => $operation,
|
||||
'cache.key' => $key,
|
||||
'component' => 'cache'
|
||||
]
|
||||
]);
|
||||
|
||||
try {
|
||||
// Add cache tags
|
||||
$span->setTag('cache.system', $this->config['cache_system'] ?? 'redis');
|
||||
$span->setTag('cache.name', $cache);
|
||||
$span->setTag('cache.operation', $operation);
|
||||
$span->setTag('cache.key', $key);
|
||||
|
||||
// Execute cache operation
|
||||
$result = $this->executeCacheOperation($operation, $key, $value, $cache);
|
||||
|
||||
// Set success status
|
||||
$span->setStatus(['code' => 0, 'message' => 'OK']);
|
||||
|
||||
// Add result tags
|
||||
$span->setTag('cache.hit', $result !== null);
|
||||
|
||||
// Record metrics
|
||||
$this->recordCacheMetrics($cache, $operation, $result !== null, $startTime);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Set error status
|
||||
$span->setStatus([
|
||||
'code' => 1,
|
||||
'message' => $e->getMessage(),
|
||||
'error' => true
|
||||
]);
|
||||
|
||||
$span->setTag('error', true);
|
||||
$span->setTag('error.message', $e->getMessage());
|
||||
$span->setTag('error.type', get_class($e));
|
||||
|
||||
// Record error metrics
|
||||
$this->recordErrorMetrics('cache', $e);
|
||||
|
||||
throw $e;
|
||||
|
||||
} finally {
|
||||
$this->tracer->finishSpan($span);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace message queue operation.
|
||||
*/
|
||||
public function traceMessageQueue(string $operation, string $topic, array $message = [], string $queue = 'default'): mixed
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Start span for queue operation
|
||||
$span = $this->tracer->startSpan('queue.operation', [
|
||||
'tags' => [
|
||||
'messaging.system' => $this->config['queue_system'] ?? 'rabbitmq',
|
||||
'messaging.destination' => $topic,
|
||||
'messaging.operation' => $operation,
|
||||
'messaging.message_id' => $message['id'] ?? uniqid(),
|
||||
'component' => 'message-queue'
|
||||
]
|
||||
]);
|
||||
|
||||
try {
|
||||
// Add queue tags
|
||||
$span->setTag('messaging.system', $this->config['queue_system'] ?? 'rabbitmq');
|
||||
$span->setTag('messaging.destination', $topic);
|
||||
$span->setTag('messaging.operation', $operation);
|
||||
|
||||
// Inject trace context into message
|
||||
$this->tracer->inject($span->getContext(), $message['headers'] ?? []);
|
||||
|
||||
// Execute queue operation
|
||||
$client = $this->getQueueClient();
|
||||
$result = $client->$operation($topic, $message, $queue);
|
||||
|
||||
// Set success status
|
||||
$span->setStatus(['code' => 0, 'message' => 'OK']);
|
||||
|
||||
// Record metrics
|
||||
$this->recordQueueMetrics($queue, $operation, $startTime);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Set error status
|
||||
$span->setStatus([
|
||||
'code' => 1,
|
||||
'message' => $e->getMessage(),
|
||||
'error' => true
|
||||
]);
|
||||
|
||||
$span->setTag('error', true);
|
||||
$span->setTag('error.message', $e->getMessage());
|
||||
$span->setTag('error.type', get_class($e));
|
||||
|
||||
// Record error metrics
|
||||
$this->recordErrorMetrics('queue', $e);
|
||||
|
||||
throw $e;
|
||||
|
||||
} finally {
|
||||
$this->tracer->finishSpan($span);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace external service call.
|
||||
*/
|
||||
public function traceExternalService(string $serviceName, string $operation, array $params = []): mixed
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Start span for external service call
|
||||
$span = $this->tracer->startSpan('external.service', [
|
||||
'tags' => [
|
||||
'external.service' => $serviceName,
|
||||
'external.operation' => $operation,
|
||||
'component' => 'external-client'
|
||||
]
|
||||
]);
|
||||
|
||||
try {
|
||||
// Add external service tags
|
||||
$span->setTag('external.service', $serviceName);
|
||||
$span->setTag('external.operation', $operation);
|
||||
|
||||
// Execute external service call
|
||||
$result = $this->callExternalService($serviceName, $operation, $params);
|
||||
|
||||
// Set success status
|
||||
$span->setStatus(['code' => 0, 'message' => 'OK']);
|
||||
|
||||
// Record metrics
|
||||
$this->recordExternalServiceMetrics($serviceName, $operation, $startTime);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Set error status
|
||||
$span->setStatus([
|
||||
'code' => 1,
|
||||
'message' => $e->getMessage(),
|
||||
'error' => true
|
||||
]);
|
||||
|
||||
$span->setTag('error', true);
|
||||
$span->setTag('error.message', $e->getMessage());
|
||||
$span->setTag('error.type', get_class($e));
|
||||
|
||||
// Record error metrics
|
||||
$this->recordErrorMetrics('external', $e);
|
||||
|
||||
throw $e;
|
||||
|
||||
} finally {
|
||||
$this->tracer->finishSpan($span);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add service interceptor.
|
||||
*/
|
||||
public function addInterceptor(string $name, CallInterceptor $interceptor): void
|
||||
{
|
||||
$this->interceptors[$name] = $interceptor;
|
||||
|
||||
$this->logInfo("Added service interceptor: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove service interceptor.
|
||||
*/
|
||||
public function removeInterceptor(string $name): bool
|
||||
{
|
||||
if (!isset($this->interceptors[$name])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($this->interceptors[$name]);
|
||||
|
||||
$this->logInfo("Removed service interceptor: {$name}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service metrics.
|
||||
*/
|
||||
public function getServiceMetrics(): array
|
||||
{
|
||||
return $this->serviceMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset service metrics.
|
||||
*/
|
||||
public function resetMetrics(): void
|
||||
{
|
||||
$this->serviceMetrics = [];
|
||||
|
||||
$this->logInfo("Reset service metrics");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HTTP client.
|
||||
*/
|
||||
protected function getHttpClient(): HttpClient
|
||||
{
|
||||
if (!isset($this->clients['http'])) {
|
||||
$this->clients['http'] = new HttpClient($this->config['http_client'] ?? []);
|
||||
}
|
||||
|
||||
return $this->clients['http'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RPC client.
|
||||
*/
|
||||
protected function getRpcClient(): RpcClient
|
||||
{
|
||||
if (!isset($this->clients['rpc'])) {
|
||||
$this->clients['rpc'] = new RpcClient($this->config['rpc_client'] ?? []);
|
||||
}
|
||||
|
||||
return $this->clients['rpc'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue client.
|
||||
*/
|
||||
protected function getQueueClient(): QueueClient
|
||||
{
|
||||
if (!isset($this->clients['queue'])) {
|
||||
$this->clients['queue'] = new QueueClient($this->config['queue_client'] ?? []);
|
||||
}
|
||||
|
||||
return $this->clients['queue'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute database query.
|
||||
*/
|
||||
protected function executeQuery(string $query, array $params, string $database): mixed
|
||||
{
|
||||
// This would be implemented based on your database layer
|
||||
// For now, return a mock result
|
||||
return ['row_count' => 1, 'data' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute cache operation.
|
||||
*/
|
||||
protected function executeCacheOperation(string $operation, string $key, $value, string $cache): mixed
|
||||
{
|
||||
// This would be implemented based on your cache layer
|
||||
// For now, return a mock result
|
||||
return $operation === 'get' ? 'cached_value' : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call external service.
|
||||
*/
|
||||
protected function callExternalService(string $serviceName, string $operation, array $params): mixed
|
||||
{
|
||||
// This would be implemented based on your external service client
|
||||
// For now, return a mock result
|
||||
return ['result' => 'success', 'data' => $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize SQL query for logging.
|
||||
*/
|
||||
protected function sanitizeQuery(string $query): string
|
||||
{
|
||||
// Remove sensitive data from query
|
||||
$query = preg_replace('/\b(password\s*=\s*[\'"][^\'"]*[\'"])\b/', 'password=***', $query);
|
||||
$query = preg_replace('/\b(token\s*=\s*[\'"][^\'"]*[\'"])\b/', 'token=***', $query);
|
||||
|
||||
// Truncate long queries
|
||||
if (strlen($query) > 500) {
|
||||
$query = substr($query, 0, 497) . '...';
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record HTTP metrics.
|
||||
*/
|
||||
protected function recordHttpMetrics(string $method, string $url, int $statusCode, float $startTime): void
|
||||
{
|
||||
$duration = (microtime(true) - $startTime) * 1000; // Convert to milliseconds
|
||||
|
||||
$host = parse_url($url, PHP_URL_HOST) ?? 'unknown';
|
||||
$path = parse_url($url, PHP_URL_PATH) ?? '/';
|
||||
|
||||
if (!isset($this->serviceMetrics['http'])) {
|
||||
$this->serviceMetrics['http'] = [
|
||||
'total_requests' => 0,
|
||||
'total_duration' => 0,
|
||||
'error_count' => 0,
|
||||
'status_codes' => [],
|
||||
'hosts' => [],
|
||||
'methods' => []
|
||||
];
|
||||
}
|
||||
|
||||
$metrics = &$this->serviceMetrics['http'];
|
||||
$metrics['total_requests']++;
|
||||
$metrics['total_duration'] += $duration;
|
||||
|
||||
if ($statusCode >= 400) {
|
||||
$metrics['error_count']++;
|
||||
}
|
||||
|
||||
$metrics['status_codes'][$statusCode] = ($metrics['status_codes'][$statusCode] ?? 0) + 1;
|
||||
$metrics['hosts'][$host] = ($metrics['hosts'][$host] ?? 0) + 1;
|
||||
$metrics['methods'][$method] = ($metrics['methods'][$method] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record RPC metrics.
|
||||
*/
|
||||
protected function recordRpcMetrics(string $service, string $method, float $startTime): void
|
||||
{
|
||||
$duration = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
if (!isset($this->serviceMetrics['rpc'])) {
|
||||
$this->serviceMetrics['rpc'] = [
|
||||
'total_calls' => 0,
|
||||
'total_duration' => 0,
|
||||
'services' => [],
|
||||
'methods' => []
|
||||
];
|
||||
}
|
||||
|
||||
$metrics = &$this->serviceMetrics['rpc'];
|
||||
$metrics['total_calls']++;
|
||||
$metrics['total_duration'] += $duration;
|
||||
|
||||
$metrics['services'][$service] = ($metrics['services'][$service] ?? 0) + 1;
|
||||
$metrics['methods'][$method] = ($metrics['methods'][$method] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record database metrics.
|
||||
*/
|
||||
protected function recordDatabaseMetrics(string $database, string $query, float $startTime): void
|
||||
{
|
||||
$duration = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
$queryType = $this->extractQueryType($query);
|
||||
|
||||
if (!isset($this->serviceMetrics['database'])) {
|
||||
$this->serviceMetrics['database'] = [
|
||||
'total_queries' => 0,
|
||||
'total_duration' => 0,
|
||||
'databases' => [],
|
||||
'query_types' => []
|
||||
];
|
||||
}
|
||||
|
||||
$metrics = &$this->serviceMetrics['database'];
|
||||
$metrics['total_queries']++;
|
||||
$metrics['total_duration'] += $duration;
|
||||
|
||||
$metrics['databases'][$database] = ($metrics['databases'][$database] ?? 0) + 1;
|
||||
$metrics['query_types'][$queryType] = ($metrics['query_types'][$queryType] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record cache metrics.
|
||||
*/
|
||||
protected function recordCacheMetrics(string $cache, string $operation, bool $hit, float $startTime): void
|
||||
{
|
||||
$duration = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
if (!isset($this->serviceMetrics['cache'])) {
|
||||
$this->serviceMetrics['cache'] = [
|
||||
'total_operations' => 0,
|
||||
'total_duration' => 0,
|
||||
'hits' => 0,
|
||||
'misses' => 0,
|
||||
'caches' => [],
|
||||
'operations' => []
|
||||
];
|
||||
}
|
||||
|
||||
$metrics = &$this->serviceMetrics['cache'];
|
||||
$metrics['total_operations']++;
|
||||
$metrics['total_duration'] += $duration;
|
||||
|
||||
if ($hit) {
|
||||
$metrics['hits']++;
|
||||
} else {
|
||||
$metrics['misses']++;
|
||||
}
|
||||
|
||||
$metrics['caches'][$cache] = ($metrics['caches'][$cache] ?? 0) + 1;
|
||||
$metrics['operations'][$operation] = ($metrics['operations'][$operation] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record queue metrics.
|
||||
*/
|
||||
protected function recordQueueMetrics(string $queue, string $operation, float $startTime): void
|
||||
{
|
||||
$duration = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
if (!isset($this->serviceMetrics['queue'])) {
|
||||
$this->serviceMetrics['queue'] = [
|
||||
'total_operations' => 0,
|
||||
'total_duration' => 0,
|
||||
'queues' => [],
|
||||
'operations' => []
|
||||
];
|
||||
}
|
||||
|
||||
$metrics = &$this->serviceMetrics['queue'];
|
||||
$metrics['total_operations']++;
|
||||
$metrics['total_duration'] += $duration;
|
||||
|
||||
$metrics['queues'][$queue] = ($metrics['queues'][$queue] ?? 0) + 1;
|
||||
$metrics['operations'][$operation] = ($metrics['operations'][$operation] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record external service metrics.
|
||||
*/
|
||||
protected function recordExternalServiceMetrics(string $serviceName, string $operation, float $startTime): void
|
||||
{
|
||||
$duration = (microtime(true) - $startTime) * 1000;
|
||||
|
||||
if (!isset($this->serviceMetrics['external'])) {
|
||||
$this->serviceMetrics['external'] = [
|
||||
'total_calls' => 0,
|
||||
'total_duration' => 0,
|
||||
'services' => [],
|
||||
'operations' => []
|
||||
];
|
||||
}
|
||||
|
||||
$metrics = &$this->serviceMetrics['external'];
|
||||
$metrics['total_calls']++;
|
||||
$metrics['total_duration'] += $duration;
|
||||
|
||||
$metrics['services'][$serviceName] = ($metrics['services'][$serviceName] ?? 0) + 1;
|
||||
$metrics['operations'][$operation] = ($metrics['operations'][$operation] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record error metrics.
|
||||
*/
|
||||
protected function recordErrorMetrics(string $component, \Exception $e): void
|
||||
{
|
||||
if (!isset($this->serviceMetrics['errors'])) {
|
||||
$this->serviceMetrics['errors'] = [
|
||||
'total_errors' => 0,
|
||||
'components' => [],
|
||||
'error_types' => []
|
||||
];
|
||||
}
|
||||
|
||||
$metrics = &$this->serviceMetrics['errors'];
|
||||
$metrics['total_errors']++;
|
||||
|
||||
$metrics['components'][$component] = ($metrics['components'][$component] ?? 0) + 1;
|
||||
$metrics['error_types'][get_class($e)] = ($metrics['error_types'][get_class($e)] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract query type from SQL.
|
||||
*/
|
||||
protected function extractQueryType(string $query): string
|
||||
{
|
||||
$query = trim(strtoupper($query));
|
||||
|
||||
if (strpos($query, 'SELECT') === 0) return 'SELECT';
|
||||
if (strpos($query, 'INSERT') === 0) return 'INSERT';
|
||||
if (strpos($query, 'UPDATE') === 0) return 'UPDATE';
|
||||
if (strpos($query, 'DELETE') === 0) return 'DELETE';
|
||||
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize service tracer.
|
||||
*/
|
||||
protected function initialize(): void
|
||||
{
|
||||
// Add default interceptors
|
||||
if ($this->config['enable_default_interceptors'] ?? true) {
|
||||
$this->addInterceptor('metrics', new \Fendx\Service\Tracing\Service\Interceptor\MetricsInterceptor());
|
||||
$this->addInterceptor('logging', new \Fendx\Service\Tracing\Service\Interceptor\LoggingInterceptor());
|
||||
}
|
||||
|
||||
$this->logInfo("Service tracer initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message.
|
||||
*/
|
||||
protected function logInfo(string $message): void
|
||||
{
|
||||
if ($this->config['logging_enabled']) {
|
||||
error_log("[ServiceTracer] {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'rpc_system' => 'grpc',
|
||||
'db_system' => 'mysql',
|
||||
'cache_system' => 'redis',
|
||||
'queue_system' => 'rabbitmq',
|
||||
'enable_default_interceptors' => true,
|
||||
'logging_enabled' => true,
|
||||
'http_client' => [
|
||||
'timeout' => 30,
|
||||
'follow_redirects' => true
|
||||
],
|
||||
'rpc_client' => [
|
||||
'timeout' => 10,
|
||||
'retry_attempts' => 3
|
||||
],
|
||||
'queue_client' => [
|
||||
'timeout' => 5,
|
||||
'persistent' => true
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create service tracer instance.
|
||||
*/
|
||||
public static function create(DistributedTracer $tracer, array $config = []): self
|
||||
{
|
||||
return new self($tracer, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(DistributedTracer $tracer): self
|
||||
{
|
||||
return new self($tracer, [
|
||||
'logging_enabled' => true,
|
||||
'enable_default_interceptors' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(DistributedTracer $tracer): self
|
||||
{
|
||||
return new self($tracer, [
|
||||
'logging_enabled' => false,
|
||||
'enable_default_interceptors' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,952 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Service\Tracing\Visualization;
|
||||
|
||||
use Fendx\Service\Tracing\Span\Span;
|
||||
use Fendx\Service\Tracing\Visualization\Renderer\HtmlRenderer;
|
||||
use Fendx\Service\Tracing\Visualization\Renderer\JsonRenderer;
|
||||
use Fendx\Service\Tracing\Visualization\Renderer\SvgRenderer;
|
||||
use Fendx\Service\Tracing\Visualization\Layout\TreeLayout;
|
||||
use Fendx\Service\Tracing\Visualization\Layout\TimelineLayout;
|
||||
|
||||
class TraceVisualizer
|
||||
{
|
||||
protected array $config = [];
|
||||
protected array $renderers = [];
|
||||
protected array $layouts = [];
|
||||
protected array $colorSchemes = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->getDefaultConfig(), $config);
|
||||
$this->initializeRenderers();
|
||||
$this->initializeLayouts();
|
||||
$this->initializeColorSchemes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualize trace as HTML.
|
||||
*/
|
||||
public function visualizeHtml(array $spans, array $options = []): string
|
||||
{
|
||||
$renderer = $this->renderers['html'];
|
||||
$layout = $this->selectLayout($spans, $options['layout'] ?? 'timeline');
|
||||
$colorScheme = $this->selectColorScheme($options['color_scheme'] ?? 'default');
|
||||
|
||||
$visualizationData = $this->prepareVisualizationData($spans, $layout, $colorScheme);
|
||||
|
||||
return $renderer->render($visualizationData, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualize trace as SVG.
|
||||
*/
|
||||
public function visualizeSvg(array $spans, array $options = []): string
|
||||
{
|
||||
$renderer = $this->renderers['svg'];
|
||||
$layout = $this->selectLayout($spans, $options['layout'] ?? 'timeline');
|
||||
$colorScheme = $this->selectColorScheme($options['color_scheme'] ?? 'default');
|
||||
|
||||
$visualizationData = $this->prepareVisualizationData($spans, $layout, $colorScheme);
|
||||
|
||||
return $renderer->render($visualizationData, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualize trace as JSON.
|
||||
*/
|
||||
public function visualizeJson(array $spans, array $options = []): string
|
||||
{
|
||||
$renderer = $this->renderers['json'];
|
||||
$layout = $this->selectLayout($spans, $options['layout'] ?? 'timeline');
|
||||
|
||||
$visualizationData = $this->prepareVisualizationData($spans, $layout);
|
||||
|
||||
return $renderer->render($visualizationData, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate trace timeline.
|
||||
*/
|
||||
public function generateTimeline(array $spans, array $options = []): array
|
||||
{
|
||||
$timeline = [
|
||||
'trace_id' => $this->extractTraceId($spans),
|
||||
'total_duration' => $this->calculateTotalDuration($spans),
|
||||
'spans' => [],
|
||||
'gaps' => [],
|
||||
'parallelism' => [],
|
||||
'waterfall' => []
|
||||
];
|
||||
|
||||
// Sort spans by start time
|
||||
usort($spans, function($a, $b) {
|
||||
return $a->getStartTime() <=> $b->getStartTime();
|
||||
});
|
||||
|
||||
$baseTime = $spans[0]->getStartTime();
|
||||
|
||||
foreach ($spans as $span) {
|
||||
$spanData = [
|
||||
'span_id' => $span->getContext()->getSpanId(),
|
||||
'parent_id' => $span->getContext()->getParent()?->getSpanId(),
|
||||
'operation' => $span->getOperationName(),
|
||||
'start_time' => ($span->getStartTime() - $baseTime) * 1000, // milliseconds
|
||||
'duration' => $span->getDuration() * 1000, // milliseconds
|
||||
'end_time' => ($span->getEndTime() - $baseTime) * 1000,
|
||||
'service' => $span->getTags()['service.name'] ?? 'unknown',
|
||||
'status' => $span->getStatus(),
|
||||
'tags' => $span->getTags(),
|
||||
'level' => $this->calculateSpanLevel($span, $spans)
|
||||
];
|
||||
|
||||
$timeline['spans'][] = $spanData;
|
||||
}
|
||||
|
||||
// Calculate gaps
|
||||
$timeline['gaps'] = $this->calculateGaps($timeline['spans']);
|
||||
|
||||
// Calculate parallelism
|
||||
$timeline['parallelism'] = $this->calculateParallelism($timeline['spans']);
|
||||
|
||||
// Generate waterfall data
|
||||
$timeline['waterfall'] = $this->generateWaterfall($timeline['spans']);
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate service map.
|
||||
*/
|
||||
public function generateServiceMap(array $traces): array
|
||||
{
|
||||
$serviceMap = [
|
||||
'services' => [],
|
||||
'connections' => [],
|
||||
'metrics' => []
|
||||
];
|
||||
|
||||
// Extract services and connections
|
||||
foreach ($traces as $trace) {
|
||||
foreach ($trace as $span) {
|
||||
$serviceName = $span->getTags()['service.name'] ?? 'unknown';
|
||||
|
||||
// Add service
|
||||
if (!isset($serviceMap['services'][$serviceName])) {
|
||||
$serviceMap['services'][$serviceName] = [
|
||||
'name' => $serviceName,
|
||||
'span_count' => 0,
|
||||
'total_duration' => 0,
|
||||
'error_count' => 0,
|
||||
'operations' => []
|
||||
];
|
||||
}
|
||||
|
||||
$serviceMap['services'][$serviceName]['span_count']++;
|
||||
$serviceMap['services'][$serviceName]['total_duration'] += $span->getDuration();
|
||||
|
||||
if (isset($span->getStatus()['error']) && $span->getStatus()['error']) {
|
||||
$serviceMap['services'][$serviceName]['error_count']++;
|
||||
}
|
||||
|
||||
$operation = $span->getOperationName();
|
||||
if (!in_array($operation, $serviceMap['services'][$serviceName]['operations'])) {
|
||||
$serviceMap['services'][$serviceName]['operations'][] = $operation;
|
||||
}
|
||||
|
||||
// Add connections
|
||||
$parentContext = $span->getContext()->getParent();
|
||||
if ($parentContext) {
|
||||
$parentService = $this->findServiceBySpanId($trace, $parentContext->getSpanId());
|
||||
if ($parentService && $parentService !== $serviceName) {
|
||||
$connectionKey = $parentService . '->' . $serviceName;
|
||||
|
||||
if (!isset($serviceMap['connections'][$connectionKey])) {
|
||||
$serviceMap['connections'][$connectionKey] = [
|
||||
'from' => $parentService,
|
||||
'to' => $serviceName,
|
||||
'count' => 0,
|
||||
'avg_duration' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$serviceMap['connections'][$connectionKey]['count']++;
|
||||
$serviceMap['connections'][$connectionKey]['avg_duration'] += $span->getDuration();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
foreach ($serviceMap['connections'] as &$connection) {
|
||||
$connection['avg_duration'] /= $connection['count'];
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
$serviceMap['metrics'] = $this->calculateServiceMapMetrics($serviceMap);
|
||||
|
||||
return $serviceMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flame graph.
|
||||
*/
|
||||
public function generateFlameGraph(array $spans, array $options = []): array
|
||||
{
|
||||
$flameGraph = [
|
||||
'name' => 'root',
|
||||
'value' => 0,
|
||||
'children' => [],
|
||||
'metadata' => [
|
||||
'trace_id' => $this->extractTraceId($spans),
|
||||
'total_spans' => count($spans)
|
||||
]
|
||||
];
|
||||
|
||||
// Build span tree
|
||||
$spanTree = $this->buildSpanTree($spans);
|
||||
|
||||
// Find root spans
|
||||
$rootSpans = [];
|
||||
foreach ($spans as $span) {
|
||||
if (!$span->getContext()->getParent()) {
|
||||
$rootSpans[] = $span;
|
||||
}
|
||||
}
|
||||
|
||||
// Build flame graph
|
||||
foreach ($rootSpans as $rootSpan) {
|
||||
$childData = $this->buildFlameGraphNode($rootSpan, $spanTree, $spans);
|
||||
$flameGraph['children'][] = $childData;
|
||||
$flameGraph['value'] += $childData['value'];
|
||||
}
|
||||
|
||||
return $flameGraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Gantt chart.
|
||||
*/
|
||||
public function generateGanttChart(array $spans, array $options = []): array
|
||||
{
|
||||
$ganttChart = [
|
||||
'trace_id' => $this->extractTraceId($spans),
|
||||
'tasks' => [],
|
||||
'dependencies' => [],
|
||||
'milestones' => [],
|
||||
'timeline' => []
|
||||
];
|
||||
|
||||
// Sort spans by start time
|
||||
usort($spans, function($a, $b) {
|
||||
return $a->getStartTime() <=> $b->getStartTime();
|
||||
});
|
||||
|
||||
$baseTime = $spans[0]->getStartTime();
|
||||
|
||||
foreach ($spans as $index => $span) {
|
||||
$task = [
|
||||
'id' => $span->getContext()->getSpanId(),
|
||||
'name' => $span->getOperationName(),
|
||||
'service' => $span->getTags()['service.name'] ?? 'unknown',
|
||||
'start' => ($span->getStartTime() - $baseTime) * 1000,
|
||||
'duration' => $span->getDuration() * 1000,
|
||||
'end' => ($span->getEndTime() - $baseTime) * 1000,
|
||||
'progress' => 100, // Completed spans
|
||||
'status' => $this->getTaskStatus($span),
|
||||
'dependencies' => [],
|
||||
'level' => $this->calculateSpanLevel($span, $spans)
|
||||
];
|
||||
|
||||
// Add parent dependency
|
||||
$parentContext = $span->getContext()->getParent();
|
||||
if ($parentContext) {
|
||||
$task['dependencies'][] = $parentContext->getSpanId();
|
||||
$ganttChart['dependencies'][] = [
|
||||
'from' => $parentContext->getSpanId(),
|
||||
'to' => $span->getContext()->getSpanId()
|
||||
];
|
||||
}
|
||||
|
||||
$ganttChart['tasks'][] = $task;
|
||||
}
|
||||
|
||||
// Generate timeline markers
|
||||
$ganttChart['timeline'] = $this->generateTimelineMarkers($spans, $baseTime);
|
||||
|
||||
return $ganttChart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance heatmap.
|
||||
*/
|
||||
public function generateHeatmap(array $traces, array $options = []): array
|
||||
{
|
||||
$heatmap = [
|
||||
'type' => $options['type'] ?? 'duration',
|
||||
'data' => [],
|
||||
'scale' => [],
|
||||
'metadata' => []
|
||||
];
|
||||
|
||||
// Group traces by time intervals
|
||||
$interval = $options['interval'] ?? 3600; // 1 hour default
|
||||
$groupedTraces = $this->groupTracesByTime($traces, $interval);
|
||||
|
||||
// Calculate heatmap data
|
||||
foreach ($groupedTraces as $timestamp => $traceGroup) {
|
||||
$value = $this->calculateHeatmapValue($traceGroup, $heatmap['type']);
|
||||
|
||||
$heatmap['data'][] = [
|
||||
'timestamp' => $timestamp,
|
||||
'value' => $value,
|
||||
'trace_count' => count($traceGroup)
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate scale
|
||||
$values = array_column($heatmap['data'], 'value');
|
||||
if (!empty($values)) {
|
||||
$heatmap['scale'] = [
|
||||
'min' => min($values),
|
||||
'max' => max($values),
|
||||
'avg' => array_sum($values) / count($values)
|
||||
];
|
||||
}
|
||||
|
||||
$heatmap['metadata'] = [
|
||||
'interval' => $interval,
|
||||
'total_traces' => count($traces),
|
||||
'time_range' => [
|
||||
'start' => min(array_keys($groupedTraces)),
|
||||
'end' => max(array_keys($groupedTraces))
|
||||
]
|
||||
];
|
||||
|
||||
return $heatmap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom renderer.
|
||||
*/
|
||||
public function addRenderer(string $name, object $renderer): void
|
||||
{
|
||||
$this->renderers[$name] = $renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom layout.
|
||||
*/
|
||||
public function addLayout(string $name, object $layout): void
|
||||
{
|
||||
$this->layouts[$name] = $layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom color scheme.
|
||||
*/
|
||||
public function addColorScheme(string $name, array $colors): void
|
||||
{
|
||||
$this->colorSchemes[$name] = $colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export visualization data.
|
||||
*/
|
||||
public function exportData(array $spans, string $format = 'json'): string
|
||||
{
|
||||
$data = [
|
||||
'trace_id' => $this->extractTraceId($spans),
|
||||
'spans' => $this->serializeSpans($spans),
|
||||
'timeline' => $this->generateTimeline($spans),
|
||||
'flame_graph' => $this->generateFlameGraph($spans),
|
||||
'gantt_chart' => $this->generateGanttChart($spans),
|
||||
'exported_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
case 'php':
|
||||
return '<?php return ' . var_export($data, true) . ';';
|
||||
default:
|
||||
throw new \InvalidArgumentException("Unsupported export format: {$format}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare visualization data.
|
||||
*/
|
||||
protected function prepareVisualizationData(array $spans, object $layout, array $colorScheme = null): array
|
||||
{
|
||||
$data = [
|
||||
'spans' => $spans,
|
||||
'layout' => $layout->layout($spans),
|
||||
'metadata' => [
|
||||
'trace_id' => $this->extractTraceId($spans),
|
||||
'span_count' => count($spans),
|
||||
'total_duration' => $this->calculateTotalDuration($spans),
|
||||
'services' => $this->extractServices($spans)
|
||||
]
|
||||
];
|
||||
|
||||
if ($colorScheme) {
|
||||
$data['colors'] = $colorScheme;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select layout based on spans and options.
|
||||
*/
|
||||
protected function selectLayout(array $spans, string $layoutType): object
|
||||
{
|
||||
if (isset($this->layouts[$layoutType])) {
|
||||
return $this->layouts[$layoutType];
|
||||
}
|
||||
|
||||
// Auto-select layout based on span count
|
||||
if (count($spans) > 50) {
|
||||
return $this->layouts['timeline'];
|
||||
} else {
|
||||
return $this->layouts['tree'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select color scheme.
|
||||
*/
|
||||
protected function selectColorScheme(string $schemeName): array
|
||||
{
|
||||
return $this->colorSchemes[$schemeName] ?? $this->colorSchemes['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total duration.
|
||||
*/
|
||||
protected function calculateTotalDuration(array $spans): float
|
||||
{
|
||||
if (empty($spans)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$startTime = null;
|
||||
$endTime = null;
|
||||
|
||||
foreach ($spans as $span) {
|
||||
if ($startTime === null || $span->getStartTime() < $startTime) {
|
||||
$startTime = $span->getStartTime();
|
||||
}
|
||||
|
||||
if ($endTime === null || $span->getEndTime() > $endTime) {
|
||||
$endTime = $span->getEndTime();
|
||||
}
|
||||
}
|
||||
|
||||
return $endTime && $startTime ? $endTime - $startTime : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate span level in tree.
|
||||
*/
|
||||
protected function calculateSpanLevel(Span $span, array $spans): int
|
||||
{
|
||||
$level = 0;
|
||||
$currentContext = $span->getContext()->getParent();
|
||||
|
||||
while ($currentContext) {
|
||||
$level++;
|
||||
$parentSpan = $this->findSpanById($spans, $currentContext->getSpanId());
|
||||
$currentContext = $parentSpan ? $parentSpan->getContext()->getParent() : null;
|
||||
}
|
||||
|
||||
return $level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build span tree.
|
||||
*/
|
||||
protected function buildSpanTree(array $spans): array
|
||||
{
|
||||
$tree = [];
|
||||
|
||||
foreach ($spans as $span) {
|
||||
$spanId = $span->getContext()->getSpanId();
|
||||
$parentContext = $span->getContext()->getParent();
|
||||
|
||||
if ($parentContext) {
|
||||
$parentId = $parentContext->getSpanId();
|
||||
$tree[$parentId][] = $span;
|
||||
} else {
|
||||
$tree[$spanId] = $tree[$spanId] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build flame graph node.
|
||||
*/
|
||||
protected function buildFlameGraphNode(Span $span, array $spanTree, array $spans): array
|
||||
{
|
||||
$node = [
|
||||
'name' => $span->getOperationName(),
|
||||
'value' => $span->getDuration() * 1000, // Convert to milliseconds
|
||||
'children' => [],
|
||||
'metadata' => [
|
||||
'span_id' => $span->getContext()->getSpanId(),
|
||||
'service' => $span->getTags()['service.name'] ?? 'unknown',
|
||||
'status' => $span->getStatus()
|
||||
]
|
||||
];
|
||||
|
||||
$spanId = $span->getContext()->getSpanId();
|
||||
if (isset($spanTree[$spanId])) {
|
||||
foreach ($spanTree[$spanId] as $childSpan) {
|
||||
$childNode = $this->buildFlameGraphNode($childSpan, $spanTree, $spans);
|
||||
$node['children'][] = $childNode;
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate gaps between spans.
|
||||
*/
|
||||
protected function calculateGaps(array $spans): array
|
||||
{
|
||||
$gaps = [];
|
||||
|
||||
for ($i = 1; $i < count($spans); $i++) {
|
||||
$prevSpan = $spans[$i - 1];
|
||||
$currSpan = $spans[$i];
|
||||
|
||||
$gap = $currSpan['start_time'] - $prevSpan['end_time'];
|
||||
|
||||
if ($gap > 0) {
|
||||
$gaps[] = [
|
||||
'start' => $prevSpan['end_time'],
|
||||
'end' => $currSpan['start_time'],
|
||||
'duration' => $gap,
|
||||
'between_spans' => [$prevSpan['span_id'], $currSpan['span_id']]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $gaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate parallelism.
|
||||
*/
|
||||
protected function calculateParallelism(array $spans): array
|
||||
{
|
||||
$parallelism = [];
|
||||
$timePoints = [];
|
||||
|
||||
// Collect all start and end points
|
||||
foreach ($spans as $span) {
|
||||
$timePoints[] = ['time' => $span['start_time'], 'type' => 'start', 'span_id' => $span['span_id']];
|
||||
$timePoints[] = ['time' => $span['end_time'], 'type' => 'end', 'span_id' => $span['span_id']];
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
usort($timePoints, function($a, $b) {
|
||||
return $a['time'] <=> $b['time'];
|
||||
});
|
||||
|
||||
// Calculate concurrent spans at each point
|
||||
$concurrent = 0;
|
||||
foreach ($timePoints as $point) {
|
||||
if ($point['type'] === 'start') {
|
||||
$concurrent++;
|
||||
} else {
|
||||
$concurrent--;
|
||||
}
|
||||
|
||||
$parallelism[] = [
|
||||
'time' => $point['time'],
|
||||
'concurrent_spans' => $concurrent
|
||||
];
|
||||
}
|
||||
|
||||
return $parallelism;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate waterfall data.
|
||||
*/
|
||||
protected function generateWaterfall(array $spans): array
|
||||
{
|
||||
$waterfall = [];
|
||||
|
||||
foreach ($spans as $span) {
|
||||
$waterfall[] = [
|
||||
'span_id' => $span['span_id'],
|
||||
'operation' => $span['operation'],
|
||||
'service' => $span['service'],
|
||||
'start' => $span['start_time'],
|
||||
'duration' => $span['duration'],
|
||||
'level' => $span['level'],
|
||||
'color' => $this->getSpanColor($span)
|
||||
];
|
||||
}
|
||||
|
||||
return $waterfall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get span color based on status and duration.
|
||||
*/
|
||||
protected function getSpanColor(array $span): string
|
||||
{
|
||||
if (isset($span['status']['error']) && $span['status']['error']) {
|
||||
return '#ff4444'; // Red for errors
|
||||
}
|
||||
|
||||
$duration = $span['duration'];
|
||||
if ($duration > 5000) { // > 5 seconds
|
||||
return '#ff8800'; // Orange for slow
|
||||
} elseif ($duration > 1000) { // > 1 second
|
||||
return '#ffaa00'; // Yellow for moderate
|
||||
} else {
|
||||
return '#44ff44'; // Green for fast
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find span by ID.
|
||||
*/
|
||||
protected function findSpanById(array $spans, string $spanId): ?Span
|
||||
{
|
||||
foreach ($spans as $span) {
|
||||
if ($span->getContext()->getSpanId() === $spanId) {
|
||||
return $span;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find service by span ID in trace.
|
||||
*/
|
||||
protected function findServiceBySpanId(array $trace, string $spanId): ?string
|
||||
{
|
||||
foreach ($trace as $span) {
|
||||
if ($span->getContext()->getSpanId() === $spanId) {
|
||||
return $span->getTags()['service.name'] ?? 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract trace ID.
|
||||
*/
|
||||
protected function extractTraceId(array $spans): string
|
||||
{
|
||||
if (!empty($spans)) {
|
||||
return $spans[0]->getContext()->getTraceId();
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract services from spans.
|
||||
*/
|
||||
protected function extractServices(array $spans): array
|
||||
{
|
||||
$services = [];
|
||||
|
||||
foreach ($spans as $span) {
|
||||
$serviceName = $span->getTags()['service.name'] ?? 'unknown';
|
||||
if (!in_array($serviceName, $services)) {
|
||||
$services[] = $serviceName;
|
||||
}
|
||||
}
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task status from span.
|
||||
*/
|
||||
protected function getTaskStatus(Span $span): string
|
||||
{
|
||||
$status = $span->getStatus();
|
||||
|
||||
if (isset($status['error']) && $status['error']) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate timeline markers.
|
||||
*/
|
||||
protected function generateTimelineMarkers(array $spans, float $baseTime): array
|
||||
{
|
||||
$markers = [];
|
||||
|
||||
foreach ($spans as $span) {
|
||||
$markers[] = [
|
||||
'time' => ($span->getStartTime() - $baseTime) * 1000,
|
||||
'type' => 'start',
|
||||
'span_id' => $span->getContext()->getSpanId(),
|
||||
'operation' => $span->getOperationName()
|
||||
];
|
||||
|
||||
$markers[] = [
|
||||
'time' => ($span->getEndTime() - $baseTime) * 1000,
|
||||
'type' => 'end',
|
||||
'span_id' => $span->getContext()->getSpanId(),
|
||||
'operation' => $span->getOperationName()
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
usort($markers, function($a, $b) {
|
||||
return $a['time'] <=> $b['time'];
|
||||
});
|
||||
|
||||
return $markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group traces by time intervals.
|
||||
*/
|
||||
protected function groupTracesByTime(array $traces, int $interval): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($traces as $trace) {
|
||||
if (!empty($trace)) {
|
||||
$timestamp = floor($trace[0]->getStartTime() / $interval) * $interval;
|
||||
$grouped[$timestamp][] = $trace;
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate heatmap value.
|
||||
*/
|
||||
protected function calculateHeatmapValue(array $traces, string $type): float
|
||||
{
|
||||
switch ($type) {
|
||||
case 'duration':
|
||||
$durations = [];
|
||||
foreach ($traces as $trace) {
|
||||
$durations[] = $this->calculateTotalDuration($trace);
|
||||
}
|
||||
return !empty($durations) ? array_sum($durations) / count($durations) : 0;
|
||||
|
||||
case 'error_rate':
|
||||
$totalSpans = 0;
|
||||
$errorSpans = 0;
|
||||
foreach ($traces as $trace) {
|
||||
foreach ($trace as $span) {
|
||||
$totalSpans++;
|
||||
if (isset($span->getStatus()['error']) && $span->getStatus()['error']) {
|
||||
$errorSpans++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $totalSpans > 0 ? ($errorSpans / $totalSpans) * 100 : 0;
|
||||
|
||||
case 'throughput':
|
||||
return count($traces);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate service map metrics.
|
||||
*/
|
||||
protected function calculateServiceMapMetrics(array $serviceMap): array
|
||||
{
|
||||
$metrics = [
|
||||
'total_services' => count($serviceMap['services']),
|
||||
'total_connections' => count($serviceMap['connections']),
|
||||
'total_spans' => 0,
|
||||
'total_errors' => 0,
|
||||
'average_duration' => 0
|
||||
];
|
||||
|
||||
foreach ($serviceMap['services'] as $service) {
|
||||
$metrics['total_spans'] += $service['span_count'];
|
||||
$metrics['total_errors'] += $service['error_count'];
|
||||
$metrics['average_duration'] += $service['total_duration'];
|
||||
}
|
||||
|
||||
if ($metrics['total_spans'] > 0) {
|
||||
$metrics['average_duration'] /= $metrics['total_spans'];
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize spans for export.
|
||||
*/
|
||||
protected function serializeSpans(array $spans): array
|
||||
{
|
||||
$serialized = [];
|
||||
|
||||
foreach ($spans as $span) {
|
||||
$serialized[] = [
|
||||
'span_id' => $span->getContext()->getSpanId(),
|
||||
'trace_id' => $span->getContext()->getTraceId(),
|
||||
'parent_id' => $span->getContext()->getParent()?->getSpanId(),
|
||||
'operation' => $span->getOperationName(),
|
||||
'start_time' => $span->getStartTime(),
|
||||
'end_time' => $span->getEndTime(),
|
||||
'duration' => $span->getDuration(),
|
||||
'status' => $span->getStatus(),
|
||||
'tags' => $span->getTags(),
|
||||
'logs' => $span->getLogs()
|
||||
];
|
||||
}
|
||||
|
||||
return $serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize renderers.
|
||||
*/
|
||||
protected function initializeRenderers(): void
|
||||
{
|
||||
$this->renderers['html'] = new HtmlRenderer($this->config['html'] ?? []);
|
||||
$this->renderers['svg'] = new SvgRenderer($this->config['svg'] ?? []);
|
||||
$this->renderers['json'] = new JsonRenderer($this->config['json'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize layouts.
|
||||
*/
|
||||
protected function initializeLayouts(): void
|
||||
{
|
||||
$this->layouts['tree'] = new TreeLayout($this->config['layouts']['tree'] ?? []);
|
||||
$this->layouts['timeline'] = new TimelineLayout($this->config['layouts']['timeline'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize color schemes.
|
||||
*/
|
||||
protected function initializeColorSchemes(): void
|
||||
{
|
||||
$this->colorSchemes['default'] = [
|
||||
'success' => '#28a745',
|
||||
'warning' => '#ffc107',
|
||||
'error' => '#dc3545',
|
||||
'info' => '#17a2b8',
|
||||
'primary' => '#007bff',
|
||||
'secondary' => '#6c757d'
|
||||
];
|
||||
|
||||
$this->colorSchemes['dark'] = [
|
||||
'success' => '#28a745',
|
||||
'warning' => '#ffc107',
|
||||
'error' => '#dc3545',
|
||||
'info' => '#17a2b8',
|
||||
'primary' => '#007bff',
|
||||
'secondary' => '#6c757d'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration.
|
||||
*/
|
||||
protected function getDefaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'html' => [
|
||||
'template' => 'default',
|
||||
'include_css' => true,
|
||||
'include_js' => true
|
||||
],
|
||||
'svg' => [
|
||||
'width' => 1200,
|
||||
'height' => 600,
|
||||
'margin' => 20
|
||||
],
|
||||
'json' => [
|
||||
'pretty_print' => true,
|
||||
'include_metadata' => true
|
||||
],
|
||||
'layouts' => [
|
||||
'tree' => [
|
||||
'horizontal_spacing' => 150,
|
||||
'vertical_spacing' => 50
|
||||
],
|
||||
'timeline' => [
|
||||
'row_height' => 30,
|
||||
'min_width' => 800
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration.
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create trace visualizer instance.
|
||||
*/
|
||||
public static function create(array $config = []): self
|
||||
{
|
||||
return new self($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for development.
|
||||
*/
|
||||
public static function forDevelopment(): self
|
||||
{
|
||||
return new self([
|
||||
'html' => [
|
||||
'include_css' => true,
|
||||
'include_js' => true
|
||||
],
|
||||
'debug' => true
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create for production.
|
||||
*/
|
||||
public static function forProduction(): self
|
||||
{
|
||||
return new self([
|
||||
'html' => [
|
||||
'include_css' => false,
|
||||
'include_js' => false
|
||||
],
|
||||
'debug' => false
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user