目錄
UDP網絡程序服務端
封裝 UdpSocket
服務端創建套接字
服務端綁定
運行服務器
UDP網絡程序客戶端
客戶端創建套接字
客戶端綁定
運行客戶端
通過上篇文章的學習,我們已經對網絡套接字有了一定的了解。在本篇文章中,我們將基于之前掌握的知識進行實際運用,動手實現一個簡單的 UDP 網絡程序。
UDP網絡程序服務端
封裝 UdpSocket
服務端創建套接字
我們為了使得代碼整體看起來比較的簡介,這里進行封裝,將服務器封裝成一個類。
當我們定義出一個服務器類后,首先要做的就是進行初始化,進行初始化的第一件事就是進行創建套接字。
socket函數
創建套接字的函數叫做socket,該函數的函數原型如下:
int socket(int domain, int type, int protocol);
參數說明:
domain
:套接字創建時使用的域(也稱為協議族),用于指定套接字的類型。該參數對應?struct sockaddr
?結構的前16位。若為本地通信,應設置為?AF_UNIX
;若為網絡通信,則通常使用?AF_INET
(IPv4)或?AF_INET6
(IPv6)。type
:套接字所需的服務類型。常見的有?SOCK_STREAM
?和?SOCK_DGRAM
?兩種。基于 UDP 的網絡通信應選用?SOCK_DGRAM
(用戶數據報服務);而基于 TCP 的網絡通信則使用?SOCK_STREAM
(流式套接字),提供可靠的流式數據傳輸服務。protocol
:套接字所使用的協議類型。可以顯式指定為 TCP 或 UDP,但通常建議直接設置為?0
,表示使用默認協議。系統會根據前兩個參數(domain
?和?type
)自動推斷應采用的協議。
返回值說明:
- 套接字創建成功返回一個文件描述符,創建失敗返回-1,同時錯誤碼會被設置。
創建套接字時我們需要填入的協議家族就是AF_INET
,因為我們要進行的是網絡通信,而我們需要的服務類型就是SOCK_DGRAM
,因為我們現在編寫的UDP服務器是面向數據報的,而第三個參數之間設置為0即可。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>class UdpServer
{
public:bool InitServer(){// 創建套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){// 創建套接字失敗std::cerr << "socket error" << std::endl;return false;}std::cout << "socket create success, sockfd: " << _sockfd << std::endl;return true;}~UdpServer(){if(_sockfd >= 0){close(_sockfd);}}
private:int _sockfd; // 文件描述符
};
除此之外,我們還理應當設置析構函數,當程序結束或者服務器關閉時,理應當關閉對應的文件描述符對應的文件。
測試,檢擦是否創建成功:
#include "UdpServer.hpp"int main()
{UdpServer* svr = new UdpServer();svr->InitServer();svr->~UdpServer();return 0;
}
運行結果如下:
運行程序后可以看到套接字是創建成功的,對應獲取到的文件描述符就是3,這也很好理解,因為0、1、2默認被標準輸入流、標準輸出流和標準錯誤流占用了,此時最小的、未被利用的文件描述符就是3。
服務端綁定
現在套接字已經創建成功了,但作為一款服務器來講,如果只是把套接字創建好了,那我們也只是在系統層面上打開了一個文件,操作系統將來并不知道是要將數據寫入到磁盤還是刷到網卡,此時該文件還沒有與網絡關聯起來。
這里需要調用的函數就是bind函數,同樣需要提醒的是,我們寫的這個是UDP,所以是不連接的,所以第二件是為綁定,與TCP略有不同。
綁定的函數叫做bind,該函數的函數原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
參數說明:
- sockfd:綁定的文件的文件描述符。也就是我們創建套接字時獲取到的文件描述符。
- addr:網絡相關的屬性信息,包括協議家族、IP地址、端口號等。
- addrlen:傳入的addr結構體的長度。
返回值說明:
- 綁定成功返回0,綁定失敗返回-1,同時錯誤碼會被設置。
struct sockaddr_in結構體
在上篇文章中,僅僅介紹了sockaddr的三種結構的適用場景與區別,然后進行了簡單的使用介紹,那么下面就看源碼進行了解。
因為我們是跨網絡通信,所以使用的是sockaddr_in。
在該文件中就可以找到struct sockaddr_in
結構的定義,需要注意的是,struct sockaddr_in
屬于系統級的概念,不同的平臺接口設計可能會有點差別。
可以看到,struct sockaddr_in
當中的成員如下:
- sin_family:表示協議家族。
- sin_port:表示端口號,是一個16位的整數。
- sin_addr:表示IP地址,是一個32位的整數。
其中sin_addr的類型是struct in_addr
,實際該結構體當中就只有一個成員,該成員就是一個32位的整數,IP地址實際就是存儲在這個整數當中的。
剩下的字段一般不做處理,當然你也可以進行初始化。
如何理解綁定?
簡單的來說,socket就像我們買了一部手機,這部手機本身沒有任何身份,它可以用來打給任何人,也可以接聽任何電話,但別人不知道如何聯系到你。
bind就好比辦一個手機卡并公布號碼,在這個過程中,bind它將這個“手機號碼”(網絡地址)與你的“手機”(套接字)綁定在一起。
技術角度來說就是:bind
?系統調用的作用是將一個協議地址(IP地址 + 端口號)分配給一個套接字(socket)。
所以bind
?的本質就是“掛牌營業”。?它告訴操作系統:“我這個套接字就在這個IP地址的這個端口上提供服務了,所有發往這個地址的數據包都交給我來處理!”
增加IP地址與端口號
所以我們根據bind的參數,得知我們除了要有sockfd,還需要知道IP與端口號。
所以為剛才的類添加成員變量。
// ...class UdpServer
{
public:UdpServer(std::string ip, int port):_sockfd(-1),_port(port),_ip(ip){}bool InitServer(){// ...}~UdpServer(){// ...}
private:int _sockfd; // 文件描述符int _port; //端口號std::string _ip; //IP地址
};
注意:?雖然這里端口號定義為整型,但由于端口號是16位的,因此我們實際只會用到它的低16位。
服務端綁定
套接字初始化完成后,就需要進行綁定了,但在綁定之前,我們需要先自己創建一個
struct sockaddr_in
類型的變量,將對應的網絡屬性信息填充到該結構當中。由于該結構體當中還有部分選填字段,因此我們最好在填充之前對該結構體變量里面的內容進行清空,然后再將協議家族、端口號、IP地址等信息填充到該結構體變量當中。
還需要注意的就是,我們的IP格式,因為我們類中的成員IP變量是string類型的,因為在網絡中通信的規定,我們還需要先調用c_str()將其轉化為字符串,然后再轉化為整形IP的形式,此時我們需要調用inet_addr函數將字符串IP轉換成整數IP。除此之外還需要注意的就是網絡字節序的問題,因為網絡中傳輸使用的是大端序,所以我們在發送到網絡之前需要將端口號設置為網絡序列,由于端口號是16位的,因此我們需要使用htons函數將端口號轉為網絡序列。
當網絡屬性信息填充完畢后,由于bind函數提供的是通用參數類型,因此在傳入結構體地址時還需要將struct sockaddr_in*
強轉為struct sockaddr*
類型后再進行傳入。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>class UdpServer
{
public:UdpServer(std::string ip, int port):_sockfd(-1),_port(port),_ip(ip){}bool InitServer(){// 創建套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){// 創建套接字失敗std::cerr << "socket error" << std::endl;return false;}std::cout << "socket create success, sockfd: " << _sockfd << std::endl;//填充網絡通信相關信息struct sockaddr_in local;memset(&local, '\0', sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = inet_addr(_ip.c_str());//綁定if (bind(_sockfd, (struct sockaddr*)&local, sizeof(sockaddr)) < 0){ //綁定失敗std::cerr << "bind error" << std::endl;return false;}std::cout << "bind success" << std::endl;return true;}~UdpServer(){if(_sockfd >= 0){close(_sockfd);}}
private:int _sockfd; // 文件描述符int _port; //端口號std::string _ip; //IP地址
};
同樣我們可以進行再次封裝,將初始化的函數整體看起來簡便些。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>class UdpServer
{
public:UdpServer(std::string ip, int port):_sockfd(-1),_port(port),_ip(ip){}bool Socket(){_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){// 創建套接字失敗std::cerr << "socket error" << std::endl;return false;}std::cout << "socket create success, sockfd: " << _sockfd << std::endl;return true;}bool Bind(){//填充網絡通信相關信息struct sockaddr_in local;memset(&local, '\0', sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = inet_addr(_ip.c_str());//綁定if (bind(_sockfd, (struct sockaddr*)&local, sizeof(sockaddr)) < 0){ //綁定失敗std::cerr << "bind error" << std::endl;return false;}std::cout << "bind success" << std::endl;return true;}bool InitServer(){// 檢查socket創建是否成功if (!Socket()) {return false;}// 檢查綁定是否成功if (!Bind()) {close(_sockfd);_sockfd = -1;return false;}return true;}~UdpServer(){if(_sockfd >= 0){close(_sockfd);}}
private:int _sockfd; // 文件描述符int _port; //端口號std::string _ip; //IP地址
};
運行服務器
UDP服務器的初始化就只需要創建套接字和綁定就行了,當服務器初始化完畢后我們就可以啟動服務器了。
服務器持續運行,其核心使命就是周而復始地為客戶端提供特定服務。正因如此,服務器程序一旦啟動,便通常不會主動退出,其內部邏輯往往通過一個循環結構不斷執行,以保持長時間的服務能力。
UDP 服務器采用無連接通信模式,這意味著它無需建立和維護連接狀態。一旦啟動完成,UDP 服務器即可隨時接收來自任何客戶端的數據報,直接讀取對方發送的信息,并進行相應處理,處理完一個數據后,進行返回,然后執行下一次循環,等待下一次發來的數據,進行周而復始的操作。
所以整體的代碼就是一個死循環,可以使用for,也可使用while,這里使用for。
recvfrom函數
接收客戶端發來的數據的函數是recvfrom函數。該函數的函數原型如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
參數說明:
- sockfd:對應操作的文件描述符。表示從該文件描述符索引的文件當中讀取數據。
- buf:讀取數據的存放位置。
- len:期望讀取數據的字節數。
- flags:讀取的方式。一般設置為0,表示阻塞讀取。
- src_addr:對端網絡相關的屬性信息,包括協議家族、IP地址、端口號等。
- addrlen:調用時傳入期望讀取的src_addr結構體的長度,返回時代表實際讀取到的src_addr結構體的長度,這是一個輸入輸出型參數。
返回值說明:
- 讀取成功返回實際讀取到的字節數,讀取失敗返回-1,同時錯誤碼會被設置。
注意:
- 在調用recvfrom讀取數據時,必須將addrlen設置為你要讀取的結構體對應的大小。
- 由于recvfrom函數提供的參數也是
struct sockaddr*
類型的,因此我們在傳入結構體地址時需要將struct sockaddr_in*
類型進行強轉。
啟動服務器函數
服務器通過?recvfrom
?函數讀取客戶端發來的數據時,現在視為接收到的數據為字符串。所以為了確保字符串正確終止,應在數據末尾手動添加?'\0'
。這樣,接收到的內容就可以直接用于輸出或后續的字符串操作。
同時,我們還可以獲取并輸出客戶端的地址信息,包括IP地址和端口號。需要注意的是:
-
獲取到的客戶端端口號是網絡字節序格式,所以要在輸出前應當使用?
ntohs
?函數將其轉換為主機字節序。 -
獲取到的客戶端IP地址是一個整型的網絡字節序地址,應當使用?
inet_ntoa
?函數將其轉換為點分十進制格式的字符串后再進行輸出。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>#define SIZE 128class UdpServer
{
public:UdpServer(std::string ip, int port)// ...{}bool InitServer(){// ...}void Start(){char buffer[SIZE];for(;;){struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);if (size > 0){buffer[size] = '\0';int port = ntohs(peer.sin_port);std::string ip = inet_ntoa(peer.sin_addr);std::cout << ip << ":" << port << "# " << buffer << std::endl;}else{std::cerr << "recvfrom error" << std::endl;}}}~UdpServer(){// ...}
private:int _sockfd; // 文件描述符int _port; //端口號std::string _ip; //IP地址
};
如果調用recvfrom函數讀取數據失敗,我們可以打印一條提示信息,但是不要讓服務器退出,因為考慮到實際情況,服務器不能因為讀取某一個客戶端的數據失敗就退出,不能因為別人的問題,而自己去承擔,應該自己的問題自己承擔,所以對此應該是客戶端去處理。
sendto函數
同樣我們還需要對客戶端發來的數據進行處理,然后進行返回,這里返回處理后的數據使用到的函數是sendto函數,該函數的函數原型如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
參數說明:
- sockfd:對應操作的文件描述符。表示將數據寫入該文件描述符索引的文件當中。
- buf:待寫入數據的存放位置。
- len:期望寫入數據的字節數。
- flags:寫入的方式。一般設置為0,表示阻塞寫入。
- dest_addr:對端網絡相關的屬性信息,包括協議家族、IP地址、端口號等。 addrlen:傳入dest_addr結構體的長度。
返回值說明:
- 寫入成功返回實際寫入的字節數,寫入失敗返回-1,同時錯誤碼會被設置。
注意:
- 由于sendto函數提供的參數也是
struct sockaddr*
類型的,因此我們在傳入結構體地址時需要將struct sockaddr_in*
類型進行強轉。
補充啟動客戶端函數
對于數據我們這里為了方便,就將原數據不進行處理,而是再數據前加一個server get->,然后直接返回。
void Start()
{char buffer[SIZE];for(;;){struct sockaddr_in peer;socklen_t len = sizeof(peer);ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);if (size > 0){buffer[size] = '\0';int port = ntohs(peer.sin_port);std::string ip = inet_ntoa(peer.sin_addr);std::cout << ip << ":" << port << "# " << buffer << std::endl;}else{std::cerr << "recvfrom error" << std::endl;}std::string echo_msg = "server get->";echo_msg += buffer;sendto(_sockfd, echo_msg.c_str(), echo_msg.size(), 0, (struct sockaddr*)&peer, len);}
}
那么我們就先對其進行簡單的測試,看看有沒有語法錯誤。
這里的參數我是隨便寫的,對于真正的測試還需要編寫客戶端的代碼,才可以知道start的邏輯是否有錯,這里只可以證明初始化沒有錯,start沒有語法錯誤與越界之類的問題,真正的邏輯問題不能檢測。
#include "UdpServer.hpp"int main()
{std::string server_ip = "127.0.0.1";int server_port = 8080;UdpServer* svr = new UdpServer(server_ip, server_port);svr->InitServer();svr->Start();svr->~UdpServer();return 0;
}
但是我們在main函數中設置這一些ip與端口號的信息,多少有點影響美觀,所以我們將其設置在.hpp文件內。
設置?IP = "0.0.0.0"
?表示服務器將監聽本機所有可用的網絡接口(網卡)上的指定端口。而不僅僅是我們最一開始設置的127.0.0.1,只可以收到本機發的數據。
到這里我們就簡單的實現了一個UDP網絡程序,雖然十分簡單,但也向前邁出了第一步。
下面我們就緊跟編寫客戶端的代碼,然后進行測試我們寫的服務端代碼是否真正無錯誤。
UDP網絡程序客戶端
同樣的,我們把客戶端也封裝成一個類,當我們定義出一個客戶端對象后也是需要對其進行初始化,而客戶端在初始化時也需要創建套接字,之后客戶端發送數據或接收數據也就是對這個套接字進行操作。
客戶端創建套接字
客戶端創建套接字時選擇的協議家族也是AF_INET
,需要的服務類型也是SOCK_DGRAM
,當客戶端被析構時也可以選擇關閉對應的套接字。與服務端不同的是,客戶端在初始化時只需要創建套接字就行了,而不需要進行綁定操作。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>class UdpClient
{
public:bool InitClient(){//創建套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){std::cerr << "socket create error" << std::endl;return false;}return true;}~UdpClient(){if(_sockfd >= 0){close(_sockfd);}}
private:int _sockfd; //文件描述符
};
客戶端綁定
與之不同的客戶端綁定問題
在網絡通信中,通信雙方都需要通過IP地址和端口號來定位對方。服務器和客戶端雖然都具備各自的IP地址和端口號,但它們在端口號的使用方式上存在重要區別。
服務器作為服務的提供方,必須明確告知外界自己的訪問地址。通常,服務器通過域名公開其IP地址,而端口號則往往不會顯式地對外公布。因此,服務器必須使用一個眾所周知的、固定的端口號,并且在選定之后不能隨意更改,否則客戶端將無法得知應當連接至哪個端口。這正是服務器需要主動綁定端口的原因——通過調用?bind
?系統調用,服務器獨占該端口,確保同一時刻只有一個進程能夠在此端口上提供服務。
相反,客戶端雖然同樣需要端口號進行通信,但通常不需要主動綁定端口。客戶端訪問服務端時,僅要求其端口號在當前系統中是唯一的,而不必與某一特定進程長期關聯。
如果客戶端綁定了某個固定端口,會導致幾個問題:首先,該端口將被獨占,即使客戶端未運行,其他程序也無法使用該端口;其次,若該端口已被占用,客戶端程序將無法啟動。因此,客戶端的端口分配更適合采用動態方式,無需人工指定。當調用?sendto
?等網絡接口時,操作系統會自動為其分配合適的、當前未被使用的臨時端口號。
也就是說,客戶端每次啟動時使用的端口號可能是不同的。只要系統中有可用的臨時端口,客戶端就能正常啟動和通信。這種機制既提高了端口資源的利用率,也增加了客戶端運行的靈活性。
運行客戶端
同樣,根服務端一個道理,我們要添加IP地址和端口號成員變量。但對于一個客戶端來說,我們是必須要知道服務器端的ip地址與端口號的,因此在客戶端類當中引入服務端的IP地址和端口號,此時我們就可以根據傳入的服務端的IP地址和端口號對對應的成員進行初始化。
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <string>class UdpClient
{
public:UdpClient(std::string server_ip, int server_port):_sockfd(-1),_server_port(server_port),_server_ip(server_ip){}bool InitClient(){// ...}~UdpClient(){// ...}
private:int _sockfd; //文件描述符int _server_port; //服務端端口號std::string _server_ip; //服務端IP地址
};
同樣跟服務器端一樣,當運行起來后,我們就需要處理通信的問題了,對于客戶端來說,是應該先發送數據,然后再接收到服務器端處理完后返回的數據。
那么思路就是我們將客戶端也設置為死循環,設置為我們自行輸入要發送的數據,然后向服務器端發送數據,然后接收到返回的數據后打印出來。按照此邏輯寫代碼。
void Start(){std::string msg;struct sockaddr_in peer;memset(&peer, '\0', sizeof(peer));peer.sin_family = AF_INET;peer.sin_port = htons(_server_port);peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());for (;;){std::cout << "Please Enter# ";getline(std::cin, msg);sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));char buffer[SIZE];struct sockaddr_in tmp;socklen_t len = sizeof(tmp);ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&tmp, &len);if (size > 0){buffer[size] = '\0';std::cout << buffer << std::endl;}}}
然后我們簡單的編寫一下UDP客戶端的.cc文件,這里用到了引入命令行參數,不了解的話,可以去搜一下,理解起來也是不難的。因為這部分的代碼不是很難,所以就直接給代碼,不講解了。
#include "UdpClient.hpp"void Usage(std::string proc)
{std::cout << "\n\rUsage: " << proc << " serverip serverport\n" << std::endl;
}int main(int argc, char *argv[])
{if(argc != 3){Usage(argv[0]);exit(0);}// 同樣作為客戶端,我們是先發信息,然后經過服務端處理后才會返回讓客戶端接收到// 同樣要創建套接字// int sockfd_; // 網路文件描述符// std::string ip_; // 任意 ip 地址 表示的是服務器的IP地址。具體來說,ip_ 是用來綁定服務器的網絡接口的IP地址。// uint16_t port_; // 表明服務器進程的端口號std::string server_ip = argv[1];int server_port = atoi(argv[2]);UdpClient* clt = new UdpClient(server_ip, server_port);clt->InitClient();clt->Start();return 0;
}
最后添加Makefile文件
.PHONY:all
all:udpserver udpclientudpserver:main.ccg++ -o $@ $^ -std=c++11
udpclient:UdpClient.ccg++ -o $@ $^ -std=c++11 -lpthread.PHONY:clean
clean:rm -f udpserver udpclient
最后測試結果:
當然我上面代碼的封裝,其實做的不是很好,UdpServer.hpp與UdpClient.hpp代碼還有很多的重復。