天天看點

C++網絡程式設計——socket技術基礎什麼是socketsocket的類型C/S架構下的socket設計socket常用資料結構網絡與主機位元組序的互相轉換socket相關函數伺服器端執行個體

研究所學生階段項目開發用到了socket技術,寫個部落格簡單記錄一下socket通信相關的基礎知識,包含我個人對socket技術的一些了解,個人經驗,如有錯誤煩請大佬們批評指正

什麼是socket

  中文翻譯過來叫“套接字”,可以了解為一個通信端點,我們都知道主機與主機之間通信是通過ip和端口(傳輸層和網絡層),那麼兩台主機上的應用程式(應用層)如果想互相交流,也需要借助主機間的通信機制,但是應用程式不是主機本身,想要使用這一機制,就需要借助于socket,也就是說,socket是連接配接應用層和各種網絡協定的接口。

  socket的通信機制有點類似于我們用手機通信,當兩個人需要遠端對話的時候,首先要各有一部手機,然後需要插入電話卡(類似于ip位址和端口号),然後一方撥打另一方的手機号碼,接通之後,可以互相發送/接收資訊。

socket的類型

流格式套接字

  流格式套接字(Stream Sockets)也叫面向連接配接的套接字,代碼中使用SOCK_STREAM表示。提到面向連接配接,一定是基于TCP協定的,SOCK_STREAM是一種可靠的、雙向的通信資料流,在底層實作了重傳機制,使得資料可以準确無誤地到達另一台計算機。

  SOCK_STREAM的特征:

  • 資料按照順序傳輸(早發送的早到)
  • 在傳輸過程中資料不會丢失(TCP協定保證)
  • 資料發送和接收是不同步的(傳輸的資料存入緩沖區)

    實際應用:HTTP協定傳輸資料

資料報格式套接字

  資料報格式套接字(Datagram Sockets),也叫無連接配接的套接字(聽名字就知道基于UDP),代碼中使用SOCK_DGRAM表示。擁有UDP協定傳輸的優缺點,資料傳送速度快,消耗小,但是資料容易丢失或損壞

  SOCK_DGRAM的特征:

  • 快速傳輸(不注重順序)
  • 資料可能會丢失或損壞(隻是小機率事件,隻是相對TCP來說不穩定)
  • 資料的發送和接收是同步的
  • 每次傳輸的資料大小有限制

    實際應用: QQ視訊、語音

原始套接字

  原始套接字(Raw Socket)是一個特殊的額套接字類型,代碼中使用SOCK_RAW表示,原始套接字的特殊之處在于:能夠實作從應用層到資料鍊路層的所有資料操作。TCP/UDP類型的套接字隻能通路傳輸層以及傳輸層以上的資料(因為TCP/UDP是傳輸層協定,當傳到傳輸層的時候,下層的IP標頭/幀頭幀尾都已經被丢棄)

  關于原始套接字,在此僅僅作一個簡單介紹,我了解和使用的不是很多,不敢妄言,感興趣的同學可以自行百度。

C/S架構下的socket設計

  我參與開發的項目主要是C/S架構(網上的大多數socket教程也都采用C/S架構舉例),就從這一方向簡單介紹一下。

  下圖是我自己繪制的Stream Sockets在用戶端和伺服器端上的建立建立、連接配接、通信和關閉流程圖,并包含了通過程中各種狀态的變化。

C++網絡程式設計——socket技術基礎什麼是socketsocket的類型C/S架構下的socket設計socket常用資料結構網絡與主機位元組序的互相轉換socket相關函數伺服器端執行個體

  Datagram Sockets的流程與Stream Sockets流程類似,隻是其中的TCP協定換成了UDP協定,少了幾次握手和揮手的過程。

socket常用資料結構

socket描述符

int類型,用于儲存建立好的套接字

sockaddr

struct sockaddr {
  unsigned short sa_family; /* 位址家族, AF_xxx */
  char sa_data[14]; /*14位元組協定位址*/
};
           

其中sa_family是兩位元組的位址家族,一般都是“AF_xxx”的形式,用于指定函數傳回的位址資訊類型

  AF_INET:傳回IPV4位址資訊

  AF_INET6:傳回IPV6位址資訊

  AF_UNSPEC:可以傳回任何協定族的位址

