实战项目 Workers D1 数据库 R2 存储

Workers + D1 + R2
做一个博客系统

以一个完整的博客系统为案例,手把手带你从零开始构建后端 API、连接数据库、处理图片上传,最后用 Vue 对接。 整个项目不需要购买服务器,全部运行在 Cloudflare 免费套餐上。

系统架构一览

🖥️
Vue 前端
Pages 部署
Workers
API 层(你的后端)
🗄️
D1
文章 / 用户数据
🪣
R2
封面图片
💡 给 Java 开发者的类比

Workers = Spring Boot Controller(接收请求、处理逻辑、返回 JSON)
D1 = MySQL(用 SQL 建表、增删改查,语法几乎一样)
R2 = 阿里云 OSS(存图片/文件,有 API 可读写)
wrangler = Maven + Spring Boot CLI(项目管理 + 本地启动工具)

准备阶段

我们要做什么接口?

这个博客系统包含以下 API,你可以类比自己写 Spring Boot Controller 时定义的 @RequestMapping。

GET
/api/posts
获取文章列表(支持分页)
GET
/api/posts/:id
获取单篇文章详情
POST
/api/posts
创建新文章(需 token 鉴权)
PUT
/api/posts/:id
更新文章
DELETE
/api/posts/:id
删除文章
POST
/api/upload
上传封面图片到 R2(返回图片 URL)
⚠️ 前置条件

1. 已有 Cloudflare 账号(免费注册)
2. 安装 Node.js 18+(node -v 查看)
3. 有 Vue 项目基础(会用 axios 就够了)

Step 1 / 8

初始化 Workers 项目

Wrangler 是 Cloudflare 官方的 CLI 工具,相当于 Spring Boot 的 Maven 插件。我们用它来创建项目、本地开发、部署。

1

全局安装 Wrangler

只需安装一次,后续所有 Cloudflare 项目都用它

TERMINAL
npm install -g wrangler

# 安装完验证版本
wrangler --version
2

登录 Cloudflare 账号

会自动打开浏览器,点击授权即可

TERMINAL
wrangler login
3

创建 Workers 项目

选择 "Hello World" 模板,语言选 JavaScript(或 TypeScript 都行)

TERMINAL
npm create cloudflare@latest blog-api

# 选项:
# ✓ What type of application? → "Hello World" Worker
# ✓ Language? → JavaScript
# ✓ Deploy now? → No(我们先在本地开发)

cd blog-api

生成的项目结构如下:

blog-api/
├── src/
│ └── index.js  ← 这就是你的"Controller 入口"
├── wrangler.toml  ← 相当于 application.yml
└── package.json
Step 2 / 8

创建 D1 数据库 & 建表

D1 是 Cloudflare 托管的 SQLite。你之前用 MySQL 怎么建表,D1 就怎么写 SQL,语法几乎一样。

1

创建 D1 数据库实例

执行后会输出一个 database_id,记下来待会要用

TERMINAL
wrangler d1 create blog-db

# 输出示例:
✅ Successfully created DB 'blog-db'
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
2

在 wrangler.toml 中绑定数据库

把 database_id 填入配置文件,相当于 application.yml 配置数据源

TOML wrangler.toml
name = "blog-api"
main = "src/index.js"
compatibility_date = "2024-01-01"

# 绑定 D1 数据库
[[d1_databases]]
binding = "DB"              # 在代码里用 env.DB 访问
database_name = "blog-db"
database_id = "你的-database-id"  # ← 替换成上一步输出的 id

# 绑定 R2(Step 4 会用到)
[[r2_buckets]]
binding = "IMAGES"           # 在代码里用 env.IMAGES 访问
bucket_name = "blog-images"
3

创建 SQL 初始化文件并建表

在项目根目录新建 schema.sql,写建表语句

SQL schema.sql
-- 文章表(对标你 MySQL 里的 posts 表)
CREATE TABLE IF NOT EXISTS posts (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  title     TEXT NOT NULL,
  content   TEXT NOT NULL,
  cover_url TEXT,           -- R2 图片链接
  author    TEXT,
  created_at DATETIME DEFAULT (datetime('now')),
  updated_at DATETIME DEFAULT (datetime('now'))
);

-- 插入两条测试数据
INSERT INTO posts (title, content, author) VALUES
  ('我的第一篇博客', 'Hello Cloudflare Workers!', '王文'),
  ('Workers vs Spring Boot', '两者的对比与适用场景...', '王文');
4

执行 SQL 建表

先在本地执行(--local),确认没问题后再同步到线上

TERMINAL
# 本地执行(开发用)
wrangler d1 execute blog-db --local --file=schema.sql

# 线上执行(部署后用)
wrangler d1 execute blog-db --file=schema.sql

# 验证:查询一下数据
wrangler d1 execute blog-db --local --command="SELECT * FROM posts"
✅ 你学到了什么

D1 就是用命令行管理的 MySQL。建表、插数据的 SQL 语法几乎一样,只是用 wrangler d1 execute 替代了 MySQL 命令行客户端。

Step 3 / 8

写 Workers API 接口

现在打开 src/index.js,把它改成我们的博客 API。 这就是你写 Spring Boot Controller 的地方,只不过换成了 JS。

🔑 核心概念

Workers 只有一个入口函数 fetch(request, env),所有请求都从这里进来。 env.DB 就是你的 D1 数据库连接,env.IMAGES 是 R2 存储桶。 我们用 URL 路径判断该走哪个逻辑,类似 Spring 的路由分发。

JAVASCRIPT src/index.js  (完整代码)
// ─── 工具函数:生成 JSON 响应(相当于 ResponseEntity)
function json(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*',   // 允许跨域(Vue 前端需要)
    }
  });
}

// ─── 主入口(相当于 DispatcherServlet)
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const path = url.pathname;
    const method = request.method;

    // 处理 OPTIONS 预检请求(跨域必须)
    if (method === 'OPTIONS') {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
          'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        }
      });
    }

    // ─── 路由分发(相当于 @RequestMapping)
    if (path === '/api/posts' && method === 'GET')    return getPosts(request, env);
    if (path === '/api/posts' && method === 'POST')   return createPost(request, env);
    if (path.startsWith('/api/posts/') && method === 'GET')    return getPost(request, env, path);
    if (path.startsWith('/api/posts/') && method === 'PUT')    return updatePost(request, env, path);
    if (path.startsWith('/api/posts/') && method === 'DELETE') return deletePost(request, env, path);
    if (path === '/api/upload' && method === 'POST')  return uploadImage(request, env);

    return json({ error: 'Not Found' }, 404);
  }
};

// ─── GET /api/posts ─── 获取文章列表
async function getPosts(request, env) {
  const url = new URL(request.url);
  const page = parseInt(url.searchParams.get('page')) || 1;
  const pageSize = 10;
  const offset = (page - 1) * pageSize;

  // SQL 查询(相当于 Mapper.selectPage())
  const { results } = await env.DB
    .prepare('SELECT id, title, author, cover_url, created_at FROM posts ORDER BY created_at DESC LIMIT ? OFFSET ?')
    .bind(pageSize, offset)
    .all();

  const total = await env.DB
    .prepare('SELECT COUNT(*) as count FROM posts')
    .first();

  return json({ data: results, total: total.count, page });
}

// ─── GET /api/posts/:id ─── 获取单篇文章
async function getPost(request, env, path) {
  const id = path.split('/').pop();
  const post = await env.DB
    .prepare('SELECT * FROM posts WHERE id = ?')
    .bind(id)
    .first();

  if (!post) return json({ error: '文章不存在' }, 404);
  return json(post);
}

// ─── POST /api/posts ─── 创建文章
async function createPost(request, env) {
  // 简单的 Token 鉴权(生产环境请换 JWT)
  const token = request.headers.get('Authorization');
  if (token !== 'Bearer my-secret-token') {
    return json({ error: '未授权' }, 401);
  }

  const body = await request.json();
  const { title, content, author, cover_url } = body;

  if (!title || !content) {
    return json({ error: '标题和内容不能为空' }, 400);
  }

  const result = await env.DB
    .prepare('INSERT INTO posts (title, content, author, cover_url) VALUES (?, ?, ?, ?)')
    .bind(title, content, author || '匿名', cover_url || null)
    .run();

  return json({ id: result.meta.last_row_id, message: '发布成功' }, 201);
}

// ─── PUT /api/posts/:id ─── 更新文章
async function updatePost(request, env, path) {
  const id = path.split('/').pop();
  const body = await request.json();
  const { title, content, cover_url } = body;

  await env.DB
    .prepare('UPDATE posts SET title=?, content=?, cover_url=?, updated_at=datetime("now") WHERE id=?')
    .bind(title, content, cover_url, id)
    .run();

  return json({ message: '更新成功' });
}

// ─── DELETE /api/posts/:id ─── 删除文章
async function deletePost(request, env, path) {
  const id = path.split('/').pop();
  await env.DB
    .prepare('DELETE FROM posts WHERE id = ?')
    .bind(id)
    .run();
  return json({ message: '删除成功' });
}

// ─── POST /api/upload ─── 上传图片到 R2
async function uploadImage(request, env) {
  const formData = await request.formData();
  const file = formData.get('file');

  if (!file) return json({ error: '没有文件' }, 400);

  const filename = `covers/${Date.now()}-${file.name}`;
  await env.IMAGES.put(filename, file.stream(), {
    httpMetadata: { contentType: file.type }
  });

  // 返回公开访问 URL(需要 R2 开启公开访问,Step 4 有说明)
  const publicUrl = `https://images.你的域名.com/${filename}`;
  return json({ url: publicUrl });
}
✅ 对比理解

env.DB.prepare(sql).bind(...).all() → 相当于 MyBatis 的 mapper.selectList()
.first() → 相当于 mapper.selectById()
.run() → 相当于 mapper.insert() / update() / delete()

Step 4 / 8

创建 R2 存储桶(存封面图片)

R2 就是 Cloudflare 版的阿里云 OSS。我们用它来存博客封面图,上传后得到一个公开 URL。

1

创建 R2 存储桶

执行以下命令,Cloudflare 控制台会出现一个名叫 blog-images 的 bucket

TERMINAL
wrangler r2 bucket create blog-images
2

开启公开访问(让图片有公网 URL)

登录 Cloudflare Dashboard → R2 → blog-images → Settings → Public Access → 开启

⚠️ 注意

开启公开访问后,R2 会给你一个 *.r2.dev 的公共域名,也可以绑定自己的域名。把这个域名填入 Step 3 的 publicUrl 变量中。

3

更新 index.js 中的图片域名

把占位符替换成你的实际 R2 公开域名

JAVASCRIPT src/index.js
// 把这行
const publicUrl = `https://images.你的域名.com/${filename}`;

// 改成你的 R2 公开域名,例如:
const publicUrl = `https://pub-xxxxxx.r2.dev/${filename}`;
Step 5 / 8

本地启动 & 测试接口

在部署上线前,先在本地跑起来验证每个接口是否正常。

1

启动本地开发服务器

wrangler dev 会在本地模拟 Workers + D1 环境

TERMINAL
wrangler dev --local

# 成功后会输出:
⎷ Ready on http://localhost:8787
2

用 curl 测试接口

打开新的终端窗口,依次执行以下测试命令

TERMINAL
# 获取文章列表
curl http://localhost:8787/api/posts

# 获取单篇文章
curl http://localhost:8787/api/posts/1

# 创建新文章(带 token)
curl -X POST http://localhost:8787/api/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer my-secret-token" \
  -d '{"title":"测试文章","content":"内容内容内容","author":"王文"}'

# 删除文章
curl -X DELETE http://localhost:8787/api/posts/1
✅ 预期响应示例

GET /api/posts 应返回:
{"data":[{"id":1,"title":"我的第一篇博客",...}],"total":2,"page":1}

Step 6 / 8

部署到 Cloudflare 线上

本地测试通过后,一条命令部署到全球边缘节点。

1

先把数据库表同步到线上

线上的 D1 和本地是独立的,需要单独执行一次建表

TERMINAL
# 注意:不加 --local,直接同步到远程
wrangler d1 execute blog-db --file=schema.sql
2

部署 Workers

一条命令,30 秒内全球生效

TERMINAL
wrangler deploy

# 成功后输出:
✅ Deployed blog-api to https://blog-api.你的账号.workers.dev
🌍 部署成功后

你的 API 现在全球可访问:
https://blog-api.你的账号.workers.dev/api/posts

后续如果配置了 CI/CD(GitHub Actions),每次 git push 会自动重新部署,不用手动执行命令。

Step 7 / 8

Vue 前端对接 Workers API

Workers 就是一个标准的 REST API,你的 Vue 项目用 axios 调用它,和之前调 Spring Boot 接口完全一样。

1

配置 API 基础地址

