Magento 2 Development – Full Page Cache Hole Punching

John Cymerman

One common area of knowledge that can be lacking in Magento 2 developers that have worked heavily on Magento 1 CE — where full page cache is less of a consideration — is preparing custom frontend content for full page cache via hole punching.  In this blog post, I’m going to show you how to properly prepare your blocks for hole punching in Magento 2.x, compare the different methodologies, and provide you with insight as to how and why block caching works the way it does.

Full Page Cache – What’s the Point?

The first thing to understand about full page caching is the problem it’s trying to solve.  Without full page cache, whenever a user visits a page on your Magento website, it’s a very expensive process.  Presumably, we’re using production mode, so all the Interceptor, Factory, and other classes that are generated via dependency injection, plugins, etc. already exist.  However, all the Controller logic, including Models, the Layout, and all the Block classes will be instantiated and loaded.  Depending on the complexity of the controller and view, this could end up in quite a lot of PHP and mySQL processing.  However, what if the content of the page you loaded hasn’t changed since your (or someone else’s) last visit?  We could gain quite a bit of efficiency if we were somehow able to remember what that page looked like the last time you (or someone else) visited that page, and could therefore send back this static copy back to the browser and avoid having to process most of this Controller/Model/Block logic.  This is where full page caching comes in.

What if the page’s contents DO need to change, though?

First, let’s look at the most crude way that exists in Magento 2 for circumventing the full page cache.  If adding a Block to a given Layout should completely disable the full page cache, Magento 2 does provide a way to do that via the layout XML.  However, this will lose all performance benefits gained by partial or complete full page caching, and should only be done if you are sure that every single load of this page should be completely dynamic.  One should be extremely careful about using this method when building extensions that will be used by third parties — you can have many upset customers if their site suddenly becomes a lot slower after installing your module if you’ve used this option lightheartedly.
An example of a built-in page that Magento 2 provides that has caching completely disabled is the Order Success Page.  Here is the contents of Magento_Checkout::view/frontend/layout/checkout_onepage_success.xml

[code language=”xml”]
<?xml version=”1.0″?>
<!– /** * Copyright © 2013-2017 Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ –>
<page xmlns:xsi=”https://www.w3.org/2001/XMLSchema-instance” layout=”1column” xsi:noNamespaceSchemaLocation=”urn:magento:framework:View/Layout/etc/page_configuration.xsd”>
<head>
<title>Success Page</title>
</head>
<body>
<referenceBlock name=”page.main.title”>
<block class=”Magento\Checkout\Block\Onepage\Success” name=”checkout.success.print.button” template=”button.phtml”/>
<action method=”setPageTitle”>
<argument translate=”true” name=”title” xsi:type=”string”>Thank you for your purchase!</argument>
</action>
</referenceBlock>
<referenceContainer name=”content”>
<block class=”Magento\Checkout\Block\Onepage\Success” name=”checkout.success” template=”success.phtml” cacheable=”false”/>
<block class=”Magento\Checkout\Block\Registration” name=”checkout.registration” template=”registration.phtml” cacheable=”false”/>
</referenceContainer>
<container name=”order.success.additional.info” label=”Order Success Additional Info”/>
</body>
</page>
[/code]

The cacheable=”false” attribute that’s been added to the <block /> telsl the full page cache to not cache the results of this layout at all.

All About Identities

In most cases, the content only on some specific part of the page needs to change, while the rest stays the same.  In this case, if we still want to gain the performance benefits of a full page cache, we need some way of informing the full page cache that, on the next page hit, we need to re-generate a specific part of this page.
In order to do this, Magento 2 has a built-in way for Model changes to automatically inform the full page cache that this particular Model instance has been changed (i.e., a database update has occurred on a certain row of a certain table).  It does this via the interface Magento\Framework\DataObject\IdentityInterface.

[code language=”php”]
<?php
/**
* Copyright © 2013-2017 Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
namespace Magento\Framework\DataObject;
/**
* Interface for
* 1. models which require cache refresh when it is created/updated/deleted
* 2. blocks which render this information to front-end
*/
interface IdentityInterface
{
/**
* Return unique ID(s) for each object in system
*
* @return string[]
*/
public function getIdentities();
}
[/code]

The idea here is that we have a subscription-based event service.  First, a Model class is assigned one or more Identities.  These Identities are essentially event handles that refer to this Model instance.  When a database update occurs (by save()ing the Model), these events fire.  The full page cache will hear those events, and invalidate the parts of the full page cache belonging to Blocks that shared an identity with this Model instance.  Then, when the next loading of the affected page(s) occurs, the invalidated Blocks will cause the Controller to execute as normal, and re-render the HTML for the pieces of the page that were invalidated.
Let’s look at a common example from the Magento 2 CE codebase.  If we examine Magento\Catalog\Model\Product, we can see that the getIdentities() function exists.

[code language=”php”]
/**
* Get identities
*
* @return array
*/
public function getIdentities()
{
$identities = [self::CACHE_TAG . ‘_’ . $this->getId()];
if ($this->getIsChangedCategories()) {
foreach ($this->getAffectedCategoryIds() as $categoryId) {
$identities[] = self::CACHE_PRODUCT_CATEGORY_TAG . ‘_’ . $categoryId;
}
}
if (($this->getOrigData(‘status’) != $this->getData(‘status’)) || $this->isStockStatusChanged()) {
foreach ($this->getCategoryIds() as $categoryId) {
$identities[] = self::CACHE_PRODUCT_CATEGORY_TAG . ‘_’ . $categoryId;
}
}
if ($this->_appState->getAreaCode() == \Magento\Framework\App\Area::AREA_FRONTEND) {
$identities[] = self::CACHE_TAG;
}
return array_unique($identities);
}
[/code]

This is one of the more complex examples of a getIdentities() function for a Model, but we can see the general gist by examining this code.

  • First, purge the FPC of all cached content related to this Product.
  • Next, if a Product is being re-categorized when it’s saved, then purge the FPC of all content related to each affected Category.
  • Also, whenever the Product’s Status or Stock Status changes, purge the FPC of all content related to its Categories.

Next, let’s look at Magento\Catalog\Block\Product\ListProduct.

[code language=”php”]
/**
* Return identifiers for produced content
*
* @return array
*/
public function getIdentities()
{
$identities = [];
foreach ($this->_getProductCollection() as $item) {
$identities = array_merge($identities, $item->getIdentities());
}
$category = $this->getLayer()->getCurrentCategory();
if ($category) {
$identities[] = Product::CACHE_PRODUCT_CATEGORY_TAG . ‘_’ . $category->getId();
}
return $identities;
}
[/code]

Put simply, this code is doing the following:

  • First, associate this Block with every Product currently being shown on the page.
  • Also, if we’re on a Category View Page, associate this Block with Products belonging to that Category.

You can see that saving any Product appearing on a particular page of a Category’s Product List will invalidate the cache for that Block, because it will appear in the array of Identities returned by that Block’s getIdentities() function.

Adding Identities to Custom Modules

So, we can see by closely following this code example from Magento’s Catalog module, that all we need to do to make our dynamic pages compatible with the full page cache is by adding our own Identities to Model and Block classes that we create.  For example, if we made a page that displays 5 blog posts at a time, we should make sure that our Post Model returns an Identity that is something like ‘TRELLIS_BLOG_POST_’ . $this->getId().  Then, in our Block that renders the blog posts, we should also have a getIdentities() function that loops through our Post Model Collection and assembles an array of up to 5 entries, where each entry is the Identity of that Post Model.  If built properly, the full page cache should automatically invalidate the correct page(s) of posts, if these posts are updated in the admin.
How easy or difficult do you find it to utilize the full page cache in Magento 2, as compared to Magento 1?  Are you having trouble making it work properly for your module?  Sound off in the comments, I’m interested to hear what you’re working on!
***
If you need help optimizing Magento for site speed or want to implement Full Page Caching please reach out to us at hello@trellis.co and we will be happy to provide assistance. Please comment if you have anything to add regarding this topic!

Leave a Comment

Share this post

Related Posts

See all posts