返回首页

用Cloudflare Workers+D1构建无服务器API:零成本部署

用Cloudflare Workers+D1构建无服务器:零成本部署

TL;DR: Cloudflare Workers免费额度每天10万次请求,D1免费10万行读/5万行写/天,5GB存储。用Wrangler 5分钟就能部署一个全球边缘运行的REST API,延迟比传统云服务器低60%以上。本文带你从零构建一个带认证、分页、搜索的完整API。

为什么选Cloudflare Workers+D1

对比项 CF Workers+D1 AWS Lambda+DynamoDB Vercel+PlanetScale Fly.io+SQLite
免费请求/天 100,000 1,000,000 100,000 无免费
免费存储 5GB 25GB 5GB 3GB
冷启动 <1ms 100-500ms 50-200ms 0ms
边缘节点 300+城市 20+区域 10+区域 30+区域
月费(生产) $5 $20+ $29+ $10+
学习曲线 ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐

环境搭建

安装Wrangler CLI

# 安装Wrangler(Cloudflare官方CLI)
 install -g wrangler

# 登录Cloudflare账户
wrangler login

# 验证登录
wrangler whoami

创建项目

# 创建新项目
npm create cloudflare@latest my-api -- --type hello-world
cd my-api

# 项目结构
# my-api/
# ├── wrangler.toml      # 配置文件
# ├── src/
# │   └── index.ts       # Worker代码
# ├── package.json
# └── tsconfig.json

配置wrangler.toml

name = "my-api"
main = "src/index.ts"
compatibility_date = "2026-01-01"

# D1数据库绑定
[[d1_databases]]
binding = "DB"
database_name = "my-api-db"
database_id = "xxx-xxx-xxx"  # 创建后填入

# 环境变量
[vars]
JWT_SECRET = "your-jwt-secret-min-32-chars-long"
CORS_ORIGIN = "https://yourdomain.com"
API_VERSION = "v1"

创建D1数据库

# 创建数据库
wrangler d1 create my-api-db

# 输出:
# ✅ Successfully created DB 'my-api-db'
# [[d1_databases]]
# binding = "DB"
# database_name = "my-api-db"
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

数据库Schema设计

创建 schema.sql

-- 用户表
CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    name TEXT,
    role TEXT DEFAULT 'user' CHECK(role IN ('admin', 'user', 'viewer')),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 帖子表
