Cloudflare Workers + R2 构建极致低成本、高性能随机图库(含分页与缓存优化)

ShowUNow 发布于 6 天前 50 次阅读


写在前面:为什么选 Cloudflare R2?

传统对象存储(AWS S3、阿里云 OSS)的流量费太贵,且 API 调用次数也是一笔隐形开销。

R2 的优势:零出口流量费。

R2 虽然免流量费,但 Class A(List/Put)和 Class B(Get/Head)操作是收费的。如果代码写得不好(例如每次访问都去 List 或 Head),高并发下依然会产生账单,且速度受限。

核心思路:从“能用”到“极致优化”

  1. 驯服昂贵的 List 操作
    • 每次随机请求都需要知道桶里有哪些图片。频繁调用 bucket.list() 既慢又贵。利用 Cloudflare Cache API,将 R2 的文件列表缓存 1 小时。每小时只消耗 1 次 Class A 操作,无论流量多大。
  2. 消灭探测请求
    • 当用户不支持 WebP 时,需要查找对应的 JPG/PNG 原图。使用 bucket.head() 依次探测,不存在则 404。这会导致大量无谓的 Class B 计费。在内存中建立 Hash Set(映射表)。直接在已缓存的文件列表中查找后缀,0 网络请求,0 费用,微秒级响应
  3. 突破 1000 个文件限制
    • :R2 的 list 默认只返回 1000 个对象。实现 cursor 分页循环,确保拿到全量数据,不漏掉任何一张图。

代码实现

/**
 * ==============================================================================
 * Cloudflare Workers + R2 高性能随机图床 (通用版)
 * 
 * 功能特点:
 * 1. 极致缓存:R2 列表缓存 1 小时,具体图片缓存 1 年 (CDN Friendly)。
 * 2. 零 Class B 费用:利用内存查找原图,消灭所有探测性 HEAD 请求。
 * 3. 大容量支持:支持 R2 分页 (Pagination),哪怕目录下有 10 万张图片也能完整读取。
 * 4. 双端适配:自动根据 User-Agent 或 URL 参数区分移动端/PC 端文件夹。
 * 5. 智能压缩:自动识别浏览器是否支持 WebP,优先返回 WebP 格式。
 * 
 * 使用说明:
 * 1. 在 Cloudflare 后台绑定 R2 存储桶到变量,变量名默认建议为 IMAGE_BUCKET。
 * 2. 修改下方的 CONFIG 配置项即可使用。
 * ==============================================================================
 */

// --- ⚙️ 用户配置区域 (请修改这里) ---
const CONFIG = {
  // R2 桶绑定的变量名 (需要在 Wrangler.toml 或 CF 后台设置)
  // 对应代码: const bucket = env[CONFIG.R2_BUCKET_VAR];
  R2_BUCKET_VAR: "IMAGE_BUCKET",

  // 允许的域名 (防盗链白名单)
  // 包含你的主站域名,支持正则转义字符 (如 "." 写成 "\\.")
  ALLOWED_REFERERS: [
    "tingyun\\.top",
    "www\\.example\\.com",
    "localhost" // 本地调试用
  ],

  // 哪里算“特殊域名”?
  // 如果通过这些域名访问,将跳过防盗链检查,且强制开启“动态文件夹”逻辑
  // 通常配置为你绑定在 Worker 上的自定义域名
  SPECIAL_HOSTNAMES: [
    "images.tingyun.top",
    "img.example.com"
  ],

  // 目录结构配置
  // 系统假定你的 R2 目录结构为:
  // 根目录/web/webp/ (PC端 WebP)
  // 根目录/web/images/ (PC端 原图)
  // 根目录/phone/webp/ (移动端 WebP)
  // 根目录/phone/images/ (移动端 原图)
  FOLDERS: {
    PC_BASE: "web/",       // PC 端基础目录
    MOBILE_BASE: "phone/", // 移动端基础目录
    WEBP_SUB: "webp/",     // WebP 子目录
    ORIGIN_SUB: "images/"  // 原图 子目录
  },

  // 缓存时间设置 (秒)
  CACHE_TTL: {
    LIST: 3600,         // R2 文件列表缓存 (1小时)
    IMAGE: 31536000     // 图片文件 CDN 缓存 (1年)
  }
};

// --- 🔧 核心逻辑区域 (通常无需修改) ---

const MOBILE_REGEX = /(Mobi|Android|iPhone|iPad|iPod)/i;

