Appearance
Ruby on Rails 怎么获取访客 IP 归属地?用 middleware 给请求打地域标签(含缓存与限流)
一个做本地生活服务的朋友把站点从 PHP 迁到了 Rails,迁移完第一件事就卡住了:以前 PHP 里两行代码就能拿到的访客城市,换到 Rails 不知道该怎么写才「地道」。他先搜了一圈,看到的方案基本是 MaxMind 离线库配 geoip gem,或者是 IPinfo 的官方 client —— 两条路都能跑,但对一个主要服务国内用户的站点来说各有各的别扭:MaxMind 的中国数据在城市级别上命中率一般,IPinfo 需要申请 token 且请求要绕到海外。
最后他用的是国内在线接口,配上 Rails 原生的缓存和一层中间件,代码量比想象中小。这篇就把这套写法拆开讲一遍,顺带把几个容易踩的坑讲清楚。
一、场景问题:Rails 里「拿到 IP」和「拿到属地」是两件事
第一件:拿到真实 IP。 Rails 给了 request.remote_ip,看着挺省心,但线上环境里这个值经常是错的。站点前面挂了 Nginx 或者 CDN 之后,remote_ip 拿到的是上游代理的地址(常见的是 127.0.0.1 或者内网段),而不是访客的。
Rails 自己有一套 ActionDispatch::RemoteIp 中间件会去读 X-Forwarded-For,但它的处理逻辑跟 config.action_dispatch.trusted_proxies 有关 —— 代理层数没配对,它就会把代理 IP 当成访客 IP。手工读 X-Forwarded-For 第一个值更危险,因为这个头是客户端可以自己塞的,写个假 IP 就能伪造。
第二件:别每次请求都去问接口。 归属地是个慢变量 —— 一个家庭宽带 IP 可能几个月都不换城市,一个公司出口 IP 更是常年不变。如果每个请求都实时查一遍,一个日活一万的站一天就能把免费接口的 60 次/分钟额度打成筛子。真实流量里 IP 的重复率其实很高,缓存命中率通常能到 80% 以上。
第三件:查不到的时候怎么办。 内网 IP、非法 IP、接口超时,这些情况在真实日志里占比不低。如果代码里写成「查询失败 → 当作异常抛出去」,页面就会 500;写成「查询失败 → 当作境外流量」,风控就会天天误报。标准做法是明确区分「已知未知」和「明确未知」,前者不参与任何地域判断。
二、方案设计
按关注点拆三层,每层只管一件事:
第一层,中间件取 IP。 在 env 里放一个 geo.ip,只负责「这个请求的访客 IP 是谁」,顺手处理 XFF 和 CDN 的真实 IP 头。
第二层,查询客户端带缓存和限速。 一个 Ip9Geo 类,对外只暴露 lookup(ip),内部自己解决缓存(文件 / Redis)、限速(免费版 1 秒 1 次)、错误分类(ret=400 非法、ret=429 限速、超时)。业务代码完全不需要知道接口地址长什么样。
第三层,业务侧打标签。 把查询结果翻译成业务语言:地域标签、推荐的城市内容、风控线索。这一层纯逻辑,最好不依赖网络。
Rails 生产环境里第二层的缓存建议直接用 Rails.cache(配 Redis 或 Memcached);下面给的独立脚本用文件缓存,是为了能脱离 Rails 直接跑起来验证,两者逻辑一致。
三、Ruby 实现
完整可运行脚本(geo_tag.rb,只用标准库 net/http + json,不需要 Gemfile):
ruby
#!/usr/bin/env ruby
require 'net/http'
require 'json'
require 'uri'
API_ENDPOINT = 'https://ip9.com.cn/get'
CACHE_FILE = File.join(__dir__, 'ip_cache.json')
CACHE_TTL = 7 * 24 * 3600 # 归属地变化很慢,缓存 7 天
MIN_INTERVAL = 1.0 # 免费版 60 次/分钟/IP -> 1 秒 1 次
class Ip9Geo
def initialize(cache_file: CACHE_FILE, ttl: CACHE_TTL, interval: MIN_INTERVAL)
@cache_file = cache_file
@ttl = ttl
@interval = interval
@last_at = 0.0
@cache = load_cache
end
def load_cache
return {} unless File.exist?(@cache_file)
parsed = JSON.parse(File.read(@cache_file))
parsed.is_a?(Hash) ? parsed : {}
rescue JSON::ParserError
{}
end
# 返回 hash: ok / country / prov / city / isp / big_area / err / cached
def lookup(ip)
hit = @cache[ip]
if hit && Time.now.to_i - hit['ts'].to_i < ttl_for(hit)
return hit.merge('cached' => true)
end
rec = { 'ts' => Time.now.to_i, 'cached' => false, 'ok' => false, 'country' => '',
'prov' => '', 'city' => '', 'isp' => '', 'big_area' => '', 'err' => '' }
begin
throttle
uri = URI("#{API_ENDPOINT}?ip=#{URI.encode_www_form_component(ip)}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
open_timeout: 6, read_timeout: 6) do |http|
http.get(uri.request_uri, 'User-Agent' => 'geo_tag/1.0', 'Accept' => 'application/json')
end
json = begin
JSON.parse(res.body.to_s)
rescue JSON::ParserError
nil
end
if json.nil?
rec['err'] = '响应不是 JSON'
elsif json['ret'].to_i != 200
rec['err'] = "ret=#{json['ret']}" # 400 非法 IP / 429 限速
else
d = json['data'] || {}
rec['ok'] = true
rec['country'] = d['country'].to_s
rec['prov'] = d['prov'].to_s
rec['city'] = d['city'].to_s
rec['isp'] = d['isp'].to_s
rec['big_area'] = d['big_area'].to_s
end
rescue StandardError => e
rec['err'] = "#{e.class}: #{e.message}" # 超时、DNS、TLS 全都落这里
end
@cache[ip] = rec
File.write(@cache_file, JSON.dump(@cache))
rec
end
private
# 成功结果缓存 7 天;失败结果只缓存 1 分钟,避免一次超时把 IP「拉黑」一周
def ttl_for(rec)
rec['ok'] ? @ttl : 60
end
# 限速只放在这一层,业务代码不用管调用频率
def throttle
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
wait = @interval - (now - @last_at)
sleep(wait) if wait.positive?
@last_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end业务侧的打标签逻辑和演示入口:
ruby
CITY_STORES = {
'南京' => %w[新街口店 江宁店 仙林店],
'上海' => %w[静安店 徐汇店],
'深圳' => %w[南山店 福田店]
}.freeze
def tag_request(geo, ip, claimed_city)
tags = []
internal = geo['ok'] && (geo['country'] == '保留' || geo['isp'] == '内网地址')
# 1) 地域标签:用于内容分发
if internal
tags << '内网/保留地址'
elsif geo['ok'] && geo['country'] == '中国' && !geo['city'].empty?
tags << "#{geo['prov']}·#{geo['city']}"
tags << "大区:#{geo['big_area']}" unless geo['big_area'].to_s.empty?
elsif geo['ok']
tags << "境外:#{geo['country']}"
else
tags << '未知'
end
# 2) 门店/内容选择:拿不到归属地就回退到默认全国页,绝不报错
stores = CITY_STORES[geo['city']] || ['全国通用内容']
# 3) 风控线索:用户自称的城市和 IP 城市对不上(只提示,不拦截)
mismatch = geo['ok'] && !internal && !geo['city'].empty? && !claimed_city.nil? &&
!claimed_city.empty? && geo['city'] != claimed_city
{ tags: tags, stores: stores, mismatch: mismatch }
end几个 Ruby 特有的实现细节,值得单独点出来:
限速用单调时钟,不要用 Time.now。 我第一版写的是 wait = @interval - (Time.now - @last_at),跑起来直接报 TypeError: Time can't be coerced into Float —— Time 对象减浮点数在 Ruby 里不合法。而且就算写成 Time.now.to_f,系统时间被 NTP 往回校一下,限速就失效了。Process.clock_gettime(Process::CLOCK_MONOTONIC) 是这里的正解。
失败结果不能按成功结果的 TTL 缓存。 我第一版把超时和 ret=400 的结果也按 7 天缓存了,结果一次网络抖动就会让某个 IP 在接下来一周里都返回「未知」。现在分开:成功了缓存 7 天,失败了只缓存 60 秒。
Puma 是多线程的,文件缓存会有写竞争。 独立脚本这么写没问题,进了 Rails 请换成 Rails.cache 或者给 lookup 外面套一层 Mutex。生产环境用 Redis 的话,expires_in 直接交给 Redis 管,连 ts 字段都不用手工维护。
四、接进 Rails:一个中间件就够
独立脚本验证通过之后,Rails 里可以这样接:
ruby
# app/middleware/geo_tag_middleware.rb
class GeoTagMiddleware
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
ip = client_ip(request)
geo = Rails.cache.fetch("geo:ip:#{ip}", expires_in: 7.days) do
geo_client.lookup(ip) # 复用上面那个类
end
env['geo.ip'] = ip
env['geo.geo'] = geo
env['geo.city'] = geo.is_a?(Hash) ? geo['city'].to_s : ''
@app.call(env)
end
private
# 只信任自己这层代理写入的值:CDN 有专属真实 IP 头时优先用它
def client_ip(request)
cf = request.headers['HTTP_CF_CONNECTING_IP'] # 换成你自己的 CDN 变量名
return cf.strip if cf && !cf.strip.empty?
xff = request.headers['HTTP_X_FORWARDED_FOR']
if xff && !xff.empty?
# 取最右侧的、不在可信代理列表里的那一个;简单场景直接取第一个
xff.split(',').map(&:strip).reject(&:empty?).first
else
request.remote_ip
end
end
def geo_client
@geo_client ||= Ip9Geo.new(cache_file: Rails.root.join('tmp', 'ip_cache.json').to_s)
end
end
# config/application.rb
config.middleware.insert_after ActionDispatch::RemoteIp, GeoTagMiddleware然后在控制器里用 request.env['geo.city'] 决定给访客展示哪些门店,或者把 geo.city 写进登录日志当风控线索。中间件只做「打标签」,具体怎么用留给业务 —— 这样将来换数据源或者加缓存策略,都不用动业务代码。
五、实测输出
脚本里塞了 5 个请求、4 个不同 IP,跑两遍看缓存效果:
共 5 个请求,涉及 4 个 IP
R-01 114.114.114.114 [接口] 标签=江苏·南京 大区:华东
自称:南京 推荐内容:新街口店、江宁店、仙林店
R-02 114.114.114.114 [缓存] 标签=江苏·南京 大区:华东
自称:上海 推荐内容:新街口店、江宁店、仙林店
· 属地与自称城市不一致,已记入风控线索
R-03 45.153.160.2 [接口] 标签=境外:捷克
自称:深圳 推荐内容:全国通用内容
R-04 192.168.1.10 [接口] 标签=内网/保留地址
自称:南京 推荐内容:全国通用内容
R-05 999.1.1.1 [接口] 标签=未知
自称:南京 推荐内容:全国通用内容
· 无法定位(ret=400),按默认内容展示首跑 3.4 秒(4 次真实请求,1 秒 1 次的限速 + 请求本身耗时),第二遍全走缓存 0.13 秒。几个判定值得解释:
R-01 和 R-02 共用一个 IP,但只发了一次请求。 第二个请求命中文件缓存,标签直接复用。真实站点里同一 IP 反复出现太常见了,缓存命中率越高,越不容易撞上 429。
R-04 的 192.168.1.10 是内网地址,接口返回的是 country=保留、isp=内网地址,而不是报错。 这种结果既不能当境外(会误报),也不能当中国某城市(会误判),所以给了独立标签。开发环境本机测试、公司内网访问、走负载均衡的请求都会落到这一类。
R-05 的 999.1.1.1 是非法 IP,接口返回 HTTP 400 加 {"ret":400}。 注意 Ruby 的 Net::HTTP 不会因为 400 抛异常,res.body 照样能读到,所以解析出 ret 才知道是「IP 不合法」而不是「服务挂了」。这个区分很重要,前者不该重试,后者值得重试。
R-02 那条风控线索是白捡的。 用户资料里填的城市和当前 IP 城市不一致,不一定有问题(出差、VPN、帮别人下单),但把它记下来,将来做异常登录提醒时就是现成的特征。
六、落地时要想清楚的几件事
XFF 的信任边界,比代码本身更容易出问题。 上面那个 client_ip 只对 CDN 专属头做了优先处理,取 XFF 第一个值在「前置代理层数固定」的架构下是安全的;但如果站点允许用户直连,攻击者塞一个 X-Forwarded-For: 1.1.1.1 就能让你的地域判断完全失真。稳妥做法是数清楚自己前面有几层代理,只取「从右往左数第 N+1 个」地址。
缓存 TTL 定 7 天是有依据的。 IP 归属地变动通常发生在宽带重拨、基站切换或者运营商调整网段的时候,频率远低于「天」这个量级。TTL 太短等于白缓存,太长又会遇到「用户搬了家但属地还是旧的」。7 天是个折中点,遇到运营活动类的高流量场景可以压到 1 天。
合规上只展示到省。 国内平台的惯例是境内展示到省(自治区、直辖市)、境外展示到国家(地区),这也是《互联网用户账号信息管理规定》的口径。接口返回的 city、经纬度(城市中心点)精度足够做内容分发,但真要在页面上展示给其他用户看,展示到省级就够了。
区县和 IP 类型是 VIP 版能力。 免费版 GET https://ip9.com.cn/get?ip=<ip> 返回国家、省市、邮编、区号、运营商、经纬度、long_ip、大区这些字段,够做绝大多数地域化需求。要判断这个 IP 是家庭宽带还是 IDC 机房(ip_type)、要拿到 AS 号(ip_asn)或者精确到区县(area),这些是 VIP 版才有的字段,具体口径以官网 https://www.ip9.com.cn 为准。免费版不用注册,60 次/分钟/IP,配上缓存对中小站点完全够用。
高并发场景可以考虑换个语言。 Ruby 在 IO 密集型任务上表现不差(上面那个限速是 sleep,不是 CPU 忙等),但如果每天要处理的 IP 量到了百万级,把查询挪到异步任务(Sidekiq)或者干脆换 Go/Java 写个独立的查询服务,比在 Rails 进程里硬扛要省心。
落到具体行动上:先在 Rails 控制台里 Ip9Geo.new.lookup('114.114.114.114') 跑通一次,确认网络出口能访问接口;再把中间件接上并观察一周的缓存命中率;最后才是把地域标签接进业务逻辑。