Пробуем KPHP: реально ли его использовать в своих проектах

25.08.2021 в 06:24
16920
+11

ВК - крупнейшая социальная сеть в России. Её код изначально был написан на PHP - языке, позволяющим быстро реализовывать функционал веб-сайтов. Однако, PHP является интерпретируемым языком, что само собой не есть хорошо для высоких нагрузок.

Впрочем, до мая 2013 года ВК продолжал работать на PHP, и при этом вполне себе масштабировался путём увеличения серверных мощностей. В конце мая 2013 года командой ВК был реализован компилятор PHP-кода под названием KPHP (Kitten PHP), после перехода на который скорость загрузки страниц сократилась в среднем в 2 раза (пост Павла Дурова). В 2014 году ВК заопенсорсили KPHP, однако, ввиду отсутствия документации, поддержки, а также сильно урезанным функционалом по сравнению с PHP 5.4, актуальным на тот момент, он не нашёл широкого применения вне ВК. В ноябре 2020 года ВК выложили доработанную версию KPHP, которая на данный момент поддерживает большинство возможностей современного PHP и имеет поддержку синтаксиса на уровне 7.4.

При этом, новый релиз сопровождается подробной документацией, бенчмарками и демо, что делает KPHP более доступным и привлекательным для широких масс. И в этой статье мы постараемся рассмотреть преимущества и недостатки KPHP - компилятора PHP-кода от ВКонтакте.

Сразу оговорюсь, что всё что описанное ниже я пишу только от своего имени и прошу никак не ассоциировать текст этой статьи с компанией ВКонтакте. Поехали.

Вставлю тут немного ссылок, это то, с чего стоит начать своё знакомство с KPHP:

Собираем KPHP

Первым делом давайте соберем компилятор kphp из исходников. Я собирал под убунтой 20 вот по этой инструкции - https://vkcom.github.io/kphp/kphp-internals/developing-and-extending-kphp/compiling-kphp-from-sources.html
Всё собралось без каких-либо проблем.
После компиляции компилятора kphp давайте попробуем глянуть его версию, чтобы убедиться, что всё прошло нормально.

Теперь давайте попробуем запустить первое приложение, скомпилированное с помощью kphp. Я создал новый проект kphp.loc, в нём создал папку www, внутри файлик index.php. Внутри написал простейший код:

<?php

echo 2 + 2;

После чего в консоли переходим в директорию проекта и компилим бинарь приложения:

../kphp/objs/bin/kphp2cpp --composer-no-dev --composer-root ./ ./www/index.php

После компиляции можно запускать:

./kphp_out/server -H 1337

-H 1337 – это номер порта, на котором слушать запросы.
После этого можно сделать запрос из браузера:
http://127.0.0.1:1337/

Что-ж, первую версию приложения мы собрали. Давайте теперь попробуем написать что-то хотя бы частично напоминающее реальное приложение. Почти ни одно полноценное веб-приложение не обойдется без работы с базой данных.

Дорабатываем KPHP для работы с MySQL

В данный момент версия kphp, размещенная на github, ограничена тем, что не умеет работать с MySQL на кастомном хосте/с кастомным портом/с кастомными логином и паролем. Немного поковырявшись в исходниках мне показалось, что ограничения на самом деле несколько искуственные, и внеся совсем небольшие изменения можно заставить KPHP работать с мускулем полноценно.

Драйвер для MySQL в KPHP работает не так как в обычном PHP - соединение с базой создаётся в рамках одного KPHP-воркера и поддерживается уже установленным. За счёт этого происходит оптимизация, благодаря которой при каждом запросе к KPHP-приложению не происходит установления соединения с базой, а сразу летят запросы в уже открытый коннекшен. В связи с этим аргументы функции mysqli_connect будут игнорироваться. Вместо этого они сейчас захардкожены в коде компилятора. Я решил попробовать обойти это ограничением путём замены этих участков кода переменными, которые будут передаваться параметрами-флагами при запуске бинарника приложения, как-то так:

./kphp_out/server -H 1337 -mysql-host 12.34.56.78 -mysql-password P@$$w0rd

Немного подправив код, я пересобрал сначала компилятор, а потом и бинарь приложения – на удивление, приложение заработало с базой данных на другом сервере без каких-либо проблем. Ссылка на коммит с изменениями - https://github.com/ivashkevitch/kphp-with-mysql-and-utf-8/pull/1/commits/aa85ad363d63ce0d4854db333d22fadc5dbd674a

После сборки можно запускать приложение со следующими флагами:

server -H 1337 --mysql-db-name db1 --mysql-host test.beget.tech --mysql-user phpzone --mysql-password phpzone --disable-mysql-same-datacenter-check

Все параметры интуитивно понятны, за исключением --disable-mysql-same-datacenter-check – это выключает проверку, которая не позволяет устанавливать соединение с базой, если она расположена на другом ip-адресе (видимо какие-то внутренние хаки для инфры ВК).

После чего я написал в index.php следующий код:

<?php

$connection = mysqli_connect('127.0.0.1', 'fake', 'fake', 'fake', 3306);
mysqli_query($connection, 'set names utf8');
$mysqli_result = mysqli_query($connection, 'SELECT * FROM posts limit 10');
$result = [];
if ($mysqli_result !== false) {
    while ($res = mysqli_fetch_array($mysqli_result, MYSQLI_ASSOC)) {
        $result[] = $res;
    }
}

var_dump($result);

Собрал бинарь и запустил. На моё удиваление, запрос в базу успешно прошел, но выявилась проблема с кодировкой:

Дело в том, что для оптимизации по умолчанию используется кодировка windows-1251…

Фиксим кодировку

Покопавшись еще немного в исходниках KPHP я нашел место, где в ответном заголовке захардкожена кодировка windows-1251. Чтобы не ломать совместимость я опять-таки с помощью дополнительного флага, передаваемого при старте приложения, добавил поддержку utf-8. Ссылка на коммит:
https://github.com/ivashkevitch/kphp-with-mysql-and-utf-8/pull/2/commits/792e46fe00be4e7ca1ef9a4cc531fb4000db82bc

После чего опять пересобираем KPHP, а затем и само приложение. Теперь при запуске добавляем еще один флаг:

--use-utf8

И теперь запуск бинарника будет выглядеть следующим образом:

server -H 1337 --mysql-db-name db1 --mysql-host test.beget.tech --mysql-user phpzone --mysql-password phpzone --disable-mysql-same-datacenter-check --use-utf8

Пробуем обновить страничку.

Успех! Всё завелось.

Форкнутую версию KPHP с поддержкой MySQL и UTF-8 я положил пока тут - https://github.com/ivashkevitch/kphp-with-mysql-and-utf-8 ну и сделал pull request в основную репу KPHP - https://github.com/VKCOM/kphp/pull/288

Вряд ли код вмержат как есть, так как на C я писать толком не умею и скорее всего получился лютый говнокод. В общем, жду фидбека от разрабов KPHP.

Тестовый проект

Я не стал ничего шибко умного придумывать, решил сделать простенькую API для вывода постов.

База данных

Я создал локально новую базу данных с названием kphp_test и добавил в неё табличку posts, добавив несколько записей.

Запрос на создание таблички:

CREATE TABLE `posts` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `text` text NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `posts` (`id`, `title`, `text`) VALUES
(1, 'Post 1',   'Text 1'),
(2, 'Post 2',   'Text 2'),
(3, 'Post 3',   'Post 3'),
(4, 'Post 4',   'Post 4'),
(5, 'Post 5',   'Post 5'),
(6, 'Post 6',   'Post 6'),
(7, 'Post 7',   'Post 7');

Код

Я создал папку с проектом ~/projects/kphp.loc/
Внутри создал папку www, в ней файлик index.php.

<?php

echo $_SERVER['REQUEST_URI'];

После чего пересобрал бинарник, запустил и проверил в браузере запрос: http://127.0.0.1:1337/api/v1/posts

Адрес корректно обрабатывается, значит можно городить роутинг.

Но перед этим давайте создадим наш первый контроллер:
src/KphpTest/Controllers/PostsController.php

<?php

namespace KphpTest\Controllers;

class PostsController
{
    public function __construct()
    {
    }

    public function getNew(): array
    {
        return [
            'result' => 0
        ];
    }
}

Теперь набросаем в index.php простейший роутинг и возврат результата от контроллера:

<?php

require __DIR__ . '/../src/KphpTest/Controllers/PostsController.php';

$routes = [
    '^/api/v1/posts$' => [\KphpTest\Controllers\PostsController::class, 'getNew']
];

$uri = $_SERVER['REQUEST_URI'];

$result = null;
foreach ($routes as $route => $controllerAndAction) {
    if (preg_match('~' . $route . '~', $uri, $matches) === 1) {
        [$controllerName, $actionName] = $controllerAndAction;
        $controller = new $controllerName;
        $result = $controller->$actionName();
    }
}

if ($result !== null) {
    header("Content-type: application/json; charset=utf-8");
    echo json_encode($result);
}

При попытке собрать бинарь приложения получаем следующее:

Такой код работал бы в обычном PHP за счет динамической типизации, но в KPHP нельзя на лету создать объект непонятно какого класса, так как код транслируется в C++ - язык со строгой типизацией. А там такого проворачивать нельзя. Поэтому наш для KPHP придется переписать как-то так:

<?php

require __DIR__ . '/../src/KphpTest/Controllers/PostsController.php';

$routes = [
    '^/api/v1/posts$' => static fn() => (new \KphpTest\Controllers\PostsController())->getNew(),
];

$uri = $_SERVER['REQUEST_URI'];

$result = null;
foreach ($routes as $route => $closure) {
    if (preg_match('~' . $route . '~', $uri, $matches) === 1) {
        $result = $closure();
    }
}

if ($result !== null) {
    header("Content-type: application/json; charset=utf-8");
    echo json_encode($result);
}

Хм, а мне так даже больше нравится – IDE всегда подскажет где юзается экшен.

Пробуем запустить.

Всё успешно отработало. Чтобы не писать вручную каждый раз require для каждого нужного нам класса, напишем функцию автозагрузки:

<?php

spl_autoload_register(function (string $className) {
    require_once __DIR__ . '/../src/' . str_replace('\\', '/', $className) . '.php';
});

...

И снова нас ждет ошибка:

В require разрешено передавать только строки. И как же нам быть? Для каждого класса добавлять require в index.php? Конечно, нет! KPHP поддерживает работу с автозагрузкой с помощью composer. Опишем неймспейс и соответствующий ему путь:

{
    "autoload": {
        "psr-4": {
            "KphpTest\\": "src/KphpTest/"
        }
    }
}

Помимо этого я рекомендую вам также добавить в зависимости KPHP-полифилы – набор функций, реализованных в KPHP, но недоступных по умолчанию в PHP. О них мы поговорим чуть позже, пока просто добавьте:

{
    "autoload": {
        "psr-4": {
            "KphpTest\\": "src/KphpTest/"
        }
    },
    "require-dev": {
        "vkcom/kphp-polyfills": "^1.0"
    }
}

Запускаем composer install. В index.php подключаем autoload.php:

<?php

require_once __DIR__ . '/../vendor/autoload.php';
...

Теперь при сборке бинарника нужно передать следующие параметры:

kphp/objs/bin/kphp2cpp --composer-no-dev --composer-root ./ ./www/index.php

После сборки пробуем запустить:

Всё успешно отработало.

Давайте теперь напишем класс для работы с базой данных:
src/KphpTest/Infrastructure/Db.php

<?php

namespace KphpTest\Infrastructure;

class Db
{
    private static ?Db $instance = null;

    /** @var \mysqli */
    private \mysqli $connection;

    public function __construct()
    {
        $this->connection = mysqli_connect('127.0.0.1', 'fake2', 'fake2', 'kphp_test', 3306);
        mysqli_query($this->connection, 'set names utf8');
    }

    public static function getInstance(): Db
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }

        return self::$instance;
    }

    public function query(string $sql): array
    {
        $mysqli_result = mysqli_query($this->connection, $sql);
        $result = [];
        if ($mysqli_result !== false) {
            while ($res = mysqli_fetch_array($mysqli_result, MYSQLI_ASSOC)) {
                $result[] = $res;
            }
        }
        return $result;
    }
}

