Ping命令这种事情用Go也能优雅实现

在网络协议栈中,TCP(Transmission Control Protocol) 是传输层的核心协议,负责提供可靠的、面向连接的通信。当我们浏览网页、发送邮件或传输文件时,TCP确保数据完整有序地到达目的地。它通过三次握手建立连接,使用序列号和确认机制保证可靠性,并通过流量控制和拥塞控制管理数据传输。

但TCP的可靠性是有代价的:当网络出现问题时,TCP连接会默默重传数据包,而不会主动告知用户底层发生了什么故障。这就引出了一个问题:当网络不通时,我们如何诊断底层连接问题?

ICMP:网络层的诊断专家

ICMP(Internet Control Message Protocol) 作为IP协议的辅助协议,位于网络层(OSI第三层),专门负责传递控制信息和错误报告:

1)当路由器无法传递数据包时,发送"目标不可达"消息

2)当数据包TTL过期时,发送"超时"通知

3)提供网络可达性测试(Ping的核心)

4)支持路径MTU发现等关键功能

Ping命令正是基于ICMP协议的最常用网络诊断工具。它的工作原理简单而优雅:

1)发送ICMP回显请求(类型8) 到目标主机

2)等待目标主机返回ICMP回显应答(类型0)

3)计算往返时间(RTT)并统计结果

用Go实现Ping命令工具

下面我们使用Go语言的golang.org/x/net/icmp包实现一个完整的Ping工具,整个过程采用面向对象的设计思路,主要包含以下几个核心组件:

数据结构设计

go 复制代码
// PingResult 表示单个ping请求的结果
type PingResult struct {
    Seq      int
    Size     int
    TTL      int
    RTT      time.Duration
    Error    error
    Received bool
    SendTime time.Time
}

// PingStats 表示ping统计信息
type PingStats struct {
    Target   string
    IP       string
    Sent     int
    Received int
    MinRTT   time.Duration
    MaxRTT   time.Duration
    TotalRTT time.Duration
    mu       sync.Mutex // 保护并发访问
}

// Pinger 表示ping工具
type Pinger struct {
	Target      string        // 目标主机名或IP
	Count       int           // ping次数,0表示无限
	Interval    time.Duration // ping间隔
	Timeout     time.Duration // 单个ping超时
	Size        int           // 数据包大小
	TTL         int           // 生存时间
	ID          int           // ICMP标识符
	IPv6        bool          // 是否使用IPv6
	stats       PingStats     // 统计信息
	conn        net.PacketConn
	ipv4Conn    *ipv4.PacketConn
	ipv6Conn    *ipv6.PacketConn
	addr        net.Addr
	sequence    int
	done        chan bool
	wg          sync.WaitGroup
	stopOnce    sync.Once
	sendChan    chan *icmp.Message
	receiveChan chan *PingResult
}

这种设计清晰地分离了关注点:

PingResult:单次ping操作的结果

PingStats:整体统计信息

Pinger:核心功能实现

初始化与配置

go 复制代码
func NewPinger(target string) (*Pinger, error) {
    // 初始化Pinger实例
    p := &Pinger{
        Target:      target,
        Count:       4,
        Interval:    time.Second,
        Timeout:     time.Second * 2,
        Size:        56,
        TTL:         64,
        ID:          os.Getpid() & 0xffff,
        // ... 其他初始化
    }

    // 解析目标地址
    ipAddr, err := net.ResolveIPAddr("ip", target)
    if err != nil {
        return nil, fmt.Errorf("无法解析目标地址: %w", err)
    }

    // 根据地址类型选择IPv4或IPv6
    p.IPv6 = ipAddr.IP.To4() == nil
    if p.IPv6 {
        return p.setupIPv6(ipAddr)
    }
    return p.setupIPv4(ipAddr)
}

首先这段代码使用了工厂模式创建Pinger实例,然后判断IPv4/IPv6后再执行后面的Ping命令具体逻辑。

执行Ping命令

go 复制代码
// Run 开始ping操作
func (p *Pinger) Run() {
	defer p.conn.Close()

	// 设置信号处理,捕获更多类型的终止信号
	c := make(chan os.Signal, 1)
	signal.Notify(c, os.Interrupt, os.Kill)
	go func() {
		sig := <-c
		fmt.Printf("\n收到信号 %v,正在停止...\n", sig)
		p.Stop()
	}()

	fmt.Printf("PING %s (%s) %d(%d) bytes of data:\n",
		p.stats.Target, p.stats.IP, p.Size, p.Size+8) // 8 bytes for ICMP header

	// 启动接收goroutine
	p.wg.Add(1)
	go p.receiver()

	// 启动发送goroutine
	p.wg.Add(1)
	go p.sender()

	// 启动结果处理goroutine
	p.wg.Add(1)
	go p.resultHandler()

	// 等待完成
	<-p.done
	close(p.receiveChan) // 关闭接收通道,确保resultHandler能够退出
	p.wg.Wait()
	p.stats.print()
}

这段代码实现了 Pinger 结构体的 Run 方法,负责启动完整的 ping 流程:

它首先设置连接关闭和中断信号处理确保优雅退出,打印目标信息后启动三个核心协程------sender 发送请求、receiver 接收响应、resultHandler 处理统计结果

然后主程序通过等待 done 通道阻塞,直到收到停止信号后同步所有协程退出,最终打印汇总的 ping 统计信息。整个过程实现了并发执行、资源安全和有序终止。

核心逻辑

其实整个Ping命令工具的核心逻辑就是Run()方法中的三个子流程,分别为receiver()、sender()和resultHandler(),下面我们分别讲解:

receiver()

receiver 方法负责持续监听 ICMP 响应,解析响应消息,计算 RTT 并将结果发送到 receiveChan 通道。

1)函数初始化与资源清理

go 复制代码
func (p *Pinger) receiver() {
    defer p.wg.Done()

    // 创建接收缓冲区
    rb := make([]byte, 1500)

    // 创建一个映射来存储发送时间
    sendTimeMap := make(map[int]time.Time)
    var mapMutex sync.Mutex
  • defer p.wg.Done():确保函数退出时通知 WaitGroup 该 goroutine 已完成。
  • rb := make([]byte, 1500):创建一个 1500 字节的缓冲区,用于存储接收到的 ICMP 数据包。
  • sendTimeMap:用于存储每个 ICMP 请求的发送时间,键为序列号。
  • mapMutex:互斥锁,用于保护 sendTimeMap 的并发访问。

2)监听发送消息并记录发送时间

go 复制代码
// 监听发送的消息,记录发送时间
// 将此goroutine添加到WaitGroup以确保程序退出时它也能正确退出
p.wg.Add(1)
go func() {
    defer p.wg.Done()
    for {
        select {
        case <-p.done:
            return
        case msg := <-p.sendChan:
            if echo, ok := msg.Body.(*icmp.Echo); ok {
                mapMutex.Lock()
                sendTimeMap[echo.Seq] = time.Now()
                mapMutex.Unlock()
            }
        }
    }
}()

启动一个新的 goroutine 监听 sendChan 通道,当有消息发送时,记录该消息的发送时间到 sendTimeMap 中。

3)持续接收 ICMP 响应

go 复制代码
for {
    select {
    case <-p.done:
        return
    default:
        // 接收响应
        if err := p.conn.SetReadDeadline(time.Now().Add(p.Timeout)); err != nil {
            log.Printf("设置读取超时失败: %v", err)
            continue
        }
  • 使用 select 监听 p.done 通道,如果接收到信号则退出循环。
  • 设置读取超时时间,若设置失败则记录日志并继续下一次循环。

4)根据协议类型读取响应

go 复制代码
var n int
var err error
var ttl int = p.TTL // 默认使用设置的TTL值

// 根据IPv4/IPv6使用不同的读取方法来获取控制消息
if p.IPv6 {
    if p.ipv6Conn != nil {
        var cm *ipv6.ControlMessage
        n, cm, _, err = p.ipv6Conn.ReadFrom(rb)
        if err == nil && cm != nil {
            ttl = cm.HopLimit
        }
    } else {
        n, _, err = p.conn.ReadFrom(rb)
    }
} else {
    if p.ipv4Conn != nil {
        var cm *ipv4.ControlMessage
        n, cm, _, err = p.ipv4Conn.ReadFrom(rb)
        if err == nil && cm != nil {
            ttl = cm.TTL
        }
    } else {
        n, _, err = p.conn.ReadFrom(rb)
    }
}

根据 IPv4 或 IPv6 协议类型,使用不同的方法读取 ICMP 响应,并尝试获取 TTL 值。

5)错误处理

go 复制代码
if err != nil {
    if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
        // 超时,继续等待下一个包
        continue
    }
    log.Printf("读取响应失败: %v", err)
    continue
}

若读取过程中出现错误,判断是否为超时错误,若是则继续等待下一个包,否则记录错误日志并继续。

6)解析 ICMP 响应

go 复制代码
// 获取接收时间
receiveTime := time.Now()

// 解析响应
var proto int
if p.IPv6 {
    proto = protocolIPv6ICMP
} else {
    proto = protocolICMP
}

rm, err := icmp.ParseMessage(proto, rb[:n])
if err != nil {
    log.Printf("解析ICMP消息失败: %v", err)
    continue
}

记录接收时间,根据协议类型解析接收到的 ICMP 消息。

7)处理 ICMP 响应

go 复制代码
// 处理响应
switch rm.Type {
case ipv4.ICMPTypeEchoReply, ipv6.ICMPTypeEchoReply:
    echoReply, ok := rm.Body.(*icmp.Echo)
    if !ok {
        continue
    }

    if echoReply.ID != p.ID {
        continue // 忽略不匹配的响应
    }

    // 获取发送时间并计算RTT
    mapMutex.Lock()
    sendTime, exists := sendTimeMap[echoReply.Seq]
    if !exists {
        sendTime = receiveTime.Add(-p.Timeout) // 如果找不到发送时间,使用估计值
    }
    delete(sendTimeMap, echoReply.Seq) // 清理已使用的条目
    mapMutex.Unlock()

    rtt := receiveTime.Sub(sendTime)

    // 创建结果
    p.receiveChan <- &PingResult{
        Seq:      echoReply.Seq,
        Size:     len(echoReply.Data),
        TTL:      ttl,
        RTT:      rtt,
        Received: true,
        SendTime: sendTime,
    }
default:
    // 忽略其他类型的ICMP消息
}

仅处理 ICMP Echo Reply 消息,验证消息的 ID 是否匹配。从 sendTimeMap 中获取发送时间,计算 RTT,然后将结果封装成 PingResult 结构体并发送到 receiveChan 通道。

sender()

sender 方法会按照设定的间隔发送 ICMP 请求包,直到达到指定的发送次数或者收到停止信号。在发送过程中,它会处理消息序列化、网络发送、超时设置等操作,同时更新统计信息。

1)函数初始化与资源清理

go 复制代码
func (p *Pinger) sender() {
    defer p.wg.Done()

    seq := 0
    ticker := time.NewTicker(p.Interval)
    defer ticker.Stop()
  • defer p.wg.Done():确保函数结束时通知 sync.WaitGroup 该 goroutine 已完成。
  • seq := 0:初始化 ICMP 请求包的序列号。
  • ticker := time.NewTicker(p.Interval):创建一个定时器,按照 p.Interval 设定的间隔触发。
  • defer ticker.Stop():确保函数结束时停止定时器,避免资源泄漏。

2)主循环逻辑

go 复制代码
for {
    select {
    case <-p.done:
        return
    case <-ticker.C:
        if p.Count > 0 && seq >= p.Count {
            p.Stop()
            return
        }

        seq++
        p.sequence = seq

select 语句用于监听两个通道:

  • <-p.done:若收到该通道的信号,说明需要停止发送,函数直接返回。
  • <-ticker.C:定时器触发,开始发送新的 ICMP 请求包。
  • if p.Count > 0 && seq >= p.Count:若 p.Count 大于 0 且已发送的包数量达到 p.Count,调用 p.Stop() 停止操作并返回。
  • seq++p.sequence = seq:更新序列号。

3)创建 ICMP 消息

go 复制代码
// 创建ICMP消息
var typ icmp.Type
if p.IPv6 {
    typ = ipv6.ICMPTypeEchoRequest
} else {
    typ = ipv4.ICMPTypeEcho
}

// 创建数据负载
payload := make([]byte, p.Size)
for i := range payload {
    payload[i] = byte(i & 0xff)
}

msg := &icmp.Message{
    Type: typ,
    Code: 0,
    Body: &icmp.Echo{
        ID:   p.ID,
        Seq:  seq,
        Data: payload,
    },
}
  • 根据是否使用 IPv6 协议,选择不同的 ICMP 消息类型。
  • 创建指定大小的数据负载,并用 i & 0xff 的结果填充。
  • 构建 icmp.Message 结构体,包含消息类型、代码、标识符、序列号和数据负载。

4)序列化消息

go 复制代码
// 序列化消息
wb, err := msg.Marshal(nil)
if err != nil {
    p.receiveChan <- &PingResult{
        Seq:   seq,
        Error: fmt.Errorf("序列化ICMP消息失败: %w", err),
    }
    continue
}

使用 msg.Marshal(nil) 对 ICMP 消息进行序列化。若序列化失败,将错误信息封装到 PingResult 结构体中,发送到 receiveChan 通道,然后继续下一次循环。

5)记录发送时间并发送消息到通道

go 复制代码
// 记录发送时间
sendTime := time.Now()

// 将消息发送到sendChan通道
select {
case p.sendChan <- msg:
default:
    // 如果通道已满,不阻塞
}

sendTime := time.Now():记录消息的发送时间。通过 select 语句尝试将消息发送到 p.sendChan 通道,若通道已满则不阻塞。

6)发送 ICMP 请求

go 复制代码
// 发送请求
if _, err := p.conn.WriteTo(wb, p.addr); err != nil {
    p.receiveChan <- &PingResult{
        Seq:      seq,
        Error:    fmt.Errorf("发送ICMP消息失败: %w", err),
        SendTime: sendTime,
    }
    continue
}

使用 p.conn.WriteTo(wb, p.addr) 将序列化后的消息发送到目标地址。若发送失败,将错误信息封装到 PingResult 结构体中,发送到 receiveChan 通道,然后继续下一次循环。

7)更新统计信息

go 复制代码
// 更新统计
p.stats.mu.Lock()
p.stats.Sent++
p.stats.mu.Unlock()

使用互斥锁 p.stats.mu 保护共享资源,更新已发送的 ICMP 请求包数量。

8)设置接收超时

go 复制代码
// 设置接收超时
deadline := sendTime.Add(p.Timeout)
if err := p.conn.SetReadDeadline(deadline); err != nil {
    p.receiveChan <- &PingResult{
        Seq:      seq,
        Error:    fmt.Errorf("设置读取超时失败: %w", err),
        SendTime: sendTime,
    }
}

计算接收超时时间,使用 p.conn.SetReadDeadline(deadline) 设置读取截止时间。若设置失败,将错误信息封装到 PingResult 结构体中,发送到 receiveChan 通道。

resultHandler()

resultHandler 方法会持续监听 p.done 通道和 p.receiveChan 通道,根据接收到的信号或结果进行相应处理,直到收到停止信号。

1)函数初始化与资源清理

go 复制代码
func (p *Pinger) resultHandler() {
    defer p.wg.Done()

defer p.wg.Done():确保函数退出时通知 sync.WaitGroup 该 goroutine 已完成,方便主程序等待所有 goroutine 结束。

2)主循环逻辑

go 复制代码
for {
    select {
    case <-p.done:
        return

select 语句用于监听两个通道:

  • <-p.done:若收到该通道的信号,说明需要停止处理结果,函数直接返回。

3)处理接收到的 PingResult

go 复制代码
case result := <-p.receiveChan:
    if result.Error != nil {
         log.Printf("Ping错误 (seq=%d): %v", result.Seq, result.Error)
         continue
    }
  • case result := <-p.receiveChan:从 p.receiveChan 通道接收 PingResult 结构体实例。
  • if result.Error != nil:若 result 中包含错误信息,使用 log.Printf 打印错误日志,包含序列号和错误详情,然后跳过本次循环继续等待下一个结果。

4)处理成功接收的响应

go 复制代码
if result.Received {
      p.stats.update(*result)
      // 格式化RTT为毫秒,保留两位小数
      rttMs := float64(result.RTT.Microseconds()) / 1000.0
      fmt.Printf("%d bytes from %s: icmp_seq=%d ttl=%d time=%.2f ms\n",
            result.Size, p.stats.IP, result.Seq, result.TTL, rttMs)
      }
  • if result.Received:若 Received 字段为 true,表示成功接收到 ICMP 响应。
  • p.stats.update(*result):调用 PingStats 结构体的 update 方法,更新统计信息,如接收次数、最小 RTT、最大 RTT 等。
  • rttMs := float64(result.RTT.Microseconds()) / 1000.0:将 RTT 转换为毫秒,保留两位小数。
  • fmt.Printf:打印接收到的响应信息,包含数据包大小、目标 IP 地址、序列号、TTL 值和 RTT 时间。

命令行参数处理

go 复制代码
func main() {
    // 解析命令行参数
    var (
        count    = flag.Int("c", 4, "发送的ping包数量 (0 = 无限)")
        interval = flag.Duration("i", time.Second, "发送间隔")
        timeout  = flag.Duration("W", time.Second*2, "等待响应超时时间")
        size     = flag.Int("s", 56, "发送的数据包大小")
        ttl      = flag.Int("t", 64, "生存时间")
    )
    flag.Parse()

    // ... 参数验证和应用
}

最后是main函数的参数处理,Go的flag包提供了简洁而强大的命令行参数处理能力,支持多种数据类型,包括自定义的time.Duration类型。

编译工具并使用

最后我们编译自己用Go语言开发的Ping工具,直接在main函数所在目录执行go build

然后我们执行下编译后的二进制工具,注意在Mac系统或Linux系统非root情况下需要加sudo,比如sudo ./go-ping -c 5 github.com,就表示向github.com发送ICMP请求一共5次,最后我们就会得到:

小总结

通过这个Go语言实现的Ping工具,我们不仅实践了如何构建一个实用的网络诊断工具,还深入了解了Go语言在以下方面的优势:

并发编程:goroutine和channel提供了简洁而强大的并发模型

网络编程:标准库提供了丰富的网络编程支持

错误处理:Go的错误处理机制简单而有效

系统交互:Go可以方便地与操作系统交互

这个实现展示了如何将这些特性结合起来,构建一个既实用又优雅的工具。无论是学习Go语言还是网络编程,这都是一个很好的参考案例。

完整代码: ​ github.com/ibarryyan/g...

相关推荐
天氰色等烟雨31 分钟前
支持MCP服务的多平台一键发布工具
大数据·github·mcp
icecreamstorm1 小时前
预处理Statement
后端
轻语呢喃1 小时前
useReducer : hook 中的响应式状态管理
javascript·后端·react.js
陈随易1 小时前
MoonBit能给前端开发带来什么好处和实际案例演示
前端·后端·程序员
Qter1 小时前
RedHat7.5运行qtcreator时出现qt.qpa.plugin: Could not load the Qt platform plugin "xcb
前端·后端
木西1 小时前
10 分钟搞定直播:Node.js + FFmpeg + flv.js 全栈实战
前端·后端·直播
胡萝卜1381 小时前
Spring扩展接口(五)- 实例2自定义插件
后端
ezl1fe1 小时前
RAG 每日一技(五):大海捞针第一步,亲手构建你的向量索引!
后端
GiraKoo1 小时前
【GiraKoo】Windows的目录ownership问题
后端
OpenTiny社区1 小时前
TinyEditor v4.0 alpha:表格更强大,表情更丰富,上传体验超乎想象!
前端·github