
Кратко: nginx не умеет пулить websockets, а php работает per request. Нужна прослойка которая будет держать открытыми вебсокеты и при поступлении данных соединяться с php (через тот же fastcgi) и отправлять обратно ответ.
update: Здесь не идётся про решения на php, так как по сравнению даже с nodejs, они гораздо медленнее.
Тема, как оказалось, не нова, исходники тянуться аж из 2014, но, тем не менее, информации о трюке, про который здесь пойдёт речь, крайне мало. Можете погуглить "websockets php". Усугубляется тема ещё тем, что найденные примеры реализации (два, точнее) не работают, включая тот, что в документации :)
Вот где-то внутри чувствовал, знал, что есть. Мне настолько давно хотелось иметь этот Middleware внутри Nginx, чтобы не использовать разные довольно медленные php библиотеки (раз и два) и обойти стороной однопоточность nodejs. А вебсокетов хочется много (и как можно больше), и чтобы лишние затраты на прослойку были поменьше. Так вот, дабы не плодить кучу машин с nodejs (в будущем при высоких нагрузках так и поступают обычно), воспользуемся тем, что предоставляет Nginx с некоторыми пристройками в виде lua + resty. Nginx+lua можно установить из пакета nginx-extras или же собрать самому. От Resty нам понадобятся только websockets. Скачиваем и закидываем содержимое каталога lib куда-нибудь себе в пути (у меня это /home/username/lib/lua/lib, а по-хорошему надо бы в /usr/local/share/lua).
Стандартно nginx+websockets работает так:
- Клиент соединяется с nginx
- Nginx проксирует в upstream/открывает прокси поток с другим сервером (Middle Server на основе nodejs + sockets.io например), обслуживающим websockets.
- Middle Server сервер кидает socket соединение в какой-нибудь слушатель событий типа epoll и ждёт данных.
- При получении данных, Middle Server сервер, в свою очередь, открывает Fastcgi соединение с php, ожидает и забирает ответ. Отправляет его в socket. Возвращает socket снова в ожидание данных.
- И так по кругу, пока не прийдёт специальный фрейм закрытия websocket.
Всё просто, кроме накладных расходов на ресурсы и однопоточность этого решения.
В предлагаемой схеме MiddleServer превращается в middleware внутри nginx. К тому же нет никакого ожидания Fastcgi, всю работу делает тот же epoll, к которому nginx доверяет открытый сокет, а тем временем поток nginx'a может заняться другими делами. Схема позволяет одновременно работать с кучей вебсокетов раскиданными по потокам.
Здесь приведу только упрощённый код, который относится к задаче без остальных настроек хостинга. Я не старался сделать правильными все заголовки за ненадобностью оных.
lua_package_path "/home/username/lib/lua/lib/?.lua;;"; server { # магия, которая держит вебсокет открытым столько, сколько нам надо внутри nginx location ~ ^/ws/?(.*)$ { default_type 'plain/text'; # всё что надо здесь для веб сокета - это включить луа, который будет его хендлить content_by_lua_file /home/username/www/wsexample.local/ws.lua; } # а это магия, которая отдаёт ответы от php # я шлю только POST запросы, чтобы нормально передать json payload location ~ ^/lua_fastcgi_connection(/?.*)$ { internal; # видно только подзапросам внутри nginx fastcgi_pass_request_body on; fastcgi_pass_request_headers off; # never never use it for lua handler #include snippets/fastcgi-php.conf; fastcgi_param QUERY_STRING $query_string; fastcgi_param REQUEST_METHOD "POST"; # $request_method; fastcgi_param CONTENT_TYPE "application/x-www-form-urlencoded"; #вместо $content_type; fastcgi_param CONTENT_LENGTH $content_length; fastcgi_param DOCUMENT_URI "$1"; # вместо $document_uri fastcgi_param DOCUMENT_ROOT $document_root; fastcgi_param SERVER_PROTOCOL $server_protocol; fastcgi_param REQUEST_SCHEME $scheme; fastcgi_param HTTPS $https if_not_empty; fastcgi_param GATEWAY_INTERFACE CGI/1.1; fastcgi_param SERVER_SOFTWARE nginx/$nginx_version; fastcgi_param REMOTE_ADDR $remote_addr; fastcgi_param REMOTE_PORT $remote_port; fastcgi_param SERVER_ADDR $server_addr; fastcgi_param SERVER_PORT $server_port; fastcgi_param SERVER_NAME $server_name; fastcgi_param SCRIPT_FILENAME "$document_root/mywebsockethandler.php"; fastcgi_param SCRIPT_NAME "/mywebsockethandler.php"; fastcgi_param REQUEST_URI "$1"; # здесь вообще может быть что угодно. А можно передать параметр из lua чтобы сделать какой-нибудь роутинг внутри php обработчика. fastcgi_pass unix:/var/run/php/php7.1-fpm.sock; fastcgi_keep_conn on; }
И код ws.lua:
local server = require "resty.websocket.server" local wb, err = server:new{ -- timeout = 5000, -- in milliseconds -- не надо нам таймаут max_payload_len = 65535, } if not wb then ngx.log(ngx.ERR, "failed to new websocket: ", err) return ngx.exit(444) end while true do local data, typ, err = wb:recv_frame() if wb.fatal then return elseif not data then ngx.log(ngx.DEBUG, "Sending Websocket ping") wb:send_ping() elseif typ == "close" then -- send a close frame back: local bytes, err = wb:send_close(1000, "enough, enough!") if not bytes then ngx.log(ngx.ERR, "failed to send the close frame: ", err) return end local code = err ngx.log(ngx.INFO, "closing with status code ", code, " and message ", data) break; elseif typ == "ping" then -- send a pong frame back: local bytes, err = wb:send_pong(data) if not bytes then ngx.log(ngx.ERR, "failed to send frame: ", err) return end elseif typ == "pong" then -- just discard the incoming pong frame elseif data then -- здесь в пути передаётся реальный uri, а json payload уходит в body local res = ngx.location.capture("/lua_fastcgi_connection"..ngx.var.request_uri,{method=ngx.HTTP_POST,body=data}) if wb == nil then ngx.log(ngx.ERR, "WebSocket instaince is NIL"); return ngx.exit(444) end wb:send_text(res.body) else ngx.log(ngx.INFO, "received a frame of type ", typ, " and payload ", data) end end
Что ещё можно с этим сделать? Замерить скорость и сравнить с nodejs :) А можно внутри lua делать запросы в Redis, MySQL, Postgres… проверять куки и прочие токены авторизации, обрабатывать сессии, кешировать ответы в memcached и потом быстро-быстро отдавать другим клиентам с одинаковыми запросами внутри websocket.
Известные мне недоработки: максимальный размер пакета данных по вебсокету 65Кб. При желании можно дописать разбитие на фреймы. Протокол не сложный.
Тестовый html (ws.html):
HTML тут
<!DOCTYPE> <html> <head> <meta charset="utf-8" /> <script type="text/javascript"> "use strict"; let socket; function tryWebSocket() { socket = new WebSocket("ws://try6.local/ws/"); socket.onopen = function() { console.log("Соединение установлено."); }; socket.onclose = function(event) { if (event.wasClean) { console.log('Соединение закрыто чисто'); } else { console.log('Обрыв соединения'); // например, "убит" процесс сервера } console.log('Код: ' + event.code + ' причина: ' + event.reason); }; socket.onmessage = function(event) { console.log("Получены данные " + event.data); }; socket.onerror = function(error) { console.log("Ошибка " + error.message); }; } function tryWSSend(event) { let msg = document.getElementById('msg'); socket.send(msg.value); event.stopPropagation(); event.preventDefault(); return false; } function closeWebSocket(event) { socket.close(); } </script> </head> <body onLoad="tryWebSocket(event);return false;"> <form onsubmit="tryWSSend(event); return false;"> <button onclick="tryWebSocket(event); return false;">Try WebSocket</button> <fieldset> Message: <input value="Test message 4444" type="text" size="10" id="msg"/><input type="submit"/> </fieldset> <fieldset> <button onclick="closeWebSocket(event); return false;">Close Websocket</button><br/> </fieldset> </form> </body> </html>
Тестовый php (mywebsockethandler.php):
PHP тут
<?php header("Content-Type: application/json; charset=utf-8"); echo json_encode(["status"=>"ok","response"=>"php websocket json @ ".time(), "payload"=>[$_REQUEST,$_SERVER]]); exit;
Чтобы воспользоваться FastCGI для Lua, установите ещё одно Resty расширение.