Appearance
Go 怎么给请求打上 IP 归属地标签?一个中间件,地区灰度和风控规则都能用
新功能要按地区灰度、活动要限地区参加、每条请求日志想带上访客省市——三个需求找上来,别让每个业务各查一遍 IP 归属地:代码重复不说,免费接口的配额几下就被打满。正确姿势是在入口写一个中间件,查一次、缓存住、把归属地塞进请求上下文,下游谁要用谁取。
一、典型场景:三个需求盯上同一个数据
- 地区灰度放量:新版本不敢全量,先放广东、浙江两个省试点。判断依据就是请求 IP 的省份。
- 风控规则:注册接口想对某些地区收紧(比如机房 IP 集中的地区加验证),或者活动只允许指定省份参加。
- 日志审计:每条访问日志带上
prov/city/isp,事后按地域排查问题不用再翻接口。
这三件事要是各写各的,等于同一个 IP 被查三遍——在线接口免费版限速 60 次/分钟/IP,服务器出口就一个公网 IP,全站共享这个额度,几下就 429。所以归属地查询必须收敛到一层,并且只在真正需要的时候才发查询。
二、方案设计:一个中间件,三件事
入口中间件做三步:
- 取真实客户端 IP:服务挂在 Nginx/网关后面时,
RemoteAddr拿到的是反代地址,必须从X-Forwarded-For里取。这里有个安全细节:从右往左取第一个不在"可信代理名单"里的 IP。直接取最左会被用户伪造——他随便塞个X-Forwarded-For: 1.2.3.4就能伪装成任何地区,灰度白名单瞬间失效。 - 查归属地 + 缓存:调
https://ip9.com.cn/get?ip=<ip>,返回 JSON 含国家/省/市/运营商等字段;内存缓存 24 小时,同一 IP 一天最多查一次。免费版免注册,IPv4/IPv6 都支持。 - 注入上下文:把归属地结构体挂到请求 context 上,handler 里
GeoFrom(ctx)直接读,规则判断和业务代码解耦。
关键设计是懒查询:中间件先看当前请求路径有没有注册过属地规则,没有就直接放行,一次接口都不调。这样免费额度只花在刀刃上。
三、Go 实现(零第三方依赖,标准库一把梭)
go
package main
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
)
type Geo struct {
IP string
Country string
Prov string
City string
ISP string
}
type geoKey struct{}
// ---------- 1. 取真实 IP:从右往左跳过可信代理 ----------
var trustedProxies = map[string]bool{
"127.0.0.1": true, // 这里填你自己 Nginx/网关的 IP
"::1": true,
}
func realClientIP(r *http.Request) string {
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if !trustedProxies[ip] {
return ip
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
// ---------- 2. 查归属地(带 24h 缓存) ----------
type cacheItem struct {
geo Geo
expireAt time.Time
}
var (
cacheMu sync.RWMutex
cache = map[string]cacheItem{}
client = &http.Client{Timeout: 2 * time.Second}
)
func lookupGeo(ip string) Geo {
cacheMu.RLock()
item, ok := cache[ip]
cacheMu.RUnlock()
if ok && time.Now().Before(item.expireAt) {
return item.geo
}
geo := Geo{IP: ip}
var resp struct {
Ret int `json:"ret"`
Data struct {
Country string `json:"country"`
Prov string `json:"prov"`
City string `json:"city"`
ISP string `json:"isp"`
} `json:"data"`
}
r, err := client.Get("https://ip9.com.cn/get?ip=" + ip)
if err == nil {
err = json.NewDecoder(r.Body).Decode(&resp)
r.Body.Close()
}
if err == nil && resp.Ret == 200 {
geo.Country, geo.Prov, geo.City, geo.ISP =
resp.Data.Country, resp.Data.Prov, resp.Data.City, resp.Data.ISP
} // 查失败/被限流(429)就返回空 Geo,调用方自行降级
cacheMu.Lock()
cache[ip] = cacheItem{geo: geo, expireAt: time.Now().Add(24 * time.Hour)}
cacheMu.Unlock()
return geo
}
func GeoFrom(ctx context.Context) Geo {
if g, ok := ctx.Value(geoKey{}).(Geo); ok {
return g
}
return Geo{}
}
// ---------- 3. 中间件:带规则的路径才查询 ----------
type geoRule struct {
pathPrefix string
handler http.Handler
}
func WithGeo(next http.Handler, needGeo func(*http.Request) bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if needGeo == nil || needGeo(r) {
geo := lookupGeo(realClientIP(r))
r = r.WithContext(context.WithValue(r.Context(), geoKey{}, geo))
}
next.ServeHTTP(w, r)
})
}
// ---------- 使用示例:地区灰度 + 日志 ----------
func main() {
// 只有 /api/v2/ 前缀的请求才需要属地(懒查询,省免费额度)
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/feature", func(w http.ResponseWriter, r *http.Request) {
geo := GeoFrom(r.Context())
// 地区灰度:新功能先放广东(guangdong)、浙江(zhejiang)
if geo.Prov != "广东" && geo.Prov != "浙江" {
fmt.Fprint(w, `{"code":403,"msg":"灰度中,暂未开放"}`)
return
}
fmt.Fprint(w, `{"code":0,"msg":"新功能上线"}`)
})
mux.HandleFunc("/api/v1/old", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"code":0,"msg":"老接口"}`)
})
handler := WithGeo(mux, func(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/api/v2/")
})
http.ListenAndServe(":8080", handler)
}跑起来后模拟两拨请求:
bash
# 模拟广东用户(中间件查到 广东 后放行)
curl -H 'X-Forwarded-For: 1.2.3.4' http://127.0.0.1:8080/api/v2/feature
# 假设 1.2.3.4 归属地非粤浙 → 返回 403 灰度中中间件返回的 Geo 同时在 handler 里被读到,往日志里打 geo.Prov 就是地域审计。想换 Gin?把 WithGeo 改成 gin.HandlerFunc,c.Set("geo", geo) 即可,判断逻辑一行不用动。
四、落地注意事项
- 信任链是安全底线:灰度/风控规则依赖省份,就必须保证 IP 取不错。Nginx 层要配
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for(把直连 IP 追加到末尾),Go 侧只信任名单内的反代。直接读最左的写法等于给攻击者发了一张"任意地区"通行证。 - 缓存 TTL 别太短:运营商 IP 段是按月级别变的,24 小时缓存完全够,还能把免费接口的调用压到"每个 IP 每天 1 次"。
- 失败必须放行:接口超时、429 限流时返回空 Geo,规则判断要按"查不到=放行"设计,否则接口一抖整个灰度区全 403,事故比 bug 大。高危操作(如支付)想 Fail-Closed,单独加开关。
- 额度估算:免费版 60 次/分钟是"每 IP 每分钟",服务器出口就一个 IP,等于全站每分钟 60 次。靠懒查询 + 24h 缓存,日常流量没问题;真有几万新增 IP 要打标,再上付费档或换离线库方案。
总结
把归属地查询收敛成一层中间件:真实 IP 取对、缓存挂上、按需查询、失败放行,灰度、风控、日志三处复用同一个 Geo 标签。接口用 https://ip9.com.cn/get?ip=<ip> 免费版就行,免注册、支持 IPv4/IPv6,返回省市区和运营商字段够上述规则用了。官网:https://www.ip9.com.cn