Go 网络与协议实现详解(TCP/HTTP/DNS/TCP粘包)

2022-02-11T10:00:00+08:00 | 7分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@
网络与协议实现TCP/IPHTTP/gRPC协议追问EchoServerEchoClient粘包处理HTTP ServerHTTP Client中间件链三次握手TIME_WAIT长度前缀编码路由匹配器四次挥手分隔符编码GET/POST路由SYN Flood定长编码gRPC 四种模式epoll原理DNS Resolver连接池实现HTTP/2流复用URL到渲染

学习目标

读完本文,你应该能够:

  • 手写 TCP Echo Server/Client,理解网络编程基础
  • 实现一个简单的 HTTP Server,解析请求行和头部
  • 实现 TCP 粘包/拆包的解决方案(长度前缀、分隔符、定长)
  • 理解 DNS Resolver 的工作原理并实现简化版
  • 掌握 HTTP/2 流复用和 gRPC 四种通信模式的应用场景
  • 回答关于 TCP 三次握手、TIME_WAIT、epoll 等高频原理问题

一、TCP Echo Server/Client

1.1 Echo Server

package main

import (
	"bufio"
	"fmt"
	"net"
)

func handleConnection(conn net.Conn) {
	defer conn.Close()
	
	reader := bufio.NewScanner(conn)
	for reader.Scan() {
		line := reader.Text()
		fmt.Fprintf(conn, "ECHO: %s\n", line)
	}
}

func main() {
	listener, err := net.Listen("tcp", ":8080")
	if err != nil {
		panic(err)
	}
	defer listener.Close()
	
	fmt.Println("Echo server started on :8080")
	
	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Printf("accept error: %v\n", err)
			continue
		}
		go handleConnection(conn)
	}
}

1.2 Echo Client

应用场景

在实际项目中,这个知识点经常应用于:

  1. 典型场景一:说明具体应用场景
  2. 典型场景二:说明另一个应用场景
  3. 面试要点:提醒面试中可能被问到的点
package main

import (
	"bufio"
	"fmt"
	"net"
	"os"
)

func main() {
	conn, err := net.Dial("tcp", "localhost:8080")
	if err != nil {
		panic(err)
	}
	defer conn.Close()
	
	go func() {
		serverReader := bufio.NewScanner(conn)
		for serverReader.Scan() {
			fmt.Println(serverReader.Text())
		}
	}()
	
	reader := bufio.NewScanner(os.Stdin)
	for reader.Scan() {
		fmt.Fprintf(conn, "%s\n", reader.Text())
	}
}

二、TCP 粘包/拆包处理(中兴必考!)

2.1 问题根源

TCP 是字节流协议,没有消息边界。多次 write 可能被合并发送,一次 read 可能读到多个消息。

2.2 方案一:长度前缀编码

type LengthPrefixCodec struct{}

func (c *LengthPrefixCodec) Encode(msg []byte) ([]byte, error) {
	// 4字节大端整数 + 消息体
	length := len(msg)
	header := make([]byte, 4)
	binary.BigEndian.PutUint32(header, uint32(length))
	return append(header, msg...), nil
}

func (c *LengthPrefixCodec) Decode(conn net.Conn) ([]byte, error) {
	// 先读4字节头
	header := make([]byte, 4)
	if _, err := io.ReadFull(conn, header); err != nil {
		return nil, err
	}
	length := int(binary.BigEndian.Uint32(header))
	
	// 再读消息体
	body := make([]byte, length)
	if _, err := io.ReadFull(conn, body); err != nil {
		return nil, err
	}
	return body, nil
}

2.3 方案二:分隔符编码

type DelimiterCodec struct {
	delimiter []byte
}

func (c *DelimiterCodec) Encode(msg []byte) []byte {
	return append(msg, c.delimiter.

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

..)
}

func (c *DelimiterCodec) Decode(data []byte) [][]byte {
	return bytes.Split(data, c.delimiter)
}

2.4 方案三:定长编码

const msgSize = 1024

type FixedLengthCodec struct{}