CREATE TABLE IF NOT EXISTS posts (
    id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
    author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    slug TEXT UNIQUE NOT NULL,
    content TEXT,
    status TEXT DEFAULT 'draft' CHECK(status IN ('draft', 'published', 'archived')),
    tags TEXT,  -- JSON数组
    view_count INTEGER DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 索引
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_status ON posts(status, created_at DESC);
CREATE INDEX idx_posts_slug ON posts(slug);

-- 全文搜索(D1支持FTS5)
CREATE VIRTUAL TABLE posts_fts USING fts5(title, content, tags, content=posts, content_rowid=rowid);

-- FTS触发器:保持索引同步
CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN
    INSERT INTO posts_fts(rowid, title, content, tags)
    VALUES (.rowid, NEW.title, NEW.content, NEW.tags);
END;

CREATE TRIGGER posts_ad AFTER DELETE ON posts BEGIN
    INSERT INTO posts_fts(posts_fts, rowid, title, content, tags)
    VALUES ('delete', OLD.rowid, OLD.title, OLD.content, OLD.tags);
END;

CREATE TRIGGER posts_au AFTER  ON posts BEGIN
    INSERT INTO posts_fts(posts_fts, rowid, title, content, tags)
    VALUES ('delete', OLD.rowid, OLD.title, OLD.content, OLD.tags);
    INSERT INTO posts_fts(rowid, title, content, tags)
    VALUES (NEW.rowid, NEW.title, NEW.content, NEW.tags);
END;

-- API Keys表
CREATE TABLE IF NOT EXISTS api_keys (
    id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    key_hash TEXT UNIQUE NOT NULL,
    name TEXT,
    permissions TEXT DEFAULT '["read"]',  -- JSON
    expires_at DATETIME,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

应用数据库迁移:

wrangler d1 execute my-api-db --file=./schema.sql

核心API代码

项目结构

src/
├── index.ts           # 路由入口
├── auth.ts            # 认证逻辑
├── routes/
│   ├── users.ts       # 用户CRUD
│   ├── posts.ts       # 帖子CRUD
│   └── .ts      # 全文搜索
├── middleware/
│   ├── cors.ts        # CORS处理
│   ├── auth.ts        # JWT验证中间件
│   └── rateLimit.ts   # 速率限制
└── utils/
    ├── jwt.ts         # JWT工具
    ├── crypto.ts      # 密码哈希
    └── response.ts    # 响应工具

主入口:src/index.ts

export interface Env {
    DB: D1Database;
    JWT_SECRET: string;
    CORS_ORIGIN: string;
    API_VERSION: string;
}

type RouteHandler = (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>;

// 路由表
const routes: Record<string, Record<string, RouteHandler>> = {
    'POST /api/v1/auth/register': handleRegister,
    'POST /api/v1/auth/login': handleLogin,
    'GET /api/v1/posts': handleListPosts,
    'GET /api/v1/posts/:id': handleGetPost,
    'POST /api/v1/posts': handleCreatePost,
    'PUT /api/v1/posts/:id': handleUpdatePost,
    'DELETE /api/v1/posts/:id': handleDeletePost,
    'GET /api/v1/search': handleSearch,
};

export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
        // CORS预检
        if (request.method === 'OPTIONS') {
            return new Response(null, {
                headers: {
                    'Access-Control-Allow-Origin': env.CORS_ORIGIN,
                    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
                    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
                    'Access-Control-Max-Age': '86400',
                },
            });
        }

        const url = new URL(request.url);
        const method = request.method;
        const path = url.pathname;

        try {
            // 匹配路由
            const handler = matchRoute(method, path);
            if (!handler) {
                return jsonResponse({ error: 'Not Found' }, 404);
            }

            const response = await handler(request, env, ctx);
            
            // 添加CORS头
            response.headers.set('Access-Control-Allow-Origin', env.CORS_ORIGIN);
            response.headers.set('X-API-Version', env.API_VERSION);
            
            return response;
        } catch (error) {
            console.error('Request error:', error);
            return jsonResponse({ error: 'Internal Server Error' }, 500);
        }
    },
};

JWT认证实现:src/utils/jwt.ts

// 使用Web Crypto API(Workers内置)
export async function createJWT(payload: object, secret: string, expiresIn: number = 3600): Promise<string> {
    const header = { alg: 'HS256', typ: 'JWT' };
    const now = Math.floor(Date.now() / 1000);
    const claims = { ...payload, iat: now, exp: now + expiresIn };

    const encodedHeader = btoa(JSON.stringify(header)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
    const encodedPayload = btoa(JSON.stringify(claims)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

    const key = await crypto.subtle.importKey(
        'raw',
        new TextEncoder().encode(secret),
        { name: 'HMAC', hash: 'SHA-256' },
        false,
        ['sign']
    );

    const signature = await crypto.subtle.sign(
        'HMAC',
        key,
        new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)
    );

    const encodedSignature = btoa(String.fromCharCode(...new Uint8Array(signature)))
        .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

    return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
}

export async function verifyJWT(token: string, secret: string): Promise<any> {
    const parts = token.split('.');
    if (parts.length !== 3) throw new Error('Invalid token');

    const key = await crypto.subtle.importKey(
        'raw',
        new TextEncoder().encode(secret),
        { name: 'HMAC', hash: 'SHA-256' },
        false,
        ['verify']
    );

    const signature = Uint8Array.from(
        atob(parts[2].replace(/-/g, '+').replace(/_/g, '/')),
        c => c.charCodeAt(0)
    );

    const valid = await crypto.subtle.verify(
        'HMAC',
        key,
        signature,
        new TextEncoder().encode(`${parts[0]}.${parts[1]}`)
    );

    if (!valid) throw new Error('Invalid signature');

    const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
    if (payload.exp < Math.floor(Date.now() / 1000)) {
        throw new Error('Token expired');
    }

    return payload;
}

密码哈希:src/utils/crypto.ts

export async function hashPassword(password: string): Promise<string> {
    const salt = crypto.getRandomValues(new Uint8Array(16));
    const key = await crypto.subtle.importKey(
        'raw',
        new TextEncoder().encode(password),
        { name: 'PBKDF2' },
        false,
        ['deriveBits']
    );

    const hash = await crypto.subtle.deriveBits(
        { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
        key,
        256
    );

    const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('');
    const hashHex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');

    return `${saltHex}:${hashHex}`;
}

export async function verifyPassword(password: string, stored: string): Promise<boolean> {
    const [saltHex, hashHex] = stored.split(':');
    const salt = new Uint8Array(saltHex.match(/.{2}/g)!.map(h => parseInt(h, 16)));

    const key = await crypto.subtle.importKey(
        'raw',
        new TextEncoder().encode(password),
        { name: 'PBKDF2' },
        false,
        ['deriveBits']
    );

    const hash = await crypto.subtle.deriveBits(
        { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
        key,
        256
    );

    const computedHash = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
    return computedHash === hashHex;
}

CRUD路由:src/routes/posts.ts

import { verifyJWT } from '../utils/jwt';

// 列出帖子(分页)
async function handleListPosts(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const page = parseInt(url.searchParams.get('page') || '1');
    const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
    const status = url.searchParams.get('status') || 'published';
    const offset = (page - 1) * limit;

    const { results } = await env.DB.prepare(`
        SELECT p.id, p.title, p.slug, p.status, p.tags, p.view_count, p.created_at,
               u.name as author_name
        FROM posts p
        LEFT JOIN users u ON p.author_id = u.id
        WHERE p.status = ?
        ORDER BY p.created_at DESC
        LIMIT ? OFFSET ?
    `).bind(status, limit, offset).all();

    const countResult = await env.DB.prepare(
        `SELECT COUNT(*) as total FROM posts WHERE status = ?`
    ).bind(status).first();

    return jsonResponse({
        : results,
        pagination: {
            page,
            limit,
            total: countResult?.total || 0,
            pages: Math.ceil((countResult?.total as number || 0) / limit),
        },
    });
}

// 创建帖子
async function handleCreatePost(request: Request, env: Env): Promise<Response> {
    const auth = await authenticate(request, env);
    if (!auth) return jsonResponse({ error: 'Unauthorized' }, 401);

    const body = await request.json() as any;
    const { title, content, tags, status } = body;

    if (!title) return jsonResponse({ error: 'Title is required' }, 400);

    const slug = title.toLowerCase()
        .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
        .replace(/^-|-$/g, '')
        + '-' + Date.now().toString(36);

    const id = crypto.randomUUID();

    await env.DB.prepare(`
        INSERT INTO posts (id, author_id, title, slug, content, tags, status)
        VALUES (?, ?, ?, ?, ?, ?, ?)
    `).bind(id, auth.userId, title, slug, content || '', JSON.stringify(tags || []), status || 'draft').run();

    return jsonResponse({ id, title, slug, status: status || 'draft' }, 201);
}

// 全文搜索
async function handleSearch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const q = url.searchParams.get('q');
    if (!q) return jsonResponse({ error: 'Query parameter "q" is required' }, 400);

    const page = parseInt(url.searchParams.get('page') || '1');
    const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
    const offset = (page - 1) * limit;

    const { results } = await env.DB.prepare(`
        SELECT p.id, p.title, p.slug, p.status, p.tags, p.created_at,
               snippet(posts_fts, 1, '<mark>', '</mark>', '...', 32) as content_snippet,
               rank
        FROM posts_fts
        JOIN posts p ON p.rowid = posts_fts.rowid
        WHERE posts_fts MATCH ? AND p.status = 'published'
        ORDER BY rank
        LIMIT ? OFFSET ?
    `).bind(q, limit, offset).all();

    return jsonResponse({ query: q, data: results });
}

// 辅助函数
async function authenticate(request: Request, env: Env): Promise<{ userId: string; role: string } | null> {
    const authHeader = request.headers.get('Authorization');
    if (!authHeader?.startsWith('Bearer ')) return null;

    try {
        const token = authHeader.slice(7);
        const payload = await verifyJWT(token, env.JWT_SECRET);
        return { userId: payload.userId, role: payload.role };
    } catch {
        return null;
    }
}

function jsonResponse(data: any, status: number = 200): Response {
    return new Response(JSON.stringify(data), {
        status,
        headers: { 'Content-Type': 'application/json' },
    });
}

部署和测试

# 本地开发(使用真实D1数据库)
wrangler dev

# 部署到Cloudflare
wrangler deploy

# 测试API
# 注册
curl -X POST https://my-api.workers.dev/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"secure123","name":"Test User"}'

# 登录
TOKEN=$(curl -s -X POST https://my-api.workers.dev/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"secure123"}' | jq -r '.token')

# 创建帖子
curl -X POST https://my-api.workers.dev/api/v1/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"title":"Hello World","content":"This is my first post","tags":["test","hello"],"status":"published"}'

# 搜索
curl "https://my-api.workers.dev/api/v1/search?q=hello"

性能数据

实际测试结果(从中国访问CF全球边缘网络):

端点              响应时间(P50)  响应时间(P99)  缓存命中
GET /posts        12ms          45ms          是
GET /posts/:id    8ms           30ms          是
POST /posts       35ms          120ms         否
GET /search?q=    15ms          55ms          否
POST /auth/login  45ms          180ms         否

对比传统服务器(美东→亚太):

传统API服务器: 150-300ms(网络延迟主导)
CF Workers:    8-45ms(边缘计算,就近执行)

成本估算

免费额度(足够中小项目):
- Workers: 100,000请求/天 = 约300万/月
- D1读取: 500万行/天 = 约1.5亿/月
- D1写入: 10万行/天 = 约300万/月
- D1存储: 5GB
- R2存储: 10GB

付费($5/月 Workers Paid):
- Workers: +$0.30/百万请求(前1000万免费)
- D1: +$0.75/十亿行读取
- D1: +$1.00/十亿行写入
- D1存储: +$0.75/GB-月

Cloudflare Workers+D1的组合特别适合:API网关、Webhook处理器、短链接服务、后端、小程序后端等场景。对于日请求量在百万以下的项目,免费额度完全够用。

常见问题

为什么选Cloudflare Workers+D1

>为什么选Cloudflare Workers+D1 对比项 CF Workers+D1 AWS Lambda+DynamoDB Vercel+PlanetScale Fly.io+SQLite 免费请求/天 100,000 1,000,000 100,000 无免费 免费存储 5GB 25GB 5GB 3GB 冷启动 &lt;1ms 100-500ms 50-200ms 0ms 边缘节点 300+城市 20+区域 10+区域 30+区域 月费(生产) $5 $20+ $29+ $10+ 学习曲线 ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐

评论