介绍

  • ⽬标:学习使⽤C++和socket编程建⽴⼀个基础Web服务器。
  • 内容概述:
    • 理解Web服务器⼯作原理。
    • 学习创建和监听socket。
    • 掌握接收和响应HTTP请求的⽅法。

⼯作原理(重要)

  1. 监听端⼝
    • Web服务器在⽹络中开放⼀个或多个端⼝(如HTTP的默认端⼝80),⽤于监听客⼾端的请求。
    • 当服务器绑定到特定端⼝后,它会持续检测该端⼝是否有传⼊的连接请求。
  2. 处理请求
    • 当接收到请求时,服务器⾸先解析HTTP请求头和请求体的内容。
    • 解析过程包括提取请求的⽅法(GET、POST等)、⽬标资源的URI、HTTP版本和请求头信等。
    • 服务器还可以处理附加数据,例如在POST请求中提交的表单数据或上传的⽂件。
  3. 发送响应
    • 根据请求的类型和资源,服务器确定响应的内容。这可能是HTML⻚⾯、图像、样式表、脚本⽂件或其他类型的数据。
    • 服务器⽣成HTTP响应消息,包括状态⾏(如HTTP/1.1 200 OK)、响应头(如内容类型、内容⻓度等)和响应体(请求的资源或错误信息)。
    • 最后,服务器通过与客⼾端建⽴的连接发送这个HTTP响应。

重要组成

  • 静态内容处理:直接从服务器⽂件系统中提供⽂件,如HTML⽂件、图像、样式表和JavaScript⽂件。服务器直接从硬盘读取并发送回客⼾端。
  • 动态内容⽣成:运⾏应⽤程序或脚本来动态⽣成⽹⻚内容,
    • 服务器会将请求传递给应⽤程序服务器或脚本引擎(如PHP解释器、Java Servlet容器、Python WSGI应⽤服务器等),由它们处理业务逻辑并⽣成最终的HTTP响应内容。
  • 安全性管理:包括加密通信(HTTPS)、⾝份验证、访问控制等。
    • ⽀持HTTPS加密通信,通过SSL/TLS协议确保传输数据的安全性。
    • 实现⽤⼾⾝份验证机制,例如基本认证、摘要认证、JWT token验证等。
    • 实施访问控制策略,基于⻆⾊的权限管理(RBAC)以保护敏感资源不被未授权访问
  • ⽇志记录:记录服务器活动,如访问记录、错误信息等,⽤于监控和调试。

⽤到的C++语法知识

类型别名与 std::function 模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 声明⼀个 std::function 实例,表⽰它可以存储接受两个整数参数并返回⼀个整数的可调⽤对象
std::function<int(int, int)> add_function;

// 现在可以将符合这个签名的任何可调⽤对象赋值给它,如下⾯的普通函数、lambda 或者绑定到成员函数的对象
add_function = [](int a, int b) { return a + b; }; // lambda 函数

// 如果有⼀个这样的全局函数:
int global_add(int a, int b) {
return a + b;
}

// 也可以赋值给上⾯声明的 std::function 实例
add_function = &global_add;

// 对于类的成员函数,可以通过 std::bind 或 lambda 来创建匹配签名的可调⽤对象
class MyClass {
public:
int member_add(int a, int b) {
return a + b;
}
};
MyClass obj;
add_function = std::bind(&MyClass::member_add, &obj, std::placeholders::_1, std::placeholders::_2); // 绑定成员函数

// 使⽤ std::function 调⽤
int result = add_function(3, 5);// 将会执⾏加法操作并返回结果8

计算机通信的完整过程

  1. DHCP 初始化(获取IP地址)
    • 请求过程:Bob的便携机连接到⽹络时,因为没有IP地址,所以⾸先会向⽹络发送⼀个DHCP请报⽂(⽬的端⼝67,源端⼝68),这是⼀个⼴播请求。
    • 报⽂内容:报⽂包含了便携机的MAC地址,以0.0.0.0为源IP,⽬标IP为⼴播地址255.255.255.255。
    • DHCP服务器的响应:⽹络中的DHCP服务器接收到该请求,提供⼀个IP地址给Bob的便携机,同时提供⼦⽹掩码、默认⽹关、DNS服务器地址等信息。
    • IP分配确认:便携机从DHCP服务器收到⼀个包含分配的IP地址的DHCP ACK报⽂后,确认使⽤该IP。
  2. DNS查询(解析域名)
  3. ARP请求(获取MAC地址)
    • ARP查询:为了与默认⽹关进⾏通信,Bob的便携机需要知道⽹关的MAC地址。此时便携机会发送⼀个
    • ARP查询报⽂(⽬标MAC地址为⼴播地址FF:FF:FF:FF:FF:FF)到⽹络中,以请求⽹关的MAC地址。
    • ARP响应:⽹关收到ARP请求后,返回⼀个ARP响应报⽂,包含⽹关的MAC地址。便携机接收到该报⽂后,记录下⽹关的MAC地址。
  4. TCP三次握⼿
  5. HTTP请求和响应
    • HTTP请求:建⽴TCP连接后,Bob的便携机发送HTTP GET请求来请求⽹⻚内容,该请求报⽂包含了⽬标URL等信息。
    • 服务器响应:⽬标服务器接收到HTTP请求后,处理请求并返回⼀个HTTP响应报⽂,其中包含所请求的⽹⻚内容。
    • 数据传输和关闭连接:便携机接收到HTTP响应后,开始呈现⽹⻚内容。在数据传输完成后,TCP连接会通过四次挥⼿来关闭连接。

Socket编程基础

socket简介:

在⽹络编程中,socket是⼀个重要概念,⽤于描述IP地址和端⼝,是⽹络数据传输的端点。通过创建socket,计算机之间可以相互发送和接收数据,实现⽹络通信。

  • 端⼝是指⽹络通信中的⼀个虚拟通道,⽤于标识不同的服务或应⽤程序。每个端⼝都有⼀个唯
    ⼀的号码,范围从0到65535。
  • 服务器监听特定的端⼝,通常是80端⼝(HTTP协议的默认端⼝)或443端⼝(HTTPS协议的默
    认端⼝)
  • 对于客⼾端和服务器之间的通信,默认会假设HTTP服务运⾏在80端⼝,⽽HTTPS服务运⾏
    在443端⼝。因此,如果浏览器访问 http://example.com/
    https://example.com/ 时,它会⾃动尝试连接到该域名对应的80端⼝或443端⼝。
  • 功能:socket作为⽹络通信的基础,提供了建⽴⽹络连接、数据传输等功能。

Socket类型

流式Socket(SOCK_STREAM)

  • ⽤于创建TCP连接。
  • 保证数据完整性和顺序性。
  • 适⽤于要求可靠传输的应⽤,如Web服务器、⽂件传输等。

数据报Socket(SOCK_DGRAM)

  • ⽤于创建UDP连接。
  • 不保证数据的顺序和可靠性。
  • 适⽤于实时应⽤,如在线游戏、实时视频会议等。

TCP连接流程详解:

服务器端(Server):

  1. 创建套接字(create):通过系统调⽤ socket() 函数,指定协议类型(如AF_INET或
    AF_INET6)、传输层协议(如SOCK_STREAM表⽰TCP)来创建⼀个⽤于⽹络通信的套接字。
  2. 绑定端⼝号(bind):使⽤ bind() 函数将套接字与特定的IP地址和端⼝号关联起来,这样客⼾端可以通过这个端⼝号找到并连接到服务器。
  3. 监听连接(listen):调⽤ listen() 函数使服务器端的套接字进⼊被动监听状态,等待来⾃客⼾端的连接请求。
  4. 接受连接请求(accept):当有新的连接请求时, accept() 函数会阻塞并返回⼀个新的套接字,该新套接字专⻔⽤来与发起连接的客⼾端进⾏数据交换。
  5. 接收/发送数据(recv/send):通过返回的新套接字,服务器使⽤ recv() 和 send() 函数与客⼾端进⾏全双⼯的数据传输。
  6. 关闭套接字(close):完成数据交互后,调⽤ close() 函数关闭已建⽴连接的套接字。
    客⼾端(Client):
  7. 创建套接字(create):与服务器相同,客⼾端也需要先创建⼀个套接字。
  8. 发起连接请求(connect):客⼾端调⽤ connect() 函数主动向服务器发起连接请求,并提供服务器的IP地址和端⼝号。
  9. 发送/接收数据(send/recv):⼀旦连接建⽴成功,客⼾端同样可以使⽤ send() 和 recv() 函数与服务器交换数据。
  10. 关闭套接字(close):在数据交换完成后,客⼾端关闭其使⽤的套接字以释放资源

UDP连接流程简介:

由于UDP是⽆连接的,因此没有“监听”和“接受连接”的概念。但仍有以下基本步骤:
服务器端(Server):

  1. 创建套接字(create):与TCP⼀样,⾸先创建⼀个UDP套接字。
  2. 绑定端⼝号(bind):通过 bind() 函数将套接字绑定到特定的本地IP地址和端⼝上,以便接收来⾃客⼾端的消息。
  3. 接收/发送消息(recvfrom/sendto):对于UDP,服务器和客⼾端都使⽤ recvfrom() 和sendto() 函数直接读写数据,它们不仅负责数据传输,还需要处理源和⽬标地址信息。
  4. 关闭套接字(close):在不需要继续接收或发送数据时,关闭套接字以释放资源。
    客⼾端(Client):
  5. 创建套接字(create):同样创建⼀个UDP套接字。
  6. 发送/接收消息(sendto/recvfrom):客⼾端可以直接使⽤ sendto() 向任意服务器发送数据,并使⽤ recvfrom() 接收从服务器或其他客⼾端发来的数据。
  7. 关闭套接字(close):完成数据交互后,客⼾端关闭其套接字。

Socket编程流程

  1. 创建Socket:使⽤ socket() 函数创建新的socket。
  2. 绑定Socket:使⽤ bind() 函数将socket与特定的IP地址和端⼝号绑定,这样socket才能监听来⾃该地址和端⼝的⽹络请求。
  3. 监听连接:对于服务端,使⽤ listen() 函数让socket进⼊监听状态,等待客⼾端的连接请求。
  4. 接收连接:使⽤ accept() 函数接受客⼾端的连接请求。
  5. 数据交换:使⽤ send() 和 recv() (或 read() 和 write() )在连接的socket上发送和接收数据。
  6. 关闭Socket:通信结束后,使⽤ close() 关闭socket。

创建和监听socket

socket

1
2
3
#include <sys/socket.h>

int socket(int domain, int type, int protocol);
  • domain :指定协议族,对于IPv4⽹络通信,通常设置为 AF_INET 。
  • type :指定套接字类型,对于⾯向连接的流式传输服务(如TCP),设置为 SOCK_STREAM 。
  • protocol :指定特定的协议编号,若为0,则会根据 domain 和 type ⾃动选择最合适的协议(对于 AF_INET 和 SOCK_STREAM ,系统会选择TCP协议)。
  • 函数返回值是⼀个整数,代表新创建套接字的描述符。如果成功创建套接字,该描述符将⽤于后续的连接、绑定、接收和发送数据等操作;若失败,则返回-1,并可以通过 errno 获取错误代码。
  • 定义地址并绑定:
    1
    2
    3
    4
    5
    6
    7
    struct sockaddr_in address; //⽤于互联⽹的地址结构。
    address.sin_family = AF_INET; //设置地址族为IPv4
    address.sin_addr.s_addr = INADDR_ANY;//允许服务器接受发往本机任何IP的请求
    address.sin_port = htons(PORT); //设置端⼝号,htons确保端⼝格式正确。
    //将服务器 socket server_fd 绑定到 address 所代表的地址和端⼝上,使服务器能够监听来⾃该地址和端⼝的连接请求
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3); //让socket进⼊被动监听状态。
  • sockaddr_in :⽤于互联⽹的地址结构。
  • sin_family :设置地址族为IPv4。
  • sin_addr.s_addr :允许服务器接受发往本机任何IP的请求。
  • sin_port :设置端⼝号, htons 确保端⼝格式正确。

bind

bind() 函数的作⽤是将指定的套接字与给定的本地地址进⾏关联,也就是为服务器端的套接字分配⼀个本地IP地址和端⼝号。这样当客⼾端发起连接请求时,就知道应该连到哪个IP地址和端⼝了。

1
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
  • server_fd :是⼀个已创建好的套接字⽂件描述符,通过调⽤ socket() 函数得到。
  • (struct sockaddr *)&address :是指向⼀个已初始化的 sockaddr_in 结构体(或
    更通⽤的 sockaddr 结构体)的指针。这个结构体包含了服务器要绑定的本地地址信息,包
    括IP地址、端⼝号等。
  • sizeof(address) :表⽰上述结构体的⼤⼩。

listen

监听⽹络请求:

1
listen(server_fd, 3);
  • listen() :让socket进⼊被动监听状态。
  • 3 :等待连接队列的最⼤⻓度。
  • 具体来说,这⾥的数字表⽰服务器可以同时等待的连接请求的最⼤数量。当服务器正在处理⼀个连接请求时,如果有其他客⼾端的连接请求到达,它们将被放⼊等待队列中,直到服务器有空闲的资源来处理它们。

accept

1
int new_socket = accept(server_fd, (struct sockaddr *)&client_address,&client_len);
  • 使⽤ accept() 函数等待并接受客⼾端连接请求。
  • new_socket :新的socket描述符,专⻔⽤于与连接的客⼾端通信。
    在服务器端调⽤ listen() 函数进⼊监听状态后,接下来使⽤ accept() 函数等待并接受客⼾端的连接请求。 accept() 会阻塞直到有新的客⼾端连接到来。
  • server_fd :之前创建并进⼊监听状态的服务器套接字描述符。
  • (struct sockaddr *)&client_address :是⼀个指向 sockaddr_in 或
    sockaddr 结构体的指针,⽤于存储发起连接的客⼾端的地址信息。
  • &client_len :是指向⼀个整数的指针,⽤于存储实际返回的客⼾端地址结构体的⼤⼩。当⼀个新的客⼾端连接成功建⽴时, accept() 函数会返回⼀个新的套接字描述符new_socket ,这个新描述符专⽤于与该客⼾端进⾏

处理HTTP请求

  • 读取请求:
    • 使⽤ read() 函数从socket中读取数据。
    • buffer :定义为⼀个⾜够⼤的字符数组(缓冲区),⽤于临时存储从socket中读取的客⼾端数据。
    • BUFFER_SIZE :缓冲区⼤⼩,需要确保它⾜以容纳⼀个完整的HTTP请求头。
    • read() 函数第三个参数通常减去1,以保证在读取到的数据末尾添加结束符 ‘\0’。read() 函数将从指定的套接字中读取数据,并将其存⼊缓冲区 buffer 中。返回值bytes_received 表⽰实际读取到的字节数。根据读取到的数据内容,服务器可以解析出HTTP请求⽅法、URL、HTTP版本等信息,然后执⾏相应的服务逻辑。

发送HTTP响应

  • 响应⽅法:
    • 使⽤ write() 或 send() 函数向客⼾端发送响应。
    • response :响应内容,包括HTTP状态码和响应体。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#include <iostream>  // 引入标准输入输出库
#include <map> // 引入标准映射容器库
#include <functional>// 引入函数对象库,用于定义函数类型
#include <string> // 引入字符串处理库
#include <sys/socket.h> // 引入socket编程接口
#include <stdlib.h> // 引入标准库,用于通用工具函数
#include <netinet/in.h> // 引入网络字节序转换函数
#include <string.h> // 引入字符串处理函数
#include <unistd.h> // 引入UNIX标准函数库


// 测试命令 curl http://localhost:8080/register
#define PORT 8080 // 定义监听端口号为8080

using RequestHandler = std::function<std::string(const std::string&)>; // 定义请求处理函数类型

std::map<std::string, RequestHandler> route_table; // 定义路由表,映射路径到对应的处理函数

// 初始化路由表

void setupRoutes() {
// 根路径处理
route_table["/"] = [](const std::string& request) {
return "HelloWorld!";
};

// 注册处理
route_table["/register"] = [](const std::string& request) {
// TODO: 实现用户注册逻辑
return "RegisterSuccess!";
};

// 登录处理
route_table["/login"] = [](const std::string& request) {
// TODO: 实现用户登录逻辑
return "LoginSuccess!";
};

// TODO: 添加其他路径和处理函数
}

int main() {
int server_fd, new_socket; // 声明服务器的socket描述符和新客户端连接的socket描述符
struct sockaddr_in address; // 声明一个用于存储IPv4地址信息的结构体
int addrlen = sizeof(address); // 获取地址结构的长度,用于后续函数调用中

// 创建服务器端的socket
// AF_INET表示IPv4协议族,SOCK_STREAM表示使用TCP传输协议
// 返回的server_fd是服务器的socket描述符,用于监听和处理客户端连接
server_fd = socket(AF_INET, SOCK_STREAM, 0);

// 设置服务器地址结构体
address.sin_family = AF_INET; // 设置地址族为IPv4
address.sin_addr.s_addr = INADDR_ANY; // 服务器绑定到本地机器的所有可用网络接口
address.sin_port = htons(PORT); // 设置服务器端口号,使用htons确保端口号的字节序正确(主机字节序转换为网络字节序)

// 将服务器的socket绑定到指定的IP地址和端口号
// 这一步确保服务器监听在指定的网络接口上
bind(server_fd, (struct sockaddr *)&address, sizeof(address));

// 设置服务器的socket为监听模式
// 其中,第二个参数为等待连接队列的最大长度,表示最多有3个未处理连接请求可以排队等待
listen(server_fd, 3);

// 初始化路由表,用于定义不同的URI对应的处理函数
setupRoutes();

// 服务器主循环,持续运行以处理客户端连接
while (true) {
// 等待并接受来自客户端的连接请求
// 如果有客户端连接请求,accept会返回一个新的socket描述符,用于与该客户端通信
new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);

// 初始化缓冲区并读取客户端发送的数据
// 这里假设最大请求大小为1024字节
char buffer[1024] = {0};
read(new_socket, buffer, 1024); // 读取数据到buffer中
std::string request(buffer); // 将buffer转换为std::string便于处理

// 解析HTTP请求中的URI
// 从HTTP请求字符串中提取出请求行中的URI部分
// 假设HTTP请求的第一行格式为 "METHOD URI HTTP/1.1"
std::string uri = request.substr(request.find(" ") + 1); // 获取第一个空格后的内容
uri = uri.substr(0, uri.find(" ")); // 获取URI的结尾(即下一个空格之前的部分)

// 根据请求的URI在路由表中查找对应的处理函数,并生成响应内容
std::string response_body;
if (route_table.count(uri) > 0) { // 如果路由表中存在该URI
response_body = route_table[uri](request); // 调用相应的处理函数,并将返回值作为响应内容
} else {
response_body = "404 Not Found"; // 如果路由表中没有匹配的URI,返回404响应
}

// 构造HTTP响应,状态码为200 OK,内容类型为text/plain
std::string response = "HTTP/1.1 200 OK\nContent-Type: text/plain\n\n" + response_body;
// 通过socket发送响应给客户端
send(new_socket, response.c_str(), response.size(), 0);

// TODO: 实现多线程处理以提高并发性能,当前只能一次处理一个连接
// TODO: 添加日志系统以记录每个请求和响应,便于调试和分析
// TODO: 实现更完善的错误处理机制,当前代码没有处理很多可能的错误场景

// 处理完请求后关闭与客户端的连接,释放资源
close(new_socket);
}

return 0; // 程序结束,正常退出
}

编译和运⾏