func (c *FixedLengthCodec) Encode(msg []byte) ([]byte, error) {
	if len(msg) > msgSize {
		return nil, fmt.Errorf("message too large")
	}
	buf := make([]byte, msgSize)
	copy(buf, msg)
	return buf, nil
}

func (c *FixedLengthCodec) Decode(data []byte) ([]byte, error) {
	

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

// 去除尾部空字节
	return bytes.TrimRight(data, "\x00"), nil
}
复习提示:生产环境推荐:长度前缀编码最通用,HTTP/Protobuf/TCP 网关都使用此方案。 分隔符适合文本协议(如 SMTP),定长适合固定格式数据。

三、简易 HTTP Server

type Server struct {
	mux     *ServeMux
	handler Handler
}

type ServeMux struct {
	m     map[string]Handler
	mu    sync.RWMutex
}

func (mux *ServeMux) Handle(pattern string, handler Handler) {
	mux.mu.Lock()
	defer mux.mu.Unlock()
	mux.m[pattern] = handler
}

func (mux *ServeMux) Find(pattern string) (Handler, bool) {
	mux.mu.RLock()
	defer mux.mu.RUnlock()
	if h, ok := mux.m[pattern]; ok {
		return h, true
	}
	// 简单通配符支持
	for p, h := range mux.m {
		if strings.HasSuffix(p, "/*") {
			if strings.HasPrefix(pattern, strings.TrimSuffix(p, "*")) {
				return h, true
			}
		}
	}
	return nil, false
}

type Request struct {
	Method string
	Path   string
	Header http.Header
	Body   []byte
}

type Response struct {
	StatusCode int
	Header     http.Header
	Body       []byte
}

func (s *Server) ServeConn(conn net.Conn) {
	defer conn.Close()
	
	reader := bufio.NewReader(conn)
	method, path, _ := readRequestLine(reader)
	header := readHeaders(reader)
	body, _ := io.ReadAll(reader)
	
	req := &Request{Method: method, Path: path, Header: header, Body: body}
	resp := s.mux.Dispatch(req)
	
	writeResponse(conn, resp)
}

func (mux *ServeMux) Dispatch(req *Request) *Response {
	handler, ok := mux.Find(req.Path)
	if !ok {
		return &Response{StatusCode: 404, Body: []byte("Not Found")}
	}
	resp := handler(req)
	return resp
}

四、DNS Resolver 简化版

type DNSResolver struct {
	server string // DNS服务器地址
}

func NewDNSResolver(server string) *DNSResolver {
	return &DNSResolver{server: server}
}

func (r *DNSResolver) Resolve(domain string) ([]net.IP, error) {
	// 构造 DNS 查询报文
	query := r.buildQuery(domain)
	
	// 发送到 DNS 服务器
	conn, err := net.Dial("udp", r.server+":53")
	if err != nil {
		return nil, err
	}
	defer conn.Close()
	
	if _, err := conn.Write(query); err != nil {
		return nil, err
	}
	
	// 读取响应
	reply := make([]byte, 512)
	if _, err := conn.Read(reply); err != nil {
		return nil, err
	}
	
	return r.parseResponse(reply)
}

func (r *DNSResolver) buildQuery(domain string) []byte {
	// DNS 报文格式:ID(2) + Flags(2) + QDCount(2) + ANCount(2) + NSCount(2) + ARCount(2) + Question...
	header := make([]byte, 12)
	binary.BigEndian.PutUint16(header[0:], 0x0100) // standard query, recursion desired
	binary.BigEndian.PutUint16(header[4:], 1)      // 1 question
	
	// 编码域名
	var qd []byte
	for _, label := range strings.Split(domain, ".") {
		qd = append(qd, byte(len(label)))
		qd = append(qd, []byte(label)...)
	}
	qd = append(qd, 0) // 终止符
	
	header = append(header, qd...)
	header = append(header, 0, 1) // QTYPE=A, QCLASS=IN
	return header
}

func (r *DNSResolver) parseResponse(reply []byte) ([]net.IP, error) {
	// 解析响应中的 Answer 部分
	// 简化版:实际解析较为复杂
	var ips []net.IP
	// ... 省略详细解析逻辑
	return ips, nil
}

五、ICMP Ping 工具

type Ping struct {
	id   int
	seq  int
Conn   net.PacketConn
}

func (p *Ping) Send(target string) error {
	addr, err := net.ResolveIPAddr("ip4:icmp", target)
	if err != nil {
		return err
	}
	
	msg := make([]byte, 8)
	msg[0] = 8  // ICMP Echo Request
	msg[1] = 0  // Code
	binary.BigEndian.PutUint16(msg[2:], uint16(p.id))
	binary.BigEndian.PutUint16(msg[4:], uint16(p.seq))
	checksum := checksum(msg)
	binary.BigEndian.PutUint16(msg[6:], checksum)
	
	_, err = p.Conn.WriteTo(msg, addr)
	return err
}

func checksum(data []byte) uint16 {
	sum := uint32(0)
	for i := 0; i < len(data)-1; i += 2 {
		sum += uint32(data[i])<<8 | uint32(data[i+1])
	}
	if len(data)%2 == 1 {
		sum += uint32(data[len(data)-1]) << 8
	}
	for sum > 0xffff {
		sum = (sum >> 16) + (sum & 0xffff)
	}
	return uint16(^sum)
}

六、TCP 网络原理追问

6.1 三次握手为什么不能两

应用场景

在实际项目中,这个知识点经常应用于:

  1. 典型场景一:说明具体应用场景
  2. 典型场景二:说明另一个应用场景
  3. 面试要点:提醒面试中可能被问到的点

次?

面试官
如果只有两次握手会发生什么问题?
面试官
候选人

假设只有两次握手:

  1. 客户端发 SYN
  2. 服务端回 ACK

此时服务端认为连接已建立,但客户端可能还没收到 SYN-ACK。 如果客户端的 SYN 丢失,客户端不会重传(因为没有确认机制),而服务端会一直等待。

三次握手的核心目的是双方都能确认对方的收发能力正常。

TCP 连接状态机:三次握手 + 四次挥手

CLOSED
<rect x="60" y="120" width="220" height="54" rx="10"/>
<text x="170" y="152" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">SYN_SENT</text>
<rect x="60" y="250" width="220" height="54" rx="10" stroke-width="3.5"/>
<text x="170" y="282" text-anchor="middle" fill="#B03030" font-size="13" font-weight="700" stroke="none">ESTABLISHED</text>
<rect x="60" y="360" width="220" height="54" rx="10"/>
<text x="170" y="392" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">FIN_WAIT_1</text>
<rect x="60" y="430" width="220" height="54" rx="10"/>
<text x="170" y="462" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">FIN_WAIT_2</text>
<rect x="60" y="500" width="220" height="54" rx="10" stroke-dasharray="7 4"/>
<text x="170" y="532" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">TIME_WAIT</text>
CLOSED
<rect x="720" y="120" width="220" height="54" rx="10"/>
<text x="830" y="152" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">LISTEN</text>
<rect x="720" y="200" width="220" height="54" rx="10"/>
<text x="830" y="232" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">SYN_RCVD</text>
<rect x="720" y="250" width="220" height="54" rx="10" stroke-width="3.5"/>
<text x="830" y="282" text-anchor="middle" fill="#B03030" font-size="13" font-weight="700" stroke="none">ESTABLISHED</text>
<rect x="720" y="330" width="220" height="54" rx="10"/>
<text x="830" y="362" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">CLOSE_WAIT</text>
<rect x="720" y="410" width="220" height="54" rx="10"/>
<text x="830" y="442" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">LAST_ACK</text>
<rect x="720" y="500" width="220" height="54" rx="10" stroke-dasharray="7 4"/>
<text x="830" y="532" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700" stroke="none">CLOSED</text>
① SYN② SYN + ACK③ FIN(主动关闭)④ FIN(被动关闭)主动关闭方被动关闭方TIME_WAIT 持续 2MSL:确保最后 ACK 到达 + 旧报文消散

看图要点:面试必问「为什么握手 3 次、挥手 4 次」。握手 3 次是为了双方都确认对方的收发能力;挥手 4 次是因为 TCP 全双工,被动方收到 FIN 后只能先回 ACK(此时还能发剩余数据),等自己也没数据了再发 FIN。主动关闭方最后进入 TIME_WAIT 保持 2MSL,防止最后 ACK 丢失导致连接未关闭、并让网络中旧报文消散。追问:高并发下 TIME_WAIT 过多会占端口,可用 SO_REUSEADDR 或调小 tcp_max_tw_buckets 缓解。

6.2 TIME_WAIT 的作用

主动关闭方状态变化:
FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT(2MSL) → CLOSED

被动关闭方状态变化:
CLOSE_WAIT → LAST_ACK → CLOSED

2MSL 的原因:

  1. 确保最后一个 ACK 能到达服务端
  2. 让本连接持续时间内产生的所有报文都从网络中消失

6.3 epoll 为什么快

// Go netpoller 伪代码
func epollWait(events []EpollEvent, maxEvents int, timeout int) int {
    return syscall.EpollWait(epfd, events, maxEvents, timeout)
}

// 事件驱动模型
f

### 应用场景

在实际项目中,这个知识点经常应用于:

1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点

unc poll() {
    for {
        n := epollWait(activeEvents, maxEvents, -1)
        for i := 0; i < n; i++ {
            fd := activeEvents[i].Fd
            if activeEvents[i].Events&EPOLLIN != 0 {
                // 可读事件
                dispatchRead(fd)
            }
            if activeEvents[i].Events&EPOLLOUT != 0 {
                // 可写事件
                dispatchWrite(fd)
            }
        }
    }
}
复习提示:

epoll vs select/poll:

  • select/poll:每次调用 O(n) 遍历所有 fd
  • epoll:O(1) 获取就绪 fd(红黑树注册 + 就绪链表返回)
  • epoll 支持边缘触发(ET)和水平触发(LT)

七、HTTP/2 流复用概念

// HTTP/2 帧结构简化演示
type Frame struct {
    Length uint32
    Type   byte    // 0:DATA, 1:HEADERS, 2:PRIORITY, etc.
    Flags  byte
    StreamID uint32
    Payload []byte
}

// 多路复用的核心思想
// 一个 TCP 连接上并发多个 Stream
// 每个 Stream 有自己的 Stream ID
// 通过 Frame 头部标识属于哪个 Stream

八、gRPC 四种通信模式

模式描述典型场景
Unary单向请求响应CRUD 操作
Server Streaming服务端推送流实时数据流、日志订阅
Client Streaming客户端推送流大数据批量上传
Bidirectional Streaming双向流聊天室、游戏同步

九、自测题

面试官
应用层心跳包 vs TCP keepalive 有什么区别?怎么选?
面试官
候选人
TCP keepalive:内核级功能,默认2小时检测一次,粒度粗。 应用层心跳:自定义协议,可配置间隔,携带业务信息。

建议:需要快速检测连接失效时用应用层心跳;普通长连接用 TCP keepalive 即可。
面试官
如何实现一个带连接池的 HTTP Client?
面试官
候选人

核心要素:

  1. MaxIdleConns:空闲连接最大数
  2. IdleConnTimeout:空闲超时时间
  3. Transport:复用底层 TCP 连接
  4. 健康检查:定期检测连接是否有效

    Go 标准库的 http.Transport 已实现大部分功能,直接使用即可。

十、本章小结

复习提示:
  • TCP 粘包是网络编程的核心问题,长度前缀是最通用的解决方案
  • epoll 比 select/poll 快的关键在于 O(1) 事件分发,Go 的 netpoller 在此基础上包装
  • HTTP/2 流复用解决了 HTTP/1.1 的队头阻塞问题,QUIC 进一步解决了传输层队头阻塞
  • 下一篇将讲解存储系统实现:B+树、MVCC、连接池、Redis Client 等

参考

  • 《Go 网络编程》第 3 章:TCP 粘包处理
  • TCP/IP 详解卷一:第 18-20 章(TCP 状态机)
  • epoll 源码:https://man7.org/linux/man-pages/man2/epoll_wait.2.html
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!