「本文已參與好文召集令活動,點選檢視:後端、大前端雙賽 道投稿,2萬元獎池等你挑戰!」
引言
無論你是否用過, wendux 大佬開源的 dio 專案,應該是目前 Flutter 中最 ? 的網路請求庫,在 github 上接近 1W 的 star。
但其實 Dart 中已經有 dart:io
庫為我們提供了網路服務,為何 Dio 又如此受到開發者青睞?背後有哪些優秀的設計值得我們學習?
這個系列預計會花 6 期左右從計算機網路原理,到 Dart 中的網路程式設計,最後再到 Dio 的架構設計,通過原理分析 + 練習的方式,帶大家由淺入深的掌握 Dart 中的網路程式設計與 Dio 庫的設計。
本期,我們會通過編寫一個簡單的本地群聊服務,一起學習計算機網路基礎知識與 Dart 中的 Socket 程式設計。
Socket 是什麼
想要了解 Socket 是什麼,需要先複習一下網路基礎。
無論微信聊天,觀看視訊或者開啟網頁,當我們通過網路進行一次資料傳輸時。資料根據網路協議進行傳輸,
在 TCP/IP
協議中,經歷如下的流轉:
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)
為什麼我們一開始要了解 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、聊天伺服器
有了上面的實踐,我們可以嘗試編寫一個簡單的群聊服務。當某個客戶端傳送訊息時,其他所有連線的客戶端都可以收到這條訊息,並且能優雅的處理錯誤和斷開連線。
如圖,我們的三個客戶端與伺服器保持連線,當其中一個傳送訊息時,由服務端將訊息分發給其他連線者。 所以我們建立一個集合來儲存每一個客戶端連線物件
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);
}
}
複製程式碼
當服務端接受到某個客戶端傳送的訊息時,需要轉發給聊天室的其他客戶端。
我們通過 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);
}
複製程式碼
之後執行伺服器,並通過多個命令列執行多個客戶端程式。你可以在某個客戶端中輸入訊息,之後在其他客戶端接收到訊息。
如果你有多個裝置,也可以通過 Socket.connect(host, int port)
與伺服器進行連線,當然這需要你提供每個裝置的 IP 地址,這該如何做到?下一期我會通過 UDP 與組播協議進一步完善群聊服務。
本系列程式碼均上傳,可直接執行:io_practice/socket_study
致謝:
jamesslocum Socket 練習案例(已聯絡授權)
最後
網上關於 dio 的文章基本只有如何使用,更深的解析包括 dart 網路程式設計的文章幾乎沒有,所以這個系列對我而言也是一次不小的挑戰。下一期會介紹 Dart 中的 UDP 程式設計,完善我們的群聊服務。如果你有任何疑問可以通過公眾號與聯絡我,如果文章對你有所啟發,希望能得到你的點贊、關注和收藏,這是我持續寫作的最大動力。Thanks~
公眾號:進擊的Flutter或者 runflutter 裡面整理收集了最詳細的Flutter進階與優化指南,歡迎關注。
往期精彩內容: