Блиц, Блиц, скорость без границ!
А я решил максимально, на сколько получится, покрыть тему кэширования в Drupal 8. Всего будет два материала, это первый, следующий я напишу чуть позже, так как он будет куда объемнее, интереснее и там куда гибче функционал. А у меня пока не хватит времени его завершить быстро, и сам пока ковыряю ядро чтобы была цельная картинка. Да и надо помочь портировать парочку модулей на 8-ку.
Сегодня мы поговорим об одном единственном параметре для render array —
знакомьтесь, #lazy_builder
. Что это такое? А это некий аналог Композитного
сайта у Битрикс, только бесплатно (а ведь там стоит убрать логотип 300к руб.),
без смс. Если не отсылаться к битриксу и говорить в целом, это в прямом смысле
как и дословный перевод: ленивый "строитель". А в связке с гибкостью основного
кэша, о котором я расскажу в следующем материале, это просто пушка.
Что это, зачем оно, надо ли мне его использовать?
Вы, наверное, уже видели видео, но я всеравно его приложу, так как это самый
наглядный пример работы #lazy_builder
.
Так что же это такое? Это такой подход рендера страницы в Drupal 8. Итоговая скорость загрузки страницы не меняется. Данный подход не ускоряет загрузку страницы в целом, но он ускоряет первое отображение сайта. Иными словами, используя данный подход на тяжелых элементах, которые требуют очень много времени на свой рендер, мы можем ускорить загрузку сайта "визуально", т.е. первое отображение и рендер будет просто коллосально раньше, вместо белой страницы, даже на очень, очень высоконагруженном сайте, а если обмазаться кэшем и сайт простенький, то всё будет летать как статичный сайт.
Зачем оно мне нужно, ведь скорость не меняется? Как видно из видео выше, это позволяет отдать контент и сайт в целом намного раньше, а тяжелые подгрузить в конце, когда они будут готовы, не блокируя загрузку и рендер основного содержимого страницы. Так как PHP не ассинхронный, все выполняется поэтапно, и если на середине этапа генерации страницы появляется, например, блок, с динамическими данными, или данные которые получаются реалтайм каждый раз откуда-то, возможно даже со стороннего сервера, то пока они не будут получены и обработаны, дальнейшее выполнение скрипта не будет выполнено. И пока не будет готова основная часть содержимого, пользователь будет видеть белую страницу. Так вот эта метка, позволяет указать уязвимым местам, чтобы они не выполнялись в своей последовательности, а были отложены на конец. Когда уже вся страница будет готова и человек уже будет читать материал, смотреть картинки, видосики или товары, фоном догрузятся данные для тяжелых элементов и вставятся на страницу. Но при этом да, общая скорость загрузки страницы не изменится, ведь эти элементы чудом не появятся.
Падажжи! А что на видео написано BigPipe?
Вместе с ядром идет модуль
Internal Dynamic Page Cache и он включен по умолчанию. По сути он
отвечает за весь кэш в Drupal 8, а BigPipe с 8.3 стал стабильным в ядре, и он
дополняет IDPC, тем что позволяет несколько иначе обрабатывать #lazy_builder
,
хотя он может и без него успешно работать, просто с ним намного шустрее и
оптимальнее. Об этом чутка ниже.
Когда мне использовать #lazy_builder
? И нужно ли вообще? И да, и нет. Я
могу вас заверить, он вам пригодится практически никогда. ШТА?! Да-да,
вероятность того, что вы будете применять на практике, то, что будет написано
ниже, уверенно стремится к нулю. Достаточно посмотреть на настройки Internal
Dynamic Page Cache и BigPipe, как вам
станет всё ясно. На уровне ядра и
всех основных компонентов и элементов за вас уже побеспокоились разработчи ядра.
В большинстве остальных случаев ядро автоматически всё сделает за вас средствами
обычного кэширования, которое до безумия гибкое в 8-ке. Единственный случай,
когда вам реально потребуется обратиться к ленивым рендерам, это когда вы будете
писать что-то, что не должно кэшироваться вообще, т.е. что-то прямо
динамическое, что каждому юзеру, каждую загрузку выдает разные данные. Кароче
прямо динамику-динамику. А легкую динамику которая, например генерируется 1 раз
под юзера, например раз в пару минут\секунд, уже решит обычное кеширование. Я
действительно не могу найти ни одного применения ленивому рендеру кроме как
чистая динамика и загрузка данных с третьих сайтов без кэширования, ну или те же
самые формы. Хотя, по всей видимости, все основные формы в ядре уже обернуты
в #lazy_builder
. Но это неточно.
Тогда зачем ты все это пишешь? Это просто интересно и полезно знать что
такое есть, как оно работает, и как им пользоваться, ведь я не сказал что оно
бесполезное, прямо супер-динамический контент это клиент данного подхода. Также,
вы можете что угодно перевести на #lazy_builder
. Как никак, это один из
способов организации "кэширования" и оптимизации сайта. Это очень полезно на
крупных и динамических проектах. И мало ли кто будет читать не из Drupal
разработчиков, посмотрит и скажет: Круто! — и внедрит у себя подобную штуку или
вообще на друпал подсядет.
Плавно перекатываясь к более технической части, давайте расскажу вам как оно
работает. Когда вы используете #lazy_builder
, то вместо содержимого на
страницу добавляется специальный
placeholder, например:
<span data-big-pipe-placeholder-id="callback=Drupal\dummy\DummyLazyRenderer::renderDummyDelay&&token=rRSlx2J7AUdyElSUQS6KfQBAF9SbND8Lh-LRc7vnzsk"></span>
Попутно цепляется необходимый для этого JS.
А в самом коцне загрузки страницы добавляется следующее:
<script type="application/vnd.drupal-ajax" data-big-pipe-event="start"></script>
<script type="application/vnd.drupal-ajax" data-big-pipe-replacement-for-placeholder-with-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args%5B0%5D&token=_HAdUpwWmet0TOTe2PSiJuMntExoshbm1kh2wQzzzAA">
[{"command":"settings","settings":{"ajaxPageState":{"theme":"bartik","libraries":"bartik\/global-styling,big_pipe\/big_pipe,classy\/base,classy\/messages,classy\/node,comment\/drupal.node-new-comments-link,contextual\/drupal.contextual-links,contextual\/drupal.contextual-toolbar,core\/drupal.active-link,core\/html5shiv,core\/normalize,devel\/devel-toolbar,quickedit\/quickedit,shortcut\/drupal.shortcut,system\/base,toolbar\/toolbar,toolbar\/toolbar.escapeAdmin,tour\/tour,user\/drupal.user.icons,views\/views.module"},"pluralDelimiter":"\u0003","user":{"uid":"1","permissionsHash":"a30137c0ac401cc4ad2826f92eaa0e633b4b90f869166a1515aac2bbbc04d421"}},"merge":true},{"command":"add_css","data":"\u003Clink rel=\u0022stylesheet\u0022 href=\u0022\/sites\/default\/files\/css\/css_eWmbbi3frMJPauCYHygIVEjcDmNqivacE1SJjDW017s.css?osmmi9\u0022 media=\u0022all\u0022 \/\u003E\n"},{"command":"insert","method":"replaceWith","selector":"[data-big-pipe-placeholder-id=\u0022callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages\u0026args%5B0%5D\u0026token=_HAdUpwWmet0TOTe2PSiJuMntExoshbm1kh2wQzzzAA\u0022]","data":"\n ","settings":null}]
</script>
<script type="application/vnd.drupal-ajax" data-big-pipe-event="stop"></script>
И после этого отображается страница.
Как вы поняли, он закидывает свои плейсхолдеры и специальный JS файл, тем самым
DOM готов раньше чем содержимое для него. А затем BigPipe в фоне
каждые 50 милисекунд проводит опрос в
поиске вот тех "скриптов", проверяя, появились ли они, и если появились,
выполняет их команды, которые заменяют плейсхолдеры на контент, добавляет
настройки, JS и CSS. Да, он может добавлять попутно и JS и CSS и Drupal
Settings, в общем все что только требуется на странице. И добавит он это только
когда содержимое будет готово, тем самым не блокируя рендер и отображение
остальных элементов, скриптов и стилей на странице. За счет этого и получается
такое визуальное ускорение. Не знаю как для поисковых систем, есть ли для них
разница, но для людей это точно огромная разница, так как грамотно собранный
сайт с использованием BigPipe и #lazy_builder
будет всегда отдавать основное
содержимое практически моментально, и количество юзеров закрывших сайт не
дождавшись загрузки до конца, пойдет на спад.
Отвечаю сразу на два вопроса, которые, возможно назрели у вас, или назрели бы позже:
- Будет ли это работать с отключенным JS у юзера? Да, будет. Просто страница будет грузиться как обычно, с рендером содержимого в своем порядке. Поведение будет ровно такое же, как если бы всего этого не было. Сайт не развалится и контент не пропадет.
- Будет ли это работать с отключенным BigPipe? Да, но медленнее. Там используется какой-то подход Single Flush, он не такой эффективный как BigPipe по заверениям самих разрабов ядра, ну и это очевидно, тогда бы BigPipe не появился в ядре. Да и зачем его отключать? Он есть, ничего не требует, вообще не работает если на странице нет элементов для его работы, так что включил и забыл. Врубаете на каждом сайте и не паритесь, даже если вы не пишите такой код, то он есть в ядре и другие модули это могут спокойно учитывать и ускоряться на автомате.
Теория
А теперь лениво перекатываемся к более технической части ленивых строителей.
Как можно догадаться, #lazy_builder
прямо указывает на то, что указывается для
render array. Учитывая что в Drupal 8 всё так или иначе render array, это вообще
не проблема. Соответствено, это можно подцепить куда угодно.
Важно запомнить то, что #lazy_builder
полностью заменяет render array
который надо загрузить лениво. То есть весь элемент будет состоять только
из #lazy_builder
со специальным форматом, а также может иметь
дружка #create_placeholder
. Всё, больше в render array ничего не должно быть (
хотя парочка исключений допускается), иначе будет ошибка.
Быстро и подробно о двух основных и единственных параметрах, читайте очень внимательно, тут все самое важное:
#lazy_builder
: Это фундамент всего и вся в данном материале, без него ничего не заведется. В него передается массив из двух аргументов.- Первый аргумент: метод который будет обрабатывать логику для рендера.
Туда можо передать как метод по
неймспейсу
Drupal\mymodule\MyRenderer::renderContent
, так и при помощи сервиса:mymodule.my_renderer:renderContent
. Второй подход, при помощи сервиса самый правильный и предпочтительный, постарайтесь не обращаться напрямую по неймспейсу, так как теряются все возможности сервиса, да и писать больше. Данный метод обязан возвращать render array, а вот уже какой, решать вам. Для этого, как правило, создается специальный класс с методами для ленивых рендов в своем модуле. Чтобы он был только с теми зависимостями которые ему нужны. Если вы хотите блок перевести на lazy builder, то создавать метод прямо в плагине и ссылаться на него не прокатит, делайте пустой класс под эти задачи, так как у вас банально не удовлетворятся зависимости для объекта плагина. - Второй аргумент: массив с данными которые вы хотите отдать в метод для будущего рендера. Они могут быть лишь следующих типов: string, int, bool, float, NULL — больше никакие не допускаются, ни массивы, ни объекты, ничего вообще. Если у вас всё сделано верно, это не вызовет никаких сложностей. Если у вас это вызывает проблемы, то скорее всего вы что-то делаете не так или используете не по назначению. Количество данных в этом массиве неограниченно, главное чтобы типы совпали. Если вам не нужны аргументы, просто передавайте пустой массив, этот параметр обязателен. Важно: данные аргументы видны в плейсхолдерах, ни в коем случае туда не передавайте важную информацию.
- Первый аргумент: метод который будет обрабатывать логику для рендера.
Туда можо передать как метод по
неймспейсу
#create_placeholder
: Когда вы объявляете свой#lazy_builder
, по сути, вы можете опускать данный параметр. Он принимает лишьTRUE
иFALSE
, по умолчанию там FALSE. Он делает то что и написано, создает placeholder как я показывал выше, который нужен для работы BigPipe и загрузки туда контента. Вы скажите мне: "Что за чушь? Оно же всегда нужно! Для этого я это и использую". Да, вы можете указатьTRUE
и данный элемент всегда будет рендериться лениво, но это не всегда требуется. Ядро автоматически умеет ставить это значение вTRUE
, когда обнаруживает что кэша у элемента нет. Если по дереву render array в#cache
где-то указаноmax-age=0
(т.е. кэш отключен у элемента) или же присутствуют контексты очень плохо кэшируемые, напримерuser
илиsession
. Об этих вещах в следующем материале. Просто знайте и запомните. Вы можете объявлять свои элементы и писать код полностью из#lazy_builder
, и если у него отключат кэш, он автоматически заработает через BigPipe, а если кэш останется включенным, то и рендериться будет как обычно. Т.е. данный параметр позволяет принудить Drupal при любых раскладах отдавать данное содержимое через BigPipe. Конечно, не теряйте здравого смысла, и все писать через ленивый рендер не стоит, только те части, которые могут оказаться без кэша (в определенный момент, например есть соответствующие настройки или контексты).
Сложна! Нет! Да, поначалу может показаться сложно и неясно, на деле это очень и очень просто, это очень простой и элегантный способ оптимизировать тяжелые части сайта. И дальше вы в этом убедитесь!
Примеры объявляения #lazy_builder
Объявить его можно несколькоими способами, каждый что-то может предложить своё. Где-то удобство и простота, где-то гибкость и доп. возможности.
/**
* Пример ленивца, который будет всегда рендериться лениво. Он возвет метод
* lazyRenderMe1() у класса LazyBuilder, и не передаст туда ничего.
* Не совсем предпочтительнный пример, старайтесь избегать вызовов по
* неймспейсу.
*/
$content['example_1'] = [
'#create_placeholder' => TRUE,
'#lazy_builder' => [
'Drupal\mymodule\LazyBuilder::lazyRenderMe1', [],
],
];
/**
* Пример правильного и маленького ленивца. У данного вида вызов
* производится при помощи сервиса, он передает два аргумент типа string.
* Рендерится такой элемент будет через BigPipe только в тех случаях, когда
* ему станет лениво, или где-то по цепочке в render array будет отключено
* кэширование. Во всех остальных случаях поведение будет стандартным.
*/
$content['example_2'] = [
'#lazy_builder' => [
'mymodule.lazy_builder:lazyRenderMe1', ['arg1', 'arg2'],
],
];
/**
* Данный ленивец это дитё первого и второго. Так примеры выше
* интерпритирует Drupal, и уже затем отправляет на рендер. Указанные
* варианты выше, это краткие записи текущего.
*/
$content['example_3'] = [
'#markup' => '',
'#attached' => [
'placeholders' => [
'' => [
'#lazy_builder' => [
'mymodule.lazy_builder:lazyRenderMe1', ['arg1', 'arg2'],
],
],
],
],
];
/**
* Данный вид — прокаченный ленивец. Их, как вы могли догадаться,
* может быть целое семейство, и вы можете намазать "медом" для их
* превлечения на свои места в ленивом темпе.
*
* @normal Текст "Этого ленивца зовут ..." пользователи увидят сразу, а вот
* %slothname% будет заменен на placeholder, а в дальнейшем на результат
* содержимого, вы можете делать сколько угодно таких подставлений сохраняя
* структуру.
*/
$content['example_4'] = [
'#markup' => 'Этого ленивца зовут %slothname%, друзья обращаются к нему: %slothnickname%',
'#attached' => [
'placeholders' => [
'%slothname%' => [
'#lazy_builder' => [
'mymodule.lazy_builder:slothName', ['Flash'],
],
],
'%slothnickname%' => [
'#lazy_builder' => [
'mymodule.lazy_builder:slothNickname', ['Flash Flash Hundred Yard Dash!'],
],
],
],
],
];
/**
* Ну и самый обвешанный пример, хотя никто не мешает подключать библиотеки и
* настройки прямо в рендере.
*/
$content['example_5'] = [
'#markup' => '',
'#attached' => [
'drupalSettings' => [
'foo' => 'bar',
],
'library' => [
'mymodule/my-awesome-js',
'mymodule/my-awesome-css',
],
'placeholders' => [
'' => [
'#lazy_builder' => [
'mymodule.lazy_builder:lazyRenderMe1', ['arg1'],
],
],
],
],
];
Интересный факт о ленивцах — они не толстеют! В отличии от моих гайдов, где о простом так сложно.
Как вы уже поняли, #lazy_builder
может дружить только с #markup
в
определенных случаях, все остальные рендер элементы рядом будут выдавать ошибки.
Самый ходовой — второй.
Как по мне, всё что только можно уже разжевано, остальное за вас уже сделать BigPipe и ядро.
Пример
Далее по коду подразумевается что модуль имеет название dummy.
Пример будет всего один, так как все варианты разжеваны выше и в каких-то конкретных примерах не нуждаются.
Зато мы в этом премерье обьявим вообще все, и страницу, и элемент который будет
генерироваться в #lazy_builder
и сам лейзи билдер, кароче от и до на одном
примере.
Немного сделаю пример заведомо длиннее на один шаг, чтобы он был ближе к реальности. Для этого мы объявим свой theme hook который будет выводить заголовки статей списком.
Допустим мы хотим выводить от 0 до 3000 материалов в блоке, а точнее, их
заголовков. При каждой загрузке страницы этот блок должен показывать их в
случайном порядке, соответственно кэшировать ни в коем случае нельзя. Но
загрузка материалов уже достаточно прожорливая операция, которая может заметно
притормозить рендер сайта, а темболее нельзя кэшировать из-за случайного порядка
каждый раз. Для этого мы и перенесем вывод данных материалов на #lazy_builder
.
Первым делом мы обьявим hook_theme() и препроцесс для него. В нем какраз и будут
грузиться материалы типа article
и перемешиваться при каждом новом вызове.
Также создадим шаблон для вывода.
<?php
/**
* @file
* Main file for custom hooks and functions.
*/
/**
* Implements hook_theme().
*/
function dummy_theme($existing, $type, $theme, $path) {
return [
'dummy_node_list' => [
'variables' => [
'limit' => 10,
],
],
];
}
/**
* Implements hook_preprocess_HOOK().
*/
function template_preprocess_dummy_node_list(&$variables) {
$variables['nodes'] = NULL;
$nids = \Drupal::entityQuery('node')
->condition('type', 'article')
->range(0, $variables['limit'])
->execute();
$nodes = \Drupal\node\Entity\Node::loadMultiple($nids);
foreach ($nodes as $node) {
$variables['nodes'][] = [
'label' => $node->title->value,
];
}
// Random order.
shuffle($variables['nodes']);
}
<ul>
{% for node in nodes %}
<li>{{ node.label }}</li>
{% endfor %}
</ul>
Мы уже знаем что вызов данного theme hook является уязвимым местом в производительности, мы хотим вынести его в ленивый рендер. Как я писал выше, объект, отвечающий за рендер в lazy builder лучше выносить в отдельный объект, и объявлять как сервис.
Обычные объекты, для собственного использования, не являющиеся частью какого-то API можно просто хранить в папке src. Создаем свой объект с методом, который будет рендерить наш theme hook.
<?php
namespace Drupal\dummy;
/**
* {@inheritdoc}
*/
class LazyRenderer {
/**
* Renderer for dummy_node_list theme hook.
*/
public function renderNodeList($max_nodes = 10) {
$build = [
'#theme' => 'dummy_node_list',
'#limit' => $max_nodes,
];
return $build;
}
}
Данный метод возвращает обычный render array, как если бы мы вызывали на рендер прямо в нужном месте.
Теперь его объявим как сервис.
services:
dummy.lazy_renderer:
class: Drupal\dummy\LazyRenderer
Ну и всё, теперь нам нужно вызвать данный метод в #lazy_builder
где нам нужно
и всё готово!
Первым делом давайте вызов сделаем в блоке. Блок назовем LazyBlock:
<?php
namespace Drupal\dummy\Plugin\Block;
use Drupal\Core\Block\BlockBase;
/**
* @Block(
* id = "dummy_lazy_block",
* admin_label = @Translation("Lazy block"),
* )
*/
class LazyBlock extends BlockBase {
/**
* {@inheritdoc}
*/
public function build() {
$block['content'] = [
'#create_placeholder' => TRUE,
'#lazy_builder' => [
'dummy.lazy_renderer:renderNodeList', [1000],
],
];
return $block;
}
}
В данном блоке мы будем выводить максимум 1000 заголовоков нод, ну что бы залагало, а то все будет очень быстро даже на скорости 50кб\сек.
И заодно объявим страницу где также будет выводиться содержимое при помощи lazy builder, чтобы показать то, что не важно где его использовать, он работает абсолютно везде.
Создаем контроллер страницы:
<?php
namespace Drupal\dummy\Controller;
use Drupal\Core\Controller\ControllerBase;
/**
* {@inheritdoc}
*/
class LazyPage extends ControllerBase {
/**
* {@inheritdoc}
*/
public function build() {
$build = [];
$build['#title'] = 'Lazy builder test';
$build['content'] = [
'#create_placeholder' => TRUE,
'#lazy_builder' => [
'dummy.lazy_renderer:renderNodeList', [3000],
],
];
return $build;
}
}
Ну добавляем маршрут до нашей странички.
dummy.lazy_page:
path: '/lazy-page'
defaults:
_controller: '\Drupal\dummy\Controller\LazyPage::build'
requirements:
_permission: 'access content'
Вот собственно и всё! Можно включать модуль и смотреть на работу. И не забудьте добавить блок на страницы, чтобы было вдвойне тяжелее.
Моя криворукая демонстрация с данным примером.
Он работает при любом варианте кэширования. Хоть включен, хоть отключен. Он просто заменяет содержимое на плейсхолдер а плесхолдер заменяется на содержимое в конце загрузки страницы и исполнения всех операций php.
Сегодня боролся, и не совсем так. Он работает если есть активная сессия. Если сессии нет, а у анонимов, если ничего специально в сессию не записано, её и не будет, то отдаваться будет кешированная страница без плейсхолдера, как у автора комментария.
А кэш теги и\или max-age какие анонимам на рендер уходят? Не видать в коде у него каких-то специфичных условий для сессий.
max-age = 0
Ограничение по сессии в big-pipe прописаны. // BigPipe is only used when there is an actual session, so only add the no-JS // detection when there actually is a session.
Исходная у меня такая: страница на параграфах, у одного из параграфов в поле референс на программный блок. Пробовал блок цеплять и через twig_tweak. На выходе одно и тоже, если нет активной сессии то полностью закешированная страница, вообще без плейсхолдеров, независимо от того включен или выключен big pipe. Если есть активная сессия, то big pipe отрабатывает на ура и блок каждый раз рандомится.
Судя по тому что есть Sessionless BigPipe вы правы. И судя по нему же, это недоработка Symfony (от части, так как не поддерживает HTTP trailers, которые могли бы решить проблему), будем надеется что исправится со временем. На досуге обновлю информацию в статье, спасибо!
Тут очень интересное обсуждение этого поведения. Собственно Sessionless BigPipe можно считать официальным модулем для решения этой задачи. Я так понял, она считается крайне редкой и добавление в ядро не планируется так как требует доп. зависимость и настройку, которая не нужна подавляющему большинству тех кто будет использовать модуль.