$30 off During Our Annual Pro Sale. View Details »

Developing cacheable PHP applications - PHP Barcelona 2019

Thijs Feryn
November 12, 2019

Developing cacheable PHP applications - PHP Barcelona 2019

See https://feryn.eu/speaking/developing-cacheable-php-applications/ for more information about this presentation.

Thijs Feryn

November 12, 2019
Tweet

More Decks by Thijs Feryn

Other Decks in Technology

Transcript

  1. DEVELOPING CACHEABLE
    PHP APPLICATIONS
    Thijs Feryn

    View Slide

  2. Slow websites
    SUCK

    View Slide

  3. WEB PERFORMANCE IS AN
    ESSENTIAL PART OF THE
    USER EXPERIENCE

    View Slide

  4. SLOW ~ DOWN

    View Slide

  5. View Slide

  6. View Slide

  7. View Slide

  8. THROWING
    SERVERS
    AT THE PROBLEM

    View Slide

  9. MO' MONEY
    MO' SERVERS
    MO' PROBLEMS

    View Slide

  10. IDENTIFY SLOWEST PARTS

    View Slide

  11. OPTIMIZE

    View Slide

  12. AFTER A WHILE YOU HIT THE LIMITS

    View Slide

  13. CACHE

    View Slide

  14. HI, I'M THIJS

    View Slide

  15. I'M AN
    EVANGELIST
    AT

    View Slide

  16. View Slide

  17. View Slide

  18. 4,800,000 WEBSITES
    19% OF THE TOP 10K WEBSITES

    View Slide

  19. View Slide

  20. I'M @THIJSFERYN

    View Slide

  21. View Slide

  22. USER SERVER

    View Slide

  23. REVERSE CACHING PROXY

    View Slide

  24. USER PROXY SERVER

    View Slide

  25. View Slide

  26. View Slide

  27. HTTP CACHING MECHANISMS
    Expires: Sat, 23 July 2019 00:30:00 GMT
    Cache-control: public, max-age=3600,
    s-maxage=86400
    Cache-control: private, no-cache, no-store

    View Slide

  28. IN AN IDEAL WORLD

    View Slide

  29. ✓STATELESS
    ✓WELL-DEFINED TTL
    ✓CACHE / NO-CACHE PER RESOURCE
    ✓CACHE VARIATIONS
    ✓CONDITIONAL REQUESTS
    ✓PLACEHOLDERS FOR NON-CACHEABLE CONTENT
    ✓EDGE-SIDE LOGIC FOR PERSONALIZED CACHING
    IN AN IDEAL WORLD

    View Slide

  30. REALITY
    SUCKS

    View Slide

  31. View Slide

  32. FOR YOUR EYES ONLY
    NOT CACHED

    View Slide

  33. TIME TO LIVE

    View Slide

  34. CACHING PURGING LOGIC

    View Slide

  35. CACHE VARIATIONS

    View Slide

  36. LEGACY

    View Slide

  37. WHAT IF WE COULD
    DESIGN OUR
    SOFTWARE WITH
    HTTP CACHING IN
    MIND?

    View Slide

  38. CACHE-CONTROL

    View Slide

  39. Cache-Control: public, s-maxage=500

    View Slide

  40. namespace App\Controller;
    use Symfony\Component\HttpFoundation\Request;
    use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
    use Symfony\Bundle\FrameworkBundle\Controller\Controller;
    class DefaultController extends Controller
    {
    /**
    * @Route("/", name="home")
    */
    public function index()
    {
    return $this
    ->render('index.twig')
    ->setSharedMaxAge(500)
    ->setPublic();
    }
    }

    View Slide

  41. Cache-Control: private, no-store

    View Slide

  42. /**
    * @Route("/private", name="private")
    */
    public function private()
    {
    $response = $this
    ->render('private.twig')
    ->setPrivate();
    $response->headers->addCacheControlDirective('no-store');
    return $response;
    }

    View Slide

  43. CONDITIONAL
    REQUESTS

    View Slide

  44. ONLY FETCH PAYLOAD THAT HAS CHANGED

    View Slide

  45. HTTP/1.1 200 OK

    View Slide

  46. OTHERWISE:
    HTTP/1.1 304 NOT MODIFIED

    View Slide

  47. CONDITIONAL REQUESTS
    HTTP/1.1 200 OK
    Host: localhost
    Etag: 7c9d70604c6061da9bb9377d3f00eb27
    Content-type: text/html; charset=UTF-8
    Hello world output
    GET / HTTP/1.1
    Host: localhost

    View Slide

  48. CONDITIONAL REQUESTS
    HTTP/1.1 304 Not Modified
    Host: localhost
    Etag: 7c9d70604c6061da9bb9377d3f00eb27
    GET / HTTP/1.1
    Host: localhost
    If-None-Match: 7c9d70604c6061da9bb9377d3f00eb27

    View Slide

  49. CONDITIONAL REQUESTS
    HTTP/1.1 200 OK
    Host: localhost
    Last-Modified: Fri, 22 Jul 2016 10:11:16 GMT
    Content-type: text/html; charset=UTF-8
    Hello world output
    GET / HTTP/1.1
    Host: localhost

    View Slide

  50. CONDITIONAL REQUESTS
    HTTP/1.1 304 Not Modified
    Host: localhost
    Last-Modified: Fri, 22 Jul 2016 10:11:16 GMT
    GET / HTTP/1.1
    Host: localhost
    If-Last-Modified: Fri, 22 Jul 2016 10:11:16 GMT

    View Slide

  51. VARNISH SUPPORTS
    CONDITIONAL REQUESTS
    FOR CLIENTS & BACKENDS

    View Slide

  52. AND WILL SERVE STALE DATA
    WHILE IT ASYNCHRONOUSLY
    REVALIDATES CONTENT

    View Slide

  53. Cache-Control: public, max-age=100,
    s-maxage=500, stale-while-revalidate=20

    View Slide

  54. QUICKLY

    View Slide

  55. EARLY

    View Slide

  56. Store &
    retrieve
    Etag

    View Slide

  57. namespace App\EventListener;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpKernel\Event\GetResponseEvent;
    use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
    use SymfonyBundles\RedisBundle\Redis\Client as RedisClient;
    class ConditionalRequestListener
    {
    protected $redis;
    public function __construct(RedisClient $redis)
    {
    $this->redis = $redis;
    }
    protected function isModified(Request $request, $etag)
    {
    if ($etags = $request->getETags()) {
    return in_array($etag, $etags) || in_array('*', $etags);
    }
    return true;
    }
    ...
    src/EventListener/ConditionalRequestListener.php

    View Slide

  58. {
    $this->redis = $redis;
    }
    protected function isModified(Request $request, $etag)
    {
    if ($etags = $request->getETags()) {
    return in_array($etag, $etags) || in_array('*', $etags);
    }
    return true;
    }
    public function onKernelRequest(GetResponseEvent $event)
    {
    $request = $event->getRequest();
    $etag = $this->redis->get('etag:'.md5($request->getUri()));
    if(!$this->isModified($request,$etag)) {
    $event->setResponse(Response::create('Not Modified',Response::HTTP_NOT_MODIFIED));
    }
    }
    public function onKernelResponse(FilterResponseEvent $event)
    {
    $response = $event->getResponse();
    $request = $event->getRequest();
    $etag = md5($response->getContent());
    $response->setEtag($etag);
    if($this->isModified($request,$etag)) {
    $this->redis->set('etag:'.md5($request->getUri()),$etag);
    }
    }
    }
    src/EventListener/ConditionalRequestListener.php

    View Slide

  59. CONTENT COMPOSITION
    & PLACEHOLDERS

    View Slide

  60. View Slide

  61. NO CACHE

    View Slide

  62. PLACEHOLDERS

    View Slide

  63. SEPARATE
    HTTP REQUEST

    View Slide

  64. AJAX

    View Slide

  65. ✓CLIENT-SIDE
    ✓COMMON KNOWLEDGE
    ✓PARALLEL PROCESSING
    ✓GRACEFUL
    DEGRADATION
    -PROCESSED BY THE
    BROWSER
    -EXTRA ROUNDTRIPS
    -SOMEWHAT SLOWER
    AJAX

    View Slide

  66. EDGE-SIDE INCLUDES ESI

    View Slide


  67. View Slide

  68. ESI
    ✓ PLACEHOLDER
    ✓ PARSED BY VARNISH
    ✓ OUTPUT IS A COMPOSITION OF BLOCKS
    ✓ STATE PER BLOCK
    ✓ TTL PER BLOCK

    View Slide

  69. VARNISH Surrogate-Capability: key="ESI/1.0"
    Surrogate-Control: content="ESI/1.0"

    BACKEND
    Parse ESI placeholders
    VARNISH

    View Slide

  70. WRITE SOME VCL CODE
    FOR AUTOMATIC ESI DETECTION

    View Slide

  71. sub vcl_recv {
    set req.http.Surrogate-Capability = "key=ESI/1.0";
    }
    sub vcl_backend_response {
    if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
    unset beresp.http.Surrogate-Control;
    set beresp.do_esi = true;
    }
    }

    View Slide

  72. EDGE SIDE INCLUDES
    ✓ SERVER-SIDE
    ✓ STANDARDIZED
    ✓ PROCESSED ON THE
    “EDGE”, NOT IN THE
    BROWSER
    ✓ GENERALLY FASTER
    - SEQUENTIAL
    (ONLY PARALLEL IN ENTERPRISE VERSION)
    - ONE FAILS, ALL FAIL
    - LIMITED IMPLEMENTATION
    IN VARNISH

    View Slide

  73. ESI VS AJAX

    View Slide

  74. COMPOSITION AT THE VIEW LAYER

    View Slide

  75. /**
    * @Route("/", name="home")
    */
    public function index()
    {
    return $this
    ->render('index.twig')
    ->setPublic()
    ->setSharedMaxAge(500);
    }
    /**
    * @Route("/header", name="header")
    */
    public function header()
    {
    $response = $this
    ->render('header.twig')
    ->setPrivate();
    $response->headers->addCacheControlDirective('no-store');
    return $response;
    }
    /**
    * @Route("/footer", name="footer")
    */
    public function footer()
    {
    $response = $this->render('footer.twig');
    $response
    ->setSharedMaxAge(500)
    ->setPublic();
    return $response;
    }
    /**
    * @Route("/nav", name="nav")
    */
    public function nav()
    {
    $response = $this->render('nav.twig');
    $response
    ->setVary('X-Login',false)
    ->setSharedMaxAge(500)
    ->setPublic();
    return $response;
    }
    Controller
    action per
    fragment

    View Slide


  76. {{ include('header.twig') }}


    {{ include('nav.twig') }}


    {% block content %}{% endblock %}


    {{ include('footer.twig') }}


    {{ render_esi(url('header')) }}


    {{ render_esi(url('nav')) }}


    {% block content %}{% endblock %}


    {{ render_esi(url('footer')) }}

    View Slide









  77. An example page Rendered at 2017-05-17 16:57:14

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris consequat orci eget libero
    sollicitudin,…




    View Slide

  78. CACHE VARIATIONS

    View Slide

  79. HOW DO YOU IDENTIFY AN
    OBJECT IN CACHE?

    View Slide

  80. THE URL IDENTIFIES
    THE OBJECT IN CACHE

    View Slide

  81. WHAT IF THE CONTENT
    OF A URL VARIES
    BASED ON THE VALUE
    OF A REQUEST
    HEADER?

    View Slide

  82. CACHE VARIATIONS
    HTTP/1.1 200 OK
    Host: localhost
    Content-Language: en
    Content-type: text/html; charset=UTF-8
    Hello world output
    GET / HTTP/1.1
    Host: localhost
    Accept-Language: en, nl, de

    View Slide

  83. Vary: Accept-Language
    Request
    header
    value
    Response
    header

    View Slide

  84. WHAT IF YOU CAN'T
    LEVERAGE HTTP?

    View Slide

  85. WRITE VCL CODE

    View Slide

  86. sub vcl_recv {
    if (req.url ~ "^/status\.php$" ||
    req.url ~ "^/update\.php$" ||
    req.url ~ "^/admin$" ||
    req.url ~ "^/admin/.*$" ||
    req.url ~ "^/flag/.*$" ||
    req.url ~ "^.*/ajax/.*$" ||
    req.url ~ "^.*/ahah/.*$") {
    return (pass);
    }
    }
    URL
    BLACKLIST
    EXAMPLE

    View Slide

  87. sub vcl_recv {
    if (req.http.Cookie) {
    set req.http.Cookie = ";" + req.http.Cookie;
    set req.http.Cookie = regsuball(req.http.Cookie, "; +", ";");
    set req.http.Cookie = regsuball(req.http.Cookie,
    ";(SESS[a-z0-9]+|SSESS[a-z0-9]+|NO_CACHE)=", "; \1=");
    set req.http.Cookie = regsuball(req.http.Cookie, ";[^ ][^;]*", "");
    set req.http.Cookie = regsuball(req.http.Cookie, "^[; ]+|[; ]+$", "");
    if (req.http.Cookie == "") {
    unset req.http.Cookie;
    }
    else {
    return (pass);
    }
    }
    }
    ONLY
    KEEP CERTAIN
    COOKIES

    View Slide

  88. NON-CACHEABLE ROUTES WILL STILL BE A
    TARGET FOR LOAD/LATENCY

    View Slide

  89. CACHING PERSONALIZED DATA

    View Slide

  90. DECISION MAKING AT THE EDGE

    View Slide

  91. SYNTHETIC HTTP
    HTTP OUTPUT THAT
    DIDN'T ORIGINATE FROM
    THE BACKEND SERVER

    View Slide

  92. {USERNAME: "THIJS"}
    GENERATED IN VCL
    BASED ON REDIS DATA

    View Slide

  93. vcl 4.1;
    import cookieplus;
    import redis;
    import synthbackend;
    sub vcl_init
    {
    new db = redis.db(
    location="redis:6379",
    type=master,
    connection_timeout=500,
    shared_connections=false,
    max_connections=1);
    }
    sub vcl_recv
    {
    set req.http.X-Login = "false";
    set req.http.x-session = cookieplus.get("PHPSESSID","guest");
    if(req.http.x-session != "guest") {
    db.command("EXISTS");
    db.push("sf_s"+cookieplus.get("PHPSESSID"));
    db.execute();
    if(db.get_integer_reply() == 1) {
    set req.http.X-Login = "true";
    }
    }
    }
    SYNTHETIC HTTP

    View Slide

  94. sub vcl_backend_fetch
    {
    if(bereq.url == "/session") {
    if(bereq.http.X-Login != "true") {
    set bereq.backend = synthbackend.from_string("{}");
    return(fetch);
    }
    db.command("EVAL");
    db.push({"
    local session = redis.call('GET', KEYS[1])
    if session == nil then
    return '{}'
    end
    local result = string.gsub(session, '[%c]', '')
    local username = string.gsub(result,'.+Userusername\";s:[0-9]+:\"([^\"]+)\";.+','%1')
    if username == nil then
    return '{}'
    end
    return '{"username":"'.. username ..'"}'
    "});
    db.push(1);
    db.push("sf_s"+cookieplus.get("PHPSESSID"));
    db.execute();
    set bereq.backend = synthbackend.from_string(db.get_string_reply());
    }
    }
    sub vcl_backend_response
    {
    if(bereq.url == "/session") {
    set beresp.http.Content-Type = "application/json; charset=utf-8";
    set beresp.ttl = 3600s;
    set beresp.http.vary = "x-session";
    }
    }
    REDIS LUA
    CODE

    View Slide

  95. EDGESTASH
    VARNISH
    MODULE FOR MUSTACHE
    PROCESSING ON THE
    EDGE

    View Slide

  96. {{ USERNAME }}
    VARNISH
    MODULE FOR MUSTACHE
    PROCESSING ON THE
    EDGE
    REPLACES
    PLACEHOLDERS WITH JSON
    VALUES

    View Slide

  97. Surrogate-Control: edgestash="EDGESTASH/2.1"
    Link: ; rel=edgestash
    Welcome {{ username }}
    Welcome Thijs
    +
    =

    View Slide

  98. vcl 4.1;
    import edgestash;
    import std;
    backend default
    {
    .host = "1.1.1.1";
    }
    sub vcl_recv
    {
    set req.http.Surrogate-Capability={"edgestash="EDGESTASH/2.1""};
    }
    sub vcl_backend_response
    {
    if(beresp.http.Link) {
    std.collect(beresp.http.Link,",");
    }
    if(beresp.http.Link ~ "<([^>]+)>; rel=edgestash") {
    set beresp.http.x-edgestash-json-urls = regsuball(beresp.http.Link,"(?(?=<[^>]+>; rel=edgestash)<([^>]+)>;
    rel=edgestash|<([^>]+)>; rel=[a-z]+, )","\1");
    }
    if(beresp.http.Surrogate-Control) {
    std.collect(beresp.http.Surrogate-Control);
    }
    if(beresp.http.Surrogate-Control ~ {".*="EDGESTASH/2\.[0-9]+".*"}) {
    edgestash.parse_response();
    }
    }
    EDGESTASH

    View Slide

  99. sub vcl_deliver
    {
    if(edgestash.is_edgestash() && resp.http.x-edgestash-json-urls) {
    edgestash.add_json_url_csv(resp.http.x-edgestash-json-urls);
    edgestash.execute();
    }
    unset resp.http.Link;
    unset resp.http.x-edgestash-json-urls;
    unset resp.http.surrogate-control;
    }
    EDGESTASH

    View Slide

  100. View Slide

  101. COMPOSER REQUIRE THIJSFERYN/EDGESTASH-TWIG-BUNDLE

    View Slide

  102. {{ edgestash('username','/session') }}
    {{ username | edgestash('username','/session') }}

    View Slide

  103. View Slide

  104. 14 DAYS FREE TRIAL

    View Slide

  105. View Slide

  106. View Slide

  107. HTTPS://FERYN.EU
    HTTPS://TWITTER.COM/THIJSFERYN
    HTTPS://INSTAGRAM.COM/THIJSFERYN

    View Slide