不得不说,在底层操作系统方面,C/C++具有无比巨大的优势,如果学好了C++,相对来说对底层的了解也比别人更加具体,基于此,重新捡起C++还是有必要的。
书单
下面就分享下腾讯大佬们的书单,从C++到Go
实践
实现一个聊天服务器:
1.支持多人同时在线聊天
2.要求保存最近100条聊天记录
在写一个项目或作业的时候,强烈建议大家,先把流程图画出来,这样逻辑会更清晰,就算代码卡碟了,也可以通过流程图重新梳理一下思路,先骨架后血肉,基本套路。
环境
Centos7.5 + g++ + vim + gdb
技术点
聊天室分为服务端和客户端。采用C/S模型,使用TCP连接。 相关技术点:
1. 支持多个用户接入,实现多人聊天室。
2. 使用epoll机制实现并发,增加效率。
3. 使用fork创建两个进程,一个为父进程负责写入,一个为子进程负责读取。
4. 将聊天信息写到管道(pipe),并由父进程将信息写入管道,子进程将信息从管道读端读取。
5. 使用epoll机制接受服务端发来的信息,并广播信息给其他客户端。
运行
server
client1
client2
文件
build.sh # 脚本,用于生成.out文件和可执行文件
*.out # .out文件用于gdb调试
代码
build.sh
#!/bin/bash
g++ -o server server.cpp
g++ -o server.out server.cpp -g
g++ -o client client.cpp
g++ -o client.out client.cpp -g
common.hpp
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>
#include <list>
#include <string.h>
#include <string>
using namespace std;
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8000
#define EPOLL_SIZE 1024
#define SERVER_MESSAGE "ClientID #%d: \"%s\""
#define EPOLL_EVENT_NUM 2
#define EXIT "exit"
#define RECORDS_NUM 100 // 聊天记录的条数限制
list<int> clients_list; // 存放客户端socket
list<string> chat_records; // 存放聊天记录
void panic(const char* msg) {
perror(msg);
exit(EXIT_FAILURE);
}
// 将文件描述符添加到内核事件表中
void addfd_to_epoll(int fd, int epollfd) {
struct epoll_event ev;
// 将文件描述符添加到内核事件表中
ev.data.fd = fd;
// 注册 EPOLLIN事件(可读意味着有客户端通过这个socket文件描述符向服务端请求连接了,即有客户端写入了东西,那么服务端就可以读取到东西了),设置为ET 工作方式
ev.events = EPOLLIN | EPOLLET;
// 注册目标文件描述符到epfd中,同时关联内部event到文件描述符上
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev);
// 获取文件的flags,即open函数的第二个参数
int flags = fcntl(fd, F_GETFD, 0);
// 将文件描述符设置非阻塞方式
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// 广播
int broadcast(int clientfd) {
// BUFSIZ为系统默认的缓冲区大小(大小为8192)
char buf[BUFSIZ];
char message[BUFSIZ];
// 清空初始化,置字节字符串的前n个字节为零且包括‘\0’,之所以需要清空是因为在多线程或多进程环境中,为防止某个进程将其初始化的值影响到其他线程或进程对它的操作的结果
bzero(buf, BUFSIZ);
bzero(message, BUFSIZ);
// 从TCP连接的另一端接收数据, 返回接收到的字节数
int bytes = recv(clientfd, buf, BUFSIZ, 0);
if (bytes < 0) {
panic("recv failed");
}
if(bytes == 0) { // 另一端关闭了连接
close(clientfd);
clients_list.remove(clientfd);
} else {
// 当聊天记录达到阈值
if (chat_records.size() == RECORDS_NUM) {
chat_records.pop_front();
}
chat_records.push_back(buf);
// 对接受到的客户端消息进行拼接
sprintf(message, SERVER_MESSAGE, clientfd, buf);
for(list<int>::iterator it = clients_list.begin(); it != clients_list.end(); it++) {
// 将消息广播给其余客户端
if (*it != clientfd) {
// 将应用程序请求发送的数据拷贝到发送缓存中发送并得到确认后再返回
if (send(*it, message, BUFSIZ, 0) < 0) {
panic("send failed");
}
}
}
}
return bytes;
}
server.cpp
#include "common.hpp"
int main()
{
// 1、创建socket
int listener = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
// 创建失败会返回-1
if (listener < 0){
panic("creat socket failed");
}
// 设置套接字的属性使它能够在计算机重启的时候可以再次使用套接字的端口和IP
int sock_reuse = 1;
if (setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &sock_reuse, sizeof(sock_reuse)) < 0) {
panic("reuse socket failed");
}
// IPv4套接口地址结构,需要将主机字节序转换位网络字节序
struct sockaddr_in serverAddr;
serverAddr.sin_family = PF_INET; // 选择协议族
serverAddr.sin_port = htons(SERVER_PORT); // 将port转换为网络字节序
serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP); // 将ip转换为网络字节序
// 2、绑定socket
if (bind(listener, (struct sockaddr *) &serverAddr, sizeof(serverAddr)) < 0) { // 由于原始结构sockaddr已经废弃了,为了兼容需要将sockaddr_in进行强转
panic("bind failed");
}
// 3、监听
if (listen(listener, SOMAXCONN) < 0) { // SOMAXCONN定义了系统中每一个端口最大的监听队列的长度,这是个全局的参数,默认值为1024
panic("listen failed");
}
cout << "listening: " << SERVER_IP << " : " << SERVER_PORT << endl;
// 4、创建epoll句柄
int epfd = epoll_create(EPOLL_SIZE);
if (epfd < 0) {
panic("create epfd failed");
}
// 指定epoll事件监听的文件描述符的个数
struct epoll_event events[EPOLL_SIZE];
// 将socket文件描述符添加到内核事件表中
addfd_to_epoll(listener, epfd);
// 5、接收请求信息
while(1) {
// 等待事件的发生,返回就绪事件的数目。第二个参数指当检测到事件就会将所有就绪的事件从内核事件表中复制到其中;最后一个参数指定epoll的超时时间当为-1时,epoll_wait调用将永远阻塞,直到某个事件发生
int epoll_events_count = epoll_wait(epfd, events, EPOLL_SIZE, -1);
if (epoll_events_count < 0) {
panic("epoll_wait failed");
}
//处理已经就绪的事件
for (int i = 0; i < epoll_events_count; i++) {
// 从内核事件表的拷贝数组中获取其中的socket文件描述符
int sockfd = events[i].data.fd;
// 如果就绪事件的socket文件描述符与服务端的socket文件描述符相等,说明是新客户端的请求,因为每一个客户端的socket文件描述符都会被重新命名放入内核事件表里
if (sockfd == listener) {
// 客户端的IPv4套接口地址结构
struct sockaddr_in client_address;
// 提供缓冲区addr的长度以避免缓冲区溢出问题
socklen_t client_addr_length = sizeof(struct sockaddr_in);
// 接受连接,成功返回一个新的socket文件描述符,由于事件已经发生了,所以此处并不会阻塞
int clientfd = accept(listener, (struct sockaddr *) &client_address, &client_addr_length);
cout << "client connection from: " << inet_ntoa(client_address.sin_addr) << " : " << ntohs(client_address.sin_port) << endl;
// 将socket文件描述符添加到内核事件表中
addfd_to_epoll(clientfd, epfd);
// 将客户端加入到客户端socket链表中
clients_list.push_back(clientfd);
}
// 6、处理信息,进行广播
else {
int bytes = broadcast(sockfd);
if (bytes < 0) {
panic("broadcast failed");
}
}
}
}
// 7、关闭socket
close(listener);
// 8、关闭epoll
close(epfd);
return 0;
}
client.cpp
#include "common.hpp"
int main() {
// 1、创建socket
int clientfd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
// 创建失败会返回-1
if (clientfd < 0){
panic("creat socket failed");
}
// IPv4套接口地址结构,需要将主机字节序转换位网络字节序
struct sockaddr_in serverAddr;
serverAddr.sin_family = PF_INET; // 选择协议族
serverAddr.sin_port = htons(SERVER_PORT); // 将port转换为网络字节序
serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP); // 将ip转换为网络字节序
// 2、创建管道
int pipefd[2]; // 其中pipefd[0]是读端被子进程使用,pipefd[1]是写端被父进程使用,由于管道是利用环形队列实现的,数据从写端流入管道,从读端流出,这样就实现了进程间通信
if (pipe(pipefd) < 0) {
panic("create pipe failed");
}
// 3、连接服务端
if (connect(clientfd, (struct sockaddr *) &serverAddr, sizeof(serverAddr)) < 0) {
panic("connect failed");
}
// 4、创建epoll句柄
int epfd = epoll_create(EPOLL_SIZE);
if (epfd < 0) {
panic("create epfd failed");
}
// 指定epoll事件监听的文件描述符的个数
struct epoll_event events[EPOLL_EVENT_NUM];
//将socket和管道读端描述符都添加到内核事件表中
addfd_to_epoll(clientfd, epfd);
addfd_to_epoll(pipefd[0], epfd);
// 表示客户端是否正常工作
bool isClientwork = true;
// 聊天信息缓冲区
char message[BUFSIZ];
// 5、Fork一个子进程
// 这时候就相当于有两个几乎完全相同的进程在同时运行这行代码之后的代码,产生了分叉,因为这时候有两个进程,那么就需要根据不同进程返回不同的进程id,
// 返回两次,对于父进程来说,fork返回新创建子进程的进程ID;对于子进程来说,fork返回0,因为子进程并没有子进程,所以就可以理解为是子进程的子进程的进程ID是0
int pid = fork();
// 6、读写管道
// 由于有两个进程都在运行同样的逻辑,所以,就可以根据fork()返回的不同的pid来判断究竟是父进程还是子进程
if (pid < 0) {
panic("fork failed");
}
// 对于父进程来说,fork返回新创建子进程的进程ID,所以如果大于0就说明当前运行的进程是父进程
else if (pid > 0) {
// 父进程负责向管道中写入数据,所以父进程需要关闭管道读端
close(pipefd[0]);
cout << "please input 'exit' to exit the chat room" << endl;
cout << "--------------------------===---------------------------" << endl;
// 需要保证客户端还在正常运行
while (isClientwork) {
// 清空初始化,置字节字符串的前n个字节为零且包括‘\0’,之所以需要清空是因为在多线程或多进程环境中,为防止某个进程将其初始化的值影响到其他线程或进程对它的操作的结果
bzero(&message, BUFSIZ);
cout << "\n\n" ;
// 从stdin流读入键盘输入的信息
fgets(message, BUFSIZ, stdin);
// 客户端输入"exit"退出
if (strncasecmp(message, EXIT, strlen(EXIT)) == 0) { // 比较前N个字符,并忽略大小写差异
isClientwork = false;
} else { // 父进程将输入的信息写入管道中
if (write(pipefd[1], message, strlen(message) - 1) < 0) { // 将message里的信息写入到管道写端文件描述符里
panic("father progress write failed");
}
}
}
}
// 对于子进程来说,fork返回0,所以如果等于0就说明当前运行的进程是子进程
else if (pid == 0) {
// 子进程负责将管道中的数据读出,所以子进程需要关闭管道写端
close(pipefd[1]);
// 需要保证客户端还在正常运行
while (isClientwork) {
// 等待事件的发生,返回就绪事件的数目。第二个参数指当检测到事件就会将所有就绪的事件从内核事件表中复制到其中;最后一个参数指定epoll的超时时间当为-1时,epoll_wait调用将永远阻塞,直到某个事件发生
int epoll_events_count = epoll_wait(epfd, events, EPOLL_EVENT_NUM, -1);
if (epoll_events_count < 0) {
panic("epoll_wait failed");
}
//处理已经就绪的事件
for (int i = 0; i < epoll_events_count; i++) {
// 清空初始化,置字节字符串的前n个字节为零且包括‘\0’,之所以需要清空是因为在多线程或多进程环境中,为防止某个进程将其初始化的值影响到其他线程或进程对它的操作的结果
bzero(&message, BUFSIZ);
// 当监听到的事件为服务端socket文件描述符,说明是服务端发来的消息
if (events[i].data.fd == clientfd) {
// 从TCP连接的另一端接收数据, 返回接收到的字节数
int bytes = recv(clientfd, message, BUFSIZ, 0);
// 如果返回的字节数等于零,说明服务端关闭了
if (bytes == 0) {
cout << "server closed connection" << endl;
isClientwork = false;
goto CLOSE;
} else if (bytes > 0) {
// 将服务端发来的消息打印到终端
cout << message << endl;
}
}
// 当监听到的事件为管道读端文件描述符,说明是父进程写入数据到管道中,即客户端用户在终端输入了信息,想要发送消息到服务端了
else {
// 子进程从管道中读取数据,将管道读端文件描述符的数据取出放入message里
int bytes = read(events[i].data.fd, message, BUFSIZ);
if (bytes < 0) {
panic("child progress read failed");
} else if (bytes == 0) {
isClientwork = false;
}
else {
// 将消息发送给服务端
send(clientfd, message, BUFSIZ, 0);
}
}
}
}
}
CLOSE:
// 7、关闭文件描述符
if (pid) {
// 关闭写端文件描述符
close(pipefd[1]);
// 关闭socket文件描述符
close(clientfd);
} else {
//关闭读端文件描述符
close(pipefd[0]);
}
// 关闭epoll
close(epfd);
return 0;
}
看了一些代码,发现注释挺少,对不熟悉c++的人来说还是很难看懂,于是我加了很多注释,这样对于初学者来说会更友好,也更方便去学习Linux下的网络编程。
参考
https://www.zhihu.com/question/20114168
https://www.cnblogs.com/Kobe10/p/5691601.html
https://www.cppentry.com/bencandy.php?fid=45&aid=217010&page=3
https://github.com/LinHaoo/chat
https://www.cnblogs.com/DOMLX/p/9613027.html
http://c.biancheng.net/cpp/html/3032.html
https://my.oschina.net/lmoon/blog/894649
https://blog.csdn.net/qq_42914528/article/details/82023408