「本文已參與好文召集令活動,點擊查看:後端、大前端雙賽 道投稿,2萬元獎池等你挑戰!」html
不管你是否用過, wendux 大佬開源的 dio 項目,應該是目前 Flutter 中最 🔥 的網絡請求庫,在 github 上接近 1W 的 star。前端
但其實 Dart 中已經有 dart:io
庫爲咱們提供了網絡服務,爲什麼 Dio 又如此受到開發者青睞?背後有哪些優秀的設計值得咱們學習?git
這個系列預計會花 6 期左右從計算機網絡原理,到 Dart 中的網絡編程,最後再到 Dio 的架構設計,經過原理分析 + 練習的方式,帶你們由淺入深的掌握 Dart 中的網絡編程與 Dio 庫的設計。github
本期,咱們會經過編寫一個簡單的本地羣聊服務,一塊兒學習計算機網絡基礎知識與 Dart 中的 Socket 編程。web
想要了解 Socket 是什麼,須要先複習一下網絡基礎。編程
不管微信聊天,觀看視頻或者打開網頁,當咱們經過網絡進行一次數據傳輸時。數據根據網絡協議進行傳輸, 在 TCP/IP
協議中,經歷以下的流轉:後端
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)
爲何咱們一開始要了解 Socket 編程,由於比起直接使用封裝好的網絡接口,Socket 能讓咱們更接近接近網絡的本質,同時不用關心底層鏈路的細節。
dart:io
庫中提供了兩個類,第一個是 Socket
,咱們能夠用它做爲客戶端與服務器創建鏈接。 第二個是 ServerSocket
,咱們將使用它建立一個服務器,並與客戶端進行鏈接。
本系列代碼均上傳,可直接運行: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
複製代碼
使用 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 已經爲咱們處理好了一切。
有了上面的實踐,咱們能夠嘗試編寫一個簡單的羣聊服務。當某個客戶端發送消息時,其餘全部鏈接的客戶端均可以收到這條消息,而且能優雅的處理錯誤和斷開鏈接。
如圖,咱們的三個客戶端與服務器保持鏈接,當其中一個發送消息時,由服務端將消息分發給其餘鏈接者。 因此咱們建立一個集合來存儲每個客戶端鏈接對象
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
複製代碼
聊天客戶端會簡單不少,他只須要鏈接到服務器並接受消息;以及讀取用戶的輸入信息並將其發送至客戶端的方法。
前面咱們已經實踐過如何從服務器接收數據,因此咱們只需實現發送消息便可。
經過 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進階與優化指南,歡迎關注。
往期精彩內容: