使用 Go 自定义 TCP 应用程序

2023-12-25 08:01:22 浏览数 (1)

TCP是全球所有数据传输的协议,例如HTTP和Websocket都通过TCP运行。即使是最常用的数据库,如 Mongo、Redis 或 Postgres,也使用 TCP 来运行其协议。

因此,编写自定义TCP应用程序只是创建一个TCP的超级协议。TCP 应用程序协议。

多亏了 golang,一半的工作就完成了,因为有一个用于此目的的原生包:“net”包。

首先,让我们看一下它在使用原始 TCP 时的一些注意事项。

  • 客户端管理。
  • 消息缓冲区管理。
  • 应用程序自定义协议。
  • 来自客户端的服务器连接。

由于 TCP 仅提供用于传输数据的协议,因此该日期的获取和解释是应用程序的工作。这就是存在这些考虑的原因。

Server

基本步骤是创建一个客户端可以连接到的服务器。如前所述,包网络具有所需的所有工具。

代码语言:go复制
package server

func Listen(p int) error {  
  srv, err := net.Listen("tcp", "127.0.0.1:%d", p)
  if err != nil {
    return err
  }
  defer srv.Close()
  return nil
}

函数“Listen”将侦听 127.0.0.1 IP(本地主机)上的端口。当函数结束时,松开获取的端口。

现在怎么样,服务器不能接受客户端,所以让我们编写代码。

代码语言:go复制
package server

func Listen(p int) error {  
  srv, err := net.Listen("tcp", "127.0.0.1:%d", p)
  if err != nil {
    return err
  }
  defer srv.Close()
  
  // infinity loop
  for {
    cli, err := srv.Accept()
    if err != nil {
      return err
    }
  }

  return nil
}

太好了,现在服务器可以接受客户端了。Accept 方法阻止循环,直到新客户端执行连接。

Accept 函数内部发生的情况是 TCP 握手。这包括三个步骤。

  • 客户端向服务器发送 SYN。
  • 服务器通过使用 SYN-ACK 响应客户端来接受该 SYN。
  • 客户端使用 ACK 进行响应。

完成这三个步骤后,即可建立连接。

SYN 和 ACK 是构成 TCP 本身一部分的 TCP 标志,其中 SYN menas Synchronize 和 ACK 表示 Acknowledged。

关闭连接

服务器目前只接受客户端,但从不对它们做任何事情。重要的是要知道关闭连接的责任在服务器上。这意味着如果服务器建立连接,则在使用后必须关闭它。

对于每个新的客户端连接,将执行一个处理客户端的 goroutine。

代码语言:go复制
package server

func Listen(p int) error {  
  srv, err := net.Listen("tcp", "127.0.0.1:%d", p)
  if err != nil {
    return err
  }
  defer srv.Close()
  
  // infinity loop
  for {
    cli, err := srv.Accept()
    if err != nil {
      return err
    }
    go handle(cli)
  }

  return nil
}

func handle(cli net.Conn) {
  defer cli.Close()
}

Conn 是 net package 中的一个接口,它提供了操作该特定连接所需的方法。

读取消息问题

当与客户端建立连接时。客户端和服务器都可以共享数据。但是这里出现了很大的启动问题。如何阅读消息?

这是一个问题,因为消息长度未知。通常在 TCP 中,消息缓冲区由“n”个字节块读取,直到没有更多字节可供读取。

另一种解决方案是将消息长度作为消息元数据的一部分发送。例如,在 HTTP 中,此长度在 Header 中发送。

这种机制是应用程序协议的一部分,而不是TCP本身。

自定义应用程序协议

自定义应用程序的协议只是客户端和服务器相互理解的一组规则。

此应用程序要遵循的规则是。

  • TCP 有效负载必须包含以下部分:长度消息和正文消息。
  • 长度消息是 TCP 有效负载的前 2 个字节。
  • 正文消息具有 JSON 格式。
  • 处理消息后,连接将关闭。

第二条规则确定正文消息的最大长度,即 2 个字节的整数。从 0x0000 到 0xFFFF,以十进制为基数:0 到 65535 字节。2 个字节 int 是 int16。

此应用程序的消息将如下所示。

代码语言:shell复制
LEN | BODY
0019 7B226D657373616765223A2268656C6C6F20776F726C64227D

LEN | BODY
25   {"message":"hello world"}

对协议进行编码

好吧,让我们对这个称为 JSONP(JSON 协议)的协议的规则进行编码

代码语言:go复制
package jsonp

func ReadMessage(c net.Conn) []byte {
  l := make([]byte, 2) // takes the first two bytes: the Length Message.
  i, _ := c.Read(l)    // read the bytes.

  lm := binary.BigEndian.Uint16l(l[:i]) // convert the bytes into int16.
  
  b := make([]byte, lm) // create the byte buffer for the body.
  e, _ := c.Read(b)     // read the bytes.
  
  return b[:e]          // returns the body
}

上面的函数有消息读取规则。一个导入的东西,提到长度消息字节是如何存储的。在这种情况下,以及在大多数情况下,对于 TCP,存储的字节具有 BigEndian 格式。

BigEndian 格式存储从右到左的字节。另一方面,LittleEndian 恰恰相反。

代码语言:shell复制
len = {00, 01}

BigEndian(len) => 0001 => 1: int16
LittleEndian(len) => 0100 => 256: int16
代码语言:go复制
package jsonp

func WriteMessage(m []byte, c net.Conn) {
  l := make([]byte, 2)  // creates the Length Message buffer.
  binary.BigEndian.PutUint16(l, uint16(len(m))) // Converts len to bytes.
  c.Write(append(l, m...)) // send the message
}

目前为止,一切都好。现在让我们创建一些 json marshal/unmarshal 函数,以便从该逻辑中抽象出来。

代码语言:go复制
package jsonp

func ReadJSON(c net.Conn, a any) error {
  msg := ReadMessage(c)
  return json.Unmarshal(msg, a)
}

func SendJSON(c net.Conn, a any) error {
  msg, err := json.Marshal(a)
  
  if err != nil {
    return err
  }

  WriteMessage(c, msg)
  return nil
}

处理客户端

服务器的最后一部分需要对原始的 Listen 函数进行一些修改。

为协议添加一个类型别名。

代码语言:go复制
package jsonp

type Handler func(c net.Conn)

此处理程序是发送和接收消息的上下文/回调。

在服务端中实现处理程序。

代码语言:go复制
package server

func Listen(p int, h jsonp.Handler) error {  
  srv, err := net.Listen("tcp", "127.0.0.1:%d", p)
  if err != nil {
    return err
  }
  defer srv.Close()
  
  // infinity loop
  for {
    cli, err := srv.Accept()
    if err != nil {
      return err
    }
    
    go func(c net.Conn, hd jsonp.Handler) {
      defer c.Close()
      hd(c)
    }(cli, h)
  }

  return nil
}

通过此修改,应用程序拥有客户端处理,而不是服务器。最后一个仅提供使用客户端的安全上下文(通过在使用后关闭连接)。

客户端

服务端已经完成,让我们继续客户端。这是一个简单的部分,因为网络包对TCP套接字的两端都使用相同的接口。因此,工作的一半已经完成。

与服务器的连接可以通过网络完成。拨号功能。但是,由于连接仅在发送一条消息时处于活动状态,之后连接将关闭,因此服务器连接本身就是消息发送者。

代码语言:go复制
package client

func Connect(p int, h jsonp.Handler) error {
  conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", p))
  if err != nil {
    return err
  }
  defer conn.Close()
  
  h(conn)
  
  return nil
}

客户端将发送“Hello World”消息。

代码语言:go复制
package main

type Message struct {
  Content string `json:"content"`
}

func main() {
  client.Connect(8080, func(c net.Conn) {
    jsonp.SendJSON(c, &Message{Content:"Hello World"})

    var res Message
    jsonp.ReadJSON(c, &res)

    fmt.Println(res.Content)
  })
}

服务器将执行与客户端相同的操作,但首先读取消息,然后发送“Hello World”作为响应。

代码语言:go复制
package main

type Message struct {
  Content string `json:"content"`
}

func main() {
  server.Listen(8080, func(c net.Conn) {
    var res Message
    jsonp.ReadJSON(c, &res)

    fmt.Println(res.Content)

    jsonp.SendJSON(c, &Message{Content:"Hello World"})
  })
}

本文不是要创建一个比现有协议更好的新协议,而是旨在了解在基于 TCP 的协议的引擎盖下会发生什么。

例如,使用本文中显示的概念,可以复制 HTTP 。

此外,为了避免每次发送消息时都连接和重新连接(就像 HTTP 一样),连接可以保持活动状态一段时间,直到客户端发送某种命令来关闭套接字。

我正在参与2023腾讯技术创作特训营第四期有奖征文,快来和我瓜分大奖!

0 人点赞