☰
PHP 代理脚本:按国家代码动态改写 API version 参数
2026/10/14 15:31:28 网站建设 项目流程

做海外 API 对接的时候,最头疼的往往不是接口本身,而是“同一个接口,不同地区要传不同的参数”。我前阵子就接手了一个活儿:上游服务商提供了一套 API,但不同国家/地区的客户端请求需要携带不同的 version 参数,比如日本区域要传 v2,欧美区域传 v3,其他地区走 v1 默认。客户端又不是我们自家维护的,没法指望 App 发版来解决。最干净的办法,就是自己在中间加一层 PHP 代理脚本,在转发请求之前,根据拿到的国家代码动态改写 query 里的 version 参数。如果你也遇到类似“接口一样、参数按地区切”的破事,这篇文章应该正好能用上。

先声明一下:这里说的“代理”,是一个纯粹的 API 请求转发层,也就是把客户端的请求原样接住、根据规则改掉参数、再转发给上游服务端,跟网络传输链路里的那种代理工具完全是两码事。本文只聊 PHP 脚本如何实现按国家代码改写 API 参数这件事,不涉及任何网络链路的代理、加速类内容。

1. 为什么要做"按国家代码切换version参数"

1.1 多区域版本不一致的三个典型场景

先说场景。单纯的"一个 API 一个 version"根本不需要代理脚本,nginx 里写死一个参数就完事了。真正麻烦的是业务上出现了区域维度差异。

第一个典型场景是区域灰度发布。通常灰度是先放一个地区试水,验证没问题再扩量。但这个试水期间,下游服务的版本号在物理上就是跟默认环境不一样的。这时候线上客户端不可能分两批发版,只能在请求链路里找一个"让部分流量走新版本、其他流量走旧版本"的开关——国家代码恰好是最天然的分流维度。

第二个场景是区域定制版本。有些地区因为当地规范要求、或者业务合作方指定的技术协议不一样,API 的数据格式、接口字段会有差异,但外层路由还是同一个地址。这种定制版的 version 参数就会跟标准版本有区别。

第三个场景是 A/B 实验。很多时候实验不想按用户比例抽,而是想按区域切,比如北欧地区先切新算法,观察一段时间再扩大到别的区域。用代理脚本切 version 参数,比在服务端硬编码区域白名单要灵活,因为服务端根本不知道来的是哪个区域的请求,它只看得到 version 参数。

1.2 为什么不在客户端改、非要加一层代理

我知道你肯定想:这破事在客户端 if 一下国家代码不就完事了吗?问题就在这里——客户端拿到的往往是"自己设备所在的国家",跟"业务上该走哪个地区版本"经常不是一回事。比如一个日本用户去美国出差,App 里的地区设置还是日本,但他的出口 IP 已经在美国了。上游 API 要根据 IP 归属地来判定合规区域,客户端自己传的 version 根本对不上。

再说,改客户端就意味着发版、审核、用户升级,一套流程下来少说一两周。中间还有不同版本的 App 共存,老版本客户端永远带着老的 version 逻辑去请求,你后端根本没有办法强迫它升级。放到代理层就不一样了,规则一变,所有客户端下一毫秒就生效了,不需要等任何用户手动升级。

1.3 什么时候不需要这套方案

也不是说什么场景都值得上代理。如果你们只有一个地区、一个服务端、version 参数全球统一,那直接让 API 网关配一个固定参数就行,加一层 Nginx 转发都嫌多余。另外,如果上游 API 本身就支持通过请求头或者 IP 直接识别区域,压根不依赖 version 参数,那也不需要折腾代理。这套方案真正适用的,是"上游 API 只认 query 里的 version 参数,且不同区域的 version 值确实不一样"的场景。

2. 国家代码从哪来、version 怎么映射:前置设计

2.1 获取国家代码的三种方式对比

动手写代码之前,需要先定一个关键问题:代理脚本用什么去判断"这个请求属于哪个国家"?我在实际项目里见过三种做法,各有各的适用场景:

方式原理优点缺点推荐场景
GeoIP 识别按请求来源 IP 查询归属地对客户端透明,无需改动受 IP 库精度影响,偶尔误判通用场景、Web/H5 请求
网关写入请求头前置网关解析后写入 X-Geo-Country可信度高,代理层只读头即可依赖网关能力,需先接入已有 API 网关的统一架构
客户端显式声明客户端传 country_code 参数判定精准可被伪造,不能单独信任需登录态的 App 场景

我最终的方案是把三种方式组合起来,优先级从高到低:网关写入的请求头 → GeoIP 识别 → 默认值。为什么网关头优先?因为网关是最靠近流量入口的,它拿到的 IP 就是真实客户端 IP,而且网关厂商自带的 IP 库通常比你自己部署的更新得勤。客户端声明的 country_code 我一般只用来打日志,不做最终判定,因为伪造太容易了。

2.2 版本映射策略:先有规则,再写代码

代码永远是最简单的部分,真正需要想清楚的是映射策略。我是用"精确匹配 + 前缀匹配 + 默认版本"三级来做的。

精确匹配就是类似JP => v2、US => v3这种一一对应的关系表。前缀匹配解决的是大区维度的统一规则,比如所有欧洲国家都走eu-v1,并不需要把几十个国家全部列出来——只需要判断国家代码是不是以E开头?不对,欧洲国家代码并没有统一前缀规律,所以更合理的做法是在配置里显式定义一个大区列表。第三种默认版本是兜底策略,比如version=latest,保证任何没有命中的区域都能拿到一个可用的版本号,不会白屏。

关于 version 参数值是写死还是动态获取,我建议量少就写死在配置文件里。如果版本列表经常变动,可以考虑从上游拉取一个版本清单存到缓存,但那属于进阶玩法,初期直接用配置文件最简单,改动也最可控。

2.3 整个请求的处理顺序

处理一个请求的逻辑是这样的:客户端请求进来 → 从请求头或 IP 得到国家代码 → 查映射表得到 version → 把原请求 query 里的 version 替换掉(没有就加上) → 转发到上游 → 把上游响应原样返回给客户端。整个过程中,请求方法、请求体、关键业务请求头都要原样透传,只改 query 里的 version 参数。这一点听起来简单,实际写的时候经常有人不小心把整个 query 串重新拼装时把别的参数弄丢了,尤其要注意。

3. 核心实现:PHP 代理脚本的完整代码

3.1 文件结构与运行环境

既然要贴代码,就贴一套能直接落地的东西。我的项目结构是:

proxy/ ├── public/ │ └── proxy.php # 对外入口,唯一接收请求的文件 ├── src/ │ ├── GeoLocator.php # 根据 IP 识别国家代码 │ └── VersionMapper.php # 根据国家代码决定 version 参数 ├── config/ │ └── version_map.php # 映射规则配置文件 ├── cache/ │ └── geo_cache.php # IP 归属地缓存文件 └── logs/ └── proxy.log # 运行日志

运行环境不需要很复杂,PHP 8.0+ 即可,扩展只需要 curl、json、file_put_contents。如果你追求更好的性能,可以改成 Swoole 常驻内存版本,但 PHP-FPM 加文件缓存的组合在日请求量几十万的规模下是完全够用的。我下面给的代码是基于 PHP-FPM 写的,方便大多数人直接部署。

3.2 映射规则配置文件

<?php // config/version_map.php // 返回一个关联数组,包含默认版本、精确映射表、区域映射表 return [ // 兜底版本:未命中任何规则时使用 'default_version' => 'v1', // 精确映射:国家代码 => version 'exact_map' => [ 'JP' => 'v2', 'KR' => 'v2', 'US' => 'v3', 'CA' => 'v3', ], // 区域映射:一组国家代码 => version 'region_map' => [ 'EU_VERSIONS' => [ 'countries' => ['DE', 'FR', 'IT', 'ES', 'NL', 'SE', 'PL'], 'version' => 'v4', ], 'APAC_VERSIONS' => [ 'countries' => ['SG', 'MY', 'TH', 'VN', 'PH', 'ID'], 'version' => 'v5', ], ], // 是否强制覆盖客户端传来的 version 参数 'force_override' => true, ];

force_override这个开关值得多说一句。如果设成 false,那么客户端请求里如果已经带了 version 参数,代理就直接放行,不做改写;只有没带 version 的时候才按国家代码补充。实际项目中我建议先设成 true,因为既然已经决定按区域切换,就不应该允许客户端自行覆盖,否则之前做的区域灰度就失效了。等规则稳定后,再考虑改成 false 放行一部分个性化流量。

3.3 根据 IP 识别国家代码封装

