Mqtt實戰項目

  • 一個基於Mqtt的小項目,服務器採用mosquitto,客戶端有Python,C,Android三種,涉及SSL加密,傳輸內容:文字圖片。
  • 時間推移,難免忘記當時學習配置的細節,有走不通的或者其他建議歡迎指出,我們一起努力讓過程變成一條直線而不是虛線。
  • 環境:Ubuntu 16.04

目錄

一..Mqtt相關

MQTT(Message Queuing Telemetry Transport,消息隊列遙測傳輸)是IBM開發的一個即時通訊協議,有可能成爲物聯網的重要組成部分。該協議支持所有平臺,幾乎可以把所有聯網物品和外部連接起來,被用來當做傳感器和制動器(比如通過Twitter讓房屋聯網)的通信協議。

1.1 延伸閱讀

推薦一些背景補充:

  • 協議詳細內容,我肯定說得不如協議內容中文版,建議大家先掃一下,對一些名詞有印象,後續再查看。
    其中,比較重要的部分,也是代碼裏需要設置的可變頭部部分,

  • 推薦幾個比較好的學習地方:

1.2 協議特點

  • Mqtt使用發佈/訂閱的消息模式,提供一對多消息分發.
  • 對傳輸消息有三種服務質量(QoS):
    • 最多一次,這一級別會發生消息丟失或重複,消息發佈依賴於底層TCP/IP網絡。即:<=1
    • 至多一次,這一級別會確保消息到達,但消息可能會重複。即:>=1
    • 只有一次,確保消息只有一次到達。即:=1。在一些要求比較嚴格的計費系統中,可以使用此級別

訂閱和發佈以及代理服務器的理解示意圖:

工作流:
服務器先啓動,然後客戶端訂閱相關的Topic。Client A 和C發佈主題爲:QuestionWhat's the temperature?。Client B因爲訂閱了Question這個Topic,所以可以收到信息,Client B收到信息做判斷後發佈答案Topic: Temperture出去,訂閱了相關Topic的Client A 和Client C能接收到37°。

  • 實現MQTT協議需要:客戶端和服務器端
  • MQTT協議中有三種身份:發佈者(Publish)、代理(Broker)(服務器)、訂閱者(Subscribe)。其中,消息的發佈者和訂閱者都是客戶端,消息代理是服務器,消息發佈者可以同時是訂閱者。
  • MQTT傳輸的消息分爲:主題(Topic)和負載(payload)兩部分
    Topic,可以理解爲消息的主題,訂閱者訂閱(Subscribe)後,就會收到該主題的消息內容(payload)
    payload,可以理解爲消息的內容,是指訂閱者具體要使用的內容

代理服務器,是用於服務客戶端的,目前很多公司都有相關的服務: 列表
裏面我只選用過Mosquitto,也就不做分析.

二..服務器

2.1 Mosquitto的安裝與使用

  • Mosquitto加入庫並更新:
sudo apt-add-repository ppa:mosquitto-dev/mosquitto-ppa
sudo apt-get update
  • 安裝:
sudo apt-get install mosquitto

安裝後位於:/etc/mosquitto,裏面可以看到默認配置文件mosquitto.conf

  • 查看狀態
sudo service mosquitto status
  • 開啓和停止Mosquitto服務:
sudo service mosquitto start
sudo service mosquitto stop (常用,在測試SSL的時候)
  • Mosquittof服務器啓動:
    上面的指令是加載默認參數,大多時候喜歡自己啓動,也不麻煩
mosquitto -v (加載默認配置)
mosquitto -v -c xx/xx/mosquitto.conf -p 8883 (加載指定配置)

避免麻煩,建議在使用SSL新建一個配置文件,這樣不用一直改來改去。-c加載配置文件,-p端口。

TCP端口8883和1883已在IANA註冊,分別用於MQTT的TLS和非TLS通信。

2.2 Mosquitto-clients

上一步只是安裝了Mosquitto服務器,不包括客戶端,安裝這個是用於調試,可以在命令行測試證書、ip等,很方便。

  • 安裝
sudo apt-get install mosquitto-clients
  • 訂閱
mosquitto_sub -t temperature 
  • 發佈
