Appearance
Rust 怎么查询 IP 归属地?reqwest + serde 写一个带缓存和限速的命令行工具(完整可跑代码)
用 Rust 写网关插件、日志处理程序或者内部 CLI 的时候,经常要补一个动作:给一批 IP 打上归属地。运维查登录日志想知道异地登录来自哪个省、数据组要给访问日志做地域分布、风控要给可疑请求打标签——都需要"IP → 省/市/运营商"这一步。
Rust 生态里做 IP 定位的 crate 不少,但方向基本是离线库:要么依赖 ip2location 的数据库文件,要么是封装好的第三方 API 客户端。如果你只是想要"给我这个 IP 的城市",为了几行逻辑去拉一个几百兆的库文件、或者等一个稳定的 crate,都不太值。中文资料里,调在线接口的例子几乎清一色是 Python 和 PHP,Rust 得自己拼一遍。
这篇就是把这一遍拼清楚:一个 150 行的 Rust 命令行工具 ipq,可以接参数查单个 IP,也可以直接吃 nginx 访问日志的行;带内存缓存、限速、429 退避重试。代码在本机 cargo build 通过并跑过,下面的输出都是实测的。
一、先说三个跟接口有关的坑
data 字段不一定是个对象。 查询成功的响应长这样:
json
{"ret":200,"data":{"ip":"114.114.114.114","country":"中国","country_code":"cn","prov":"江苏",
"city":"南京","city_code":"nanjing","city_short_code":"nj","area":"","post_code":"210000",
"area_code":"025","isp":"114DNS","lng":"118.77","lat":"32.04","long_ip":1920103026,"big_area":"华东"},"qt":0.001}但传进去一个非法 IP 时返回的是:
json
{"data":[],"ret":400}注意 data 变成了空数组。如果你直接把它反序列化成一个 struct Data,serde 会报类型错误——这不是网络问题,是解析问题,很容易被当成接口故障排查半天。正确做法是外层 data 先用 serde_json::Value 接住,ret == 200 时再转成结构体。
HTTP 请求必须带 User-Agent。 不带 UA 的裸请求有可能被前置防护挡掉,返回的不是 JSON,r.json() 直接抛错。
限速是按调用方 IP 算的。 免费版 60 次/分钟,意味着两次请求之间至少要隔 1 秒。批量跑的时候如果不去重、不限速,几百个 IP 打过去,一半请求会撞到限速。
二、设计
工具的目标是"能塞进管道里用",所以设计得比较素:
ipq 114.114.114.114 8.8.8.8 # 直接给 IP
cat access.log | ipq # 从标准输入读日志行,自动抠 IP内部四步:抠 IP(日志模式)→ 去重 → 查接口(缓存 + 限速 + 重试)→ 输出表格和地域分布。
几个取舍:
- 用
reqwest的 blocking 客户端,不引 tokio。 这是个跑批的小工具,进程启动开销比异步运行时占的资源多,用tokio反而重。真要并发批量查,再换 async 版本也不迟。 - 缓存用
HashMap,不落盘。 单次运行内去重就够了;跨次运行要复用的话,把HashMap用serde_json序列化到文件即可,最多十行代码。 - 限速用"记账式"而不是令牌桶。 记录上次请求时间,不够 1.1 秒就 sleep 到够。简单、够准,也不会有令牌桶那种"攒了额度一次打出去"的意外。
三、完整代码
Cargo.toml:
toml
[package]
name = "ipq"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"default-features = false + rustls-tls 是为了避开 OpenSSL 的开发依赖,交叉编译到 Alpine 容器里不用再折腾 libssl-dev。
src/main.rs:
rust
//! ipq —— 用 IP 归属地接口给一批 IP 打地域标签。
//! cargo run -- 114.114.114.114 8.8.8.8
//! cat access.log | cargo run
use std::collections::HashMap;
use std::io::{self, BufRead};
use std::str::FromStr;
use std::time::{Duration, Instant};
use serde::Deserialize;
const API: &str = "https://ip9.com.cn/get";
const UA: &str = "Mozilla/5.0 (X11; Linux x86_64) ipq/0.1";
const MIN_INTERVAL: Duration = Duration::from_millis(1100); // 免费版 60 次/分钟
/// 接口外层:{"ret":200,"data":{...}}
/// 注意 ret=400 时 data 是 **空数组**,所以先用 Value 接,成功再转结构体
#[derive(Debug, Deserialize)]
struct Envelope {
ret: i32,
#[serde(default)]
data: serde_json::Value,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct Geo {
ip: String,
country: String,
country_code: String,
#[serde(default)] prov: String,
#[serde(default)] city: String,
#[serde(default)] isp: String,
#[serde(default)] big_area: String,
#[serde(default)] lng: String,
#[serde(default)] lat: String,
}
struct Limiter { last: Instant }
impl Limiter {
fn new() -> Self {
Limiter { last: Instant::now() - MIN_INTERVAL } // 让第一次请求不用等
}
fn wait(&mut self) {
let elapsed = self.last.elapsed();
if elapsed < MIN_INTERVAL { std::thread::sleep(MIN_INTERVAL - elapsed); }
self.last = Instant::now();
}
}
fn fetch(client: &reqwest::blocking::Client, limiter: &mut Limiter, ip: &str) -> Result<Geo, String> {
let mut attempt = 0;
loop {
attempt += 1;
limiter.wait();
match client.get(API).query(&[("ip", ip)]).header("User-Agent", UA).send() {
Ok(r) => {
let status = r.status();
if status.as_u16() == 429 && attempt < 3 { // 触发限速,退避重试
std::thread::sleep(Duration::from_secs(2 * attempt));
continue;
}
let body: Envelope = r.json().map_err(|e| format!("解析响应失败: {e}"))?;
if body.ret != 200 {
return Err(format!("ret={}", body.ret)); // 非法 IP 返回 400
}
return serde_json::from_value::<Geo>(body.data)
.map_err(|e| format!("字段解析失败: {e}"));
}
Err(e) => {
if attempt < 3 {
std::thread::sleep(Duration::from_secs(attempt as u64));
continue;
}
return Err(format!("请求失败: {e}"));
}
}
}
}
/// 从一行文本里挑出第一个像 IP 的 token(方便直接喂 nginx access.log)
fn pick_ip(line: &str) -> Option<String> {
line.split_whitespace()
.find(|t| std::net::IpAddr::from_str(t).is_ok())
.map(|s| s.to_string())
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut ips: Vec<String> = if args.is_empty() {
let stdin = io::stdin();
stdin.lock().lines()
.map_while(Result::ok)
.filter_map(|l| pick_ip(&l))
.collect()
} else { args };
if ips.is_empty() {
eprintln!("用法: ipq <ip>... 或 cat access.log | ipq");
std::process::exit(2);
}
// 去重但保持顺序:日志里同一个 IP 会重复几十上百次
let mut seen = std::collections::HashSet::new();
let uniq: Vec<String> = ips.into_iter().filter(|ip| seen.insert(ip.clone())).collect();
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5)) // 不设超时,一个卡住的请求能挂住整个进程
.build().expect("创建 HTTP 客户端失败");
let mut limiter = Limiter::new();
let mut cache: HashMap<String, Geo> = HashMap::new();
let mut errors = 0usize;
let start = Instant::now();
for ip in &uniq {
match fetch(&client, &mut limiter, ip) {
Ok(g) => { cache.insert(ip.clone(), g); }
Err(e) => { errors += 1; println!("{:<40} !! {}", ip, e); }
}
}
println!("\n{:<16} {:<8} {:<8} {:<8} {:<10} {:<12} {}",
"IP", "国家", "省", "市", "大区", "运营商", "经纬度");
for ip in &uniq {
match cache.get(ip) {
Some(g) => println!("{:<16} {:<8} {:<8} {:<8} {:<10} {:<12} {},{}",
g.ip, g.country, g.prov, g.city, g.big_area, g.isp, g.lng, g.lat),
None => println!("{:<16} {:<8}", ip, "查询失败"),
}
}
// 按省份/国家做个粗略汇总,日志富化时通常就想要这个
let mut stat: HashMap<String, usize> = HashMap::new();
for ip in &uniq {
if let Some(g) = cache.get(ip) {
let key = if g.country == "保留" { "内网/保留".to_string() }
else if g.prov.is_empty() { g.country.clone() }
else { g.prov.clone() };
*stat.entry(key).or_insert(0) += 1;
}
}
let mut rows: Vec<(String, usize)> = stat.into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1));
println!("\n-- 地域分布(按独立 IP 数)--");
for (k, v) in rows { println!("{:<14} {}", k, v); }
// 顺手算一下境内/境外比例:country_code 比中文国家名更适合做聚合键
let overseas = cache.values()
.filter(|g| !g.country_code.is_empty() && g.country_code != "cn").count();
let total = cache.values().filter(|g| !g.country_code.is_empty()).count();
if total > 0 {
println!("\n境外 IP: {} / {}({:.2}%)", overseas, total,
overseas as f64 / total as f64 * 100.0);
}
println!("\n去重后 {} 个 IP,成功 {},失败 {},耗时 {:.1}s",
uniq.len(), cache.len(), errors, start.elapsed().as_secs_f64());
}首次 cargo build 要下 reqwest 那一串依赖,我在 2 核机器上花了 1 分 38 秒;之后改代码再编 30 秒上下。
四、实跑结果
命令行模式,查 8 个 IP(含一个内网地址、一个 IPv6、一个重复项):
text
IP 国家 省 市 大区 运营商 经纬度
114.114.114.114 中国 江苏 南京 华东 114DNS 118.77,32.04
8.8.8.8 美国 Google Cloud -97.82,37.75
192.168.1.1 保留 内网地址 ,
203.198.0.1 中国 香港 香港 港澳台 香港电讯 114.17,22.32
240e:ff:e02c:1:0:ff:b0e4:20f 中国 广东 广州 华南 中国电信 113.28,23.13
45.153.160.2 捷克 AT&T 14.41,50.08
183.204.111.150 中国 河南 新乡 华中 中国移动 113.88,35.30
101.226.4.6 中国 上海 上海 华东 DNSpai/电信 121.47,31.23
-- 地域分布(按独立 IP 数)--
内网/保留 1
江苏 1
香港 1
捷克 1
上海 1
美国 1
广东 1
河南 1
境外 IP: 2 / 7(28.57%)
去重后 8 个 IP,成功 8,失败 0,耗时 7.9s(地域分布那几行的顺序是 HashMap 的迭代顺序,每次运行可能不一样;按独立 IP 数并列时不用纠结排序。)
几处细节值得说明:
- 7.9 秒里 7.7 秒是限速等待。 8 个 IP 各 1.1 秒。想快就只能升 VIP 版(按次计费,18 万次/分钟)或者换离线库,这是额度和速度的直接交换。
- 境外 IP 的比例是 2/7 而不是 2/8。
192.168.1.1的country_code是空字符串,被排除在分母外——内网地址参与比例计算会污染结果,这是刻意的(也是为什么代码里要专门判country == "保留")。 8.8.8.8只有国家没有省市。 境外 IP 普遍如此,prov/city都是空,经纬度是美国地理中心点。45.153.160.2(捷克 / AT&T)同理,所以汇总里它落到了"捷克"而不是某个城市。203.198.0.1走的是另一套逻辑:country=中国、prov=香港、big_area=港澳台,有省份也有大区。处理港澳台数据时不能按"境外 IP 只有国家"的假设写死。
管道模式(把日志行扔进去):
text
101.226.4.6 中国 上海 上海 华东 DNSpai/电信 121.47,31.23
114.114.114.114 中国 江苏 南京 华东 114DNS 118.77,32.04
10.0.0.5 保留 内网地址 ,pick_ip 只做了一件事——按空白切开,找第一个能 IpAddr::from_str 解析成功的 token,所以 101.226.4.6 - - [19/Sep/2026:09:01:02 +0800] "GET / HTTP/1.1" 这种行也能直接用。
错误路径,故意传一个非法 IP:
text
IP 国家 省 市 大区 运营商 经纬度
999.1.1.1 查询失败
去重后 1 个 IP,成功 0,失败 1,耗时 0.3s0.3 秒就返回了——接口对非法 IP 直接返回 {"data":[],"ret":400},工具把它翻译成"查询失败",不会重试到天荒地老。
五、要放进生产还要补什么
缓存落盘。 现在是单次运行内去重。做成常驻服务或者每天跑一次报表时,把 HashMap<String, Geo> 用 serde_json 写到一个文件,启动时读回来,重复的 IP 一次都不用查——这是省额度最有效的办法。
并发要自己控。 想批量查几千个 IP 又不想等,换 tokio + reqwest::Client(异步),用 Semaphore 把并发卡在 2~3,配合每次请求的间隔,别裸着并发打接口。免费版 60 次/分钟是硬上限,并发只会更早撞到 429。
UA 和超时都别省。 上面两个常量看着琐碎,实际是"能不能跑通"和"卡死还是报错"的分界线。timeout(Duration::from_secs(5)) 让异常请求 5 秒内失败,而不是把进程挂到天亮。
内网地址提前过滤。 日志里 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8 以及 IPv6 的 fc00::/7、fe80::/10 出现频率很高(健康检查、内网调用),它们都能查出来(返回 country=保留),但查了也是浪费额度。用 IpAddr::is_loopback() 之类的判断提前筛掉更省事。
别拿城市中心点当用户位置。 接口给的经纬度是城市中心点坐标,城市级精度;要算距离或者做地理围栏,先确认业务能接受这个误差范围。
ipq 里调的就是 IP9 的免费接口 https://ip9.com.cn/get?ip=<IP>——不传 ip 参数返回调用方自己的归属地,IPv4/IPv6 都支持,免费版 60 次/分钟、无需注册;返回的 country、country_code、prov、city、big_area、isp、lng、lat 这些字段就是上面结构体里用到的。要区县、机房类型(ip_type)、AS 号这些维度,或者量上来了,官网 https://www.ip9.com.cn 上有 VIP 版和私有化部署的说明。想对比离线库和在线接口的取舍,可以看本站《ip2region、qqzeng-ip、GeoIP2 和在线接口怎么选》。