<?php // src/GeoLocator.php class GeoLocator { private string $dbfile; private string $cacheFile; private int $cacheTtl; public function __construct(string $dbfile, string $cacheFile, int $cacheTtl = 86400) { $this->dbfile = $dbfile; $this->cacheFile = $cacheFile; $this->cacheTtl = $cacheTtl; } public function locate(string $ip): string { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false) { return 'UNKNOWN'; } $cache = $this->readCache(); if (isset($cache[$ip])) { return $cache[$ip]; } $country = $this->queryFromDatabase($ip); $cache[$ip] = $country; $this->writeCache($cache); return $country; } private function queryFromDatabase(string $ip): string { // 这里读取本地的 IP 归属地数据库,返回 ISO 3166-1 alpha-2 国家代码。 // 以常见的开源 GeoIP 库为例: // $reader = reader_open($this->dbfile); // $record = reader_lookup($reader, $ip); // return $record['country']['iso_code'] ?? 'UNKNOWN'; return 'UNKNOWN'; } private function readCache(): array { if (!file_exists($this->cacheFile)) { return []; } $data = json_decode(file_get_contents($this->cacheFile), true); return is_array($data) ? $data : []; } private function writeCache(array $cache): void { // 只保留最近 10000 条记录,防止缓存文件无限膨胀 if (count($cache) > 10000) { $cache = array_slice($cache, -8000, 8000, true); } file_put_contents($this->cacheFile, json_encode($cache), LOCK_EX); } }

代码里我刻意留了一个queryFromDatabase的空实现,因为不同的 IP 库调用方式不同,但整体模式是一样的:先查缓存,命中就直接返回;没命中再去查数据库,查到后写回缓存。缓存是整个脚本性能的命门,这一点到后面第 4 节再展开。

3.4 核心版本映射类

<?php // src/VersionMapper.php class VersionMapper { private array $config; public function __construct(array $config) { $this->config = $config; } public function resolve(string $countryCode): string { $countryCode = strtoupper($countryCode); $config = $this->config; // 1. 精确匹配优先 if (isset($config['exact_map'][$countryCode])) { return $config['exact_map'][$countryCode]; } // 2. 区域匹配 foreach ($config['region_map'] as $region) { if (in_array($countryCode, $region['countries'], true)) { return $region['version']; } } // 3. 兜底默认版本 return $config['default_version']; } }

这个类没啥技术含量,但它是整个方案的"决策中枢"。你需要把精确匹配、区域匹配、默认版本这三个优先级在注释里写清楚,不然三个月后你自己回来看都会忘。

3.5 代理脚本主入口

这是整个方案的核心,我把它拆成几个步骤来讲。

<?php // public/proxy.php require __DIR__ . '/../src/GeoLocator.php'; require __DIR__ . '/../src/VersionMapper.php'; $config = require __DIR__ . '/../config/version_map.php'; $geo = new GeoLocator( '/data/geoip/geolite2-country.mmdb', __DIR__ . '/../cache/geo_cache.php' ); $mapper = new VersionMapper($config); // 1. 读取客户端 IP,支持常见代理头(按需信任) $clientIp = $_SERVER['REMOTE_ADDR'] ?? ''; if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $clientIp = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]); } // 2. 解析国家代码:优先取网关写好的请求头 $countryCode = strtoupper($_SERVER['HTTP_X_GEO_COUNTRY'] ?? ''); if ($countryCode === '') { $countryCode = $geo->locate($clientIp); } // 3. 解析原始请求参数 $query = $_GET; // 4. 确定 version 参数 $finalVersion = $mapper->resolve($countryCode); if ($config['force_override'] || !isset($query['version'])) { $query['version'] = $finalVersion; } // 5. 重建转发 URL $forwardUrl = 'https://api.upstream.example.com' . $_SERVER['REQUEST_URI']; $forwardUrl = preg_replace('/\?.*/', '', $forwardUrl) . '?' . http_build_query($query); // 6. 使用 cURL 转发请求,透传请求方法、请求体、关键请求头 $ch = curl_init(); $headers = []; if (!empty($_SERVER['HTTP_AUTHORIZATION'])) { $headers[] = 'Authorization: ' . $_SERVER['HTTP_AUTHORIZATION']; } if (!empty($_SERVER['CONTENT_TYPE'])) { $headers[] = 'Content-Type: ' . $_SERVER['CONTENT_TYPE']; } curl_setopt_array($ch, [ CURLOPT_URL => $forwardUrl, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CUSTOMREQUEST => $_SERVER['REQUEST_METHOD'], CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 10, ]); if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH'], true)) { curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('php://input')); } $response = curl_exec($ch); $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); // 7. 记录访问日志 $logLine = sprintf( "[%s] %s %s country=%s version=%s upstream_status=%d\n", date('Y-m-d H:i:s'), $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $countryCode, $finalVersion, $statusCode ); file_put_contents(__DIR__ . '/../logs/proxy.log', $logLine, FILE_APPEND); // 8. 返回响应 if ($curlErr) { http_response_code(502); echo json_encode(['error' => 'upstream request failed']); exit; } http_response_code($statusCode); echo $response;