/**
 * 从缓存或 R2 中列出对象(支持分页,获取目录下所有文件)
 */
async function listObjects(bucket, prefix, ctx, cacheKeyUrl) {
  const cache = caches.default;
  const cacheKey = new Request(cacheKeyUrl, { cf: { image: { format: 'json' } } });

  let response = await cache.match(cacheKey);
  if (response) {
    // console.log(`List Cache HIT: ${prefix}`);
    const data = await response.json();
    return data.objects || [];
  }

  // console.log(`List Cache MISS: ${prefix}`);
  let allObjects = [];
  let cursor = undefined;
  let truncated = true;
  let loopCount = 0;

  try {
    while (truncated) {
      const result = await bucket.list({
        prefix: prefix,
        cursor: cursor,
        limit: 1000
      });
      allObjects = allObjects.concat(result.objects);
      truncated = result.truncated;
      cursor = result.cursor ? result.cursor : undefined;
      loopCount++;
      if (loopCount > 50) break; // 安全阀
    }
  } catch (e) {
    console.error("R2 List Error:", e);
  }

  const cacheData = JSON.stringify({ objects: allObjects });
  response = new Response(cacheData, {
    headers: {
      "Content-Type": "application/json",
      "Cache-Control": `public, max-age=${CONFIG.CACHE_TTL.LIST}`
    }
  });

  ctx.waitUntil(cache.put(cacheKey, response.clone()));
  return allObjects;
}

/**
 * 内存查找原图后缀 (Set 优化版)
 */
function findOriginalImageExtInMemory(filenameWithoutExt, objectKeySet, originalFolder) {
  const possibleOriginals = [".jpg", ".jpeg", ".png", ".gif"];
  for (const ext of possibleOriginals) {
    if (objectKeySet.has(`${originalFolder}${filenameWithoutExt}${ext}`)) {
      return ext;
    }
  }
  return null;
}

