一文看懂socket編程


1.網絡模型的設計模式

1.1 B/S模式

B/S: Browser/Server,瀏覽器/服務器模式,在一端部署服務器,在另外外一端使用默認配置的瀏覽器即可完成數據的傳輸。
B/S結構是隨着互聯網的發展,web出現后興起的一種網絡結構模式。這種模式統一了客戶端,讓核心的業務處理在服務端完成。你只需要在自己電腦或手機上安裝一個瀏覽器,就可以通過web Server與數據庫進行數據交互

  • 優點:跨平台移植性好、將系統功能實現的核心部分集中到服務器上,簡化了系統的開發、維護和使用。
  • 缺點:安全性較差,不能緩存大量數據,且要嚴格遵守http協議

1.2 C/S模式

C/S: Client/Server,客戶/服務器模式服務器通常采用高性能的PC、工作站或小型機,並采用大型數據庫系統,如ORACLE、SYBASE、InfORMix或 SQL Server。客戶端需要安裝專用的客戶端軟件。通過將任務合理分配到Client端和Server端,降低了系統的通訊開銷,可以充分利用兩端硬件環境的優勢。
我們常用的微信、QQ等應用程序就是C/S結構。

  • 優點:安全性能可以很容易保證。(因為只有兩層的傳輸,而不是中間有很多層),傳輸速度和響應速度很快、可以在客戶端本地事先緩存大量數據、協議靈活。
  • 缺點:需要對客戶端和發服務端開發,工作量大,用戶群固定,護成本高,發生一次升級,則所有客戶端的程序都需要改變

2.預備知識

2.1 socket套接字的概念

在linux系統下,所有資源都以文件形式存在,socket是用來表示進程間網絡通信的特殊文件類型,本質是linux內核借助緩沖區形成的偽文件。
既然是文件,所以我們就可以使用文件描述符引用套接字,用於網絡進程間的數據傳遞。

2.2 網絡進程之間是如何進行通信的

  1. TCP/IP協議中利用IP地址唯一標識一台主機。
  2. IP地址 + 端口號 唯一標識一台主機中的唯一進程。

因此,我們利用三元組(ip地址,協議,端口)就可以標識網絡的進程了,網絡中的進程通信就可以利用這個標志與其它進程進行交互。

2.3 主機字節序和網絡字節序

學習socke地址API,我們首先要了解主機字節序和網絡字節序。

內存中的多字節數據相對於內存地址有大端和小端之分,例如JAVA虛擬機采用打大端字節序,即低地址高字節,最高有效字節在最前面。
比如0x012345

socket地址數據結構

現代的PC大多采用小端字節序,因此又被稱為主機字節序,即低字節地地址,最低有效字節在最前面。

網絡數據流同樣有大端小端之分,那么如何定義網絡數據流的地址呢?

發送主機通常將發送緩沖區中的數據按內存地址從低到高的順序發出,接收主機把從網絡上接到的字節依次保存在接收緩沖區中,也是按內存地址從低到高的順序保存,因此,網絡數據流的地址應這樣規定:先發出的數據是低地址,后發出的數據是高地址。

TCP/IP協議規定,網絡數據流應采用大端字節序,即低地址高字節,也叫做網絡字節序。

當格式化的數據在兩台使用不同字節序的逐級之間傳遞時,如果不進行字節序轉換,則必然會發生錯誤。
為使網絡程序具有可移植性,使同樣的C代碼在大端和小端計算機上編譯后都能正常運行,可以調用以下庫函數做網絡字節序和主機字節序的轉換。

#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong); 
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

這里的含義很明確,htol代表“host to network long”,將長整形32bit的主機字節序轉化為網絡字節序。
如果主機是小端字節序,這些函數將參數做相應的大小端轉換然后返回,如果主機是大端字節序,這些函數不做轉換,將參數原封不動地返回
長整形通常用來轉換IP地址,短整型用來轉換端口號

2.4 IP地址轉換函數

通常情況下我們用點分十進制字符串來表示IPv4地址,十六進制字符串表示IPv6地址,可讀性好,但實際使用中需要把他們轉化成二進制。記錄日志時,則相反。
下面幾個函數分別完成這些功能。

#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