Опишем модель поста:
src/KphpTest/Models/Posts/Post.php

<?php

namespace KphpTest\Models\Posts;

class Post
{
    private int $id;

    private string $title;

    private string $text;

    public function getId(): int
    {
        return $this->id;
    }

    public function setId(int $id): Post
    {
        $this->id = $id;
        return $this;
    }

    public function getTitle(): string
    {
        return $this->title;
    }

    public function setTitle(string $title): Post
    {
        $this->title = $title;
        return $this;
    }

    public function getText(): string
    {
        return $this->text;
    }

    public function setText(string $text): Post
    {
        $this->text = $text;
        return $this;
    }
}

Напишем слой для маппинга объекта в/из базы данных:

<?php

namespace KphpTest\Models\Posts;

use KphpTest\Infrastructure\Db;

class PostsStorage
{
    private static ?PostsStorage $instance = null;

    private Db $db;

    private function __construct(Db $db)
    {
        $this->db = $db;
    }

    public static function getInstance(): PostsStorage
    {
        if (self::$instance === null) {
            self::$instance = new self(Db::getInstance());
        }

        return self::$instance;
    }

    /**
     * @return Post[]
     */
    public function getNew(int $n): array
    {
        $rawItems = $this->db->query(sprintf('SELECT * FROM posts ORDER BY id DESC LIMIT %d', $n));

        /** @var Post[] $result */
        $result = [];
        foreach ($rawItems as $item) {
            $result[] = $this->unserialize((array)$item);
        }

        return $result;
    }

    private function unserialize(array $item): Post
    {
        return (new Post())
            ->setId((int)$item['id'])
            ->setTitle((string)$item['title'])
            ->setText((string)$item['text']);
    }
}

И, наконец, обновим наш контроллер:

<?php

namespace KphpTest\Controllers;

use KphpTest\Models\Posts\Post;
use KphpTest\Models\Posts\PostsStorage;

class PostsController
{
    private PostsStorage $postsStorage;

    public function __construct()
    {
        $this->postsStorage = PostsStorage::getInstance();
    }

    public function getNew(): array
    {
        $newPosts = $this->postsStorage->getNew(5);

        return [
            'result' => array_map(static fn(Post $post) => [
                'id' => $post->getId(),
                'title' => $post->getTitle(),
                'text' => $post->getText(),
            ], $newPosts)
        ];
    }
}

Собираем, обновляем страничку:

Как видим, всё успешно отработало. Ничем особенным код на KPHP от кода на PHP не отличается, если знать, как писать :)

Теперь я предлагаю сравнить скорость работы нашего бинарника со связкой Apache+PHP 8.0.6.

Одновременно один воркер KPHP может обрабатывать один запрос. Чтобы поддержать больше одновременных запросов можно использовать флаг --workers-num при запуске бинарника:

server -H 1337 --mysql-db-name db1 --mysql-host test.beget.tech --mysql-user phpzone --mysql-password phpzone --disable-mysql-same-datacenter-check --use-utf8 --workers-num 10

Для тестов я использовал ab. Сначала проверим на 5 параллельных запросах

Результаты для PHP:

Результаты для KPHP:

Затем в 1 поток:

PHP:

KPHP:

KPHP отработал быстрее в 1,6 раза быстрее! И это не предел, ведь у него в запасе есть еще всякие крутые штуки, которые могут быть доступны только у приложения, постоянно висящего в памяти. Но об этом мы поговорим в следующих статьях. Пока что, я лишь хотел продемонстрировать что этот инструмент вполне себе доступен, у него есть дока, и он действительно быстрее обычного PHP. Да, есть некоторые особенности при написании кода, но, если вам нужна производительность, можно подстроиться. Пробуйте, изучайте, и до встречи в следующей статье!

Код проекта на github: https://github.com/ivashkevitch/kphp-test-project

loader
25.08.2021 в 06:24
16920
+11
Комментарии
К этому посту больше нельзя оставлять новые комментарии
Логические задачи с собеседований