在 Vue 项目的 .env 文件中配置,方便本地和生产环境切换

ENV .env.development
VITE_API_BASE=http://localhost:8787
ENV .env.production
VITE_API_BASE=https://blog-api.你的账号.workers.dev
2

封装 API 模块

新建 src/api/blog.js,和你平时写的 service 层一样

JAVASCRIPT src/api/blog.js
import axios from 'axios'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE,
  headers: { 'Content-Type': 'application/json' }
})

// 获取文章列表
export const getPosts = (page = 1) =>
  api.get(`/api/posts?page=${page}`)

// 获取文章详情
export const getPost = (id) =>
  api.get(`/api/posts/${id}`)

// 发布文章(需要 token)
export const createPost = (data, token) =>
  api.post('/api/posts', data, {
    headers: { 'Authorization': `Bearer ${token}` }
  })

// 上传图片
export const uploadImage = (file) => {
  const form = new FormData()
  form.append('file', file)
  return api.post('/api/upload', form, {
    headers: { 'Content-Type': 'multipart/form-data' }
  })
}
3

在组件中使用

和调 Spring Boot 接口完全一样的写法

VUE src/views/BlogList.vue
<script setup>
import { ref, onMounted } from 'vue'
import { getPosts } from '@/api/blog'

const posts = ref([])
const loading = ref(true)

onMounted(async () => {
  const { data } = await getPosts()
  posts.value = data.data     // 赋值给响应式变量
  loading.value = false
})
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else>
    <div v-for="post in posts" :key="post.id">
      <h2>{{ post.title }}</h2>
      <p>{{ post.author }} · {{ post.created_at }}</p>
      <img v-if="post.cover_url" :src="post.cover_url" />
    </div>
  </div>
</template>
Step 8 / 8

Vue 图片上传到 R2

发布文章时选择封面图,上传到 R2,拿回 URL 后保存到文章数据中。

VUE src/views/CreatePost.vue(图片上传部分)
<script setup>
import { ref } from 'vue'
import { uploadImage, createPost } from '@/api/blog'

const form = ref({ title: '', content: '', cover_url: '' })
const uploading = ref(false)

// 选择图片后自动上传
async function onFileChange(e) {
  const file = e.target.files[0]
  if (!file) return

  uploading.value = true
  const { data } = await uploadImage(file)
  form.value.cover_url = data.url   // 保存 R2 返回的图片 URL
  uploading.value = false
}

async function submit() {
  await createPost(form.value, 'my-secret-token')
  // 跳转到列表页...
}
</script>

<template>
  <input type="text" v-model="form.title" placeholder="文章标题" />
  <textarea v-model="form.content" placeholder="文章内容"></textarea>

  <!-- 图片上传 -->
  <input type="file" accept="image/*" @change="onFileChange" />
  <span v-if="uploading">上传中...</span>
  <img v-if="form.cover_url" :src="form.cover_url" style="max-width:200px" />

  <button @click="submit">发布文章</button>
</template>
完成 🎉

验收清单 & 下一步

按下面的清单逐项确认,全部通过代表你已经掌握了 Workers + D1 + R2 的核心用法。

✅ 实战验收清单

wrangler dev 本地服务正常启动
curl GET /api/posts 返回文章列表 JSON
curl POST /api/posts 成功创建一篇文章
图片上传接口返回 R2 URL
Vue 页面成功渲染文章列表
wrangler deploy 部署到线上,URL 可访问
你学会了 对应你原来的技术 关键命令 / 代码
Workers Spring Boot Controller export default { fetch() }
D1 建表 MySQL 建表 wrangler d1 execute --file
D1 查询 MyBatis Mapper env.DB.prepare(sql).bind().all()
R2 上传 OSS SDK putObject env.IMAGES.put(key, stream)
Vue 对接 axios 调 Spring Boot api.get('/api/posts')
部署 上传 jar / Docker push wrangler deploy
🚀 下一步可以做什么

1. 加 JWT 鉴权:把简单 token 换成 Jose 库实现 JWT(Workers 支持 Web Crypto API)
2. 接入 KV:用 Cloudflare KV 做缓存,加速文章列表读取
3. 配置自定义域名:给 Workers 绑定你自己的域名
4. Vue 部署到 Pages:用 Cloudflare Pages 部署前端,和 Workers 无缝对接

返回首页,查看更多教程