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 能让你在构建网络服务时更有信心和底气。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。