mqtt協議-broker之moqutte源碼研究四之PUBLISH報文處理

先簡單說明一下,對於mqtt是個雙向通信的過程,也就是說,他既允許client向broker發佈消息,同時也允許broker向client發佈消息

public void processPublish(Channel channel, MqttPublishMessage msg) {
    final MqttQoS qos = msg.fixedHeader().qosLevel();
    final String clientId = NettyUtils.clientID(channel);
    LOG.info("Processing PUBLISH message. CId={}, topic={}, messageId={}, qos={}", clientId,
            msg.variableHeader().topicName(), msg.variableHeader().packetId(), qos);
    switch (qos) {
        case AT_MOST_ONCE:
            this.qos0PublishHandler.receivedPublishQos0(channel, msg);
            break;
        case AT_LEAST_ONCE:
            this.qos1PublishHandler.receivedPublishQos1(channel, msg);
            break;
        case EXACTLY_ONCE:
            this.qos2PublishHandler.receivedPublishQos2(channel, msg);
            break;
        default:
            LOG.error("Unknown QoS-Type:{}", qos);
            break;
    }
}

根據發佈的消息的qos,用不同的QosPublishHandler來處理,QosPublishHandler有三個具體實現,分別是Qos0PublishHandler,Qos1PublishHandler,Qos2PublishHandler.
這裏面先講解Qos1PublishHandler的處理

void receivedPublishQos1(Channel channel, MqttPublishMessage msg) {
    // verify if topic can be write
    final Topic topic = new Topic(msg.variableHeader().topicName());
    String clientID = NettyUtils.clientID(channel);
    String username = NettyUtils.userName(channel);
    if (!m_authorizator.canWrite(topic, username, clientID)) {
        LOG.error("MQTT client is not authorized to publish on topic. CId={}, topic={}", clientID, topic);
        return;
    }

    final int messageID = msg.variableHeader().packetId();

    // route message to subscribers
    IMessagesStore.StoredMessage toStoreMsg = asStoredMessage(msg);
    toStoreMsg.setClientID(clientID);

    this.publisher.publish2Subscribers(toStoreMsg, topic, messageID);

    sendPubAck(clientID, messageID);

    if (msg.fixedHeader().isRetain()) {
        if (!msg.payload().isReadable()) {
            m_messagesStore.cleanRetained(topic);
        } else {
            // before wasn't stored
            m_messagesStore.storeRetained(topic, toStoreMsg);
        }
    }

    // 修改publish消息,slice出的ByteBuf對象,原文中存在內存泄漏
    MoquetteMessage moquetteMessage = new MoquetteMessage(msg.fixedHeader(), msg.variableHeader(), msg.content());
    m_interceptor.notifyTopicPublished(moquetteMessage, clientID, username);
    msg.content().release();
}

    1.鑑權,該client下的username是否有對該topic發佈消息(對topic寫)的權限。
    2.創建一個IMessagesStore.StoredMessage,同時把消息推送給所有該對該消息的訂閱者。

        if (LOG.isTraceEnabled()) {
        LOG.trace("Sending publish message to subscribers. ClientId={}, topic={}, messageId={}, payload={}, " +
                "subscriptionTree={}", pubMsg.getClientID(), topic, messageID, DebugUtils.payload2Str(pubMsg.getPayload()),
            subscriptions.dumpTree());
    } else if(LOG.isInfoEnabled()){
        LOG.info("Sending publish message to subscribers. ClientId={}, topic={}, messageId={}", pubMsg.getClientID(), topic,
            messageID);
    }
    publish2Subscribers(pubMsg, topic);

