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

理解TCP和UDPhtml

根據數據傳輸方式的不一樣,基於網絡協議的套接字通常分爲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協議棧socket

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

圖1-3   UDP協議棧post

各層可能經過操做系統等軟件實現,也可能經過相似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函數,依次向五個客戶端提供服務
  • 第4四、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特性的問題,咱們將在下一章給出解決的辦法

相關文章
相關標籤/搜索