Мы в Badoo активно занимаемся переходом на PHP 7.4 и с большим энтузиазмом ждём возможности использовать новую функцию preload. Не так давно мы рассказывали о наших экспериментах с ней.

Судя по всему, сообщество взбудоражено не меньше, чем мы. Разработчики фреймворков активно обсуждают возможности внедрения прелоада (а некоторые уже сделали его поддержку). Теперь дошла очередь и до менеджера зависимостей Composer. 



Italo Baeza написал статью, в которой высказал своё мнение о том, как Composer должен работать с прелоадом. Я решил поделиться переводом этого текста, а заодно и переводом другой его статьи — о том, что ответили на предложение сами разработчики Composer, а также о новом инструменте, который облегчает работу с прелоадом.

Как Composer должен выполнять предзагрузку в PHP 7.4


Предзагрузка (preload) — одна из важных возможностей, которую PHP 7.4 предлагает разработчикам, нуждающимся в более высокой производительности. Эту функцию можно назвать «прогревом» перед внедрением JIT-движка, который появится (или должен появиться) в PHP 8. До этого будет достаточно предзагрузки, и кто знает, возможно, они смогут работать в тандеме.

Что собой представляет функция предзагрузки, объяснено в этой статье. Суть очень проста: в php.ini указывается PHP-скрипт, для которого при запуске процесса в память загружаются файлы (предзагрузка). В сочетании с OPCache и функцией autoloader Composer-файлы также могут быть однократно скомпилированы и залинкованы, после чего они будут доступны для всех последующих запросов. Благодаря этому PHP не нужно загружать и компилировать файлы при каждом запросе.

Однако разработчики Composer не пришли к согласию, как он должен помогать предзагрузке, помимо предоставления функции автозагрузки. Факты таковы:

  • предзагрузка впервые анонсирована в PHP 7.4;
  • не существует директивы Composer, помогающей выполнять предзагрузку файлов;
  • для предзагрузки нужен доступ к php.ini, то есть к самому процессу;
  • предзагрузка всех файлов не обязательно повысит производительность по сравнению с предзагрузкой только самых востребованных файлов.

Иными словами, предзагрузкой смогут пользоваться только те, у кого есть полный доступ к серверам. Это исключает серверы общего использования и некоторые PaaS-решения, которые не предполагают работу с php.ini.

Итак, как же Composer может помочь предзагрузке, учитывая, что это нововведение? Вот моё мнение.

Как должна работать предзагрузка


Механизм работы предзагрузки должен опираться на список файлов, которые будут загружены и сохранены в памяти при запуске. А поскольку это список, нам нужно работать с массивом файлов и позволить Composer выполнять всю работу, а не загружать каждый файл вручную.

Composer должен брать список файлов, заданный приложением (корневым проектом), и компилировать всё в файлы, которые PHP сможет использовать безо всяких затруднений.
В то же время нам нужна возможность добавлять и удалять пакеты из механизма предзагрузки.
Предзагрузка никогда не должна работать на уровне пакетов, поскольку это ответственность разработчика — разрешать или запрещать предзагрузку каждого пакета.

Предзагрузка в Composer должна быть опциональной. Разработчик должен иметь возможность её отключать, чтобы PHP использовал собственный предзагрузчик, который может работать на основе анализа OPCache, — это зависит от загрузки приложения и работает гораздо эффективнее простой предзагрузки всех файлов.

Всё начинается в preload.json


Чтобы не усложнять систему, поместим в корень проекта файл preload.json. В нём будут перечислены файлы для предзагрузки, которые сможет выбирать Composer. Поскольку это JSON-файл, разработчик может по своему усмотрению даже генерировать его с помощью специальной команды. Я считаю, что было бы отлично, если бы Composer имел утилиту для создания такого JSON-файла на основе скрипта.

{
    "pre-compile": [
        "my-script.php",
        "my-other-script.php"
    ],
    "extensions": [
        "php"
    ],
    "files": [
        "app/*",
        "config/",
        "helpers.php",
        "app/Models/*",
        "app/Controllers/*/Http/*",
        "app/Views/Compiled*.php"
    ],
    "namespace": [
        "App\\Models",
        "App\\Controllers\\",
        "App\\Views\\MainView",
        "Vendor\\Package\\*",
    ],
    "packages": {
        "symfony/http-client": true,
        "robert/*-client": true,
        "vendor/package": {
            "files": true,
            "namespace": true
        },
        "foo/bar": {
            "files": [
                "helpers.php",
                "loaders/*"
            ],
            "namespace": [
                "Foo\\Bar\\DynamicLoaders\\*",
                "Foo\\Bar\\Clients"
            ]
        }
    },
    "output": "preload-compiled.php"
}

Использование preload.json позволяет быстро проверять, включена ли в проекте предзагрузка: если файл отсутствует, то предзагрузка не поддерживается или нежелательна.

Давайте разберёмся, что делают ключи.

pre-compile

Эти файлы будут исполняться Composer. Каждый скрипт должен вернуть массив абсолютных путей к файлам, чтобы добавить их в список предзагрузки, который будет играть роль основного списка.

"pre-compile": [
   "my-script.php",
   "my-other-script.php"
]

Эти файлы будут исполнены в указанном порядке.

Цель в том, чтобы разработчик мог создавать список файлов по своему усмотрению, а не полагаться на один лишь JSON-файл. Эти файлы будут исполнены прежде всего. И да, вы сможете реализовать preload.json только с этим ключом. Поскольку мы говорим о PHP-файлах, при компилировании массива вы даже можете добавлять другие файлы.

extensions

Это список расширений файлов, которые нужно предварительно загрузить. По умолчанию берутся файлы только с расширением php. 

"extensions": ["php", "php5", "php7"]

Например, вы можете добавить директорию, наполненную файлами *.phtml, среди которых несколько полезных PHP-файлов, и Composer выберет только их, а не всё содержимое директории.
Как вы понимаете, этот процесс можно заменить добавлением файлов вручную.

files

Этот ключ говорит Composer, чтобы он загрузил все файлы из списка, пути к которым указаны относительно местонахождения composer.json.

"files": [
   "helpers.php",
   "app/Models/*",
   "app/Controllers/*/Http/*",
   "app/Views/Compiled*.php",
]

Разобраться в списке очень просто:

  • для добавления файлов и директорий используйте относительные пути;
  • из директорий будут добавляться только хранящиеся в них дочерние файлы (не рекурсивно);
  • рекурсивные пути обозначаются окончанием в виде звёздочки (*);
  • с помощью этого символа вы также можете, к примеру, добавлять определённые файлы и директории: src/Clients/*/Stores или src/Model*.php.

Добавление файлов по маске без ручного выбора или создания привязанных к приложению скриптов особенно полезно при разработке больших приложений.

Если вам нужно просто предварительно загрузить все файлы с помощью ключа autoload в JSON-файле Composer, то присвойте ему значение true.
 
namespace

Этот ключ говорит Composer, чтобы тот загрузил файлы с заданным пространством имён или названием класса вроде file или directory. Этот же механизм позволяет динамически вызывать имена пространств из других установленных пакетов.

"namespaces": [
   "App\\Models",
   "App\\Controllers\\",
   "App\\Views\\MainView",
   "Vendor\\Package\\*",
]

Это тоже удобно при работе над большими приложениями, больше зависящими от пространств имён, а не от файлов, которые могут в любой момент измениться. Composer будет автоматически извлекать файлы в соответствии с пространством имён и помещать их в список.

packages

Этот ключ позволяет загружать другие файлы, зарегистрированные из внешних пакетов, например вспомогательные файлы или классы, привязанные к пространству имён.

"packages": {
   "symfony/http-client": true,
   "robert/*-client": true,
   "vendor/package": {
       "files": true,
       "namespace": true
   },
   "foo/bar": {
       "files": {
           "helpers.php",
           "loaders/*"
       },
       "namespace": [
           "Foo\\Bar\\DynamicLoaders\\*",
           "Foo\\Bar\\Clients"
       ]
   }
}

Тут всё очень просто: если значение равно true, то будет загружено всё содержимое ключа  autoload в файле composer.json этого пакета. В противном случае можно более тонко управлять добавлением в предзагрузку.

Если значение ключа равно true, то он загрузит все файлы, зарегистрированные в autoload. По умолчанию значение равно false. Это верно и для ключа namespace.

Также с помощью этого правила можно выбирать отдельные файлы или пространства имён. Но в таком случае ключ autoload использоваться не будет.

output

Это просто имя файла скомпилированного списка предзагрузки.

"output": "preload-compiled.php"

Простота сборки


Наш список предзагрузки готов, и мы можем вызвать Composer, чтобы он собрал основной скрипт предзагрузки.

composer preload

В результате будет создан preload-compiled.php со всеми файлами, которые должен предварительно загрузить PHP. Конечно, вы можете изменить название файла по своему усмотрению. 

Также нужно переопределить ключи preload параметрами.

composer preload    --input=my-custom-preload-list.json    --output=my-preload.php

Отключено по умолчанию


Проекты без preload.json будут возвращать ошибку, если вы попытаетесь собрать файл для предзагрузки. Причина в том, что Composer не будет (да и не должен) гадать, что ему предварительно загружать.

Напомню, что preload не вмешивается в нормальную функциональность Composer. Поскольку это консольная команда, то при локальной разработке вы можете полностью отказаться от предварительной загрузки. Единственное, что нужно механизму предзагрузки от Composer, это файл Autoload, который должен быть сгенерирован в случае отсутствия. Ну ведь почти 2020-й год на дворе, везде используется PSR-4, верно?

Результат


У вас должен получиться php-файл с примерно таких содержимым:

<?php
/**
 * Preloading @generated by Composer
 */
 
// Autoload the classes so those can be preloaded using `require_once`.
require_once __DIR__.'/../autoload.php';

// File list
$files = [
    '/var/www/app/Foo.php',
    '/var/www/app/Bar.php',
    '/var/www/helpers/basic.php',
    '/var/www/helpers/advanced.php',
    '/var/www/vendor/Foo/Bar/src/Class.php',
    '/var/www/vendor/Foo/Bar/helpers/helpers.php',
    '/var/www/vendor/Foo/Bar/config.php',
    // ...
];

// Preload all root project files
foreach ($files as $file) {
    require_once $file;
}

По сути, это лишь список файлов, которые будут предварительно загружаться с помощью функции autoloader в Composer. PHP исполнит этот файл однократно, и он станет историей.


Я искренне надеюсь, что в Composer появится какой-то способ предварительно загружать файлы без необходимости писать хак.

Поскольку описанный выше способ не является частью ядра Composer, вы всё ещё можете на основе анализа OPCache выбирать для предзагрузки самые важные файлы, не трогая менее востребованные. Представьте, что вместо предзагрузки 1500 файлов объёмом 100 Мб вы можете загрузить всего 150 файлов объёмом 10 Мб, сохранив 99% изначальной производительности.

Предзагружаем одной строкой проект на PHP 7.4 


Вскоре после того как я написал статью о том, как Composer может помочь вам в предзагрузке проекта, Seldaek (участник команды разработки Composer) убил всякую надежду на появление в Composer опции лёгкой предзагрузки проекта в PHP-процесс из менеджера пакетов.

(…) Поясню: я уверен в том, что в ближайшем будущем мы не будем добавлять в Composer ничего, что относится к предзагрузке.

Почему? Предзагрузка в PHP является проблемой скорее разработки (а не зависимостей), она решается с помощью ручного редактирования php.ini — это могут делать только разработчики, если они сами управляют PHP.

Но это не помешает мне создать свой пакет для предзагрузки проекта. И вам тоже.

Предзагрузка и метрики


Предзагрузка может стать хорошим инструментом для простого и дешёвого повышения производительности без серьёзной переработки.

Но проблема не в том, как предзагружать, а что. Предзагрузка целых фреймворков и тысяч файлов быстро истощит память, так что делать это вслепую — не вариант, по крайней мере в больших проектах. Целесообразно загружать только самые востребованные файлы. Но как их определить?

К счастью, OPCache позволяет с помощью opcache_get_status() собирать данные о том, к каким файлам происходит больше всего обращений. Можно не только узнать, какие файлы самые востребованные, но даже сколько памяти они потребляют спустя какое-то время после запуска приложения.

Рекомендуют подождать либо неделю, либо до того момента, когда OPCache зарегистрирует определённое количество обращений. Всё зависит от приложения, но суть вы поняли. 
Так давайте создадим список предзагрузки на основе статистики наиболее популярных файлов. Я сделал для этого пакет.

Представляю… Preloader!


Этот пакет автоматически создаст список предзагрузки для вашего приложения. Он соберёт статистику использования OPCache, отсортирует файлы по количеству обращений и создаст список так, чтобы общий размер файлов не превышал заданный порог.



Я долго ломал голову в поисках наилучшей стратегии создания списка. И пришёл к выводу, что лучше всего добавлять в него все файлы, пока не упрёшься в предел памяти, который для пакетов по умолчанию равен 32 Мб. Файлы будут отсортированы по количеству обращений, а сам пакет автоматически будет исключен.

Иными словами, PHP повысит производительность приложения при обработке большинства запросов к нему.

И как это использовать? Укажите Composer Autoloader, куда записывать скрипт Preloader, и готово.

use DarkGhostHunter\Preloader\Preloader;
Preloader::make()
   ->autoload('vendor/autoload.php')
   ->output('preload.php')
   ->generate();

Конечно, вам выбирать, когда генерировать, но в этом вся соль. Вы даже можете делать это рандомно и перезаписывать список, например, на каждый 100-й запрос.

use DarkGhostHunter\Preloader\Preloader;
Preloader::make()
   ->whenOneIn(100)
   ->autoload('vendor/autoload.php')
   ->output('preload.php')
   ->overwrite()
   ->generate();

Вы получите готовый скрипт предзагрузки, которой можно класть в php.ini.

<?php
/**
 * This file is generated automatically by Preloader.
 *
 * This script uses Composer Autoload file and `require_once` to preload the files in this
 * list. Add this file to your `php.ini` in `opcache.preload` to preload this list into
 * PHP at startup. Additionally, this file also includes information about Opcache.
 *
 *
 * Add (or update) this line in `php.ini`:
 *
 * opcache.preload=/www/app/vendor/preload.php
 *
 * --- Config ---
 * Generated at: 2019-11-20 15:20:49 UTC
 * Opcache
 *     - Used Memory: 130585 B
 *     - Free Memory: 294896 B
 *     - Wasted Memory: 347764 B
 *     - Cached files: 2675
 *     - Hit rate: 94%
 *     - Misses: 542
 * Preloader config
 *     - Memory limit: 32 MB
 *     - Overwrite: false
 *     - Files excluded: 0
 *     - Files appended: 0
 */

require_once '/www/app/vendor/autoload.php';

$files = [
    '/www/app/ClassFoo.php',
    '/www/app/ClassBar.php',
    '/www/app/ClassBaz.php',
    '/www/app/ClassQuz.php',
    '/www/app/ClassQux.php',
    '/www/app/vendor/author/package/src/Example.php',
    // ...
];

foreach ($files as $file) {
    require_once $file;
}

И всё. Попробуйте сами: darkghosthunter/preloader — Packagist.

Комментарии (2)


  1. TheCluster
    19.12.2019 21:29

    Попробовал preload через пару дней после релиза 7.4 — столько segmentation fault у php-fpm я не видел за все 12 лет работы с php))) Недавно вышел 7.4.1, там все работает более стабильно, но все равно пару раз fpm падал у меня. И проект вроде бы не самый сложный, на symfony 4.x с небольшим кол-во зависимостей, но все равно что-то ломается. Поспешили они с релизом 7.4.


    1. Programmer
      20.12.2019 13:14

      nikic@php.net
      We've found some fundamental design problems in the preloading functionality today…
      The tl;dr is that you need to use opcache_compile_file() based preloading for now. Preloading based on require calls has a whole series of issues that may result in crashes.