使用低级语言的经验的人们(通常使用字节)在理解字节数的[]byte含义和用法方面通常没有挑战。

但是,如果您来自动态或高级语言背景,尽管我们所做的一切最终都是一堆字节,但高级语言向我们隐藏了此类细节。来自Ruby的背景,我同情这一挑战的人们。

受到与这些读者的交谈的启发,我决定尝试提供帮助。因此,让我们探索如何构建一个简单的基于TCP的应用协议。并在过程中彻底了解字节。

站在巨人的肩膀上
我们将要实现的协议将在TCP之上运行,因为:

TCP足够高级,因此我们不必担心太多的低级连接细节
Go通过该net软件包具有出色的TCP支持
net程序包提供的接口使我们可以播放字节(和字节片段)
TCP 在数据传输方面提供了一定的保证,因此我们不用担心什么
站在巨人的肩膀上比重新发明轮子更简单
现在,由于协议设计是一项艰巨的任务,因此我们将实现一个非常小巧的协议。我绝不是协议设计者,所以我们在这里要做的仅仅是一个例子。但是,如果您对协议有任何经验,请不要犹豫,给我留言(或留言),让我知道我做错了什么。

输入SLCK
让我们想象一下,无所不在的协作和通信应用程序Slack希望将其聊天设计转变为开放的Internet协议。作为开放的互联网协议,任何人都可以通过实现SLCK协议来创建兼容Slack的服务器并构建其Slack版本。

从理论上讲,拥有一个开放的协议将允许Slack成为分布式的,人们可以托管他们的Slack服务器。如果人们托管他们的SLCK服务器,则这些服务器将与集群(或服务器间)协议进行通信。使用群集协议(在服务器之间)和客户端协议(在客户端和服务器之间),两个SLCK服务器将能够在它们之间以及与客户端进行通信。

但是,集群协议不是我们将在这里探讨的内容,因为实现起来要困难得多。这就是为什么我们只关注客户端SLCK协议的原因。

现在,要使客户机SLCK协议准备好投入生产将是一项艰巨的工作,这超出了文章的范围。但是,我相信这是一个很好的例子,通过它我们可以了解有关使用字节的更多信息。

事不宜迟,让我们谈谈SLCK。

SLCK设计
SLCK是基于文本的连线协议,这意味着在连线上传输的数据不是二进制的,而只是ASCII文本。基于文本的协议的优势在于,客户端实际上可以打开与实现该协议的服务器的TCP连接,并通过发送ASCII字符与其进行对话。

客户端可以使用接下来要定义的一组命令和约定,通过TCP套接字连接到SLCK服务器并与之通信。

协议约定
SLCK遵循一些简单但重要的约定:

它具有TLV(类型-长度-值)格式
它有一条控制线和一条内容线
它使用回车符(\r\n)作为分隔符
它包含代表动作的主题(标签)的固定列表
就像已经提到的,它在网络上使用ASCII编码的文本
或者,如果将所有这些约定组合在一起,则SLCK消息将如下所示:

MSG #general 11\r\nHello World
那么,为什么要使用ASCII编码的基于文本的有线协议呢?好的,此类协议的优势在于,一旦协议的规范(通常称为规范)公开,任何人都可以编写实现。因此,这样的协议可以成为生态系统诞生的基础。

实际上,拥有一个简单的协议使其易于使用。我们不需要花哨的客户端来与实现SLCK的服务器对话。一个连接telnet就足够了,任何了解协议规范的人都可以手工编写发送到服务器的消息。

协议命令(主题和选项)
SLCK有一些不同的命令:

ID 由..送出 描述
REG 客户 注册为客户
JOIN 客户 加入频道
LEAVE 客户 离开频道
MSG 两个都 与实体(渠道或用户)之间收发消息
CHNS 客户 列出可用频道
USRS 客户 列出用户
OK 服务器 命令确认
ERR 服务器 错误
让我们探索它们中的每一个:

REG
当客户端连接到服务器时,他们可以使用以下REG命令注册为客户端 。它以标识符作为参数,即客户端的用户名。

句法:

REG
在哪里:

handle:用户名
加入
当客户端连接到服务器时,他们可以使用JOIN 命令加入频道。它以标识符作为参数,即通道ID。

句法:

JOIN
在哪里:

channel-id:频道编号
离开
用户加入频道后,他们可以使用LEAVE 命令(以频道ID作为参数)离开频道。

句法:

LEAVE
在哪里:

channel-id:频道编号
范例1:离开#general频道,客户可以传送:

LEAVE #general
味精
要将消息发送到通道或用户,客户端可以使用MSG命令,以通道或用户标识符作为参数,然后是正文长度和正文本身。

句法:

MSG \r\n[payload]
在哪里:

entity-id:频道或用户的ID
length:有效载荷长度
payload:消息正文
示例1:向通道发送Hello everyone!消息#general:

MSG #general 16\r\nHello everyone!
范例2:传送Hello!讯息至@jane:

MSG @jane 4\r\nHey!
中华网
要列出所有可用频道,客户端可以发送CHNS消息。服务器将回复可用频道列表。

句法:

CHNS
USRS
要列出所有用户,客户端可以发送USRS消息。服务器将回复可用用户列表。

句法:

USRS
确定/错误
服务器收到命令后,可以使用OK或答复ERR。

OK之后没有任何文本,请将其视为HTTP 204。

ERR 是服务器返回给客户端的错误的格​​式。没有协议错误会导致服务器关闭连接。这意味着尽管ERR返回了,但服务器仍保持与客户端的连接。

示例1:由于在注册过程中选择了错误的用户名而导致的协议错误:

ERR Username must begin with @
示例2:由于发送错误的频道ID而导致的协议错误JOIN:

ERR Channel ID must begin with #
实施服务器
现在我们已经具备了SLCK协议的基础知识,我们可以继续进行实现了。创建服务器的方法有很多,但是只要它正确地实现了SLCK协议,客户端就不会在意服务器的内部情况。

继续,我将解释构建SLCK TCP服务器的方法,当我们使用它时,我们将学到很多有关字节和字节片的知识。

(如果您想继续前进,可以在此版本库中找到SLCK协议服务器实现的完整代码。)

服务器设计
服务器设计将包含四个不同的部分:一个客户端(用户),一个通道(聊天室),一个命令(从客户端到服务器)以及一个集线器(管理所有内容的服务器)。

让我们从最简单的部分到最复杂的部分。

指令
命令是从客户端流向集线器的内容。每个从所述用户接收的命令,诸如REG,MSG和其他的,必须适当解析,验证和处理。

每个命令的类型都是command。类型定义如下:

type command struct {
id ID
recipient string
sender string
body []byte
}
类型的四个属性,包括它们的解释:

id-的标识command,可以是协议命令之一。
recipient-谁/什么是命令的接收者。可以是@user或#channel。
sender-命令的发送者,即@username用户的。
body -发送方发送给接收方的命令的正文。
的流动command旨意是:一个client接收引线协议消息,分析它,并且把它在一个command,即client发送到hub。

此外,command还使用type ID,这是int类型别名。我们使用它,ID以便可以使用常量和an来控制有效的命令类型iota:

type ID int

const (
REG ID = iota
JOIN
LEAVE
MSG
CHNS
USRS
)
尽管clients必须使用它们从网络接收的原始字符串,但必须在服务器内部,但是我们将wire命令映射到它们的常数。这样,我们就可以严格控制所有由Go的编译器强制执行的命令类型。使用这种方法,我们确保id 始终将是有效的命令类型。

频道数
SLCK协议术语中的频道只是聊天室。值得一提的是,除了名称外,它们与Go频道没有任何共同点。

Achannel只是type具有两个属性的a:

type channel struct {
name string
clients map[client]bool } 该name通道的仅仅是一个string包含渠道的唯一名称。该clients地图是一组clientS中的值为在给定时间的信道的一部分。拥有可用的客户端列表使我们能够轻松地向频道中的所有客户端广播消息,例如:

func (c *channel) broadcast(s string, m []byte) {
msg := append([]byte(s), ": "…)
msg = append(msg, m…)
msg = append(msg, '\n')

for cl := range c.clients {
    cl.conn.Write(msg)
}

}
这将我们带入了client自己。

客户
Aclient是TCP连接的包装器。它封装了围绕从TCP连接接收消息,解析消息,验证消息的结构和内容以及将消息发送给进行进一步处理和处理的所有功能hub。

让我们仔细研究一下client类型:

type client struct {
conn net.Conn
outbound chan<- command
register chan<- *client
deregister chan<- *client
username string
}
client类型的四个属性,依次为:

conn-TCP连接本身(类型 net.Conn)
outbound-类型的仅发送通道command。该信道将是之间的连接client和hub,通过其client 将发送commandS到hub
register-一种类型的仅发送通道,client客户端将通过该通道hub告知其想要向hub (也称为聊天服务器)注册自己 deregister-一种仅发送通道,client客户端将通过该通道hub告知用户已关闭套接字,因此hub 应当注销客户端的注册(通过从客户端map和所有通道中将其删除)
username-string位于TCP连接后面的用户(类型)的用户名
如果这有点令人困惑,请不要担心。一旦您看到整个过程都在进行,它将变得更加明显。

现在,让我们继续使用client的方法。一旦我们实例化了client,它就可以侦听TCP连接上的传入消息。为此, client有一个称为的方法read:

