Go netip 入门:更清楚地处理 IP 地址和网段

本文详解 Go 1.18 引入的 net/netip 包,涵盖 IP 解析、网段判断、白名单实现、IPv6 处理等场景,对比传统 net.IP 的优缺点并提供实战示例。

IP 地址不只是字符串

很多 Web 服务都会遇到 IP 处理:记录客户端 IP、判断内网地址、做白名单、解析 CIDR 网段、限制管理后台来源。初学时你可能会把 IP 当成字符串处理,比如 strings.HasPrefix(ip, "192.168.")。这种写法很脆弱,遇到 IPv6、前导格式、网段判断时很容易错。

Go 1.18 引入了 net/netip 包,提供了更现代的 IP 地址和前缀类型。它比早期 net.IP 在值语义、可比较性和清晰度上更适合很多场景。

这篇文章用几个小例子讲 netip.Addr 和 netip.Prefix 的基本用法,并对比传统 net.IP 的写法,帮助你在现代 Go 项目中做出正确选择。

解析 IP 地址

addr, err := netip.ParseAddr("192.168.1.10")
if err != nil {
	return err
}

fmt.Println(addr.String())
fmt.Println(addr.Is4())
fmt.Println(addr.Is6())

IPv6:

addr, err := netip.ParseAddr("2001:db8::1")
if err != nil {
	return err
}
fmt.Println(addr.Is6())

netip.Addr 是值类型,可以比较,也可以作为 map key:

seen := map[netip.Addr]bool{}
seen[addr] = true

这比 net.IP 的切片表示更方便。net.IP 不能直接作为 map key,因为切片不可比较。

netip.Addr 与 net.IP 的核心差异

传统 net.IP 是一个字节切片,值语义不清晰。比如两个代表同一 IP 的 net.IP 变量可能不相等,因为底层切片地址不同。

ip1 := net.ParseIP("192.168.1.1")
ip2 := net.ParseIP("192.168.1.1")
fmt.Println(ip1.Equal(ip2)) // true,但不能用 ==

而 netip.Addr 是值类型:

addr1 := netip.MustParseAddr("192.168.1.1")
addr2 := netip.MustParseAddr("192.168.1.1")
fmt.Println(addr1 == addr2) // true

值类型意味着零值安全、可比较、可作为 map key。这些特性让 netip.Addr 在配置解析、缓存、集合运算中更方便。

解析网段

prefix, err := netip.ParsePrefix("192.168.1.0/24")
if err != nil {
	return err
}

addr, _ := netip.ParseAddr("192.168.1.42")
fmt.Println(prefix.Contains(addr)) // true

IPv6 网段也一样:

prefix, err := netip.ParsePrefix("2001:db8::/32")

白名单可以这样表示:

type IPAllowList struct {
	prefixes []netip.Prefix
}

func NewIPAllowList(values []string) (IPAllowList, error) {
	prefixes := make([]netip.Prefix, 0, len(values))
	for _, value := range values {
		prefix, err := netip.ParsePrefix(value)
		if err != nil {
			return IPAllowList{}, fmt.Errorf("parse prefix %s: %w", value, err)
		}
		prefixes = append(prefixes, prefix)
	}
	return IPAllowList{prefixes: prefixes}, nil
}

func (l IPAllowList) Allows(addr netip.Addr) bool {
	for _, prefix := range l.prefixes {
		if prefix.Contains(addr) {
			return true
		}
	}
	return false
}

调用:

allowList, err := NewIPAllowList([]string{
	"127.0.0.1/32",
	"10.0.0.0/8",
})
if err != nil {
	return err
}

addr, _ := netip.ParseAddr("10.1.2.3")
fmt.Println(allowList.Allows(addr))

从 HTTP 请求里取客户端 IP

最直接的来源是:

host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
	return netip.Addr{}, err
}

addr, err := netip.ParseAddr(host)
if err != nil {
	return netip.Addr{}, err
}

如果服务部署在反向代理后面,真实客户端 IP 可能在 X-Forwarded-For 或 X-Real-IP。但这些头不能无条件信任,因为客户端也能伪造。只有当请求来自可信代理时,才应该读取这些头。

一个保守函数:

func ClientAddr(r *http.Request) (netip.Addr, error) {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return netip.Addr{}, err
	}
	return netip.ParseAddr(host)
}

真实生产环境可以在网关层统一处理真实 IP,并把规则写清楚。业务服务不要各自猜。

管理后台 IP 限制中间件

