目錄
前言
1.Socket編程準備
1.理解源IP地址和目的IP地址
2.認識端口號
3.socket源來
4.傳輸層的典型代表
5.網絡字節序
6.socket編程接口
2.Socket編程UDP
1.服務端創建套接字
2.服務端綁定
3.運行服務器
4.客戶端訪問服務器
5.測試
6.補充參考內容
總結
前言
? 這是我們網絡部分socket套接字上篇的內容,本篇篇幅還是比較長的,大概在14000字左右,可以預見到內容還是比較細的并且也有包含代碼的編寫,這一篇是非常重要的,因為上難度了并且知識點也非常重要qwq
1.Socket編程準備
在正式講解socket套接字之前,我們不妨先來看看一些預備知識來醞釀一下:
但是系統中, 同時會存在非常多的進程, 當數據到達目標主機之后, 怎么轉發給目標進程? 這就要在網絡的背景下, 在系統中, 標識主機的唯一性
1.理解源IP地址和目的IP地址
因特網上的每臺計算機都有一個唯一的IP地址,如果一臺主機上的數據要傳輸到另一臺主機,那么對端主機的IP地址就應該作為該數據傳輸時的目的IP地址。但僅僅知道目的IP地址是不夠的,當對端主機收到該數據后,對端主機還需要對該主機做出響應,因此對端主機也需要發送數據給該主機,此時對端主機就必須知道該主機的IP地址。因此一個傳輸的數據當中應該涵蓋其源IP地址和目的IP地址,目的IP地址表明該數據傳輸的目的地,源IP地址作為對端主機響應時的目的IP地址。
在數據進行傳輸之前,會先自頂向下貫穿網絡協議棧完成數據的封裝,其中在網絡層封裝的IP報頭當中就涵蓋了源IP地址和目的IP地址。而除了源IP地址和目的IP地址之外,還有源MAC地址和目的MAC地址的概念
2.認識端口號
端口號(port)是傳輸層協議的內容 ,可以用來標識系統中唯一的一個網絡進程
端口號是一個 2 字節 16 位的整數
端口號用來標識一個進程,告訴操作系統,當前的這個數據要交給哪一個進程來處理
IP 地址 + 端口號能夠標識網絡上的某一臺主機的某一個進程
一個端口號只能被一個進程占用,但一個進程可以綁定多個端口號
端口號范圍劃分
-
0 - 1023: 知名端口號, HTTP, FTP, SSH 等這些廣為使用的應用層協議,他們的端口號都是固定的
-
1024 - 65535:操作系統動態分配的端口號. 客戶端程序的端口號,就是由操作系統從這個范圍分配的
我們在系統部分就知道進程有pid來標識該進程,那么這里的端口號和pid有啥不一樣的地方呢?
-
不是所有的進程都需要進程網絡通信,也就是說所有的進程都有pid,但是不一定會有端口號
-
從技術角度pid可以勝任這里的端口號的作用,但是pid是一個系統概念,如果使用pid,那么pid變化了網絡也得跟著變,解耦性低;換句話說設計端口號就是為了和系統解耦
3.socket源來
現在我們知道了:
-
IP地址可以用來標識全網內唯一的一個主機
-
port(端口號)可以用來標識該主機內唯一的一個網絡進程
所以:IP + port 可以表示全網內唯一的一個進程
{源IP,源port ——》 目的IP,目的port}(所以不必說就知道源port和目的port是啥啦)
也就是:一個進程——》另一個進程(全網內唯二的兩個進程間在通信——網絡通信的本質)
我們用對方的IP和port標識對方的唯一性
而我們的IP + port就是接下來要學習的socket(套接字)
socket通信本質也就是進程間通信
直抒胸臆,我們就可以形象的把套接字理解為一個插座(在通信之前先拿IP+port進行鏈接,相當于插座和插頭鏈接就可以輸送電力了)
4.傳輸層的典型代表
如果我們了解了系統,也了解了網絡協議棧, 我們就會清楚, 傳輸層是屬于內核的, 那么我們要通過網絡協議棧進行通信, 必定調用的是傳輸層提供的系統調用, 來進行的網絡通信
傳輸層最典型的兩種協議就是 TCP協議 和 UDP協議
-
TCP協議是面向連接的,如果兩臺主機之間想要進行數據傳輸,那么必須要先建立連接,當連接建立成功后才能進行數據傳輸。其次,TCP協議是保證可靠的協議,數據在傳輸過程中如果出現了丟包、亂序等情況,TCP協議都有對應的解決方法
-
使用UDP協議進行通信時無需建立連接,如果兩臺主機之間想要進行數據傳輸,那么直接將數據發送給對端主機就行了,但這也就意味著UDP協議是不可靠的,數據在傳輸過程中如果出現了丟包、亂序等情況,UDP協議本身是不知道的
TCP協議是一種可靠的傳輸協議,使用TCP協議能夠在一定程度上保證數據傳輸時的可靠性,而UDP協議是一種不可靠的傳輸協議,UDP協議的存在有什么意義?
-
首先,可靠是需要我們做更多的工作的,TCP協議雖然是一種可靠的傳輸協議,但這一定意味著TCP協議在底層需要做更多的工作,因此TCP協議底層的實現是比較復雜的,占有的資源也比較多,我們不能只看到TCP協議面向連接可靠這一個特點,我們也要能看到TCP協議對應的缺點。
(銀行轉賬、獲取網頁...)
-
同樣的,UDP協議雖然是一種不可靠的傳輸協議,但這一定意味著UDP協議在底層不需要做過多的工作,因此UDP協議底層的實現一定比TCP協議要簡單,也不需要占有過多資源,UDP協議雖然不可靠,但是它能夠快速的將數據發送給對方,雖然在數據在傳輸的過程中可能會出錯。
(網課端、直播...)
(注意:可靠和不可靠是它們的特性,而不是缺點)
編寫網絡通信代碼時具體采用TCP協議還是UDP協議,完全取決于上層的應用場景。如果應用場景嚴格要求數據在傳輸過程中的可靠性,此時我們就必須采用TCP協議,如果應用場景允許數據在傳輸出現少量丟包,那么我們肯定優先選擇UDP協議,因為UDP協議足夠簡單。
(注意: 一些優秀的網站在設計網絡通信算法時,會同時采用TCP協議和UDP協議,當網絡流暢時就使用UDP協議進行數據傳輸,而當網速不好時就使用TCP協議進行數據傳輸,此時就可以動態的調整后臺數據通信的算法)
5.網絡字節序
這里先簡單回顧一下大小端存儲
-
數據擁有高權值位和低權值位,比如在 32 位操作系統中,十六進制數 0x11223344,其中的 11 稱為 最高權值位,44 稱為 最低權值位
-
內存有高地址和低地址之分
如果將數據的高權值存放在內存的低地址處,低權值存放在高地址處,此時就稱為 大端字節序,反之則稱為 小端字節序,這兩種字節序沒有好壞之分,只是系統設計者的使用習慣問題
內存中的多字節數據相對于內存地址有大端和小端之分, 磁盤文件中的多字節數據相對于文件中的偏移地址也有大端小端之分, 網絡數據流同樣有大端小端之分. 那么如何定義網絡數據流的地址呢?
在網絡出現之前,使用大端或小端存儲都沒有問題,網絡出現之后,就需要考慮使用同一種存儲方案了,因為網絡通信時,兩臺主機存儲方案可能不同,會出現無法解讀對方數據的問題
TCP/IP 協議規定:網絡中傳輸的數據,統一采用大端存儲方案,也就是網絡字節序, 之前的大端/小端存儲稱為 主機字節序,發送數據時,將 主機字節序 轉化為 網絡字節序,接收到數據后,再轉回 主機字節序 就好了,完美解決不同機器中的大小端差異
為何需要這樣來規定?
答:前面我們知道網絡數據流的地址應這樣規定:先發出的數據是低地址,后發出的數據是高地址
如這幅圖,如果不是大端存儲的方式,那么在低地址處存的是cd,以此類推,那么從低地址向高地址依次發出形成的地址是反過來的,大端存儲之后從低地址到高地址就是0x1234abcd——符合我們的閱讀習慣,更直觀
為使網絡程序具有可移植性,使同樣的 C 代碼在大端和小端計算機上編譯后都能正常運行,可以調用以下庫函數做網絡字節序和主機字節序的轉換
#include <arpa/inet.h>
?
// 主機字節序轉網絡字節序
uint32_t htonl(uint32_t hostlong); // l 表示32位長整數
uint16_t htons(uint16_t hostshort); // s 表示16位短整數
?
// 網絡字節序轉主機字節序
uint32_t ntohl(uint32_t netlong); // l 表示32位長整數
uint16_t ntohs(uint16_t netshort); // s 表示16位短整數
結論:網絡規定所有發送到網絡上的數據都必須是大端的!
6.socket編程接口
socket 常見API
socket套接字提供了下面這一批常用接口,用于實現網絡通信
#include <sys/types.h>
#include <sys/socket.h> // 創建socket文件描述符(TCP/UDP ? 服務器 + 客戶端)
int socket(int domain, int type, int protocol);
?
// 綁定端口號(TCP/UDP ? 服務器)
int bind(int socket, const struct sockaddr* address, socklen_t address_len);
?
// 開始監聽socket (TCP 服務器)
int listen(int socket, int backlog);
?
// 接收連接請求 (TCP 服務器)
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
?
// 建立連接 (TCP 客戶端)
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
通過上面的接口我們可以看到3/5的接口參數中都有sockaddr這個結構體存在
關于sockaddr結構
1背景:
網絡通信的本質就是進程間通信
-
system V——本地進程間通信
-
posix標準——網絡通信,也就是進程間通信,亦可進行本地通信
(所以system V的通信方式我們基本不用了)
socket API 是一層抽象的網絡編程接口,適用于各種底層網絡協議,如 IPv4、 IPv6,以及后面要講的 UNIX Domain Socket. 然而, 各種網絡協議的地址格式并不相同
2內容:
socket 這套網絡通信標準隸屬于 POSIX 通信標準,該標準的設計初衷就是為了實現可移植性,程序可以直接在使用該標準的不同機器中運行,但有的機器使用的是網絡通信,有的則是使用本地通信,socket 套接字為了能同時兼顧這兩種通信方式,提供了 sockaddr 結構體
由 sockaddr 結構體衍生出了兩個不同的結構體:sockaddr_in 網絡套接字、sockaddr_un 域間套接字,前者用于網絡通信,后者用于本地通信
-
可以根據 16 位地址類型,判斷是網絡通信(AF_INET),還是本地通信(AF_UNIX)
-
在進行網絡通信時,需要提供 IP 地址、端口號 等網絡通信必備項,本地通信只需要提供一個路徑名,通過文件讀寫的方式進行通信(類似于命名管道)
(網絡通信就創建sockaddr_in結構,本地通信就創建sockaddr_un 結構,但是要調用socket的API接口就必須要強轉成sockaddr結構,在接口內部拿出sockaddr的16位地址類型來區分是網絡通信還是本地通信——函數內部自行區分)也就是繼承和多態的體現
socket 提供的接口參數為 sockaddr,我們既可以傳入 &sockaddr_in 進行網絡通信,也可以傳入 &sockaddr_un 進行本地通信,傳參時將參數進行強制類型轉換即可,這是使用 C語言 實現 多態 的典型做法,確保該標準的通用性 > 為什么不將參數設置為 void ? > 因為在該標準設計時,C語言還不支持 void* 這種類型,為了確保向前兼容性,即便后續支持后也不能進行修改了
2.Socket編程UDP
1.服務端創建套接字
這里就要使用我們前面的socket編程接口了
-
socket():創建一個通信的一端(另一端在后面也需要創建一次)
[^] ?成功返回新的文件描述符,失敗返回-1并設置錯誤碼?
參數說明:
-
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就可以了,設置為0表示的就是默認,此時會根據傳入的前兩個參數自動推導出你最終需要使用的是哪種協議
2.服務端綁定
-
bind() 綁定端口號
參數說明:
-
sockfd:綁定的文件的文件描述符。也就是我們創建套接字時獲取到的文件描述符。
-
addr:網絡相關的屬性信息,包括協議家族、IP地址、端口號等。
-
addrlen:傳入的addr結構體的長度。
返回值說明:
-
綁定成功返回0,綁定失敗返回-1,同時錯誤碼會被設置
這個時候我們第二個參數需要先創建網絡/本地的addr結構,需要兩個頭文件,然后這個sockaddr——in/sockaddr_un結構類型才會存在
#include<netinet/in.h>
#include<arpa/inet.h>
一般我們做網絡套接字設計的時候需要這四個頭文件
可以在in.h中看到struct sockaddr_in結構的定義,需要注意的是,struct sockaddr_in 屬于系統級的概念,不同的平臺接口設計可能會有點差別
關于其中的成員
sin_port:表示端口號,是一個16位的整數。——要綁定的端口號
sin_addr:表示IP地址,是一個32位的整數。 ——要綁定的ip地址
其中sin_addr的類型是struct in_addr,實際該結構體當中就只有一個成員,該成員就是一個32位的整數,IP地址實際就是存儲在這個整數當中的
關于我們的協議家族在哪里呢,欸,我們轉到第一段代碼 __ SOCKADDR_COMMON的定義就會看到(__SOCKADDR_COMMON是一個宏)
#define __SOCKADDR_COMMON(sa_prefix) \sa_family_t sa_prefix##family
其中的sa_prefix是前綴,##的作用就是把##左右兩邊的符號合并成為一個符號,我們在上一層傳進來的是sin_,所以在這里就合并成了sin _family;對應的我們的sa_family_t是一種數據類型
(上述字段一般得進行初始化,剩下為填充字段一般不做處理,保證結構體的大小是固定的)
我們可以使用bzero函數來進行清空該結構體中緩存再初始化各字段
[^] ?參數1為指定的內存空間,參數2為指定的空間大小?
由于該結構體當中還有部分選填字段,因此我們最好在填充之前對該結構體變量里面的內容進行清空,然后再將協議家族、端口號、IP地址等信息填充到該結構體變量當中
(注意:在發送到網絡之前需要將端口號設置為網絡序列,由于端口號是16位的,因此我們需要使用前面說到的 htons 函數將端口號轉為網絡序列。此外,由于網絡當中傳輸的是整數IP,我們需要調用inet_addr函數將字符串IP轉換成整數IP,然后再將轉換后的整數IP進行設置)
[^] ?這個函數也有一個地方得注意,那就是傳進來的字符串得是c語言風格的,所以我們的string類型的字符串得調用c_str()函數?
上述內容寫出的暫時初始化代碼
// 初始化服務器void Init(){// 1. 創建套接字_socket = socket(AF_INET, SOCK_DGRAM, 0);if (_socket < 0){LOG(LogLevel::FATAL) << "socket error!";exit(1);}
?// 創建socket成功LOG(LogLevel::INFO) << "socket success,sockfd: " << _socket;
?// 2. 綁定socket信息:ip和端口// 2.1 填充sockaddr_in結構體struct sockaddr_in local;// 清空local中的緩存bzero(&local, sizeof(local));local.sin_family = AF_INET;// 在進行通信時我需要把我的ip地址和端口號發送給對方// 所以ip信息和端口信息一定要發送到網絡// 那么我們的ip地址和端口號目前是本地存儲格式// 我們發送到網絡前首先要進行轉換成網絡序列// 本地格式 -> 網絡序列local.sin_port = htons(_port);// ip亦如此,不過我們保存的ip現在是字符串風格// 所以得將ip轉成4字節,再轉成網絡序列 ——> inet_addr函數一步到位local.sin_addr.s_addr = inet_addr(_ip.c_str());// 2.2 綁定本地的套接字——bind函數int n = bind(_socket, (struct sockaddr *)&local, sizeof(local));if (n < 0){LOG(LogLevel::FATAL) << "bind error";exit(2);}LOG(LogLevel::INFO) << "bind success,sockfd: " << _socket;}
我們再來說說為啥上面要把字符串IP先轉換成整數IP:
網絡傳輸數據時是寸土寸金的,如果我們在網絡傳輸時直接以基于字符串的點分十進制IP的形式進行IP地址的傳送,那么此時一個IP地址至少就需要15個字節,但實際并不需要耗費這么多字節。
IP地址實際可以劃分為四個區域,其中每一個區域的取值都是0~255,而這個范圍的數字只需要用8個比特位就能表示,因此我們實際只需要32個比特位就能夠表示一個IP地址。其中這個32位的整數的每一個字節對應的就是IP地址中的某個區域,我們將IP地址的這種表示方法稱之為整數IP,此時表示一個IP地址只需要4個字節
如果我們自己去將ip轉換成4字節
這個轉成字符串實際上也不需要我們手動去完成,已經有現成的接口啦
:也就是和inet_addr同頭文件的inet_ntoa()函數——將整數IP轉換成字符串IP
char *inet_ntoa(struct in_addr in);
需要注意的是,傳入inet_ntoa函數的參數類型是in_addr,因此我們在傳參時不需要選中in_addr結構當中的32位的成員傳入,直接傳入in_addr結構體即可
3.運行服務器
服務器實際上就是在周而復始的為我們提供某種服務,服務器之所以稱為服務器,是因為服務器運行起來后就永遠不會退出,因此服務器實際執行的是一個死循環代碼。由于UDP服務器是不面向連接的,因此只要UDP服務器啟動后,就可以直接讀取客戶端發來的數據。
-
UDP服務器讀取數據(收消息)的函數叫做recvfrom
參數說明:
-
sockfd:對應操作的文件描述符。表示從該文件描述符索引的文件當中讀取數據
-
buf:讀取數據的存放位置(緩沖區)
-
len:期望讀取數據的字節數(緩沖區的長度)
-
flags:表示讀取的方式;一般設置為0,表示阻塞讀取
(阻塞式IO:如果對方不發數據,該函數(進程)就會一直阻塞,等同于scanf)
-
src_addr:對端網絡相關的屬性信息,包括協議家族、IP地址、端口號等
-
addrlen:調用時傳入期望讀取的src_addr結構體的長度,返回時代表實際讀取到的src_addr結構體的長度,這是一個輸入輸出型參數
返回值說明:
-
讀取成功返回實際讀取到的字節數,讀取失敗返回-1,同時錯誤碼會被設置
注意:
-
ssize_t實際是long int類型(有符號整數),socklen_t為無符號整數
-
由于UDP是不面向連接的,因此我們除了獲取到數據以外還需要獲取到對端網絡相關的屬性信息,包括IP地址和端口號等。
-
在調用 recvfrom 讀取數據時,必須將 addrlen 設置為你要讀取的結構體對應的大小。
-
由于 recvfrom 函數提供的參數也是 struct sockaddr* 類型的,因此我們在傳入結構體地址時需要將 struct sockaddr_in* 類型進行強轉
-
如果調用recvfrom函數讀取數據失敗,我們可以打印一條提示信息,但是不要讓服務器退出,服務器不能因為讀取某一個客戶端的數據失敗就退出
現在服務端通過recvfrom函數讀取客戶端數據,我們可以先將讀取到的數據當作字符串看待,將讀取到的數據的最后一個位置設置為’\0’,此時我們就可以將讀取到的數據進行輸出,同時我們也可以將獲取到的客戶端的IP地址和端口號也一并進行輸出。
這里要注意:我們獲取到的客戶端的端口號此時是網絡序列,我們需要調用 ntohs 函數將其轉為主機序列再進行打印輸出。同時,我們獲取到的客戶端的IP地址是整數IP,我們需要通過調用 inet_ntoa 函數將其轉為字符串IP再進行打印輸出
-
UDP服務器輸出數據(發消息)的函數叫做sendto
參數和上面的讀函數差不多,不過多贅述,后兩個參數就代表要發給誰(且都是輸入型的參數)
返回值也和recvform一樣的
從我們的udp有這兩讀寫函數就可以明白:udp sockfd既可以讀,又可以寫;udp通信是全雙工的
鑒于構造服務器時需要傳入IP地址和端口號,我們這里可以引入命令行參數。此時當我們運行服務器時在后面跟上對應的IP地址和端口號即可
我們手動將IP地址設置為127.0.0.1。IP地址為127.0.0.1實際上等價于localhost表示本地主機,我們將它稱之為本地環回,相當于我們一會先在本地測試一下能否正常通信,然后再進行網絡通信的測試
// ./udpserver ip port
int main(int argc, char *argv[])
{// 判斷命令行參數是否傳了上面的三個if (argc != 3){std::cerr << "Usage: " << argv[0] << "ip port" << std::endl;return 1;}// 到這就說明已經傳了ip和port了std::string ip = argv[1];uint16_t port = std::stoi(argv[2]);
?Enable_Console_Log_Strategy();std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(ip, port); // c++14usvr->Init();usvr->Start();
?return 0;
}
注意:agrv數組里面存儲的是字符串,而端口號是一個整數,因此需要使用stoi函數將字符串轉換成整數。然后我們就可以用這個IP地址和端口號來構造服務器了,服務器構造完成并初始化后就可以調用Start函數啟動服務器了
此時帶上端口號運行程序就可以看到套接字創建成功、綁定成功,現在服務器就在等待客戶端向它發送數據
4.客戶端訪問服務器
關于客戶端的綁定問題
首先,由于是網絡通信,通信雙方都需要找到對方,因此服務端和客戶端都需要有各自的IP地址和端口號,只不過服務端需要進行端口號的綁定,而客戶端不需要。
因為服務器就是為了給別人提供服務的,會有很多個客戶端來訪問,因此服務器必須要讓別人知道自己的IP地址和端口號,IP地址一般對應的就是域名,而端口號一般沒有顯示指明過,因此服務端的端口號一定要是一個眾所周知的端口號,并且選定后不能輕易改變,否則客戶端是無法知道該服務端的端口號的,這就是服務端要進行綁定的原因,只有綁定之后這個端口號才真正屬于自己,因為一個端口只能被一個進程所綁定,服務器綁定一個端口就是為了獨占這個端口。
而客戶端在通信時雖然也需要端口號,但客戶端一般是不進行顯式調用bind進行綁定的,客戶端訪問服務端的時候,端口號只要是唯一的就行了,不需要和特定客戶端進程強相關。
如果客戶端綁定了某個端口號,那么以后這個端口號就只能給這一個客戶端使用,就是這個客戶端沒有啟動,這個端口號也無法分配給別人,并且如果這個端口號被別人使用了,那么這個客戶端就無法啟動了(比如說可能京東的app綁定了某個端口號,淘寶的app也綁定了這個端口號,那么一個軟件啟動之后就無法啟動其他軟件了)
所以客戶端的端口只要保證唯一性就行了(為了避免client端口號沖突),客戶端的端口號是多少不重要,因此客戶端端口可以動態的進行設置,并且客戶端的端口號不需要我們來設置,當我們調用類似于sendto這樣的接口首次發送消息時,操作系統會自動給當前客戶端bind綁定(我們不需要顯式調用bind),ip操作系統是知道的,端口號采用隨機端口號
也就是說,客戶端每次啟動時使用的端口號可能是變化的,此時只要我們的端口號沒有被耗盡,客戶端就永遠可以啟動
5.測試
前面我們已經有了服務端的測試代碼了
那么客戶端測試代碼:
#include <iostream>
#include <string.h>
#include <string>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/socket.h>
?
// 客戶端在命令行啟動時
// ./udpclient server_ip server_port
int main(int argc, char *argv[])
{if (argc != 3){std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;return 1;}std::string server_ip = argv[1];uint16_t server_port = std::stoi(argv[2]);
?// 1. 創建socket套接字int sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0){std::cerr << "socket error" << std::endl;return 2;}
?// 2.本地的ip和端口號要不要和上面的“文件”關聯呢?// 問題:client要不要bind?需要bind綁定// client要不要顯式的bind?不要!!首次發送消息,os會自動給client進行bind// os知道ip,端口號采用隨機端口號的方式// 為什么:一個端口號只能被一個進程bind,避免client端口號沖突
?// 填寫服務器信息struct sockaddr_in server;// 初始化清空servermemset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(server_port);server.sin_addr.s_addr = inet_addr(server_ip.c_str());
?// 3.客戶端開始發送消息while (true){std::string input;std::cout << "Please Enter: ";std::getline(std::cin, input);int n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));(void)n;
?// 讀取服務端消息char buffer[1024];// 一個客戶端可能訪問多個服務器// 這里直接創建struct sockaddr_in就行(當作占位符)struct sockaddr_in peer;socklen_t len = sizeof(peer);int m = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&peer, &len);if (m > 0){// 讀取成功// 先在字符串結尾添加\0buffer[m] = 0;std::cout << buffer << std::endl;}}return 0;
}
客戶端運行之后提示我們進行輸入,當我們在客戶端輸入數據后,客戶端將數據發送給服務端,此時服務端再將收到的數據打印輸出,這時我們在服務端的窗口也看到我們輸入的內容
我們可以通過netstat命令來查看當前網絡的狀態,這里我們可以選擇 攜帶nlup 選項。
netstat常用選項說明:
-
-a:表示顯示所有
-
-n:直接使用IP地址,而不通過域名服務器(能寫成數字的全部數字化)
-
-l:顯示監控中的服務器的Socket。
-
-t:顯示TCP傳輸協議的連線狀況。
-
-u:顯示UDP傳輸協議的連線狀況(只查看udp協議)
-
-p:顯示正在使用Socket的程序識別碼和程序名稱(顯示進程相關的信息)
此時你就能查看到對應網絡相關的信息,在這些信息中程序名稱為 ./udpserver 的那一行顯示的就是我們運行的 UDP服務器 的網絡信息
其中 netstat 命令顯示的信息中,Proto表示協議的類型,Recv-Q表示網絡接收隊列,Send-Q表示網絡發送隊列,Local Address表示本地地址,Foreign Address表示外部地址,State表示當前的狀態,PID表示該進程的進程ID,Program name表示該進程的程序名稱。
其中Foreign Address寫成0.0.0.0:*表示任意IP地址、任意的端口號的程序都可以訪問當前進程
我們可以測試一下綁定公網ip、內網ip,就會有以下幾種現象
-
bind公網ip ——綁定失敗
原因:公網ip其實沒有配置到你的ip上,所以公網ip無法被直接bind
-
bind 127.0.0.1 或者 內網ip ——成功綁定
-
server端bind內網ip,但是客戶端用127.0.0.1訪問 ——訪問不了(反之也是不行 )
原因:如果我們顯式的進行地址bind,client未來訪問的時候就必須使用server端bind的地址信息
從上面的三種現象我們不禁要問:到底要如何實現跨網絡(外網)訪問呢?
答:如果需要讓外網訪問,不建議手動bind特定的ip,而是此時我們需要bind 0。系統當當中提供的一個INADDR_ANY,這是一個宏值,它對應的值就是0;因此如果我們需要讓外網訪問,那么在云服務器上進行綁定時就應該綁定INADDR_ANY,此時我們的服務器才能夠被外網訪問
關于綁定INADDR_ANY的好處
當一個服務器的帶寬足夠大時,一臺機器接收數據的能力就約束了這臺機器的IO效率,因此一臺服務器底層可能裝有多張網卡,此時這臺服務器就可能會有多個IP地址,但一臺服務器上端口號為8080的服務只有一個。這臺服務器在接收數據時,這里的多張網卡在底層實際都收到了數據,如果這些數據也都想訪問端口號為8080的服務。此時如果服務端在綁定的時候是指明綁定的某一個IP地址,那么此時服務端在接收數據的時候就只能從綁定IP對應的網卡接收數據。而如果服務端綁定的是INADDR_ANY,那么只要是發送給端口號為8080的服務的數據,系統都會可以將數據自底向上交給該服務端
那么我們在服務端的初始化代碼中socket的ip信息就應該修改為
這樣在下面bind時綁定的ip就是我們的INADDR_ANY了(做任意地址綁定)
當然,如果你既想讓外網訪問你的服務器,但你又指向綁定某一個IP地址,那么就不能用云服務器,此時可以選擇使用虛擬機或者你自定義安裝的Linux操作系統,那個IP地址就是支持你綁定的,而云服務器是不支持的
此時當我們再用netstat命令查看時會發現,該服務器的本地IP地址變成了0.0.0.0,這就意味著該UDP服務器可以在本地讀取任何一張網卡里面的數據(可以收到任意一個ip對應的報文了,無論是公網ip、內網IP或者本地環回)
網絡測試:
我們可以將生成的客戶端的可執行程序發送給你的其他朋友,進行網絡級別的測試。為了保證程序在你們的機器是嚴格一致的,可以選擇在編譯客戶端時 攜帶 -static 選項進行靜態編譯。
此時我們可以先使用 sz命令 將該客戶端可執行程序下載到本地機器,然后將該程序發送給你的朋友,這就跟我們自己在PC端上下載文件是一樣的道理;接著你先把你的服務器啟動起來,然后你的朋友將你的IP地址和端口號作為命令行參數運行客戶端,就可以訪問你的服務器了
我們可以再進一步修改我們的代碼來實現一個簡單的聊天室
需要借助我們之前學過寫過的線程池(網絡提供數據任務,讓線程池來分發給各個用戶的客戶端)
這個轉發消息的服務器其實就是一個生產者消費者模型
我們的線程將來要執行的是消息轉發的任務,所以我們在服務器和線程池之間還應該存在一個消息路由(route)的模塊
補充知識:
我們可以通過 ls /dev/pts/命令來查看我們所開啟的終端數與序號
(原因是我們所開的終端都會保存在這個路徑下)
比如說我目前所處的終端就是序列0
這個知識點可以幫助我們解決用兩個線程分別負責發消息和收消息任務時導致的收發消息輸出時的雜糅到一起的問題:
在用重定向之前
在用這個知識點重定向了標準錯誤到這個終端之后就解決了這一問題
關于我們簡單聊天室的代碼在:?單進程的簡易聊天室
6.補充參考內容
-
地址轉換函數
本節只介紹基于 IPv4 的 socket 網絡編程,sockaddr_in 中的成員 struct in_addr sin_addr 表示 32 位 的 IP 地址,但是我們通常用點分十進制的字符串表示 IP 地址,以下函數可以在字符串表示 和in_addr 表示之間轉換
字符串轉 in_addr 的函數:
這里我們推薦使用inet_pton函數,比較安全
in_addr 轉字符串的函數:
其中 inet_pton 和 inet_ntop 不僅可以轉換 IPv4 的 in_addr,還可以轉換 IPv6 的in6_addr,因此函數接口是 void *addrptr
代碼樣例:
-
關于inet_ntoa函數
inet_ntoa 這個函數返回了一個 char*, 很顯然是這個函數自己在內部為我們申請了一塊內存來保存 ip 的結果. 那么是否需要調用者手動釋放呢 ?
man 手冊上說, inet_ntoa 函數, 是把這個返回結果放到了靜態存儲區. 這個時候不需要我們手動進行釋放;那么問題來了, 如果我們調用多次這個函數, 會有什么樣的效果呢? 參見如下代碼:
運行結果如下:
因為 inet_ntoa 把結果放到自己內部的一個靜態存儲區, 這樣第二次調用時的結果會覆蓋掉上一次的結果
思考: 如果有多個線程調用 inet_ntoa, 是否會出現異常情況呢?
在 APUE這個教程中, 明確提出 inet_ntoa 不是線程安全的函數,所以我們想說的是在多線程環境下, 推薦使用 inet_ntop函數,這個函數由調用者提供一個緩沖區保存結果, 可以規避線程安全問題
[^] ?其實在上面就講過?
總結
? ok,在這一篇中我們講述了socket套接字編程的一些前置知識點儲備(這個在整個章節都是需要用上的),同時也講述了socket編程UDP版本的接口以及代碼實現了基于UDP實現的簡易網絡聊天室;那么本篇內容到這就結束啦,不過我們的socket套接字章節的內容還在繼續,我們可以先把上面的這么多內容好好消化一下再接著繼續!!