几个关键细节我需要单独拎出来讲讲。

第一是转发 URL 的重建。$_SERVER['REQUEST_URI']可能带着原先的 query,所以我用正则先去掉?之后的部分,再重新拼接。如果直接拿原串改,很容易把 query 里的其他参数搞乱。这个坑我踩过,当时线上出现过一次参数重复,排查了半天。

第二是透传请求体。只有 POST、PUT、PATCH 才有 body,GET 和 DELETE 一般不需要。php://input可以直接拿到原始请求体,不需要走$_POST,因为$_POST只能解析表单格式,拿不到 JSON 字符串。

第三是 CUSTOMREQUEST 的用法。如果你直接把$_SERVER['REQUEST_METHOD']传给 cURL 的CURLOPT_CUSTOMREQUEST,那理论上这个方法会被原样发送到上游。但要注意,如果方法是 GET 而你传了 POSTFIELDS,cURL 会自动把 Content-Type 改成 multipart/form-data,这会导致上游收到跟期望不一样的 Content-Type,所以一定要按方法区分是否传 POSTFIELDS。

3.6 错误处理与日志

代理层最忌讳的是"上游出错,代理也跟着懵"。我在上面代码里做了一层最简单的处理:如果 cURL 执行出错(超时、DNS 解析失败、连接被拒),直接统一返回 502,并且不打到正常流程里去。如果你需要更细的区分,可以按CURLE_OPERATION_TIMEDOUT返回 504,但这个可以后续慢慢加。

日志方面,我给每条记录都打上了国家代码、解析后的 version、上游返回状态码。这些信息在排查灰度问题时非常有价值——你可以一眼看出某条异常请求到底是从哪个国家来的,命中了哪条规则。反正我自己的经验是:日志字段宁可多打,也不要在出问题的时候发现没打全,回头补日志还得让流量再跑一遍。

4. 性能、缓存与安全:上线前的三道关

4.1 GeoIP 查询必须加缓存,否则会被打爆

很多人第一次写完这个脚本,压测的时候会发现性能很差,原因几乎都出在 GeoIP 查询上。如果你用的是本地数据库,每次请求都去磁盘读文件加载索引,就会产生大量的磁盘 I/O,QPS 稍微上来就直接把磁盘打满。如果是去请求外部 HTTP 接口做 IP 归属查询,那更惨,一次查询要几十毫秒,并发一高还有可能被上游限流。

我的做法是加两级缓存。第一级是进程内缓存,PHP-FPM 下可以用 APCu 或者 Memcached,存一个IP => 国家代码的小数组;第二级是文件缓存,也就是我在 GeoLocator 里写的cache/geo_cache.php文件。查询顺序是:APCu 未命中 → 查文件缓存 → 都未命中才查 IP 库。命中后的 TTL 我设成 24 小时,因为 IP 归属地本质上没有想象中变化得那么频繁。

4.2 转发请求的超时与重试

代理本身不产生数据,它只是个传话的,但传话的超时得把握好。我在代码里把CURLOPT_TIMEOUT设置成了 10 秒,这意味着整个请求从发起到拿到响应,最多等 10 秒。如果你发现上游接口经常超过 10 秒才返回,建议根据自己业务改到 15 秒或 20 秒,但不要设置成 0(无限等待),否则一个慢接口就能占满你所有的 FPM worker。

重试策略上,我只对"连接被拒"这类瞬时错误做一次重试,其他情况不重试。原因很简单:代理层盲目重试,到了上游可能造成同一笔交易的重复处理,万一上游没有做幂等,用户那边就要出大问题。

4.3 安全边界:防 SSRF、限流、白名单

代理脚本本质上是一个"请求转发器",这种中间层最怕两件事:被用来攻击上游、被用来转发非法内容。

第一层防护是限制上游地址。我们在代码里是把转发目标写死在业务逻辑里的https://api.upstream.example.com,这就已经隔绝了绝大多数 SSRF 风险。如果你需要支持多个上游,建议维护一份白名单域名列表,不要直接输出用户传来的 URL。

第二层防护是响应体不要加特殊头。如果你在代码里加了调试头、链路追踪头之类的,注意考虑会不会把自己的内网信息带出去。

第三层是限流。这里不能依赖 Nginx 去限,因为代理脚本里解析 IP 之后还要做 IP 库查询,本身消耗就比静态页面大。我建议至少在应用层做一个简单的按 IP 限流,比如 1 分钟超过 300 次的 IP 直接拒绝。当然这也可以依赖网关来做,但如果你没有网关,就只能自己在 PHP 里做。