func RequireIP(allowList IPAllowList, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		addr, err := ClientAddr(r)
		if err != nil || !allowList.Allows(addr) {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

使用:

allowList, err := NewIPAllowList([]string{"10.0.0.0/8"})
if err != nil {
	log.Fatal(err)
}

mux.Handle("/admin", RequireIP(allowList, adminHandler))

这只是入门版本。真实管理后台还需要登录、权限、审计和更完整的代理信任规则。IP 白名单是补充,不是唯一安全机制。

IPv6 与 IPv4 的兼容处理

现代服务越来越多地需要同时支持 IPv4 和 IPv6。netip.Addr 让这种兼容处理更自然:

func IsPrivate(addr netip.Addr) bool {
	if addr.Is4() {
		return addr.IsPrivate()
	}
	// IPv6 也支持 IsPrivate 判断
	return addr.IsPrivate()
}

netip.Addr 提供了丰富的方法来判断地址属性:

  • IsPrivate():私有地址(RFC 1918)
  • IsLoopback():回环地址
  • IsMulticast():多播地址
  • IsLinkLocalUnicast():链路本地单播

这些方法不仅省掉了手写字符串判断,还正确覆盖了 IPv6 场景。

网段重叠与合并

有时候需要判断两个网段是否重叠:

func PrefixesOverlap(a, b netip.Prefix) bool {
	// 取较小的前缀(更宽泛的网段)作为基准
	if a.Bits() < b.Bits() {
		return a.Contains(b.Maddr()) || a.Contains(b.Addr())
	}
	return b.Contains(a.Maddr()) || b.Contains(a.Addr())
}

这其实是一个很常见的需求,尤其是在网络管理系统中。netip.Prefix 提供了 Maddr() 方法获取网络地址,Broadcast() 获取广播地址,方便做各种网段运算。

配置里的 IP 白名单

IP 白名单经常出现在配置文件里,比如 YAML:

allow_list:
  - "10.0.0.0/8"
  - "172.16.0.0/12"
  - "127.0.0.1/32"

解析配置时可以直接用 netip.ParsePrefix:

func ParseAllowList(raw []string) (IPAllowList, error) {
	return NewIPAllowList(raw)
}

如果配置格式允许单独 IP 和网段混用,可以统一处理:

func ParseIPOrPrefix(s string) (netip.Prefix, error) {
	if strings.Contains(s, "/") {
		return netip.ParsePrefix(s)
	}
	addr, err := netip.ParseAddr(s)
	if err != nil {
		return netip.Prefix{}, err
	}
	return netip.PrefixFrom(addr, addr.BitLen()), nil
}

常见错误与排查

错误:IP 字符串包含端口

netip.ParseAddr("192.168.1.1:8080") 会报错。端口需要用 net.SplitHostPort 先分离。

错误:IPv6 地址没有括号

netip.ParseAddr("::1") 可以工作,但 net.SplitHostPort("[::1]:8080") 的输入需要方括号。

错误:网段位宽超过地址长度

netip.ParsePrefix("192.168.1.1/33") 对 IPv4 会报错,因为 IPv4 只有 32 位。

错误:用 net.IP 作为 map key

// 错误
m := map[net.IP]bool{}

应该用 netip.Addr 或把 net.IP 转成字符串。

从 net.IP 迁移到 netip.Addr

如果你已有代码使用 net.IP,迁移到 netip.Addr 通常很直接:

// 旧代码
ip := net.ParseIP(s)

// 新代码
addr, err := netip.ParseAddr(s)

.String() 的输出格式一致。唯一需要注意的是 net.IP 的 4-in-6 格式(IPv4 映射 IPv6 会表示为 ::ffff:192.168.1.1),netip.Addr 对此有更明确的方法控制。

IP 地址的序列化与存储

netip.Addr 支持标准序列化接口,可以安全存入 JSON、数据库存储和网络传输中:

type ServerConfig struct {
	BindAddr netip.Addr `json:"bind_addr"`
	Allowed  []netip.Prefix `json:"allowed"
}

cfg := ServerConfig{
	BindAddr: netip.MustParseAddr("0.0.0.0"),
	Allowed:  []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
}

b, _ := json.Marshal(cfg)
fmt.Println(string(b))

输出会是人类可读的字符串格式,而不是二进制切片。这比 net.IP 方便很多。

数据库存储时,可以借助 MarshalText 和 UnmarshalText 实现 Scanner 和 Valuer:

func (a *netip.Addr) Scan(value interface{}) error {
	if value == nil {
		return nil
	}
	s, ok := value.(string)
	if !ok {
		return fmt.Errorf("invalid type %T", value)
	}
	addr, err := netip.ParseAddr(s)
	if err != nil {
		return err
	}
	*a = addr
	return nil
}

func (a netip.Addr) Value() (driver.Value, error) {
	if !a.IsValid() {
		return nil, nil
	}
	return a.String(), nil
}

这样 IP 地址在数据库中存储为规范的文本格式,查询时也能直接比较。

遍历网段内所有地址

有时候需要遍历一个网段内的所有地址,比如扫描或分配:

func PrefixAddresses(p netip.Prefix) []netip.Addr {
	var addrs []netip.Addr
	addr := p.Addr()
	for {
		if !p.Contains(addr) {
			break
		}
		addrs = append(addrs, addr)
		addr = addr.Next()
		if !addr.IsValid() {
			break
		}
	}
	return addrs
}

注意 IPv4 网段 /24 有 256 个地址尚可遍历,/16 有 65536 个,不要在大网段上盲目全部展开。

速率限制中的 IP 键

做 API 速率限制时,用 IP 做限速键很自然:

type RateLimiter struct {
	mu      sync.Mutex
	buckets map[netip.Addr]*tokenBucket
}

func (rl *RateLimiter) Allow(addr netip.Addr) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()

	bucket, ok := rl.buckets[addr]
	if !ok {
		bucket = newBucket(100, time.Minute)
		rl.buckets[addr] = bucket
	}
	return bucket.Allow()
}

netip.Addr 作为 map key,不会因为格式差异导致同一 IP 出现多个条目。这是它比 net.IP 更适合做键的重要原因。

实战 FAQ

Q: netip 包需要 Go 1.18 吗?
A: 是的,net/netip 在 Go 1.18 中引入。旧版本项目可以继续使用 net.IP。

Q: netip.Addr 支持 UnmarshalJSON 吗?
A: 支持,netip.Addr 和 netip.Prefix 都实现了 MarshalText 和 UnmarshalText,配合 JSON 标签可以直接序列化。

Q: 怎么判断两个 IPv6 地址是否属于同一网段?
A: 先解析成 netip.Prefix,然后用 Contains 判断。

Q: netip 能替代所有 net.IP 的用法吗?
A: 大部分场景可以替代。如果涉及底层 socket 编程,可能需要 net.IP 配合系统调用。另外 net.ParseCIDR 返回的 net.IPNet 在某些网络工具中还有用途。

Q: IPv4 映射的 IPv6 地址怎么处理?
A: netip.Addr 提供 Unmap() 方法可以把 IPv4 映射的 IPv6 地址转回纯 IPv4,方便统一比较。

Q: 网段包含判断时包含网段地址本身吗?
A: prefix.Contains(addr) 对网段地址(如 192.168.1.0/24 中的 192.168.1.0)和广播地址都是包含的。如果需要排除,需要额外判断。

最佳实践速查表

场景推荐做法
解析 IP 字符串netip.ParseAddr(s)
解析 CIDR 网段netip.ParsePrefix(s)
判断地址类型addr.Is4(), addr.Is6(), addr.IsPrivate()
网段包含判断prefix.Contains(addr)
作为 map key直接用 netip.Addr
存储到数据库转为字符串存储,读取时解析
序列化到 JSON配合 MarshalText 直接序列化
遍历网段地址addr.Next() 循环,注意性能
反向代理取 IP网关层统一处理,业务层不猜测
IP 白名单用 []netip.Prefix + Contains 判断

小结

net/netip 让 Go 处理 IP 地址和网段更清楚。netip.Addr 可以表示 IPv4 和 IPv6,netip.Prefix 可以判断网段包含关系,二者都是值语义,适合 map key 和配置解析。

不要把 IP 当普通字符串判断。只要涉及网段、IPv6、白名单和安全边界,就应该使用专门类型。netip 是现代 Go 网络相关代码中非常值得掌握的标准库能力。从 net.IP 迁移过来不仅提升代码清晰度,还能减少很多因切片比较带来的隐蔽 bug。

日常开发中记住几个关键点:值语义更安全、序列化更直接、网段判断更准确。熟练运用 netip 包后,你会不再把 IP 当作字符串,而是当作一个有着丰富行为的真实类型来处理。网络编程中每一个边界条件的正确处理,都是系统安全和稳定性的重要保障。掌握 netip 能让你在构建网络服务时更有信心和底气。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练