首發地址:day01-從一個基礎的socket服務說起 教程說明:C++高性能網路服務保姆級教程 本節目的 實現一個基於socket的echo服務端和客戶端 服務端監聽流程 第一步:使用socket函數創建套接字 在linux中,一切都是文件,所有文件都有一個int類型的編號,稱為文件描述符。服務端 ...
首發地址:day01-從一個基礎的socket服務說起
教程說明:C++高性能網路服務保姆級教程
本節目的
實現一個基於socket的echo服務端和客戶端
服務端監聽流程
第一步:使用socket函數創建套接字
在linux中,一切都是文件,所有文件都有一個int類型的編號,稱為文件描述符。服務端和客戶端通信本質是在各自機器上創建一個文件,稱為socket(套接字),然後對該socket文件進行讀寫。
在 Linux 下使用 <sys/socket.h>
頭文件中 socket() 函數來創建套接字
int socket(int af, int type, int protocol);
- af: IP地址類型; IPv4填
AF_INET
, IPv6填AF_INET6
- type: 數據傳輸方式,
SOCK_STREAM
表示流格式、面向連接,多用於TCP。SOCK_DGRAM
表示數據報格式、無連接,多用於UDP - protocol: 傳輸協議, IPPROTO_TCP表示TCP。
IPPTOTO_UDP
表示UDP。可直接填0
,會自動根據前面的兩個參數自動推導協議類型
#include <sys/socket.h>
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
第二步:使用bind函數綁定套接字和監聽地址
socket()函數創建出套接字後,套接字中並沒有任何地址信息。需要用bind()函數將套接字和監聽的IP和埠綁定起來,這樣當有數據到該IP和埠時,系統才知道需要交給綁定的套接字處理。
bind函數也在<sys/socket.h>
頭文件中,原型為:
int bind(int sock, struct sockaddr *addr, socklen_t addrlen);
- sock: socket函數返回的socket描述符
- addr:一個sockaddr結構體變數的指針,後續會展開說。
- addrlen:addr的大小,直接通過sizeof得到
我們先看看socket和bind的綁定代碼,下麵代碼中,我們將創建的socket與ip='127.0.0.1',port=8888進行綁定:
#include <sys/socket.h>
#include <netinet/in.h>
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr)); //用0填充
server_addr.sin_family = AF_INET; //使用IPv4地址
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具體的IP地址;填入INADDR_ANY表示"0.0.0.0"
server_addr.sin_port = htons(8888); //埠
//將套接字和IP、埠綁定
bind(server_addr, (struct sockaddr*)&server_addr, sizeof(server_addr));
可以看到,我們使用sockaddr_in結構體設置要綁定的地址信息,然後再強制轉換為sockaddr類型。這是為了讓bind函數能適應多種協議。
struct sockaddr_in{
sa_family_t sin_family; //地址族(Address Family),也就是地址類型
uint16_t sin_port; //16位的埠號
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //不使用,一般用0填充
};
struct sockaddr_in6 {
sa_family_t sin6_family; //(2)地址類型,取值為AF_INET6
in_port_t sin6_port; //(2)16位埠號
uint32_t sin6_flowinfo; //(4)IPv6流信息
struct in6_addr sin6_addr; //(4)具體的IPv6地址
uint32_t sin6_scope_id; //(4)介面範圍ID
};
struct sockaddr{
sa_family_t sin_family; //地址族(Address Family),也就是地址類型
char sa_data[14]; //IP地址和埠號
};
其中,sockaddr_in是保存IPv4的結構體;sockadd_in6是保存IPv6的結構體;sockaddr是通用的結構體,通過將特定協議的結構體轉換成sockaddr,以達到bind可綁定多種協議的目的。
註意在設置server_addr的埠號時,需要使用htons函數將傳進來的埠號轉換成大端位元組序
電腦硬體有兩種儲存數值的方式:大端位元組序和小端位元組序
大端位元組序指數值的高位位元組存在前面(低記憶體地址),低位位元組存在後面(高記憶體地址)。
小端位元組序則反過來,低位位元組存在前面,高位位元組存在後面。
電腦電路先處理低位位元組,效率比較高,因為計算都是從低位開始的。而電腦讀記憶體數據都是從低地址往高地址讀。所以,電腦的內部是小端位元組序。但是,人類還是習慣讀寫大端位元組序。除了電腦的內部處理,其他的場合比如網路傳輸和文件儲存,幾乎都是用的大端位元組序。
linux在頭文件<arpa/inet.h>
提供了htonl/htons用於將數值轉化為網路傳輸使用的大端位元組序儲存;對應的有ntohl/ntohs用於將數值從網路傳輸使用的大端位元組序轉化為電腦使用的位元組序
第三步:使用listen函數讓套接字進入監聽狀態
int listen(int sock, int backlog); //Linux
- backlog:表示全連接隊列的大小
半連接隊列&全連接隊列:我們都知道tcp的三次握手,在第一次握手時,服務端收到客戶端的SYN後,會把這個連接放入半連接隊列中。然後發送ACK+SYN。在收到客戶端的ACK回包後,握手完成,會把連接從半連接隊列移到全連接隊列中,等待處理。
第四步:調用accept函數獲取客戶端請求
調用listen後,此時客戶端就可以和服務端三次握手建立連接了,但建立的連接會被放到全連接隊列中。accept就是從這個隊列中獲取客戶端請求。每調用一次accept,會從隊列中獲取一個客戶端請求。
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);
- sock:服務端監聽的socket
- addr:獲取到的客戶端地址信息
accpet返回一個新的套接字,之後服務端用這個套接字與連接對應的客戶端進行通信。
在沒請求進來時調用accept會阻塞程式,直到新的請求進來。
至此,我們就講完了服務端的監聽流程,接下來我們可以先調用read等待讀入客戶端發過來的數據,然後再調用write向客戶端發送數據。再用close把accept_fd關閉,斷開連接。完整代碼如下
// server.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string.h>
#include <cstdio>
#include <errno.h>
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(8888);
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
printf("bind err: %s\n", strerror(errno));
close(listen_fd);
return -1;
}
if (listen(listen_fd, 2048) < 0) {
printf("listen err: %s\n", strerror(errno));
close(listen_fd);
return -1;
}
struct sockaddr_in client_addr;
bzero(&client_addr, sizeof(struct sockaddr_in));
socklen_t client_addr_len = sizeof(client_addr);
int accept_fd = 0;
while((accept_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_addr_len)) > 0) {
printf("get accept_fd: %d from: %s:%d\n", accept_fd, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
char read_msg[100];
int read_num = read(accept_fd, read_msg, 100);
printf("get msg from client: %s\n", read_msg);
int write_num = write(accept_fd, read_msg, read_num);
close(accept_fd);
}
}
[C++小知識] 在使用printf列印調試信息時,由於系統緩衝區問題,如果不加"\n",有時會列印不出來字元串。
C提供的很多函數調用產生錯誤時,會將錯誤碼賦值到一個全局int變數errno上,可以通過strerror(errno)輸入具體的報錯信息
客戶端建立連接
客戶端就比較簡單了,創建一個sockaddr_in
變數,填充服務端的ip和埠,通過connect調用就可以獲取到一個與服務端通信的套接字。
int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen);
各個參數的說明和bind()相同,不再重覆。
創建連接後,我們先調write向服務端發送數據,再調用read等待讀入服務端發過來的數據,然後調用close斷開連接。完整代碼如下:
// client.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string.h>
#include <cstdio>
#include <iostream>
int main() {
int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(8888);
if (connect(sock_fd, (sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
printf("connect err: %s\n", strerror(errno));
return -1;
};
printf("success connect to server\n");
char input_msg[100];
// 等待輸入數據
std::cin >> input_msg;
printf("input_msg: %s\n", input_msg);
int write_num = write(sock_fd, input_msg, 100);
char read_msg[100];
int read_num = read(sock_fd, read_msg, 100);
printf("get from server: %s\n", read_msg);
close(sock_fd);
}
分別編譯後,我們就得到了一個echo服務的服務端和客戶端
~# ./server
get accept_fd: 4 from: 127.0.0.1:56716
get msg from client: abc
~# ./client
abc
input_msg: abc
get from server: abc
完整源碼已上傳到CProxy-tutorial,歡迎fork and star!
思考題
先啟動server,然後啟動一個client,不輸入數據,這個時候在另外一個終端上再啟動一個client,併在第二個client終端中輸入數據,會發生什麼呢?
如果本文對你有用,點個贊再走吧!或者關註我,我會帶來更多優質的內容。