另外提一句:不要把内部错误信息(比如curl_error的原文)直接返回给客户端。我曾经见过一个代理脚本把上游的报错详情透传给前端,结果把内部网络结构、接口地址全暴露了。统一在出口处包一层自己的错误格式,对内打日志,对外只返回错误码和一句笼统的提示。

5. 实测验证与踩坑记录

5.1 用 curl 模拟多国家请求来验证

脚本写完后,第一步是用 curl 直接模拟不同国家的请求去验证 version 切换逻辑是否正常。

# 模拟没有网关头的请求,依赖 GeoIP 识别本地 IP curl -i "https://proxy.example.com/api/items?page=1" # 模拟网关已经识别好国家代码的场景 curl -i -H "X-Geo-Country: JP" "https://proxy.example.com/api/items?page=1"

与此同时去查日志,重点看两个字段:country和version。如果 IP 本地归属是未知,country=UNKNOWN,那 version 应该落到默认值v1,不会报错。

第二个要验证的是"覆盖"逻辑。第一次用真实客户端传version=v99来测试,配合force_override=true,看代理是否把 v99 强制改写了。如果配置成 false,要注意保证原参数能透传,这往往是别人最容易忽略的边界条件。

第三个验证是请求体透传。发起一个 JSON POST 请求,到上游打印接收到的 body,检查字段是否完整、Content-Type 是否被意外改写。

5.2 跟线上流量放量前必查的四项清单

我自己上线前反复检查的就这四样,列成表给你们参考:

检查项预期结果检查方式
版本参数覆盖逻辑有规则的国家走对应版本,其他国家走默认版本用 H 头模拟 + 查日志
请求体透传JSON 字段不丢失、不改变 Content-Type写一个临时回显接口
日志记录完整country、version、上游 status、耗时均有记录批量请求后查 log 文件
超时处理上游无响应时返回 502,不挂起停掉上游服务再测一次

5.3 踩过的坑:IP 库版本与缓存过期时间

第一个坑是 IP 库太旧。我们一开始用的是很老的 IP 归属库,判断特别不准,日本用户被识别成韩国,导致走了韩国的 v2 接口,数据格式对不上,线上直接告警。后来换成了维护频率较高的开源数据库,并且设置了定时更新的 cron,问题才彻底解决。所以说,IP 库要么自己维护更新机制,要么就用支持自动更新的服务,不能一套库打天下。

第二个坑是文件缓存并发写。多个 PHP-FPM worker 同时写同一个缓存文件是有可能锁冲突的,我在file_put_contents里加了LOCK_EX,基本能避免,但如果你流量很大,建议还是换 Redis 做缓存更稳妥。文件缓存在我这边的量级下跑得好,不代表在你的量级下也能跑得好。

第三个坑是$_SERVER['HTTP_X_FORWARDED_FOR']的信任问题。如果这个请求头可以被客户端伪造,那别人随便填一个X-Forwarded-For: 1.1.1.1就能把自己伪装成任意国家。所以这个头必须只在可信网关后面才能信任,如果脚本被直接暴露公网,至少要校验来源 IP 是否来自网关的内网网段。

第四个比较隐蔽,是查询参数顺序导致的缓存 key 区别。?version=v1&page=2和?page=2&version=v1在语义上完全一样,但如果你拿整个 query 串做缓存 key,它们的 key 就不同了,缓存命中率会直线下降。这个我在排查缓存命中率时发现的,后来改为只取关键参数做 key。

5.4 上线后观察什么

上线不是终点。第一周我建议重点观察两个指标:一是代理层本身的错误率,二是上游不同 version 的调用量占比。如果发现某个地区的 version 调用量异常低,可能是 GeoIP 误判了;如果上游错误率升高,则要考虑是不是新 version 的字段格式不兼容,或者是缓存了过期的版本信息。

还有一个小技巧:在日志里额外记录一条country_from字段,标记这个国家代码是来自网关头还是 GeoIP 查询。这样万一跟上游扯皮"为什么给我打了这个 version",你能清楚地说明判断依据,而不是只能回一句"我们就是这么配的"。

我在实际部署中还有一个体会,就是宁可把映射表写在配置文件里,也不要写在数据库里。配置文件有版本管理,改错了可以立即回滚;数据库改错了你还得写回滚 SQL。等到规则多到配置文件难以维护时,可以考虑再接配置中心动态下发,但那是后话了。这套方案目前的运行状态非常稳定,代理进程的 CPU 占用一直很低,日志里的 version 命中记录跟业务后台的期望也完全吻合。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询