判斷是否是跟蹤模式,如果是的話,會把當前所有的訂閱關係打印到日誌,由於這個需要遍歷topic樹,消耗比較大,所以是可配置的,在moquette.cof裏面配置。
核心的處理邏輯在下面,接着往下看

    void publish2Subscribers(IMessagesStore.StoredMessage pubMsg, Topic topic) {
    List<Subscription> topicMatchingSubscriptions = subscriptions.matches(topic);
    final String topic1 = pubMsg.getTopic();
    final MqttQoS publishingQos = pubMsg.getQos();
    final ByteBuf origPayload = pubMsg.getPayload();

    for (final Subscription sub : topicMatchingSubscriptions) {
        MqttQoS qos = lowerQosToTheSubscriptionDesired(sub, publishingQos);
        ClientSession targetSession = m_sessionsStore.sessionForClient(sub.getClientId());

        boolean targetIsActive = this.connectionDescriptors.isConnected(sub.getClientId());
//TODO move all this logic into messageSender, which puts into the flightZone only the messages that pull out of the queue.
        if (targetIsActive) {
            if(LOG.isDebugEnabled()){
                LOG.debug("Sending PUBLISH message to active subscriber. CId={}, topicFilter={}, qos={}",
                    sub.getClientId(), sub.getTopicFilter(), qos);
            }
            // we need to retain because duplicate only copy r/w indexes and don't retain() causing
            // refCnt = 0
            ByteBuf payload = origPayload.retainedDuplicate();
            MqttPublishMessage publishMsg;
            if (qos != MqttQoS.AT_MOST_ONCE) {
                // QoS 1 or 2
                int messageId = targetSession.inFlightAckWaiting(pubMsg);
                // set the PacketIdentifier only for QoS > 0
                publishMsg = notRetainedPublishWithMessageId(topic1, qos, payload, messageId);
            } else {
                publishMsg = notRetainedPublish(topic1, qos, payload);
            }
            this.messageSender.sendPublish(targetSession, publishMsg);
        } else {
            if (!targetSession.isCleanSession()) {
                if(LOG.isDebugEnabled()){
                    LOG.debug("Storing pending PUBLISH inactive message. CId={}, topicFilter={}, qos={}",
                        sub.getClientId(), sub.getTopicFilter(), qos);
                }
                // store the message in targetSession queue to deliver
                targetSession.enqueue(pubMsg);
            }
        }
    }
}

大概分爲以下幾步
2.1.根據topic找出匹配的訂閱集合list,這裏面由於涉及到比較大的計算,所以單獨講解

         public List<Subscription> matches(Topic topic) {
    Queue<Token> tokenQueue = new LinkedBlockingDeque<>(topic.getTokens());
    List<ClientTopicCouple> matchingSubs = new ArrayList<>();
    subscriptions.get().matches(tokenQueue, matchingSubs);

  // 客戶端使用帶通配符的主題過濾器請求訂閱時,客戶端的訂閱可能會重複,因此發佈的消息可能會匹配多個過濾器。對於這種情
        //況,服務端必須將消息分發給所有訂閱匹配的QoS等級最高的客戶端。服務端之後可以按照訂閱的QoS等級,分發消息的副本給每
        //一個匹配的訂閱者。
    Map<String, Subscription> subsForClient = new HashMap<>();
    for (ClientTopicCouple matchingCouple : matchingSubs) {
        Subscription existingSub = subsForClient.get(matchingCouple.clientID);
        Subscription sub = this.subscriptionsStore.getSubscription(matchingCouple);
        if (sub == null) {
            // if the m_sessionStore hasn't the sub because the client disconnected
            continue;
        }
        // update the selected subscriptions if not present or if has a greater qos
        if (existingSub == null || existingSub.getRequestedQos().value() < sub.getRequestedQos().value()) {
            subsForClient.put(matchingCouple.clientID, sub);//注意這裏最終存入的從session裏面獲取的訂閱,而不是從topic目錄裏面獲取的,是因爲,有可能client當時並不在線或者該訂閱的qos等級變化了
        }
    }
    return new ArrayList<>(subsForClient.values());
}
可以看的出來,會先創建一個隊列,存儲topic的層級比如/a/b/c,隊列裏面就會有三個運輸[c,b,a]   這裏面之所以要用到隊列而不是,list就是因爲後面進行匹配的時候需要確保先從第一個層級開始匹配,而不是最後一個
void matches(Queue<Token> tokens, List<ClientTopicCouple> matchingSubs) {
    Token t = tokens.poll();

    // check if t is null <=> tokens finished
    if (t == null) {
        matchingSubs.addAll(m_subscriptions);
        // check if it has got a MULTI child and add its subscriptions
        for (TreeNode n : m_children) {
            if (n.getToken() == Token.MULTI || n.getToken() == Token.SINGLE) {
                matchingSubs.addAll(n.subscriptions());
            }
        }

        return;
    }
    // we are on MULTI, than add subscriptions and return
    if (m_token == Token.MULTI) {
        matchingSubs.addAll(m_subscriptions);
        return;
    }
    for (TreeNode n : m_children) {
        if (n.getToken().match(t)) {
            // Create a copy of token, else if navigate 2 sibling it
            // consumes 2 elements on the queue instead of one
            n.matches(new LinkedBlockingQueue<>(tokens), matchingSubs);
            // TODO don't create a copy n.matches(tokens, matchingSubs);
        }
    }
}

這段代碼不好理解,涉及到迭代topic樹io.moquette.spi.impl.subscriptions.TreeNode,下面以一個圖說明
另外對topic匹配規則不熟悉的同學可以看一下這裏https://github.com/mcxiaoke/mqtt/blob/master/mqtt/04-OperationalBehavior.md
假如有A,B,C,D, E五個client,其中A訂閱了/test/#,B訂閱了/test/hello/#,C 訂閱了/test/hello/beijing,
D訂閱了/test/+/hello,現在E向topic-name爲/test/hello/shanghai發佈了一條消息請問哪幾個client應該收到這條消息。
向畫出topic樹如下(請原諒我手畫)
mqtt協議-broker之moqutte源碼研究四之PUBLISH報文處理
先分析 E發佈消息的整個過程:
2.1.1./test/hello/shanghai被放入queue,取出來的順序依次爲test,hello,shanghai
2.1.2.第一輪先匹配test,test不爲空,RootNode(其實是null)不爲#,執行到遍歷RootNode下的子節點,RootNode先的子節點只有一個,test,test.equals(test),然後當前treenode變爲test,
2.1.3.從queue裏面取出hello,hello不爲空,test不爲#,遍歷test這個treenode的子節點,test有三個子節點,分別是(#,+,hello)
2.1. 3.1 子節點是#,# .mathcs(hello),當前節點是#,然後從隊列裏面取出shanghai,shanghai不爲空,#爲#,當前迭代終止,節點A匹配放入匹配的list,
2.1.3.2 子節點是+,+ .mathcs(hello)當前節點是+,然後從隊列裏面取出shanghai(這裏可能有的同學有疑問,爲什麼還能取出shanghai呢,因爲進行下一級迭代的時候是new的新的queue),上海不爲空,+不爲#所以不匹配,接着匹配+這個treenode的子節點,+只有一個子節點
2.1. 3.2.1 當前節點是hello,hello.mathcs(shanghai)不成立,迭代終止,所以D不會放入匹配的list
1.3.3 子節點是hello,hello.equals(hello),當前節點變成hello,然後從隊列裏面取出shanghai,shanghai不爲空,hello不爲#,遍歷hello這個treenode下的子節點,hello先有兩個子節點,分別是(#,beijing),
2.1.3.3.1 子節點是#,# .mathcs(hello),當前節點是#,從隊列裏面取出的是空,所以直接會走第一個if分支,並且把B放入匹配的list,並且退出方法。
2.1. 3.3.2 子節點是beijing,beijing.equals(shanghai)不成立,退出迭代
最終能夠匹配成功的只有A,B這兩個client
也就說說能夠被成功匹配,要麼/test/hello/shanghai的每一層級都能成功匹配,“+”能夠夠任意的單個層級,或者某一個層級是“#”(分別對應上面的兩個if分支)
到此位置,有topic-name匹配由topic-filters組成的topic樹的整個過程分析完成了。下面接着回到上面的publish2Subscribers的這個方法講解匹配出client之後的動作

