diff --git a/.travis.yml b/.travis.yml index 21863df..de21e6d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,7 +26,7 @@ script: - composer cs-fix - composer phpstan - composer psalm - - ./vendor/bin/phpqa --report --ignoredDirs vendor + - composer phpqa after_success: - bash <(curl -s https://codecov.io/bash) diff --git a/composer.json b/composer.json index e5cdee7..3405f5e 100644 --- a/composer.json +++ b/composer.json @@ -14,13 +14,15 @@ } ], "require": { - "php": "^7.2" + "php": "^7.2", + "ext-json": "*" }, "require-dev": { "edgedesign/phpqa": "v1.21.1", "friendsofphp/php-cs-fixer": "^2.12", "guzzlehttp/guzzle": "^6.3", "jakub-onderka/php-parallel-lint": "^1.0", + "mikey179/vfsStream": "^1.6", "phpstan/phpstan": "^0.11.5", "phpstan/phpstan-phpunit": "^0.11.0", "phpunit/phpunit": "^8.0", diff --git a/src/Contracts/TransitionDispatcher.php b/src/Contracts/TransitionDispatcher.php new file mode 100644 index 0000000..8264f36 --- /dev/null +++ b/src/Contracts/TransitionDispatcher.php @@ -0,0 +1,20 @@ +circuitBreaker = $circuitBreaker; $this->eventName = $eventName; $this->service = $service; $this->parameters = $parameters; } + /** + * @return CircuitBreaker the Circuit Breaker + */ + public function getCircuitBreaker(): CircuitBreaker + { + return $this->circuitBreaker; + } + /** * @return string the Transition name */ diff --git a/src/PartialCircuitBreaker.php b/src/MainCircuitBreaker.php similarity index 50% rename from src/PartialCircuitBreaker.php rename to src/MainCircuitBreaker.php index fbfcb7d..4c68a6b 100644 --- a/src/PartialCircuitBreaker.php +++ b/src/MainCircuitBreaker.php @@ -2,7 +2,9 @@ namespace Resiliency; +use Resiliency\Contracts\TransitionDispatcher; use Resiliency\Transactions\SimpleTransaction; +use Resiliency\Exceptions\UnavailableService; use Resiliency\Contracts\CircuitBreaker; use Resiliency\Contracts\Transaction; use Resiliency\Contracts\Storage; @@ -11,43 +13,100 @@ use Resiliency\Contracts\Place; use DateTime; -abstract class PartialCircuitBreaker implements CircuitBreaker +/** + * Main implementation of the Circuit Breaker. + */ +final class MainCircuitBreaker implements CircuitBreaker { public function __construct( System $system, Client $client, - Storage $storage + Storage $storage, + TransitionDispatcher $transitionDispatcher ) { $this->currentPlace = $system->getInitialPlace(); $this->places = $system->getPlaces(); $this->client = $client; $this->storage = $storage; + $this->transitionDispatcher = $transitionDispatcher; } /** * @var Client the Client that consumes the service URI */ - protected $client; + private $client; /** * @var Place the current Place of the Circuit Breaker */ - protected $currentPlace; + private $currentPlace; /** * @var Place[] the Circuit Breaker places */ - protected $places = []; + private $places; /** * @var Storage the Circuit Breaker storage */ - protected $storage; + private $storage; + + /** + * @var TransitionDispatcher the Circuit Breaker transition dispatcher + */ + private $transitionDispatcher; /** * {@inheritdoc} */ - abstract public function call(string $service, callable $fallback, array $serviceParameters = []): string; + public function call(string $service, callable $fallback, array $serviceParameters = []): string + { + $transaction = $this->initTransaction($service, $serviceParameters); + + if ($this->isOpened()) { + if (!$this->canAccessService($transaction)) { + return (string) $fallback(); + } + + $transaction = $this->moveStateTo(States::HALF_OPEN_STATE, $service); + $this->dispatch( + Transitions::CHECKING_AVAILABILITY_TRANSITION, + $service, + $serviceParameters + ); + } + + try { + $response = $this->request($service, $serviceParameters); + $this->moveStateTo(States::CLOSED_STATE, $service); + $this->dispatch( + Transitions::CLOSING_TRANSITION, + $service, + $serviceParameters + ); + + return $response; + } catch (UnavailableService $exception) { + $transaction->incrementFailures(); + $this->storage->saveTransaction($service, $transaction); + + if (!$this->isAllowedToRetry($transaction)) { + $this->moveStateTo(States::OPEN_STATE, $service); + + $transition = Transitions::OPENING_TRANSITION; + + if ($this->isHalfOpened()) { + $transition = Transitions::REOPENING_TRANSITION; + } + + $this->dispatch($transition, $service, $serviceParameters); + + return (string) $fallback(); + } + + return $this->call($service, $fallback, $serviceParameters); + } + } /** * {@inheritdoc} @@ -87,7 +146,7 @@ public function isClosed(): bool * * @return Transaction */ - protected function moveStateTo($state, $service): Transaction + private function moveStateTo($state, $service): Transaction { $this->currentPlace = $this->places[$state]; $transaction = SimpleTransaction::createFromPlace( @@ -102,15 +161,18 @@ protected function moveStateTo($state, $service): Transaction /** * @param string $service the service URI + * @param array $serviceParameters the service UI parameters * * @return Transaction */ - protected function initTransaction(string $service): Transaction + private function initTransaction(string $service, $serviceParameters): Transaction { if ($this->storage->hasTransaction($service)) { $transaction = $this->storage->getTransaction($service); $this->currentPlace = $this->places[$transaction->getState()]; } else { + $this->dispatch(Transitions::INITIATING_TRANSITION, $service, $serviceParameters); + $transaction = SimpleTransaction::createFromPlace( $this->currentPlace, $service @@ -127,7 +189,7 @@ protected function initTransaction(string $service): Transaction * * @return bool */ - protected function isAllowedToRetry(Transaction $transaction): bool + private function isAllowedToRetry(Transaction $transaction): bool { return $transaction->getFailures() < $this->currentPlace->getFailures(); } @@ -137,7 +199,7 @@ protected function isAllowedToRetry(Transaction $transaction): bool * * @return bool */ - protected function canAccessService(Transaction $transaction): bool + private function canAccessService(Transaction $transaction): bool { return $transaction->getThresholdDateTime() < new DateTime(); } @@ -150,8 +212,10 @@ protected function canAccessService(Transaction $transaction): bool * * @return string */ - protected function request(string $service, array $parameters = []): string + private function request(string $service, array $parameters = []): string { + $this->dispatch(Transitions::TRIAL_TRANSITION, $service, $parameters); + return $this->client->request( $service, array_merge($parameters, [ @@ -160,4 +224,23 @@ protected function request(string $service, array $parameters = []): string ]) ); } + + /** + * Helper to dispatch transition events. + * + * @param string $transition the transition name + * @param string $service the URI service called + * @param array $parameters the service parameters + */ + private function dispatch($transition, $service, array $parameters): void + { + $this->transitionDispatcher + ->dispatch( + $this, + $transition, + $service, + $parameters + ) + ; + } } diff --git a/src/SimpleCircuitBreaker.php b/src/SimpleCircuitBreaker.php deleted file mode 100644 index 2b7bbdf..0000000 --- a/src/SimpleCircuitBreaker.php +++ /dev/null @@ -1,53 +0,0 @@ -initTransaction($service); - - if ($this->isOpened()) { - if (!$this->canAccessService($transaction)) { - return (string) $fallback(); - } - - $transaction = $this->moveStateTo(States::HALF_OPEN_STATE, $service); - } - - try { - $response = $this->request($service); - $this->moveStateTo(States::CLOSED_STATE, $service); - - return $response; - } catch (UnavailableService $exception) { - $transaction->incrementFailures(); - $this->storage->saveTransaction($service, $transaction); - - if (!$this->isAllowedToRetry($transaction)) { - $this->moveStateTo(States::OPEN_STATE, $service); - - return (string) $fallback(); - } - - return $this->call($service, $fallback, $serviceParameters); - } - } -} diff --git a/src/SimpleCircuitBreakerFactory.php b/src/SimpleCircuitBreakerFactory.php index d505d27..627be52 100644 --- a/src/SimpleCircuitBreakerFactory.php +++ b/src/SimpleCircuitBreakerFactory.php @@ -2,15 +2,16 @@ namespace Resiliency; +use Resiliency\TransitionDispatchers\SimpleDispatcher; use Resiliency\Contracts\CircuitBreaker; -use Resiliency\Contracts\Factory; +use Resiliency\Storages\SimpleArray; use Resiliency\Clients\GuzzleClient; use Resiliency\Systems\MainSystem; -use Resiliency\Storages\SimpleArray; +use Resiliency\Contracts\Factory; /** * Main implementation of Circuit Breaker Factory - * Used to create a SimpleCircuitBreaker instance. + * Used to create a basic CircuitBreaker instance. */ final class SimpleCircuitBreakerFactory implements Factory { @@ -24,10 +25,11 @@ public function create(array $settings): CircuitBreaker $clientSettings = array_key_exists('client', $settings) ? (array) $settings['client'] : []; $client = new GuzzleClient($clientSettings); - return new SimpleCircuitBreaker( + return new MainCircuitBreaker( $mainSystem, $client, - new SimpleArray() + new SimpleArray(), + new SimpleDispatcher('php://stdout') ); } } diff --git a/src/SymfonyCircuitBreaker.php b/src/SymfonyCircuitBreaker.php deleted file mode 100644 index 6da03e7..0000000 --- a/src/SymfonyCircuitBreaker.php +++ /dev/null @@ -1,128 +0,0 @@ -eventDispatcher = $eventDispatcher; - - parent::__construct($system, $client, $storage); - } - - /** - * {@inheritdoc} - */ - public function call(string $service, callable $fallback, array $serviceParameters = []): string - { - $transaction = $this->initTransaction($service); - - if ($this->isOpened()) { - if (!$this->canAccessService($transaction)) { - return (string) $fallback(); - } - - $transaction = $this->moveStateTo(States::HALF_OPEN_STATE, $service); - $this->dispatch( - Transitions::CHECKING_AVAILABILITY_TRANSITION, - $service, - $serviceParameters - ); - } - - try { - $response = $this->request($service, $serviceParameters); - $this->moveStateTo(States::CLOSED_STATE, $service); - $this->dispatch( - Transitions::CLOSING_TRANSITION, - $service, - $serviceParameters - ); - - return $response; - } catch (UnavailableService $exception) { - $transaction->incrementFailures(); - $this->storage->saveTransaction($service, $transaction); - - if (!$this->isAllowedToRetry($transaction)) { - $this->moveStateTo(States::OPEN_STATE, $service); - - $transition = Transitions::OPENING_TRANSITION; - - if ($this->isHalfOpened()) { - $transition = Transitions::REOPENING_TRANSITION; - } - - $this->dispatch($transition, $service, $serviceParameters); - - return (string) $fallback(); - } - - return $this->call($service, $fallback, $serviceParameters); - } - } - - /** - * {@inheritdoc} - */ - protected function initTransaction(string $service): Transaction - { - if (!$this->storage->hasTransaction($service)) { - $this->dispatch(Transitions::INITIATING_TRANSITION, $service, []); - } - - return parent::initTransaction($service); - } - - /** - * {@inheritdoc} - */ - protected function request(string $service, array $parameters = []): string - { - $this->dispatch(Transitions::TRIAL_TRANSITION, $service, $parameters); - - return parent::request($service, $parameters); - } - - /** - * Helper to dispatch event - * - * @param string $eventName the event name - * @param string $service the URI service called - * @param array $parameters the service parameters - * - * @return object the passed $event object - */ - private function dispatch($eventName, $service, array $parameters): object - { - $event = new TransitionEvent($eventName, $service, $parameters); - - return $this->eventDispatcher - ->dispatch( - 'resiliency.' . strtolower($eventName), - $event - ) - ; - } -} diff --git a/src/TransitionDispatchers/SimpleDispatcher.php b/src/TransitionDispatchers/SimpleDispatcher.php new file mode 100644 index 0000000..aa9ac94 --- /dev/null +++ b/src/TransitionDispatchers/SimpleDispatcher.php @@ -0,0 +1,39 @@ +destination = $destination; + } + + /** + * {@inheritdoc} + */ + public function dispatch(CircuitBreaker $circuitBreaker, $transition, $service, array $parameters): void + { + $eventMessage = sprintf( + '[%s]:"%s"_(%s)_%s', + $transition, + $service, + $circuitBreaker->getState(), + json_encode($parameters) + ); + + error_log($eventMessage, 3, $this->destination); + } +} diff --git a/src/TransitionDispatchers/SymfonyDispatcher.php b/src/TransitionDispatchers/SymfonyDispatcher.php new file mode 100644 index 0000000..2f63734 --- /dev/null +++ b/src/TransitionDispatchers/SymfonyDispatcher.php @@ -0,0 +1,37 @@ +eventDispatcher = $eventDispatcher; + } + + /** + * {@inheritdoc} + */ + public function dispatch(CircuitBreaker $circuitBreaker, $transition, $service, array $parameters): void + { + $event = new TransitionEvent($circuitBreaker, $transition, $service, $parameters); + + $this->eventDispatcher->dispatch( + 'resiliency.' . strtolower($transition), + $event + ); + } +} diff --git a/tests/CircuitBreakerWorkflowTest.php b/tests/CircuitBreakerWorkflowTest.php index 163ac66..6d0fc12 100644 --- a/tests/CircuitBreakerWorkflowTest.php +++ b/tests/CircuitBreakerWorkflowTest.php @@ -2,14 +2,16 @@ namespace Tests\Resiliency; +use Resiliency\TransitionDispatchers\SimpleDispatcher; +use Resiliency\TransitionDispatchers\SymfonyDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Cache\Simple\ArrayCache; +use Symfony\Component\EventDispatcher\Event; use Resiliency\Contracts\CircuitBreaker; use Resiliency\Storages\SymfonyCache; use Resiliency\Storages\SimpleArray; -use Resiliency\SymfonyCircuitBreaker; -use Resiliency\SimpleCircuitBreaker; -use Symfony\Component\EventDispatcher\Event; -use Symfony\Component\EventDispatcher\EventDispatcher; -use Symfony\Component\Cache\Simple\ArrayCache; +use Resiliency\MainCircuitBreaker; +use org\bovigo\vfs\vfsStream; class CircuitBreakerWorkflowTest extends CircuitBreakerTestCase { @@ -112,21 +114,28 @@ public function getCircuitBreakers(): array } /** - * @return SimpleCircuitBreaker the circuit breaker for testing purposes + * @return MainCircuitBreaker the circuit breaker for testing purposes */ - private function createSimpleCircuitBreaker(): SimpleCircuitBreaker + private function createSimpleCircuitBreaker(): MainCircuitBreaker { - return new SimpleCircuitBreaker( + $root = vfsStream::setup(); + $file = vfsStream::newFile('logs.txt', 0644) + ->withContent('') + ->at($root) + ; + + return new MainCircuitBreaker( $this->getSystem(), $this->getTestClient(), - new SimpleArray() + new SimpleArray(), + new SimpleDispatcher($file->url()) ); } /** - * @return SymfonyCircuitBreaker the circuit breaker for testing purposes + * @return MainCircuitBreaker the circuit breaker for testing purposes */ - private function createSymfonyCircuitBreaker(): SymfonyCircuitBreaker + private function createSymfonyCircuitBreaker(): MainCircuitBreaker { $symfonyCache = new SymfonyCache(new ArrayCache()); $eventDispatcherS = $this->createMock(EventDispatcher::class); @@ -135,11 +144,11 @@ private function createSymfonyCircuitBreaker(): SymfonyCircuitBreaker ->willReturn($this->createMock(Event::class)) ; - return new SymfonyCircuitBreaker( + return new MainCircuitBreaker( $this->getSystem(), $this->getTestClient(), $symfonyCache, - $eventDispatcherS + new SymfonyDispatcher($eventDispatcherS) ); } diff --git a/tests/Events/TransitionEventTest.php b/tests/Events/TransitionEventTest.php index 1556885..1a0b6b5 100644 --- a/tests/Events/TransitionEventTest.php +++ b/tests/Events/TransitionEventTest.php @@ -3,13 +3,19 @@ namespace Tests\Resiliency\Events; use PHPUnit\Framework\TestCase; +use Resiliency\Contracts\CircuitBreaker; use Resiliency\Events\TransitionEvent; class TransitionEventTest extends TestCase { - public function testCreation() + public function testCreation(): void { - $event = new TransitionEvent('foo', 'bar', []); + $event = new TransitionEvent( + $this->createMock(CircuitBreaker::class), + 'foo', + 'bar', + [] + ); $this->assertInstanceOf(TransitionEvent::class, $event); } @@ -17,9 +23,14 @@ public function testCreation() /** * @depends testCreation */ - public function testGetService() + public function testGetService(): void { - $event = new TransitionEvent('eventName', 'service', []); + $event = new TransitionEvent( + $this->createMock(CircuitBreaker::class), + 'foo', + 'service', + [] + ); $this->assertSame('service', $event->getService()); } @@ -27,9 +38,14 @@ public function testGetService() /** * @depends testCreation */ - public function testGetEvent() + public function testGetEvent(): void { - $event = new TransitionEvent('eventName', 'service', []); + $event = new TransitionEvent( + $this->createMock(CircuitBreaker::class), + 'eventName', + 'bar', + [] + ); $this->assertSame('eventName', $event->getEvent()); } @@ -37,14 +53,19 @@ public function testGetEvent() /** * @depends testCreation */ - public function testGetParameters() + public function testGetParameters(): void { $parameters = [ 'foo' => 'myFoo', 'bar' => true, ]; - $event = new TransitionEvent('eventName', 'service', $parameters); + $event = new TransitionEvent( + $this->createMock(CircuitBreaker::class), + 'foo', + 'bar', + $parameters + ); $this->assertSame($parameters, $event->getParameters()); } diff --git a/tests/SimpleCircuitBreakerFactoryTest.php b/tests/SimpleCircuitBreakerFactoryTest.php index 1b4dc56..13bfe96 100644 --- a/tests/SimpleCircuitBreakerFactoryTest.php +++ b/tests/SimpleCircuitBreakerFactoryTest.php @@ -3,7 +3,7 @@ namespace Tests\Resiliency; use PHPUnit\Framework\TestCase; -use Resiliency\SimpleCircuitBreaker; +use Resiliency\Contracts\CircuitBreaker; use Resiliency\SimpleCircuitBreakerFactory; class SimpleCircuitBreakerFactoryTest extends TestCase @@ -31,7 +31,7 @@ public function testCircuitBreakerCreation(array $settings): void $factory = new SimpleCircuitBreakerFactory(); $circuitBreaker = $factory->create($settings); - $this->assertInstanceOf(SimpleCircuitBreaker::class, $circuitBreaker); + $this->assertInstanceOf(CircuitBreaker::class, $circuitBreaker); } /** diff --git a/tests/SymfonyCircuitBreakerEventsTest.php b/tests/SymfonyCircuitBreakerEventsTest.php index 6e30f27..bbab39f 100644 --- a/tests/SymfonyCircuitBreakerEventsTest.php +++ b/tests/SymfonyCircuitBreakerEventsTest.php @@ -2,13 +2,18 @@ namespace Tests\Resiliency; +use PHPUnit\Framework\MockObject\Matcher\AnyInvokedCount; +use Resiliency\TransitionDispatchers\SymfonyDispatcher; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Cache\Simple\ArrayCache; use Symfony\Component\EventDispatcher\Event; -use PHPUnit\Framework\MockObject\Matcher\AnyInvokedCount; -use Resiliency\SymfonyCircuitBreaker; +use Resiliency\Contracts\CircuitBreaker; use Resiliency\Storages\SymfonyCache; +use Resiliency\MainCircuitBreaker; +/** + * Validates that the right events are dispatched. + */ class SymfonyCircuitBreakerEventsTest extends CircuitBreakerTestCase { /** @@ -27,7 +32,7 @@ public function testCircuitBreakerEventsOnFirstFailedCall(): void $circuitBreaker = $this->createCircuitBreaker(); $circuitBreaker->call( - 'https://httpbin.org/get/foo', + 'https://httpbin.org/get/foobar', function () { return '{}'; } @@ -47,7 +52,7 @@ function () { $this->assertSame('resiliency.opening', $invocations[3]->getParameters()[0]); } - private function createCircuitBreaker(): SymfonyCircuitBreaker + private function createCircuitBreaker(): CircuitBreaker { $system = $this->getSystem(); @@ -58,11 +63,11 @@ private function createCircuitBreaker(): SymfonyCircuitBreaker ->willReturn($this->createMock(Event::class)) ; - return new SymfonyCircuitBreaker( + return new MainCircuitBreaker( $system, $this->getTestClient(), $symfonyCache, - $eventDispatcherS + new SymfonyDispatcher($eventDispatcherS) ); } } diff --git a/tests/TransitionDispatchers/SimpleDispatcherTest.php b/tests/TransitionDispatchers/SimpleDispatcherTest.php new file mode 100644 index 0000000..fca1084 --- /dev/null +++ b/tests/TransitionDispatchers/SimpleDispatcherTest.php @@ -0,0 +1,45 @@ +assertInstanceOf(SimpleDispatcher::class, new SimpleDispatcher('php://stderr')); + } + + public function testDispatch() + { + $root = vfsStream::setup(); + $file = vfsStream::newFile('logs.txt', 0644) + ->withContent('') + ->at($root) + ; + + $circuitBreakerS = $this->createMock(CircuitBreaker::class); + $circuitBreakerS->method('getState') + ->willReturn('OPEN') + ; + + $simpleDispatcher = new SimpleDispatcher($file->url()); + $simpleDispatcher->dispatch( + $circuitBreakerS, + 'INIT', + 'http://test.org', + [ + 'a' => 1, + 'b' => 'B', + ] + ); + + $expectedMessage = '[INIT]:"http://test.org"_(OPEN)_{"a":1,"b":"B"}'; + + $this->assertSame($expectedMessage, $file->getContent()); + } +}