mosquitto_pub -t temperature -m 37°

結果:
這裏寫圖片描述

三..Python客戶端

服務器我們藉助mosquitto軟件來試下,那麼客戶端我們當然不會自己去寫一個協議.顯然已經有很多先驅寫了,我們只需要導入就好了.這裏採用比較出名的Eclipse Paho庫,它包含的各種語言,或者庫列表.

看圖就明白了:
這裏寫圖片描述

3.1 導入庫

pip3 install paho-mqtt

3.2 Code

  • 關鍵指令
#導入包
import paho.mqtt.client as mqtt

#創建client對象
client = mqtt.Client(id)

#連接
client.connect(host,post)

#訂閱
client.subscribe(topic)
client.on_message = func #接收到信息後的處理函數

#發佈
client.publish(topic, payload)
  • 完整Code
import paho.mqtt.client as mqtt
import sys

#改成自己的ip,命令ifconfig可以查看
host = "xx.xxx.xxx.xxx"
topic_sub = "Question"
topic_pub = "temperature"

def on_connect(client, userdata, flags, rc):
    print("Connected with result code " + str(rc))
    client.subscribe(topic_sub)

def on_message(client, userdata, msg):
    print(msg.payload) 
    client.publish(topic_pub, "37°")

def main(argv = None):
    #聲明客戶端
    client = mqtt.Client()
    #連接
    client.connect(host, 1883, 60)
    #兩個回調函數,用於執行連接成功和接收到信息要做的事
    client.on_connect = on_connect
    client.on_message = on_message
    client.loop_forever()

if __name__ == "__main__":
    sys.exit(main())

運行python客戶端,然後在終端發佈一條消息

mosquitto_pub -t Question -m 123
  • 結果:
    這裏寫圖片描述
    可以看到我發佈一條消息,然後上述python客戶端接到後發送了一條37° 信息出來,被訂閱了temperature的終端接收了.

  • 如果你不想知道圖片怎麼發送和接收,跳過這一小段:

#發送端
fp = open("7.jpg", "rb")
payload = fp.read()
client.publish(topic_pub, payload)

#接收端
def on_message(client, userdata, msg):
    new_filename = "new_img.jpg"
    fp = open(new_filename, 'wb')
    fp.write(msg.payload)
    fp.close()

這個可以推廣到傳輸其他文件.

四..C客戶端

4.1 導入庫

從源碼編譯,這個稍微比較麻煩,安裝過程如下,注意文件整理:

git clone https://github.com/eclipse/paho.mqtt.c.git
cd paho.mqtt.c
make
sudo make install

在使用的過程中也要注意編譯的方式,這裏提供一個Makefile做參考:

test:test.cpp cmqtt.cpp cmqtt.h
    g++ -o test test.cpp cmqtt.cpp -lpaho-mqtt3c \
    -I ../../paho.mqtt.c/src \
    -L ../../paho.mqtt.c/build \
    -pthread -Imqtt \
    -std=c++11 

4.2 Code

C代碼方面我提供關鍵部分供初學者容易上手,我自己進行過一次c++的封裝,比較複雜.有時間的話另開一帖.

  • 首先,C代碼方面我們肯定不會是隻做一件事情,所以我們需要開兩個線程兩個客戶端來進行訂閱和發佈.一個同時發佈和訂閱,也可以.
//mqttclient.c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "MQTTClient.h"
#include <unistd.h>
#include <sys/stat.h>


#define NUM_THREADS 2
#define ADDRESS "tcp://xx.xxx.xx.xxx:1883"
#define CLIENTID "ExampleClient_pub"
#define SUB_CLIENTID    "ExampleClient_sub" //更改此處客戶端ID
#define TOPICPUB    "Question"  //更改發送的話題
#define TOPICSUB    "temperature"
#define QOS         1
#define TIMEOUT     10000L
#define DISCONNECT  "out"

int CONNECT = 1;
volatile MQTTClient_deliveryToken deliverytoken;
long PAYLOADLEN;
char* PAYLOAD;

void delivered(void *context, MQTTClient_deliveryToken dt)
{
  printf("Message with token value %d delivery confirmed\n", dt);
  deliverytoken = dt;
}