2.2 遍歷找出的匹配的client,確定qos,qos取訂閱請求要求的qos和發佈的消息自身的qos的最小值,這個是mqtt協議自身規定的,之所以這樣規定是因爲,訂閱的時候其基本單位是某個topic,訂閱者只能訂閱一個topic,而不能訂閱一個消息,而發佈消息的基本單位是一個消息,一個topic下可以有很多個消息,不同的消息可以有不同的qos,所以這裏面,真正的qos是由訂閱方和發佈方共同決定的,出於性能的考慮,去最小的qos
2.3 根據連接描述符,判斷是否client依然在線,如果不在線,且客戶端要求保留會話,則把消息保存到該client的session的BlockingQueue<StoredMessage>,以待client再次上線之後,再次發送給該client,這個對應着在建立連接的時候有一個republish的動作,具體看http://blog.51cto.com/13579730/2073630的 第10步
2.3.如果在線,根據qos做不同的處理,如果qos是0,比較簡單,之間發送,qos是1或者2,則會先把消息放入outboundFlightZone,產生一個messageId,再通過PersistentQueueMessageSender進行發送
io.moquette.spi.impl.PersistentQueueMessageSender#sendPublish,具體的分發邏輯比較簡單,這裏不詳細講解。類之間的關係是
ProtocolProcessor--》qos1PublishHandler--》MessagesPublisher--》PersistentQueueMessageSender,基本的邏輯就是通過ConnectionDescriptorStore進行發送,對於發送失敗的要求保存會話的qos1或者2消息,將會繼續保留,知道重複成功

到此publish2Subscribers方法即發送的核心邏輯講解完了,讓我們回到io.moquette.spi.impl.Qos1PublishHandler#receivedPublishQos1這個方法,

3.發送PUBACK消息,
4.如果是retain消息,但是有沒有paylod,將該topic下的retain消息清除掉,可以理解成是客戶端主動要求清除的,因爲它發送了一個空的消息,如果有payload,則存儲retain消息,對於保留消息,詳細看這裏https://github.com/mcxiaoke/mqtt/blob/master/mqtt/0303-PUBLISH.md 我簡單總結一下,1.每個topic下永遠只會存儲一條retain消息,如果發送了兩條,那麼後面的將會將前面的覆蓋;2.如果客戶端發送了一個零字節的retain消息,broker將會清理調該topic下的retain消息,因爲broker不會存儲零字節的retain消息;3.服務端的保留消息不是會話狀態的組成部分。服務端應該保留那種消息直到客戶端刪除它。4.當client收到broker發送的一個retain消息是,可以理解成這是client新建立的一個訂閱的第一條消息。
5.喚醒攔截器

到此moquette對PUBLISH報文的處理講解完了,這裏面只講解了qos1的處理,是因爲qos0處理比較簡單,而qos2我們沒什麼應用場景。另外這裏說明一下比較容易混淆的概念cleanSession與retain消息
1.retain消息並不是session的一部分,它不與client掛鉤,而是與topic-filter掛鉤。也就是說當發佈這個retain消息的clientsession不存在了,但是retain消息依然存在,除非有client主動刪除它,
2.對於要求保留會話的client,會存在一個broker主動重新發送消息的過程,這個動作實在client重新建立連接的時候,具體看這裏http://blog.51cto.com/13579730/2073630 的第10步,這是因爲broker有責任對要求保留會話的client重新發送qos1與qos2消息
3.對於client發佈訂閱的時候,broker也會有一個主動發送消息的過程,具體看這裏http://blog.51cto.com/13579730/2073914 的第8步
4.qos1消息和qos2是在消息發送失敗或者,client不在線的時候,存儲到clientsession裏面的一個BlockingQueue<StoredMessage>裏面的,而retain消息是在,broker收到的時候直接存儲到IMessagesStore進行存儲的,其底層是個Map<Topic, StoredMessage>

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章