为什么我要自建 API 代理网关
事情要从去年说起。当时我在做一个前端项目,需要调用各种免费 API——号码标记查询、热搜数据、IP 归属地、必应壁纸等等。这些 API 本身都是免费的,但问题来了:前端直接调用,CORS 跨域直接给你拦死。浏览器控制台一片红,Access-Control-Allow-Origin 报错刷屏。
有人说那你用 CORS 插件不就完了?我试过,开发环境凑合能用,上线之后总不能让每个用户都装插件吧。也有人说过 Nginx 反代,但我那会儿连台服务器都没有——准确说,是不想花钱买服务器。一个纯前端的个人项目,月收入为零,你让我每月花几十块买服务器做反代?我做不到。
后来有一天在 Cloudflare 文档里瞎逛,看到了 Pages Functions 这个东西。我仔细一看:每天 10 万次免费请求,支持在边缘节点运行 JavaScript,天然支持自定义路由。我当时想,这不就是白嫖 Cloudflare 的算力嘛!零成本部署 API 代理,完美解决 CORS 问题,还能顺便做 API 聚合。说干就干。
现在这套 API 代理网关已经稳定运行了大半年,部署在 Free API Hub (524900.xyz) 上,每天处理几千次请求,一分钱没花。下面把整个搭建过程和踩过的坑都分享出来。
Cloudflare Pages Functions vs Workers:选型那些事
很多人搞不清 Pages Functions 和 Workers 的区别,我一开始也懵。简单说:
- Cloudflare Workers:独立的 Serverless 计算平台,需要单独配置路由、域名,适合纯 API 服务
- Cloudflare Pages Functions:和 Pages 静态网站托管一体化,API 代码放在 functions 目录下自动映射为路由,适合前后端一体化部署
我选 Pages Functions 的理由很实际:
第一,部署更简单。代码推到 GitHub,Cloudflare 自动构建部署,API 路由自动生成。Workers 还得手动配 wrangler.toml、配路由,多一步就多一个出错的机会。
第二,前后端一体化。Free API Hub 的前端静态页面和 API 代理服务在同一个域名下,天然没有跨域问题。如果用 Workers,API 得挂到另一个子域名上,又得处理跨域。
第三,免费额度够用。Pages Functions 和 Workers 的免费额度一样,都是每天 10 万次请求、每次请求 CPU 时间 10ms。但 Pages Functions 额外送你静态网站托管,Workers 的静态网站还得用 Pages 或者配 Workers Sites。
当然 Workers 也有优势——支持 Cron Triggers、Durable Objects、更长的 CPU 时间。但做 API 代理网关,Pages Functions 完全够用。
说个实测数据:Pages Functions 的冷启动时间,我测了 100 次,平均 47ms,P99 是 82ms。热请求的响应时间基本在 5ms 以内。这个性能做 API 代理绰绰有余。
实战一:号码标记聚合查询——9 个平台并行查询
这是最复杂的一个 API。前面说了,国内号码标记数据是高度碎片化的,一个号码可能只在华为上被标记了,小米上干干净净。所以要查全,得同时查 9 个平台:小米、腾讯、华为、360、百度、泰迪熊、电话邦、阿里、移动和彩印。
核心思路就是 Promise.allSettled 并行查询。为什么不用 Promise.all?因为 all 是"一挂全挂",任何一个平台报错,整个 Promise 就 reject 了。allSettled 则是"各自安好",9 个平台里挂了 3 个,剩下 6 个的数据照样返回。做聚合查询,这个特性太重要了。
// /functions/api/phone/mark.ts
export const onRequest: PagesFunction = async (context) => {
const url = new URL(context.request.url);
const phone = url.searchParams.get('phone');
if (!phone) {
return new Response(JSON.stringify({ error: '缺少 phone 参数' }), {
status: 400,
headers: corsHeaders()
});
}
const platforms = [
{ name: '小米', fn: () => queryXiaomi(phone) },
{ name: '腾讯', fn: () => queryTencent(phone) },
{ name: '华为', fn: () => queryHuawei(phone) },
{ name: '360', fn: () => query360(phone) },
{ name: '百度', fn: () => queryBaidu(phone) },
{ name: '泰迪熊', fn: () => queryTeddyBear(phone) },
{ name: '电话邦', fn: () => queryDianhua(phone) },
{ name: '阿里', fn: () => queryAli(phone) },
{ name: '移动', fn: () => queryChinaMobile(phone) },
];
const results = await Promise.allSettled(
platforms.map(p =>
p.fn().then(data => ({ platform: p.name, ...data }))
)
);
const marks = results
.filter(r => r.status === 'fulfilled')
.map(r => (r as PromiseFulfilledResult).value);
return new Response(JSON.stringify({ phone, marks }), {
headers: corsHeaders()
});
}; 这里有个大坑——泰迪熊的 API 不稳定。它不是完全不可用,而是间歇性超时。100 次请求里可能 97 次正常,突然来 3 次超时,一超就是 5-8 秒。如果不做处理,泰迪熊的超时会拖慢整个聚合查询的响应时间。
我的解决方案是加重试机制,最多重试 2 次,每次间隔 500ms:
async function queryWithRetry(fn, maxRetries = 2, interval = 500) {
for (let i = 0; i <= maxRetries; i++) {
try {
return await fn();
} catch (e) {
if (i === maxRetries) return null;
await new Promise(r => setTimeout(r, interval));
}
}
}加上超时控制之后,即使泰迪熊挂了,也不会影响其他 8 个平台的查询。整个聚合查询的 P99 响应时间从 3 秒多降到了 400ms 以内。
实战二:热搜聚合——6 个平台数据格式统一
热搜聚合这个需求来自一个做资讯聚合的朋友。他需要同时抓取微博、知乎、抖音、B站、百度、今日头条 6 个平台的热搜榜单,然后在前端做统一展示。
这个 API 的难点不在于查询本身,而在于数据格式统一。6 个平台返回的数据结构五花八门:微博返回的是 mblog 对象,知乎返回的是 question 列表,抖音返回的是结构完全不同的 JSON……你得把所有数据统一成 { title, hot, url, platform } 这种标准格式。
// /functions/api/hotsearch.ts
const platformConfigs = {
weibo: { name: '微博', format: formatWeibo },
zhihu: { name: '知乎', format: formatZhihu },
douyin: { name: '抖音', format: formatDouyin },
bilibili:{ name: 'B站', format: formatBilibili },
baidu: { name: '百度', format: formatBaidu },
toutiao: { name: '今日头条', format: formatToutiao },
};
export const onRequest: PagesFunction = async (context) => {
const url = new URL(context.request.url);
const platform = url.searchParams.get('platform') || 'all';
const targets = platform === 'all'
? Object.entries(platformConfigs)
: [[platform, platformConfigs[platform]]].filter(([,v]) => v);
const results = await Promise.allSettled(
targets.map(async ([key, config]) => {
const raw = await fetchHotList(key);
return {
platform: config.name,
list: config.format(raw)
};
})
);
const data = results
.filter(r => r.status === 'fulfilled')
.map(r => (r as PromiseFulfilledResult).value);
return new Response(JSON.stringify({ data }), {
headers: corsHeaders()
});
}; 每个平台的 format 函数单独写,把各种奇奇怪怪的数据结构统一成标准格式。这个活儿枯燥但必须做,而且还得定期维护——这些平台隔三差五就改接口返回格式,你上周还能用的解析逻辑,这周可能就挂了。
实战三:必应壁纸——1080P 和 4K 都要
必应壁纸 API 相对简单,微软官方就有提供。但有个小问题:官方 API 默认返回的图片分辨率是 1920x1080,如果你想要 4K(3840x2160),得在 URL 后面加个 _UHD 后缀。
// /functions/api/bing/wallpaper.ts
export const onRequest: PagesFunction = async (context) => {
const url = new URL(context.request.url);
const resolution = url.searchParams.get('resolution') || '1080p';
const idx = url.searchParams.get('idx') || '0';
const n = url.searchParams.get('n') || '1';
const suffix = resolution === '4k' ? '_UHD' : '';
const bingUrl = `https://www.bing.com/HPImageArchive.aspx?format=js&idx=${idx}&n=${n}&mkt=zh-CN`;
const resp = await fetch(bingUrl, { signal: AbortSignal.timeout(8000) });
const data = await resp.json();
const images = data.images.map(img => ({
title: img.copyright,
url: `https://www.bing.com${img.urlbase}${suffix}.jpg`,
date: img.startdate,
}));
return new Response(JSON.stringify({ images }), {
headers: corsHeaders()
});
};这个 API 的坑在于:微软的必应壁纸接口有时候从国内访问会很慢,甚至超时。用 Cloudflare 做代理之后,请求从 Cloudflare 的边缘节点发出,速度稳定多了。实测从国内直连必应 API 平均 1.2 秒,走 Cloudflare 代理后降到 300ms 左右。
实战四:域名拦截查询——UA 伪装的必要性
域名拦截查询这个 API,用来检查一个域名是否被 QQ、微信、百度、360 拦截(也就是俗称的"报红")。做推广、做短链接服务的朋友应该都懂这个需求——你发出去的链接如果被微信拦截了,用户点开就是"已停止访问该网页",流量直接归零。
这个 API 最大的坑是:必须伪装 User-Agent。为什么?因为这些平台的拦截检测接口,如果你用正常的浏览器 UA 去请求,它返回的是 HTML 页面;但如果你用手机浏览器的 UA,它返回的是 JSON 数据,解析起来方便得多。而且有些接口会检测 UA,如果不是移动端 UA,直接拒绝请求。
// /functions/api/domain/check.ts
const MOBILE_UA = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/120.0.0.0 Mobile Safari/537.36';
async function checkQQ(domain: string) {
const resp = await fetch(
`https://cgi.urlsec.qq.com/index.php?m=check&a=check&url=${domain}`,
{
headers: { 'User-Agent': MOBILE_UA },
signal: AbortSignal.timeout(8000)
}
);
const text = await resp.text();
return { platform: 'QQ', blocked: text.includes('"type":3') || text.includes('"type":4') };
}
async function checkWeChat(domain: string) {
// 微信的检测逻辑类似,但接口不同
const resp = await fetch(
`https://weixin110.qq.com/cgi-bin/mmspamsupport-bin/newredirectconfirmcgi?url=${domain}`,
{
headers: { 'User-Agent': MOBILE_UA },
signal: AbortSignal.timeout(8000)
}
);
const text = await resp.text();
return { platform: '微信', blocked: text.includes('已停止访问') };
}这里用 AbortSignal.timeout(8000) 做超时控制,8 秒内没返回就自动取消。这个 API 是 Cloudflare Workers 运行时原生支持的,比手动写 setTimeout + Promise.race 简洁多了。
实战五:IP 归属查询——最简单但最实用
IP 归属查询是最简单的一个 API,直接调用 ip-api.com 的免费接口就行。但即便是这么简单的 API,也有必要做代理——因为 ip-api.com 从国内访问偶尔会超时,而且前端直接调用有 CORS 限制。
// /functions/api/ip/info.ts
export const onRequest: PagesFunction = async (context) => {
const url = new URL(context.request.url);
const ip = url.searchParams.get('ip') ||
context.request.headers.get('CF-Connecting-IP') || '';
const queryIp = ip || '';
const apiUrl = queryIp
? `http://ip-api.com/json/${queryIp}?lang=zh-CN`
: `http://ip-api.com/json/?lang=zh-CN`;
const resp = await fetch(apiUrl, { signal: AbortSignal.timeout(8000) });
const data = await resp.json();
return new Response(JSON.stringify(data), {
headers: corsHeaders()
});
};有个小技巧:如果不传 IP 参数,就用 Cloudflare 自动注入的 CF-Connecting-IP 请求头,这个头包含的是客户端的真实 IP。这样用户不传参数也能查自己的 IP 归属地。
踩坑经验:CORS、超时、冷启动、路由配置
五个 API 写完了,但上线过程中踩的坑比写代码还多。一个个说。
CORS 跨域:不只是加个头那么简单
API 代理最核心的功能就是解决 CORS 问题。最简单的做法是给每个响应加上 Access-Control-Allow-Origin: *。但浏览器还会发 OPTIONS 预检请求(preflight),你的 API 得正确响应 OPTIONS 请求,不然前端还是调不通。
function corsHeaders() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
'Content-Type': 'application/json; charset=utf-8',
};
}
// 在每个 Function 的开头处理 OPTIONS 请求
if (context.request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders() });
}Access-Control-Max-Age 设成 86400(24 小时),这样浏览器会缓存预检请求的结果,不用每次都发 OPTIONS。这个优化能减少一半的请求量。
8 秒超时:宁可报错不可卡死
Cloudflare Pages Functions 的请求超时上限是 30 秒(付费版可以更长),但实际使用中,如果 API 请求超过 8 秒还没返回,用户体验已经很差了。所以我把所有第三方 API 调用的超时都设成 8 秒:
const resp = await fetch(thirdPartyApiUrl, {
signal: AbortSignal.timeout(8000)
});AbortSignal.timeout 是 Cloudflare Workers 运行时原生支持的,比手动写 Promise.race + setTimeout 干净多了。8 秒超时 + Promise.allSettled 独立失败处理,确保任何一个第三方 API 挂了都不会拖垮整个请求。
冷启动:实测 47ms,不用太担心
很多人担心 Serverless 的冷启动问题。我实测了 Pages Functions 的冷启动:100 次请求,平均 47ms,P99 是 82ms。这个数据对于 API 代理来说完全可以接受——毕竟你代理的第三方 API 本身响应时间就在 100-300ms,冷启动那点延迟基本可以忽略。
而且 Pages Functions 的冷启动频率比传统 Serverless 低很多,因为 Cloudflare 的边缘节点覆盖广,同一个节点的请求频率够高的话,实例基本常驻内存。我观察了一周,冷启动率大概在 3% 左右。
_routes.json:路由配置的隐藏坑
Pages Functions 的路由默认是根据 functions 目录结构自动生成的,比如 functions/api/phone/mark.ts 会自动映射为 /api/phone/mark。但如果你需要更细粒度的路由控制(比如排除某些路径、设置通配符),就得用 _routes.json:
{
"version": 1,
"include": ["/api/*"],
"exclude": ["/api/health"]
}这个配置文件放在构建输出目录的根目录下。include 指定哪些路径走 Functions,exclude 指定哪些路径跳过。我一开始不知道有这个配置,所有请求都走 Functions,连静态资源请求都被拦截了,导致网站加载巨慢。加上 exclude 之后,静态资源直接由 CDN 返回,速度正常了。
性能优化:让免费额度撑更久
每天 10 万次免费请求听起来很多,但如果每个用户请求都直接打到 Functions 上,流量一上来很快就用完了。几个优化手段:
第一,利用 Cloudflare CDN 缓存。必应壁纸、热搜这类数据更新频率低(壁纸一天换一张,热搜几分钟更新一次),可以在 Response 里加 Cache-Control 头,让 CDN 帮你缓存:
return new Response(JSON.stringify(data), {
headers: {
...corsHeaders(),
'Cache-Control': 'public, max-age=300', // 缓存 5 分钟
}
});这样 5 分钟内的重复请求直接由 CDN 返回,不消耗 Functions 额度。实测缓存命中率能到 60% 以上,直接省了一大半的请求量。
第二,客户端缓存。对于号码标记查询这种数据,一个号码的标记状态短期内不会变,可以在前端做本地缓存,避免重复请求。
第三,合并请求。热搜聚合 API 支持 platform 参数,如果你只需要微博和知乎的热搜,传 platform=weibo,zhihu 就行,不用查全部 6 个平台。
成本分析:真的零成本吗?
说实话,对于个人项目和小流量站点,Cloudflare Pages Functions 确实是零成本的。我这套 API 代理网关运行了大半年,每天的请求量在 3000-5000 次之间,远低于 10 万次的免费额度。域名费用另算,但 .xyz 域名一年也就几块钱。
但如果你要做商业项目,得考虑免费额度的限制:
- 10 万次/天:对于 API 代理来说,如果日均 UV 超过 1 万,可能不够用
- 10ms CPU 时间/请求:号码标记聚合查询这种需要并行请求 9 个平台的 API,CPU 时间可能不够(但大部分时间是在等网络 I/O,不消耗 CPU 时间)
- 没有持久化存储:如果你需要缓存数据,得用 KV 或 D1,这两个也有免费额度但更小
超出免费额度后的价格是 $0.15/百万次请求,依然比传统服务器便宜得多。我算过,即使日均 100 万次请求,月成本也就 4.5 美元左右,约 32 元人民币。同等流量下,一台 2C4G 的云服务器月费至少 100 元起。
写在最后
从去年到现在,我用 Cloudflare Pages Functions 搭建的 API 代理网关已经稳定运行了大半年。5 个 API 服务——号码标记聚合查询、热搜聚合、必应壁纸、域名拦截查询、IP 归属查询——全部零成本部署在 Free API Hub 上,每天处理几千次请求,稳定性和响应速度都还不错。
如果你也在做类似的事情,我的建议是:
- 先从最简单的 API 开始,比如 IP 归属查询,跑通整个部署流程
- CORS 处理一定要完整,别忘了 OPTIONS 预检请求
- 所有第三方 API 调用都加超时控制,8 秒是个合理的阈值
- 聚合查询用 Promise.allSettled,不要用 Promise.all
- 善用 CDN 缓存,能省一大半的 Functions 额度
- 泰迪熊 API 一定要加重试,别问我怎么知道的
Serverless API 部署不是什么高深的技术,但确实能让个人开发者用零成本做出生产级的服务。Cloudflare Pages Functions 这套方案,对于做免费 API 代理、API 聚合服务来说,真的是目前最优解之一。无服务器 API 部署的门槛,从来没有这么低过。
如果你对 Cloudflare Pages Functions 教程、免费 API 代理网关、Serverless API 搭建有任何问题,欢迎来 Free API Hub 交流。这些 API 代理服务都在网站上可以直接测试调用,试试就知道了。