├── .gitignore ├── .gitmodules ├── 1-blocking-io └── server.php ├── 2-echo-server ├── .gitignore ├── composer.json └── server.php ├── 3-broadcasting ├── .gitignore ├── composer.json └── server.php ├── 4-parsing ├── .gitignore ├── composer.json └── server.php ├── 5-multiple-instances ├── .gitignore ├── composer.json └── server.php └── docs ├── .gitignore ├── Gemfile ├── _config.yml ├── asset ├── index.html └── tcp-chat ├── basic-echo-server.md ├── broadcasting.md ├── index.md ├── multiple-instances.md └── parsing.md /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/.shared"] 2 | path = docs/.shared 3 | url = https://github.com/amphp/amphp.github.io 4 | -------------------------------------------------------------------------------- /1-blocking-io/server.php: -------------------------------------------------------------------------------- 1 | read()) { 20 | // Yielding a write() waits until the data is fully written to the OS's internal buffer, it might not have 21 | // been received by the client when the promise returned from write() returns. 22 | yield $socket->write($chunk); 23 | } 24 | }; 25 | 26 | // listen() is a small wrapper around stream_socket_server() returning a Server object 27 | $server = Amp\Socket\Server::listen($uri); 28 | 29 | // Like in the previous example, we accept each client as soon as we can 30 | // Server::accept() returns a promise. The coroutine will be interrupted and continued once the promise resolves. 31 | while ($socket = yield $server->accept()) { 32 | // Call $clientHandler without returning a promise. If an error happens in the callback, it will be passed to 33 | // the global error handler, we don't have to care about it here. 34 | asyncCall($clientHandler, $socket); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /3-broadcasting/.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor 3 | -------------------------------------------------------------------------------- /3-broadcasting/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amphp/getting-started-echo-server", 3 | "description": "A TCP echo server based on amphp/socket.", 4 | "type": "project", 5 | "require": { 6 | "amphp/socket": "^1" 7 | }, 8 | "license": "MIT", 9 | "authors": [ 10 | { 11 | "name": "Niklas Keller", 12 | "email": "me@kelunik.com" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /3-broadcasting/server.php: -------------------------------------------------------------------------------- 1 | $client 16 | private $clients = []; 17 | 18 | public function listen() { 19 | asyncCall(function () { 20 | $server = Amp\Socket\Server::listen($this->uri); 21 | 22 | print "Listening on " . $server->getAddress() . " ..." . PHP_EOL; 23 | 24 | while ($socket = yield $server->accept()) { 25 | $this->handleClient($socket); 26 | } 27 | }); 28 | } 29 | 30 | private function handleClient(Socket $socket) { 31 | asyncCall(function () use ($socket) { 32 | $remoteAddr = $socket->getRemoteAddress(); 33 | 34 | // We print a message on the server and send a message to each client 35 | print "Accepted new client: {$remoteAddr}". PHP_EOL; 36 | $this->broadcast($remoteAddr . " joined the chat." . PHP_EOL); 37 | 38 | // We only insert the client afterwards, so it doesn't get its own join message 39 | $this->clients[(string) $remoteAddr] = $socket; 40 | 41 | while (null !== $chunk = yield $socket->read()) { 42 | $this->broadcast($remoteAddr . " says: " . trim($chunk) . PHP_EOL); 43 | } 44 | 45 | // We remove the client again once it disconnected. 46 | // It's important, otherwise we'll leak memory. 47 | unset($this->clients[(string) $remoteAddr]); 48 | 49 | // Inform other clients that that client disconnected and also print it in the server. 50 | print "Client disconnected: {$remoteAddr}" . PHP_EOL; 51 | $this->broadcast($remoteAddr . " left the chat." . PHP_EOL); 52 | }); 53 | } 54 | 55 | private function broadcast(string $message) { 56 | foreach ($this->clients as $client) { 57 | // We don't yield the promise returned from $client->write() here as we don't care about 58 | // other clients disconnecting and thus the write failing. 59 | $client->write($message); 60 | } 61 | } 62 | }; 63 | 64 | $server->listen(); 65 | }); 66 | -------------------------------------------------------------------------------- /4-parsing/.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor 3 | -------------------------------------------------------------------------------- /4-parsing/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amphp/getting-started-echo-server", 3 | "description": "A TCP echo server based on amphp/socket.", 4 | "type": "project", 5 | "require": { 6 | "amphp/socket": "^1" 7 | }, 8 | "license": "MIT", 9 | "authors": [ 10 | { 11 | "name": "Niklas Keller", 12 | "email": "me@kelunik.com" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /4-parsing/server.php: -------------------------------------------------------------------------------- 1 | $client 16 | private $clients = []; 17 | 18 | // Store a $clientAddr => $username map 19 | private $usernames = []; 20 | 21 | public function listen() { 22 | asyncCall(function () { 23 | $server = Amp\Socket\Server::listen($this->uri); 24 | 25 | print "Listening on " . $server->getAddress() . " ..." . PHP_EOL; 26 | 27 | while ($socket = yield $server->accept()) { 28 | $this->handleClient($socket); 29 | } 30 | }); 31 | } 32 | 33 | private function handleClient(Socket $socket) { 34 | asyncCall(function () use ($socket) { 35 | $remoteAddr = $socket->getRemoteAddress(); 36 | 37 | // We print a message on the server and send a message to each client 38 | print "Accepted new client: {$remoteAddr}". PHP_EOL; 39 | $this->broadcast($remoteAddr . " joined the chat." . PHP_EOL); 40 | 41 | // We only insert the client afterwards, so it doesn't get its own join message 42 | $this->clients[(string) $remoteAddr] = $socket; 43 | 44 | $buffer = ""; 45 | 46 | while (null !== $chunk = yield $socket->read()) { 47 | $buffer .= $chunk; 48 | 49 | while (($pos = strpos($buffer, "\n")) !== false) { 50 | $this->handleMessage($socket, substr($buffer, 0, $pos)); 51 | $buffer = substr($buffer, $pos + 1); 52 | } 53 | } 54 | 55 | // We remove the client again once it disconnected. 56 | // It's important, otherwise we'll leak memory. 57 | // We also have to unset our new usernames. 58 | unset($this->clients[(string) $remoteAddr], $this->usernames[(string) $remoteAddr]); 59 | 60 | // Inform other clients that that client disconnected and also print it in the server. 61 | print "Client disconnected: {$remoteAddr}" . PHP_EOL; 62 | $this->broadcast(($this->usernames[(string) $remoteAddr] ?? $remoteAddr) . " left the chat." . PHP_EOL); 63 | }); 64 | } 65 | 66 | private function handleMessage(Socket $socket, string $message) { 67 | if ($message === "") { 68 | // ignore all empty messages 69 | return; 70 | } 71 | 72 | if ($message[0] === "/") { 73 | // message is a command 74 | $message = substr($message, 1); // remove slash 75 | $args = explode(" ", $message); // parse message into parts separated by space 76 | $name = strtolower(array_shift($args)); // the first arg is our command name 77 | 78 | switch ($name) { 79 | case "time": 80 | $socket->write(date("l jS \of F Y h:i:s A") . PHP_EOL); 81 | break; 82 | 83 | case "up": 84 | $socket->write(strtoupper(implode(" ", $args)) . PHP_EOL); 85 | break; 86 | 87 | case "down": 88 | $socket->write(strtolower(implode(" ", $args)) . PHP_EOL); 89 | break; 90 | 91 | case "exit": 92 | $socket->end("Bye." . PHP_EOL); 93 | break; 94 | 95 | case "nick": 96 | $nick = implode(" ", $args); 97 | 98 | if (!preg_match("(^[a-z0-9-.]{3,15}$)i", $nick)) { 99 | $error = "Username must only contain letters, digits and " . 100 | "its length must be between 3 and 15 characters."; 101 | $socket->write($error . PHP_EOL); 102 | return; 103 | } 104 | 105 | $remoteAddr = $socket->getRemoteAddress(); 106 | $oldnick = $this->usernames[(string) $remoteAddr] ?? $remoteAddr; 107 | $this->usernames[(string) $remoteAddr] = $nick; 108 | 109 | $this->broadcast($oldnick . " is now " . $nick . PHP_EOL); 110 | break; 111 | 112 | default: 113 | $socket->write("Unknown command: {$name}" . PHP_EOL); 114 | break; 115 | } 116 | 117 | return; 118 | } 119 | 120 | $remoteAddr = $socket->getRemoteAddress(); 121 | $user = $this->usernames[(string) $remoteAddr] ?? $remoteAddr; 122 | $this->broadcast($user . " says: " . $message . PHP_EOL); 123 | } 124 | 125 | private function broadcast(string $message) { 126 | foreach ($this->clients as $client) { 127 | // We don't yield the promise returned from $client->write() here as we don't care about 128 | // other clients disconnecting and thus the write failing. 129 | $client->write($message); 130 | } 131 | } 132 | }; 133 | 134 | $server->listen(); 135 | }); 136 | -------------------------------------------------------------------------------- /5-multiple-instances/.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor 3 | -------------------------------------------------------------------------------- /5-multiple-instances/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amphp/getting-started-echo-server", 3 | "description": "A TCP echo server based on amphp/socket.", 4 | "type": "project", 5 | "require": { 6 | "amphp/socket": "^1", 7 | "amphp/redis": "^1" 8 | }, 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Niklas Keller", 13 | "email": "me@kelunik.com" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /5-multiple-instances/server.php: -------------------------------------------------------------------------------- 1 | $client 25 | private $clients = []; 26 | 27 | // Store a $clientAddr => $username map 28 | private $usernames = []; 29 | 30 | /** @var Redis */ 31 | private $redisClient; 32 | 33 | public function listen() { 34 | asyncCall(function () { 35 | $remoteExecutor = new RemoteExecutor(Config::fromUri($this->redisHost)); 36 | $this->redisClient = new Redis($remoteExecutor); 37 | 38 | $server = Amp\Socket\Server::listen($this->uri); 39 | $this->listenToRedis(); 40 | 41 | print "Listening on " . $server->getAddress() . " ..." . PHP_EOL; 42 | 43 | while ($socket = yield $server->accept()) { 44 | $this->handleClient($socket); 45 | } 46 | }); 47 | } 48 | 49 | private function handleClient(Socket $socket) { 50 | asyncCall(function () use ($socket) { 51 | $remoteAddr = $socket->getRemoteAddress(); 52 | 53 | // We print a message on the server and send a message to each client 54 | print "Accepted new client: {$remoteAddr}". PHP_EOL; 55 | yield $this->redisClient->publish("chat", $remoteAddr . " joined the chat." . PHP_EOL); 56 | 57 | // We only insert the client afterwards, so it doesn't get its own join message 58 | $this->clients[(string) $remoteAddr] = $socket; 59 | 60 | $buffer = ""; 61 | 62 | while (null !== $chunk = yield $socket->read()) { 63 | $buffer .= $chunk; 64 | 65 | while (($pos = strpos($buffer, "\n")) !== false) { 66 | $this->handleMessage($socket, substr($buffer, 0, $pos)); 67 | $buffer = substr($buffer, $pos + 1); 68 | } 69 | } 70 | 71 | // We remove the client again once it disconnected. 72 | // It's important, otherwise we'll leak memory. 73 | // We also have to unset our new usernames. 74 | unset($this->clients[(string) $remoteAddr], $this->usernames[(string) $remoteAddr]); 75 | 76 | // Inform other clients that that client disconnected and also print it in the server. 77 | print "Client disconnected: {$remoteAddr}" . PHP_EOL; 78 | $message = ($this->usernames[(string) $remoteAddr] ?? $remoteAddr) . " left the chat." . PHP_EOL; 79 | yield $this->redisClient->publish("chat", $message); 80 | }); 81 | } 82 | 83 | private function handleMessage(Socket $socket, string $message) { 84 | if ($message === "") { 85 | // ignore all empty messages 86 | return; 87 | } 88 | 89 | if ($message[0] === "/") { 90 | // message is a command 91 | $message = substr($message, 1); // remove slash 92 | $args = explode(" ", $message); // parse message into parts separated by space 93 | $name = strtolower(array_shift($args)); // the first arg is our command name 94 | 95 | switch ($name) { 96 | case "time": 97 | $socket->write(date("l jS \of F Y h:i:s A") . PHP_EOL); 98 | break; 99 | 100 | case "up": 101 | $socket->write(strtoupper(implode(" ", $args)) . PHP_EOL); 102 | break; 103 | 104 | case "down": 105 | $socket->write(strtolower(implode(" ", $args)) . PHP_EOL); 106 | break; 107 | 108 | case "exit": 109 | $socket->end("Bye." . PHP_EOL); 110 | break; 111 | 112 | case "nick": 113 | $nick = implode(" ", $args); 114 | 115 | if (!preg_match("(^[a-z0-9-.]{3,15}$)i", $nick)) { 116 | $error = "Username must only contain letters, digits and " . 117 | "its length must be between 3 and 15 characters."; 118 | $socket->write($error . PHP_EOL); 119 | return; 120 | } 121 | 122 | $remoteAddr = $socket->getRemoteAddress(); 123 | $oldnick = $this->usernames[(string) $remoteAddr] ?? $remoteAddr; 124 | $this->usernames[(string) $remoteAddr] = $nick; 125 | 126 | $this->redisClient->publish("chat", $oldnick . " is now " . $nick . PHP_EOL); 127 | break; 128 | 129 | default: 130 | $socket->write("Unknown command: {$name}" . PHP_EOL); 131 | break; 132 | } 133 | 134 | return; 135 | } 136 | 137 | $remoteAddr = $socket->getRemoteAddress(); 138 | $user = $this->usernames[(string) $remoteAddr] ?? $remoteAddr; 139 | $this->redisClient->publish("chat", $user . " says: " . $message . PHP_EOL); 140 | } 141 | 142 | private function broadcast(string $message) { 143 | foreach ($this->clients as $client) { 144 | // We don't yield the promise returned from $client->write() here as we don't care about 145 | // other clients disconnecting and thus the write failing. 146 | $client->write($message); 147 | } 148 | } 149 | 150 | private function listenToRedis() { 151 | asyncCall(function () { 152 | $redisClient = new Subscriber(Config::fromUri($this->redisHost)); 153 | 154 | do { 155 | try { 156 | /** @var Subscription $subscription */ 157 | $subscription = yield $redisClient->subscribe("chat"); 158 | 159 | while (yield $subscription->advance()) { 160 | $message = $subscription->getCurrent(); 161 | $this->broadcast($message); 162 | } 163 | } catch (RedisException $e) { 164 | // reconnect in case the connection breaks, wait a second before doing so 165 | yield new Delayed(1000); 166 | } 167 | } while (true); 168 | }); 169 | } 170 | }; 171 | 172 | $server->listen(); 173 | }); 174 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | _site 3 | vendor 4 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "github-pages" 3 | gem "kramdown" 4 | gem "jekyll-github-metadata" 5 | gem "jekyll-relative-links" 6 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | kramdown: 2 | input: GFM 3 | toc_levels: 2..3 4 | 5 | baseurl: "/getting-started" 6 | layouts_dir: ".shared/layout" 7 | includes_dir: ".shared/includes" 8 | 9 | exclude: ["Gemfile", "Gemfile.lock", "README.md", "vendor"] 10 | safe: true 11 | 12 | repository: amphp/getting-started 13 | gems: 14 | - "jekyll-github-metadata" 15 | - "jekyll-relative-links" 16 | 17 | defaults: 18 | - scope: 19 | path: "" 20 | type: "pages" 21 | values: 22 | layout: "docs" 23 | 24 | shared_asset_path: "/getting-started/asset" 25 | -------------------------------------------------------------------------------- /docs/asset: -------------------------------------------------------------------------------- 1 | .shared/asset -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | permalink: / 4 | layout: base 5 | --- 6 |
7 |

Getting Started

8 | 9 |
10 |
11 |

Hands-on: Writing a chat server

12 |

13 | Want to build something to get started? Go through a series of posts to learn building a small TCP chat server without prior knowledge. 14 |

15 |
16 | 17 |
18 |

Documentation

19 |

20 | Want to know some details before writing code? Then start by digging into our documentation. 21 |

22 |
23 |
24 | 25 |
26 |
27 |

Questions?

28 |

29 | Chat with us! It's best to just ask the questions you have. Our support page mentions multiple support channels. 30 |

31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /docs/tcp-chat/basic-echo-server.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic TCP Echo Server 3 | permalink: /tcp-chat/basic-echo-server 4 | --- 5 | [Previously](./), we started by creating a simple TCP server with blocking I/O. We'll rewrite `server.php` on top of `amphp/socket` now. 6 | 7 | Create a new [Composer](https://getcomposer.org/) project in a new directory by running `composer init`. Use whatever name and description you want, don't require any libraries yet. 8 | 9 | Then run `composer require amphp/socket` to install the latest `amphp/socket` release. 10 | 11 | {:.note} 12 | > You can find the [code for this tutorial on GitHub](https://github.com/amphp/getting-started/tree/master/2-echo-server). 13 | 14 | ```php 15 | read()) { 30 | yield $socket->write($chunk); 31 | } 32 | }; 33 | 34 | $server = Amp\Socket\Server::listen($uri); 35 | 36 | while ($socket = yield $server->accept()) { 37 | asyncCall($clientHandler, $socket); 38 | } 39 | }); 40 | ``` 41 | 42 | All we do here is accepting the clients and echoing their input back as before, but it happens concurrently now. While just a few lines of code, there are a lot of new concepts in there. 43 | 44 | What happens there? `Amp\Loop::run()` runs the event loop and executes the passed callback right after starting. `Amp\Socket\Server::listen()` is a small wrapper around `stream_socket_server()` creating a server socket and returning it as `Server` object. Like in the previous example, we accept each client as soon as we can. 45 | 46 | Fine so far, now to the probably new concepts, which will be explained in the following sections. `Server::accept()` returns a promise. `yield` will interrupt the coroutine and continue once the promise resolves. It then asynchronously calls `$clientHandler` for each accepted client. `$clientHandler` reads from the socket and directly writes the read contents to the socket again. 47 | 48 | ## What is the Event Loop? 49 | 50 | Good question! The event loop is the main scheduler of every asynchronous program. In it's simplest form, it's a while loop calling [`stream_select()`](https://www.php.net/stream_select). The following pseudo-code might help you to understand what's going on. 51 | 52 | ```php 53 | You can find the [code for this tutorial on GitHub](https://github.com/amphp/getting-started/tree/master/3-broadcasting). 12 | 13 | ```php 14 | uri); 31 | 32 | while ($socket = yield $server->accept()) { 33 | $this->handleClient($socket); 34 | } 35 | }); 36 | } 37 | 38 | public function handleClient(Socket $socket) { 39 | asyncCall(function () use ($socket) { 40 | while (null !== $chunk = yield $socket->read()) { 41 | yield $socket->write($chunk); 42 | } 43 | }); 44 | } 45 | }; 46 | 47 | $server->listen(); 48 | }); 49 | ``` 50 | 51 | All we did there is rewriting the previous example by removing the comments and putting it inside an anonymous class. We can simply add a property to that class now keeping track of our connections. We will also add some output on the server-side when a client connects or disconnects. 52 | 53 | ```php 54 | $client 69 | private $clients = []; 70 | 71 | public function listen() { 72 | asyncCall(function () { 73 | $server = Amp\Socket\Server::listen($this->uri); 74 | 75 | print "Listening on " . $server->getAddress() . " ..." . PHP_EOL; 76 | 77 | while ($socket = yield $server->accept()) { 78 | $this->handleClient($socket); 79 | } 80 | }); 81 | } 82 | 83 | private function handleClient(Socket $socket) { 84 | asyncCall(function () use ($socket) { 85 | $remoteAddr = $socket->getRemoteAddress(); 86 | 87 | // We print a message on the server and send a message to each client 88 | print "Accepted new client: {$remoteAddr}". PHP_EOL; 89 | $this->broadcast($remoteAddr . " joined the chat." . PHP_EOL); 90 | 91 | // We only insert the client afterwards, so it doesn't get its own join message 92 | $this->clients[(string) $remoteAddr] = $socket; 93 | 94 | while (null !== $chunk = yield $socket->read()) { 95 | $this->broadcast($remoteAddr . " says: " . trim($chunk) . PHP_EOL); 96 | } 97 | 98 | // We remove the client again once it disconnected. 99 | // It's important, otherwise we'll leak memory. 100 | unset($this->clients[(string) $remoteAddr]); 101 | 102 | // Inform other clients that that client disconnected and also print it in the server. 103 | print "Client disconnected: {$remoteAddr}" . PHP_EOL; 104 | $this->broadcast($remoteAddr . " left the chat." . PHP_EOL); 105 | }); 106 | } 107 | 108 | private function broadcast(string $message) { 109 | foreach ($this->clients as $client) { 110 | // We don't yield the promise returned from $client->write() here as we don't care about 111 | // other clients disconnecting and thus the write failing. 112 | $client->write($message); 113 | } 114 | } 115 | }; 116 | 117 | $server->listen(); 118 | }); 119 | ``` 120 | 121 | ## Next Steps 122 | 123 | We have a working chat server now... well, kind of working. We currently just take every chunk we receive from a client as a message. If a user writes a long message, that message might not be sent as a single packet and we won't receive it in one chunk. We also don't have an usernames or authentication yet. It only works with a single process on the server side, what if we have a lot of clients and can't handle them all in a single process? We will cover those topics in the coming sections, extending our simple project. We won't post all code in the coming sections, but only the interesting / changing parts. 124 | 125 | [Continue with the next section about parsing](parsing). 126 | -------------------------------------------------------------------------------- /docs/tcp-chat/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Building a TCP Chat 3 | permalink: /tcp-chat/ 4 | --- 5 | In this tutorial we're going to create a simple TCP chat server based on Amp that allows many users to connect and exchange messages concurrently. We'll start by building a TCP server which uses blocking I/O like it's traditionally done in PHP and see the limitations of it. 6 | 7 | Get started by creating a new directory for our new project and create a simple `server.php` file with the following content: 8 | 9 | {:.note} 10 | > You can find the [code for this tutorial on GitHub](https://github.com/amphp/getting-started/tree/master/1-blocking-io). 11 | 12 | ```php 13 | You can find the [code for this tutorial on GitHub](https://github.com/amphp/getting-started/tree/master/5-multiple-instances). 17 | 18 | ```php 19 | $redisClient = new SubscribeClient("tcp://localhost:6379"); 20 | 21 | do { 22 | try { 23 | $subscription = yield $redisClient->subscribe("chat"); 24 | 25 | while (yield $subscription->advance()) { 26 | $message = $subscription->getCurrent(); 27 | $this->broadcast($message); 28 | } 29 | } catch (RedisException $e) { 30 | // reconnect in case the connection breaks, wait a second before doing so 31 | yield new Delayed(1000); 32 | } 33 | } while (true); 34 | ``` 35 | 36 | Subscriptions in `amphp/redis` follow [Amp's `Iterator` interface](http://amphp.org/amp/iterators/). 37 | 38 | We will replace our current `$this->broadcast()` calls with `$redisClient->publish()`, the messages will be sent when received from Redis. 39 | 40 | You can find the [complete code in the GitHub repository](https://github.com/amphp/getting-started/tree/master/5-multiple-instances). 41 | 42 | This is the end of our TCP chat server series. Feel free to refactor and extend the code. We'd be glad to hear which cool features you added! If you're looking for real-world use cases not using raw TCP, you might want to have a look at [our HTTP server](https://github.com/amphp/http-server) with WebSocket support. 43 | -------------------------------------------------------------------------------- /docs/tcp-chat/parsing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Parsing Line-Delimited Messages 3 | permalink: /tcp-chat/parsing 4 | --- 5 | [Previously](broadcasting), we successfully completed the basic chat server. In this section we'll parse the input stream into lines. 6 | 7 | Our previous code for reading from the client and passing messages to other clients looked like that: 8 | 9 | {:.note} 10 | > You can find the [code for this tutorial on GitHub](https://github.com/amphp/getting-started/tree/master/4-parsing). 11 | 12 | ```php 13 | while (null !== $chunk = yield $socket->read()) { 14 | $this->broadcast($remoteAddr . " says: " . trim($chunk) . PHP_EOL); 15 | } 16 | ``` 17 | 18 | Using coroutines, it's quite simple to extend this to parse the stream into separate lines. 19 | 20 | ```php 21 | $buffer = ""; 22 | 23 | while (null !== $chunk = yield $socket->read()) { 24 | $buffer .= $chunk; 25 | 26 | while (($pos = strpos($buffer, "\n")) !== false) { 27 | $this->broadcast($remoteAddr . " says: " . substr($buffer, 0, $pos) . PHP_EOL); 28 | $buffer = substr($buffer, $pos + 1); 29 | } 30 | } 31 | ``` 32 | 33 | We create a `$buffer` variable to store the current buffer content. If we can't find a newline character in the `$buffer`, we just continue reading. If we find one, we broadcast the message to all clients as before and remove the message we just broadcasted from the buffer. We use a `while` loop here, as a client also send multiple messages in a single packet. 34 | 35 | ## Parsing Commands 36 | 37 | This section is about parsing, but just parsing newlines is boring, right? Let's add some commands to our server. Commands are special messages that will be treated differently by the server and their result will not be broadcasted to all clients. 38 | 39 | All commands in our server will start with a `/`, so let's parse them. We will modify our above code to separate message handling from parsing the stream into lines. 40 | 41 | ```php 42 | $buffer = ""; 43 | 44 | while (null !== $chunk = yield $socket->read()) { 45 | $buffer .= $chunk; 46 | 47 | while (($pos = strpos($buffer, "\n")) !== false) { 48 | $this->handleMessage($socket, substr($buffer, 0, $pos)); 49 | $buffer = substr($buffer, $pos + 1); 50 | } 51 | } 52 | ``` 53 | 54 | ```php 55 | function handleMessage(ServerSocket $socket, string $message) { 56 | if ($message === "") { 57 | // ignore all empty messages 58 | return; 59 | } 60 | 61 | if ($message[0] === "/") { 62 | // message is a command 63 | $message = substr($message, 1); // remove slash 64 | $args = explode(" ", $message); // parse message into parts separated by space 65 | $name = strtolower(array_shift($args)); // the first arg is our command name 66 | 67 | switch ($name) { 68 | case "time": 69 | $socket->write(date("l jS \of F Y h:i:s A") . PHP_EOL); 70 | break; 71 | 72 | case "up": 73 | $socket->write(strtoupper(implode(" ", $args)) . PHP_EOL); 74 | break; 75 | 76 | case "down": 77 | $socket->write(strtolower(implode(" ", $args)) . PHP_EOL); 78 | break; 79 | 80 | default: 81 | $socket->write("Unknown command: {$name}" . PHP_EOL); 82 | break; 83 | } 84 | 85 | return; 86 | } 87 | 88 | $this->broadcast($socket->getRemoteAddress() . " says: " . $message . PHP_EOL); 89 | } 90 | ``` 91 | 92 | We have three commands now: `time`, `up` and `down`. `time` reports the current server time to the client, while `up` and `down` change the rest of the message to upper / lower case and return the result to the client. 93 | 94 | As you can see, adding commands is pretty easy now. Let's add another one to allow the client to exit (you can do that via `Ctrl + C` in `nc` anyway). 95 | 96 | ```php 97 | case "exit": 98 | $socket->end("Bye." . PHP_EOL); 99 | break; 100 | ``` 101 | 102 | `$socket->end()` sends a final message before closing the socket. 103 | 104 | ## Adding Usernames 105 | 106 | As we already have commands now, why not add a command that let's a client choose its username? Currently we just used the socket address as a username. Let's add a new `nick` command. 107 | 108 | ```php 109 | case "nick": 110 | $nick = implode(" ", $args); 111 | 112 | if (!preg_match("(^[a-z0-9-.]{3,15}$)i", $nick)) { 113 | $error = "Username must only contain letters, digits and " . 114 | "its length must be between 3 and 15 characters."; 115 | $socket->write($error . PHP_EOL); 116 | return; 117 | } 118 | 119 | $remoteAddr = $socket->getRemoteAddress(); 120 | $oldnick = $this->usernames[$remoteAddr] ?? $remoteAddr; 121 | $this->usernames[$remoteAddr] = $nick; 122 | 123 | $this->broadcast($oldnick . " is now " . $nick . PHP_EOL); 124 | break; 125 | ``` 126 | 127 | We also need to change our `broadcast` calls now to use the username, and also need to unset the username when the client disconnects. 128 | 129 | ```php 130 | $remoteAddr = $socket->getRemoteAddress(); 131 | $user = $this->usernames[$remoteAddr] ?? $remoteAddr; 132 | $this->broadcast($user . " says: " . $message . PHP_EOL); 133 | ``` 134 | 135 | ## Adding Authentication 136 | 137 | Adding authentication is a task left to you, we'll just give some hints. You could for example create a `register` command that accepts a name and password and save that somewhere using `password_hash`. You could then extend `nick` with the same mechanism and require the right password using `password_verify` otherwise disallow changing to that name. 138 | 139 | In the next step we will use [Redis](https://redis.io/) for Pub/Sub to broadcast messages over multiple instances. 140 | 141 | [Continue with the next section covering multiple instances](multiple-instances). 142 | --------------------------------------------------------------------------------