1. 程式人生 > >基於TCP協議的伺服器/客戶端程式

基於TCP協議的伺服器/客戶端程式

作為傳輸層的主要協議,TCP協議不僅可以支援本地的資料通訊,還可以支援跨網路的程序間通訊。

在偌大的網際網路中,我們可以通過“IP地址+端⼜號”標識網際網路中唯一的一個程序。然而,“IP地址+端⼜號”就稱為socket,這就是網路socket程式設計

在TCP協議中,建⽴連線的兩個程序各⾃有⼀個socket來標識,那麼這兩個socket組成 的socketpair就唯⼀標識⼀個連線。

socket本⾝有“插座”的意思,因此⽤來描述⽹絡連線的⼀ 對⼀關係。

要寫出一個基於TCP的網路服務程式,我們應該具有以下的知識:

  1. 傳送主機通常將傳送緩衝區中的資料按記憶體地址從低到⾼的順序發出,接收主機把從⽹絡上接到的位元組依次儲存在接收緩衝區中,也是按記憶體地址從低到⾼的順序儲存。
  2. TCP/IP協議規定,⽹絡資料流應採⽤⼤端位元組序,即低地址⾼位元組。
  3. socket API是⼀層抽象的⽹絡程式設計接⼜,適⽤於各種底層⽹絡協議,如IPv4、 IPv6,以及後⾯要講的UNIX Domain Socket。
       IPv4和IPv6的地址格式定義在netinet/in.h中,IPv4地址⽤sockaddr_in結構體表⽰,包括16位端⼜號和32位IP地址,IPv6地址⽤sockaddr_in6結構體表⽰,包括16位端⼜號、 128位IP地址和⼀些 控制欄位。 UNIX Domain Socket的地址格式定義在sys/un.h中,⽤sockaddr_un結構體表⽰。各 種socket地址結構體的開頭都是相同的,前16位表⽰整個結構體的長度(並不是所有UNIX的實現 都有長度欄位,如Linux就沒有),後16位表⽰地址型別。 IPv4、 IPv6和UNIXDomain Socket的地 址型別分別定義為常數AF_INET、 AF_INET6、 AF_UNIX。這樣,只要取得某種sockaddr結構體的 ⾸地址,不需要知道具體是哪種型別的sockaddr結構體,就可以根據地址型別欄位確定結構體中的 內容。因此,socket API可以接受各種型別的sockaddr結構體指標做引數,例 如bind、 accept、 connect等函式,這些函式的引數應該設計成void *型別以便接受各種型別的指 針,但是sock API的實現早於ANSI C標準化,那時還沒有void *型別,
因此這些函式的引數都 ⽤struct sockaddr *型別表⽰,在傳遞引數之前要強制型別轉換⼀下.結構如圖所示:

通常⽤點分⼗進位制的字串表⽰IP 地址,以下函式可以在字串表⽰ 和in_addr表⽰之間轉換。
字串轉in_addr的函式:

in_addr轉字串的函式:

以下是一次客戶端,服務端從請求連線到斷開連線的依次完整會話過程
       伺服器調⽤socket()、 bind()、 listen() 完成初始化後,調⽤accept()阻塞等待,處於監聽端⼜的狀態,客戶端調⽤socket()初始化後,調⽤connect()發出SYN段並阻塞等待伺服器應答,伺服器應答⼀個SYN-ACK段,客戶端收到後從connect()返回,同時應答⼀個ACK段,伺服器收到後 從accept()返回。
資料傳輸的過程: 建⽴連線後,TCP協議提供全雙⼯的通訊服務,但是⼀般的客戶端/伺服器程式的流程是由客戶端主 動發起請求,伺服器被動處理請求,⼀問⼀答的⽅式。因此,伺服器從accept()返回後⽴刻調 ⽤read(),讀socket就像讀管道⼀樣,如果沒有資料到達就阻塞等待,這時客戶端調⽤write()傳送 請求給伺服器,伺服器收到後從read()返回,對客戶端的請求進⾏處理,在此期間客戶端調 ⽤read()阻塞等待伺服器的應答,伺服器調⽤write()將處理結果發回給客戶端,再次調⽤read()阻塞 等待下⼀條請求,客戶端收到後從read()返回,傳送下⼀條請求,如此迴圈下去。如果客戶端沒有更多的請求了,就調⽤close() 關閉連線,就像寫端關閉的管道⼀樣,伺服器 的ead()返回0,這樣伺服器就知道客戶端關閉了連線,也調⽤close()關閉連線。注意,任何⼀⽅調⽤close() 後,連線的兩個傳輸⽅向都關閉,不能再發送資料了。如果⼀⽅調⽤shutdown() 則連線處 於半關閉狀態,仍可接收對⽅發來的資料。
有了上面的分析過程,不難寫出一下程式: (一)server.c 的作⽤是接受client的請求,並與client進⾏簡單的資料通訊,整體為⼀個阻塞式的⽹絡聊天⼯具。
<span style="font-family:Microsoft YaHei;font-size:14px;"><span style="font-family:Microsoft YaHei;font-size:14px;">#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<errno.h>
#include<string.h>
#include<unistd.h>
#include<netinet/in.h>


static void Usage(const char* proc)
{
	printf("Usage:%s[ip][port]\n",proc);
}
int main(int argc,char* argv[])
{
	if(argc!=3)
	{
		Usage(argv[0]);
	//	exit(1);
		return 4;
	}
	int listen_sock=socket(AF_INET,SOCK_STREAM,0);
	if(listen_sock<0)
	{
		perror("socket");
		return 1;
	}
	struct sockaddr_in local;
	local.sin_family=AF_INET;
	local.sin_port=htons(atoi(argv[2]));
	local.sin_addr.s_addr=inet_addr(argv[1]);
	if(bind(listen_sock,(struct sockaddr*)&local,sizeof(local))<0)
	{
		perror("bind");
		return 2;
	}
	listen(listen_sock,5);//max listen bind 5
	struct sockaddr_in peer;
	socklen_t len=sizeof(peer);
	int fd=accept(listen_sock,(struct sockaddr*)&peer,&len);
	if(fd<0)
	{
		perror("accept");
		return 3;
	}



	char buf[1024];
	while(1)
	{
		memset(buf,'\0',sizeof(buf));
		ssize_t _s=read(fd,buf,sizeof(buf)-1);
		if(_s>0)
		{
			buf[_s]='\0';
			printf("client :");
			printf("%s\n",buf);
			write(fd,buf,strlen(buf));
			printf("server :%s\n",buf);
		}else
		{
			printf("read done....\n");
			break;
		}
	}
	return 0;
}
</span></span>
socket()開啟⼀個⽹絡通訊端⼜,如果成功的話,就像open()⼀樣返回⼀個⽂件描述符,應⽤程式可以像讀寫⽂件⼀樣⽤read/write在⽹絡上收發資料,如果socket()調⽤出錯則返回-1。對 於IPv4,family引數指定為AF_INET。對於TCP協議,type引數指定為SOCK_STREAM,表⽰⾯向流的傳輸協議。如果是UDP協議,則type引數指定為SOCK_DGRAM,表⽰⾯向資料報的傳輸協 議。 protocol引數的介紹從略,指定為0即可。
bind()的作⽤是將引數sockfd和myaddr繫結在⼀起,使sockfd這個⽤於⽹絡通訊的⽂件描述符監聽myaddr所描述的地址和端⼜號。
listen()宣告sockfd處於監聽狀態,並且最多允許有backlog個客戶端處於連線等待狀態,如果接收到更多的連線請求就忽略。
三⽅握⼿完成後,伺服器調⽤accept()接受連線,如果伺服器調⽤accept()時還沒有客戶端的連線請 求,就阻塞等待直到有客戶端連線上來。 cliaddr是⼀個傳出引數,accept()返回時傳出客戶端的地 址和端⼜號。 addrlen引數是⼀個傳⼊傳出引數(value-result argument),傳⼊的是調⽤者提供的 緩衝區cliaddr 的長度以避免緩衝區溢位問題,傳出的是客戶端地址結構體的實際長度(有可能沒有佔滿調⽤者提供的緩衝區)。如果給cliaddr 引數傳NULL,表⽰不關⼼客戶端的地址。
(二)client.c的作⽤是連結server,並向server發起通訊請求。
<span style="font-family:Microsoft YaHei;font-size:14px;"><span style="font-family:Microsoft YaHei;font-size:14px;">#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
#include<unistd.h>
#include<errno.h>
#include<netinet/in.h>

