TrueAsync ServerВаш перший сервер

Ваш перший сервер

Пригадаймо, як PHP зазвичай обслуговує запит. Nginx приймає з'єднання і передає його PHP-FPM. Той бере вільний процес. Процес прокидається, підвантажує класи, відкриває з'єднання з базою, збирає відповідь, віддає її. І помирає. Усе, що він устиг збудувати, усі з'єднання, усі кеші, усі ті гарно прогріті пули з першої серії, вирушає у смітник. За мілісекунду надходить наступний запит, і вся історія повторюється з нуля. Сто разів на секунду. Тисячу.

Тим часом у першій серії ми збудували цілий арсенал речей, яким таке життя протипоказане: пул з'єднань добрий, коли живе довго, а мемоїзований Future безглуздий, якщо помирає разом із процесом.

Тож план на цю серію простий: прибрати посередників. TrueAsync Server це розширення, яке запускає HTTP-сервер просто всередині PHP-процесу:

php
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;

$server = new HttpServer(
    new HttpServerConfig()->addListener('0.0.0.0', 8080)
);

$server->addHttpHandler(function ($request, $response) {
    $response->setStatusCode(200)->setBody('Hello, World!');
});

$server->start();
bash
$ php server.php &
$ curl -i http://localhost:8080/
HTTP/1.1 200 OK
Content-Length: 13

Hello, World!

addListener відкриває порт, addHttpHandler реєструє функцію-обробник, а start() запускає цикл подій і не повертається. Кожен вхідний запит запускає обробник.

Обробник це корутина

Кожен виклик обробника запускається у власній корутині (spawn). Що це означає на практиці? Влаштуймо експеримент. Додамо до сервера завідомо повільний маршрут:

php
use function Async\delay;

$server->addHttpHandler(function ($request, $response) {
    if ($request->getPath() === '/slow') {
        delay(5000); // п'ять секунд «важкої» роботи з введенням-виведенням
        $response->setBody("was slow\n");
        return;
    }

    $response->setBody("fast\n");
});

Тепер відкрийте два термінали. У першому запросіть /slow. Він завис, чекає свої п'ять секунд. Не дочекавшись його, у другому терміналі запросіть /:

bash
$ curl http://localhost:8080/
fast

Миттєво.

Якщо ви читали першу серію, ви вже розумієте, що сталося. Корутина /slow заснула в delay, планувальник передав керування далі, і сервер спокійно обслужив другий запит. Це ті самі лічильники «A» і «B» з найпершого розділу, просто тепер вони називаються HTTP-запитами. Один потік. Один цикл подій. Тисячі конкурентних клієнтів.

А тепер прикиньте, що той самий /slow зробив би з класичним FPM. П'ять секунд сну це п'ять секунд, на які цілий воркер вибув із ладу. Десяток таких запитів, і пул воркерів вичерпано. Увесь сайт стоїть і чекає, поки хтось досипляє.

Процес, який не помирає

Другий наслідок цікавіший за перший, хоча виглядає буденно. start() не повертається. А отже, усе, що створено до нього, живе стільки ж, скільки сервер:

php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_POOL_ENABLED => true,
    PDO::ATTR_POOL_MAX     => 10,
]);

$directory = new RegionsDirectory(); // мемоїзація із шостого розділу

$server->addHttpHandler(function ($request, $response) use ($pdo, $directory) {
    // пул уже прогрітий, довідник уже завантажений
});

$server->start();

Пул з'єднань відкривається один раз. Довідник регіонів завантажується один раз. Маршрути компілюються один раз. Пам'ятаєте, скільки сил перша серія витратила на інструменти повторного використання? Ось місце, де вони всі нарешті вдома. Cold start не став швидшим. Він зник.

Заради справедливості, у довгого життя є ціна, і про неї варто сказати відразу. Витік пам'яті більше не прощається смертю процесу після запиту. Глобальна змінна більше не «на один запит», вона назавжди і для всіх. Лякатися не треба: scope, пули та контекст із першої серії вигадані саме для цього, і серверну специфіку ми розберемо окремим розділом.

А поки що в нашого сервера є простіша проблема: він відповідає те саме на все підряд. GET /profile/42, POST /profile/42/address, друкарська помилка в URL, без різниці. Справжньому ProfileService доведеться навчитися читати запит. Цим і займемося далі.