简介:面向PHP开发者与网络测试、数据分析相关人员的省份IP生成工具包,围绕“按指定省份随机产出IPv4地址”这一需求,提供可直接复用的PHP类与配套说明。资源以PHP实现为核心,结合省份IP段数据完成IP字符串与32位整数之间的转换,再通过随机函数在目标省份的地址范围内取值并还原为标准点分十进制格式,可用于模拟访问、区域网络测试等场景。压缩包共85个文件,以83个PHP源码为主,另含1个txt说明与1个md文档,整体约110KB,体量轻便,便于查阅类结构、数据组织方式与调用思路;其中还涉及查询组件相关代码,可辅助理解依赖与扩展用法。目前已有301人学习,适合希望快速获得省份IP生成脚本、了解地理定位与随机IP实现细节的初中级开发者参考。
1. 一个 php 小类,救回“测试数据里 IP 全是同一个”的联调现场
上个月帮朋友看一个地域相关的后台,用户表里省份字段填得挺齐,可 IP 列清一色127.0.0.1,运营一看报表就反问:这堆“华东用户”怎么 IP 全在本机?后来他用一份随机 IP 生成脚本应付差事,结果 IP 段和省份对不上,反而更乱。这个 php 类要解决的就是这件事:给它一个省份键,它返一个落在该省份网段内的合法 IPv4,而不是闭着眼睛乱拼四段数字。适合两类人:做测试数据填充、埋点模拟、地域功能联调的后端;以及想拿一个结构完整、几百行就能读完的 php 类练手的人。它不连数据库、不调外部接口,纯本地数据 + 纯函数式取值,扔进任何 php 项目里require一下就能用。
2. 类的骨架与数据映射:从省份键到一个整数区间
先看构造,别急着读随机逻辑。这类小工具最容易翻车的地方不是随机算法,而是“数据长什么样”没定清楚,写到最后全是一个个if判断堆在一起。我的处理是:把“省份”抽象成一个人为的键(字符串),把“网段”抽象成一组整数闭区间,随机逻辑只跟整数打交道,跟省份名彻底解耦。
2.1 一个文件、一份数据表、三个公开方法
类的外形我一般控制在这几步:构造函数只做两件事——加载数据、编译成区间;公开方法只暴露one()、many()、exists();其余全是 private 静态工具。这样读源码的人从one()往下追,一层就到底。
<?php // src/ProvinceIp.php final class ProvinceIp { /** @var array<string, array<int, array{start:int,end:int,w:int}>> 省份键 => 区间列表 */ private array $ranges = []; /** @var array<int, array{0:int,1:int}> 需要跳过的保留段,按 start 升序 */ private array $deny = []; /** @var callable(int,int):int 随机源,注入后方便单测 */ private $rng; public function __construct(string $dataFile, string $denyFile = '', ?callable $rng = null) { $this->rng = $rng ?? static fn(int $a, int $b): int => random_int($a, $b); $this->loadData($dataFile); // 读 JSON,编译成整数区间 if ($denyFile !== '') { $this->loadDeny($denyFile); // 读黑名单,排序,供二分查找 } } public function exists(string $province): bool { return !empty($this->ranges[$province]); } public function one(string $province): string { /* 见第 4 章 */ } public function many(string $province, int $n): array { /* 见第 6 章 */ } }逻辑上分三段:构造期做“重活”(解析、编译、排序),调用期只做“查表 + 取随机数”,所以one()的耗时基本是一次二分加两次random_int,QPS 上不去才怪。参数说明一下:$dataFile是必须的,没数据这个类就是个空壳;$denyFile可选,不给就跳过黑名单校验;$rng是给测试留的口子,线上别传,默认的random_int用的是系统 CSPRNG,够用且不会被时间种子坑。
注意:
random_int抛Exception(熵源不可用),别在业务里裸调,外面套一层 try 或者在构造时决定降级策略。
2.2 省份 → 网段:为什么用 CIDR 存、用整数跑
数据文件我推荐存 CIDR 字符串数组,人读得懂、diff 好看、从离线 IP 归属地库导出来也方便。但运行期一定转成整数区间,原因很实在:字符串比大小是字典序,9.0.0.0会排在10.0.0.0后面,做区间判断必错;而整数比较没有这个问题,还顺便让“段内均匀随机”变成一次random_int($start, $end)。
{ "pn_01": ["100.64.10.0/24", "100.65.0.0/23", "101.12.32.0/19"], "pn_02": ["110.6.0.0/16", "110.7.128.0/17"], "pn_03": ["120.36.0.0/14"] }键名这里不写死,pn_01只是占位。实际项目里用拼音缩写、英文名或者行政区划码前两位都行,只要导数据时两边对得上。值里每一项是一条 CIDR,/24是 256 个地址,/19是 8192 个,权重天然不一样——这直接影响生成结果的分布,后面第 4 章会讲怎么用权重把它掰回来。
这里有个容易被忽略的设计取舍:是把所有省份的所有网段摊平成一个大数组,还是按省份分组。我选分组,因为实际调用几乎总是“给我某个省的 IP”,分组后单次查找只需要遍历几十条,用不着建索引;摊平的话每次都得全表扫,省那点内存不值。
2.3 随机策略:均匀、加权、拒绝采样,三选一
“随机生成”这四个字最含糊。段内均匀随机最直观,但数据本身是不均匀的:有的省份能导出三百条网段,有的只有八条,直接随机选段会让大省占满结果。所以我在区间结构里带了个w(weight),先按权重选段,再在段内均匀取点。这样最终分布近似“按网段容量加权”,跟真实用户的分布更接近。
第二层是拒绝采样。有些段里混着保留地址、组播段、广播地址,生成出来虽然是合法 IPv4,但拿去当业务数据一看就假。这时候把黑名单单独维护成一份区间表,取到点先查一遍,命中就重抽。重抽次数设上限(我给 8 次),超了就换段重来,避免在某条“脏段”里死循环。
第三层是去重,属于批量调用的场景,放在第 6 章讲。三层策略叠起来,整个类的复杂度仍然是一个文件几百行,但覆盖了“单条生成”“批量生成”“结果可信”三种诉求,这是我觉得这类小工具该有的边界。
3. 跑起来:解压、配数据、第一行调用
拿到.7z之后别急着找入口文件。这类单类工具通常没有index.php,包里就是src+data+ 一两个demo,跑通的标志是你能在命令行打出一串 IP 并且校验通过。
3.1 目录与文件清单
按经验,包里的结构大致是下面这样,对不上也不影响,认准“类文件”和“数据文件”就行:
province-ip/ ├── src/ProvinceIp.php # 核心类,唯一必须引入的文件 ├── data/province_cidr.json # 省份 -> CIDR 列表 ├── data/deny.txt # 可选,保留段黑名单,一行一条 CIDR ├── demo/cli.php # 命令行示例 └── demo/web.php # 浏览器示例环境要求不苛刻:PHP 7.4 以上(用到 typed property 和箭头函数),8.x 更稳;ext-json默认就编进去了;不需要数据库、不需要 Redis、不需要任何 Composer 依赖。这也是我推荐用它做练手项目的原因——没有环境噪音,所有坑都在代码里。
数据文件如果没有,就得自己生成。思路是从本地离线 IP 归属地库里把每个省份的网段导出来,写成上面那个 JSON。导出时记得过滤掉10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8、169.254.0.0/16、224.0.0.0/4这几类,归属地库偶尔会把它们挂在某个省份下面,混进来就是第 5 章要说的第一个坑。
3.2 最小可运行示例:CLI 与 Web 两条路
命令行版最适合先验证,不带任何框架和路由,出错信息直接打脸上:
<?php // demo/cli.php require __DIR__ . '/../src/ProvinceIp.php'; $gen = new ProvinceIp( __DIR__ . '/../data/province_cidr.json', __DIR__ . '/../data/deny.txt' ); // 一次生成 10 条,同时打印校验信息 foreach ($gen->many('pn_01', 10) as $ip) { printf("%-16s %s\n", $ip, filter_var($ip, FILTER_VALIDATE_IP) ? 'ok' : 'bad'); }跑php demo/cli.php,输出十行形如100.65.13.201 ok就算通了。这里many()的参数是“省份键 + 条数”,返回字符串数组;filter_var那一段是我习惯加的廉价自检,能挡住大部分“整数转字符串越界”的低级错误。
Web 版多一层输出头,别在浏览器里直接var_dump,中文和换行都会乱:
<?php // demo/web.php require __DIR__ . '/../src/ProvinceIp.php'; $gen = new ProvinceIp(__DIR__ . '/../data/province_cidr.json'); $ip = $gen->one($_GET['p'] ?? 'pn_01'); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['province' => $_GET['p'] ?? 'pn_01', 'ip' => $ip], JSON_UNESCAPED_UNICODE);php -S 127.0.0.1:8080 -t demo起内置服务器就能访问。参数p传省份键,缺省给个默认值,避免第一次访问就 500 吓人。
注意:Web 示例里的
$_GET['p']直接进one(),生产环境必须先做白名单校验,不认识的键一律走默认省份,别把用户输入当数组下标用。
3.3 用离线归属地库做自校验
生成器自己说“这 IP 属于某省”不算数,得让第三方数据说话。本地挂一个离线 IP 归属地库(数据文件放本地,查询不发网络请求),跑一轮回归:
<?php // tools/verify.php require __DIR__ . '/../src/ProvinceIp.php'; $gen = new ProvinceIp(__DIR__ . '/../data/province_cidr.json'); $db = new YourOfflineIpDb(__DIR__ . '/../data/ipdb.dat'); // 本地归属地库 $bad = 0; foreach (['pn_01', 'pn_02', 'pn_03'] as $province) { for ($i = 0; $i < 500; $i++) { $ip = $gen->one($province); $hit = $db->search($ip)['province'] ?? ''; if ($hit !== $province) { $bad++; fwrite(STDERR, "mismatch: {$province} -> {$ip} ({$hit})\n"); } } } printf("checked=%d bad=%d\n", 1500, $bad);这段是整套流程里最有价值的一步。它会同时暴露三类问题:数据文件本身不准、CIDR 转整数算错、黑名单没生效。我的验收线是bad为 0;如果只是零星几条,多半是归属地库把跨省边界段归到了隔壁,属于数据源差异,可以接受,但要记下来。
4. 核心代码拆解:整数化、加权取值与边界处理
到这儿功能是通的,接下来逐段看实现。这类工具最难写的从来不是“随机”,而是边界:32 位和 64 位的差异、/0和/32的极值、负数、权重溢出,随便一个都能让你在生产上看到0.0.0.0。
4.1 IPv4 与整数互转:两行代码里的三个坑
ip2long()在 32 位 PHP 上会返回负数,long2ip()在边界值上行为也不一致。我的写法是转换时统一成无符号整数,回程用pack()绕开函数差异:
<?php public static function ip2int(string $ip): int { $long = ip2long($ip); if ($long === false) { throw new InvalidArgumentException("不是合法 IPv4: {$ip}"); } // 32 位环境下 ip2long 可能给负数,统一转到无符号区间 [0, 2^32-1] return $long < 0 ? $long + 4294967296 : $long; } public static function int2ip(int $int): string { if ($int < 0 || $int > 4294967295) { throw new OutOfRangeException("超出 IPv4 范围: {$int}"); } // pack('N') 按大端写入 4 字节,交给 inet_ntop 格式化,避开 long2ip 的平台差异 return inet_ntop(pack('N', $int)); }$long + 4294967296这个魔数就是 2³²,把有符号区间平移到无符号区间。int2ip里我先做了范围断言,因为pack('N', ...)在越界时会静默截断——这是最阴的一类 bug,你不报错,它给你个看着正常的 IP。
参数说明:两个方法都只接受严格类型,PHP 8 下传null会直接TypeError,这比在函数体里做is_int判断干净得多。单位换算记住一句话:1 个/24是 256,1 个/16是 65536,/32是 1,/0是 2³²。
4.2 CIDR 转区间:掩码对齐与/0的溢出
每条 CIDR 都要在构造期编译成[start, end],这一步错了,后面全错:
<?php private static function cidr2range(string $cidr): array { [$ip, $bits] = array_pad(explode('/', $cidr, 2), 2, '32'); $bits = (int) $bits; if ($bits < 0 || $bits > 32) { throw new InvalidArgumentException("非法掩码: {$cidr}"); } $size = 1 << (32 - $bits); // /24 => 256, /32 => 1 $mask = ~($size - 1) & 0xFFFFFFFF; // 主机位清零,得到网络地址掩码 $start = self::ip2int($ip) & $mask; // 对齐到网络地址,容忍 100.64.10.7/24 这种写法 return [$start, $start + $size - 1]; }几个细节值得说。array_pad是为了兼容只写 IP 不写掩码的脏数据,默认按/32处理。& $mask那句是在纠错:数据源里经常出现100.64.10.7/24这种非网络地址写法,如果不对齐,区间起点就偏了,随机结果会渗到隔壁段去。0xFFFFFFFF是为了在 64 位 PHP 上把高 32 位清掉,否则~会给你一堆负的高位。
/0这个极值我特意没拦,1 << 32在 64 位 PHP 里是合法整数,能算对;真要拦就在上面加一条if ($bits === 0) throw,看你的数据源质量决定。
4.3 加权取段 + 段内均匀 + 黑名单拒绝采样
主流程三合一,代码不长,但每一行都有讲究:
<?php private function pickOnce(array $segments): int { // 1) 先按权重抽一条网段,权重默认取段容量,也可由数据文件指定 $total = 0; foreach ($segments as $s) { $total += $s['w']; } $hit = ($this->rng)(1, $total); $acc = 0; foreach ($segments as $s) { $acc += $s['w']; if ($hit <= $acc) { // 2) 段内均匀取点,random_int 两端都是闭区间 return ($this->rng)($s['start'], $s['end']); } } throw new LogicException('权重累计异常,检查 w 是否为 0'); } private function isDenied(int $ip): bool { // $this->deny 已按 start 升序排好,二分找最后一个 start <= ip 的区间 $lo = 0; $hi = count($this->deny) - 1; while ($lo <= $hi) { $mid = ($lo + $hi) >> 1; if ($this->deny[$mid][0] <= $ip) { $lo = $mid + 1; } else { $hi = $mid - 1; } } return $hi >= 0 && $ip <= $this->deny[$hi][1]; }pickOnce()里用“前缀和 + 命中区间”做加权,比mt_rand洗牌省内存,也不用预先把每个 IP 展开成数组——一个/14段展开就是 26 万个元素,纯属浪费。isDenied()用二分而不是foreach,因为黑名单少说几十上百条,每次生成都全量扫会拖垮批量场景;二分是O(log n),代价是构造期必须排好序,这个一次性开销很划算。
主循环把两者串起来:
<?php public function one(string $province): string { if (empty($this->ranges[$province])) { throw new OutOfRangeException("未收录的省份键: {$province}"); } $pool = $this->ranges[$province]; for ($i = 0; $i < 8; $i++) { $int = $this->pickOnce($pool); if (!$this->isDenied($int)) { return self::int2ip($int); } } // 连续 8 次命中黑名单,说明这段数据里脏段占比过高,直接报错比返回假数据好 throw new RuntimeException("省份 {$province} 的网段黑名单占比异常,请检查 deny.txt"); }8 次这个数字不是拍脑袋:假设脏段占比 10%,连中 8 次的概率是千万分之一,已经不可能发生;真发生了说明数据有问题,报错比默默返回一个内网地址强。参数上唯一要调的是重试次数,数据源干净的话调到 3 就够,追求极致性能甚至可以设 1 并靠数据治理保证质量。
5. 避坑与排查:五个真实翻车现场
下面这几条,每条我都在不同项目里见过至少一次。现象、原因、解决按顺序写,照着对号入座就行。
5.1 生成结果里混进内网地址
现象:批量生成一万条,里面偶尔冒出一两条10.x、192.168.x,前端拿去展示被当成异常数据。
原因:归属地库对保留地址的处理不一致,有些版本会把10.0.0.0/8的一部分挂在某个省份下面;另外数据导出脚本用正则粗筛时,10.开头的判断容易把100.x、101.x也一起误伤或者漏掉。
解决:不要靠正则筛,统一走isDenied()的黑名单机制,把 RFC 保留段一次性写进deny.txt,用 CIDR 表示,加载时编译成整数区间。顺手在回归脚本里加一条断言:生成的 IP 不允许落在10/8、172.16/12、192.168/16、127/8、169.254/16、0/8、224/4里。
5.2 同一个省份每次刷新都出同一批 IP
现象:页面上点“换一批”,出来的 IP 高度重复,看着像写死了。
原因有两个。一个是数据文件里那个省份只收录了一两个/29级别的小段,可用地址一共几十个,随机池就这么大,怎么抽都重。另一个是把mt_srand()换成了固定种子做“可复现测试”,上线忘了删,PHP-FPM 每个进程种子一样,结果自然一致。
解决:先看池子大小——one()里加一句调试日志打印区间总容量,容量小于 1000 的省份要么补数据,要么在业务侧接受重复率。种子问题检查有没有残留的mt_srand,用random_int()替掉mt_rand(),它不走全局种子,天然免疫这个坑。
5.3 32 位环境上结果全变成0.0.0.0
现象:本地 64 位跑得好好的,扔到一台老机器上,返回全是0.0.0.0或者负数。
原因:ip2long()在 32 位 PHP 上对高地址(比如200.x以上)返回负数,代码里没做无符号平移,& $mask之后高位全丢;long2ip()拿到越界整数也会静默返回0.0.0.0。
解决:ip2int()里那句$long < 0 ? $long + 4294967296 : $long不能省,int2ip()用pack('N')+inet_ntop而不是long2ip。最省事的办法是在构造函数里断言PHP_INT_SIZE === 8,不满足直接抛异常——现在还在跑 32 位 PHP 的环境,本来也该升级了。
5.4 批量生成耗时突然涨到几秒
现象:单条生成毫秒级,一次要两万条的时候接口直接超时。
原因:many()里每次都重新算权重总和、每次isDenied都foreach扫黑名单、每条都json_decode一次数据文件——单条看不出来,乘两万就露馅了。
解决:把“权重总和”“编译后的区间”“排序后的黑名单”全部挪到构造期,算一次存成属性;many()复用同一个实例,别在循环里new ProvinceIp()。真要在循环里 new,至少给数据文件加一层static缓存,按文件路径 +filemtime做键。
5.5 校验脚本大量 mismatch,但 IP 看着没问题
现象:自校验跑出来bad上百条,人工抽查几个 IP,归属地库查出来是隔壁省份。
原因:省份边界本身有模糊地带,不同归属地库对同一段 IP 的标注不一致;还有一种更隐蔽的,数据文件里键名是从 A 库导的,校验用的是 B 库,两套命名压根不是一回事,比对必然失败。
解决:先把两边的省份命名做一层映射表,别直接字符串比较;再容忍边界段的个位数误差,把验收线从bad === 0放宽到bad / total < 1%,同时把 mismatch 的 IP 打印出来人工过一遍,确认是数据源差异而不是代码 bug。
6. 二次开发:把单文件类接进业务,并且让它可测
这类小工具最容易死在“接进业务之后就没人敢改”上,所以我在包装的时候会多做两件事:把随机源抽成可注入的,把批量逻辑做成幂等可去重的。
<?php public function many(string $province, int $n, array $exclude = []): array { $out = []; $guard = $n * 20; // 去重失败的上限,防止小池子死循环 while (count($out) < $n && $guard-- > 0) { $ip = $this->one($province); if (isset($exclude[$ip]) || isset($out[$ip])) { continue; // 已存在就重抽,用哈希表判重是 O(1) } $out[$ip] = true; } if (count($out) < $n) { throw new RuntimeException("省份 {$province} 可用 IP 不足 {$n} 条"); } return array_keys($out); }$guard那个上限是关键,$n * 20的意思是“平均重试 20 次还凑不齐就认输”。没有它,池子只有 50 个地址却要生成 100 条,代码会原地转到超时,这类死循环在测试数据脚本里特别常见。返回前用array_keys转回列表,键本身当哈希表用,一行代码换来 O(1) 判重。
可测性是第二个改造点。把$rng注入成固定序列,就能写确定性单测,这是我最看重的一条经验:
<?php // tests/ProvinceIpTest.php $seq = [1, 137, 2, 200]; // 预先编排的随机序列 $i = 0; $gen = new ProvinceIp(__DIR__ . '/../data/province_cidr.json', '', function (int $a, int $b) use (&$i, $seq) { return $seq[$i++ % count($seq)]; // 每次返回可预测的值 }); $ip = $gen->one('pn_01'); assert(filter_var($ip, FILTER_VALIDATE_IP) !== false);有了这个口子,cidr2range()的对齐、isDenied()的二分边界、many()的去重上限,全都能用固定输入跑出来,不用靠“多跑几次看看”。我一般还会补一张表,把/24、/30、/31、/32、/0五种掩码的起止整数手算一遍写进断言,改代码前先跑,跑不过就别提交。
数据更新也要有章法。归属地库每隔一段时间会调整,直接覆盖province_cidr.json风险很高——上一次我就因为整包替换,某个省份的键名从缩写变成了全拼,业务侧静默降级到默认省份,一周后才发现报表里那个省的 IP 全是同一个段。从那以后我每次更新数据文件都强制走一遍流程:先跑校验脚本拿到bad率,再 diff 新旧文件的键名集合,键名有任何增删都当作不兼容变更,先在映射表里补别名再上线。这三步加起来不到五分钟,比事后排查一周便宜太多。希望帮到你。
本文还有配套的精品资源,点击获取