深入解析dio(一) Socket 程式設計實現本地多端群聊

Nayuta發表於2021-07-20

「本文已參與好文召集令活動,點選檢視:後端、大前端雙賽 道投稿,2萬元獎池等你挑戰!

引言

無論你是否用過, wendux 大佬開源的 dio 專案,應該是目前 Flutter 中最 ? 的網路請求庫,在 github 上接近 1W 的 star。

但其實 Dart 中已經有 dart:io 庫為我們提供了網路服務,為何 Dio 又如此受到開發者青睞?背後有哪些優秀的設計值得我們學習?

這個系列預計會花 6 期左右從計算機網路原理,到 Dart 中的網路程式設計,最後再到 Dio 的架構設計,通過原理分析 + 練習的方式,帶大家由淺入深的掌握 Dart 中的網路程式設計與 Dio 庫的設計。

本期,我們會通過編寫一個簡單的本地群聊服務一起學習計算機網路基礎知識與 Dart 中的 Socket 程式設計


Socket 是什麼

想要了解 Socket 是什麼,需要先複習一下網路基礎。

無論微信聊天,觀看視訊或者開啟網頁,當我們通過網路進行一次資料傳輸時。資料根據網路協議進行傳輸, 在 TCP/IP 協議中,經歷如下的流轉:

image.png

TCP/IP 定義了四層結構,每一層都是為了完成一種功能,為了完成這些功能,需要遵循一些規則,這些規則就是協議,每一層都定義了一些協議。

  • 應用層

應用層決定了向使用者提供應用服務時通訊的活動。TCP/IP 協議族內預存了各類通用的應用服務。比如,FTP(FileTransfer Protocol,檔案傳輸協議)和 DNS(Domain Name System,域名系統)服務就是其中兩類。HTTP 協議也處於該層。

  • 傳輸層

傳輸層對上層應用層,提供處於網路連線中的兩臺計算機之間端到端的資料傳輸。在傳輸層有兩個性質不同的協議:TCP(Transmission ControlProtocol,傳輸控制協議)和UDP(User Data Protocol,使用者資料包協議)。

  • 網路層(又名網路互連層)

網路層用來處理在網路上流動的資料包。資料包是網路傳輸的最小資料單位。該層規定了通過怎樣的路徑(所謂的傳輸路線)到達對方計算機,並把資料包傳送給對方。與對方計算機之間通過多臺計算機或網路裝置進行傳輸時,網路層所起的作用就是在眾多的選項內選擇一條傳輸路線。

  • 網路訪問層(又名鏈路層)

用來處理連線網路的硬體部分。包括控制作業系統、硬體的裝置驅動、NIC(Network Interface Card,網路介面卡,即網路卡),及光纖等物理可見部分(還包括聯結器等一切傳輸媒介)。硬體上的範疇均在鏈路層的作用範圍之內。

今天的主角 Socket 是應用層 與 TCP/IP 協議族通訊的中間軟體抽象層,表現為一個封裝了 TCP / IP協議族 的程式設計介面(API)

image.png

為什麼我們一開始要了解 Socket 程式設計,因為比起直接使用封裝好的網路介面,Socket 能讓我們更接近接近網路的本質,同時不用關心底層鏈路的細節。


如何使用 Dart 中的 Socket

dart:io 庫中提供了兩個類,第一個是 Socket,我們可以用它作為客戶端與伺服器建立連線。 第二個是 ServerSocket,我們將使用它建立一個伺服器,並與客戶端進行連線。

1、Socket 客戶端

本系列程式碼均上傳,可直接執行:io_practice/socket_study

Socket 類中有一個靜態方法 connect(host, int port) 。第一個引數 host 可以是一個域名或者 IP 的 String,也可以是 InternetAddress 物件。

connect 返回一個 Future<Socket> 物件,當 socket 與 host 完成連線時 Future 物件回撥。

// socket_pratice1.dart
void main() {
 Socket.connect("www.baidu.com", 80).then((socket) {
   print('Connected to: '
       '${socket.remoteAddress.address}:${socket.remotePort}');
   socket.destroy();
 });
}
複製程式碼

這個 case 中,我們通過 80 埠(為 HTTP 協議開放)與 www.baidu.com 連線。連線到伺服器之後,列印出連線的 IP 地址和埠,最後通過 socket.destroy() 關閉連線。在命令列中 執行 dart socket_pratice1.dart 可以看到如下輸出:

➜  socket_study dart socket_pratice1.dart 
socket_pratice2.dart: Warning: Interpreting this as package URI, 'package:io_pratice/socket_study/socket_pratice2.dart'.
Connected to: 220.181.38.149:80
複製程式碼

