深刻解析dio(一) Socket 編程實現本地多端羣聊

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

引言

不管你是否用過, wendux 大佬開源的 dio 項目,應該是目前 Flutter 中最 🔥 的網絡請求庫,在 github 上接近 1W 的 star。前端

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

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

本期,咱們會經過編寫一個簡單的本地羣聊服務一塊兒學習計算機網絡基礎知識與 Dart 中的 Socket 編程web

屏幕錄製2021-07-22 上午10.55.17.gif


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,用戶數據報協議)。markdown

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

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

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

用來處理鏈接網絡的硬件部分。包括控制操做系統、硬件的設備驅動、NIC(Network Interface Card,網絡適配器,即網卡),及光纖等物理可見部分(還包括鏈接器等一切傳輸媒介)。硬件上的範疇均在鏈路層的做用範圍以內。

今天的主角 Socket 是應用層 與 TCP/IP 協議族通訊的中間軟件抽象層,表現爲一個封裝了 TCP / IP協議族 的編程接口(API)

image.png

爲何咱們一開始要了解 Socket 編程,由於比起直接使用封裝好的網絡接口,Socket 能讓咱們更接近接近網絡的本質,同時不用關心底層鏈路的細節。


如何使用 Dart 中的 Socket

dart:io 庫中提供了兩個類,第一個是 Socket,咱們能夠用它做爲客戶端與服務器創建鏈接。 第二個是 ServerSocket,咱們將使用它建立一個服務器,並與客戶端進行鏈接。

一、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 創建鏈接,咱們只須要等待便可。 在鏈接創建以後,咱們能夠和服務端進行數據交互,爲此咱們須要作兩件事。

一、發起請求 二、響應接受數據

對應 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
複製代碼

二、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 已經爲咱們處理好了一切。

實戰:本地羣聊服務

一、聊天服務器

有了上面的實踐,咱們能夠嘗試編寫一個簡單的羣聊服務。當某個客戶端發送消息時,其餘全部鏈接的客戶端均可以收到這條消息,而且能優雅的處理錯誤和斷開鏈接。

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
複製代碼

二、聊天客戶端

聊天客戶端會簡單不少,他只須要鏈接到服務器並接受消息;以及讀取用戶的輸入信息並將其發送至客戶端的方法。

前面咱們已經實踐過如何從服務器接收數據,因此咱們只需實現發送消息便可。

經過 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);
}
複製代碼

以後運行服務器,並經過多個命令行運行多個客戶端程序。你能夠在某個客戶端中輸入消息,以後在其餘客戶端接收到消息。

屏幕錄製2021-07-22 上午10.55.17.gif

若是你有多個設備,也能夠經過 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路由設計與源碼解析

相關文章
相關標籤/搜索