github仓库:多人聊天室smallChatRoom
前言
由于Linux系统采用了Socket套接字,Socket套接字接口被广泛使用。与套接字相关的函数被包含在头文件sys/socket.h中。使用套接字接口可以完成网络通信。
epoll全称eventpoll,是linux内核实现IO多路转接/复用(IO multiplexing)的一个实现。epoll是select和poll的升级版,相较于slecet和poll是基于线性方式处理的,epoll通过基于红黑树来管理待检测集合等工作方式,提高工作效率。
本次实验通过学习网络编程相关知识,使用Socket API、Select、Epoll、线程池等相关知识,探究高性能处理服务器,基于此完成一个多人聊天室(smallChatRoom)程序,支持用户自定义昵称进行实时聊天室通信。
Socket网络编程
字节序
由于主机字节序(小端)与网络字节序(大端)的区别,因此,在使用PC机的时候,数据的存储默认使用的是小端,而在套接字通信过程中的数据都是大端存储的,包括:接受/发送的数据、IP地址、端口信息。
// 这套api主要用于 网络通信过程中 IP 和 端口 的 转换
uint16_t htons(uint16_t hostshort); // 将一个短整形从主机字节序 -> 网络字节序
uint32_t htonl(uint32_t hostlong); // 将一个整形从主机字节序 -> 网络字节序
uint16_t ntohs(uint16_t netshort) // 将一个短整形从网络字节序 -> 主机字节序
uint32_t ntohl(uint32_t netlong); // 将一个整形从网络字节序 -> 主机字节序
// 将大端的整形数, 转换为小端的点分十进制的IP地址
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
// 主机字节序的IP地址是字符串, 网络字节序IP地址是整形
//af: 地址族协议 AF_INET: ipv4格式的ip地 AF_INET6: ipv6格式的ip地址
int inet_pton(int af, const char *src, void *dst);
sockadder数据结构
// 在写数据的时候不好用,写数据时使用sockaddr_in数据结构,传输时强制类型转换为sockaddr
struct sockaddr {
sa_family_t sa_family; // 地址族协议, ipv4
char sa_data[14]; // 端口(2字节) + IP地址(4字节) + 填充(8字节)
}
struct sockaddr_in
{
sa_family_t sin_family; /* 地址族协议: AF_INET */
in_port_t sin_port; /* 端口, 2字节-> 大端 */
struct in_addr sin_addr; /* IP地址, 4字节 -> 大端 */
/* 填充 8字节 */
unsigned char sin_zero[sizeof (struct sockaddr) - sizeof(sin_family) -
sizeof (in_port_t) - sizeof (struct in_addr)];
};
套接字函数
使用套接字通信函数包含头文件<arpa/inet.h>,此头文件包含了<sys/socket.h>就不用包含了。
服务器端建立通信流程:创建一个套接字->绑定IP和端口->设置监听->接受客户端请求->收发数据
客户端建立通信流程:创建套接字->链接服务器->收发数据
// 创建一个套接字
int socket(int domain, int type, int protocol);
// 将文件描述符和本地的IP与端口进行绑定
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 给监听的套接字设置监听
int listen(int sockfd, int backlog);
// 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 接收数据
ssize_t read(int sockfd, void *buf, size_t size);
ssize_t recv(int sockfd, void *buf, size_t size, int flags);
// 发送数据的函数
ssize_t write(int fd, const void *buf, size_t len);
ssize_t send(int fd, const void *buf, size_t len, int flags);
// 成功连接服务器之后, 客户端会自动随机绑定一个端口
// 服务器端调用accept()的函数, 第二个参数存储的就是客户端的IP和端口信息
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
IO多路复用:Epoll
Epoll是 linux 内核实现IO多路转接/复用(IO multiplexing)的一个实现。有如下优点:
- 对于待检测集合select和poll是基于线性方式处理的,epoll是基于红黑树来管理待检测集合的。
- select和poll每次都会线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降
- select和poll工作过程中存在内核/用户空间数据的频繁拷贝问题,在epoll中内核和用户区使用的是共享内存(基于mmap内存映射区实现),省去了不必要的内存拷贝。
- 程序猿需要对select和poll返回的集合进行判断才能知道哪些文件描述符是就绪的,通过epoll可以直接得到已就绪的文件描述符集合,无需再次检测
- 使用epoll没有最大文件描述符的限制,仅受系统中进程能打开的最大文件数目限制
操作函数
在epoll中一共提供是三个API函数,分别处理不同的操作,epoll管理红黑树上的各个节点,检测各个节点的事件,函数原型如下:
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
// 联合体, 多个变量共用同一块内存
typedef union epoll_data {
void *ptr;
int fd; // 通常情况下使用这个成员, 和epoll_ctl的第三个参数相同即可
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
工作模式
水平模式
水平模式可以简称为LT模式,LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。
水平模式特点:
- 读事件:如果文件描述符对应的读缓冲区还有数据,读事件就会被触发,epoll_wait()解除阻塞
– 如果接收数据的buf很小,不能全部将缓冲区数据读出,那么读事件会继续被触发,直到数据被全部读出,如果接收数据的内存相对较大,读数据的效率也会相对较高(减少了读数据的次数)
– 因为读数据是被动的,必须要通过读事件才能知道有数据到达了,因此对于读事件的检测是必须的 - 写事件:如果文件描述符对应的写缓冲区可写,写事件就会被触发,epoll_wait()解除阻塞
–因为写数据是主动的,并且写缓冲区一般情况下都是可写的(缓冲区不满),因此对于写事件的检测不是必须的
–写事件的触发发生在写数据之前而不是之后,被写入到写缓冲区中的数据是由内核自动发送出去的
边沿模式
边沿模式可以简称为ET模式,ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当文件描述符从未就绪变为就绪时,内核会通过epoll通知使用者。然后它会假设使用者知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知(only once)。如果我们对这个文件描述符做IO操作,从而导致它再次变成未就绪,当这个未就绪的文件描述符再次变成就绪状态,内核会再次进行通知,并且还是只通知一次。ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
边沿模式的设置
边沿模式不是默认的epoll模式,需要额外进行设置。epoll设置边沿模式是非常简单的,epoll管理的红黑树示例中每个节点都是struct epoll_event类型,只需要将EPOLLET添加到结构体的events成员中即可
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 设置边沿模式
设置非阻塞
如果使用epoll的边沿模式进行读事件的检测,有新数据达到只会通知一次,那么必须要保证得到通知后将数据全部从读缓冲区中读出,此时就要设置循环将读缓冲区数据全部读出,而循环读缓冲区会造成如下影响:套接字读操作会导致堵塞问题,当前线程/其他线程会受其影响,以及当数据读完时套接字操作会返回 -1导致程序失败,此时需要设置格外检测进行处理。
// 设置完成之后, 读写都变成了非阻塞模式
int flag = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(cfd, F_SETFL, flag);
// 非阻塞模式下recv() / read()函数返回值 len == -1
int len = recv(curfd, buf, sizeof(buf), 0);
if(len == -1){
if(errno == EAGAIN){
printf("数据读完了...\n");
}
else{...}
}
专题实验:网络并发处理-多人聊天室(smallChatRoom)
功能介绍
- 服务器端
–使用Socket API进行TCP流式传输
– 使用epoll进行I/O多路复用
– 使用线程池管理任务队列进行多线程处理信息(pthread)
– 使用JSONCpp进行数据的序列化与反序列化
– 使用消息队列管理群发信息同一发送 - 客户端
– 使用select进行I/O多路复用(监控TCP链接与控制台输入流)
– 使用JSONCpp进行数据的序列化与反序列化
– 用户自行添加昵称进行聊天
服务器端搭建
套接字通信
在初始化过程中,使用socket API函数建立套接字通信,设置监听,同时创建线程池、Epoll实例、消息队列实例,线程池中添加epoll监听任务以及消息队列消息处理任务。服务器主要工作为监听epoll实例(epollController),通过监听接受客户端发来的连接请求建立连接(acceptConnection),接受客户端发来的消息并进行分类:设置昵称(acceptMessage)及群发消息(messageHandler)两个信息进行分别处理。同时在此中建立文件描述符与用户昵称之间的哈希表映射,维护在线用户的昵称与文件描述符。
创建socketEpoll实例
SocketEpoll::SocketEpoll() {
// 创建套接字
fd = socket(AF_INET,SOCK_STREAM,0);
if(fd == -1) {
perror("socket");
exit(0);
}
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
// 绑定接口
int ret = bind(fd,(sockaddr*)&addr,sizeof(addr));
if(ret == -1) {
perror("bind");
exit(0);
}
// 设置监听
ret = listen(fd,128);
if(ret == -1) {
perror("listen");
exit(0);
}
// 初始化epoll
int epfd = epoll_create(128); //2.6.8内核版本后此值被忽略,大于0即可
if(epfd == -1){
perror("epoll_create");
exit(0);
}
int flag = fcntl(fd,F_GETFL);
flag |= O_NONBLOCK;
fcntl(fd,F_SETFL,flag);
// 添加epoll事件
epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = fd;
ret = epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev);
if(ret == -1) {
perror("epoll_ctl");
exit(0);
}
// 创建线程池
PthreadPool* pool = new PthreadPool(3,10);
// 创建消息队列
MessageQueue* msgQ = new MessageQueue();
auto* info = (ConnInfo*)malloc(sizeof(ConnInfo));
info->pool = pool;
info->msgQ = msgQ;
info->fd = fd;
info->epfd = epfd;
Task task;
task.function = epollController;
task.arg = info;
// epoll监控文件描述符
pool->TaskAdd(task);
task.function = messageHandler;
task.arg = info;
// 处理接受来的信息
pool->TaskAdd(task);
pthread_exit(nullptr);
监听epoll实例
void SocketEpoll::epollController(void *arg) {
auto *info = (ConnInfo*)(arg);
int fd = info->fd;
int epfd = info->epfd;
PthreadPool *pool = info->pool;
epoll_event evs[1024];
int size = sizeof(evs) / sizeof(epoll_event);
Task task;
while(true) {
int num = epoll_wait(epfd,evs,size,-1);
for(int i = 0;i<num;++i){
int curfd = evs[i].data.fd;
if(curfd == fd) {
info->fd = curfd;
task.function = acceptConnection;
task.arg = info;
pool->TaskAdd(task);
}else{
info->fd = curfd;
task.function = acceptMessage;
task.arg = info;
pool->TaskAdd(task);
}
}
}
}
处理客户端链接请求
void SocketEpoll::acceptConnection(void *arg) {
printf("accept\n");
auto* info = (ConnInfo*)(arg);
int fd = info->fd;
int epfd = info->epfd;
PthreadPool* pool = info->pool;
epoll_event ev;
sockaddr_in caddr;
int len = sizeof(caddr);
int cfd = accept(fd, (sockaddr*)&caddr, reinterpret_cast<socklen_t *>(&len));
if(cfd == -1) {
perror("accept");
exit(0);
}
char IP[24] = {0};
printf("client information : ");
printf(" IP : %s , PORT : %d\n",
inet_ntop(AF_INET,&caddr.sin_addr.s_addr,IP,sizeof(IP)),
ntohs(caddr.sin_port));
// 设置文件描述符为非阻塞模式,得到文件描述符的属性
int flag = fcntl(cfd,F_GETFL);
flag |= O_NONBLOCK;
fcntl(cfd,F_SETFL,flag);
// 新得到的文件描述符添加到epoll模型中,下一轮循环的时候可以被检测
// 通信的文件描述符艰涩赌缓冲区数据的时候设置为边沿触发模式
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = cfd;
int ret = epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&ev);
if(ret == -1){
perror("epoll_ctl-accept");
exit(0);
}
}
处理客户端信息请求
void SocketEpoll::acceptMessage(void *arg) {
auto info = (ConnInfo*)(arg);
int cfd = info->fd;
int epfd = info->epfd;
auto msgQ = info->msgQ;
char buf[MAXMESSAGESIZE];
int len = recv(cfd,buf,sizeof(buf),0);
if(len > 0) {
Message msg;
msg = msg.deserialization(buf);
// 接受信息 处理昵称问题
if(msg.getType() == setNickname){
std::string nickname = msg.getFromName();
if(nicknames.count(nickname) == 0){
printf("fd : %d , nickname : %s \n",cfd,nickname.c_str());
users.insert({cfd,nickname});
nicknames.insert(nickname);
msg.setType(setNicknameSuccess);
std::string str = msg.serialization();
len = send(cfd,str.c_str(),strlen(str.c_str()),0);
}else{
msg.setType(setNicknameFail);
std::string str = msg.serialization();
len = send(cfd,str.c_str(),strlen(str.c_str()),0);
}
if(len<0){
perror("send");
exit(0);
}
}else{
// 将群发消息插入消息队列
msgQ->AddMsg(msg);
}
}
else if(len == 0){
printf("client close the connection!\n");
// 将此文件描述符从epoll中删除
epoll_ctl(epfd,EPOLL_CTL_DEL,cfd, nullptr);
// 将此用户用哈希表中删除
std::string nickname = users[cfd];
users.erase(users.find(cfd));
nicknames.erase(nickname);
close(cfd);
}else if(len < 0){
if(errno == EAGAIN){
printf("data read complete ...\n");
}else {
perror("recv");
exit(0);
}
}
}
处理群发信息队列
void SocketEpoll::messageHandler(void *arg) {
auto info = (ConnInfo*)(arg);
auto msgQ = info->msgQ;
while(true){
Message message = msgQ->getMsg();
std::string nickname = message.getFromName();
std::string from = message.getFromName();
std::string to = message.getToName();
std::string msg = message.getMsg() + "\n";
for(auto user : users){
if(user.second != from){
Message mm(sendMsg,from,user.second,msg);
std::string str = mm.serialization();
int len = send(user.first,str.c_str(),strlen(str.c_str()),0);
if(len < 0){
perror("send");
}
}
}
}
}
线程池实现
线程池维护一个工作函数队列,线程池中存在多个工作线程以及一个管理者线程,在线程池初始化过程中,会初始化最小数量的工作线程,工作线程在工作函数队列存在任务时执行此任务,管理者线程动态维护工作线程,时刻关注工作线程数量与任务数量之间的关系,如果工作线程过多将减少工作线程数量,如果数量过少会适量增加工作线程数量。
// 工作线程
void * PthreadPool::Work(void *arg) {
auto* pool = (PthreadPool*)(arg);
// printf("\n\n%d\n\n",pool->taskQueue->taskNum());
while(true){
pthread_mutex_lock(&pool->poolMutex);
while(pool->taskQueue->taskNum() == 0 && pool->shutdown == 0) {
printf("thread %ld is waiting ... \n" ,pthread_self());
// 被阻塞时自动解锁,被唤醒加锁
pthread_cond_wait(&pool->poolCondition,&pool->poolMutex);
// 被唤醒后,如果管理者进程管理需要进行退出
if(pool->exitNumber > 0) {
pool->exitNumber--;
if(pool->aliveNumber > pool->minNumber){
pool->aliveNumber--;
pthread_mutex_unlock(&pool->poolMutex);
pool->ExitThread();
}
}
}
if(pool->shutdown){
pthread_mutex_unlock(&pool->poolMutex);
pool->ExitThread();
}
Task task = pool->taskQueue->TakeTask();
pool->busyNumber++;
pthread_mutex_unlock(&pool->poolMutex);
task.function(task.arg);
// 释放内存
task.arg = nullptr;
printf("thread %ld complete the task ... \n",pthread_self());
pthread_mutex_lock(&pool->poolMutex);
pool->busyNumber--;
pthread_mutex_unlock(&pool->poolMutex);
}
return nullptr;
}
// 管理者线程
void * PthreadPool::Manage(void *arg) {
PthreadPool* pool = (PthreadPool*)(arg);
while(!pool->shutdown) {
// printf("mange...\n");
sleep(5);
pthread_mutex_lock(&pool->poolMutex);
int qSize = pool->taskQueue->taskNum();
int alive = pool->aliveNumber;
int busy = pool->busyNumber;
pthread_mutex_unlock(&pool->poolMutex);
// 当前线程无法处理更多任务时
int NUMBER = MANAGERNUMBER;
if(alive < qSize && alive < pool->maxNumber) {
pthread_mutex_lock(&pool->poolMutex);
while(pool->aliveNumber<pool->maxNumber && NUMBER--){
pthread_t id = 0;
pthread_create(&id, nullptr,Work,pool);
pool->workIds.push_back(id);
pool->aliveNumber++;
}
pthread_mutex_unlock(&pool->poolMutex);
}
// 当前线程过多时
NUMBER = MANAGERNUMBER;
if(busy*2 < qSize && alive > pool->minNumber) {
pthread_mutex_lock(&pool->poolMutex);
pool->exitNumber = NUMBER;
for (int i = 0; i < NUMBER; ++i) {
pthread_cond_signal(&pool->poolCondition);
}
pthread_mutex_unlock(&pool->poolMutex);
}
}
return nullptr;
}
客户端
客户端通过socket API函数与服务器端建立连接,由于需要同时监控与服务器建立连接的文件描述符以及控制台输入文件描述符,又因为只需监控两个文件描述符,故客户端使用select进行IO多路复用,控制台输入在获取输入后进行数据处理,将数据打包并序列化发送到服务器端;客户端在接收到来自客户端的消息后,进行相应的数据处理。
int main () {
int fd = socket(AF_INET,SOCK_STREAM,0);
if(fd == 0){
perror("socket");
exit(0);
}
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
inet_pton(AF_INET,"114.214.187.217",&addr.sin_addr.s_addr);
int ret = connect(fd, reinterpret_cast<const sockaddr *>(&addr), sizeof(addr));
if(ret == -1) {
perror("connect");
exit(0);
}
int msgfd = fileno(stdin);
// 使用 select 进行调试
int maxfd = fd;
fd_set rdset;
fd_set rdtemp;
FD_ZERO(&rdset);
FD_SET(fd,&rdset);
FD_SET(msgfd,&rdset);
std::string nickname;
bool isNickname = false;
static char m[1024] = "Welcome to the chat room , please enter your nickname ,then you can enter the message! \nyour nickname :";
write(fileno(stdout),m,sizeof(m));
// printf("Welcome to the chat room , please enter your nickname ,then you can enter the message! \nyour nickname : ");
char buf[MAXMESSAGESIZE];
while(true){
rdtemp = rdset;
int num = select(maxfd+1,&rdtemp, nullptr, nullptr, nullptr);
if(num == -1){
perror("select");
exit(0);
}else if(num) {
if(FD_ISSET(fd,&rdtemp)){
// 接受服务器端信息
memset(buf,0,sizeof(buf));
int len = read(fd,buf,sizeof(buf));
if(len == 0) {
printf("server close the connection!\n");
exit(0);
}else if(len > 0){
// printf("%s",buf);
Message msg;
msg = msg.deserialization(buf);
if(msg.getType() == setNicknameFail){
char logInfo[MAXMESSAGESIZE] = "Same nickname,Please enter again! your nickname:";
write(fileno(stdout),logInfo, strlen(logInfo));
isNickname = false;
}else if(msg.getType() == sendMsg){
std::string info = msg.printMessage();
int length = info.length();
char mm[length+1];
strcpy(mm,info.c_str());
write(fileno(stdout),mm,sizeof(mm));
}else if(msg.getType() == setNicknameSuccess){
nickname = msg.getMsg();
}
}else {
perror("read");
exit(0);
}
}else if(FD_ISSET(msgfd,&rdtemp)){
// 用户输入信息
memset(buf,0,sizeof(buf));
int len = read(msgfd,buf,sizeof(buf));
char *msg = (char*)malloc(sizeof(char)*MAXMESSAGESIZE);
int index = 0;
for(int i = 0;i<len;i++){
if(buf[i]=='\n'){
continue;
}else if(buf[i]=='\r'){
break;
}else {
msg[index++] = buf[i];
}
}
msg[index] = '\0';
if(!isNickname) {
// char tmp[MAXMESSAGESIZE];
// nickname = std::string(msg);
// sprintf(tmp,"%s%s",NICKNAMEFLAG,msg);
// sprintf(msg,"%s",tmp);
isNickname = !isNickname;
Message message(setNickname,msg,"host",msg);
std::string strMessage = message.serialization();
send(fd,strMessage.c_str(),strlen(strMessage.c_str()),0);
continue;
}
Message message(sendMsg,nickname,"host",msg);
std::string strMessage = message.serialization();
cleanCurAndUpLine();
std::string nowTime = message.getTime();
int length = nowTime.length()+1;
char tt[length+1];
strcpy(tt,nowTime.c_str());
write(fileno(stdout),"[", 1);
write(fileno(stdout),tt, strlen(tt));
write(fileno(stdout),"] you> ",8);
write(fileno(stdout),msg,strlen(msg));
write(fileno(stdout),"\n",1);
length = strMessage.length()+1;
char mm[length+1];
strcpy(mm,strMessage.c_str());
// write(fileno(stdout),mm,strlen(mm));
send(fd,mm,strlen(mm),0);
delete msg;
}
}
}
}
数据序列化与反序列化
由于TCP链接是流式传输,客户端与服务器端通信的结构化消息需要进行序列化与反序列化操作,才能进行套接字传输,因此此程序使用JSONCpp进行数据的序列化与反序列化。
std::string Message::serialization() {
// 解决中文乱码问题
Json::StreamWriterBuilder builder;
builder["commentStyle"] = "None";
builder["indentation"] = "";
builder["emitUTF8"] = true;
Json::Value root;
root["type"] = type;
root["from"] = from;
root["to"] = to;
root["time"] = sendTime;
root["msg"] = msg;
// Json::FastWriter writer;
std::string str = Json::writeString(builder, root);
return str;
}
Message Message::deserialization(std::string str) {
// std::cout << str << std::endl;
Json::Value root;
Json::Reader reader;
int Type;
std::string From;
std::string To;
std::string sendTime;
std::string Msg;
if(reader.parse(str,root)){
Type = root["type"].asInt();
From = root["from"].asString();
To = root["to"].asString();
sendTime = root["time"].asString();
Msg = root["msg"].asString();
}
return Message(Type,From,To,sendTime,Msg);
}
运行截图
客户端运行界面:
服务器端日志打印:
总结与反思
通过学习孟宁老师的网络程序设计实验的课程,学习了包括:Javascript网络编程、Socket API、网络协议设计及RPC、Linux内核网络协议栈在内多个知识点,从协议内核到应用层网络编程,涉及知识面非常广阔,在此课堂中可以收获颇多,加之孟老师上课轻松幽默,可以更好地激发起每个上课的同学的学习积极性,每个同学都可以在课堂中收获更多的知识。孟老师更像是一盏明灯,将相关知识都教授给同学们,同学们也可以根据自己的兴趣爱好在某一方面进一步学习深度学习。通过这门课程的学习,扩展了我的知识面,深刻体会到了网络编程的重要性。收获非常大!
通过对高并发处理专题的研究,进一步熟悉了C++网络编程相关的知识点,加深了对这一块知识点的认识,并且对项目整体有了全新的认识,对于本人的收获非常大。由于刚接触,项目中还存在很多问题,在后续还会继续完善,还请各位大佬不吝赐教!