Netty實現高效能IOT伺服器(Groza)之精盡程式碼篇中

穆書偉發表於2018-10-26

Netty實現高效能IOT伺服器(Groza)之精盡程式碼篇中

執行環境:

  • JDK 8+
  • Maven 3.0+
  • Redis

技術棧:

  • SpringBoot 2.0+
  • Redis (Lettuce客戶端,RedisTemplate模板方法)
  • Netty 4.1+
  • MQTT 3.1.1

IDE:

  • IDEA或者Eclipse
  • Lombok外掛

簡介

近年來,物聯網高歌猛進,美國有“工業網際網路”,德國有“工業4.0”,我國也有“中國製造2025”,這背後都是雲端計算、大資料。據波士頓諮詢報告,單單中國製造業,雲端計算、大資料、人工智慧等新技術就能為其帶來高達6萬億的額外附加值。

國內外巨頭紛紛駐足工業網際網路,國外如亞馬遜AWS、微軟Azure,國內則是三大電信運營商、百度雲、華為、金山雲等,其中騰訊雲、阿里雲最甚,還拉來了傳統制造大佬,國內巨頭紛紛在物聯網上佈局。在2018雲棲-深圳峰會上,阿里巴巴資深副總裁,阿里雲總裁胡曉明宣佈阿里巴巴將正式進軍IoT。胡曉明表示,IoT是阿里巴巴集團繼電商、金融、物流、雲端計算之後的一條新的主賽道。

IOT技術窺探

以上這些內容,作者作為一個開發人員,並不是一個投資人員和創業先鋒。並不太關係這些具體細節。我所關心的是如何用技術去實現或者模擬一個支援百萬連結的IOT伺服器,並不嚴謹,僅做大家參考。

關於為什麼選用下圖的中介軟體或者對MQTT不太瞭解的話,可以閱讀我之前的2篇文章:

  1. IOT高效能伺服器實現之路
  2. Netty實現高效能IOT伺服器(Groza)之手撕MQTT協議篇上

技術輪廓圖

Netty實現高效能IOT伺服器(Groza)之精盡程式碼篇中

快速入門

執行測試

  1. git clone github.com/sanshengshu…

  2. cd netty-iot

  3. 執行 NettyIotApplication

  4. 開啟 http://localhost:8080/groza/v1/123456/auth, 獲取密碼!

Netty實現高效能IOT伺服器(Groza)之精盡程式碼篇中

  1. 啟動Eclipse Paho,並填寫使用者名稱和密碼,即可連線。

  2. 另起一個Eclipse Paho,訂閱隨意主題,例如test。另一個Eclipse Paho釋出主題test。即可收到訊息。

  3. 取消主題訂閱,再次釋出訊息。就收不到訊息。

Netty實現高效能IOT伺服器(Groza)之精盡程式碼篇中

有了前面2篇文章的鋪墊並學習了MQTT V3.1.1 協議,說了那麼多,手癢癢的很。

You build it, You run it!

專案結構介紹

netty-iot
      ├── auth -- 認證
        ├── service -- 使用者名稱,密碼認證實現類
        ├── util -- 認證工具類
      ├── common -- 公共類
        ├── auth -- 使用者名稱,密碼認證介面
        ├── message -- 協議儲存實體及介面類
        ├── session -- session儲存實體及介面類
        ├── subscribe -- 訂閱儲存實體及介面類
      ├── config -- Redis配置
      ├── protocol -- MQTT協議實現
      ├── server -- MQTT伺服器
      ├── store -- Redis資料儲存
      	├── cache 
        ├── message 
        ├── session
        ├── subscribe
      ├── web -- web服務
      ├── NettyIotApplication -- 服務啟動類
複製程式碼

Redis

安裝

體驗 Redis 需要使用 Linux 或者 Mac 環境,如果是 Windows 可以考慮使用虛擬機器。主要方式有四種:

  • 使用 Docker 安裝。
  • 通過 Github 原始碼編譯。
  • 直接安裝 apt-get install(Ubuntu)、yum install(RedHat) 或者 brew install(Mac)。
  • 如果讀者懶於安裝操作,也可以使用網頁版的 Web Redis 直接體驗。

具體操作如下:

Docker 方式

# 拉取 redis 映象
> docker pull redis
# 執行 redis 容器
> docker run --name myredis -d -p6379:6379 redis
# 執行容器中的 redis-cli,可以直接使用命令列操作 redis
> docker exec -it myredis redis-cli...
複製程式碼

Github 原始碼編譯方式

# 下載原始碼
> git clone --branch 2.8 --depth 1 git@github.com:antirez/redis.git
> cd redis
# 編譯
> make
> cd src
# 執行伺服器,daemonize表示在後臺執行
> ./redis-server --daemonize yes
# 執行命令列
> ./redis-cli...
複製程式碼

直接安裝方式

# mac
> brew install redis
# ubuntu
> apt-get install redis
# redhat
> yum install redis
# 執行客戶端
> redis-cli
複製程式碼

使用

Spring Boot除了支援常見的ORM框架外,更是對常用的中介軟體提供了非常好封裝,隨著Spring Boot2.x的到來,支援的元件越來越豐富,也越來越成熟,其中對Redis的支援不僅僅是豐富了它的API,更是替換掉底層Jedis的依賴,取而代之換成了Lettuce(生菜),大家可以參考這篇文章對工程進行配置。所以我使用Lettuce作為客戶端來對我的MQTT協議傳輸的訊息進行快取。

下列的是Redis所對應的操作方式

  • opsForValue: 對應 String(字串)
  • opsForZSet: 對應 ZSet(有序集合)
  • opsForHash: 對應 Hash(雜湊)
  • opsForList: 對應 List(列表)
  • opsForSet: 對應 Set(集合)
  • opsForGeo: 對應 GEO(地理位置)

我主要使用opsForValue,opsForHashopsForZSet,對於字串。我推薦使用StringRedisTemplate

以下對於opsForValue和opsForHash的基礎操作,我在這裡簡短的講解一下。

Redis的Hash資料機構

Redis的雜湊可以讓使用者將多個鍵值對儲存到一個Redis鍵裡面。 public interface HashOperations<H,HK,HV> HashOperations提供一系列方法操作hash:

java > template.opsForHash().put("books","java","think in java");
redis-cli > hset books java "think in java"  # 命令列的字串如果包含空格,要用引號括起來
(integer) 1
------
java > template.opsForHash().put("books","golang","concurrency in go");
redis-cli > hset books golang "concurrency in go"
(integer) 1
------
java > template.opsForHash().put("books","python","python cookbook");
redis-cli > hset books python "python cookbook"
(integer) 1
------
java > template.opsForHash().entries("books")
redis-cli > hgetall books  # entries(),key 和 value 間隔出現
1) "java"
2) "think in java"
3) "golang"
4) "concurrency in go"
5) "python"
6) "python cookbook"
------
java > template.opsForHash().size("books")
redis-cli > hlen books
(integer) 3
------
java > template.opsForHash().get("redisHash","age")
redi-cli > hget books java
"think in java"
------
java > 
Map<String,Object> testMap = new HashMap();
      testMap.put("java","effective java");
      testMap.put("python","learning python");
      testMap.put("golang","modern golang programming");
template.opsForHash().putAll("books",testMap);
redis-cli > hmset books java "effective java" python "learning python" golang "modern golang programming"  # 批量 set
OK...

複製程式碼

Redis的Set資料結構

Redis的Set是string型別的無序集合。集合成員是唯一的,這就意味著集合中不能出現重複的資料。 Redis 中 集合是通過雜湊表實現的,所以新增,刪除,查詢的複雜度都是O(1)。

java > template.opsForSet().add("python","java","golang")
redis-cli > sadd books python java golang
(integer) 3
------
java > template.opsForSet().members("books")
redis-cli > smembers books  # 注意順序,和插入的並不一致,因為 set 是無序的
1) "java"
2) "python"
3) "golang"
------
java > template.opsForSet().isMember("books","java")
redis-cli > sismember books java  # 查詢某個 value 是否存在,相當於 contains(o)
(integer) 1
------
java > template.opsForSet().size("books")
redis-cli > scard books  # 獲取長度相當於 count()
(integer) 3
------
java > template.opsForSet().pop("books")
redis-cli > spop books  # 彈出一個
"java"...
複製程式碼

MQTT

MQTT是一種輕量級的釋出/訂閱訊息傳遞協議,最初由IBM和Arcom(後來成為Eurotech的一部分)於1998年左右建立。現在,MQTT 3.1.1規範已由OASIS聯盟標準化。

客戶端下載

Netty實現高效能IOT伺服器(Groza)之精盡程式碼篇中

對於MQTT客戶端,我選用Eclipse Paho,Eclipse Paho專案提供針對物聯網(IoT)的新的,現有的和新興的應用程式的MQTT和MQTT-SN訊息傳遞協議的開源客戶端實現。具體下載地址,大家根據自己的作業系統自行下載。

MQTT控制報文

├── Connect -- 連線服務端
├── DisConnect -- 斷開連線
├── PingReq -- 心跳請求
├── PubAck -- 釋出確認
├── PubComp -- 釋出完成(QoS2,第散步)
├── Publish -- 釋出訊息
├── PubRec -- 釋出收到(QoS2,第一步)
├── PubRel -- 釋出釋放(QoS2,第二步)
├── Subscribe -- 訂閱主題
├── UnSubscribe -- 取消訂閱
複製程式碼

Connect

讓我們對照著MQTT 3.1.1協議來實現客戶端Connect協議。

  1. 當我們對訊息解碼時,如果協議名不正確服務端可以斷開客戶端的連線,按照本規範,服務端不能繼續處理CONNECT報。

  2. 服務端使用客戶端識別符號 (ClientId) 識別客戶端。連線服務端的每個客戶端都有唯一的客戶端識別符號(ClientId)。

    // 訊息解碼器出現異常
            if (msg.decoderResult().isFailure()) {
                Throwable cause = msg.decoderResult().cause();
                if (cause instanceof MqttUnacceptableProtocolVersionException) {
                    // 不支援的協議版本
                    MqttConnAckMessage connAckMessage = (MqttConnAckMessage) MqttMessageFactory.newMessage(
                            new MqttFixedHeader(MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0),
                            new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_REFUSED_UNACCEPTABLE_PROTOCOL_VERSION, false), null);
                    channel.writeAndFlush(connAckMessage);
                    channel.close();
                    return;
                } else if (cause instanceof MqttIdentifierRejectedException) {
                    // 不合格的clientId
                    MqttConnAckMessage connAckMessage = (MqttConnAckMessage) MqttMessageFactory.newMessage(
                            new MqttFixedHeader(MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0),
                            new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_REFUSED_IDENTIFIER_REJECTED, false), null);
                    channel.writeAndFlush(connAckMessage);
                    channel.close();
                    return;
                }
                channel.close();
                return;
            }
    複製程式碼
  3. clientId為空或null的情況, 這裡要求客戶端必須提供clientId, 不管cleanSession是否為1, 此處沒有參考標準協議實現

           if (StrUtil.isBlank(msg.payload().clientIdentifier())) {
                MqttConnAckMessage connAckMessage = (MqttConnAckMessage) MqttMessageFactory.newMessage(
                        new MqttFixedHeader(MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0),
                        new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_REFUSED_IDENTIFIER_REJECTED, false), null);
                channel.writeAndFlush(connAckMessage);
                channel.close();
                return;
            }
    複製程式碼
  4. 使用者名稱和密碼驗證, 這裡要求客戶端連線時必須提供使用者名稱和密碼, 不管是否設定使用者名稱標誌和密碼標誌為1, 此處沒有參考標準協議實現

               String username = msg.payload().userName();
               String password = msg.payload().passwordInBytes() == null ? null : new String(msg.payload().passwordInBytes(), CharsetUtil.UTF_8);
               if (!grozaAuthService.checkValid(username,password)) {
                   MqttConnAckMessage connAckMessage = (MqttConnAckMessage) MqttMessageFactory.newMessage(
                           new MqttFixedHeader(MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0),
                           new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD, false), null);
                   channel.writeAndFlush(connAckMessage);
                   channel.close();
                   return;
               }
    複製程式碼
  5. 如果會話中已儲存這個新連線的clientId, 就關閉之前該clientId的連線

     if (grozaSessionStoreService.containsKey(msg.payload().clientIdentifier())){
                SessionStore sessionStore = grozaSessionStoreService.get(msg.payload().clientIdentifier());
                Channel previous = sessionStore.getChannel();
                Boolean cleanSession = sessionStore.isCleanSession();
                if (cleanSession){
                    grozaSessionStoreService.remove(msg.payload().clientIdentifier());
                    grozaSubscribeStoreService.removeForClient(msg.payload().clientIdentifier());
                    grozaDupPublishMessageStoreService.removeByClient(msg.payload().clientIdentifier());
                    grozaDupPubRelMessageStoreService.removeByClient(msg.payload().clientIdentifier());
                }
                previous.close();
            }
    複製程式碼
  6. 處理遺囑資訊

    SessionStore sessionStore = new SessionStore(msg.payload().clientIdentifier(), channel, msg.variableHeader().isCleanSession(), null);
            if (msg.variableHeader().isWillFlag()){
                MqttPublishMessage willMessage = (MqttPublishMessage) MqttMessageFactory.newMessage(
                        new MqttFixedHeader(MqttMessageType.PUBLISH,false, MqttQoS.valueOf(msg.variableHeader().willQos()),msg.variableHeader().isWillRetain(),0),
                        new MqttPublishVariableHeader(msg.payload().willTopic(),0),
                        Unpooled.buffer().writeBytes(msg.payload().willMessageInBytes())
                );
                sessionStore.setWillMessage(willMessage);
            }
    複製程式碼
  7. 處理連線心跳包

    if (msg.variableHeader().keepAliveTimeSeconds() > 0){
                if (channel.pipeline().names().contains("idle")){
                    channel.pipeline().remove("idle");
                }
                channel.pipeline().addFirst("idle",new IdleStateHandler(0, 0, Math.round(msg.variableHeader().keepAliveTimeSeconds() * 1.5f)));
            }
    複製程式碼
  8. 至此儲存會話訊息及返回接受客戶端連線 將clientId儲存到channel的map中

    grozaSessionStoreService.put(msg.payload().clientIdentifier(),sessionStore);
            channel.attr(AttributeKey.valueOf("clientId")).set(msg.payload().clientIdentifier());
            Boolean sessionPresent = grozaSessionStoreService.containsKey(msg.payload().clientIdentifier()) && !msg.variableHeader().isCleanSession();
            MqttConnAckMessage okResp = (MqttConnAckMessage) MqttMessageFactory.newMessage(
                    new MqttFixedHeader(MqttMessageType.CONNACK,false,MqttQoS.AT_MOST_ONCE,false,0),
                    new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_ACCEPTED,sessionPresent),
                    null
            );
            channel.writeAndFlush(okResp);
    複製程式碼
  9. 如果cleanSession為0, 需要重發同一clientId儲存的未完成的QoS1和QoS2的DUP訊息

     if (!msg.variableHeader().isCleanSession()){
                List<DupPublishMessageStore> dupPublishMessageStoreList = grozaDupPublishMessageStoreService.get(msg.payload().clientIdentifier());
                List<DupPubRelMessageStore> dupPubRelMessageStoreList = grozaDupPubRelMessageStoreService.get(msg.payload().clientIdentifier());
                dupPublishMessageStoreList.forEach(dupPublishMessageStore -> {
                    MqttPublishMessage publishMessage = (MqttPublishMessage)MqttMessageFactory.newMessage(
                            new MqttFixedHeader(MqttMessageType.PUBLISH,true,MqttQoS.valueOf(dupPublishMessageStore.getMqttQoS()),false,0),
                            new MqttPublishVariableHeader(dupPublishMessageStore.getTopic(),dupPublishMessageStore.getMessageId()),
                            Unpooled.buffer().writeBytes(dupPublishMessageStore.getMessageBytes())
                    );
                    channel.writeAndFlush(publishMessage);
                });
                dupPubRelMessageStoreList.forEach(dupPubRelMessageStore -> {
                    MqttMessage pubRelMessage = MqttMessageFactory.newMessage(
                            new MqttFixedHeader(MqttMessageType.PUBREL,true,MqttQoS.AT_MOST_ONCE,false,0),
                            MqttMessageIdVariableHeader.from(dupPubRelMessageStore.getMessageId()),
                            null
                    );
                    channel.writeAndFlush(pubRelMessage);
                });
            }
    複製程式碼

    其他MQTT報文大家對照著工程並對照著MQTT v3.1.1自行檢視!

使用者名稱密碼認證

/**
 * 使用者名稱和密碼認證服務
 * @author 穆書偉
 */
@Service
public class AuthServiceImpl implements GrozaAuthService {
    private RSAPrivateKey privateKey;

    @Override
    public boolean checkValid(String username, String password) {
        if (StringUtils.isEmpty(username)){
            return false;
        }
        if (StringUtils.isEmpty(password)){
            return false;
        }
        RSA rsa = new RSA(privateKey,null);
        String value = rsa.encryptBcd(username, KeyType.PrivateKey);
        return value.equals(password) ? true : false;
    }

    @PostConstruct
    public void init() {
        privateKey = IoUtil.readObj(AuthServiceImpl.class.getClassLoader().getResourceAsStream("keystore/auth-private.key"));
    }
}
複製程式碼

其他

關於Netty實現高效能IOT伺服器(Groza)之精盡程式碼篇中詳解到這裡就結束了。

原創不易,如果感覺不錯,希望給個推薦!您的支援是我寫作的最大動力!

下文會帶大家推進Netty實現MQTT協議的IOT伺服器。

版權宣告:

作者:穆書偉

部落格園出處:www.cnblogs.com/sanshengshu…

github出處:github.com/sanshengshu…    

個人部落格出處:sanshengshui.github.io/

相關文章