首發地址: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終端中輸入資料,會發生什麼呢?
如果本文對你有用,點個贊再走吧!或者關注我,我會帶來更多優質的內容。