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

Leverage HTTP to deliver cacheable websites

Ca901ddcea38854b9783781c91fc87c9?s=47 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/

Ca901ddcea38854b9783781c91fc87c9?s=128

Thijs Feryn

November 20, 2018
Tweet

Transcript

  1. Leverage HTTP to deliver cacheable websites Thijs Feryn

  2. Slow websites suck

  3. Web performance is an essential part of the user experience

  4. Slow ~ Down

  5. None
  6. None
  7. None
  8. MO' MONEY MO' SERVERS MO' PROBLEMS

  9. Identify slowest parts

  10. Optimize

  11. After a while you hit the limits

  12. Cache

  13. Hi, I'm Thijs

  14. I'm an Evangelist at

  15. I'm @thijsferyn

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

  18. None
  19. None
  20. Reverse caching proxy

  21. Normally User Server

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

  23. Content Delivery Network

  24. None
  25. None
  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
  27. In an ideal world

  28. ✓Stateless ✓Well-defined TTL ✓Cache / no-cache per resource ✓Cache variations

    ✓Conditional requests ✓Placeholders for non-cacheable content In an ideal world
  29. Reality sucks

  30. None
  31. None
  32. Time To Live

  33. Cache variations

  34. Legacy

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

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

    mind
  37. Cache-Control

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

  39. <?php 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(); } }
  40. Cache-Control: private, no-store

  41. /** * @Route("/private", name="private") */ public function private() { $response

    = $this ->render('private.twig') ->setPrivate(); $response->headers->addCacheControlDirective('no-store'); return $response; }
  42. Conditional requests

  43. Only fetch payload that has changed

  44. HTTP/1.1 200 OK

  45. Otherwise: HTTP/1.1 304 Not Modified

  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
  47. Conditional requests HTTP/1.0 304 Not Modified Host: localhost Etag: 7c9d70604c6061da9bb9377d3f00eb27

    GET / HTTP/1.1 Host: localhost If-None-Match: 7c9d70604c6061da9bb9377d3f00eb27
  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
  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
  50. Cache-Control: public, max-age=100, s-maxage=500, stale-while-revalidate=20

  51. Validate quickly

  52. Exit early

  53. Store & retrieve Etag

  54. <?php 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
  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
  56. Content composition & placeholders

  57. None
  58. Shopping cart or account information

  59. session cookie No cache

  60. Code renders single HTTP response

  61. Lowest common denominator: no cache

  62. Placeholders

  63. AJAX

  64. Non-cached AJAX call

  65. Edge Side Includes

  66. <esi:include src="/header" /> Edge Side Includes ✓Placeholder ✓W3C standard ✓Parsed

    by Varnish ✓Output is a composition of blocks ✓State per block ✓TTL per block
  67. Surrogate-Capability: key="ESI/1.0" Surrogate-Control: content="ESI/1.0" Varnish Backend <esi:include src="/header" /> Parse

    ESI placeholders Varnish
  68. Non-cached ESI placeholder

  69. ESI vs AJAX

  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
  71. ✓ Client-side ✓ Common knowledge ✓ Parallel processing ✓ Graceful

    degradation AJAX - Processed by the browser - Extra roundtrips - Somewhat slower
  72. Composition at the view layer

  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
  74. Subrequests

  75. <div class="container-fluid">
 {{ include('header.twig') }}
 <div class="row">
 <div class="col-sm-3 col-lg-2">


    {{ include('nav.twig') }}
 </div>
 <div class="col-sm-9 col-lg-10">
 {% block content %}{% endblock %}
 </div>
 </div>
 {{ include('footer.twig') }}
 </div> <div class="container-fluid">
 {{ render_esi(url('header')) }}
 <div class="row">
 <div class="col-sm-3 col-lg-2">
 {{ render_esi(url('nav')) }}
 </div>
 <div class="col-sm-9 col-lg-10">
 {% block content %}{% endblock %}
 </div>
 </div>
 {{ render_esi(url('footer')) }}
 </div>
  76. <div class="container-fluid"> <esi:include src="/header" /> <div class="row"> <div class="col-sm-3 col-lg-2">

    <esi:include src="/nav" /> </div> <div class="col-sm-9 col-lg-10"> <div class="page-header"> <h1>An example page <small>Rendered at 2017-05-17 16:57:14</small></h1> </div> <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris consequat orci eget libero sollicitudin,…</p> </div> </div> <esi:include src="/footer" /> </div>
  77. Cache variations

  78. How do you identify an object in cache?

  79. The URL identifies objects in cache

  80. What if the content of a URL varies based on

    the value of a request header?
  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
  82. Vary: Accept-Language Request header value Response header

  83. Content invalidation

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

  85. It's caching too much or too long

  86. Purging

  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; }
  88. curl -XPURGE -H"X-Purge-Pattern:/products/(.*)" http://localhost

  89. High TTL + purging

  90. Low TTL + conditional requests

  91. None
  92. None
  93. None
  94. https://feryn.eu https://twitter.com/ThijsFeryn https://instagram.com/ThijsFeryn

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

    stateful content
  96. Move state client-side

  97. Replace PHP session with JSON Web Tokens

  98. JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pb iIsImV4cCI6MTQ5NTUyODc1NiwibG9naW4iOnRydWV9.u4Idy- SYnrFdnH1h9_sNc4OasORBJcrh2fPo1EOTre8 ✓3 parts ✓Dot separated ✓Base64 encoded

    JSON ✓Header ✓Payload ✓Signature (HMAC with secret)
  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
  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)
  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 …
  102. X-Login: true End result: X-Login: false

  103. Extra cache variation required

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

  105. <script language="JavaScript">
 function getCookie(name) {
 var value = "; "

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

  107. None
  108. None
  109. https://feryn.eu https://twitter.com/ThijsFeryn https://instagram.com/ThijsFeryn