Docker系列 WordPress系列 通过Cloudflare Workers加速WordPress博客

发布于 2022-07-02  38 次阅读


底部日志部分可查看最新测试情况。

前言

目前我的个人博客托管在国外VPS上,正常情况下国内用户访问是比较慢的。一般的,如果博客是在国内已备案的机器上,只要设置内容分发网络(Content delivery network, CDN)即可大大加速国内各地用户访问博客网站的速度,并且极大地减少源站的访问压力。虽然国内CDN流量一般是要收费的,但对于小站来说没有多少流量,花不了什么钱。对于VPS在国外、域名托管在Cloudflare的小伙伴来说,免费的CloudFlare CDN本来是一个不错的选择,无奈其免费服务无法根据cookie区分访客提供针对性服务,导致国内用户在访问的时候可能会访问一个离他们很远的CloudFlare泛节点,速度甚至比直接访问源站还要慢,因此很多人都戏称CloudFlare CDN是“反向优化”、“众生平等”。在较早的时候,七牛云等CloudFlare Partner的CDN是一个不错的选择,可惜在博主建站的时候CloudFlare Partner基本上已经无法正常使用。更难受的是,为非ICP备案的国外VPS优化国内线路的第三方CDN往往收费极其昂贵,基本上都是面向企业级用户,不是我这种小站长用得起的。难道用国外VPS的小伙伴就没有一种免费好用的网站加速方案吗

一个偶然的机会,我了解到了CloudFlare Workers这个东西。CloudFlare Workers通过用户自定义规则,利用Worers KV构建无服务器应用程序并在全球范围内即时响应,可以获得较低延迟的访问。结合《抛弃Cloudflare Page Rule,拥抱Workers–自定义CDN缓存策略》、《[WordPress]利用 Cloudflare Workers 来缓存博客的 HTML 网页》两篇教程的内容,我测试了一下,发现可以成功,并且确实大大地降低了国内用户访问博客的TTFB(至少我自己测试的时候是这样的)。我将两位大佬的教程结合自己的经验糅合了一下,并基于最新的CloudFlare后台界面的相关操作进行教程编写,以方便小白用户食用(大佬们的教程都写得比较晦涩)。下面,我们就来看看如何利用CloudFlare Workers加速WordPress网站

原理

  • 编写一个Worker:大佬们已经写好了脚本,我们照搬过来就行。本文所提供的Worker的作用就是根据cookie来区分访客并针对性提供访问内容,并且不会缓存已登录或已评论用户的信息。
  • 添加一个Workers KV:据介绍,其作用是“在 Cloudflare 网络中存储应用程序数据,并从 Workers 访问键值对”。我个人的理解是,KV就是一种可以免费使用的储存方案,只是免费用户每天有请求数量的限制(10w/天)。
  • Cloudflare Page Cache:WordPress插件,可以根据你后台的文章、页面等更新自动在CloudFlare中缓存HTML页面。主要是配合Worker使用。
  • 禁用Cloudflare Page Rule:由于我们已经有Worker来定义缓存规则,所以可以禁止Cloudflare Page Rule缓存,不然Cloudflare Page Cache会将用户信息也缓存上去。

创建KV空间

首先,我们要创建一个KV空间,这个操作不难:

NVIDIA_Share_aZjDQvOf9d

创建好后,你可以进去看一下,里面是没有任何条目的。因为我们还没有开始缓存,所以这里还没有数据。

NVIDIA_Share_C6O4IXqdUp

设置Worker脚本

首先,我们从Workers——概述——创建服务里进去并创建一个Workers:

chrome_p68XPnDYnz

你可以改个服务的名称,然后点创建服务即可:

chrome_VDNjlG7QKi

下面,直接快速编辑这个Worker:

chrome_UvClPsyqwu

将它默认的规则全部删除,并填入下面大佬们写好的规则:

// IMPORTANT: Either A Key/Value Namespace must be bound to this worker script
// using the variable name EDGE_CACHE. or the API parameters below should be
// configured. KV is recommended if possible since it can purge just the HTML
// instead of the full cache.


  // Default cookie prefixes for bypass
  const DEFAULT_BYPASS_COOKIES = [
      "wp-",
      "wordpress",
      "comment_",
      "woocommerce_"
    ];

  // URL paths to bypass the cache (each pattern is a regex)
  const BYPASS_URL_PATTERNS = [
      /\/wp-admin\/.*/,
      /\/wp-adminlogin\/.*/
    ];

    /**
     * Main worker entry point. 
     */
    addEventListener("fetch", event => {
      const request = event.request;
      let upstreamCache = request.headers.get('x-HTML-Edge-Cache');

      // Only process requests if KV store is set up and there is no
      // HTML edge cache in front of this worker (only the outermost cache
      // should handle HTML caching in case there are varying levels of support).
      let configured = false;
      if (typeof EDGE_CACHE !== 'undefined') {
        configured = true;
      } else if (CLOUDFLARE_API.email.length && CLOUDFLARE_API.key.length && CLOUDFLARE_API.zone.length) {
        configured = true;
      }

      // Bypass processing of image requests (for everything except Firefox which doesn't use image/*)
      const accept = request.headers.get('Accept');
      let isImage = false;
      if (accept && (accept.indexOf('image/*') !== -1)) {
        isImage = true;
      }

      if (configured && !isImage && upstreamCache === null) {
        event.passThroughOnException();
        event.respondWith(processRequest(request, event));
      }
    });

    /**
     * Process every request coming through to add the edge-cache header,
     * watch for purge responses and possibly cache HTML GET requests.
     * 
     * @param {Request} originalRequest - Original request
     * @param {Event} event - Original event (for additional async waiting)
     */
    async function processRequest(originalRequest, event) {
      let cfCacheStatus = null;
      const accept = originalRequest.headers.get('Accept');
      const isHTML = (accept && accept.indexOf('text/html') >= 0);
      let {response, cacheVer, status, bypassCache} = await getCachedResponse(originalRequest);

      if (response === null) {
        // Clone the request, add the edge-cache header and send it through.
        let request = new Request(originalRequest);
        request.headers.set('x-HTML-Edge-Cache', 'supports=cache|purgeall|bypass-cookies');
        response = await fetch(request);

        if (response) {
          const options = getResponseOptions(response);
          if (options && options.purge) {
            await purgeCache(cacheVer, event);
            status += ', Purged';
          }
          bypassCache = bypassCache || shouldBypassEdgeCache(request, response);
          if ((!options || options.cache) && isHTML &&
              originalRequest.method === 'GET' && response.status === 200 &&
              !bypassCache) {
            status += await cacheResponse(cacheVer, originalRequest, response, event);
          }
        }
      } else {
        // If the origin didn't send the control header we will send the cached response but update
        // the cached copy asynchronously (stale-while-revalidate). This commonly happens with
        // a server-side disk cache that serves the HTML directly from disk.
        cfCacheStatus = 'HIT';
        if (originalRequest.method === 'GET' && response.status === 200 && isHTML) {
          bypassCache = bypassCache || shouldBypassEdgeCache(originalRequest, response);
          if (!bypassCache) {
            const options = getResponseOptions(response);
            if (!options) {
              status += ', Refreshed';
              event.waitUntil(updateCache(originalRequest, cacheVer, event));
            }
          }
        }
      }

      if (response && status !== null && originalRequest.method === 'GET' && response.status === 200 && isHTML) {
        response = new Response(response.body, response);
        response.headers.set('x-HTML-Edge-Cache-Status', status);
        if (cacheVer !== null) {
          response.headers.set('x-HTML-Edge-Cache-Version', cacheVer.toString());
        }
        if (cfCacheStatus) {
          response.headers.set('CF-Cache-Status', cfCacheStatus);
        }
      }

      return response;
    }

    /**
     * Determine if the cache should be bypassed for the given request/response pair.
     * Specifically, if the request includes a cookie that the response flags for bypass.
     * Can be used on cache lookups to determine if the request needs to go to the origin and
     * origin responses to determine if they should be written to cache.
     * @param {Request} request - Request
     * @param {Response} response - Response
     * @returns {bool} true if the cache should be bypassed
     */
    function shouldBypassEdgeCache(request, response) {
      let bypassCache = false;

      // Bypass the cache for all requests to a URL that matches any of the URL path bypass patterns
      const url = new URL(request.url);
      const path = url.pathname + url.search;
      if (BYPASS_URL_PATTERNS.length) {
          for (let pattern of BYPASS_URL_PATTERNS) {
              if (path.match(pattern)) {
                  bypassCache = true;
                  break;
              }
          }
      }

      if (request && response) {
        const options = getResponseOptions(response);
        const cookieHeader = request.headers.get('cookie');
        let bypassCookies = DEFAULT_BYPASS_COOKIES;
        if (options) {
          bypassCookies = options.bypassCookies;
        }
        if (cookieHeader && cookieHeader.length && bypassCookies.length) {
          const cookies = cookieHeader.split(';');
          for (let cookie of cookies) {
            // See if the cookie starts with any of the logged-in user prefixes
            for (let prefix of bypassCookies) {
              if (cookie.trim().startsWith(prefix)) {
                bypassCache = true;
                break;
              }
            }
            if (bypassCache) {
              break;
            }
          }
        }
      }

      return bypassCache;
    }

    const CACHE_HEADERS = ['Cache-Control', 'Expires', 'Pragma'];

    /**
     * Check for cached HTML GET requests.
     * 
     * @param {Request} request - Original request
     */
    async function getCachedResponse(request) {
      let response = null;
      let cacheVer = null;
      let bypassCache = false;
      let status = 'Miss';

      // Only check for HTML GET requests (saves on reading from KV unnecessarily)
      // and not when there are cache-control headers on the request (refresh)
      const accept = request.headers.get('Accept');
      const cacheControl = request.headers.get('Cache-Control');
      let noCache = false;
      // if (cacheControl && cacheControl.indexOf('no-cache') !== -1) {
      //   noCache = true;
      //   status = 'Bypass for Reload';
      // }
      if (!noCache && request.method === 'GET' && accept && accept.indexOf('text/html') >= 0) {
        // Build the versioned URL for checking the cache
        cacheVer = await GetCurrentCacheVersion(cacheVer);
        const cacheKeyRequest = GenerateCacheRequest(request, cacheVer);

        // See if there is a request match in the cache
        try {
          let cache = caches.default;
          let cachedResponse = await cache.match(cacheKeyRequest);
          if (cachedResponse) {
            // Copy Response object so that we can edit headers.
            cachedResponse = new Response(cachedResponse.body, cachedResponse);

            // Check to see if the response needs to be bypassed because of a cookie
            bypassCache = shouldBypassEdgeCache(request, cachedResponse);

            // Copy the original cache headers back and clean up any control headers
            if (bypassCache) {
              status = 'Bypass Cookie';
            } else {
              status = 'Hit';
              cachedResponse.headers.delete('Cache-Control');
              cachedResponse.headers.delete('x-HTML-Edge-Cache-Status');
              for (header of CACHE_HEADERS) {
                let value = cachedResponse.headers.get('x-HTML-Edge-Cache-Header-' + header);
                if (value) {
                  cachedResponse.headers.delete('x-HTML-Edge-Cache-Header-' + header);
                  cachedResponse.headers.set(header, value);
                }
              }
              response = cachedResponse;
            }
          } else {
            status = 'Miss';
          }
        } catch (err) {
          // Send the exception back in the response header for debugging
          status = "Cache Read Exception: " + err.message;
        }
      }

      return {response, cacheVer, status, bypassCache};
    }

    /**
     * Asynchronously purge the HTML cache.
     * @param {Int} cacheVer - Current cache version (if retrieved)
     * @param {Event} event - Original event
     */
    async function purgeCache(cacheVer, event) {
      if (typeof EDGE_CACHE !== 'undefined') {
        // Purge the KV cache by bumping the version number
        cacheVer = await GetCurrentCacheVersion(cacheVer);
        cacheVer++;
        event.waitUntil(EDGE_CACHE.put('html_cache_version', cacheVer.toString()));
      } else {
        // Purge everything using the API
        const url = "https://api.cloudflare.com/client/v4/zones/" + CLOUDFLARE_API.zone + "/purge_cache";
        event.waitUntil(fetch(url,{
          method: 'POST',
          headers: {'X-Auth-Email': CLOUDFLARE_API.email,
                    'X-Auth-Key': CLOUDFLARE_API.key,
                    'Content-Type': 'application/json'},
          body: JSON.stringify({purge_everything: true})
        }));
      }
    }

    /**
     * Update the cached copy of the given page
     * @param {Request} originalRequest - Original Request
     * @param {String} cacheVer - Cache Version
     * @param {EVent} event - Original event
     */
    async function updateCache(originalRequest, cacheVer, event) {
      // Clone the request, add the edge-cache header and send it through.
      let request = new Request(originalRequest);
      request.headers.set('x-HTML-Edge-Cache', 'supports=cache|purgeall|bypass-cookies');
      response = await fetch(request);

      if (response) {
        status = ': Fetched';
        const options = getResponseOptions(response);
        if (options && options.purge) {
          await purgeCache(cacheVer, event);
        }
        let bypassCache = shouldBypassEdgeCache(request, response);
        if ((!options || options.cache) && !bypassCache) {
          await cacheResponse(cacheVer, originalRequest, response, event);
        }
      }
    }

    /**
     * Cache the returned content (but only if it was a successful GET request)
     * 
     * @param {Int} cacheVer - Current cache version (if already retrieved)
     * @param {Request} request - Original Request
     * @param {Response} originalResponse - Response to (maybe) cache
     * @param {Event} event - Original event
     * @returns {bool} true if the response was cached
     */
    async function cacheResponse(cacheVer, request, originalResponse, event) {
      let status = "";
      const accept = request.headers.get('Accept');
      if (request.method === 'GET' && originalResponse.status === 200 && accept && accept.indexOf('text/html') >= 0) {
        cacheVer = await GetCurrentCacheVersion(cacheVer);
        const cacheKeyRequest = GenerateCacheRequest(request, cacheVer);

        try {
          // Move the cache headers out of the way so the response can actually be cached.
          // First clone the response so there is a parallel body stream and then
          // create a new response object based on the clone that we can edit.
          let cache = caches.default;
          let clonedResponse = originalResponse.clone();
          let response = new Response(clonedResponse.body, clonedResponse);
          for (header of CACHE_HEADERS) {
            let value = response.headers.get(header);
            if (value) {
              response.headers.delete(header);
              response.headers.set('x-HTML-Edge-Cache-Header-' + header, value);
            }
          }
          response.headers.delete('Set-Cookie');
          response.headers.set('Cache-Control', 'public; max-age=315360000');
          event.waitUntil(cache.put(cacheKeyRequest, response));
          status = ", Cached";
        } catch (err) {
          // status = ", Cache Write Exception: " + err.message;
        }
      }
      return status;
    }

    /******************************************************************************
     * Utility Functions
     *****************************************************************************/

    /**
     * Parse the commands from the x-HTML-Edge-Cache response header.
     * @param {Response} response - HTTP response from the origin.
     * @returns {*} Parsed commands
     */
    function getResponseOptions(response) {
      let options = null;
      let header = response.headers.get('x-HTML-Edge-Cache');
      if (header) {
        options = {
          purge: false,
          cache: false,
          bypassCookies: []
        };
        let commands = header.split(',');
        for (let command of commands) {
          if (command.trim() === 'purgeall') {
            options.purge = true;
          } else if (command.trim() === 'cache') {
            options.cache = true;
          } else if (command.trim().startsWith('bypass-cookies')) {
            let separator = command.indexOf('=');
            if (separator >= 0) {
              let cookies = command.substr(separator + 1).split('|');
              for (let cookie of cookies) {
                cookie = cookie.trim();
                if (cookie.length) {
                  options.bypassCookies.push(cookie);
                }
              }
            }
          }
        }
      }

      return options;
    }

    /**
     * Retrieve the current cache version from KV
     * @param {Int} cacheVer - Current cache version value if set.
     * @returns {Int} The current cache version.
     */
    async function GetCurrentCacheVersion(cacheVer) {
      if (cacheVer === null) {
        if (typeof EDGE_CACHE !== 'undefined') {
          cacheVer = await EDGE_CACHE.get('html_cache_version');
          if (cacheVer === null) {
            // Uninitialized - first time through, initialize KV with a value
            // Blocking but should only happen immediately after worker activation.
            cacheVer = 0;
            await EDGE_CACHE.put('html_cache_version', cacheVer.toString());
          } else {
            cacheVer = parseInt(cacheVer);
          }
        } else {
          cacheVer = -1;
        }
      }
      return cacheVer;
    }

    /**
     * Generate the versioned Request object to use for cache operations.
     * @param {Request} request - Base request
     * @param {Int} cacheVer - Current Cache version (must be set)
     * @returns {Request} Versioned request object
     */
    function GenerateCacheRequest(request, cacheVer) {
      let cacheUrl = request.url;
      if (cacheUrl.indexOf('?') >= 0) {
        cacheUrl += '&';
      } else {
        cacheUrl += '?';
      }
      cacheUrl += 'cf_edge_cache_ver=' + cacheVer;
      return new Request(cacheUrl);
    }

效果图如下,最后记得点下方的保存并部署

chrome_5frLgUissP

它不会自动跳转回worker设置界面的,自己回去就好。就是上图左上角的←silent***的链接。

Worker脚本绑定KV空间

我们进入Worker脚本 ,在设置中进行KV空间的绑定:

chrome_ei7ZLUZAcj

然后填写下列内容:

chrome_q3Q32zJSBo

注意,EDGE_CACHE是Worker脚本里的一个变量名称,所以你不可以更改。KV命名空间就下拉列表并选择你自己刚刚创建好的KV空间即可。

在Worker中添加Route

Route即路由、路径之意,就是你希望Worker缓存什么路径。一般都是缓存博客根目录,比如blognas.hwb0307.com/*。当然,更加合理的规则由Worker脚本进行定义,我们就简单地将这个路径加入到Worker中即可。

我们重新进入Worker:

chrome_s8xoxe51wj

触发器里添加新路由,按画面提示做相应的选择即可:

chrome_QZJtiFv3TI

到这里,CloudFlare的设置就基本完成了。

安装Cloudflare Page Cache插件

由于这个插件未在官方频道上传,所以我们要将Cloudflare Page Cache下载下来,然后通过Zip在后台上传并安装插件:

chrome_mFefTz3OIi

安装成功后记得启用。不用启动自动更新。这个插件启用后没有界面的,保证其处于启用状态即可。它的作用就是每次你的博客内容(文章、页面、说说等)发生变化时将内容推送至CloudFlare。不过,据说Cloudflare Page Cache每次更新都是重置全部缓存,这个特性感觉不太智能。你也可以在Chrome浏览器的Header的x-html-edge-cache-version参数观察到版本号的变化。虽然实际使用过程中用户体验基本不受影响,但Cloudflare Page Cache对VPS带宽的影响还需要持续观察。

禁止Page规则

进入域名管理界面,从规则——页面规则这里进入,创建页面规则。这里主要是为了禁用Page,从而将缓存规则完全由Worker进行定义。

chrome_k198RTpNjT

检查是否生效

完成一系列复杂的设置,下一步肯定是测试一下这些设置有没有生效。测试时不可以处于登陆状态(如果你使用WP Super Cache,登陆状态时会返回源站数据),并且将上网代理关闭以使用国内原生的网络环境。

打开一个无痕窗口并登陆自己的博客,按下图指示进行操作。记得要将Disable cache打勾,这样我们就不会因为本地缓存的存在而影响对网页元素的加载时间的测试。

chrome_4GpYFeeHIt

我们注意到图片中的这两项

x-html-edge-cache-status: Hit # Hit就说明生效了
x-html-edge-cache-version: 66 # 缓存的版本号

这与后台的KV空间的信息是一一对应的:

chrome_DCbLoDvzDI

chrome_Lx6OfKyJ6z

可能需要一段时间才会成功缓存,如果刚刚开始不成功可以等等再测试。如果实在没法成功,也可以评论区留言交流。

如果生效了,如果查看效果呢?在无痕窗口的Timing里进行查询:

chrome_91bKTCK9FR

我用登陆状态进行测试,此时我是直接访问源站,可见访客访问我博客的时候,TTFB得到了巨大的改善(源站1.11s缓存197.72ms):

chrome_B4Zs7HOvGv

小结

本文所提供的WordPress加速策略主要是将WP Super Cache预缓存好的HTML页面通过Worker规则进行缓存,这其实已经解决了个人站长对CDN的大部分需求。对于主题资源的CDN,如Argon,可以选择开启jsDelivr的缓存(fastly目录在国内可用)或者利用DogeCloud之类的自建主题缓存;也可以直接用源站资源通过Worker进行缓存。无论哪种方案,对国内用户也足够友好了。当然,由于访问次数的日限制,免费的Worker策略对于较大的网站来说是不太具有可行性的。如果你运营着一个比较大的网站且市场主要在国内,还是乖乖地使用国内备案的域名和VPS,上国内厂商的CDN;或者考虑CloudFlare Worker的付费服务。

另外,博客中的Chevereto图片暂时无解。因为也无法通过*hwb0307.com/*的规则解决,因为Chevereto并没有后台的WordPress插件支持,这样使用只能初始化第1次缓存。只能暂时使用默认的CloudFlare CDN缓存。

目前Workers方案在我博客里处于测试阶段,以后会进一步反馈使用体验!有什么问题欢迎留言或加群讨论!

日志

2022-06-17

昨天发现Cloudflare有一个Argo的付费功能,和ip优先的功能很相似:

NVIDIA_Share_iUqKUNZ5c1

其子功能Smart RoutingTunnel的相关介绍如下:

Smart Routing
Argo 的智能路由算法使用实时网络情报通过最快的 Cloudflare 网络路径来路由流量,同时保持开放、安全的连接以消除连接设置所导致的延迟。

Tunnel
使用安装在源服务器基础结构(包括容器或虚拟机)中的轻量级后台程序,Cloudflare Tunnel 可以在最近的 Cloudflare 数据中心与应用程序的源服务器之间创建一个加密隧道,而无需打开公共入站端口。

这看着真香啊,就是要钱( ̄△ ̄;)。感觉还蛮贵的,但企业用户肯定爽得不行。

2022-06-11

经过测试,由于免费CloudFlare CDN只有两个备选ip,其实怎么优化效果都是类似的。Workers还是要结合CloudFlare Partner使用才可具有最佳性能。CloudFlare Partner在免费加速的过程中估计是绕不开的。不过最近看了一下CloudFlare的用户计划,发现IP优选已经是企业级应用了:

NVIDIA_Share_vp1qngt2tE

估计是这个功能太香了,CloudFlare已经在Partner阶段测试完,现在要开始盈利了。估计个人用户很难白嫖到这个IP优选的功能了。

参考