inet_pton函數將字符串src表示的IP地址(IPv4、Ipv6)轉化成網絡字節序整數表示的IP地址,並存於dst中,af指定地址族(AF_INET/AF_INET6),成功返回0,失敗返回-1並shezhierrno。
inet_ntop成功返回目標存儲單元地址,失敗返回null並設置errno。

2.5 socket地址結構

通用socket地址:

struct sockaddr {
	sa_family_t sa_family; 		/* 地址族, AF_xxx */
	char sa_data[14];		/* 14 bytes of protocol address */
};

這個通用地址結構體不好用,更多的用的是專用socket地址結構體:sockaddr_in、sockaddr_in6

struct sockaddr_in {
	sa_family_t sin_family; 		/*  地址族:AF_INET*/  	
	uint16_t sin_port;			/* 端口號,要用網絡字節序表示*/       
	struct in_addr sin_addr;		/* IPv4地址*/	
};

struct in_addr {				/* IPv4地址,要用網絡字節序表示 */
	u_int32_t s_addr;
};

struct sockaddr_in6 {
	sa_family_t  sin6_family; 		/* 地址族:AF_INET6 */
	uint16_t sin6_port; 			/* 端口號,要用網絡字節序表示  */
	uint32_t sin6_flowinfo; 		/* 流信息,應設置為0 */
	struct in6_addr sin6_addr;		/* IPv6 address */
	uint32_t sin6_scope_id; 		/* scope id ,尚處於實驗階段 */
};

struct in6_addr {
    unsigned char sa_addr[16];                  /* IPv4地址,要用網絡字節序表示 */
};

3.套接字函數

3.1 創建一個socket

UNIX/Linux的一個哲學:所見皆文件。
創建一個socket用到下面函數:

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

domain:告訴系統使用哪個底層協議族

  • AF_INET 這是大多數用來產生socket的協議,使用TCP或UDP來傳輸,用IPv4的地址
  • AF_INET6 與上面類似,不過是來用IPv6的地址
  • AF_UNIX 本地協議,使用在Unix和Linux系統上,一般都是當客戶端和服務器在同一台及其上的時候使用。

type:

  • SOCK_STREAM 這個協議是按照順序的、可靠的、數據完整的基於字節流的連接。這是一個使用最多的socket類型,這個socket是使用TCP來進行傳輸。
  • SOCK_DGRAM 這個協議是無連接的、固定長度的傳輸調用。該協議是不可靠的,使用UDP來進行它的連接。
  • SOCK_SEQPACKET 該協議是雙線路的、可靠的連接,發送固定長度的數據包進行傳輸。必須把這個包完整的接受才能進行讀取。
  • SOCK_RAW socket 類型提供單一的網絡訪問,這個socket類型使用ICMP公共協議。(ping、traceroute使用該協議)
  • SOCK_RDM 這個類型是很少使用的,在大部分的操作系統上沒有實現,它是提供給數據鏈路層使用,不保證數據包的順序

protocol:

  • 傳0 表示使用默認協議。
  • 返回值:
    成功:返回指向新創建的socket的文件描述符,失敗:返回-1,設置errno

socket()打開一個網絡通訊端口,如果成功的話,就像open()一樣返回一個文件描述符,應用程序可以像讀寫文件一樣用read/write在網絡上收發數據,如果socket()調用出錯則返回-1。對於IPv4,domain參數指定為AF_INET。對於TCP協議,type參數指定為SOCK_STREAM,表示面向流的傳輸協議。如果是UDP協議,則type參數指定為SOCK_DGRAM,表示面向數據報的傳輸協議。protocol參數的介紹從略,指定為0即可

3.2 bind函數

創建socket時,我們只指定了地址族,並未指定那個具體的socket地址。bind()函數就是將socket套接字與地址綁定。

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:socket文件描述符
  • addr:構造出IP地址加端口號
  • addrlen:sizeof(addr)長度返回值:
  • 成功返回0,失敗返回-1, 設置errno

在linux中我們也可以使用man指令查看這些函數的端口信息,如man bind

bind()的作用是將參數sockfd和addr綁定在一起,使sockfd這個用於網絡通訊的文件描述符監聽addr所描述的地址和端口號。
struct sockaddr *是一個通用指針類型,addr參數實際上可以接受多種協議的sockaddr結構體,而它們的長度各不相同,所以需要第三個參數addrlen指定結構體的長度。如:

struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8888);

端口號一般是0-65536之間,不能超過這個值。
首先將整個結構體清零,然后設置地址類型為AF_INET,網絡地址為INADDR_ANY,這個宏表示本地的任意IP地址,因為服務器可能有多個網卡,每個網卡也可能綁定多個IP地址,這樣設置可以在所有的IP地址上監聽,直到與某個客戶端建立了連接時才確定下來到底用哪個IP地址,端口號為8888。

3.3 listen函數

socket綁定地址后,還不能馬上接受客戶連接,需要使用listen創建監聽隊列一存放待處理的客戶連接。

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);

  • sockfd:socket文件描述符
  • backlog:提示內核監聽隊列的最大長度,默認為256,如果監聽隊列的長度超過backlog,服務器不再受理新的客戶連接。
  • 成功返回0,失敗返回-1

查看系統默認backlog
cat /proc/sys/net/ipv4/tcp_max_syn_backlog

3.4 accept函數

當客戶端發起連接請求時,服務器調用accept()接受連接,返回一個新的連接socket文件描述符,服務器可通過讀寫該socket來與被接受連接對應的客戶端通信。如果服務器調用accept()時還沒有客戶端的連接請求,就阻塞等待直到有客戶端連接上來。

#include <sys/types.h> 		/* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd:socket文件描述符
  • addr:傳出參數,返回連接客戶端地址信息,含IP地址和端口號
  • addrlen:傳入傳出參數(值-結果),傳入sizeof(addr)大小,函數返回時返回真正接收到地址結構體的大小
  • 返回值:成功返回一個新的socket文件描述符,用於和客戶端通信,失敗返回-1,設置errno

3.5 connect函數

#include <sys/types.h> 					/* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

  • sockfd:socket文件描述符
  • addr:傳入參數,指定服務器端地址信息,含IP地址和端口號
  • addrlen:傳入參數,傳入sizeof(addr)大小
  • 返回值:成功返回0,失敗返回-1,設置errno

客戶端需要調用connect()連接服務器,connect和bind的參數形式一致,區別在於bind的參數是自己的地址,而connect的參數是對方的地址。

3.6 關閉連接

關閉連接有兩種方式:close和shutdown

#include<unistd,h>
int close(int fd);

close並非總是立即關閉一個連接,而是將fd的引用計數減1,只有當fd的引用計數為0時,才真正關閉連接。多進程程序中,一次fork將使父進程中打開的socket引用計數加1。

如果無論如何都要立即終止連接,可以使用shutdown。

#include<sys/socket.h>
int shutdown( int socfd, int howto);
  • howto決定了shutdown的行為,能夠設置分別關閉socket上的讀或寫,或者都關閉。而close是將讀寫全關閉。
  • SHUT_RD(0):關閉sockfd上的讀功能,此選項將不允許sockfd進行讀操作。該套接字不再接受數據,任何當前在套接字接受緩沖區的數據將被無聲的丟棄掉。
  • SHUT_WR(1):關閉sockfd的寫功能,此選項將不允許sockfd進行寫操作。進程不能在對此套接字發出寫操作。
  • SHUT_RDWR(2):關閉sockfd的讀寫功能。相當於調用shutdown兩次:首先是以SHUT_RD,然后以SHUT_WR。

注意:

  • 如果有多個進程共享一個套接字,close每被調用一次,計數減1,直到計數為0時,也就是所用進程都調用了close,套接字將被釋放。
  • 在多進程中如果一個進程調用了shutdown(sfd, SHUT_RDWR)后,其它的進程將無法進行通信。但,如果一個進程close(sfd)將不會影響到其它進程。

4.簡單的C/S模型

下圖是簡單的socket模型創建流程圖,編寫程序就可以直接參考這個框架。

下圖是基於TCP協議的客戶端/服務器程序的一般流程:

TCP協議通訊流程:
服務器調用socket()、bind()、listen()完成初始化后,調用accept()阻塞等待,處於監聽端口的狀態,客戶端調用socket()初始化后,調用connect()發出SYN段並阻塞等待服務器應答,服務器應答一個SYN-ACK段,客戶端收到后從connect()返回,同時應答一個ACK段,服務器收到后從accept()返回。

數據傳輸的過程:
建立連接后,TCP協議提供全雙工的通信服務,但是一般的客戶端/服務器程序的流程是由客戶端主動發起請求,服務器被動處理請求,一問一答的方式。因此,服務器從accept()返回后立刻調用read(),讀socket就像讀管道一樣,如果沒有數據到達就阻塞等待,這時客戶端調用write()發送請求給服務器,服務器收到后從read()返回,對客戶端的請求進行處理,在此期間客戶端調用read()阻塞等待服務器的應答,服務器調用write()將處理結果發回給客戶端,再次調用read()阻塞等待下一條請求,客戶端收到后從read()返回,發送下一條請求,如此循環下去。
如果客戶端沒有更多的請求了,就調用close()關閉連接,就像寫端關閉的管道一樣,服務器的read()返回0,這樣服務器就知道客戶端關閉了連接,也調用close()關閉連接。注意,任何一方調用close()后,連接的兩個傳輸方向都關閉,不能再發送數據了。如果一方調用shutdown()則連接處於半關閉狀態,仍可接收對方發來的數據。

下面給出簡單的C/S模型程序,可實現服務器從客戶端讀字符,然后將每個字符轉換為大寫並回送給客戶端。
服務端程序:

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

#define SERV_PORT 8888

void sys_err(const char *str)
{
     perror(str);
     exit(1);
}

int main(int argc, char *argv[])
{
    int lfd = 0, cfd = 0;
    int ret, i;
    char buf[BUFSIZ], client_IP[1024];

    struct sockaddr_in serv_addr, clit_addr;  // 定義服務器地址結構 和 客戶端地址結構
    socklen_t clit_addr_len;                  // 客戶端地址結構大小
 
    serv_addr.sin_family = AF_INET;             // IPv4
    serv_addr.sin_port = htons(SERV_PORT);      // 轉為網絡字節序的 端口號
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 獲取本機任意有效IP

    lfd = socket(AF_INET, SOCK_STREAM, 0);      //創建一個 socket
    if (lfd == -1) {
        sys_err("socket error");
    }
 
    bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));//給服務器socket綁定地址結構(IP+port)

    listen(lfd, 128);                   //  設置監聽上限
  
    clit_addr_len = sizeof(clit_addr);  //  獲取客戶端地址結構大小

    cfd = accept(lfd, (struct sockaddr *)&clit_addr, &clit_addr_len);   // 阻塞等待客戶端連接請求
    if (cfd == -1)
        sys_err("accept error");

    printf("client ip:%s port:%d\n", 
            inet_ntop(AF_INET, &clit_addr.sin_addr.s_addr, client_IP, sizeof(client_IP)), 
            ntohs(clit_addr.sin_port));         // 根據accept傳出參數,獲取客戶端 ip 和 port

    while (1) {
        ret = read(cfd, buf, sizeof(buf));      // 讀客戶端數據
        write(STDOUT_FILENO, buf, ret);         // 寫到屏幕查看
 
        for (i = 0; i < ret; i++)               // 小寫 -- 大寫
             buf[i] = toupper(buf[i]);

        write(cfd, buf, ret);                   // 將大寫,寫回給客戶端。
    }
 
    close(lfd);
    close(cfd);
  
    return 0;

客戶端程序:

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

#define SERV_PORT 8888
#define BUFFSIZE 1024
void sus_err(const char *str){
    perror(str);
    exit(1);
}

int main(int argc, char *argv[]){
    int cfd;  
    char buf[BUFFSIZE];
    
    struct sockaddr_in serv_addr;  // 服務器地質結構
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
    
    cfd = socket(AF_INET, SOCK_STREAM, 0);

    if(cfd == -1) sys_err("socket error");

    int ret = connect(cfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
    if(ret != 0) sys_err("socket error");
    
    while(1){
        ret = read(cfd, buf, sizeof(buf));
        write(STDOUT_FILENO, buf, ret);
        sleep(1);
    }
    close(cfd);

    return 0;

}

5.錯誤處理封裝

系統調用不能保證每次都成功,必須進行錯誤處理,這樣一方面可以保證程序邏輯正常,另一方面可以迅速得到故障信息。
為使錯誤處理的代碼不影響主程序的可讀性,我們把與socket相關的一些系統函數加上錯誤處理代碼包裝成新的函數,在新函數里面處理錯誤,在主程序中就可以直接使用這些封裝過的函數,更加簡潔明了。

#ifndef __WRAP_H_
#define __WRAP_H_
void perr_exit(const char *s);
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr);
int Bind(int fd, const struct sockaddr *sa, socklen_t salen);
int Connect(int fd, const struct sockaddr *sa, socklen_t salen);
int Listen(int fd, int backlog);
int Socket(int family, int type, int protocol);
ssize_t Read(int fd, void *ptr, size_t nbytes);
ssize_t Write(int fd, const void *ptr, size_t nbytes);
int Close(int fd);
ssize_t Readn(int fd, void *vptr, size_t n);
ssize_t Writen(int fd, const void *vptr, size_t n);
ssize_t my_read(int fd, char *ptr);
ssize_t Readline(int fd, void *vptr, size_t maxlen);
#endif

具體封裝函數如下:

點擊查看代碼
#include <stdlib.h>
#include <errno.h>
#include <sys/socket.h>
void perr_exit(const char *s)
{
	perror(s);
	exit(1);
}
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr)
{
	int n;
	again:
	if ( (n = accept(fd, sa, salenptr)) < 0) {
		if ((errno == ECONNABORTED) || (errno == EINTR))
			goto again;
		else
			perr_exit("accept error");
	}
	return n;
}
int Bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
	int n;
	if ((n = bind(fd, sa, salen)) < 0)
		perr_exit("bind error");
	return n;
}
int Connect(int fd, const struct sockaddr *sa, socklen_t salen)
{
	int n;
	if ((n = connect(fd, sa, salen)) < 0)
		perr_exit("connect error");
	return n;
}
int Listen(int fd, int backlog)
{
	int n;
	if ((n = listen(fd, backlog)) < 0)
		perr_exit("listen error");
	return n;
}
int Socket(int family, int type, int protocol)
{
	int n;
	if ( (n = socket(family, type, protocol)) < 0)
		perr_exit("socket error");
	return n;
}
ssize_t Read(int fd, void *ptr, size_t nbytes)
{
	ssize_t n;
again:
	if ( (n = read(fd, ptr, nbytes)) == -1) {
		if (errno == EINTR)
			goto again;
		else
			return -1;
	}
	return n;
}
ssize_t Write(int fd, const void *ptr, size_t nbytes)
{
	ssize_t n;
again:
	if ( (n = write(fd, ptr, nbytes)) == -1) {
		if (errno == EINTR)
			goto again;
		else
			return -1;
	}
	return n;
}
int Close(int fd)
{
	int n;
	if ((n = close(fd)) == -1)
		perr_exit("close error");
	return n;
}
ssize_t Readn(int fd, void *vptr, size_t n)
{
	size_t nleft;
	ssize_t nread;
	char *ptr;

	ptr = vptr;
	nleft = n;

	while (nleft > 0) {
		if ( (nread = read(fd, ptr, nleft)) < 0) {
			if (errno == EINTR)
				nread = 0;
			else
				return -1;
		} else if (nread == 0)
			break;
		nleft -= nread;
		ptr += nread;
	}
	return n - nleft;
}

ssize_t Writen(int fd, const void *vptr, size_t n)
{
	size_t nleft;
	ssize_t nwritten;
	const char *ptr;

	ptr = vptr;
	nleft = n;

	while (nleft > 0) {
		if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
			if (nwritten < 0 && errno == EINTR)
				nwritten = 0;
			else
				return -1;
		}
		nleft -= nwritten;
		ptr += nwritten;
	}
	return n;
}

static ssize_t my_read(int fd, char *ptr)
{
	static int read_cnt;
	static char *read_ptr;
	static char read_buf[100];

	if (read_cnt <= 0) {
again:
		if ((read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
			if (errno == EINTR)
				goto again;
			return -1;	
		} else if (read_cnt == 0)
			return 0;
		read_ptr = read_buf;
	}
	read_cnt--;
	*ptr = *read_ptr++;
	return 1;
}

ssize_t Readline(int fd, void *vptr, size_t maxlen)
{
	ssize_t n, rc;
	char c, *ptr;
	ptr = vptr;

	for (n = 1; n < maxlen; n++) {
		if ( (rc = my_read(fd, &c)) == 1) {
			*ptr++ = c;
			if (c == '\n')
				break;
		} else if (rc == 0) {
			*ptr = 0;
			return n - 1;
		} else
			return -1;
	}
	*ptr = 0;
	return n;
}

參考資料:

  1. 《linux高性能服務器編程》游雙 著


免責聲明!

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



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