Completed
Pull Request — master (#144)
by
unknown
10:27
created

WebSocketServer::removeConnection()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
dl 0
loc 12
c 0
b 0
f 0
ccs 0
cts 8
cp 0
rs 9.8666
cc 3
nc 3
nop 1
crap 12
1
<?php
2
3
/**
4
 * This file is a part of Woketo package.
5
 *
6
 * (c) Nekland <[email protected]>
7
 *
8
 * For the full license, take a look to the LICENSE file
9
 * on the root directory of this project
10
 */
11
12
namespace Nekland\Woketo\Server;
13
14
use Nekland\Woketo\Exception\ConfigException;
15
use Nekland\Woketo\Exception\RuntimeException;
16
use Nekland\Woketo\Message\MessageHandlerInterface;
17
use Nekland\Woketo\Rfc6455\FrameFactory;
18
use Nekland\Woketo\Rfc6455\Handshake\ServerHandshake;
19
use Nekland\Woketo\Rfc6455\MessageFactory;
20
use Nekland\Woketo\Rfc6455\FrameHandler\CloseFrameHandler;
21
use Nekland\Woketo\Rfc6455\FrameHandler\RsvCheckFrameHandler;
22
use Nekland\Woketo\Rfc6455\FrameHandler\WrongOpcodeFrameHandler;
23
use Nekland\Woketo\Rfc6455\FrameHandler\PingFrameHandler;
24
use Nekland\Woketo\Rfc6455\MessageProcessor;
25
use Nekland\Woketo\Utils\SimpleLogger;
26
use Psr\Log\LoggerInterface;
27
use Psr\Log\LogLevel;
28
use React\EventLoop\LoopInterface;
29
use React\Socket\ConnectionInterface;
30
use React\Socket\ServerInterface;
31
32
class WebSocketServer
33
{
34
    const FULL_DATE_FORMAT = 'Y-m-d H:i:s';
35
36
    /**
37
     * @var int
38
     */
39
    private $port;
40
41
    /**
42
     * @var string
43
     */
44
    private $host;
45
46
    /**
47
     * @var ServerHandshake
48
     */
49
    private $handshake;
50
51
    /**
52
     * @var MessageHandlerInterface[]
53
     */
54
    private $messageHandlers;
55
56
    /**
57
     * @var Connection[]
58
     */
59
    private $connections;
60
61
    /**
62
     * @var LoopInterface
63
     */
64
    private $loop;
65
66
    /**
67
     * @var ServerInterface
68
     */
69
    private $server;
70
71
    /**
72
     * @var MessageProcessor
73
     */
74
    private $messageProcessor;
75
76
    /**
77
     * @var array
78
     */
79
    private $config;
80
81
    /**
82
     * @var LoggerInterface
83
     */
84
    private $logger;
85
86
    /**
87
     * @param int $port The number of the port to bind
88
     * @param string $host The host to listen on (by default 127.0.0.1)
89
     * @param array $config
90
     */
91 8
    public function __construct($port, $host = '127.0.0.1', $config = [])
92
    {
93 8
        $this->setConfig($config);
94 7
        $this->host = $host;
95 7
        $this->port = $port;
96 7
        $this->handshake = new ServerHandshake();
97 7
        $this->connections = [];
98 7
        $this->buildMessageProcessor();
99
100
        // Some optimization
101 6
        \gc_enable();       // As the process never stops, the garbage collector will be usefull, you may need to call it manually sometimes for performance purpose
102 6
        \set_time_limit(0); // It's by default on most server for cli apps but better be sure of that fact
103 6
    }
104
105
    /**
106
     * @param MessageHandlerInterface|string $messageHandler An instance of a class as string
107
     * @param string                         $uri            The URI you want to bind on
108
     */
109 4
    public function setMessageHandler($messageHandler, $uri = '*')
110
    {
111 4
        if (!$messageHandler instanceof MessageHandlerInterface &&  !\is_string($messageHandler)) {
112
            throw new \InvalidArgumentException('The message handler must be an instance of MessageHandlerInterface or a string.');
113
        }
114 4
        if (\is_string($messageHandler)) {
115
            try {
116
                $reflection = new \ReflectionClass($messageHandler);
117
                if(!$reflection->implementsInterface('Nekland\Woketo\Message\MessageHandlerInterface')) {
118
                    throw new \InvalidArgumentException('The messageHandler must implement MessageHandlerInterface');
119
                }
120
            } catch (\ReflectionException $e) {
121
                throw new \InvalidArgumentException('The messageHandler must be a string representing a class.');
122
            }
123
        }
124 4
        $this->messageHandlers[$uri] = $messageHandler;
125 4
    }
126
127
    /**
128
     * Launch the WebSocket server and an infinite loop that act on event.
129
     *
130
     * @throws \Exception
131
     */
132 4
    public function start()
133
    {
134 4
        if ($this->config['prod'] && \extension_loaded('xdebug')) {
135
            throw new \Exception('xdebug is enabled, it\'s a performance issue. Disable that extension or specify "prod" option to false.');
136
        }
137
138 4
        $this->loop = $this->loop ?? \React\EventLoop\Factory::create();
139 4
        $this->server = $this->server ?? new \React\Socket\TcpServer($this->host . ':' . $this->port, $this->loop);
140
141 4
        if ($this->config['ssl']) {
142
            $this->server = new \React\Socket\SecureServer($this->server, $this->loop, array_merge([
143
                'local_cert' => $this->config['certFile'],
144
                'passphrase' => $this->config['passphrase'],
145
            ], $this->config['sslContextOptions']));
146
            $this->getLogger()->info('Enabled ssl');
147
        }
148
149 4
        $this->server->on('connection', function ($socketStream) {
150 4
            $this->onNewConnection($socketStream);
151 4
        });
152
153 4
        $this->getLogger()->info('Listening on ' . $this->host . ':' . $this->port);
154
155 4
        $this->loop->run();
156 4
    }
157
158
    /**
159
     * @param ConnectionInterface $socketStream
160
     */
161
    private function onNewConnection(ConnectionInterface $socketStream)
162
    {
163 4
        $connection = new Connection($socketStream, function ($uri, Connection $connection) {
164 4
            return $this->getMessageHandler($uri, $connection);
165 4
        }, $this->loop, $this->messageProcessor);
166
167 4
        $socketStream->on('end', function () use($connection) {
168
            $this->onDisconnect($connection);
169 4
        });
170
171 4
        $connection->setLogger($this->getLogger());
172 4
        $connection->getLogger()->info(sprintf('Ip "%s" establish connection at "%s" UTC',
173 4
            $connection->getIp(),
174 4
            date(self::FULL_DATE_FORMAT)
175
        ));
176 4
        $this->connections[] = $connection;
177 4
    }
178
179
    /**
180
     *
181
     * @param Connection $connection
182
     */
183
    private function onDisconnect(Connection $connection)
184
    {
185
        $connection->disconnect();
186
        $this->removeConnection($connection);
187
        $connection->getLogger()->info(sprintf('Ip "%s" left connection at "%s" UTC',
188
            $connection->getIp(),
189
            date(self::FULL_DATE_FORMAT)
190
        ));
191
192
        unset($connection);
193
    }
194
195
    /**
196
     * Remove a Connection instance by his object id, or log not found
197
     * @param Connection $connection
198
     */
199
    private function removeConnection(Connection $connection)
200
    {
201
        $connectionId = spl_object_hash($connection);
202
        foreach ($this->connections as $index => $connectionIt) {
203
            if ($connectionId === spl_object_hash($connectionIt)) {
204
                unset($this->connections[$index]);
205
                return;
206
            }
207
        }
208
209
        $this->logger->warning(sprintf('Impossible to find the connection with id "%d" in the running server.', $connectionId));
210
    }
211
212
    /**
213
     * @param string $uri
214
     * @param Connection $connection
215
     * @return MessageHandlerInterface|null
216
     */
217 4
    private function getMessageHandler(string $uri, Connection $connection)
0 ignored issues
show
Unused Code introduced by
The parameter $connection is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
218
    {
219 4
        $handler = null;
220
221 4
        if (!empty($this->messageHandlers[$uri])) {
222 2
            $handler = $this->messageHandlers[$uri];
223
        }
224
225 4
        if (null === $handler && !empty($this->messageHandlers['*'])) {
226 1
            $handler = $this->messageHandlers['*'];
227
        }
228
229 4
        if (null !== $handler) {
230 3
            if (\is_string($handler)) {
231
                $handler = new $handler;
232
            }
233
234 3
            return $handler;
235
        }
236
237 1
        $this->logger->warning('Connection on ' . $uri . ' but no handler found.');
238 1
        return null;
239
    }
240
241
    /**
242
     * Build the message processor with configuration
243
     */
244 7
    private function buildMessageProcessor()
245
    {
246 7
        $this->messageProcessor = new MessageProcessor(
247 7
            false,
248 7
            new FrameFactory($this->config['frame']),
249 7
            new MessageFactory($this->config['message'])
250
        );
251 7
        $this->messageProcessor->addHandler(new PingFrameHandler());
252 7
        $this->messageProcessor->addHandler(new CloseFrameHandler());
253 7
        $this->messageProcessor->addHandler(new WrongOpcodeFrameHandler());
254 7
        $this->messageProcessor->addHandler(new RsvCheckFrameHandler());
255
256 7
        foreach ($this->config['messageHandlers'] as $handler) {
257 1
            if (!$handler instanceof MessageHandlerInterface) {
258 1
                throw new RuntimeException(sprintf('%s is not an instance of MessageHandlerInterface but must be !', get_class($handler)));
259
            }
260
        }
261 6
    }
262
263
    /**
264
     * Sets the configuration
265
     *
266
     * @param array $config
267
     * @throws ConfigException
268
     */
269 8
    private function setConfig(array $config)
270
    {
271 8
        $this->config = \array_merge([
272 8
            'frame' => [],
273
            'message' => [],
274
            'messageHandlers' => [],
275
            'prod' => true,
276
            'ssl' => false,
277
            'certFile' => '',
278
            'passphrase' => '',
279
            'sslContextOptions' => [],
280 8
        ], $config);
281
282 8
        if ($this->config['ssl'] && !is_file($this->config['certFile'])) {
283 1
            throw new ConfigException('With ssl configuration, you need to specify a certificate file.');
284
        }
285 7
    }
286
287
    /**
288
     * @return SimpleLogger|LoggerInterface
289
     */
290 4
    public function getLogger()
291
    {
292 4
        if (null === $this->logger) {
293 3
            return $this->logger = new SimpleLogger(!$this->config['prod']);
294
        }
295
296 4
        return $this->logger;
297
    }
298
299
    /**
300
     * Allows you to set a custom logger
301
     *
302
     * @param LoggerInterface $logger
303
     * @return WebSocketServer
304
     */
305 1
    public function setLogger(LoggerInterface $logger)
306
    {
307 1
        $this->logger = $logger;
308
309 1
        return $this;
310
    }
311
312
    /**
313
     * Allows to specify a loop that will be used instead of the reactphp generated loop.
314
     *
315
     * @param LoopInterface $loop
316
     * @return WebSocketServer
317
     */
318 4
    public function setLoop(LoopInterface $loop)
319
    {
320 4
        $this->loop = $loop;
321
322 4
        return $this;
323
    }
324
325
    /**
326
     * @param ServerInterface $server
327
     * @return WebSocketServer
328
     */
329 4
    public function setSocketServer(ServerInterface $server)
330
    {
331 4
        $this->server = $server;
332
333 4
        return $this;
334
    }
335
}
336