用 Workers + D1 + R2
做一个博客系统
以一个完整的博客系统为案例,手把手带你从零开始构建后端 API、连接数据库、处理图片上传,最后用 Vue 对接。 整个项目不需要购买服务器,全部运行在 Cloudflare 免费套餐上。
系统架构一览
Workers = Spring Boot Controller(接收请求、处理逻辑、返回 JSON)
D1 = MySQL(用 SQL 建表、增删改查,语法几乎一样)
R2 = 阿里云 OSS(存图片/文件,有 API 可读写)
wrangler = Maven + Spring Boot CLI(项目管理 + 本地启动工具)
我们要做什么接口?
这个博客系统包含以下 API,你可以类比自己写 Spring Boot Controller 时定义的 @RequestMapping。
1. 已有 Cloudflare 账号(免费注册)
2. 安装 Node.js 18+(node -v 查看)
3. 有 Vue 项目基础(会用 axios 就够了)
初始化 Workers 项目
Wrangler 是 Cloudflare 官方的 CLI 工具,相当于 Spring Boot 的 Maven 插件。我们用它来创建项目、本地开发、部署。
全局安装 Wrangler
只需安装一次,后续所有 Cloudflare 项目都用它
npm install -g wrangler
# 安装完验证版本
wrangler --version
登录 Cloudflare 账号
会自动打开浏览器,点击授权即可
wrangler login
创建 Workers 项目
选择 "Hello World" 模板,语言选 JavaScript(或 TypeScript 都行)
npm create cloudflare@latest blog-api # 选项: # ✓ What type of application? → "Hello World" Worker # ✓ Language? → JavaScript # ✓ Deploy now? → No(我们先在本地开发) cd blog-api
生成的项目结构如下:
├── src/
│ └── index.js ← 这就是你的"Controller 入口"
├── wrangler.toml ← 相当于 application.yml
└── package.json
创建 D1 数据库 & 建表
D1 是 Cloudflare 托管的 SQLite。你之前用 MySQL 怎么建表,D1 就怎么写 SQL,语法几乎一样。
创建 D1 数据库实例
执行后会输出一个 database_id,记下来待会要用
wrangler d1 create blog-db # 输出示例: ✅ Successfully created DB 'blog-db' database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
在 wrangler.toml 中绑定数据库
把 database_id 填入配置文件,相当于 application.yml 配置数据源
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"
创建 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', '两者的对比与适用场景...', '王文');
执行 SQL 建表
先在本地执行(--local),确认没问题后再同步到线上
# 本地执行(开发用) 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 命令行客户端。
写 Workers API 接口
现在打开 src/index.js,把它改成我们的博客 API。
这就是你写 Spring Boot Controller 的地方,只不过换成了 JS。
Workers 只有一个入口函数 fetch(request, env),所有请求都从这里进来。
env.DB 就是你的 D1 数据库连接,env.IMAGES 是 R2 存储桶。
我们用 URL 路径判断该走哪个逻辑,类似 Spring 的路由分发。
// ─── 工具函数:生成 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()
创建 R2 存储桶(存封面图片)
R2 就是 Cloudflare 版的阿里云 OSS。我们用它来存博客封面图,上传后得到一个公开 URL。
创建 R2 存储桶
执行以下命令,Cloudflare 控制台会出现一个名叫 blog-images 的 bucket
wrangler r2 bucket create blog-images
开启公开访问(让图片有公网 URL)
登录 Cloudflare Dashboard → R2 → blog-images → Settings → Public Access → 开启
开启公开访问后,R2 会给你一个 *.r2.dev 的公共域名,也可以绑定自己的域名。把这个域名填入 Step 3 的 publicUrl 变量中。
更新 index.js 中的图片域名
把占位符替换成你的实际 R2 公开域名
// 把这行 const publicUrl = `https://images.你的域名.com/${filename}`; // 改成你的 R2 公开域名,例如: const publicUrl = `https://pub-xxxxxx.r2.dev/${filename}`;
本地启动 & 测试接口
在部署上线前,先在本地跑起来验证每个接口是否正常。
启动本地开发服务器
wrangler dev 会在本地模拟 Workers + D1 环境
wrangler dev --local # 成功后会输出: ⎷ Ready on http://localhost:8787
用 curl 测试接口
打开新的终端窗口,依次执行以下测试命令
# 获取文章列表 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}
部署到 Cloudflare 线上
本地测试通过后,一条命令部署到全球边缘节点。
先把数据库表同步到线上
线上的 D1 和本地是独立的,需要单独执行一次建表
# 注意:不加 --local,直接同步到远程
wrangler d1 execute blog-db --file=schema.sql
部署 Workers
一条命令,30 秒内全球生效
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 会自动重新部署,不用手动执行命令。
Vue 前端对接 Workers API
Workers 就是一个标准的 REST API,你的 Vue 项目用 axios 调用它,和之前调 Spring Boot 接口完全一样。
配置 API 基础地址
在 Vue 项目的 .env 文件中配置,方便本地和生产环境切换
VITE_API_BASE=http://localhost:8787
VITE_API_BASE=https://blog-api.你的账号.workers.dev
封装 API 模块
新建 src/api/blog.js,和你平时写的 service 层一样
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' } }) }
在组件中使用
和调 Spring Boot 接口完全一样的写法
<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>
Vue 图片上传到 R2
发布文章时选择封面图,上传到 R2,拿回 URL 后保存到文章数据中。
<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 本地服务正常启动
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 无缝对接