TCP/IP網絡編程之基於TCP的服務端/客戶端(一)


理解TCP和UDP

根據數據傳輸方式的不同,基於網絡協議的套接字一般分為TCP套接字和UDP套接字。因為TCP套接字是面向連接的,因此又稱為基於流(stream)的套接字。TCP是Transmission Control Protocol(傳輸控制協議)的簡寫,意為“對數據傳輸過程的控制”。因此,學習控制方法及范圍有助於正確理解TCP套接字

TCP/IP協議棧

講解TCP前先介紹TCP所屬的TCP/IP協議棧(Stack,層),如圖1-1所示:

圖1-1   TCP/IP協議棧

從圖1-1可以看出,TCP/IP協議棧共分為四層,可以理解為數據收發分成了四個層次化過程。也就是說,面對“基於互聯網的有效數據傳輸”的命題,並非通過一個龐大的協議解決問題,而是通過層次化方案——TCP/IP協議棧解決,通過TCP套接字收發數據需要借助四層,如圖1-2所示:

圖1-2   TCP協議棧

反之,通過UDP套接字收發數據時,利用圖1-2的四層協議棧來完成:

圖1-3   UDP協議棧

各層可能通過操作系統等軟件實現,也可能通過類似NIC的硬件設備實現

TCP/IP協議的誕生背景

“通過因特網完成有效數據傳輸”這個課題讓許多專家聚集到一起,不同人負責不同模塊,如:硬件、系統、路由。為什么要這樣做呢?因為編寫軟件前需要構建硬件系統,在此基礎上需要通過軟件實現各種算法,所以才需要眾多領域的專家進行討論,以形成各種規定。把“通過因特網完成有效數據傳輸”問題按照不同領域划分成小問題后,出現了多種協議,它們通過層級結構建立緊密聯系

把協議分成多個層次具有哪些優點?協議設計更容易?這是優點之一,但更重要的原因是:為了通過標准化操作設計開放式系統。標准本身就在於對外公開,引導更多人遵循。以多個標准為依據所設計的系統稱為開放式系統,我們現在學習的TCP/IP協議棧也屬於其中之一。那么開放式系統具有哪些優點呢?比方:路由器用來完成IP層交互任務,某公司原先使用A路由器,可將其替換成B路由器,即便A、B這兩種路由器並非同一產商也可以順利替換,因為所有的路由器生產產商都會按照IP層標准制造

再舉個例子,大家的計算機一般都裝有網卡(網絡接口卡),即便沒安裝也沒關系,網卡很容易買到,因為所有的網卡制造商都會遵守鏈路層的協議標准,這就是開放式系統的優點

鏈路層

接下來逐層了解TCP/IP協議棧,先講鏈路層。鏈路層是物理鏈接領域標准化的結果,也是最基本的領域,專門定義LAN、WAN、MAN等網絡標准。若兩台主機通過網絡進行數據進行交換,則需要圖1-4所示的物理連接,鏈路層就負責這些標准:

圖1-4   網絡連接結構

IP層

准備好物理連接后就要傳輸數據,為了在復雜的網絡中傳輸數據,首先需要考慮路徑的選擇。向目標傳輸數據需要經過哪條路徑?解決此問題就是IP層,該層使用的協議就是IP。IP本身是面向消息的、不可靠的協議。每次傳輸數據時會幫我們選擇路徑,但每次傳輸時的路徑並不一致。如果傳輸中發生路徑錯誤,則選擇其他路徑;但如果發生數據丟失或損壞,則無法解決。換言之,IP協議無法應對數據錯誤

TCP/UDP層

IP層解決數據傳輸中的路徑選擇問題,只需照此路徑傳輸數據即可。TCP和UDP層以IP層提供的路徑信息為基礎完成實際的數據傳輸,故該層又稱傳輸層。UDP比TCP簡單,我們后面還會在討論,現在只解釋TCP。TCP可以保證可靠的數據傳輸,但它發送數據時以IP層為基礎,IP層是面向消息的,是不可靠的,那TCP又是如何保證消息的可靠傳輸呢?

IP層只關注一個數據包(數據傳輸的基本單位)的傳輸過程。因此,即使傳輸多個數據包,每個數據包也是由IP層實際傳輸的,也就是說傳輸順序及傳輸本身都是不可靠的。若只利用IP層傳輸數據,則有可能后發送的數據包比早發生的數據包先到達目標主機。另外,傳輸的數據包A、B、C中可能只收到A和C,B可能丟失或接收到時已損壞。但若添加TCP協議則會按照如圖1-5的方式進行數據傳輸:

圖1-5   傳輸控制協議

我們可以看到,當主機A發送1號數據包給主機B時,必須等到主機B確認1號數據包接收成功,才會接着發送2號數據包,如果主機A發送1號數據包卻遲遲收不到主機B回復的接收成功,則會認為是超時,並重新發送一個1號數據包

實現基於TCP的服務端/客戶端

圖1-6給出了TCP服務器端默認的函數調用順序,大部分TCP服務器端都按照該順序調用

圖1-6   TCP服務端函數調用順序

調用socket函數創建套接字,聲明並初始化地址信息結構體變量,調用bind函數向套接字分配地址。這兩個階段之前都討論過了,下面講解之后的幾個過程

進入等待連接請求狀態

我們已調用bind函數給套接字分配了地址,接下來就要通過調用listen函數進入等待連接請求狀態。只有調用了listen函數,服務端套接字才能進入可接收連接的狀態,換言之,這時,客戶端才能調用connect函數(若提前調用則會發生錯誤)

#include <sys/socket.h>
int listen(int sockfd, int backlog);//成功時返回0,失敗時返回-1

  

  • sock:希望進入等待連接請求狀態的套接字文件描述符,傳遞的描述符套接字參數成為服務端套接字(監聽套接字)
  • backlog:連接請求等待隊列(Queue)的長度,若為5,則隊列長度為5,表示最多使5個連接請求進入隊列

先解釋一下等待連接請求狀態的含義和連接請求等待隊列。“服務器端處於等待連接請求狀態”是指,客戶端請求連接時,服務器端受理連接前一直處於等待狀態,當有多個客戶端一起發送連接請求時,服務器端套接字只能處理一個連接請求,而其他的連接請求,只能暫時放在請求隊列,即listen函數的第二個參數

受理客戶端連接請求

調用listen函數后,若有新的連接請求,則應按序受理。受理請求意味着進入可接收數據的狀態,這里進入這種狀態的所需部件當然還是套接字,可能有人會想使用服務器端套接字,但服務器端套接字已經用於監聽,如果將其用於與客戶端交換數據,那么誰來監聽客戶端的連接請求呢?因此需要另外一個套接字,但沒必要親自創建,accept函數將自動創建套接字,並連接到發起請求的客戶端

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);//成功時返回創建的套接字文件描述符,失敗時返回-1

  

  • sock:服務器套接字的文件描述符
  • addr:保存發起連接請求的客戶端地址信息的變量地址值,調用函數后向傳遞來的地址變量參數填充客戶端地址信息
  • addrlen:第二個參數addr結構體的長度,但是存有長度的變量地址。函數調用完成后,該變量即被填入客戶端地址長度

accept函數受理連接請求等待隊列中待處理的客戶端連接請求,函數調用成功時,accept函數內部將產生用於數據I/O的套接字,並返回其文件描述符。需要強調的是,套接字是自動創建的,並自動與發起連接請求的客戶端建立連接

這里,我們重新回顧TCP/IP網絡編程之網絡編程和套接字這一章中的hello_server.c

hello_server.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
 
void error_handling(char *message);
 
int main(int argc, char *argv[])
{
    int serv_sock;
    int clnt_sock;
 
    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t clnt_addr_size;
 
    char message[] = "Hello world!";
 
    if (argc != 2)
    {
        printf("Usage: %s <port>\n", argv[0]);
        exit(1);
    }
 
    serv_sock = socket(AF_INET, SOCK_STREAM, 0); 
    if (serv_sock == -1)
        error_handling("sock() error");
 
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(atoi(argv[1]));
 
    if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) 
        error_handling("bind() error");
 
    if (listen(serv_sock, 5) == -1) 
        error_handling("listen() error");
 
    clnt_addr_size = sizeof(clnt_addr);
    clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_size); 
    if (clnt_sock == -1)
        error_handling("accept() error");
 
    write(clnt_sock, message, sizeof(message)); 
    close(clnt_sock);
    close(serv_sock);
 
    return 0;
}
 
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

  

  • 第27行:服務器端實現過程中先要創建套接字,但此時的套接字尚未是真正的服務器端套接字
  • 第31~37行:為了完成套接字地址分配,初始化結構體變量並調用bind函數
  • 第39行:調用accept函數從隊列的頂部取出一個連接請求與客戶端建立連接,並返回創建的套接字文件描述符。另外,調用accept函數時若等待隊列為空,則accept函數不會返回,直到隊列中出現新的客戶端連接
  • 第47~49行:調用write函數向客戶端傳輸數據,調用close函數關閉連接

TCP客戶端的默認函數調用順序

接下來講解客戶端的實現順序,我們前面說過,客戶端的套接字實現比服務器端要簡單的多,因為創建套接字和請求連接就是客戶端的全部內容,如圖1-7:

圖1-7   TCP客戶端函數調用順序

與服務器端相比,區別就在於“請求連接”,它是創建客戶端套接字后向服務器端發起的連接請求。服務器端調用listen函數后創建連接請求等待隊列,之后客戶端即可請求連接。那如何發起連接請求呢?通過connect函數完成:

#include <sys/socket.h>
int connect(int sock_fd, struct sockaddr *serv_addr, socklen_t addrlen);//成功時返回0,失敗時返回-1

  

  • sock:客戶端套接字文件描述符
  • serv_addr:保存目標服務器端地址信息的變量地址值
  • addrlen:以字節為單位傳遞已傳遞給第二個結構體參數serv_addr的地址變量長度

客戶端調用connect函數后,發生以下情況之一才會返回:

  • 服務器端接收連接請求
  • 發生斷網等異常情況而中斷連接請求

需要注意,所謂的“接收連接”並不意味着服務器端調用accept函數,其實是服務器端把連接請求信息記錄到等待隊列,因此connect函數返回后並不立即進行數據交換

這里,我們再回顧之前的hello_client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
 
void error_handling(char *message);
 
int main(int argc, char *argv[])
{
    int sock;
    struct sockaddr_in serv_addr;
    char message[30];
    int str_len;
 
    if (argc != 3)
    {
        printf("Usage: %s <IP> <port>\n", argv[0]);
        exit(1);
    }
 
    sock = socket(AF_INET, SOCK_STREAM, 0); 
    if (sock == -1)
        error_handling("sock() error");
 
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_addr.sin_port = htons(atoi(argv[2]));
 
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) 
        error_handling("connect() error!");
 
    str_len = read(sock, message, sizeof(message) - 1);
    if (str_len == -1)
        error_handling("read() error!");
 
    printf("Message from server: %s\n", message);
    close(sock);
 
    return 0;
}
 
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

  

  •  第23行:創建准備連接服務器端的套接字,此時創建的是TCP套接字
  • 第27~30行:結構體變量serv_addr中初始化IP和端口信息。初始化值為目標服務器端套接字的IP和端口信息
  • 第32行:調用connect函數向服務器端發送連接請求
  • 第35行:完成連接后,接收服務器端傳輸的數據
  • 第40行:接收數據后調用close函數關閉套接字,結束與服務器端的連接

基於TCP的服務器端/客戶端函數調用關系

前面講解了TCP服務器端/客戶端的實現順序,實際上二者並非相互獨立,讓我們畫一下它們之間的交互過程,如圖1-8所示

圖1-8   函數調用關系

圖1-8的總體流程如下:服務器端創建套接字后聯系調用bind、listen函數進入等待狀態,客戶端通過調用connect函數發起連接請求,需要注意的是,客戶端只能等到服務器端調用listen函數后才能調用connect函數。同時要清楚,客戶端調用connect前,服務器端可能先調用了accept函數。當然,此時服務器端在調用accept函數時進入了阻塞狀態,直到客戶端調用connect函數為止

實現迭代服務器端/客戶端

現在,讓我們來編寫一個回聲服務器端/客戶端,所謂回聲,就是服務器端將客戶端傳輸的字符串數據原封不動地回傳給客戶端,不過在此之前,需要解釋一下何為迭代服務器端。之前我們所看到的Hello world服務器端處理完一個客戶端連接請求則退出程序,連接請求等待隊列是實際上沒太大意義,這並非我們所需的服務器端,設置好等待隊列后,應向所有客戶端提供服務,如果在受理完一個客戶端請求連接后,還需要再受理其他的請求連接,改怎么擴展代碼?最簡單的辦法就是通過循環語句返回調動accept函數,如圖1-9

圖1-9   迭代服務器端的函數調用順序

圖1-9可以看出,調用accept函數后,緊接着調用I/O相關的read、write函數,然后調用close函數。這並非針對服務器端套接字,而是針對accept函數調用時所創建的套接字。調用close函數就意味着結束了針對某一客戶端的服務,此時如果還想服務於其他客戶端,就要重新調用accept函數。目前,我們的服務器端套接字同一時刻只能服務於一個客戶端連接,將來學完進程和線程后,就可以編寫同時服務多個客戶端的服務器端了

迭代回聲服務器端/客戶端

即時服務器端以迭代方式運轉,客戶端代碼亦無太大區別,接下來創建迭代回聲服務器端及與之配套的回聲客戶端,首先整理一下程序的基本運行方式:

  • 服務器端在同一時刻只與一個客戶端相連,並提供回聲服務
  • 服務器端依次向五個客戶端提供服務並退出
  • 客戶端接收用戶輸入的字符串並發送到服務器端
  • 服務器端將接收到的字符串回傳給客戶端,即“回聲”
  • 服務器端與客戶端之間的字符串回聲一直執行到客戶端輸入Q為止

echo_server.c 

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    char messag[1024];
    int str_len, i;

    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t clnt_adr_sz;

    if (argc != 2)
    {
        printf("Usage:%s<port>\n", argv[0]);
        exit(1);
    }
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1)
        error_handling("socket()error");
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));
    if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind()error");
    if (listen(serv_sock, 5) == -1)
        error_handling("listen()error");
    clnt_adr_sz = sizeof(clnt_adr);
    for (i = 0; i < 5; i++)
    {
        clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);
        if (clnt_sock == -1)
            error_handling("accept()error");
        else
            printf("Connected client %d \n", i + 1);
        while ((str_len = read(clnt_sock, messag, 1024)) != 0)
            write(clnt_sock, messag, str_len);
        close(clnt_sock);
    }
    close(serv_sock);
    return 0;
}
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

  

  • 第37~40行:為處理5個客戶端連接而添加的循環語句。共調用五次accept函數,依次向五個客戶端提供服務
  • 第44、45行:實際完后回聲服務的代碼,原封不動地傳輸讀取的字符串
  • 第46行:針對連接客戶端的套接字調用close函數,向連接的相應套接字發送EOF。換言之,客戶端套接字若調用close函數,則第44行的循環條件變為false,因此執行第46行代碼
  • 第48行:向5個客戶端提供服務后關閉服務器端套接字並終止程序

echo_client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

void error_handling(char *message);
int main(int argc, char *argv[])
{
    int sock;
    char message[1024];
    int str_len;
    struct sockaddr_in serv_adr;
    if (argc != 3)
    {
        printf("Usage:%s<IP><port>\n", argv[0]);
        exit(1);
    }
    sock = socket(PF_INET, SOCK_STREAM, 0);
    if (sock == -1)
        error_handling("socket()error");
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_adr.sin_port = htons(atoi(argv[2]));
    if (connect(sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("connect()error");
    else
        puts("Connected..........");
    while (1)
    {
        fputs("Input message(Q to quit):", stdout);
        fgets(message, 1024, stdin);
        if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
            break;
        write(sock, message, strlen(message));
        str_len = read(sock, message, 1024 - 1);
        message[str_len] = 0;
        printf("Message from server:%s", message);
    }
    close(sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

  

  • 第27行:調用connect函數。若調用該函數引起的連接請求被注冊到服務器端等待隊列,則connect函數將完成正常調用。因此,即使通過第30行代碼輸出了連接提示字符串,如果服務器尚未調用accept函數,也不會真正建立服務關系
  • 第42行:調用close函數向相應套接字發送EOF(EOF即意味着中斷連接)

編譯echo_server.c並運行,服務器端套接字將等待客戶端連接請求

# gcc echo_server.c -o echo_server
# ./echo_server 8500

  

編譯echo_client.c並分三次運行

# gcc echo_client.c -o echo_client
# ./echo_client 127.0.0.1 8500
Connected..........
Input message(Q to quit):Hello
Message from server:Hello 
Input message(Q to quit):world
Message from server:world
Input message(Q to quit):Q
# ./echo_client 127.0.0.1 8500
Connected..........
Input message(Q to quit):Java
Message from server:Java
Input message(Q to quit):Python
Message from server:Python
Input message(Q to quit):Golang
Message from server:Golang
Input message(Q to quit):Q
# ./echo_client 127.0.0.1 8500
Connected..........
Input message(Q to quit):Spring
Message from server:Spring
Input message(Q to quit):Flask
Message from server:Flask
Input message(Q to quit):Gin
Message from server:Gin
Input message(Q to quit):Q

  

最后可看到服務器端套接字程序打印如下:

# ./echo_server 8500
Connected client 1 
Connected client 2 
Connected client 3

  

可以看到,服務器端套接字共處理了3次客戶端連接請求

回聲客戶端存在的問題

下面是echo_client.c的代碼

write(sock, message, strlen(message));
str_len = read(sock, message, 1024 - 1);
message[str_len] = 0;
printf("Message from server:%s", message);

  

以上的代碼有個錯誤假設:每次調用read、write函數時都會以字符串為單位執行實際的I/O操作。但是別忘了,TCP不存在數據邊界。因此,多次調用write函數傳遞字符串有可能一次性傳遞到服務端,此時,客戶端有可能從服務端收到多個字符串,這不是我們希望看到的結果

還要考慮另外一種情況:字符串太長,需要分兩次數據包發送,客戶端有可能在尚未收到全部數據包時就調用read函數。這些都是TCP特性的問題,我們將在下一章給出解決的辦法

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM