protocol_v2 协议
一个客户端只能订阅一个 channel,客户端可以向 nsqd 发送指令完成指定操作并返回结果,nsqd 可以向客户端推送消息.
IOLoop 操作
从上一节我们了解到,nsqd 每和一个客户端建立 TCP 连接都会创建一个 protocolV2 struct,里面仅仅包含所处语境即 nsqd,然后执行 IOLoop 操作进行 nsqd 和客户端的通信.所以只需要从 IOLoop 入手便可以一览 nsqd 与客户端之间的消息处理:
// 建立连接后的进行 IOLoop 操作,接收消息,每一个客户端 TCP 连接对应一个 protocolV2
func (p *protocolV2) IOLoop(conn net.Conn) error {
var err error
var line []byte
var zeroTime time.Time
// 获取一个 ClientID
clientID := atomic.AddInt64(&p.ctx.nsqd.clientIDSequence, 1)
// 创建一个 client 用来代理这个客户端
client := newClientV2(clientID, conn, p.ctx)
// 将这个客户端添加到 nsqd 中进行管理
p.ctx.nsqd.AddClient(client.ID, client)
// 首先创建一个 messagePumpStartedChan channel,并把它传入到 messagePump 中
// 下面阻塞在从 messagePumpStartedChan 中获取消息,但其实这个 channel 在 messagePump 中关闭了
// 这是为了保证让 IOLoop 中的 for 循环和 messagePump 中的 for 循环同步运行.
// IOLoop 中的 for 循环时接收客户端的请求并进行解析,处理请求,最后给客户端响应.
// messagePump 中的 for 循环会从内存和 disk 中获取准备好的 message 返回给客户.
// 如果不添加 messagePumpStartedChan 一般会导致 IOLoop 中的 for 循环先执行,但是 messagePump 还没有准备好
messagePumpStartedChan := make(chan bool)
go p.messagePump(client, messagePumpStartedChan)
<-messagePumpStartedChan
for {
// 根据心跳间隔设置 ReadDeadline 时间
if client.HeartbeatInterval > 0 {
client.SetReadDeadline(time.Now().Add(client.HeartbeatInterval * 2))
} else {
client.SetReadDeadline(zeroTime)
}
// 读取一个指令
line, err = client.Reader.ReadSlice('\n')
if err != nil {
if err == io.EOF {
err = nil
} else {
err = fmt.Errorf("failed to read command - %s", err)
}
break
}
// 删除指令末尾的 '\n'
line = line[:len(line)-1]
// 可能还需要修剪一下 '\r'
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
// 按照空格进行划分
params := bytes.Split(line, separatorBytes)
p.ctx.nsqd.logf(LOG_DEBUG, "PROTOCOL(V2): [%s] %s", client, params)
var response []byte
// 执行客户端对应的操作,✨✨✨
response, err = p.Exec(client, params)
if err != nil {
ctx := ""
if parentErr := err.(protocol.ChildErr).Parent(); parentErr != nil {
ctx = " - " + parentErr.Error()
}
p.ctx.nsqd.logf(LOG_ERROR, "[%s] - %s%s", client, err, ctx)
// 将错误发送给客户端
sendErr := p.Send(client, frameTypeError, []byte(err.Error()))
if sendErr != nil {
p.ctx.nsqd.logf(LOG_ERROR, "[%s] - %s%s", client, sendErr, ctx)
break
}
// FatalClientErr 类型的错误应关闭连接
if _, ok := err.(*protocol.FatalClientErr); ok {
break
}
continue
}
// 将执行后的结果返回给客户端
if response != nil {
err = p.Send(client, frameTypeResponse, response)
if err != nil {
err = fmt.Errorf("failed to send response - %s", err)
break
}
}
}
// 退出循环
p.ctx.nsqd.logf(LOG_INFO, "PROTOCOL(V2): [%s] exiting ioloop", client)
conn.Close()
// 关闭 client 的所有监听 goroutine
close(client.ExitChan)
if client.Channel != nil {
// 如果客户端监听的 channel 不为空,从 channel 移除这个客户端
client.Channel.RemoveClient(client.ID)
}
// 从 nsqd 移除这个客户端
p.ctx.nsqd.RemoveClient(client.ID)
return err
}
这个函数是用来从客户端接收指令并进行处理,主要工作有:
- 创建一个 client 用来管理客户端
- 启动 goroutine 执行 messagePump,并阻塞等待 messagePump 中的 for 同步运行
- 在一个 for 循环中不断处理客户端传过来的指令,并将处理结果进行返回
messagePump 操作
和 IOLoop 同步执行的还有 messagePump,是用来将内存和 disk 中获取准备好的 message 返回给客户.
func (p *protocolV2) messagePump(client *clientV2, startedChan chan bool) {
var err error
// 从内存获取消息的通道
var memoryMsgChan chan *Message
// 从磁盘获取消息的通道
var backendMsgChan chan []byte
// 订阅的频道
var subChannel *Channel
// 解决消息延迟的问题,强制刷新
var flusherChan <-chan time.Time
var sampleRate int32
subEventChan := client.SubEventChan
identifyEventChan := client.IdentifyEventChan
outputBufferTicker := time.NewTicker(client.OutputBufferTimeout)
heartbeatTicker := time.NewTicker(client.HeartbeatInterval)
heartbeatChan := heartbeatTicker.C
msgTimeout := client.MsgTimeout
// 用来强制刷新消息给客户端,两种情况不会进行强制刷新:
// 1. 客户端没有准备好接收消息
// 2. buffer 中没有消息,怎么刷都没用
flushed := true
// 通知 IOLoop 开始执行
close(startedChan)
到这里 messagePump 的准备工作就已经做完了,最后 close startedChan 同时启动 IOLoop 和 messagePump 中的 for 循环
for {
if subChannel == nil || !client.IsReadyForMessages() {
// 客户端暂时还不能接收消息
memoryMsgChan = nil
backendMsgChan = nil
// 不允许强制刷消息给客户端
flusherChan = nil
// 强制刷新客户端
client.writeLock.Lock()
err = client.Flush()
client.writeLock.Unlock()
if err != nil {
goto exit
}
flushed = true
} else if flushed {
// channel 中不是很堵,暂时关闭强制刷新,仅接收 channel 内存通道和磁盘通道即可
memoryMsgChan = subChannel.memoryMsgChan
backendMsgChan = subChannel.backend.ReadChan()
flusherChan = nil
} else {
// channel 中有大消息,打开强制刷新定时器
memoryMsgChan = subChannel.memoryMsgChan
backendMsgChan = subChannel.backend.ReadChan()
flusherChan = outputBufferTicker.C
}
在这个 for 循环中,每次开始执行 select 之前需要先对客户端的情况进行判断,有如下三种情况:
- 客户端没有订阅 channel 或者客户端还没有准备好接收消息,在 select 中等待 subEventChan 和 ReadyStateChan 的消息,用来加载 客户端订阅的 channel 消息通道和知晓客户端已经准备好接受消息.
- 客户端可以接受消息,但是 channel 中已经没有消息了,不需要开启强制刷新,仅从 channel 的两个通道中正常获取消息即可
- 客户端可以接收消息,并且 channel 中有很多消息等待投递,可以打开 flusherChan 进行手动刷新
这里处理初始化时属于第一种情况,订阅 channel 后就一直在 2,3 之间进行切换.是怎么判断 channel 的通道中是否有消息需要进行强制刷新呢?如果从内存通道中获取消息就表示目前 channel 情况还正常,一旦开始从 backend 中获取消息就表示 channel 中已经积压了大量的消息,需要打开强制刷新.默认刷新频率为:250ms,通过强制刷新把 client 写缓冲区中的消息推送给客户端,让 client 可以更快的从 channel 中获取消息
select {
case <-flusherChan:
// 强制刷新 client 的写缓冲区
client.writeLock.Lock()
err = client.Flush()
client.writeLock.Unlock()
if err != nil {
goto exit
}
flushed = true
case <-client.ReadyStateChan:
case subChannel = <-subEventChan:
// 仅订阅一次 channel 之后即使调用 SUB 也不会切换频道
subEventChan = nil
case identifyData := <-identifyEventChan:
// 验证确认客户端,仅触发一次
// 就是设置各种触发间隔
identifyEventChan = nil
outputBufferTicker.Stop()
if identifyData.OutputBufferTimeout > 0 {
outputBufferTicker = time.NewTicker(identifyData.OutputBufferTimeout)
}
heartbeatTicker.Stop()
heartbeatChan = nil
if identifyData.HeartbeatInterval > 0 {
heartbeatTicker = time.NewTicker(identifyData.HeartbeatInterval)
heartbeatChan = heartbeatTicker.C
}
if identifyData.SampleRate > 0 {
sampleRate = identifyData.SampleRate
}
msgTimeout = identifyData.MsgTimeout
case <-heartbeatChan:
// 发送心跳消息给客户端
err = p.Send(client, frameTypeResponse, heartbeatBytes)
if err != nil {
goto exit
}
case b := <-backendMsgChan: // 从 channel 磁盘消息缓冲区中接收消息
if sampleRate > 0 && rand.Int31n(100) > sampleRate {
continue
}
// 先 decode 消息
msg, err := decodeMessage(b)
if err != nil {
p.ctx.nsqd.logf(LOG_ERROR, "failed to decode message - %s", err)
continue
}
msg.Attempts++
// 放进到待确认列表并发送给客户端
subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout)
client.SendingMessage()
err = p.SendMessage(client, msg)
if err != nil {
goto exit
}
flushed = false
case msg := <-memoryMsgChan: // 从 channel 内存消息缓冲区中接收消息
if sampleRate > 0 && rand.Int31n(100) > sampleRate {
continue
}
msg.Attempts++
// 添加到待确认列表并发送给客户端
subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout)
client.SendingMessage()
err = p.SendMessage(client, msg)
if err != nil {
goto exit
}
flushed = false
case <-client.ExitChan:
// 退出循环
goto exit
}
}
整个阻塞在 select 有 8 种情况:
- 强制刷新消息给客户端
- 客户端准备就绪等待接收消息
- 客户端订阅频道,加载 channel 消息接收列表(仅能订阅一次 channel)
- 验证链接时执行的操作,设置消息循环的各种定时器间隔(只可以确认一次)
- 给客户端发送心跳消息
- 从磁盘队列接收消息
- 从内存队列接收消息
- 退出 messagePump
exit:
p.ctx.nsqd.logf(LOG_INFO, "PROTOCOL(V2): [%s] exiting messagePump", client)
heartbeatTicker.Stop()
outputBufferTicker.Stop()
if err != nil {
p.ctx.nsqd.logf(LOG_ERROR, "PROTOCOL(V2): [%s] messagePump error - %s", client, err)
}
}
收尾工作就是停止几个定时器并退出即可.在 messagePump 中获取了消息之后会调用 SendMessage 来吧消息发送给客户端:
func (p *protocolV2) SendMessage(client *clientV2, msg *Message) error {
var buf = &bytes.Buffer{}
// 消息放到 buf 中
_, err := msg.WriteTo(buf)
if err != nil {
return err
}
// 调用 Send 发送
err = p.Send(client, frameTypeMessage, buf.Bytes())
if err != nil {
return err
}
return nil
}
// 向 client 发送消息
func (p *protocolV2) Send(client *clientV2, frameType int32, data []byte) error {
client.writeLock.Lock()
var zeroTime time.Time
// 写超时设置
if client.HeartbeatInterval > 0 {
client.SetWriteDeadline(time.Now().Add(client.HeartbeatInterval))
} else {
client.SetWriteDeadline(zeroTime)
}
_, err := protocol.SendFramedResponse(client.Writer, frameType, data)
if err != nil {
client.writeLock.Unlock()
return err
}
// frameTypeMessage : 发送给客户端的消息立即刷新
// frameTypeResponse : 客户端指令返回不会立即刷新
if frameType != frameTypeMessage {
err = client.Flush()
}
client.writeLock.Unlock()
return err
}
nsqd 对每个客户端使用两个 gououtine 分别执行 IOLoop 和 messagePump 就完成了二者之间的指令和消息传递.代码很容易理清楚.
protocol_v2 中客户端的指令
Exec
在 IOLoop 中,没收到客户端传来的一条指令,都会解析完成后,通过 Exec 进行执行.判断指令的类型然后调用对应的函数执行即可.
// 执行客户端传递过来的指令
func (p *protocolV2) Exec(client *clientV2, params [][]byte) ([]byte, error) {
// 认证一个客户端
if bytes.Equal(params[0], []byte("IDENTIFY")) {
return p.IDENTIFY(client, params)
}
err := enforceTLSPolicy(client, p, params[0])
if err != nil {
return nil, err
}
switch {
// 确认收到一条消息
case bytes.Equal(params[0], []byte("FIN")):
return p.FIN(client, params)
// 指定可同时处理的消息数量
case bytes.Equal(params[0], []byte("RDY")):
return p.RDY(client, params)
// 消息重新加入 channel 发送
case bytes.Equal(params[0], []byte("REQ")):
return p.REQ(client, params)
// 向 topic 推送一条消息
case bytes.Equal(params[0], []byte("PUB")):
return p.PUB(client, params)
// 向 topic 推送多条消息
case bytes.Equal(params[0], []byte("MPUB")):
return p.MPUB(client, params)
// 向 topic 推送一条延迟消息
case bytes.Equal(params[0], []byte("DPUB")):
return p.DPUB(client, params)
// 不作处理
case bytes.Equal(params[0], []byte("NOP")):
return p.NOP(client, params)
// 重置一个待确认消息的超时时间
case bytes.Equal(params[0], []byte("TOUCH")):
return p.TOUCH(client, params)
// 订阅一个 channel
case bytes.Equal(params[0], []byte("SUB")):
return p.SUB(client, params)
// 关闭客户端
case bytes.Equal(params[0], []byte("CLS")):
return p.CLS(client, params)
// 授权一个客户端
case bytes.Equal(params[0], []byte("AUTH")):
return p.AUTH(client, params)
}
return nil, protocol.NewFatalClientErr(nil, "E_INVALID", fmt.Sprintf("invalid command %s", params[0]))
}
这里的各个指令,基本都是先确认参数,然后调用对应的方法即可,不过多介绍,简单了解一下对应的含义及可:
指令 | 例子 | 操作 | 执行的操作 |
---|---|---|---|
FIN | FIN MessageID | 确认收到一条消息 | topic.FinishMessage |
RDY | RDY count | 指定可同时处理的消息数量 | client.SetReadyCount |
REQ | REQ messageID timeoutMs | 消息重新加入 channel 发送 | client.Channel.RequeueMessage |
PUB | PUB topicName message | 向 topic 推送一条消息 | topic.PutMessage |
MPUB | MPUB topicName messages | 向 topic 推送多条消息 | topic.PutMessages |
DPUB | MPUB topicName timeoutMs message | 向 topic 推送一条延迟消息 | topic.PutMessage |
NOP | NOP | 无效指令 | 不做处理 |
TOUCH | TOUCH messageID | 重置一个待确认消息的超时时间 | client.Channel.TouchMessage |
SUB | SUB topicName channelName | 订阅一个 channel | client.SubEventChan <- channel 触发消息循环 |
CLS | CLS | 关闭客户端 | client.StartClose |
AUTH | AUTH body | 授权一个客户端 | client.Auth |
IDENTIFY | IDENTIFY body | 认证一个客户端 | client.Identify |
这里面比较特别的是 IDENTIFY 和 AUTH 两个操作.
client 和 nsqd 建立连接后,client 立即通过命令 IDENTIFY 将认证信息发给 nsqd,如果 nsqd 在启动的时候指定了授权地址,nsqd 就会告诉 client 你需要认证,client 就会通过命令 AUTH 将秘钥发给 nsqd,nsqd 去授权地址进行验证,验证通过后,就可以进行正常的消息发布和订阅了
小结
整个协议的流程基本看完了,其实说起来就是通过 IOLoop 和 messagePump 进行 nsqd 和 client 的通信:
- IOLoop 负责让 nsqd 执行 client 传过来的指令
- messagePump 给客户端推送消息
最后附上 github 的 nsqd 的源码解析地址:https://github.com/nercoeus/nsq_source_note