go实现通过ip解析城市

通过ip解析城市

  • 环境:
    • Go 1.24.4+
    • ip2region 数据库文件

功能特性

  • ✅ 自动从 IP 解析城市信息
  • ✅ 支持 IPv4 和 IPv6 地址
  • ✅ 内网 IP 自动跳过(不报错)
  • ✅ 数据库文件缺失时优雅降级(服务可正常启动)
  • ✅ 请求中可手动指定 city,优先级高于 IP 解析

准备

下载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.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
    }
    // 后面的处理我省略了
}

本地测试

  1. 下载 ip2region_v4.xdb 文件到 data/ 目录
  2. 确保配置 enabled: true
  3. 启动服务:go run main.go
  4. 使用 Postman 测试,注意:
    • 本地 ::1127.0.0.1 无法解析城市(内网 IP)
    • 需要公网 IP 才能解析出城市
相关推荐
Java不加班2 小时前
Java 后端定时任务实现方案与工程化指南
后端
心在飞扬2 小时前
RAG 进阶检索学习笔记
后端
Moment2 小时前
想要长期陪伴你的助理?先从部署一个 OpenClaw 开始 😍😍😍
前端·后端·github
Das1_2 小时前
【Golang 数据结构】Slice 底层机制
后端·go
得物技术2 小时前
深入剖析Spark UI界面:参数与界面详解|得物技术
大数据·后端·spark
古时的风筝2 小时前
花10 分钟时间,把终端改造成“生产力武器”:Ghostty + Yazi + Lazygit 配置全流程
前端·后端·程序员
Cache技术分享2 小时前
340. Java Stream API - 理解并行流的额外开销
前端·后端
初次攀爬者2 小时前
RocketMQ 消息可靠性保障与堆积处理
后端·消息队列·rocketmq
ygxb2 小时前
如何去创建一个规范化的Agent SKIll?
后端·ai编程·claude