static void Usage(const char* proc)
{
	printf("Usage:%s[ip][port]\n",proc);
}
int main(int argc,char* argv[])
{
	if(argc!=3)
	{
		Usage(argv[0]);
	//	exit(1);
		return 4;
	}
	int sock=socket(AF_INET,SOCK_STREAM,0);
	if(sock<0)
	{
		perror("socket");
		return 1;
	}
	struct sockaddr_in remote;
	remote.sin_family=AF_INET;
	remote.sin_port=htons(atoi(argv[2]));
	remote.sin_addr.s_addr=inet_addr(argv[1]);
	if(connect(sock,(struct sockaddr*)&remote,sizeof(remote))<0)
	{
		perror("connect");
		return 2;
	}
	while(1)
	{
		char buf[1024];
		memset(buf,'\0',sizeof(buf));
		printf("client :");
		fflush(stdout);
		ssize_t _s=read(0,buf,sizeof(buf)-1);
		if(_s>0)
		{
			buf[_s-1]='\0';
			write(sock,buf,strlen(buf));
			memset(buf,'\0',sizeof(buf));
			_s=read(sock,buf,sizeof(buf));
			printf("server :");
			printf("%s\n",buf);

		}else
		{
			printf("read done....\n");
			break;
		}


	}
	return 0;
}
</span></span>
    由於客戶端不需要固定的端⼜號,因此不必調⽤bind(),客戶端的端⼜號由核心⾃動分配。注意, 客戶端不是不允許調⽤bind(),只是沒有必要調⽤bind()固定⼀個端⼜號,伺服器也不是必須調⽤bind(),但如果伺服器不調⽤bind(),核心會⾃動給伺服器分配監聽端⼜,每次啟動伺服器時端⼜ 號都不⼀樣,客戶端要連線伺服器就會遇到⿇煩。     客戶端需要調⽤connect()連線伺服器,connect和bind的引數形式⼀致,區別在於bind的引數是⾃⼰的地址,⽽connect的引數是對⽅的地址。 connect()成功返回0,出錯返回-1。
我們來看一下server和client是怎樣通訊的(執行截圖):
(一)先執行伺服器:
伺服器阻塞等待客戶機請求連結。 (二)重啟一個終端,然後執行客戶機:
(三)然後客戶機發送資料:
(四)伺服器接到資料,並回顯給客戶機:
這裡有一個問題,當我們ctrl+Z暫停伺服器,然後再起一個服務時,它會提醒我們Address already in use。這就像伺服器掛了
client終⽌時⾃動關閉socket描述符,server的TCP連線收到client發的FIN段後處於TIME_WAIT狀 態。 TCP協議規定,主動關閉連線的⼀⽅要處於TIME_WAIT狀態,等待兩個MSL(maximum segment lifetime)的時間後才能回到CLOSED狀態,因為我們先Ctrl-C終⽌了server,所以server是主動關閉連線的⼀⽅,在TIME_WAIT期間仍然不能再次監聽同樣的server端 ⼜。 MSL在RFC1122中規定為兩分鐘,但是各作業系統的實現不同,在Linux上⼀般經過半分鐘後 就可以再次啟動server了。 解決方法是:在socket呼叫和bind呼叫之間加上一段對socket的設定:
   int opt = 1;
   setsockopt(socket_fd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt))