Building really fast websites

with Drupal 8

wimleers.com
@wimleers
Senior Software Engineer
Office of the CTO, Acquia

This talk is about

  1. What Drupal 8 does better
  2. Render caching
  3. Client-side caching
  4. Pluggable CSS/JS optimization
  5. … what still needs to happen :)

Load JavaScript on ALL the pages!

Not anymore :)

Fixed in Drupal 8

  • Declare all dependencies (#1737148)
  • No more drupal_add_*(), only #attached (#1839338)

Unfixable in Drupal 7

  • @attiks is bravely working on a work-around in #1279226
  • Only fixable for individual sites

1: Dependencies/closure

tableselect.js


(function ($, Drupal) {

"use strict";

Drupal.behaviors.tableSelect = {
  attach: function (context, settings) {
    $(context).find('th.select-all').closest('table') …
  }
};

…

})(jQuery, Drupal);
						
  • jQuery is used
  • Drupal is used

⟹ put them in the closure

2: declare library


  $libraries['drupal.tableselect'] = array(
    'title' => 'Tableselect',
    …
    'js' => array(
      'core/misc/tableselect.js' => array(),
    ),
    'dependencies' => array(
      array('system', 'jquery'),
      array('system', 'drupal'),
    ),
  );
						

jQuery and Drupal in the closure ⟹ dependencies

3: abandon drupal_add_(css|js)()


       // Add a "Select all" checkbox.
-      drupal_add_js('core/misc/tableselect.js');
+      drupal_add_library('system', 'drupal.tableselect');
						

4: abandon drupal_add_*()

Always use #attached.


       // Add a "Select all" checkbox.
-      drupal_add_library('system', 'drupal.tableselect');
+      $element['#attached']['library'][] = array('system', 'drupal.tableselect');
						

drupal_add_*() uses global state ⟹ breaks caching

Conclusion (asset loading)

Load assets by #attaching libraries
  • Better DX
  • Cacheable render arrays
  • Only required assets will be loaded
  • Dynamically loaded content has its assets

Render cache ALL the entities!

Almost!

But before we got there…

… we had to fix a thing or two:

  1. [#636454] Cache tag support
  2. [#914382] Contextual links incompatible with render cache
  3. [#1991684] comment's "new" indicator, "x new comments" links incompatible with render cache

… but still not yet there!

“There are only two hard problems in Computer Science: cache invalidation and naming things.”
  • Caching is easy
  • Cache invalidation is hard
  • Poor cache invalidation ⟹ poor performance

p15n

personalization

1. Cache invalidation is hard

Screenshot of page caching being enabled in Drupal 7.

… clears the entire page cache on node save!

Cache clearing in Drupal 7

  1. Clear a specific cache entry:
    cache_clear_all('foo:content:id', $bin);
  2. Clear a coarse set of cache entries:
    cache_clear_all('foo:content:', $bin, TRUE);
  3. Clear all entries:
    cache_clear_all('*', $bin, TRUE);

“How to clear all entries containing node 42?”

Impossible!

Cache tags in Drupal 8

cache($bin)->set($cid, $value, CACHE_PERMANENT, $tags);
  • Something in page cache:
    $tags = array('content' => TRUE);
  • HTML containing title of node 42:
    $tags = array(
      'node' => array(42)
    );
  • Node with two Taxonomy terms:
    $tags = array(
      'node' => array(42),
      'user' => array(314),
      'taxonomy_term' => array(1337, 9001),
    );

Using cache tags in render arrays

$element['#cache'] = array(
  'keys' => array('entity_view', $entity_type, $entity_id, $view_mode),
  'granularity' => DRUPAL_CACHE_PER_ROLE,
  'tags' => array(
    $entity_type . '_view' => TRUE,
    $entity_type => array($entity_id),
  ),
);

+

/**
 * Collects cache tags for an element and its children into 1 array.
 *
 * […] This allows items to be invalidated based on all tags attached
 * to the content they're constituted from.
 */
function drupal_render_collect_cache_tags($element) { … }

Clear caches:

  • deleteTags():    prevents stale content
  • invalidateTags():    allows stale content

2. Caching is easy

Contextual links in Drupal 7

Screenshot of contextual links in Drupal 7.

Contextual links: HTML in Drupal 7



						
  • Embedded in node's HTML
  • Adapt to user's permissions + page

⟹ breaks render cache

Contextual links: HTML in Drupal 8


or


  • Embedded in node's HTML
  • Invisible placeholder!

⟹ compatible with render cache

Contextual links: logic in Drupal 8

Contextual IDs in HTML


+

contextual.js
(If Use contextual links permission.)

+

POST contextual IDs to contextual/render

=

Same HTML for all users!
(P15n applied later)

"new" comment marker in Drupal 8

-  {% if new %}
-    {{ new }}
-  {% endif %}
+  
/**
 * Implements hook_node_view_alter().
 */
function comment_node_view_alter(&$build, EntityInterface $node, EntityDisplay $display) {
  if (module_exists('history')) {
    $build['#attributes']['data-history-node-id'] = $node->id();
  }
}

  • Embedded in node's HTML
  • Invisible placeholder!

⟹ compatible with render cache

"new" comment markers: logic in Drupal 8

+

comment-new-indicator.js
(If authenticated)

+

POST node IDs to history/get_node_read_timestamps

=

Same HTML for all users!
(P15n applied later)

Tracker "new" & "updated" markers, "X new comments" links

Similar.

  • Drupal 7: user-specific data in HTML
  • Drupal 8:
    1. data- attributes with "universal truths"
      (perhaps on invisible placeholders)
    2. JavaScript
      (if permission)
    3. cache user-specific data on client-side
      (if necessary: talk to server)

Pioneered by in-place editing, now applied in many places.

Conclusion (render caching)

Use cache tags precisely (cache invalidation)
  • Tag render arrays
  • Delete tags: prevent stale content
  • Invalidate tags: allow stale content
Personalize via JS, cache client-side (cache filling)
  • Cacheable render arrays
  • Universal truths: data- attributes
  • Personalization: JS + localStorage

Didn't you make Drupal slower?

Separate requests for:

  • Toolbar
  • Fixed render cache
    • "new" markers
    • contextual links
  • In-place editing

1 → 5 reqs/page load!?

Goals

  1. More scalable
  2. Better perceived performance

By

  1. Serving HTML faster
  2. Personalizing via JS
  3. Caching client-side

Then

  1. Always faster Time to Glass

    p15n: server ⟶ client

  2. P15n (on client):
slowercold cache5 reqs
fasterwarm cache1 req

So

“HTML skeleton + JS p15n” is faster

Warm client-side cache

&

More p15n slower!

The question:

How to keep the cache warm?
“There are only two hard problems in Computer Science: cache invalidation and naming things.”
  • Caching is easy
  • Cache invalidation is hard
  • Poor cache invalidation ⟹ poor performance

Example: Toolbar

  • Before: 1 HTTP req/page
  • After: 1 setting/page

[#1927174]

Example: Toolbar — hash

In hook_page_build():

$element['#attached']['js'][] = array(
  'type' => 'setting',
  'data' => array('toolbar' => array(
    'subtreesHash' => _toolbar_get_subtrees_hash(),
  )),
);

Hash depends on:

  • a role's permissions
  • a user's roles
  • a menu link i/t "admin" menu
  • a module (un)installed

Example: Toolbar — JS

var LS = localStorage;
function load() {
  var subtreesHash = drupalSettings.toolbar.subtreesHash;
  var cachedSubtreesHash = LS.getItem('Drupal.toolbar.subtreesHash');

  // Identical hashes?
  if (subtreesHash === cachedSubtreesHash) {
    var subtrees = JSON.parse(LS.getItem('Drupal.toolbar.subtrees'));
    Drupal.toolbar.setSubtrees.resolve(subtrees);
  }
  else {
    LS.removeItem('Drupal.toolbar.subtrees');
    $.ajax(Drupal.url('toolbar/subtrees/' + subtreesHash));

    // Remember the new hash.
    LS.setItem('Drupal.toolbar.subtreesHash', subtreesHash);
  }
}
function save() {
  LS.setItem('Drupal.toolbar.subtrees', JSON.stringify(subtrees));
}

Example: "new" comment markers

  • "Last read" timestamps in localStorage
  • Keyed by user ID
  • Does not ever change!
cannot be unseen meme photo

No hash necessary!

Ideally…

… we would have many hashes

(~ client-side cache tags)


Today

drupalSettings.user.uid

(Used by "new" marker.)


Probably

drupalSettings.user.permissionsHash

(Used by contextual links, in-place editing.)


Hopefully more!

Conclusion (client-side caching)

Cache slowly changing data
  • P15n
  • Expensive to:
    • send (bytes)
    • calculate (server CPU)
Keep the cache warm to keep it fast!
  • “minimize # of requests”
  • Heuristics
  • Hashes = cache tags (drupalSettings)

CSS/JS aggregation in Drupal 7

“API”:

  • #aggregate_callback
  • #group_callback

Override the whole, or nothing … or hacks

Incompatibility hell: Omega theme, CDN module …

CSS/JS aggregation in Drupal 8

API:

  • More explicit
  • More building blocks

Identical logic, but restructured

(See d.o/node/2034675)

  1. AssetCollectionOptimizerInterface
    1. AssetCollectionGrouperInterface
    2. AssetOptimizerInterface
    3. AssetDumperInterface
  2. AssetCollectionRendererInterface

Asset services

Override at will!

asset.css.collection_renderer:
  class: Drupal\Core\Asset\CssCollectionRenderer
asset.css.collection_optimizer:
  class: Drupal\Core\Asset\CssCollectionOptimizer
  arguments: [ '@asset.css.collection_grouper', '@asset.css.optimizer', '@asset.css.dumper', '@state' ]
asset.css.optimizer:
  class: Drupal\Core\Asset\CssOptimizer
asset.css.collection_grouper:
  class: Drupal\Core\Asset\CssCollectionGrouper
asset.css.dumper:
  class: Drupal\Core\Asset\AssetDumper
asset.js.collection_renderer:
  …
asset.js.collection_optimizer:
  …
asset.js.optimizer:
  …
asset.js.collection_grouper:
  …
asset.js.dumper:
  …

Asset services: possibilities

  1. Asset dumper: external server (CDN, S3 …)
  2. JS optimizer: UglifyJS
  3. JS collection renderer: LabJS script loader
  4. Groupers: 3rd party data mining ⇒ globally optimal groups
  5. Collection optimizer:
    • new aggregate ⟺ changed mtime
    • use custom architecture entirely!

Conclusion (asset handling)

Override individual asset services
  • Improve individual services (use existing architecture)
  • Or apply your own architecture!

What still needs to happen

We need your help!

[#1605290]Entity render caching (with cache tag support)
[#1712456]Views + cache tags
[#678292]Enable CSS & JS aggregation by default
[#2005644]Permissions hash: remove 2 HTTP reqs/page!

… anything tagged D8 cacheability!

Hope you liked it.

Questions?

wimleers.com/talk/really-fast-drupal-8