通過簡單的函式呼叫,Dart 為我們完成了 www.baidu.com 的 IP 查詢與 TCP 建立連線,我們只需要等待即可。 在連線建立之後,我們可以和服務端進行資料互動,為此我們需要做兩件事。

1、發起請求 2、響應接受資料

對應 Socket 中提供的兩個方法 Socket.write(String data)Socket.listen(void onData(data))

// socket_pratice2.dart
void main() {
  String indexRequest = 'GET / HTTP/1.1\nConnection: close\n\n';

  //與百度通過 80 埠連線
  Socket.connect("www.baidu.com", 80).then((socket) {
    print('Connected to: '
        '${socket.remoteAddress.address}:${socket.remotePort}');

    //監聽 socket 的資料返回
    socket.listen((data) {
      print(new String.fromCharCodes(data).trim());
    }, onDone: () {
      print("Done");
      socket.destroy();
    });

    //傳送資料
    socket.write(indexRequest);
  });
}
複製程式碼

執行這段程式碼可以看到 HTTP/1.1 請求頭,以及頁面資料。這是學習 web 協議很好的一個工具,我們還可以看到設 cookie 等值。(一般不用這種方式連線 HTTP 伺服器,Dart 中提供了 HttpClient 類,提供更多能力)

➜  socket_study dart socket_pratice2.dart 
socket_pratice2.dart: Warning: Interpreting this as package URI, 'package:io_pratice/socket_study/socket_pratice2.dart'.
Connected to: 220.181.38.150:80
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: no-cache
Content-Length: 14615
Content-Type: text/html
...
...
(headers and HTML code)
...
</script></body></html>
Done
複製程式碼

2、ServerSocket

使用 Socket 可以很容易的與伺服器連線,同樣我們可以使用 ServerSocket 物件建立一個可以處理客戶端請求的伺服器。 首先我們需要繫結到一個特定的埠並進行監聽,使用 ServerSocket.bind(address,int port) 方法即可。這個方法會返回 Future<ServerSocket> 物件,在繫結成功後返回 ServerSocket 物件。之後 ServerSocket.listen(void onData(Socket event)) 方法註冊回撥,便可以得到客戶端連線的 Socket 物件。注意,埠號需要大於 1024 (保留範圍)。

// serversocket_pratice1.dart
void main() {
  ServerSocket.bind(InternetAddress.anyIPv4, 4567)
      .then((ServerSocket server) {
    server.listen(handleClient);
  });
}

void handleClient(Socket client) {
  print('Connection from '
      '${client.remoteAddress.address}:${client.remotePort}');
  client.write("Hello from simple server!\n");
  client.close();
}
複製程式碼

與客戶端不同的是,在 ServerSocket.listen 中我們監聽的不是二進位制資料,而是客戶端連線。 當客戶端發起連線時,我們可以得到一個表示客戶端連線的 Socket 物件。作為引數呼叫 handleClient(Socket client) 函式。通過這個 Socket 物件,我們可以獲取到客戶端的 IP 埠等資訊,並且可以與其通訊。執行這個程式後,我們需要一個客戶端連線伺服器。可以將上一個案例中 conect 的地址改為 127.0.0.0.1,埠改為 4567,或者使用 telnet 作為客戶端發起。

執行服務端程式:

➜  socket_study dart serversocket_pratice1.dart 
serversocket_pratice1.dart: Warning: Interpreting this as package URI, 'package:io_pratice/socket_study/serversocket_pratice1.dart'.
Connection from 127.0.0.1:54555 // 客戶端連線之後列印其 ip 與埠
複製程式碼

客戶端使用 telnet 請求:

➜  io_pratice telnet localhost 4567
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello from simple server! // 來自服務端的訊息
Connection closed by foreign host.
複製程式碼

即使客戶端關閉連線,伺服器程式仍然不會退出,繼續等待下一個連線,Dart 已經為我們處理好了一切。

實戰:本地群聊服務

1、聊天伺服器

有了上面的實踐,我們可以嘗試編寫一個簡單的群聊服務。當某個客戶端傳送訊息時,其他所有連線的客戶端都可以收到這條訊息,並且能優雅的處理錯誤和斷開連線。

image.png

如圖,我們的三個客戶端與伺服器保持連線,當其中一個傳送訊息時,由服務端將訊息分發給其他連線者。 所以我們建立一個集合來儲存每一個客戶端連線物件

List<ChatClient> clients = [];
複製程式碼

每一個 ChatClient 表示一個連線,我們通過對 Socket 進行簡單的封裝,提供基本的訊息監聽,退出與異常處理:

class ChatClient {
  Socket _socket;
  String _address;
  int _port;
  