func (c *client) read() error {
for {
msg, err := bufio.NewReader(c.conn).ReadBytes('\n')
if err == io.EOF {
// Connection closed, deregister client
c.deregister <- c
return nil
}
if err != nil {
return err
}

    c.handle(msg)
}

}
read使用循环无休止地for循环,并接受来自conn属性的传入消息(TCP连接)。msg收到消息()后,它将把消息传递给handle方法,该方法将对其进行处理。

如果err返回的是io.EOF,则意味着用户可以关闭连接,client它将hub通过该deregister 通道发送通知。该hub会自删除注销。客户端clients地图,并从所有的通道client参与英寸

handle使用bytes包处理字节
由于协议的定义,我们知道了聊天服务器可能从用户那里接收到的命令的结构。这就是对client的 handle方法不-它获得从插座上原始消息,并解析字节尽意他们出来。

func (c *client) handle(message []byte) {
cmd := bytes.ToUpper(bytes.TrimSpace(bytes.Split(message, []byte(" "))[0]))
args := bytes.TrimSpace(bytes.TrimPrefix(message, cmd))

switch string(cmd) {
case "REG":
    if err := c.reg(args); err != nil {
        c.err(err)
    }
case "JOIN":
    if err := c.join(args); err != nil {
        c.err(err)
    }
case "LEAVE":
    if err := c.leave(args); err != nil {
        c.err(err)
    }
case "MSG":
    if err := c.msg(args); err != nil {
        c.err(err)
    }
case "CHNS":
    c.chns()
case "USRS":
    c.usrs()
default:
    c.err(fmt.Errorf("Unknown command %s", cmd))
}

}
处理消息是我们看到实际使用的字节片([]byte)类型的地方。那么,这里发生了什么?让我们分解一下。

鉴于我们的SLCK协议是基于文本的有线协议,因此TCP连接上流动的字节实际上是纯ASCII文本。十进制数字系统中的每个字节(或八位位组,因为一个字节是八位)的值在0到255之间(2的8的幂)。这意味着每个八位字节都可以包含扩展ASCII编码中的任何字符。(请参阅此ASCII表以查看所有可用字符。)

有了基于文本的协议,我们可以轻松地将通过TCP连接到达的每个字节转换为有意义的文本。这就是为什么每次 byte在[]byte片代表一个字符。因为在片的每个字节[]byte是一个字符,转换[]byte在字符串一样简单: s := string(slice)。

Go擅长处理字节。例如,它有一个 bytes包,使我们可以使用 []byte,而不是string每次我们要使用byte时都将它们转换为s。

鉴于所有SLCK命令都以一个单词开头[]byte,后跟一个空格,我们可以简单地从中提取第一个单词,将其大写,然后将其与协议的有效关键字进行比较。“但是,我们应该如何从一个字节片中提取一个单词呢?” 你可能会问。由于字节不是单词,因此我们必须求助于它们逐字节比较或使用内置bytes包。为简单起见,我们将使用该bytes 包。(您可以查看此代码段 以比较这两种方法。)

在方法的第一行中handle,我们接收了收到消息的第一部分,并对它进行大写。然后,在第二行,我们从消息的其余部分中删除第一部分。拆分使我们可以将命令(cmd)和其余命令参数(args)放在单独的变量中。

cmd := bytes.ToUpper(bytes.TrimSpace(bytes.Split(message, []byte(" "))[0]))
args := bytes.TrimSpace(bytes.TrimPrefix(message, cmd))
之后,在switch构造中,我们处理所有不同的命令。例如,REG使用reg和err 方法可以完成命令的处理:

func (c *client) reg(args []byte) error {
u := bytes.TrimSpace(args)
if u[0] != '@' {
return fmt.Errorf("Username must begin with @")
}
if len(u) == 0 {
return fmt.Errorf("Username cannot be blank")
}

c.username = string(u) c.register &lt;- c

return nil

}
该reg方法获取args切片,并删除所有空间字节(使用 bytes.TrimSpace)。假设REG命令的第二个参数是 @username用户的,则它检查传递的用户名是否以开头,@是否为空。完成此操作后,它将用户名转换为字符串,并将其分配给客户端(c)本身。从那时起,客户端将分配一个用户名。

第二步,它通过register通道发送客户端本身。hub(聊天服务器)读取此通道,它将在成功注册客户端之前对用户名进行更多验证。

func (c *client) err(e error) {
c.conn.Write([]byte("ERR " + e.Error() + "\n"))
}
所述errFUNC简单地取一个错误,并发送它的内容返回给用户,使用客户端的TCP连接。

彻底研究了聊天服务器后,我们将回到其他命令和方法。

枢纽