Unix 網路程式設計(四)- 典型TCP客服伺服器程式開發例項及基本套接字API介紹
寫在開頭:
在上一節中我們學習了一些基礎的用來支援網路程式設計的API,包括“套接字的地址結構”、“位元組排序函式”等。這些API幾乎是所有的網路程式設計中都會使用的一些,對於我們正確的編寫網路程式有很大的作用。在本節中我們會介紹編寫一個基於TCP的套接字程式需要的一些API,同時會介紹一個完整的TCP客戶伺服器程式,雖然這個程式功能相對簡單,但確包含了一個客戶伺服器程式所有的步驟,一些複雜的程式也都是在此基礎上進行擴充。在後面隨著學習的深入,我們會給這個程式新增功能。
下面我們首先給出這個程式例項,然後根據程式分析其中用到的套接字函式,這些套接字函式也是其他的TCP網路程式設計中都會使用到的,包括像:socket 函式,connect 函式,bind 函式,listen 函式,accept函式,fork和exec函式等
------------------------------------------------------------------------------------------------------------------------------
TCP客戶伺服器程式
我們這裡的伺服器程式是一個回射伺服器,實現以下功能:
(1) 客戶從標準輸入中讀入一行文字,然後將文字寫給伺服器;
(2) 伺服器從網路輸入讀入這行文字,並回射給使用者;
(3) 客戶從網路輸入中讀入這行文字,並顯示在標準輸出中。
功能的模型如下面所示:
下面是具體的伺服器端和客戶端的程式,可以在我們所下載的原始碼tcpcliserv/tcpcli01.c 和tcpcliserv/tcpserv01.c中找到,但是為了讓大家更直觀的看到最原始的樣子,這裡重寫
了Richard老先生的程式碼,你可以直接拷貝,並用gcc編譯然後在你機器上執行。
下面是伺服器端程式碼 echo_server.c
通過建立子程序來處理客戶端的請求從而實現伺服器的併發。伺服器會呼叫下面的str_echo的函式,他將客戶端傳送過來的內容按原樣返回。
#include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <signal.h> #include <errno.h> #define LISTENQ 5 #define MAXLINE 2048 #define SA struct sockadddr #define SERV_PORT 9877 void str_echo(int sockfd); int main(int argc, char **argv) { int listenfd, connfd; pid_t childpid; socklen_t clilen; struct sockaddr_in cliaddr, servaddr; listenfd = socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); bind(listenfd, (SA *) &servaddr, sizeof(servaddr)); listen(listenfd, LISTENQ); for ( ; ; ) { clilen = sizeof(cliaddr); //connfd = accept(listenfd, (SA *) &cliaddr, &clilen); connfd = accept(listenfd,(SA*)NULL, NULL); printf("Successfully Connected!\n"); if ( (childpid = fork()) == 0) { /* child process */ close(listenfd); /* close listening socket */ str_echo(connfd); /* process the request */ exit(0); } close(connfd); /* parent closes connected socket */ } } void str_echo(int sockfd) { ssize_t n; char buf[MAXLINE]; again: while ( (n = read(sockfd, buf, MAXLINE)) > 0) { printf("write back to the client!\n"); write(sockfd, buf, n); //printf("write back to the client!"); } if (n < 0 && errno == EINTR) goto again; else if (n < 0) { perror("str_echo: read error"); exit(1); } }
下面是客戶端的程式,echo_tcp_client.c
它發起和伺服器連線,然後從標準輸入中讀入資料然後通過socket傳送給伺服器,並讀取從socket回射的程式。
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define LISTENQ 5
#define MAXLINE 2048
#define SERV_PORT 9877
typedef struct sockaddr SA;
void str_cli(FILE *fp, int sockfd);
int
main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
{
perror("usage: tcpcli <IPaddress>");
exit(-1);
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
if(connect(sockfd, (SA *) &servaddr, sizeof(servaddr)) < 0)
{
perror("Connect Error!");
exit(1);
}
else
printf("Connected Successfully!\n");
str_cli(stdin, sockfd); /* do it all */
exit(0);
}
str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE], recvline[MAXLINE];
while (fgets(sendline, MAXLINE, fp) != NULL) {
write(sockfd, sendline, strlen(sendline));
if (read(sockfd, recvline, MAXLINE) == 0)
{
perror("str_cli: server terminated prematurely");
exit(-1);
}
fputs(recvline, stdout);
}
}
編譯兩個程式:
gcc -O2 - wall echo_tcp_server.c -o tcpsvr01
gcc -O2 -wall echo_tcp_client.c -o tcpcli01
然後在兩個程序中分別將伺服器和客戶端執行起來,如下所示在客戶端可以看到我們輸入一行之後按回車會顯示相同的從伺服器端傳回來的文字。
--------------------------------------------------------------------------------------------------------------------------
至此這個程式執行起來了,下面我們開始介紹伺服器程式和客戶端程式中的套接字函式是怎樣將整個功能完成的,這裡的函式包括:socket 函式,connect 函式,bind 函式,listen 函式,accept函式,fork和exec函式 等。首先我們給出整個函式被呼叫的一個流程圖,這個流程圖是根據tcp協議建立起來的:
這就是整個函式被呼叫過程的一個流程以及完成的功能。下面我們詳細的介紹這些函式的用法:
socket 函式
socket 函式是程序執行網路I/O操作第一件需要做的事情,通過呼叫socket 函式來指定期望的通訊協議型別並返回一個套接字描述符用來標識這個連線,套接字描述符,簡稱sockfd,是一個小的非負整數值類似於檔案描述符。用法:
#include <sys/socket.h>
int socket ( int family, int type, int procotol); /* 返回:若建立成功返回一個非負sockfd, 否則返回 -1 */
例如上面伺服器端程式中的
listenfd = socket(AF_INET, SOCK_STREAM, 0);
family:代表的是協議族,指明該套接字在網路層使用什麼來輸出,包括:AF_INET(IPv4), AF_INET6(IPv6), AF_LOCAL(Unix 域協議), AF_ROUTE(路由套接字), AF_KEY(祕鑰套接字)等
type:指明套接字使用的資料流的型別,包括 SOCK_STREAM(位元組流套接字), SOCK_DGRAM (資料報套接字),SOCK_SEQPACKET(有序分組套接字), SOCK_RAW(原始套接字)等;
protocol: 指明的套接字使用的傳輸層協議型別,包括:IPPROTO_TCP(TCP傳輸協議) IPPROTO_UDP(UDP傳輸協議),IPPROTO_SCTP(SCTP傳輸協議等);一般為了省事直接將這個欄位置0,由給定的family和type來決定使用什麼協議。
connect函式
connect 函式是客戶端用來和伺服器建立連線使用的。呼叫connect 函式,會引起TCP三次握手的建立。下面是具體的API
#include <sys/socket.h>
int connect ( int sockfd, const struct sockaddr *servaddr, socklen_t addrlen); /* 成功返回0,出錯返回-1*/
socketfd 就是socket 函式返回的那個套接字描述符用來表示這個連線;
*servaddr 是一個指向y要連線的伺服器的套接字地址結構的指標,這裡需要強制型別轉換成通用地址結構,在上一節(三)中我們講過這個套接字地址結構的幾個型別;
addrlen 是這個地址的大小;
如上面事例中
connect(sockfd, (SA *) &servaddr, sizeof(servaddr));
connect被呼叫時大概會發生如下幾種情況:
(1) 如果目標地址可達,並運行了伺服器程式,那麼就正常返回,不會出錯;
(2) 如果目標地址可達,但是沒有執行伺服器程式,那麼出錯返回: connect error: Connection Refused;
(3) 如果目標地址在同一個網路,但是不可達,那麼出錯返回: connect error: connection timed out;(大概是75s之後返回這個錯誤)
(4) 如果目標地址不在同一個網路,而且無法路由,那麼直接返回: connect error: No route to host.
bind函式
bind 函式是給一個socket 繫結一個套接字地址結構(或者更準確的是:將一個本地協議地址賦予一個套接字),在這個套接字地址結構中有使用的協議、ip、埠號等。如上面程式中:
- servaddr.sin_family = AF_INET;
- servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
- servaddr.sin_port = htons(SERV_PORT);
- bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
這裡是伺服器呼叫Bind函式進行繫結,客戶端也可以呼叫bind函式,但是不是很必要。它的API是:
#include <sys/socket.h>
int bind ( int sockfd, const struct sockaddr *myaddr, socklen_t addrlen); /* 成功返回0,出錯返回-1*/
對於客戶端,呼叫這個函式是告訴伺服器原地址和埠號是什麼;
對於伺服器,呼叫這個函式表名自己只會接受以這個ip地址和埠號為目的的客戶端請求。
不過,他們都可以將這個地址設為通配地址(INADDR_ANY)埠號設為(0),這樣就可以接受所有客戶端的請求並且允許核心選擇源ip地址和分配臨時埠。
listen函式
listen 函式是伺服器端呼叫的函式。從巨集觀上講,listen發生在伺服器端socket, bind函式之後,accept函式之前,呼叫它表明伺服器端在監聽來自客戶端的請求。從細節的角度來講,listen函式被呼叫之後,伺服器端開始維護兩個佇列:一個佇列稱為未完成佇列是剛監聽到使用者發起的連線請求組成的佇列(接收到SYN),即三次握手的第一階段,這個時候將這個socket 請求放在這個佇列中;另一個佇列稱為已完成佇列,是從未完成佇列中將完成三次握手的socket調入的。具體的API是:
#include <sys/socket.h>
int listen( int sockfd, int backlog); /* 成功返回0,出錯返回-1*/
這裡的 backlog 沒有確切的解釋,通常認為是這兩個佇列中條目之和。但是這個值一般都會乘上一個模糊因子這裡是1.5來規定最大。歷史上這個值一般是5,現在因為伺服器繁忙會取一個比較大的值;
accept 函式
accept 函式可以緊接著上面的listen函式討論,accpet 函式被呼叫時將會從listen狀態中的已完成連線套接字佇列中選擇隊首進行服務。如果佇列為空,那麼將阻塞。下面是它的API:
#include <sys/socket.h>
int accept ( int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen); /* 成功返回非負套接字描述符,出錯返回-1*/
如上面伺服器端程式所示:
connfd = accept(listenfd, (SA *) &cliaddr, &clilen);
其中:sockfd 是監聽套接字的描述符, 第二個引數是這次連線的對端的協議地址,第三個引數是長度,這裡是引用的形式,因為要往裡面寫資料。如果沒必要得到這個地址,可以直接置0.
注意這個函式的返回值稱為 連線套接字描述符, 和socket 返回的一次伺服器程序中只建立一次的監聽套接字描述符不同,這裡每次呼叫accept 函式都會返回這麼一個連線套接字描述符,然後對此描述符進行處理。
併發伺服器 fork/exec函式
(一)中的伺服器程式是一個簡單的迭代的伺服器程式,當accept一個客戶請求之後伺服器便一直為這個程式服務,對於這種簡單的獲取時間的程式來講是可以的,但是有些伺服器程式執行的操作需要花費很長時間,而且我們又不希望伺服器一直在處理這麼一個客戶請求,所以就希望編寫併發的伺服器程式,使得伺服器同時可以處理多個請求,而編寫併發伺服器最簡單的方法就是fork 一個子程序來服務每個客戶,我們上面的程式也是採用這種方式:
- if ( (childpid = fork()) == 0) { /* child process */
- close(listenfd); /* close listening socket */
- str_echo(connfd); /* process the request */
- exit(0);
- }
- close(connfd); /* parent closes connected socket */
下面是fork()函式的具體用法:
#include <unistd.h>
pid_t fork(void); /*在子程序中返回值是0;在父程序中返回值是子程序的id;若出錯則返回-1*/
這個函式也是我們迄今為止見過的為數不多的兩個有兩個返回值的函式,因為子程序呼叫getppid()函式可以獲得父程序的id,所以在子程序中其返回值就直接是0了,而父程序因為要管理所有的子程序,所以就在父程序的返回值中拿到這個值;
注意,父程序和子程序共享在建立這個子程序之前的所有描述符。所以這裡的connfd才可以在子程序中被引用,而且描述符的引用數會將1,所以當父程序close(connfd)的時候只會減1,只有子程序也close才會減為0;
getsockname 和 getpeername 函式
在一開始的事例程式中並沒有這兩個函式的影子,但是在後面的程式中,可能會用到這兩個函式,所以這裡有必要說明一下。
#include <sys/socket.h>
int getsockname (int sockfd, struct sockadddr * localaddr, socklen_t *addrlen);
int getpeername (int sockfd, struct sockadddr * peeraddr, socklen_t *addrlen); /* 成功返回0,出錯返回-1*/
getsockname ()用來返回與sockfd這個套接字關聯的本地協議組地址,通過這個地址可以檢視核心賦予的ip地址和埠號,一般用於客戶端不適用Bind函式而直接呼叫socket從而由核心決定本地ip地址和埠號是什麼,這個時候用這個函式檢視很有用;
getpeername()一般用於伺服器在fork一個子程序處理一個客戶端的請求時,而子程序記憶體映像因為被執行的具體程式覆蓋而丟失了客戶的協議地址,這個時候通過呼叫getpeername()函式可以重新獲得。
我們將在後面碰到這兩個函式的的程式中再討論這兩個函式。
總結:
我們在篇博文中首先給出了一個併發的典型的TCP客戶服務程式,並運行了這個程式。之後我們從TCP協議的角度給出了每一個函式完成的功能,最後詳細的分析這些函式的API。所有的客戶和伺服器程式都從socket開始,它返回一個套接字描述符,對於伺服器而言返回的是監聽套接字描述符。客戶之後呼叫connect進行連線,核心傳送三次握手,之後伺服器呼叫bind, listen, 和accept函式等。accept之後就開始處理一個請求,這裡講解了通過呼叫fork()函式建立子程序,由子程序併發的排程。