  ChatClient(Socket s){
    _socket = s;
    _address = _socket.remoteAddress.address;
    _port = _socket.remotePort;

    _socket.listen(messageHandler,
        onError: errorHandler,
        onDone: finishedHandler);
  }

  void messageHandler(List data){
    String message = new String.fromCharCodes(data).trim();
    // 接收到客戶端的套接字之後進行訊息分發
    distributeMessage(this, '${_address}:${_port} Message: $message');
  }

  void errorHandler(error){
    print('${_address}:${_port} Error: $error');
    // 從儲存過的 Client 中移除
    removeClient(this);
    _socket.close();
  }

  void finishedHandler() {
    print('${_address}:${_port} Disconnected');
    removeClient(this);
    _socket.close();
  }

  void write(String message){
    _socket.write(message);
  }
}
複製程式碼

當服務端接受到某個客戶端傳送的訊息時,需要轉發給聊天室的其他客戶端。

image.png

我們通過 messageHandler 中的 distributeMessage 進行訊息分發:

...
void distributeMessage(ChatClient client, String message){
  for (ChatClient c in clients) {
    if (c != client){
      c.write(message + "\n");
    }
  }
}
...
複製程式碼

最後我們只需要監聽每一個客戶端的連線,將其新增至 clients 集合中即可:

// chatroom.dart

ServerSocket server;

void main() {
  ServerSocket.bind(InternetAddress.ANY_IP_V4, 4567)
    .then((ServerSocket socket) {
       server = socket;
       server.listen((client) {
         handleConnection(client);
       });
    });
}

void handleConnection(Socket client){
  print('Connection from '
    '${client.remoteAddress.address}:${client.remotePort}');

  clients.add(new ChatClient(client));

  client.write("Welcome to dart-chat! "
    "There are ${clients.length - 1} other clients\n");
}
複製程式碼

直接執行程式

➜ dart chatroom.dart
複製程式碼

使用 telnet 測試伺服器連線:

➜  socket_study telnet localhost 4567 
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Welcome to dart-chat! There are 0 other clients
複製程式碼

2、聊天客戶端

聊天客戶端會簡單很多,他只需要連線到伺服器並接受訊息;以及讀取使用者的輸入資訊並將其傳送至客戶端的方法。

前面我們已經實踐過如何從伺服器接收資料,所以我們只需實現傳送訊息即可。

通過 dart:io 中的 stdin 能幫助我們輕鬆的讀取鍵盤輸入:

// chatclient.dart

Socket socket;

void main() {
  Socket.connect("localhost", 4567)
    .then((Socket sock) {
      socket = sock;
      socket.listen(dataHandler, 
        onError: errorHandler, 
        onDone: doneHandler, 
        cancelOnError: false);
    })
    .catchError((AsyncError e) {
      print("Unable to connect: $e");
      exit(1);
    });

  // 監聽鍵盤輸入,將資料傳送至服務端
  stdin.listen((data) => 
      socket.write(
        new String.fromCharCodes(data).trim() + '\n'));
}

void dataHandler(data){
  print(new String.fromCharCodes(data).trim());
}

void errorHandler(error, StackTrace trace){
  print(error);
}

void doneHandler(){
  socket.destroy();
  exit(0);
}
複製程式碼

之後執行伺服器,並通過多個命令列執行多個客戶端程式。你可以在某個客戶端中輸入訊息,之後在其他客戶端接收到訊息。

image.png

如果你有多個裝置,也可以通過 Socket.connect(host, int port) 與伺服器進行連線,當然這需要你提供每個裝置的 IP 地址,這該如何做到?下一期我會通過 UDP 與組播協議進一步完善群聊服務。

本系列程式碼均上傳,可直接執行:io_practice/socket_study

致謝:

jamesslocum Socket 練習案例(已聯絡授權)

TCP/IP 協議 wiki

網路基礎以及 web

最後

網上關於 dio 的文章基本只有如何使用,更深的解析包括 dart 網路程式設計的文章幾乎沒有,所以這個系列對我而言也是一次不小的挑戰。下一期會介紹 Dart 中的 UDP 程式設計,完善我們的群聊服務。如果你有任何疑問可以通過公眾號與聯絡我,如果文章對你有所啟發,希望能得到你的點贊、關注和收藏,這是我持續寫作的最大動力。Thanks~

公眾號:進擊的Flutter或者 runflutter 裡面整理收集了最詳細的Flutter進階與優化指南,歡迎關注。

往期精彩內容:

已開源!Flutter 流暢度優化元件 Keframe

Flutter核心渲染機制

Flutter路由設計與原始碼解析

相關文章