int msgarrvd(void *context, char *topicName, int topicLen, MQTTClient_message *message)
{
  int i;
  char* payloadptr;

  printf("Message arrived\n");
  printf("    topic: %s\n", topicName);
  printf("  message: \n");

  payloadptr = message->payload;
  if (strcmp(payloadptr, DISCONNECT) == 0) {
    printf("\n out!!");
    CONNECT = 0;
  }

  for (i = 0; i < message->payloadlen; i++) {
    putchar(*payloadptr++);
  }
  printf("\n");

  MQTTClient_freeMessage(&message);
  MQTTClient_free(topicName);
  return 1;
}

void connlost(void *context, char *cause)
{
  printf("\nConnection lost\n");
  printf("     cause: %s\n", cause);
}

void *pubClient(void *threadid) {
  long tid;
  tid = (long)threadid;
  int count = 0;
  printf("Hello World! It's me, thread #%ld!\n", tid);
  //聲明一個MQTTClient
  MQTTClient client;
  //初始化MQTT Client選項
  MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;
  //#define MQTTClient_message_initializer { {'M', 'Q', 'T', 'M'}, 0, 0, NULL, 0, 0, 0, 0 }
  MQTTClient_message pubmsg = MQTTClient_message_initializer;
  //聲明消息token
  MQTTClient_deliveryToken token;
  int rc;
  //使用參數創建一個client,並將其賦值給之前聲明的client
  MQTTClient_create(&client, ADDRESS, CLIENTID,
                    MQTTCLIENT_PERSISTENCE_NONE, NULL);
  conn_opts.keepAliveInterval = 20;
  conn_opts.cleansession = 1;
  //使用MQTTClient_connect將client連接到服務器,使用指定的連接選項。成功則返回MQTTCLIENT_SUCCESS
  if ((rc = MQTTClient_connect(client, &conn_opts)) != MQTTCLIENT_SUCCESS)
  {
    printf("Failed to connect, return code %d\n", rc);
    exit(EXIT_FAILURE);
  }
  PAYLOAD = "What's the temperature";
  // printf("%s\n", PAYLOAD);
  pubmsg.payload = PAYLOAD;
  pubmsg.payloadlen = (int)strlen(PAYLOAD);
  pubmsg.qos = QOS;
  pubmsg.retained = 0;
  //循環發佈
  while (CONNECT) {
    MQTTClient_publishMessage(client, TOPICPUB, &pubmsg, &token);
    printf("Waiting for up to %d seconds for publication of %s\n"
             "on topic %s for client with ClientID: %s\n",
             (int)(TIMEOUT/1000), PAYLOAD, TOPICPUB, CLIENTID);
    rc = MQTTClient_waitForCompletion(client, token, TIMEOUT);
    printf("Message with delivery token %d delivered\n", token);
    // thread sleep
    usleep(2000000L);
  }

  MQTTClient_disconnect(client, 10000);
  MQTTClient_destroy(&client);
}

void *subClient(void *threadid) {
  long tid;
  tid = (long)threadid;
  printf("Hello World! It's me, thread #%ld!\n", tid);

  MQTTClient client;
  MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;
  int rc;
  int ch;

  MQTTClient_create(&client, ADDRESS, SUB_CLIENTID,
                    MQTTCLIENT_PERSISTENCE_NONE, NULL);
  conn_opts.keepAliveInterval = 20;
  conn_opts.cleansession = 1;
  //設置回調函數
  MQTTClient_setCallbacks(client, NULL, connlost, msgarrvd, delivered);

  if ((rc = MQTTClient_connect(client, &conn_opts)) != MQTTCLIENT_SUCCESS)
  {
    printf("Failed to connect, return code %d\n", rc);
    exit(EXIT_FAILURE);
  }
  printf("Subscribing to topic %s\nfor client %s using QoS%d\n\n"
         "Press Q<Enter> to quit\n\n", TOPICSUB, SUB_CLIENTID, QOS);
  MQTTClient_subscribe(client, TOPICSUB, QOS);

  do
  {
    ch = getchar();
  } while (ch != 'Q' && ch != 'q');
  //quit
  MQTTClient_unsubscribe(client, TOPICSUB);
  MQTTClient_disconnect(client, 10000);
  MQTTClient_destroy(&client);

  pthread_exit(NULL);
}

int main(int argc, char* argv[])
{
  pthread_t threads[NUM_THREADS];
  pthread_create(&threads[0], NULL, subClient, (void *)0);
  pthread_create(&threads[1], NULL, pubClient, (void *)1);
  pthread_exit(NULL);
}
  • 結果:
    這裏寫圖片描述

五..Android客戶端

5.1 導入庫

Android客戶端同樣需要一些配置來導入庫,官網教程不是很好看,HIVEMQ的不錯:

  • app內的build.gradle
//和android\dependencies同級
repositories {
    maven {
        url "https://repo.eclipse.org/content/repositories/paho-snapshots/"
    }
}
dependencies {
    ......
    implementation('org.eclipse.paho:org.eclipse.paho.android.service:1.0.2') {
        exclude module: 'support-v4'
    }
    ......
}
  • 權限設置AndrroidManifest.xml
// 放在manifest下一級
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />

// 放在<application>下一級
<service android:name="org.eclipse.paho.android.service.MqttService" >
</service>

5.2 Code

  • Android代碼比較簡陋:
public class MainActivity extends AppCompatActivity {

    private static final String TAG = "LQH";
    Button bt_connect;
    Button bt_sub;
    Button bt_pub;
    TextView textView;
    String log;
    MqttAndroidClient client;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        bt_connect = findViewById(R.id.bt_connect);
        bt_pub = findViewById(R.id.bt_pub);
        bt_sub = findViewById(R.id.bt_sub);
        textView = findViewById(R.id.textView);
        log = "Log:\n\n";
        textView.setText(log);
        bt_connect.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String clientId = MqttClient.generateClientId();
                //創建客戶端
                client = new MqttAndroidClient(MainActivity.this, "tcp://xx.xxx.xx.xxx:1883",
                                clientId);
        //連接
                try {
                    IMqttToken token = client.connect();
                    token.setActionCallback(new IMqttActionListener() {
                    //兩個響應函數
                        @Override
                        public void onSuccess(IMqttToken asyncActionToken) {
                            // We are connected
                            Log.d(TAG, "onSuccess");
                            Toast.makeText(MainActivity.this, "connect successed", Toast.LENGTH_SHORT).show();
                            log += "Connect successed!\n\n";
                            textView.setText(log);
                        }
                        @Override
                        public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
                            // Something went wrong e.g. connection timeout or firewall problems
                            Log.d(TAG, "onFailure");
                            Toast.makeText(MainActivity.this, "not connect", Toast.LENGTH_SHORT).show();
                            log += "Connect failed!\n\n";
                            textView.setText(log);
                        }
                    });
                } catch (MqttException e) {
                    e.printStackTrace();
                }
                //設置幾個回調函數
                client.setCallback(new MqttCallback() {

                    //連接斷開
                    @Override
                    public void connectionLost(Throwable cause) {
                        Toast.makeText(MainActivity.this, "connectionLost", Toast.LENGTH_SHORT).show();
                    }
                    //接收信息
                    @Override
                    public void messageArrived(String topic, MqttMessage message) throws Exception {
                        log = log + "Recevied msg: " + new String(message.getPayload()) + "\n\n";
                        textView.setText(log);
                    }
                    //發佈信息成功
                    @Override
                    public void deliveryComplete(IMqttDeliveryToken token) {
                        Toast.makeText(MainActivity.this, "published", Toast.LENGTH_SHORT).show();
                        log = log + "Published\n\n";
                        textView.setText(log);
                    }
                });
            }
        });

        bt_sub.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                final String topic = "temperature";
                int qos = 1;
                try {
                //訂閱
                    IMqttToken subToken = client.subscribe(topic, qos);
                    subToken.setActionCallback(new IMqttActionListener() {
                        @Override
                        public void onSuccess(IMqttToken asyncActionToken) {
                            // The message was published
                            Toast.makeText(MainActivity.this, "subscribe successed", Toast.LENGTH_SHORT).show();
                            log = log + "Subscribe topic: " + topic + " successed!\n\n";
                            textView.setText(log);
                        }

                        @Override
                        public void onFailure(IMqttToken asyncActionToken,
                                              Throwable exception) {
                            // The subscription could not be performed, maybe the user was not
                            // authorized to subscribe on the specified topic e.g. using wildcards
                            Toast.makeText(MainActivity.this, "subscribe failure", Toast.LENGTH_SHORT).show();
                        }
                    });
                } catch (MqttException e) {
                    e.printStackTrace();
                }
            }
        });

        bt_pub.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String topic = "Question";
                String payload = "What's the temperature?";
                try {
                    MqttMessage message = new MqttMessage(payload.getBytes());
                    //發佈
                    client.publish(topic, message);
                    log = log + "Publish:\n" + "  topic:" + topic + "\n  payload:" + payload + "\n\n";
                    textView.setText(log);
                } catch (MqttException e) {
                    e.printStackTrace();
                }
            }
        });
    }
}
  • 結果:

好吧,就是這麼簡陋的,例如你沒連接就發佈,程序會崩潰.hhh

六..SSL背景知識

這邊有兩個帖子,一個系列,寫得實在好:
- 對於Mqtt和TLS的認識
- 怎麼生成證書

我也稍作解釋:

對稱加密
鑰匙A加密,再用鑰匙A解密,但密鑰傳輸過程中如果被截獲就泄密了,所以產生非對稱加密
非對稱加密
鑰匙A加密,只能用鑰匙B加密
公鑰(yue)和私鑰
非對稱加密用戶擁有公鑰和私鑰,公鑰加密只能用私鑰解密,反之亦然。
公鑰發給別人,而私鑰自己妥善保管。
想象一種情況,A,B,C三人,A給B發信息捎上了自己的公鑰,結果C中途攔截了A公鑰,把C公鑰發給B,B拿到後用公鑰C加密發佈信息,C是不是可以獲得B發佈的信息,C還可以把信息用公鑰A加密發給A,不聲不響截獲數據,所以有了CA(授權中心).
CA(證書授權中心)
承擔公鑰合法性檢驗的責任。授權中心會發行一個個的證書,每個證書本質上包含:實體或個人的名字以及對應的公鑰。爲了保證證書的安全性,授權中心用自己的私鑰對證書進行加密,證書接受者用授權中心的公鑰對該證書進行解密,從而實現證書的數字簽名,如圖:


圖片來自: 怎麼生成證書

  • 證書的生成過程參考上述的 怎麼生成證書
    • 有一點要注意的,生成證書和密鑰要用同一個CA
    • Common Name要爲電腦ip,並且儘量不重複,可以用127.0.0.1, xx.xxx.xx.xxx,如果有線無線都有,那麼可以生成ca, server, client.測試雙向認證。建議:
      • ca.crt用wifi的ip,server.crt用有線ip,因爲WiFi,Android或者C客戶端可以連接,檢驗證書.
單向認證:
指的是隻有一個對象校驗對端的證書合法性。
通常都是Client來校驗Server的合法性。那麼client需要一個ca.crt,服務器需要server.crt,server.key。
雙向認證
指的是相互校驗,服務器需要校驗每個client,client也需要校驗服務器。
Server 需要 server.key 、server.crt 、ca.crt
Client 需要 client.key 、client.crt 、ca.crt

經常採用單向認證,雙向雖然更安全,但是每個客戶還要求生成證書會很麻煩。後面代碼也基於此。

七..客戶端添加SSL模塊

7.1 Mosquitto

  • 修改配置文件
    既然我們想要採用ssl認證,那麼我們自然需要改配置,稍微一思考,我們需要改的內容也不多,指定ca.crt, server.crt, server.key三個文件的路徑,指定單向認證或者雙向認證。

  • mqtt_tls.conf

# 這是我的路徑,要改
# CA證書,pem格式,
cafile /xxx/ca/ca.crt
# 服務器證書
certfile /xxx/server/server.crt
# 服務器密鑰
keyfile /xxx/server/server.key
# false->單向認證, true->雙向認證
require_certificate false
# 如果require_certificate爲true,則可以將use_identity_as_username設置爲true以將客戶端證書中的CN值用作用戶名。 如果true,則不會將password_file選項用於此偵聽器。
use_identity_as_username false
  • 運行broker
mosquitto -v -c xx/xx/mqtt_tls.conf -p 8883
  • 可以用兩個終端來測試一下:
mosquitto_sub -t temperture -h xx.xxx.xx.xxx -p 8883 --cafile /xxx/xxx/ca.crt
mosquitto_pub -t temperture -m 37°  -h xx.xxx.xx.xxx -p 8883 --cafile /xx/xxx/ca.crt

這樣就實現了mosquitto的配置測試,

7.2 Python

python代碼的改動比較的簡單:

client.tls_set("/xx/xxx/ca.crt", tls_version=ssl.PROTOCOL_TLSv1_2)
# client.connect(host, 1883, 60)
client.connect(host, 8883, 60)

簡單加載證書,後面的版本指定本來可以沒有,但我這邊後來軟件變動出現問題,所以加上這個。

7.3 C

C的代碼改動也不復雜,就是有個坑

// 1
#define ADDRESS "ssl://xx.xxx.xx.xxx:8883"

// 2
MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer;

MQTTClient_SSLOptions ssl = MQTTClient_SSLOptions_initializer;
ssl.trustStore = "/xx/xxx/ca.pem";

conn_opts.ssl = &ssl;

// 3,巨坑
//編譯加載的庫不一樣,要 +s !!! -lpaho-mqtt3cs
test:test.cpp cmqtt.cpp cmqtt.h
    g++ -o test test.cpp cmqtt.cpp -lpaho-mqtt3cs \
    -I ../../paho.mqtt.c/src \
    -L ../../paho.mqtt.c/build \
    -pthread -Imqtt \
    -std=c++11 

7.4 Android

這塊和前面兩個有點不一樣,因爲之前的ca.crt在這裏是不能用的,Android能加載的證書需要是bks格式的,所以這裏需要先生成bks,然後把ca.crt添加進去。再加載。

java -version

根據上面指令查看jdk版本,然後下載合適的bcprov,–>bcprov-ext-jdk15on-160.jar,然後放到(jdk_home)/jre/lib/ext
這裏可以用下面指令找到放置的文件夾:

locate jaccess

jaccess只是本來存在(jdk_home)/jre/lib/ext文件夾下的另一個文件,如果安裝了jdk和android-studio,那麼可以找到三條位置,選擇/xxx/android-studio/jre/jre/lib/ext/,修改這裏比較簡單,/usr/下的往往還涉及權限。

  • 生成bks
    在隨意位置運行
keytool -importcert -keystore test_ca.bks -file /xxx/ca.crt -storetype BKS -provider org.bouncycastle.jce.provider.BouncyCastleProvider -providerpath "/xxx/android-studio/jre/jre/lib/ext/bcprov-ext-jdk15on-160.jar"

輸入六位密碼,yes,就可以看到生成的test_ca.bks
test_ca.bks放到android項目下的/res/raw,沒有就新建

  • Code
// 1
client = new MqttAndroidClient(MainActivity.this, "ssl://xx/xxx/xx/xxx:8883",
                clientId);

// 2.配置MqttConnectOptions
MqttConnectOptions options = new MqttConnectOptions();
SSLContext sslContext = SSLContext.getInstance("TLS");
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance("X509");
KeyStore keyStore = KeyStore.getInstance("BKS");
// 剛纔生成的文件加載,“123456”對應密碼
keyStore.load(this.getResources().openRawResource(R.raw.test_ca),"123456".toCharArray());
trustManagerFactory.init(keyStore);
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
SocketFactory factory = sslContext.getSocketFactory();
options.setSocketFactory(factory);

然後Alt+Enter解決各種紅色波浪。基本都是異常處理。

  • 代碼有不明白的不妨百度,一行一行看過去是最快的學習路線。

結束語

  • 至此基本完結,第一個比較長的帖子,難免出現問題,希望大家可以指出,儘量改正,謝謝。
  • 後面會考慮整理代碼上傳github
  • 如果有用希望能給個贊鼓勵一下
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章