C++ - 在windows环境下,基于面向对象、网络编程和并发编程封装一个服务器框架
小白劝退预告
- 仅简单介绍思路,没有用作教程的打算,如果读者没有C++编程基础、计算机网络基础、网络编程基础或并发编程基础 —— 会很不友好的(
Web服务器概述
Web服务器概念
服务器是一种软件应用程序,用于处理客户端发送的HTTP请求并向客户端发送对应的响应。它有时通过使用网络协议(如HTTP)来传输和交换Web资源(如HTML文档、图像、视频等)
服务器是当代互联网不可分割的一部分,倘若没有服务器,便无谈上网冲浪; 每次你打开一个网页,背后的Web服务器时时刻刻都在处理你的请求,并发送给你需要的文本数据和图片; 当你打游戏时,科技公司的服务器在马不停蹄地为你计算数据
Web服务器的代码思路
在C++中实现一个简单的服务器,可以按照以下思路进行:
- 创建服务器套接字:使用C++的网络编程库,如Linux环境下的 <sys/socket.h> 和 <netinet/in.h> 等、windows环境下载Wsa等,创建一个服务器套接字
- 绑定服务器地址和端口:将服务器套接字绑定到指定的IP地址和端口上,以便监听客户端请求
- 监听连接请求:使用listen函数开始监听客户端连接请求,设置最大连接数
- 接受连接请求:使用accept函数接受客户端的连接请求,返回一个新的套接字,用于与客户端进行通信
上述操作能实现一个联网的简单服务器;本文旨在编写一个简单的服务器框架
C++ 实现多线程服务器
以下内容涉及到C++的面向对象编程、网络编程和并发编程
实现基本联网功能
定义Basic_server对象
我们可以采用过程化的语言来编写我们的服务器,在这里我采用面向对象的思路来编写
我们先定义一个Basic_server类,实现最基本的联网功能
现在分别解释说明Basic_server对象里各成员的作用:
- isopen 是一个布尔值,指示了当前服务器的运行状态,开启时为true,关闭时为false
- sock 是这个服务器内置的一个套接字
- port 是指定了这个服务器进程占用的端口号
- task 是一个函数指针,它接受一个SOCKET对象作为参数,当服务器运行时,交互逻辑实现于task所指向的函数对象
- void init() 是一个初始化函数,配置了网络编程所需要的资源
- SOCKET get_socket() 是一个安全的获取socket的函数,若获取失败,则会关闭服务器(设置isopen为false)
- virtual sockaddr_in get_addr() 是一个安全的获取sockaddr_in的函数,定义为纯虚函数,后续继承时重写
- Basic_server() 构造函数,用于初始化
- ~Basic_server() 析构函数,销毁资源
- void bind_task(void(*_task)(SOCKET sock)) 绑定任务的函数,用于修改task指针指向的函数地址
- virtual void run() 让服务器运行的函数,定义为纯虚函数,后续继承时重写
- void close() 关闭服务器的函数,并设置isopen为false
- is_open() 返回当前服务器运行状态的函数
Basic_server.h 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Basic_server{
protected:
bool isopen;
SOCKET sock;
int port;
void (*task)(SOCKET sock) = nullptr;
void init();
SOCKET get_socket();
virtual sockaddr_in get_addr()=0;
public:
Basic_server(int port);
~Basic_server();
void bind_task(void(*_task)(SOCKET sock));
virtual void run() = 0;
void close();
bool is_open();
};
Basic_server的构造函数
首先我们这个服务器是确定在Windows环境编写的,最终这个程序也只能运行在Windows环境,换到别的地方(比如Linux环境)就无法运行 —— 因为Linux不支持Windows提供的系统API
当然,现实生活中的Web服务器大多数都是运行在Linux环境里的,等以后有时间再来做修改伐~
先实现init()函数,配置好对应的网络编程环境资源
Basic_server.cpp 1
2
3
4
5
6
7
8
9
10
11
12
13
void Basic_server::init() {
WORD sockVersion = MAKEWORD(2, 2);
WSADATA wsaData;
if (WSAStartup(sockVersion, &wsaData) != 0) {
cout << "WSAStartup() error!" << endl;
close();
}
return;
}实现get_socket()函数
Basic_server.cpp 1
2
3
4
5
6
7
8
9
10
11SOCKET Basic_server::get_socket() {
if (!isopen)
return SOCKET();
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) {
cout << "socket error !" << endl;
close();
}
return sock;
}最终实现Basic_server()构造函数
Basic_server.cpp 1
2
3
4
5
6
7Basic_server::Basic_server(int _port){
init();
isopen = true;
port = _port;
sock = get_socket();
return;
}
Basic_server的析构函数
析构函数主要有以下的作用:
- 关闭服务器,若服务器已经因为故障而中途关闭,则无需再次关闭
- 销毁创建的Windows网络编程环境
先实现close()函数,帮我们关闭服务器
Basic_server.cpp 1
2
3
4
5
6void Basic_server::close() {
isopen = false;
closesocket(sock);
WSACleanup();
return;
}最后实现析构函数
Basic_server.cpp 1
2
3
4
5Basic_server::~Basic_server(){
if (isopen)
close();
return;
}
Basic_server的工具函数
工具函数主要就是提供辅助功能的函数,大多用于修改或获取类的对象
实现bind_task(void(*_task)(SOCKET sock))函数
Basic_server 1
2
3
4void Basic_server::bind_task(void(*_task)(SOCKET sock)){
task = _task;
return;
}实现is_open()函数
Basic_server 1
2
3bool Basic_server::is_open(){
return isopen;
}
实现基本并发功能
定义 Server 对象
Server 对象继承自 Basic_server,在其基本的联网功能上再附带了端口监听和多线程处理任务的功能,能实现基本并发服务器的功能
现在分别解释说明 Basic_server 对象里各成员的作用:
- listen_num 定义了该服务器最大的同时监听数量
- sockaddr_in addr 定了该服务器进程绑定的ip和端口
- sockaddr_in get_addr() 一个安全获取sockaddr_in对象的函数,若故障则服务器关闭
- void bind_socket() 将sock成员与addr成员绑定,若故障则服务器关闭
- static void do_accept(void* ptr) 服务器的接收请求、并处理请求的函数,因多线程的特殊性,定义为静态成员函数,为了能访问到类成员,需要传入一个this指针
- Server(int port, int num) 构造函数,同时完成套接字与通信地址(ip与端口)的绑定
- ~Server() 析构函数,释放资源
- void run() 让服务器最终运行的函数
Server.h 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Server : public Basic_server {
private:
int listen_num;
sockaddr_in addr;
protected:
sockaddr_in get_addr();
void bind_socket();
void listen_socket();
static void do_accept(void* ptr);
public:
Server(int port, int num);
~Server();
void run();
};
Server的构造函数
先实现sockaddr_in get_addr()函数,它依据端口号返回一个通信地址
Server.cpp 1
2
3
4
5
6
7
8
9
10sockaddr_in Server::get_addr() {
if (!isopen)
return sockaddr_in();
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.S_un.S_addr = ADDR_ANY;
return addr;
}再实现void bind_socket()函数,它负责将套接字与通信地址绑定
Server.h 1
2
3
4
5
6
7
8
9
10void Server::bind_socket() {
if (!isopen)
return;
if (bind(sock, (LPSOCKADDR)&addr, sizeof(addr)) == SOCKET_ERROR) {
printf("bind error.\n");
close();
}
return;
}最后实现Server(int port, int num)构造函数
Server.cpp 1
2
3
4
5
6Server::Server(int _port, int num) :Basic_server(_port) {
listen_num = num;
addr = get_addr();
bind_socket();
return;
}
Server的析构函数
- 非常简单粗暴的析构函数
Server.cpp 1
2
3Server::~Server() {
return;
}
Server的运行函数
实现void listen_socket(),开始监听端口
Server.cpp 1
2
3
4
5
6
7
8
9
10void Server::listen_socket() {
if (!isopen)
return;
if (listen(sock, listen_num) == SOCKET_ERROR) {
printf("listen error.\n");
close();
}
return;
}static void do_accept(void* ptr),用于执行监听的交互处理
交互处理的核心在于task指针所指向的函数地址Server.cpp 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20void Server::do_accept(void* ptr){
Server* server = (Server*)ptr;
while (server->isopen) {
SOCKET cli;
sockaddr_in cli_addr;
int addr_len = sizeof(cli_addr);
printf("accept.\n");
cli = accept(server->sock, (sockaddr*)&cli_addr, &addr_len);
if (cli == INVALID_SOCKET) {
printf("accept error.\n");
continue;
}
printf("connect to %s.\n", inet_ntoa(cli_addr.sin_addr));
server->task(cli);
printf("connect close.\n");
}
}void run(),封装了所有的服务器运行处理
在这个函数中,我们先对端口监听,并开辟了128个子线程,用来处理服务器与外界的交互
对于线程的处理,我们可以修改成动态线程池,能大大优化服务器的性能,后续有空再做伐~Server.cpp 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Server::run() {
listen_socket();
if (!isopen || task == nullptr) {
printf("no task to excuate.\n");
return;
}
thread ths[128];
for (auto& th : ths)
th = move(thread(do_accept, this));
for (auto& th : ths)
th.join();
return;
}
服务器检测
实现Echo服务器
至此,我们已经实现了一个并发服务器的框架,这个服务器的框架已经就绪,使用起来也极其方便:你只需提前写好一个task函数,将服务器对象与这个函数绑定即可。接下来,我们就依照我们写好的服务器框架来实现一个Echo服务器,用于我们框架的检测
Echo服务器又称复读服务器,即客户端发送什么内容给服务器,服务器就返回什么内容给客户端
编写task函数
main.cpp 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17void task_echo(SOCKET sock) {
while (true) {
char send_buf[255], recv_buf[255];
int len = recv(sock, recv_buf, 255, 0);
if (len > 0)
printf("Client: %s\n", recv_buf);
else {
printf("Client close.\n");
break;
}
strcpy(send_buf, recv_buf);
send(sock, send_buf, 255, 0);
printf("Server: %s\n", send_buf);
}
return;
}在主程序中创建Server对象,绑定task并运行
main.cpp 1
2
3
4
5
6
7
8int main() {
Server server(8888, 128);
server.bind_task(task_echo);
server.run();
server.close();
return 0;
}由此,你会发现我们采用面向对象的思想封装服务器对象的简洁性、以及使用时的高效性
依靠API即可便捷运行,这就是OOP!(虽然编写底层代码时很要命/bushi)
编写客户端程序
- 依旧采用OOP的思想来编写客户端,定义一个Client对象继承自Basic_server
下面依次解释说明每个类成员的含义和作用:
- ip 存储了客户端连接的服务器ip,采用字符串存储
- addr 定义了客户端要连接的通信地址
- sockaddr_in get_addr() 获取通信地址的工具函数
- client_connect() 让客户端与服务器连接
- Client(const char* ip, int port) 构造函数
- ~Client() 析构函数
- void run() 让客户端开始运行的函数
Client.h 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Client : public Basic_server {
private:
char ip[20];
sockaddr_in addr;
protected:
sockaddr_in get_addr();
void client_connect();
public:
Client(const char* ip, int port);
~Client();
void run();
};
接下来是客户端的代码实现,这里就不过多解释了
Client.cpp 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
50void Client::client_connect() {
if (!isopen)
return;
int ret = connect(sock, (struct sockaddr*)&addr, sizeof(addr));
printf("connect to %s:%d ", ip, port);
if (ret == -1) {
isopen = false;
printf("error.\n");
}
else
printf("success.\n");
return;
}
sockaddr_in Client::get_addr() {
if (!isopen)
return sockaddr_in();
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.S_un.S_addr = inet_addr(ip);
return addr;
}
Client::Client(const char* _ip, int _port) :Basic_server(_port) {
strcpy(ip, _ip);
addr = get_addr();
return;
}
Client::~Client() {
return;
}
void Client::run() {
client_connect();
if (!isopen || task == nullptr) {
printf("no task to excuate.\n");
return;
}
task(sock);
printf("connect close.\n");
return;
}与Server对象类似,Client提供了一个框架,具体的交互功能需要开发者自己去编写task函数并绑定后才可运行
编写task函数,实现Echo客户端
Client.cpp 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
30void task_echo(SOCKET sock) {
while (true) {
char send_buf[255], recv_buf[255];
while(cin.getline(send_buf, 255))
if(strlen(send_buff) > 0)
break;
send(sock, send_buf, sizeof(send_buf), 0)
printf("Client: %s\n", send_buf);
int len = recv(sock, recv_buf, 255, 0)
if(len > 0){
printf("Server: %s\n", recv_buf);
}
else{
printf("connect loss.\n");
break;
}
}
printf("Client close.\n");
return;
}
int main(){
Client client("127.0.0.1", 8888);
client.run();
client.close();
return 0;
}
检测
- 单个客户端连接检测
- 运行我们的服务器程序,可以看到它正在监听
- 运行我们的客户端程序,发现连接成功
- 在客户端发送任意消息,发现服务器可以正常地返还相同的数据
至此,检测成功
- 多个客户端连接并发检测
- 操作过程与 (1) 类似,不过同时打开很多个窗口
- 发现每个客户端都可以单独与服务器交互,服务器也可以同时与多个客户端联通
至此,检测成功
附录代码
基本并发服务器框架
采用多文件编写,两个头文件Basic_server.h、Tokis,h,两个cpp文件Basic_server、Tokis.cpp
1 |
|
1 |
|
1 |
|
1 |
|
Echo服务器和客户端
请确保你已经提前保存了上文基本并发服务器框架的代码
采用多文件编写,两个cpp文件server.cpp、client.cpp
1 |
|
1 |
|