export default {
  async fetch(request, env, ctx) {
    const bucket = env[CONFIG.R2_BUCKET_VAR];
    if (!bucket) return new Response("R2 Bucket not found config", { status: 500 });

    const url = new URL(request.url);
    const hostname = url.hostname;
    const referer = request.headers.get("Referer") || "";
    const userAgent = request.headers.get("User-Agent") || "";
    const typeParam = url.searchParams.get("type") || "";

    // 1. 检查是否为特殊域名
    const isSpecialHostname = CONFIG.SPECIAL_HOSTNAMES.includes(hostname);

    // 2. 防盗链逻辑
    if (referer && !isSpecialHostname) {
      // 构造正则:允许的域名列表
      const allowedDomainsRegex = new RegExp(
        `^(https?:\\/\\/)?([a-zA-Z0-9-]+\\.)*(${CONFIG.ALLOWED_REFERERS.join("|")})(\\/|$)`,
        "i"
      );
      if (!allowedDomainsRegex.test(referer)) {
        return new Response("403 Forbidden", { status: 403 });
      }
    }

    // 3. CORS 预检
    if (request.method === "OPTIONS") {
      return new Response(null, {
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
          "Access-Control-Allow-Headers": "Origin, Content-Type, Accept"
        }
      });
    }

    const pathname = url.pathname;
    
    // 判断是否为直接访问具体图片
    // 逻辑:如果是 /PC_BASE/ 或 /MOBILE_BASE/ 开头,则视为图片请求
    const isDirectImageRequest = 
      pathname.startsWith("/" + CONFIG.FOLDERS.PC_BASE) || 
      pathname.startsWith("/" + CONFIG.FOLDERS.MOBILE_BASE);

    // =========================
    // 🅰️ 具体图片请求:走 CDN 缓存
    // =========================
    if (isDirectImageRequest) {
      const cache = caches.default;
      const cacheKey = new Request(request.url, { method: "GET" });

      let cachedResponse = await cache.match(cacheKey);
      if (cachedResponse) return cachedResponse;

      const imageKey = pathname.substring(1);
      const imageObject = await bucket.get(imageKey);

      if (!imageObject || !imageObject.body) {
        return new Response("404 Not Found", { status: 404 });
      }

      let contentType = "application/octet-stream";
      if (imageKey.endsWith(".webp")) contentType = "image/webp";
      else if (imageKey.endsWith(".jpg") || imageKey.endsWith(".jpeg")) contentType = "image/jpeg";
      else if (imageKey.endsWith(".png")) contentType = "image/png";
      else if (imageKey.endsWith(".gif")) contentType = "image/gif";

      const response = new Response(imageObject.body, {
        headers: {
          "Content-Type": contentType,
          "Access-Control-Allow-Origin": "*",
          "Cache-Control": `public, max-age=${CONFIG.CACHE_TTL.IMAGE}, s-maxage=${CONFIG.CACHE_TTL.IMAGE}, immutable`
        }
      });

      ctx.waitUntil(cache.put(cacheKey, response.clone()));
      return response;
    }

    // =========================
    // 🅱️ 随机图片逻辑:计算重定向
    // =========================
    
    // 确定基础目录 (Mobile vs PC)
    let baseFolder = CONFIG.FOLDERS.PC_BASE;
    
    // 判定逻辑:强制参数 > 特殊域名下的 UA 检测
    if (typeParam === "mobile" || typeParam === "phone") {
      baseFolder = CONFIG.FOLDERS.MOBILE_BASE;
    } else if (typeParam === "desktop" || typeParam === "web") {
      baseFolder = CONFIG.FOLDERS.PC_BASE;
    } else if (isSpecialHostname && MOBILE_REGEX.test(userAgent)) {
      baseFolder = CONFIG.FOLDERS.MOBILE_BASE;
    }

    const webpFolder = `${baseFolder}${CONFIG.FOLDERS.WEBP_SUB}`;
    const originalFolder = `${baseFolder}${CONFIG.FOLDERS.ORIGIN_SUB}`;
    const supportsWebP = (request.headers.get("Accept") || "").includes("image/webp");

    try {
      // 列出 baseFolder 下的所有文件 (包含 webp 和 images)
      // 使用 url.origin + baseFolder 作为列表缓存的 Key
      const listCacheKeyUrl = `${url.origin}/${baseFolder}`;
      const objects = await listObjects(bucket, baseFolder, ctx, listCacheKeyUrl);

      // 筛选 WebP
      const webpImages = objects.filter(obj => obj.key.startsWith(webpFolder) && obj.key.endsWith(".webp"));

      if (webpImages.length === 0) {
        return new Response("No WebP images found in directory.", { status: 404 });
      }

      // 随机选图
      const randomImage = webpImages[Math.floor(Math.random() * webpImages.length)];
      const filenameWithoutExt = randomImage.key.replace(webpFolder, "").replace(".webp", "");

      let redirectPath;

      if (supportsWebP) {
        redirectPath = `/${randomImage.key}`;
      } else {
        // 内存查找原图
        const objectKeySet = new Set(objects.map(o => o.key));
        const originalExt = findOriginalImageExtInMemory(filenameWithoutExt, objectKeySet, originalFolder);
        
        if (originalExt) {
          redirectPath = `/${originalFolder}${filenameWithoutExt}${originalExt}`;
        } else {
          return new Response("Original image not found.", { status: 404 });
        }
      }

      return new Response(null, {
        status: 302,
        headers: {
          "Location": redirectPath,
          "Access-Control-Allow-Origin": "*",
          "Cache-Control": "no-cache, no-store, must-revalidate"
        }
      });

    } catch (error) {
      return new Response(`Server Error: ${error.message}`, { status: 500 });
    }
  }
};
  1. R2_BUCKET_VAR:
    • 你在 wrangler.toml 或 Cloudflare 网页后台给 R2 存储桶绑定的变量名。IMAGE_BUCKET。如果不改这里,记得去后台把变量名设为这个。
  2. ALLOWED_REFERERS (防盗链):
    • 允许引用你图片的网站域名列表。把 tingyun\\.top 换成用户自己的域名。注意点号 . 前面要加双反斜杠 \\ 转义。
  3. SPECIAL_HOSTNAMES:
    • 你绑定在这个 Worker 上的自定义域名(也就是图床的访问地址)。换成用户自己的图床域名,例如 img.myblog.com
  4. FOLDERS (可选):
    • R2 桶里的目录结构。如果用户的目录结构和你不一样(比如他没有分 web/ 和 phone/),可以指导他把 PC_BASE 设为空字符串 "",或者调整为他自己的目录名。

效果

大幅改善ab操作次数消耗的问题,最大化利用cloudflare cdn的效果

临时起意搭建的博客,不知道能坚持下去多久。
最后更新于 2025-11-29