通过ip解析城市
- 环境:
- Go 1.24.4+
- ip2region 数据库文件
功能特性
- ✅ 自动从 IP 解析城市信息
- ✅ 支持 IPv4 和 IPv6 地址
- ✅ 内网 IP 自动跳过(不报错)
- ✅ 数据库文件缺失时优雅降级(服务可正常启动)
- ✅ 请求中可手动指定 city,优先级高于 IP 解析
准备
- ip2region数据库文件
- ip2region库:github.com/lionsoul201...
下载ip2region数据库文件
js
// 地址: https://github.com/lionsoul2014/ip2region/releases
// 找到/data/ip2region_v4.xdb
// 放到项目的/data/ip2region_v4.xdb
将下载的 ip2region_v4.xdb 文件放到项目目录:
kotlin
your-project/
├── data/
│ └── ip2region_v4.xdb <-- 放在这里
├── config/
├── internal/
└── ...
配置说明
config/config.yaml:
yaml
ip:
resolver:
enabled: true # 是否启用 IP 解析
db_path: "data/ip2region_v4.xdb" # 数据文件路径(服务器要在根目录下例如:/f服务器根目录/your-project/data/ip2region_v4.xdb)
mode: "memory" # 查询模式:memory(内存缓存) / file(文件读取)
核心组件设计
1.1 整体架构
go
┌─────────────────────────────────────────────────────────────┐
│ API Layer │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ POST /api/v1/test(需要解析ip为city的接口) │ │
│ │ Handler: CreateLog │ │
│ │ - 从 gin.Context 获取 IP │ │
│ │ - 调用 Service.CreateDeviceLog │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────┬───────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────┐
│ Service Layer │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ UserDeviceLogService.CreateDeviceLog │ │
│ │ - 接收 IP 字符串参数 │ │
│ │ - 调用 IPResolver.Resolve(ip) 获取城市 │ │
│ │ - 组装数据,调用 Repository 保存 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼─────────────────────────────┐ │
│ │ IPResolver (internal/pkg/ip/resolver.go) │ │
│ │ - 加载 ip2region.xdb 数据文件 │ │
│ │ - 提供 Resolve(ip string) (city string, err error) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────┬───────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────┐
│ Repository Layer │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ UserDeviceLogRepository.Create │ │
│ │ - 保存设备日志到数据库 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
1.2 模块划分
bash
internal/
├── infra/
│ └── ip/
│ ├── resolver.go # IP 解析器接口和实现
│ ├── util.go # IP 工具函数
│ └── provider.go # Wire Provider
└── modules/
└── test/
├── api/v1/
│ └── test.go # Handler 层
├── service/
│ └── test_service.go # Service 层
└── repository/
└── test_repository.go # Repository 层
2. 核心组件设计
2.1 调用时序图
sequenceDiagram
participant Client as 客户端
participant Handler as Handler
participant Util as IP工具
participant Service as Service
participant Resolver as IPResolver
participant DB as ip2region
Client->>Handler: POST /api/v1/user-device-logs
Handler->>Util: GetClientIP(c)
Note over Util: 1. X-Forwarded-For?
2. X-Real-IP?
3. RemoteAddr Util-->>Handler: return "113.45.12.34" Handler->>Service: CreateLog(ctx, userID, "113.45.12.34", req) Service->>Resolver: Resolve(ctx, "113.45.12.34") Note over Resolver: 1. cleanIP()
去除端口 [::1]:8080 → ::1 Note over Resolver: 2. isPrivateIP()
检查内网IP Note over Resolver: 3. xdb.ParseIP()
字符串转字节数组 Resolver->>DB: Search(ipBytes) DB-->>Resolver: return "中国|广东省|深圳市|电信|CN" Note over Resolver: 4. extractCity()
parts[1] = "广东省" Resolver-->>Service: return "广东省" Service-->>Handler: return resp (city="广东省") Handler-->>Client: return JSON
2. X-Real-IP?
3. RemoteAddr Util-->>Handler: return "113.45.12.34" Handler->>Service: CreateLog(ctx, userID, "113.45.12.34", req) Service->>Resolver: Resolve(ctx, "113.45.12.34") Note over Resolver: 1. cleanIP()
去除端口 [::1]:8080 → ::1 Note over Resolver: 2. isPrivateIP()
检查内网IP Note over Resolver: 3. xdb.ParseIP()
字符串转字节数组 Resolver->>DB: Search(ipBytes) DB-->>Resolver: return "中国|广东省|深圳市|电信|CN" Note over Resolver: 4. extractCity()
parts[1] = "广东省" Resolver-->>Service: return "广东省" Service-->>Handler: return resp (city="广东省") Handler-->>Client: return JSON
2.1 infra/ip 模块
go
// internal\infra\ip\provider.go
package ip
import "github.com/google/wire"
// ProviderSet IP解析器依赖注入集合
var ProviderSet = wire.NewSet(
NewResolver,
)
go
// internal\infra\ip\resolver.go
// Package ip 提供IP地址解析功能
package ip
import (
"context"
"net"
"strings"
"go-api/config"
"go-api/pkg/logx"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
)
// Resolver IP解析器接口
type Resolver interface {
// Resolve 根据IP地址解析城市信息
// 返回空字符串表示无法解析(内网IP、IPv6、查询失败)
Resolve(ctx context.Context, ip string) string
// Close 关闭解析器,释放资源
Close()
}
// ip2regionResolver 基于ip2region的实现
type ip2regionResolver struct {
searcher *xdb.Searcher
}
// NewResolver 创建IP解析器
// 如果配置文件未启用或数据文件不存在,返回nil(不报错,服务可正常启动)
func NewResolver(cfg *config.Config) (Resolver, error) {
if !cfg.IP.Resolver.Enabled {
logx.Default.Info("IP解析器已禁用")
return nil, nil
}
dbPath := cfg.IP.Resolver.DBPath
if dbPath == "" {
logx.Default.Warn("IP解析器数据文件路径未配置,IP解析功能将不可用")
return nil, nil
}
// 从数据文件加载头部信息,获取版本
header, err := xdb.LoadHeaderFromFile(dbPath)
if err != nil {
logx.Default.Warn("加载IP数据库头部失败,IP解析功能将不可用",
"db_path", dbPath,
"error", err,
)
return nil, nil
}
// 从头部获取版本信息
version, err := xdb.VersionFromHeader(header)
if err != nil {
logx.Default.Warn("获取IP数据库版本失败,IP解析功能将不可用",
"db_path", dbPath,
"error", err,
)
return nil, nil
}
// 创建 searcher(使用文件模式,根据数据文件版本)
searcher, err := xdb.NewWithFileOnly(version, dbPath)
if err != nil {
logx.Default.Warn("加载IP数据库失败,IP解析功能将不可用",
"db_path", dbPath,
"error", err,
)
return nil, nil
}
logx.Default.Info("IP解析器初始化成功",
"db_path", dbPath,
"mode", cfg.IP.Resolver.Mode,
"version", version.Name,
)
return &ip2regionResolver{
searcher: searcher,
}, nil
}
// Resolve 解析IP获取城市信息
func (r *ip2regionResolver) Resolve(ctx context.Context, ip string) string {
if r == nil || r.searcher == nil {
return ""
}
// 清洗IP地址
ip = cleanIP(ip)
if ip == "" {
return ""
}
// 检查是否为内网IP
if isPrivateIP(ip) {
logx.G(ctx).Debug("内网IP,跳过解析", "ip", ip)
return ""
}
// 将IP转换为字节数组
ipBytes, err := xdb.ParseIP(ip)
if err != nil {
logx.G(ctx).Warn("IP地址解析失败", "ip", ip, "error", err)
return ""
}
// 查询ip2region
region, err := r.searcher.Search(ipBytes)
if err != nil {
logx.G(ctx).Warn("IP解析失败", "ip", ip, "error", err)
return ""
}
// 解析结果格式:国家|区域|省份|城市|ISP
city := extractCity(region)
logx.G(ctx).Debug("IP解析成功", "ip", ip, "city", city, "region", region)
return city
}
// Close 关闭解析器
func (r *ip2regionResolver) Close() {
if r != nil && r.searcher != nil {
r.searcher.Close()
}
}
// cleanIP 清洗IP地址
// - 去除端口信息 [::1]:8080 -> ::1, 192.168.1.1:8080 -> 192.168.1.1
// - 处理IPv6映射地址 ::ffff:192.168.1.1 -> 192.168.1.1
func cleanIP(ip string) string {
if ip == "" {
return ""
}
// 使用 net.SplitHostPort 分离 IP 和端口
host, _, err := net.SplitHostPort(ip)
if err == nil {
// 成功分离,使用 host 部分
ip = host
}
// 如果分离失败(没有端口),保持原样
// 处理IPv6映射的IPv4地址 ::ffff:192.168.1.1
if strings.HasPrefix(ip, "::ffff:") {
ip = ip[7:]
}
return ip
}
// isPrivateIP 检查是否为内网IP
func isPrivateIP(ip string) bool {
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return false
}
// 检查IPv4私有地址段
privateRanges := []string{
"10.0.0.0/8", // 10.0.0.0 - 10.255.255.255
"172.16.0.0/12", // 172.16.0.0 - 172.31.255.255
"192.168.0.0/16", // 192.168.0.0 - 192.168.255.255
"127.0.0.0/8", // 127.0.0.0 - 127.255.255.255
"169.254.0.0/16", // 链路本地地址
}
for _, cidr := range privateRanges {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if ipNet.Contains(parsedIP) {
return true
}
}
return false
}
// extractCity 从ip2region结果中提取省份
// 格式:国家|省份|城市|ISP|国家代码
// 示例:中国|广东省|深圳市|电信|CN
func extractCity(region string) string {
logx.Default.Info("ip2region返回的region", "region", region)
if region == "" {
return ""
}
parts := strings.Split(region, "|")
if len(parts) < 2 {
return ""
}
// 省份是第2个字段(索引1)
province := parts[1]
return province
}
go
// internal\infra\ip\util.go
package ip
import (
"strings"
"github.com/gin-gonic/gin"
)
// GetClientIP 从gin.Context获取客户端真实IP
// 优先级:X-Forwarded-For → X-Real-IP → RemoteAddr
func GetClientIP(c *gin.Context) string {
// 1. 尝试从 X-Forwarded-For 获取
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
if ip := parseXForwardedFor(xff); ip != "" {
return ip
}
}
// 2. 尝试从 X-Real-IP 获取
if xri := c.GetHeader("X-Real-IP"); xri != "" {
if ip := cleanIP(xri); ip != "" {
return ip
}
}
// 3. 从 RemoteAddr 获取
if ip := cleanIP(c.Request.RemoteAddr); ip != "" {
return ip
}
return ""
}
// parseXForwardedFor 解析X-Forwarded-For头
// 格式:client, proxy1, proxy2,取第一个有效IP
func parseXForwardedFor(header string) string {
if header == "" {
return ""
}
// 按逗号分割,取第一个IP
parts := strings.Split(header, ",")
for _, part := range parts {
ip := strings.TrimSpace(part)
if ip = cleanIP(ip); ip != "" {
return ip
}
}
return ""
}
2.2 handler和service中使用 获得ip
go
// api\test\v1\test_handler.go
package v1
import (
"go-api/internal/infra/ip"
)
// 从gin.Context获取客户端真实IP
func (h *UserDeviceLogHandler) GetClientIP(c *gin.Context) string {
// 获取客户端真实IP
clientIP := ip.GetClientIP(c)
resp, err := h.logService.CreateLog(c, userID, clientIP, &req)
if err != nil {
logx.G(c).Error("创建设备日志失败", "error", err)
return nil
}
return resp
}
go
// internal\modules\test\service\test_service.go
func (s *userDeviceLogService) CreateLog(ctx context.Context, userID int64, clientIP string, req *v1.CreateUserDeviceLogReq) (*v1.UserDeviceLogResp, error) {
// 解析IP获取城市
city := s.ipResolver.Resolve(clientIP)
if city == "" {
// 如果解析失败,保持数据库字段为空
city = nil
}
// 后面的处理我省略了
}
本地测试
- 下载
ip2region_v4.xdb文件到data/目录 - 确保配置
enabled: true - 启动服务:
go run main.go - 使用 Postman 测试,注意:
- 本地
::1或127.0.0.1无法解析城市(内网 IP) - 需要公网 IP 才能解析出城市
- 本地