diff --git a/docs/async/coroutines.md b/docs/async/coroutines.md index 38acbb0..e69971d 100644 --- a/docs/async/coroutines.md +++ b/docs/async/coroutines.md @@ -1,9 +1,6 @@ # Coroutines -> ⚠️ **Feature preview** -> -> This is a feature preview, i.e. it might not have made it into the current beta. -> Give feedback to help us prioritize. +> ⚠️ **Documentation still under construction** > > You're seeing an early draft of the documentation that is still in the works. > Give feedback to help us prioritize. @@ -13,3 +10,134 @@ * X provides Generator-based coroutines * Synchronous code structure, yet asynchronous execution * Generators can be a bit harder to understand, see [Fibers](fibers.md) for future PHP 8.1 API. + +=== "Coroutines" + + ```php + $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) { + $row = yield $db->query( + 'SELECT * FROM books WHERE ID=?', + [$request->getAttribute('id')] + ); + + $html = $twig->render('book.twig', $row); + + return new React\Http\Message\Response( + 200, + [ + 'Content-Type' => 'text/html; charset=utf-8' + ], + $html + ); + }); + ``` + +=== "Synchronous (for comparison)" + + ```php + $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) { + $row = $db->query( + 'SELECT * FROM books WHERE ID=?', + [$request->getAttribute('id')] + ); + + $html = $twig->render('book.twig', $row); + + return new React\Http\Message\Response( + 200, + [ + 'Content-Type' => 'text/html; charset=utf-8' + ], + $html + ); + }); + ``` + +This example highlights how async PHP can look pretty much like a normal, +synchronous code structure. +The only difference is in how the `yield` statement can be used to *await* an +async [promise](promises.md). +In order for this to work, this example assumes an +[async database](../integrations/database.md) that uses [promises](promises.md). + +## Coroutines vs. Promises? + +We're the first to admit that [promises](promises.md) can look more complicated, +so why offer both? + +In fact, both styles exist for a reason. +Promises are used to represent an eventual return value. +Even when using coroutines, this does not change how the underlying APIs +(such as a database) still have to return promises. + +If you want to *consume* a promise, you get to choose between the promise-based +API and using coroutines: + +=== "Coroutines" + + ```php + $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) { + $row = yield $db->query( + 'SELECT * FROM books WHERE ID=?', + [$request->getAttribute('id')] + ); + + $html = $twig->render('book.twig', $row); + + return new React\Http\Message\Response( + 200, + [ + 'Content-Type' => 'text/html; charset=utf-8' + ], + $html + ); + }); + ``` + +=== "Promises (for comparison)" + + ```php + $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) { + return $db->query( + 'SELECT * FROM books WHERE ID=?', + [$request->getAttribute('id')] + )->then(function (array $row) use ($twig) { + $html = $twig->render('book.twig', $row); + + return new React\Http\Message\Response( + 200, + [ + 'Content-Type' => 'text/html; charset=utf-8' + ], + $html + ); + }); + }); + ``` + +This example highlights how using coroutines in your controllers can look +somewhat easier because coroutines hide some of the complexity of async APIs. +X has a strong focus on simple APIs, so we also support coroutines. +For this reason, some people may prefer the coroutine-style async execution +model in their controllers. + +At the same time, it should be pointed out that coroutines build on top of +promises. +This means that having a good understanding of how async APIs using promises +work can be somewhat beneficial. +Indeed this means that code flow could even be harder to understand for some +people, especially if you're already used to async execution models using +promise-based APIs. + +**Which style is better?** +We like choice. +Feel free to use whatever style best works for you. + +> 🔮 **Future fiber support in PHP 8.1** +> +> In the future, PHP 8.1 will provide native support for [fibers](fibers.md). +> Once fibers become mainstream, there would be little reason to use +> Generator-based coroutines anymore. +> While fibers will help to avoid using promises for many common use cases, +> promises will still be useful for concurrent execution. +> See [fibers](fibers.md) for more details. diff --git a/docs/async/promises.md b/docs/async/promises.md index c625d30..acbfbbf 100644 --- a/docs/async/promises.md +++ b/docs/async/promises.md @@ -9,6 +9,65 @@ * Avoid blocking ([databases](../integrations/database.md), [filesystem](../integrations/filesystem.md), etc.) * Deferred execution * Concurrent execution more efficient than [multithreading](child-processes.md) -* API can be a bit harder (see [Coroutines](coroutines.md) or [Fibers](fibers.md)) -* See [reactphp/promise](https://reactphp.org/promise/) * Avoid blocking by moving blocking implementation to [child process](child-processes.md) + +=== "Promise-based" + + ```php + $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) { + return $db->query( + 'SELECT * FROM books WHERE ID=?', + [$request->getAttribute('id')] + )->then(function (array $row) use ($twig) { + $html = $twig->render('book.twig', $row); + + return new React\Http\Message\Response( + 200, + [ + 'Content-Type' => 'text/html; charset=utf-8' + ], + $html + ); + }); + }); + ``` + +=== "Synchronous (for comparision)" + + ```php + $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) { + $row = $db->query( + 'SELECT * FROM books WHERE ID=?', + [$request->getAttribute('id')] + ); + + $html = $twig->render('book.twig', $row); + + return new React\Http\Message\Response( + 200, + [ + 'Content-Type' => 'text/html; charset=utf-8' + ], + $html + ); + }); + ``` + +In this example, we assume an [async database](../integrations/database.md) +adapter that returns a promise which *fulfills* with some data instead of +directly returning data. + +The major feature is that this means that anything that takes some time will +no longer block the entire execution. +These non-blocking operations are especially benefitial for anything that incurs +some kind of I/O, such as +[database queries](../integrations/database.md), HTTP API requests, +[filesystem access](../integrations/filesystem.md) and much more. +If you want to learn more about the promise API, see also +[reactphp/promise](https://reactphp.org/promise/). + +Admittedly, this example also showcases how async PHP can look slightly more +complicated than a normal, synchronous code structure. +Because we realize this API can be somewhat harder in some cases, we also +support [coroutines](coroutines.md) (and in upcoming PHP 8.1 will also support +[fibers](fibers.md)). diff --git a/mkdocs.yml b/mkdocs.yml index a7d595a..a078073 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,7 @@ theme: repo: fontawesome/brands/github markdown_extensions: + - pymdownx.tabbed - pymdownx.highlight - pymdownx.superfences - toc: diff --git a/src/App.php b/src/App.php index 3b97847..d9796db 100644 --- a/src/App.php +++ b/src/App.php @@ -12,6 +12,7 @@ use React\Http\Server as HttpServer; use React\Http\Message\Response; use React\Http\Message\ServerRequest; +use React\Promise\Deferred; use React\Promise\PromiseInterface; use React\Socket\Server as SocketServer; use React\Stream\ReadableStreamInterface; @@ -336,6 +337,10 @@ private function handleRequest(ServerRequestInterface $request, Dispatcher $disp return $this->errorHandlerException($e, $handler); } + if ($response instanceof \Generator) { + $response = $this->coroutine($response, $handler); + } + if ($response instanceof ResponseInterface) { return $response; } elseif ($response instanceof PromiseInterface) { @@ -357,6 +362,39 @@ private function handleRequest(ServerRequestInterface $request, Dispatcher $disp } } // @codeCoverageIgnore + private function coroutine(\Generator $generator, callable $handler): PromiseInterface + { + $next = null; + $deferred = new Deferred(); + $next = function () use ($generator, &$next, $deferred, $handler) { + if (!$generator->valid()) { + $deferred->resolve($generator->getReturn()); + return; + } + + $step = $generator->current(); + if (!$step instanceof PromiseInterface) { + $generator = $next = null; + $deferred->resolve($this->errorHandlerCoroutine($step, $handler)); + return; + } + + $step->then(function ($value) use ($generator, $next) { + $generator->send($value); + $next(); + }, function ($reason) use ($generator, $next) { + $generator->throw($reason); + $next(); + })->then(null, function ($e) use ($handler, $deferred) { + $deferred->reject($e); + }); + }; + + $next(); + + return $deferred->promise(); + } + private function logRequestResponse(ServerRequestInterface $request, ResponseInterface $response): void { // only log for built-in webserver and PHP development webserver, others have their own access log @@ -449,6 +487,14 @@ private function errorHandlerResponse($value, callable $handler): ResponseInterf ); } + private function errorHandlerCoroutine($value, callable $handler): ResponseInterface + { + return $this->error( + 500, + $this->describeHandler($handler, true) . ' expected coroutine to yield React\Promise\PromiseInterface but got ' . $this->describeType($value) . '' + ); + } + private function describeType($value): string { if ($value === null) { diff --git a/tests/AppTest.php b/tests/AppTest.php index 05a37fe..9804f25 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -15,6 +15,7 @@ use React\Promise\PromiseInterface; use ReflectionMethod; use ReflectionProperty; +use React\Promise\Promise; class AppTest extends TestCase { @@ -449,6 +450,159 @@ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhich $this->assertEquals("OK\n", (string) $response->getBody()); } + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPendingPromiseWhenHandlerReturnsPendingPromise() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $handler = function () { + return new Promise(function () { }); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $resolved = false; + $promise->then(function () use (&$resolved) { + $resolved = true; + }, function () use (&$resolved) { + $resolved = true; + }); + + $this->assertFalse($resolved); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithResponseWhenHandlerReturnsCoroutineWhichReturnsResponseAfterYieldingResolvedPromise() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $handler = function () { + $body = yield \React\Promise\resolve("OK\n"); + + return new Response( + 200, + [ + 'Content-Type' => 'text/html' + ], + $body + ); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("OK\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithResponseWhenHandlerReturnsCoroutineWhichReturnsResponseAfterCatchingExceptionFromYieldingRejectedPromise() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $handler = function () { + $body = ''; + try { + yield \React\Promise\reject(new \RuntimeException("OK\n")); + } catch (\RuntimeException $e) { + $body = $e->getMessage(); + } + + return new Response( + 200, + [ + 'Content-Type' => 'text/html' + ], + $body + ); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("OK\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPendingPromiseWhenHandlerReturnsCoroutineThatYieldsPendingPromise() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $handler = function () { + yield new Promise(function () { }); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $resolved = false; + $promise->then(function () use (&$resolved) { + $resolved = true; + }, function () use (&$resolved) { + $resolved = true; + }); + + $this->assertFalse($resolved); + } + public function testHandleRequestWithDispatcherWithRouteFoundAndRouteVariablesReturnsResponseFromHandlerWithRouteVariablesAssignedAsRequestAttributes() { $loop = $this->createMock(LoopInterface::class); @@ -554,7 +708,7 @@ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhich $line = __LINE__ + 1; $handler = function () { - return \React\Promise\reject(''); + return \React\Promise\reject(null); }; $dispatcher = $this->createMock(Dispatcher::class); @@ -580,6 +734,148 @@ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhich $this->assertEquals("500 (Internal Server Error): Request handler (AppTest.php:$line) returned invalid value (React\Promise\RejectedPromise)\n", (string) $response->getBody()); } + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsCoroutineWhichYieldsRejectedPromise() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = __LINE__ + 2; + $handler = function () { + yield \React\Promise\reject(new \RuntimeException('Foo')); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Uncaught RuntimeException from request handler (AppTest.php:$line): Foo\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsCoroutineWhichThrowsExceptionAfterYielding() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = __LINE__ + 3; + $handler = function () { + yield \React\Promise\resolve(null); + throw new \RuntimeException('Foo'); + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Uncaught RuntimeException from request handler (AppTest.php:$line): Foo\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsCoroutineWhichReturnsNull() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = __LINE__ + 1; + $handler = function () { + $value = yield \React\Promise\resolve(null); + return $value; + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Request handler (AppTest.php:$line) returned invalid value (null)\n", (string) $response->getBody()); + } + + public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsCoroutineWhichYieldsNull() + { + $loop = $this->createMock(LoopInterface::class); + $app = new App($loop); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + $line = __LINE__ + 1; + $handler = function () { + yield null; + }; + + $dispatcher = $this->createMock(Dispatcher::class); + $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]); + + // $promise = $app->handleRequest($request, $dispatcher); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request, $dispatcher); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("500 (Internal Server Error): Request handler (AppTest.php:$line) expected coroutine to yield React\Promise\PromiseInterface but got null\n", (string) $response->getBody()); + } + public function provideInvalidReturnValue() { return [