EdgeOne Makers 最佳实践
前面学了那么多 EdgeOne Makers 的功能,写代码、搭数据库、部署上线都没问题。但真要把项目丢到生产环境跑起来,你大概率会遇到一堆问题。
项目文件越堆越乱怎么组织? 接口慢了怎么排查? API Key 放代码里被爬了怎么办? 账单突然暴涨怎么控制? 这些问题靠”能跑就行”是搞不定的。
今天这篇文章就把实战里踩过的坑和总结的经验一次性讲清楚,覆盖六个方面: 项目结构、性能优化、安全防护、成本控制、常见问题、生产部署。你可以当速查手册用,遇到问题翻一翻。
项目结构最佳实践
刚开始写 demo 的时候,把所有东西塞一个文件里没啥问题。一旦项目超过 10 个页面、20 个接口,目录乱了你找代码都找不到。
推荐的项目结构
先看一个比较通用的目录组织方式:
my-edgeone-project/
├── edgeone.json # 项目配置文件
├── functions/ # Edge Functions 云函数目录
│ ├── api/ # API 接口按业务分组
│ │ ├── user/
│ │ │ ├── login.js # 登录接口
│ │ │ └── profile.js # 用户资料接口
│ │ └── article/
│ │ ├── list.js # 文章列表
│ │ └── detail.js # 文章详情
│ ├── middleware/ # 中间件
│ │ ├── auth.js # 鉴权中间件
│ │ └── logger.js # 日志中间件
│ └── utils/ # 工具函数
│ ├── response.js # 统一响应格式
│ └── validate.js # 参数校验
├── public/ # 静态资源
│ ├── css/
│ ├── js/
│ └── images/
└── database/ # 数据库迁移脚本
├── migrations/
└── seeds/
文件命名规范
| 类型 | 命名规则 | 示例 |
|---|---|---|
| 云函数文件 | 小写加横线 | user-login.js |
| 中间件 | 功能名加 .js |
auth.js |
| 工具函数 | 功能名加 .js |
response.js |
| 静态资源 | 和前端框架保持一致 | main.css |
统一响应格式
项目里每个接口返回的数据格式应该统一,前端才好处理:
// functions/utils/response.js
// 成功响应
export function success(data, message = 'ok') {
return new Response(
JSON.stringify({
code: 0,
message,
data
}),
{
headers: {
'Content-Type': 'application/json'
}
}
);
}
// 失败响应
export function fail(message = 'error', code = -1, httpStatus = 400) {
return new Response(
JSON.stringify({
code,
message,
data: null
}),
{
status: httpStatus,
headers: {
'Content-Type': 'application/json'
}
}
);
}
用的时候直接引入:
// functions/api/user/login.js
import { success, fail } from '../../utils/response.js';
export default async function handler(request, env) {
try {
const body = await request.json();
if (!body.username || !body.password) {
return fail('用户名和密码不能为空');
}
// ... 业务逻辑
return success({ token: 'xxx' }, '登录成功');
} catch (e) {
return fail('服务器内部错误', -1, 500);
}
}
统一返回格式之后,前端只要判断 code === 0 就知道成功了,不用每个接口单独处理。
性能优化技巧
接口响应慢是用户流失的头号原因。EdgeOne Makers 跑在边缘节点上,本身就比传统服务器快不少,但还是有优化空间。
缓存策略
不是所有数据都需要每次都从数据库查。能缓存的就缓存起来:
// functions/api/article/list.js
export default async function handler(request, env) {
// 先从缓存读
const cacheKey = new URL(request.url).toString();
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
// 缓存没有,查数据库
const articles = await env.DB.prepare(
'SELECT id, title, summary FROM articles ORDER BY created_at DESC LIMIT 20'
).all();
response = new Response(
JSON.stringify({ code: 0, data: articles.results }),
{
headers: {
'Content-Type': 'application/json',
// 缓存 5 分钟
'Cache-Control': 'public, max-age=300'
}
}
);
// 写入缓存
await cache.put(cacheKey, response.clone());
}
return response;
}
缓存时间怎么选,看数据变化频率:
| 数据类型 | 建议缓存时间 | 说明 |
|---|---|---|
| 文章列表 | 5 分钟 | 更新不频繁 |
| 用户资料 | 1 分钟 | 用户可能修改 |
| 配置数据 | 1 小时 | 几乎不变 |
| 实时数据 | 不缓存 | 必须每次查最新 |
数据库查询优化
数据库查询慢是最常见的问题,几个要注意的点:
-- 加索引,查询速度能快几十倍
CREATE INDEX idx_articles_category ON articles(category_id);
-- 只查需要的字段,别用 SELECT *
SELECT id, title, summary FROM articles WHERE category_id = 1;
-- 分页查询用游标,比 OFFSET 快
SELECT id, title FROM articles
WHERE id > 100
ORDER BY id
LIMIT 20;
图片优化
如果你的项目有图片,图片体积是影响加载速度的重要因素:
// functions/middleware/image-optimize.js
export default async function handler(request, env) {
const url = new URL(request.url);
// 只处理图片路径
if (!url.pathname.startsWith('/images/')) {
return new Response('Not an image', { status: 404 });
}
// 获取原图
const response = await env.ASSETS.fetch(request);
const image = await response.arrayBuffer();
// 根据请求的 Accept 头决定输出格式
const accept = request.headers.get('Accept') || '';
let outputFormat = 'jpeg';
if (accept.includes('webp')) {
outputFormat = 'webp';
} else if (accept.includes('avif')) {
outputFormat = 'avif';
}
// 返回优化后的图片
return new Response(image, {
headers: {
'Content-Type': `image/${outputFormat}`,
'Cache-Control': 'public, max-age=86400'
}
});
}
| 优化手段 | 效果 | 适用场景 |
|---|---|---|
| WebP/AVIF 格式 | 体积减少 50%+ | 现代浏览器 |
| 设置长缓存 | 二次访问秒开 | 不常变的图片 |
| 懒加载 | 首屏加载快 | 页面下方图片 |
| 压缩质量到 80% | 肉眼无差别,体积减半 | 大部分场景 |
安全最佳实践
安全这事儿,不出事的时候觉得无所谓,出事了就是大事。API Key 泄露、SQL 注入、接口被刷,每一个都能让你半夜被叫醒。
密钥管理
最最重要的一条: 任何密钥都不要写死在代码里。
// ❌ 错误示范 - 密钥写在代码里
const API_KEY = 'sk-abc123xyz456';
const DB_PASSWORD = 'my_super_secret_password';
// ✅ 正确做法 - 使用环境变量
export default async function handler(request, env) {
const apiKey = env.API_KEY; // 从环境变量读取
const dbPassword = env.DB_PASSWORD;
// ...
}
EdgeOne Makers 的环境变量在控制台的项目设置里配置,支持加密存储,代码里只能通过 env.XXX 读取,不会被下载或泄露。
参数校验
永远不要信任用户输入的数据:
// functions/utils/validate.js
// 校验用户输入
export function validateUserInput(data) {
const errors = [];
// 用户名: 只允许字母数字下划线,3-20 位
if (!data.username || !/^[a-zA-Z0-9_]{3,20}$/.test(data.username)) {
errors.push('用户名格式不正确');
}
// 邮箱: 基本格式校验
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.push('邮箱格式不正确');
}
// 内容: 不能为空,最长 5000 字
if (!data.content || data.content.length > 5000) {
errors.push('内容不能为空且不超过 5000 字');
}
return errors;
}
用的时候:
import { validateUserInput } from '../utils/validate.js';
export default async function handler(request, env) {
const body = await request.json();
const errors = validateUserInput(body);
if (errors.length > 0) {
return new Response(
JSON.stringify({ code: -1, message: errors.join('; '), data: null }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
// 校验通过,继续处理...
}
防 SQL 注入
用参数化查询,不要用字符串拼接 SQL:
// ❌ 危险! 用户输入直接拼进 SQL
const query = `SELECT * FROM users WHERE name = '${req.body.name}'`;
// 如果 name 传入 "' OR '1'='1" 就完蛋了
// ✅ 安全! 用参数化查询
const result = await env.DB.prepare(
'SELECT * FROM users WHERE name = ?'
).bind(req.body.name).first();
请求限流
防止接口被恶意刷:
// functions/middleware/rate-limit.js
// 简单的内存限流,适合小规模项目
const requestCounts = new Map();
export default async function rateLimit(request, env) {
const ip = request.headers.get('cf-connecting-ip') || 'unknown';
const now = Date.now();
const windowMs = 60 * 1000; // 1 分钟窗口
const maxRequests = 30; // 最多 30 次
if (!requestCounts.has(ip)) {
requestCounts.set(ip, { count: 0, startTime: now });
}
const record = requestCounts.get(ip);
// 窗口过期就重置
if (now - record.startTime > windowMs) {
record.count = 0;
record.startTime = now;
}
record.count++;
if (record.count > maxRequests) {
return new Response(
JSON.stringify({ code: -1, message: '请求太频繁,请稍后再试' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '60'
}
}
);
}
// 放行,继续处理
return null;
}
安全头
给每个响应加上安全相关的 HTTP 头:
// functions/middleware/security-headers.js
export default async function handler(request, env, ctx) {
const response = await ctx.next();
// 设置安全头
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}
各安全头的作用:
| 安全头 | 作用 | 防什么 |
|---|---|---|
| X-Content-Type-Options | 禁止浏览器猜测文件类型 | MIME 嗅探攻击 |
| X-Frame-Options | 禁止页面被 iframe 嵌套 | 点击劫持 |
| X-XSS-Protection | 开启浏览器 XSS 过滤 | 跨站脚本攻击 |
| Referrer-Policy | 控制 Referer 头发送策略 | 信息泄露 |
成本控制建议
EdgeOne Makers 免费额度很够用,但不注意的话超额是分分钟的事。
免费额度对比
| 资源 | 免费额度 | 超出后计费 |
|---|---|---|
| Functions 调用次数 | 100 万次/月 | 按次计费 |
| D1 数据库读取 | 50 亿行/月 | 按行数计费 |
| D1 数据库写入 | 1 亿行/月 | 按行数计费 |
| D1 存储空间 | 5 GB | 按 GB 计费 |
| 静态资源流量 | 无限制 | 免费 |
减少数据库读取
数据库读取是最容易超额的。几个实用技巧:
// ❌ 循环里查数据库 - 100 篇文章就是 100 次查询
for (const article of articles) {
const author = await env.DB.prepare(
'SELECT name FROM users WHERE id = ?'
).bind(article.author_id).first();
article.author = author;
}
// ✅ 一条 SQL 搞定 - 用 JOIN 一次性查完
const result = await env.DB.prepare(`
SELECT a.*, u.name as author_name
FROM articles a
LEFT JOIN users u ON a.author_id = u.id
ORDER BY a.created_at DESC
LIMIT 20
`).all();
| 优化手段 | 节省效果 | 实现难度 |
|---|---|---|
| JOIN 代替循环查询 | 节省 90%+ 读取 | 低 |
| 缓存热点数据 | 节省 50%+ 读取 | 低 |
| 分页而不是全量拉取 | 节省 80%+ 读取 | 低 |
| 前端去重请求 | 节省 30%+ 调用 | 中 |
减少 Functions 调用
// ❌ 每次请求都触发函数
// 前端: 页面加载请求一次,滚动请求一次,切换 tab 又请求一次
// ✅ 前端做好请求合并和防抖
// 用 debounce 防抖,用户停止操作后才发请求
let timer = null;
function searchArticles(keyword) {
clearTimeout(timer);
timer = setTimeout(async () => {
const res = await fetch(`/api/article/search?q=${keyword}`);
// ...
}, 500); // 停止输入 500ms 后才请求
}
监控用量
在 edgeone.json 里可以设置告警:
{
"name": "my-project",
"alerts": {
"function_invocations": {
"threshold": 800000,
"action": "notify"
},
"d1_reads": {
"threshold": 4000000000,
"action": "notify"
}
}
}
建议把告警阈值设在免费额度的 80%,留个缓冲。
常见问题与解决方案
开发过程中踩过的坑,列出来帮你省时间。
问题一: 环境变量读不到
// 代码里这样写,结果拿到 undefined
const key = env.MY_SECRET_KEY;
// 原因: 环境变量的名字写错了,或者没在当前环境配置
// 解决步骤:
// 1. 去控制台检查变量名有没有拼写错误
// 2. 确认变量在 dev 还是 prod 环境配置的
// 3. 本地开发用 .dev.vars 文件配置
本地开发时创建 .dev.vars 文件:
# .dev.vars (这个文件不要提交到 Git!)
API_KEY=your_local_test_key
DB_PASSWORD=local_test_password
问题二: 数据库连接超时
// 错误: 没有设置超时时间,卡住了
const result = await env.DB.prepare('SELECT * FROM articles').all();
// 正确: 加上 AbortController 控制超时
export default async function handler(request, env) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 秒超时
try {
const result = await env.DB.prepare(
'SELECT id, title FROM articles LIMIT 20'
).all();
clearTimeout(timeoutId);
return new Response(JSON.stringify({ code: 0, data: result.results }), {
headers: { 'Content-Type': 'application/json' }
});
} catch (e) {
clearTimeout(timeoutId);
if (e.name === 'AbortError') {
return new Response(
JSON.stringify({ code: -1, message: '查询超时,请稍后重试' }),
{ status: 504, headers: { 'Content-Type': 'application/json' } }
);
}
throw e;
}
}
问题三: CORS 跨域问题
// 前端调接口报 CORS 错误
// 解决: 在 Edge Function 里设置正确的 CORS 头
export default async function handler(request, env) {
// 处理预检请求 (OPTIONS)
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': 'https://yourdomain.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400' // 预检结果缓存 1 天
}
});
}
// 正常处理请求
const response = await handleRequest(request, env);
// 给响应也加上 CORS 头
response.headers.set(
'Access-Control-Allow-Origin',
'https://yourdomain.com'
);
return response;
}
注意 Access-Control-Allow-Origin 不要设成 *,指定具体的域名才安全。
问题四: 静态资源 404
// 访问 /images/logo.png 返回 404
// 检查清单:
// 1. 文件确实在 public/images/ 目录下
// 2. 文件名大小写是否一致 (Linux 区分大小写)
// 3. edgeone.json 里有没有正确配置 assets 路由
edgeone.json 里静态资源配置:
{
"name": "my-project",
"assets": {
"directory": "./public",
"binding": "ASSETS"
}
}
问题五: 函数冷启动慢
// ❌ 全局作用域做耗时操作,每次冷启动都慢
const data = await fetch('https://api.example.com/config').then(r => r.json());
// ✅ 懒加载,第一次用到时才初始化
let configCache = null;
async function getConfig(env) {
if (!configCache) {
configCache = await env.CONFIG.get('app_config');
if (!configCache) {
configCache = await fetch('https://api.example.com/config')
.then(r => r.json());
await env.CONFIG.put('app_config', JSON.stringify(configCache));
} else {
configCache = JSON.parse(configCache);
}
}
return configCache;
}
生产环境部署建议
开发环境跑通了不代表生产环境没问题,上线前把这些检查一遍。
上线前检查清单
| 检查项 | 说明 | 重要程度 |
|---|---|---|
| 环境变量已配置 | prod 环境的所有密钥都配好 | 必须 |
| 数据库迁移已执行 | 线上数据库表结构是最新的 | 必须 |
| CORS 域名已限制 | 不要在生产环境用 * |
必须 |
| 错误处理完善 | 所有接口有 try-catch | 必须 |
| 日志已接入 | 能看到线上报错信息 | 建议 |
| 缓存策略已设置 | 热点数据有缓存 | 建议 |
| 限流已开启 | 关键接口有频率限制 | 建议 |
多环境管理
开发环境和生产环境要分开,别在开发时改了线上数据:
# 本地开发 - 使用 dev 环境
npx edgeone dev
# 部署到生产 - 使用 prod 环境
npx edgeone deploy --env production
edgeone.json 里区分环境:
{
"name": "my-project",
"env": {
"dev": {
"vars": {
"API_BASE": "https://dev-api.example.com",
"DEBUG": "true"
}
},
"production": {
"vars": {
"API_BASE": "https://api.example.com",
"DEBUG": "false"
}
}
}
}
日志记录
生产环境一定要有日志,不然出了问题两眼一抹黑:
// functions/middleware/logger.js
export default async function logger(request, env, ctx) {
const start = Date.now();
// 拿到下游处理的结果
const response = await ctx.next();
const duration = Date.now() - start;
const ip = request.headers.get('cf-connecting-ip') || 'unknown';
// 记录请求日志
const log = {
time: new Date().toISOString(),
method: request.method,
url: request.url,
status: response.status,
duration: `${duration}ms`,
ip
};
// 错误请求重点标记
if (response.status >= 400) {
console.error(JSON.stringify(log));
} else {
console.log(JSON.stringify(log));
}
return response;
}
灰度发布
大版本更新建议先让一小部分流量走新版本:
// 在路由层做灰度分发
export default {
async fetch(request, env) {
const url = new URL(request.url);
// 10% 的流量走新版接口
if (url.pathname.startsWith('/api/v2/')) {
const random = Math.random();
if (random < 0.1) {
// 走新版逻辑
return handleV2(request, env);
}
}
// 其余走老版
return handleV1(request, env);
}
};
先灰度 10%,观察一两天没报错,再逐步调到 50%、100%。
速查卡片
| 要点 | 说明 |
|---|---|
| 统一响应格式 | 所有接口返回 { code, message, data } 结构 |
| 密钥用环境变量 | 任何密钥不写死在代码里,通过 env.XXX 读取 |
| 参数化查询 | SQL 用 ? 占位符加 .bind() 绑定参数,防注入 |
| 缓存热点数据 | 文章列表、配置等不常变的数据加 Cache-Control |
| JOIN 代替循环查询 | 一条 SQL 搞定关联数据,减少数据库读取次数 |
| 请求限流 | 关键接口加频率限制,防止被恶意刷 |
| 多环境隔离 | dev 和 production 的环境变量、数据库分开配置 |
| 上线前检查清单 | 环境变量、CORS、错误处理、日志、缓存逐项确认 |