Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Leverage HTTP to deliver cacheable websites

Thijs Feryn
November 20, 2018

Leverage HTTP to deliver cacheable websites

Slides for my HTTP caching talk at Codemotion in Berlin 2018.

More information: https://feryn.eu/speaking/leverage-http-to-deliver-cacheable-websites-codemotion-berlin-2018/

Thijs Feryn

November 20, 2018
Tweet

More Decks by Thijs Feryn

Other Decks in Technology

Transcript

  1. Leverage HTTP to deliver
    cacheable websites
    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. MO' MONEY
    MO' SERVERS
    MO' PROBLEMS

    View Slide

  9. Identify slowest parts

    View Slide

  10. Optimize

    View Slide

  11. After a while you
    hit the limits

    View Slide

  12. Cache

    View Slide

  13. Hi, I'm Thijs

    View Slide

  14. I'm an
    Evangelist
    at

    View Slide

  15. I'm @thijsferyn

    View Slide

  16. View Slide

  17. Don’t
    recompute
    if the data
    hasn’t
    changed

    View Slide

  18. View Slide

  19. View Slide

  20. Reverse
    caching
    proxy

    View Slide

  21. Normally
    User Server

    View Slide

  22. With ReCaPro *
    User ReCaPro Server
    * Reverse Caching Proxy

    View Slide

  23. Content Delivery Network

    View Slide

  24. View Slide

  25. View Slide

  26. HTTP caching mechanisms
    Expires: Sat, 09 Sep 2017 14:30:00 GMT
    Cache-control: public, max-age=3600,
    s-maxage=86400
    Cache-control: private, no-cache, no-store

    View Slide

  27. In an ideal world

    View Slide

  28. ✓Stateless
    ✓Well-defined TTL
    ✓Cache / no-cache per resource
    ✓Cache variations
    ✓Conditional requests
    ✓Placeholders for non-cacheable
    content
    In an ideal world

    View Slide

  29. Reality
    sucks

    View Slide

  30. View Slide

  31. View Slide

  32. Time To Live

    View Slide

  33. Cache variations

    View Slide

  34. Legacy

    View Slide

  35. What if we
    could design
    our software
    with HTTP
    caching in
    mind?

    View Slide

  36. ✓Portability
    ✓Developer empowerment
    ✓Control
    ✓Consistent caching behavior
    Caching state of mind

    View Slide

  37. Cache-Control

    View Slide

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

    View Slide

  39. 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

  40. Cache-Control: private, no-store

    View Slide

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

    View Slide

  42. Conditional
    requests

    View Slide

  43. Only fetch
    payload that has
    changed

    View Slide

  44. HTTP/1.1 200 OK

    View Slide

  45. Otherwise:
    HTTP/1.1 304 Not Modified

    View Slide

  46. 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

  47. Conditional requests
    HTTP/1.0 304 Not Modified
    Host: localhost
    Etag: 7c9d70604c6061da9bb9377d3f00eb27
    GET / HTTP/1.1
    Host: localhost
    If-None-Match:
    7c9d70604c6061da9bb9377d3f00eb27

    View Slide

  48. 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

  49. Conditional requests
    HTTP/1.0 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

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

    View Slide

  51. Validate quickly

    View Slide

  52. Exit early

    View Slide

  53. Store
    & retrieve
    Etag

    View Slide

  54. namespace App\EventListener;
    use Symfony\Bridge\Monolog\Logger;
    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;
    protected $logger;
    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

  55. {
    $this->redis = $redis;
    $this->logger = $logger;
    }
    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

  56. Content
    composition
    & placeholders

    View Slide

  57. View Slide

  58. Shopping
    cart or account
    information

    View Slide

  59. session
    cookie
    No cache

    View Slide

  60. Code
    renders
    single HTTP
    response

    View Slide

  61. Lowest
    common
    denominator:
    no cache

    View Slide

  62. Placeholders

    View Slide

  63. AJAX

    View Slide

  64. Non-cached
    AJAX call

    View Slide

  65. Edge Side
    Includes

    View Slide


  66. Edge Side Includes
    ✓Placeholder
    ✓W3C standard
    ✓Parsed by Varnish
    ✓Output is a composition of blocks
    ✓State per block
    ✓TTL per block

    View Slide

  67. Surrogate-Capability: key="ESI/1.0"
    Surrogate-Control: content="ESI/1.0"
    Varnish
    Backend

    Parse ESI placeholders
    Varnish

    View Slide

  68. Non-cached ESI
    placeholder

    View Slide

  69. ESI
    vs
    AJAX

    View Slide

  70. ✓ Server-side
    ✓ Standardized
    ✓ Processed on the
    “edge”, no in the
    browser
    ✓ Generally faster
    Edge-Side Includes
    - Sequential
    - One fails, all fail
    - Limited
    implementation in
    Varnish

    View Slide

  71. ✓ Client-side
    ✓ Common knowledge
    ✓ Parallel processing
    ✓ Graceful
    degradation
    AJAX
    - Processed by the
    browser
    - Extra roundtrips
    - Somewhat slower

    View Slide

  72. Composition
    at the view
    layer

    View Slide

  73. /**
    * @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

  74. Subrequests

    View Slide


  75. {{ 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









  76. 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

  77. Cache
    variations

    View Slide

  78. How do you identify
    an object in cache?

    View Slide

  79. The URL identifies
    objects in cache

    View Slide

  80. What if the content
    of a URL varies
    based on the value
    of a request
    header?

    View Slide

  81. 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

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

    View Slide

  83. Content
    invalidation

    View Slide

  84. There's only one
    thing worse than
    not caching
    enough

    View Slide

  85. It's caching too
    much or too long

    View Slide

  86. Purging

    View Slide

  87. sub vcl_recv {
    if (req.method == "PURGE") {
    if (!client.ip ~ purge) {
    return (synth(405, "This IP is not allowed to send PURGE."));
    }
    if (req.http.X-Purge-Pattern) {
    ban("obj.http.X-Req-URL ~ " + req.url + " && obj.http.X-Req-Host == " + req.http.host);
    return (synth(200, "Purged"));
    } else {
    ban("obj.http.x-url == " + req.url + " && obj.http.x-host == " + req.http.host);
    return (synth(200, "Purged"));
    }
    }
    }
    sub vcl_backend_response {
    set beresp.http.x-url = bereq.url;
    set beresp.http.x-host = bereq.http.host;
    }

    View Slide

  88. curl -XPURGE -H"X-Purge-Pattern:/products/(.*)" http://localhost

    View Slide

  89. High TTL +
    purging

    View Slide

  90. Low TTL +
    conditional
    requests

    View Slide

  91. View Slide

  92. View Slide

  93. View Slide

  94. https://feryn.eu
    https://twitter.com/ThijsFeryn
    https://instagram.com/ThijsFeryn

    View Slide

  95. ✓Navigation page
    ✓Private page
    Weak spots
    Not cached
    because of
    stateful content

    View Slide

  96. Move state client-side

    View Slide

  97. Replace PHP session with
    JSON Web Tokens

    View Slide

  98. JWT
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pb
    iIsImV4cCI6MTQ5NTUyODc1NiwibG9naW4iOnRydWV9.u4Idy-
    SYnrFdnH1h9_sNc4OasORBJcrh2fPo1EOTre8
    ✓3 parts
    ✓Dot separated
    ✓Base64 encoded JSON
    ✓Header
    ✓Payload
    ✓Signature (HMAC with secret)

    View Slide

  99. eyJzdWIiOiJhZG1pbiIsIm
    V4cCI6MTQ5NTUyODc1Niwi
    bG9naW4iOnRydWV9
    {
    "alg": "HS256",
    "typ": "JWT"
    }
    {
    "sub": "admin",
    "exp": 1495528756,
    "login": true
    }
    HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret
    )
    eyJhbGciOiJIUzI1NiIsI
    nR5cCI6IkpXVCJ9
    u4Idy-
    SYnrFdnH1h9_sNc4OasOR
    BJcrh2fPo1EOTre8

    View Slide

  100. JWT
    Cookie:token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJz
    dWIiOiJhZG1pbiIsImV4cCI6MTQ5NTUyODc1NiwibG9naW4iOnRydW
    V9.u4Idy-SYnrFdnH1h9_sNc4OasORBJcrh2fPo1EOTre8
    ✓Stored in a cookie
    ✓Can be validated by Varnish
    ✓Payload can be processed by any
    language (e.g. Javascript)

    View Slide

  101. sub jwt {
    std.log("Ready to perform some JWT magic");
    if(cookie.isset("jwt_cookie")) {
    #Extract header data from JWT
    var.set("token", cookie.get("jwt_cookie"));
    var.set("header", regsub(var.get("token"),"([^\.]+)\.[^\.]+\.[^\.]+","\1"));
    var.set("type", regsub(digest.base64url_decode(var.get("header")),{"^.*?"typ"\s*:\s*"(\w+)".*?$"},"\1"));
    var.set("algorithm", regsub(digest.base64url_decode(var.get("header")),{"^.*?"alg"\s*:\s*"(\w+)".*?$"},"\1"));
    #Don't allow invalid JWT header
    if(var.get("type") == "JWT" && var.get("algorithm") == "HS256") {
    #Extract signature & payload data from JWT
    var.set("rawPayload",regsub(var.get("token"),"[^\.]+\.([^\.]+)\.[^\.]+$","\1"));
    var.set("signature",regsub(var.get("token"),"^[^\.]+\.[^\.]+\.([^\.]+)$","\1"));
    var.set("currentSignature",digest.base64url_nopad_hex(digest.hmac_sha256(var.get("key"),var.get("header") + "." + var.get("rawPayload"))));
    var.set("payload", digest.base64url_decode(var.get("rawPayload")));
    var.set("exp",regsub(var.get("payload"),{"^.*?"exp"\s*:\s*([0-9]+).*?$"},"\1"));
    var.set("jti",regsub(var.get("payload"),{"^.*?"jti"\s*:\s*"([a-z0-9A-Z_\-]+)".*?$"},"\1"));
    var.set("userId",regsub(var.get("payload"),{"^.*?"uid"\s*:\s*"([0-9]+)".*?$"},"\1"));
    var.set("roles",regsub(var.get("payload"),{"^.*?"roles"\s*:\s*"([a-z0-9A-Z_\-, ]+)".*?$"},"\1"));
    #Only allow valid userId
    if(var.get("userId") ~ "^\d+$") {
    #Don't allow expired JWT
    if(std.time(var.get("exp"),now) >= now) {
    #SessionId should match JTI value from JWT
    if(cookie.get(var.get("sessionCookie")) == var.get("jti")) {
    #Don't allow invalid JWT signature
    if(var.get("signature") == var.get("currentSignature")) {
    #The sweet spot
    set req.http.X-login="true";
    } else {
    std.log("JWT: signature doesn't match. Received: " + var.get("signature") + ", expected: " + var.get("currentSignature"));
    }
    } else {
    std.log("JWT: session cookie doesn't match JTI." + var.get("sessionCookie") + ": " + cookie.get(var.get("sessionCookie")) + ", JTI:" + var.get("jti"));
    }
    } else {
    std.log("JWT: token has expired");
    }
    } else {
    std.log("UserId '"+ var.get("userId") +"', is not numeric");
    }
    } else {
    std.log("JWT: type is not JWT or algorithm is not HS256");
    }
    std.log("JWT processing finished. UserId: " + var.get("userId") + ". X-Login: " + req.http.X-login);
    }
    #Look for full private content
    if(req.url ~ "/node/2" && req.url !~ "^/user/login") {
    if(req.http.X-login != "true") {
    return(synth(302,"/user/login?destination=" + req.url));
    }
    }
    }
    Insert incomprehensible
    Varnish VCL code here …

    View Slide

  102. X-Login: true
    End result:
    X-Login: false

    View Slide

  103. Extra cache
    variation
    required

    View Slide

  104. Vary: Accept-Language, X-Login
    Content for logged-in
    & anonymous differs

    View Slide

  105. 
<br/>function getCookie(name) {
<br/>var value = "; " + document.cookie;
<br/>var parts = value.split("; " + name + "=");
<br/>if (parts.length == 2) return parts.pop().split(";").shift();
<br/>}
<br/>function parseJwt (token) {
<br/>var base64Url = token.split('.')[1];
<br/>var base64 = base64Url.replace('-', '+').replace('_', '/');
<br/>return JSON.parse(window.atob(base64));
<br/>};
<br/>$(document).ready(function(){
<br/>if ($.cookie('token') != null ){
<br/>var token = parseJwt($.cookie("token"));
<br/>$("#usernameLabel").html(', ' + token.sub);
<br/>}
<br/>});
<br/>
    Parse JWT
    in Javascript

    View Slide

  106. Does not require
    backend access

    View Slide

  107. View Slide

  108. View Slide

  109. https://feryn.eu
    https://twitter.com/ThijsFeryn
    https://instagram.com/ThijsFeryn

    View Slide