diff --git a/README.md b/README.md index 92b57f0..522bef6 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ an event loop, it can be used with this library. * [Usage](#usage) * [await()](#await) + * [coroutine()](#coroutine) * [parallel()](#parallel) * [series()](#series) * [waterfall()](#waterfall) @@ -89,6 +90,121 @@ try { } ``` +### coroutine() + +The `coroutine(callable $function, mixed ...$args): PromiseInterface` function can be used to +execute a Generator-based coroutine to "await" promises. + +```php +React\Async\coroutine(function () { + $browser = new React\Http\Browser(); + + try { + $response = yield $browser->get('https://example.com/'); + assert($response instanceof Psr\Http\Message\ResponseInterface); + echo $response->getBody(); + } catch (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + } +}); +``` + +Using Generator-based coroutines is an alternative to directly using the +underlying promise APIs. For many use cases, this makes using promise-based +APIs much simpler, as it resembles a synchronous code flow more closely. +The above example performs the equivalent of directly using the promise APIs: + +```php +$browser = new React\Http\Browser(); + +$browser->get('https://example.com/')->then(function (Psr\Http\Message\ResponseInterface $response) { + echo $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +The `yield` keyword can be used to "await" a promise resolution. Internally, +it will turn the entire given `$function` into a [`Generator`](https://www.php.net/manual/en/class.generator.php). +This allows the execution to be interrupted and resumed at the same place +when the promise is fulfilled. The `yield` statement returns whatever the +promise is fulfilled with. If the promise is rejected, it will throw an +`Exception` or `Throwable`. + +The `coroutine()` function will always return a Proimise which will be +fulfilled with whatever your `$function` returns. Likewise, it will return +a promise that will be rejected if you throw an `Exception` or `Throwable` +from your `$function`. This allows you easily create Promise-based functions: + +```php +$promise = React\Async\coroutine(function () { + $browser = new React\Http\Browser(); + $urls = [ + 'https://example.com/alice', + 'https://example.com/bob' + ]; + + $bytes = 0; + foreach ($urls as $url) { + $response = yield $browser->get($url); + assert($response instanceof Psr\Http\Message\ResponseInterface); + $bytes += $response->getBody()->getSize(); + } + return $bytes; +}); + +$promise->then(function (int $bytes) { + echo 'Total size: ' . $bytes . PHP_EOL; +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +The previous example uses a `yield` statement inside a loop to highlight how +this vastly simplifies consuming asynchronous operations. At the same time, +this naive example does not leverage concurrent execution, as it will +essentially "await" between each operation. In order to take advantage of +concurrent execution within the given `$function`, you can "await" multiple +promises by using a single `yield` together with Promise-based primitives +like this: + +```php +$promise = React\Async\coroutine(function () { + $browser = new React\Http\Browser(); + $urls = [ + 'https://example.com/alice', + 'https://example.com/bob' + ]; + + $promises = []; + foreach ($urls as $url) { + $promises[] = $browser->get($url); + } + + try { + $responses = yield React\Promise\all($promises); + } catch (Exception $e) { + foreach ($promises as $promise) { + $promise->cancel(); + } + throw $e; + } + + $bytes = 0; + foreach ($responses as $response) { + assert($response instanceof Psr\Http\Message\ResponseInterface); + $bytes += $response->getBody()->getSize(); + } + return $bytes; +}); + +$promise->then(function (int $bytes) { + echo 'Total size: ' . $bytes . PHP_EOL; +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + ### parallel() The `parallel(array> $tasks): PromiseInterface,Exception>` function can be used diff --git a/src/functions.php b/src/functions.php index 7f644e1..f60b6b9 100644 --- a/src/functions.php +++ b/src/functions.php @@ -6,6 +6,8 @@ use React\Promise\CancellablePromiseInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; +use function React\Promise\reject; +use function React\Promise\resolve; /** * Block waiting for the given `$promise` to be fulfilled. @@ -91,6 +93,174 @@ function ($error) use (&$exception, &$rejected, &$wait) { return $resolved; } + +/** + * Execute a Generator-based coroutine to "await" promises. + * + * ```php + * React\Async\coroutine(function () { + * $browser = new React\Http\Browser(); + * + * try { + * $response = yield $browser->get('https://example.com/'); + * assert($response instanceof Psr\Http\Message\ResponseInterface); + * echo $response->getBody(); + * } catch (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * } + * }); + * ``` + * + * Using Generator-based coroutines is an alternative to directly using the + * underlying promise APIs. For many use cases, this makes using promise-based + * APIs much simpler, as it resembles a synchronous code flow more closely. + * The above example performs the equivalent of directly using the promise APIs: + * + * ```php + * $browser = new React\Http\Browser(); + * + * $browser->get('https://example.com/')->then(function (Psr\Http\Message\ResponseInterface $response) { + * echo $response->getBody(); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * The `yield` keyword can be used to "await" a promise resolution. Internally, + * it will turn the entire given `$function` into a [`Generator`](https://www.php.net/manual/en/class.generator.php). + * This allows the execution to be interrupted and resumed at the same place + * when the promise is fulfilled. The `yield` statement returns whatever the + * promise is fulfilled with. If the promise is rejected, it will throw an + * `Exception` or `Throwable`. + * + * The `coroutine()` function will always return a Proimise which will be + * fulfilled with whatever your `$function` returns. Likewise, it will return + * a promise that will be rejected if you throw an `Exception` or `Throwable` + * from your `$function`. This allows you easily create Promise-based functions: + * + * ```php + * $promise = React\Async\coroutine(function () { + * $browser = new React\Http\Browser(); + * $urls = [ + * 'https://example.com/alice', + * 'https://example.com/bob' + * ]; + * + * $bytes = 0; + * foreach ($urls as $url) { + * $response = yield $browser->get($url); + * assert($response instanceof Psr\Http\Message\ResponseInterface); + * $bytes += $response->getBody()->getSize(); + * } + * return $bytes; + * }); + * + * $promise->then(function (int $bytes) { + * echo 'Total size: ' . $bytes . PHP_EOL; + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * The previous example uses a `yield` statement inside a loop to highlight how + * this vastly simplifies consuming asynchronous operations. At the same time, + * this naive example does not leverage concurrent execution, as it will + * essentially "await" between each operation. In order to take advantage of + * concurrent execution within the given `$function`, you can "await" multiple + * promises by using a single `yield` together with Promise-based primitives + * like this: + * + * ```php + * $promise = React\Async\coroutine(function () { + * $browser = new React\Http\Browser(); + * $urls = [ + * 'https://example.com/alice', + * 'https://example.com/bob' + * ]; + * + * $promises = []; + * foreach ($urls as $url) { + * $promises[] = $browser->get($url); + * } + * + * try { + * $responses = yield React\Promise\all($promises); + * } catch (Exception $e) { + * foreach ($promises as $promise) { + * $promise->cancel(); + * } + * throw $e; + * } + * + * $bytes = 0; + * foreach ($responses as $response) { + * assert($response instanceof Psr\Http\Message\ResponseInterface); + * $bytes += $response->getBody()->getSize(); + * } + * return $bytes; + * }); + * + * $promise->then(function (int $bytes) { + * echo 'Total size: ' . $bytes . PHP_EOL; + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * @param callable(...$args):\Generator $function + * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is + * @return PromiseInterface + * @since 3.0.0 + */ +function coroutine(callable $function, ...$args): PromiseInterface +{ + try { + $generator = $function(...$args); + } catch (\Throwable $e) { + return reject($e); + } + + if (!$generator instanceof \Generator) { + return resolve($generator); + } + + $deferred = new Deferred(); + + /** @var callable $next */ + $next = function () use ($deferred, $generator, &$next) { + try { + if (!$generator->valid()) { + $deferred->resolve($generator->getReturn()); + return; + } + } catch (\Throwable $e) { + $deferred->reject($e); + return; + } + + $promise = $generator->current(); + if (!$promise instanceof PromiseInterface) { + $deferred->reject(new \UnexpectedValueException( + 'Expected coroutine to yield ' . PromiseInterface::class . ', but got ' . (is_object($promise) ? get_class($promise) : gettype($promise)) + )); + return; + } + + $promise->then(function ($value) use ($generator, $next) { + $generator->send($value); + $next(); + }, function (\Throwable $reason) use ($generator, $next) { + $generator->throw($reason); + $next(); + })->then(null, function (\Throwable $reason) use ($deferred) { + $deferred->reject($reason); + }); + }; + $next(); + + return $deferred->promise(); +} + /** * @param array> $tasks * @return PromiseInterface,Exception> diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php new file mode 100644 index 0000000..97dbe38 --- /dev/null +++ b/tests/CoroutineTest.php @@ -0,0 +1,107 @@ +then($this->expectCallableOnceWith(42)); + } + + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately() + { + $promise = coroutine(function () { + if (false) { + yield; + } + return 42; + }); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingPromise() + { + $promise = coroutine(function () { + $value = yield resolve(42); + return $value; + }); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenerator() + { + $promise = coroutine(function () { + throw new \RuntimeException('Foo'); + }); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() + { + $promise = coroutine(function () { + if (false) { + yield; + } + throw new \RuntimeException('Foo'); + }); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingPromise() + { + $promise = coroutine(function () { + $reason = yield resolve('Foo'); + throw new \RuntimeException($reason); + }); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingRejectedPromise() + { + $promise = coroutine(function () { + try { + yield reject(new \OverflowException('Foo')); + } catch (\OverflowException $e) { + throw new \RuntimeException($e->getMessage()); + } + }); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); + } + + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingRejectedPromise() + { + $promise = coroutine(function () { + try { + yield reject(new \OverflowException('Foo', 42)); + } catch (\OverflowException $e) { + return $e->getCode(); + } + }); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue() + { + $promise = coroutine(function () { + yield 42; + }); + + $promise->then(null, $this->expectCallableOnceWith(new \UnexpectedValueException('Expected coroutine to yield React\Promise\PromiseInterface, but got integer'))); + } +}