Go猜想录
大道至简,悟者天成
net 包使用指南

网络编程

net 包是网络相关的核心包。net 里面包含了 http、rpc 等关键包。

在 net 里面,最重要的两个调用:

  • Listen(network, addr string):监听某个端口,等待客户端连接
  • Dial(network, addr string):拨号,其实也就是连上某个服务端
net
├── http
   ├── cgi
      └── testdata
   ├── cookiejar
   ├── fcgi
   ├── httptest
   ├── httptrace
   ├── httputil
   ├── internal
      ├── ascii
      └── testcert
   ├── pprof
   └── testdata
├── internal
   └── socktest
├── mail
├── netip
├── rpc
   └── jsonrpc
├── smtp
├── testdata
├── textproto
└── url

tree -d

通信基本流程

基本分成两个大阶段。

创建连接阶段:

  • 服务端开始监听一个端口
  • 客户端拨通服务端,两者协商创建连接 (TCP)

通信阶段:

  • 客户端不断发送请求
  • 服务端读取请求
  • 服务端处理请求
  • 服务端写回响应

net-1.png

net.Listen

Listen 是监听一个端口,准备读取数据。它还有几个类似接口,可以直接使用:

  • ListenTCP
  • ListenUDP
  • ListenIP
  • ListenUnix

这几个方法都是返回 Listener 的具体类,如 TCPListener。一般用 Listen 就可以,除非你要依赖于具体的网络协议特性。

网络通信用 TCP 还是用 UDP 是一个影响巨大的事情,一般确认了就不会改。

func Serve(addr string) error {
	listener, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	for {
		conn, err := listener.Accept()
		if err != nil {
			return err
		}
		go func() {
			handleConn(conn)
		}()
	}
}

处理连接

处理连接基本上就是在一个 for 循环内:

  • 先读数据:读数据要根据上层协议来决定怎么读。例如,简单的 RPC 协议一般是分成两段读,先读头部,根据头部得知 Body 有多长,再把剩下的数据读出来。
  • 处理数据
  • 回写响应:即便处理数据出错,也要返回一个错误给客户端,不然客户端不知道你处理出错了。
func handleConn(conn net.Conn) {
	for {
		// 读数据
		bs := make([]byte, 8)
		_, err := conn.Read(bs)
		if err == io.EOF || err == net.ErrClosed ||
			err == io.ErrUnexpectedEOF {
			// 一般关闭的错误比较懒得管
			// 也可以把关闭错误输出到日志
			_ = conn.Close()
			return
		}
		if err != nil {
			continue
		}
		res := handleMsg(bs)
		_, err = conn.Write(res)
		if err == io.EOF || err == net.ErrClosed ||
			err == io.ErrUnexpectedEOF {
			_ = conn.Close()
			return
		}
	}
}

错误处理

在读写的时候,都可能遇到错误,一般来说代表连接已经关掉的是这三个: EOF、 ErrUnexpectedEOF 和 ErrClosed。

但是,我建议只要是出错了就直接关闭,这样对客户端和服务端代码都简单。

func handleConnV1(conn net.Conn) {
	for {
		// 读数据
		bs := make([]byte, 8)
		_, err := conn.Read(bs)
		if err != nil {
			// 一般关闭的错误比较懒得管
			// 也可以把关闭错误输出到日志
			_ = conn.Close()
			return
		}
		res := handleMsg(bs)
		_, err = conn.Write(res)
		if err != nil {
			_ = conn.Close()
			return
		}
	}
}

net.Dial

net.Dial 是指创建一个连接,连上远端的服务器。

它也是有几个类似的方法:

  • DiallP
  • DialTCP
  • DialUDP
  • DialUnix
  • DialTimeout

只有 DialTimeout 稍微特殊一点,它多了一个超时参数。

类似于 Listen,我也是建议大家直接使用 DialTimeout,因为设置超时可以避免一直阻塞。

func Connect(addr string) error {
	conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
	if err != nil {
		return err
	}
	defer func() {
		_ = conn.Close()
	}()
	for {
		// 发送请求
		_, err = conn.Write([]byte("hello"))
		if err != nil {
			return err
		}
		res := make([]byte, 8)
		// 接受响应
		_, err = conn.Read(res)
		if err != nil {
			return err
		}

		// 这两句是为了测试,不用在意
		fmt.Println(string(res))
		time.Sleep(time.Second)
	}
}

goroutine 问题

前面的模板,我们是在创建了连接之后,就交给另外一个 goroutine 去处理,除了这个位置,还有两个位置:

  • 在读取了请求之后,交给别的 goroutine 处理,当前的 goroutine 继续读请求
  • 写响应的时候,交给别的 goroutine 去写

net-2.png

由上至下:

  • TCP 通信效率提高
  • 系统复杂度提高

因为 goroutine 非常轻量,所以即便是在模式一下,对于小型应用来说,性能也可以满足。

代码演示 —— 创建简单的 TCP 服务器

type Server struct {
	addr string
}

func (s *Server) StartAndServe() error {
	listener, err := net.Listen("tcp", s.addr)
	if err != nil {
		return err
	}
	for {
		conn, err := listener.Accept()
		if err != nil {
			return err
		}
		go func() {
			// 直接在这里处理
			er := s.handleConn(conn)
			if er != nil {
				_ = conn.Close()
				fmt.Printf("connect error: %v", er)
			}
		}()
	}
}

func (s *Server) handleConn(conn net.Conn) error {
	for {
		// 读数据长度
		bs := make([]byte, lenBytes)
		_, err := conn.Read(bs)
		if err != nil {
			return err
		}

		reqBs := make([]byte, binary.BigEndian.Uint64(bs))
		_, err = conn.Read(reqBs)
		if err != nil {
			return err
		}
		res := string(reqBs) + ", from response"
		// 总长度
		bs = make([]byte, lenBytes, len(res)+lenBytes)
		// 写入消息长度
		binary.BigEndian.PutUint64(bs, uint64(len(res)))
		bs = append(bs, res...)
		_, err = conn.Write(bs)
		if err != nil {
			return err
		}
	}
}

func TestServer_StartAndServe(t *testing.T) {
	s := &Server{
		addr: ":8080",
	}
	_ = s.StartAndServe()
}
// 假定我们永远用 8 个字节来存放数据长度
const lenBytes = 8

type Client struct {
	addr string
}

func (c *Client) Send(msg string) (string, error) {
	conn, err := net.DialTimeout("tcp", c.addr, 3*time.Second)
	if err != nil {
		return "", err
	}
	defer func() {
		_ = conn.Close()
	}()
	// 总长度
	bs := make([]byte, lenBytes, len(msg)+lenBytes)
	// 写入消息长度
	binary.BigEndian.PutUint64(bs, uint64(len(msg)))
	bs = append(bs, msg...)
	_, err = conn.Write(bs)
	if err != nil {
		return "", err
	}

	// 读取响应长度
	lenBs := make([]byte, lenBytes)
	_, err = conn.Read(lenBs)
	if err != nil {
		return "", err
	}
	resLength := binary.BigEndian.Uint64(lenBs)

	// 读取响应
	resBs := make([]byte, resLength)
	_, err = conn.Read(resBs)
	return string(resBs), nil
}

func TestClient_Send(t *testing.T) {
	testCases := []struct {
		req  string
		resp string
	}{
		{
			req:  "hello",
			resp: "hello, from response",
		},
		{
			req:  "aaa bbb cc \n",
			resp: "aaa bbb cc \n, from response",
		},
	}

	c := &Client{
		addr: "localhost:8080",
	}
	for _, tc := range testCases {
		t.Run(tc.req, func(t *testing.T) {
			resp, err := c.Send(tc.req)
			assert.Nil(t, err)
			assert.Equal(t, tc.resp, resp)
		})
	}
}

总结

  • 网络的基础知识,包含 TCP 和 UDP 的基础知识。
    • 三次握手和四次挥手
  • 如何利用 Go 写一个简单的 TCP 服务器。
  • 记住 goroutine 和连接的关系,可以在不同的环节使用不同的 goroutine,以充分利用 TCP 的全双工通信。

延伸阅读


知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。