一文看懂socket程式設計

瀟灑哥lh發表於2021-11-15

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程式設計
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程式設計

通用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是將讀寫全關閉。

4.簡單的C/S模型

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

一文看懂socket程式設計

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

一文看懂socket程式設計

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高效能伺服器程式設計》遊雙 著

相關文章