我們使用的基本都是第一種。

sa_data包含套接字中的目标位址和端口資訊

sockaddr_in

struct sockaddr_in {
  short int sin_family; /* 通信類型 */
  unsigned short int sin_port; /* 端口 */
  struct in_addr sin_addr; /* Internet 位址 */
  unsigned char sin_zero[8]; /* 與sockaddr結構的長度相同*/
};
           

sin_family:等同于sa_family,實際應用中大多選擇AF_INET

sin_port:存儲端口号

sin_addr:存儲IP位址,使用in_addr這個資料就夠,并且要使用網絡位元組序

sin_zero:是為了讓sockaddr與sockaddr_in兩個結構體保持大小相同而保留的空位元組

設計sin_zero,目的就是能讓這兩個結構體大小相等,進而能夠互相轉換,實際程式設計中多數使用第二個結構體來設定和擷取位址資訊,而作為參數傳遞時通常轉換成sockaddr結構

in_addr

typedef struct in_addr {
  union {
  struct{ unsigned char s_b1,s_b2, s_b3,s_b4;} S_un_b;
  struct{ unsigned short s_w1, s_w2;} S_un_w;
  unsigned long S_addr;
  } S_un;
  } IN_ADDR;
           

是一個存儲ip位址的共用體,有三種表達方式

第一種用四個位元組表示IP位址的四個數字。

第二種用兩個雙位元組表示IP位址

第三種用一個長整型來表示IP位址

給in_addr指派最簡單的方式就是inet_addr()函數,可以将一個代表IP位址的字元串轉換為in_addr類型,其反函數是inet_ntoa()。

sockaddr_in ina;
ina.sin_addr.s_addr=inet_addr("192.168.0.1");
           

實際使用時需要對inet_addr()的傳回值進行檢查,如果為-1則說明函數錯誤,如果不檢查無符号的-1則與廣播位址255.255.255.255相同

網絡與主機位元組序的互相轉換

需要先介紹一下網絡位元組序和主機位元組序

網絡位元組序

是TCP/IP中已經規定好的一種資料表示格式,是一種固定格式,保證資料在不同主機之間傳輸時能夠被正确解釋。網絡位元組序采用大端存儲方式(低位位元組放在記憶體高位位址,高位位元組放在記憶體低位位址)

主機位元組序

主機位元組序是多樣性的,其存儲方式取決于CPU等

判斷主機位元組序的方法

bool am_little_endian()
{
	unsigned short i=1;
	return (int)*((char*)(&i))?true:false;	//傳回true則為小端存儲
}
           

轉換方法

htons()——主機位元組序轉換為網絡位元組序(short類型,兩個位元組)

htonl()——主機位元組序轉換為網絡位元組序(long類型,四個位元組)

ntohs()——網絡位元組序轉換為主機位元組序(short類型)

ntohl()——網絡位元組序轉換為主機位元組序(long類型)

為了程式的可移植性,當資料需要被傳輸到網絡上時,一定要判斷主機位元組序,并確定其和網絡位元組序相同

如sockaddr_in結構體中的sin_port和sin_addr就都需要確定為網絡位元組序

socket相關函數

終于寫到這裡了。。。以下有些函數是用戶端獨有的,有些函數是伺服器端獨有的,為了行文友善,我就混着寫了,讀者可以參考前面的圖示來确定這些函數執行的先後順序

socket()

#include<sys/types.h>
#include<sys/socket.h>
int socket(int domian,int type,int protocol);
           

一個一個參數來看

domain:需要設定為AF_INET,等同于sa_family和sin_family。

type:告訴核心,我們使用的socket是SOCK_STREAM還是SOCK_DGRAM類型(一般用不到SOCK_RAW類型)。

protocol:指使用的協定,設定為0時,是預設跟随type參數來選擇協定,如果type參數值為SOCK_STREAM,protocol預設為IPPROTO_TCP;如果type值為SOCK_DGRAM,protocol預設為IPPROTO_UDP。

這個函數傳回一個int類型套接字,我們後面要用這個套接字實作用戶端和伺服器的連接配接

bind()

#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, struct sockaddr *my_addr, int addrlen);
           

這是伺服器端獨有的函數,需要錯誤檢查,出錯時傳回值為-1

sockfd就是調用socket()函數傳回的套接字

sockaddr就是前面講的結構體的指針,指派時轉換成sockaddr_in,存放的是伺服器端的ip、端口等資訊

addrlen設定為sizeof(struct sockaddr)

一些可以交由系統自動填入的資料:

sin_port=0時,表示由系統随機選取一個沒有被使用的端口

sin_addr=htonl(INADDR_ANY) 表示使用本機IP位址

connect()

#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
           

這是用戶端獨有的函數,需要錯誤檢查,出錯時傳回值為-1

sockfd是調用socket()傳回的套接字

serv_addr記錄了要連接配接的伺服器端的位址(IP和端口号)

addrlen,同上,sizeof(struct sockaddr)

listen()

這是伺服器端獨有的函數,發生錯誤時傳回-1

sockdf 不多說了

backlog 用于設定進入等待隊列的最大連接配接數(用戶端的連接配接請求先進入等待隊列,直到伺服器端接受連接配接),系統與允許的最大數目是20,也可以設定為5-10

accept()

#include <sys/socket.h>
int accept(int sockfd, void *addr, int *addrlen);
           

這是伺服器端獨有的函數,發生錯誤時傳回-1

sockfd 繼續不多說了

addr是一個空類型的指針,指向sockaddr_in結構體

addrlen設為sizeof(struct sockaddr_in)

函數執行後傳回一個int類型的新套接字,我的了解是,前面一個套接字用于繼續監聽(listen),新套接字是為了與已經建立連接配接的用戶端通信,後面的send()/recv()函數都是使用這個新套接字

accept()是一個阻塞(block)函數,運作時會取出連接配接等待隊列中的隊首元素,當等待隊列為空時,這個函數就會阻塞等待,直至接收到消息為止,如果連接配接數量已經達到上限,系統會自動屏蔽accept()函數,直至有用戶端退出連接配接,系統再将其喚醒。

send()/recv()

之是以将這兩個函數放在一起,是因為他們是要互相配合使用的,send函數發送的資料通過recv函數接收

sockfd:注意如果是伺服器端使用這個函數,這裡的套接字要使用accept()函數傳回的新套接字,而不是socket()函數傳回的套接字。而用戶端使用這個函數,就隻需要使用socket()函數傳回的套接字即可

msg:需要傳輸的資料的指針

len:需要傳輸的資料的長度

flags:設為0,實際上這個flag可以包含兩個值,隻是我們通常用不到另一個,實際應用中一般是設為0,感興趣的同學可以自己查一下

send()函數傳回值是實際傳送出去的字元數(雖然說send()是發送函數,但是實際上這個函數隻是起到一個将應用層資料拷貝到核心層緩沖區的作用,而發送資料則是通過協定),失敗時傳回-1

sockfd 同上,不多贅述了

buf是需要讀取的資料緩沖區的指針

len是buf指向的緩沖區的資料長度

flags依舊設定為0

函數執行錯誤時傳回-1

recv()函數預設也是一個阻塞函數,隻有接收到消息才會執行,也可以設定為非阻塞模式,感興趣的同學可以自己搜尋相關内容,我後續應該也會寫一篇關于accept()函數和recv()函數的阻塞/非阻塞模式的部落格

到這裡為止,SOCK_STREAM類型的socket程式已經可以正常通信了,如果需要使用SOCK_DGRAM類型的socket程式進行通信,需要将send()/recv()函數替換成sendto()/recvfrom()函數

##sendto()/recvfrom()

SOCK_DGRAM類型的socket用戶端和伺服器端之間是不建立TCP連接配接的,是以每次發送資料時,都需要填入目标位址

前三個參數和send()函數一緻,第四個參數中記錄的是目标主機的IP位址和端口資訊,第五個參數tolen可以設定為sizeof(struct sockaddr)

sendto()函數傳回實際發送的位元組數,錯誤時傳回-1

前三個參數也和recv()函數一緻,第四個參數記錄的是源主機的IP和端口資訊,第五個參數fromlen初始值為sizeof(struct sockaddr),函數執行之後,fromlen中儲存着實際存儲在from中的位址的長度

recvform傳回收到的位元組長度,錯誤時傳回-1

如果SOCK_DGRAM類型的socket用戶端調用connect()函數連接配接一個伺服器端,也可以直接使用send()/recv()函數,此時發送的依然是UDP封包,系統接口會自動加上目标和源的資訊

closesocket()/shutdown()函數

準備關閉套接字的時候,就需要調用closesocket()函數

如果是伺服器端程式要關閉了,就需要将用于send()/recv()函數的套接字和用于listen()的套接字一并關閉

單方向關閉了套接字之後,另一端讀寫套接字都會傳回錯誤資訊,如果想要在一定方向上的通訊做一些限制,就可以使用shutdown()函數

how的值有以下幾種

0——不允許接收

1——不允許發送

2——不允許發送和接收(實際上等同于close())

shutdown()函數執行成功時傳回0,失敗時傳回-1,在SOCK_DGRAM的socket程式中使用shutdown,隻是讓send()/recv()函數不能使用了

getpeername()

#include <sys/socket.h>

int getpeername(int sockfd, struct sockaddr *addr, int *addrlen);
           

這個函數傳回值為:SOCK_STREA的socket程式中,與目前套接字連接配接的另一端的資訊,這個資訊被存儲在addr中,addrlen則存儲這個資訊的長度

gethostname()

#include <unistd.h>

int gethostname(char *hostname, size_t size);
           

這個函數傳回目前程式運作的主機的名字,然後借助gethostbyname()函數可以獲得目前主機的IP位址

函數調用成功時傳回0,失敗時傳回-1

gethostbyname()

#include <netdb.h>

struct hostent *gethostbyname(const char *name);
           

name就是前面擷取到的主機名

struct hostent的結構如下

struct hostent {
  char *h_name;
  char **h_aliases;
  int h_addrtype;
  int h_length;
  char **h_addr_list;
};
           

h_name 主機的正式名稱。

h_aliases 空位元組-位址的預備名稱的指針。

h_addrtype 位址類型; 通常是AF_INET。

h_length –位址的比特長度。

h_addr_list 主機網絡位址指針。網絡位元組序。

h_addr 是h_addr_list中的第一位址。

伺服器端執行個體

C++簡單的伺服器端實作

SocketServer.h
           
#include<WinSock2.h>
#include<windows.h>
#include<iostream>
#define LENGTH_COMM 1024		//設定消息的最大長度為1024,當然也可以設定為其他大小
#pragma comment(lib, "ws2_32.lib") //這個庫一定要加載
using namespace std;

class SocketServer
{
public:
	int _sock=0;
	int _acc_sock = 0;
	sockaddr_in _sin;
	sockaddr_in clientaddr;
	SocketServer();
	~SocketServer();
	int SocketConn();	//與用戶端socket連接配接
	int SocketSR();		//與用戶端通信
};
           
SocketServer.cpp
           
#ifndef _SOCKET_
#define _SOCKET_
#include"SocketServer.h"
#endif

SocketServer::SocketServer()
{
	WSADATA wsadata;
	if (WSAStartup(MAKEWORD(2, 2), &wsadata)!=0)
	{
		cout << "socket庫初始化失敗" << endl;
		exit(0);
	}
	_sock = socket(AF_INET, SOCK_STREAM, 0);
	if (-1 == _sock)
	{
		cout << "socket初始化失敗" << endl;
		exit(0);
	}
	_sin.sin_family = AF_INET;
	_sin.sin_port = 50001;
	_sin.sin_addr.S_un.S_addr = inet_addr("192.168.126.1");
}
SocketServer::~SocketServer()
{
	if (_sock != 0)
	{
		closesocket(_sock);
		_sock = 0;
	}
	if (_acc_sock != 0)
	{
		closesocket(_acc_sock);
		_acc_sock = 0;
	}
	WSACleanup();
}

int SocketServer::SocketConn()
{
	int sock = _sock;
	sockaddr_in sin = _sin;
	if (-1 == bind(sock, (sockaddr*)&sin, sizeof(sockaddr)))
	{
		cout << "綁定失敗!" << endl;
		return -1;
	}
	if (-1 == listen(sock, 10))
	{
		cout << "監聽失敗!" << endl;
		return -1;
	}
	
	return 0;
}
int SocketServer::SocketSR()
{
	int acc_len = sizeof(sockaddr_in);;
	_acc_sock = accept(_sock, (sockaddr*)&clientaddr, &acc_len);
	if (-1 == _acc_sock)
	{
		cout << "接收失敗" << endl;
		return -1;
	}
	cout << "已經與用戶端建立連接配接,用戶端IP為:" << inet_ntoa(clientaddr.sin_addr) << "    端口号:" << clientaddr.sin_port << endl;
	
	int acc_sock = _acc_sock;
	char buff[LENGTH_COMM] = {"\0"};
	strcpy_s(buff, "可以開始通信了!");
	int err_send=send(acc_sock, buff, strlen(buff), 0);
	if (err_send < 0)
	{
		cout << "資訊發送失敗!" << endl;
		return -1;
	}
	while(true)
	{
		char buffer[LENGTH_COMM] = {'\0'};
		int err_recv = recv(acc_sock, buffer, LENGTH_COMM, 0);
		if (err_recv < 0)
		{
			cout << "接收消息失敗" << endl;
			return -1;
		}
		cout << "用戶端(" << inet_ntoa(clientaddr.sin_addr) << ":" << clientaddr.sin_port << ")發來消息:" << buffer << endl;
	}
	return 0;
}
           

用戶端實作

SocketClient.h
           
#include<WinSock2.h>
#include<windows.h>
#include<iostream>
#define LENGTH_COMM 1024
#pragma comment(lib,"ws2_32.lib")
using namespace std;

class SocketClient
{
public:
	int _sock=0;
	sockaddr_in _sin;
	SocketClient();
	~SocketClient();
	int SocketConn();
	int SocketSR();
};
           
SocketClient.cpp
           
#ifndef _SOCKET_
#define _SOCKET_
#include"SocketClient.h"
#endif



SocketClient::SocketClient()
{
	WSADATA wsadata;
	int err = WSAStartup(MAKEWORD(2, 2), &wsadata);
	if (err != 0)
	{
		cout << "socket庫初始化失敗!" << endl;
		exit(0);
	}
	_sock = socket(AF_INET, SOCK_STREAM, 0);
	if (-1 == _sock)
	{
		cout << "socket初始化失敗!" << endl;
		exit(0);
	}
	_sin.sin_addr.S_un.S_addr = inet_addr("192.168.126.1");
	_sin.sin_family = AF_INET;
	_sin.sin_port = 50001;
}
SocketClient::~SocketClient()
{
	if (_sock != 0)
		closesocket(_sock);
	WSACleanup();
}
int SocketClient::SocketConn()
{
	int sock = _sock;
	sockaddr_in sin = _sin;
	int err=connect(sock, (sockaddr*)&sin, sizeof(sockaddr));
	if (-1 == err)
	{
		cout << "連接配接失敗!" << endl;
		return -1;
	}
	return 0;
}
int SocketClient::SocketSR()
{
	int sock = _sock;
	char buff[LENGTH_COMM] = { "\0" };
	int err = recv(sock, buff, LENGTH_COMM, 0);
	cout << buff << endl;
	if (err < 0)
	{
		cout << "消息接收失敗!" << endl;
		return -1;
	}
	while(true)
	{
		char buffer[LENGTH_COMM] = { "\0" };
		cin >> buffer;
		int err_send=send(sock, buffer, strlen(buffer), 0);
		if (err_send < 0)
		{
			cout << "消息發送失敗!" << endl;
			return -1;
		}
	}
	return 0;
}
           

  當然這隻是一個簡單執行個體,細節設計的還不是很好。如果想用一個伺服器接收多個用戶端的資料,就需要在伺服器端建立多個acc_sock套接字,建立多個線程,整個SocketServer類的結構也要發生變化。這些功能就留到以後實作(挖個坑)。