Linux网络相关概念和重要知识(2)(UDP套接字编程、聊天室的实现、观察者模式)
目录
1.UDP套接字编程
(1)socket编程
(2)UDP的使用
①socket
②bind
③recvfrom
④sendto
2.聊天室的实现
(1)整体逻辑
(2)对sockaddr_in的封装
(3)客户端设计
①观察者模式
②User设计
③观察者的设计
⑤客户端主程序设计
(4)服务端设计
①服务端主程序的设计
②服务器端成员变量
③利用线程池实现服务端
(5)重定向文件
1.UDP套接字编程
(1)socket编程
套接字编程的种类比较多:网络socket、本地socket(如Unix域间socket)、原始socket。Linux提供了socket的系统调用,保证接口统一,以后实现Linux上的各种socket通信就很简单了。
(2)UDP的使用
使用socket套路非常简单,抓住IP和port,理解通信的大致过程就能很好掌握代码的方法了
①socket
站在用户层,创建socket就是创建了一个特殊的文件,这个文件包含了应该以什么方式,什么协议来通信。但此时,这个文件还应该有自己的网络层面的标识,如IP和port,这就是bind需要做的事了。
②bind
使用过程中,只需要创建对应通信方式的addr,写入对应的网络信息(如IP和port),最后再将addr转为父类指针,传入大小进行bind
总体bind流程:
向addr写入属性:
其中_net_addr是in类型的,注意字节序的调整,修改IP需要到sin_addr里面修改其成员s_addr
测试时常用的IP是127.0.0.1,表示本地机器。把这个IP绑定到socket之后,其它socket就可以通过这个IP向这个socket发消息。上述代码使用INADDR_ANY,意思是其它socket向任何IP发的消息都会被接收。
端口号是2字节16位的,端口号0 - 1023是知名端口号,1023 - 65535是OS动态分配的端口号,是客户端程序的端口号,我们随便选择,只要该端口号没有被使用。将端口号绑定后,对方socket向该socket发送消息,只有匹配IP和端口号才会被socket接收。
注意客户端不需要手动bind,因为客户端每次启动都不能保证相应端口号的空闲。而服务器端几乎不关机,可以指定而且必须指定。因此客户端创建了socket可以直接发消息,发消息时会系统自动bind,将相关IP和port写入socket中。
③recvfrom
没有收到消息时,线程会被阻塞在这个函数里面
④sendto
socket是全双工通信,既可以收的同时也可以发
2.聊天室的实现
(1)整体逻辑
整个聊天室重要的是逻辑,因为它的封装已经比较多了,所以接下来的实现会重点强调每个模块是如何设计,和其它模块耦合的。我们只需要详细理解前面UDP的通信过程即可理解聊天室的实现。
(2)对sockaddr_in的封装
我们可以快速创建管理sockaddr_in的结构体,并单独记录port和IP
服务器端可以快速初始化结构体
接收方也可以使用这个结构体,将接收到的in交给自定义结构体进行IP和port提取
全部代码:
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
#include <stdint.h>
#include <strings.h>
using namespace std;
class myInetAddr
{
private:
void Set_Port() // 端口号的网络字节序转换为主机字节序
{
_port = ntohs(_net_addr.sin_port);
}
void Set_Ip() // IP的网络字节序转换为主机字节序
{
char ipbuffer[100] = {0};
_ip = inet_ntop(AF_INET, &_net_addr.sin_addr, ipbuffer, sizeof(ipbuffer));
// 相较于inet_ntoa,这个函数更安全,不会被覆盖
}
public:
myInetAddr(const sockaddr_in &addr) : _net_addr(addr) // 传入一个sockaddr_in结构体,将其赋值给_net_addr
{
Set_Port(); // 调用Set_Port函数,将sockaddr_in结构体里面的端口号提取出来
Set_Ip(); // 调用Set_Ip函数,将sockaddr_in结构体里面的IP地址提取出来
}
// 只需要传个端口号,就可以实现IP、端口号的自动初始化,其中IP默认为空,意味着局域网内任意IP都可以通信
myInetAddr(uint16_t port, string ip = "")
: _port(port), _ip(ip) // 传入端口号,IP默认为空,可自动初始化
{
// 初始化文件的属性
bzero(&_net_addr, sizeof(_net_addr)); // 对_net_addr里面的空间清0,类似memset
_net_addr.sin_family = AF_INET; // 网络通信,该属性必须要和socket的一致,后续才能绑定
_net_addr.sin_port = htons(_port); // 写入端口号,htons保证字节序,主机字节序转网络字节序
// sockaddr_in里面有struct in_addr sin_addr,这个结构体里面有s_addr,这个就是IP地址
_net_addr.sin_addr.s_addr = INADDR_ANY; // 局域网内任意IP,只要端口号一致就能通信,也可以inet_addr(指定IP的c_str)
}
const sockaddr *Get_Const_Sockaddr_ptr()
{
return (const sockaddr *)&_net_addr;
}
socklen_t Get_Socklen()
{
return sizeof(_net_addr);
}
const string Get_Ip() const // 获取sockaddr_in结构体里面的IP地址,加const是为了保证不会修改_ip
{
return _ip;
}
const uint16_t Get_Port() const // 获取sockaddr_in结构体里面的端口号,加const是为了保证不会修改_ip
{
return _port;
}
private:
// 使用sockaddr_in结构而不是sockaddr
sockaddr_in _net_addr; // 管理属性的结构体,包括IP和端口号
string _ip; // 单独记录IP地址
uint16_t _port; // 单独记录端口号
};
(3)客户端设计
①观察者模式
在管理用户的UserManager中有一个链表,这个链表的实例化类型是BaseUser的指针,链表中的指针就是观察者,这些观察者可以通过多态实现管理User。当有什么操作需要完成时,会遍历链表中的指针,调用User里面的方法完成任务。整个过程中,UserManager都充当管理者、观察者,因此叫做观察者模式。
②User设计
这个User里面保存着用户的属性和方法,当服务器遍历list执行里面的方法时就会到这个类里面调用函数,每个观察者都知道观察对象的IP和port。
③观察者的设计
路由设计,服务器接收消息后就会在这里路由,遍历方法向别人发送信息。
注意这个sockfd是服务器的socket
全部代码
#pragma once
#include <iostream>
#include <string>
#include <list>
#include <memory>
#include <algorithm>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "myLog.hpp"
#include "myMutex.hpp"
#include "myInetAddr.hpp"
using namespace std;
using namespace myLogModule;
using namespace myMutexModule;
class BaseUser // 多态,管理用户的基类
{
public:
// 虚函数说明销毁的时候会调用子类的析构函数,default说明是默认的析构函数
virtual ~BaseUser() = default;
// 纯虚函数,第一个参数作用是socket的fd,发送消息时会根据fd发送消息和接收消息,顺便还会发送主机IP和端口号
virtual void SendTo(int sockfd, const string &message) = 0;
virtual bool CompareAddr(const myInetAddr &addr) = 0;
};
class User : public BaseUser // 服务器用来保存用户的IP和端口号
{
public:
User(const myInetAddr &addr)
: _addr(addr)
{
}
// 这个sockfd是服务器的socket,用于转发消息,_addr是接收转发消息的客户端的地址
void SendTo(int sockfd, const string &message) override // override说明这个函数是重写的,用于规范化代码
{
LOG(DEBUG) << "服务器转发一条消息至" << _addr.Get_Ip() << ":" << _addr.Get_Port();
// 将服务器收到的message发给客户端
sendto(sockfd, message.c_str(), message.size(), 0, _addr.Get_Const_Sockaddr_ptr(), _addr.Get_Socklen());
}
bool CompareAddr(const myInetAddr &addr) override
{
return _addr.Get_Ip() == addr.Get_Ip() && _addr.Get_Port() == addr.Get_Port();
}
private:
myInetAddr _addr;
};
// 观察者,这里面会管理所有的用户
class UsersManager
{
public: // 添加和删除用户都只需要传入管理用户IP和端口号的结构体即可
void AddUser(const myInetAddr &addr) // 只管添加用户,查重由该函数自己完成
{
myLockGuard lockguard(_mutex); // 一次性只允许一个进程访问公共资源,即用户链表
for (auto &user : _online_users)
{
if (user->CompareAddr(addr))
{
return;
}
}
_online_users.push_back(make_shared<User>(addr)); // 多态,父类指针指向子类对象
// 服务器新添加了一个用户,这个用户的管理的结构体是myInetAddr,但实际上是多态,所以可以调用子类的函数,这个结构体里面也有IP和端口号的信息
LOG(INFO) << "新增用户:" << addr.Get_Ip() << ":" << to_string(addr.Get_Port());
}
void DeleteUser(const myInetAddr &addr)
{
myLockGuard lockguard(_mutex); // 一次性只允许一个进程访问公共资源,即用户链表
for (auto &user : _online_users)
{
if (user->CompareAddr(addr))
{
_online_users.remove(user); // 这个函数会到链表里面找到这个user,然后删除,前面if已经找到了,所以这里不会有问题
LOG(INFO) << addr.Get_Ip() << ":" << to_string(addr.Get_Port()) << "退出聊天室,服务器已删除用户";
return; // 找到了就删除,不需要再继续找了
}
}
}
void Router(int sockfd, const string &message) // 路由函数,将服务器收到的消息转发给所有的用户
{
myLockGuard lockguard(_mutex); // 一次性只允许一个进程访问公共资源,即用户链表
for (auto &user : _online_users) // 遍历所有用户
{
// 每一次获取数据都会调用一次这个函数,这个函数会将数据转发给所有的用户
user->SendTo(sockfd, message); // 某一个用户发送过来信息,遍历所有用户,将信息转发给所有用户,这个socket是发信息的socket,这样其它用户才知道是他发的
}
}
private:
// 每个用户都是观察者
list<shared_ptr<BaseUser>> _online_users; // 这是多态的体现,基类指针指向派生类对象,可以调用子类的函数
myMutex _mutex; // 一把锁,保证线程安全
};
⑤客户端主程序设计
为了实现全双工,客户端主程序需要双线程执行,一端负责写消息,一端负责收信息(收不到信息时会一直阻塞在recvfrom中)
全部代码
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <unistd.h>
#include "myCommon.hpp"
using namespace std;
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// sockaddr_in写入要连接的服务器的IP和端口号
sockaddr_in server; // 可以向指定IP和端口号发送信息
void ClientQuit(int signal)
{
string message = "QUIT";
sendto(sockfd, message.c_str(), message.size(), 0, (const sockaddr *)(&server), sizeof(server));
cout << "你已退出聊天室" << endl;
exit(0);
}
void *ReceiveMessage(void *args)
{
while (1)
{
sockaddr_in temp;
socklen_t len = sizeof(temp);
char read_buffer[1024];
int n = recvfrom(sockfd, read_buffer, sizeof(read_buffer) - 1, 0,
(sockaddr *)(&temp), &len); // 接收对方服务器的IP和端口号
read_buffer[n] = '\0';
cerr << read_buffer << endl;// 输出接收到的信息,用错误流接收,后面专门用重定向设置一个聊天界面
}
}
// CS模式,client和server,client发送消息,server接收消息,服务器端永远不会主动发送消息,都是被动的
int main(int argc, char *argv[]) // 第二个参数是IP,第三个参数是端口号
{
if (argc != 3)
{
cerr << "三个参数,一个IP,一个端口号" << endl;
exit(CLIENT_ERROR); // 2表示客户端错误
}
string server_ip = argv[1];
uint16_t server_port = stoi(argv[2]);
if (sockfd < 0)
{
cerr << "客户端启动失败" << endl;
exit(CLIENT_ERROR);
}
cout << "客户端启动成功" << endl;
memset(&server, 0, sizeof(server)); // 用0初始化,0是char类型的0,即0x00
server.sin_family = AF_INET;
server.sin_port = htons(server_port); // 保证字节序
server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 保证字节序
signal(2, ClientQuit); // 捕获ctrl+c信号,退出程序,触发信号会执行ClientQuit函数,这个函数会向服务器发送QUIT信息,服务器会删除用户信息
pthread_t tid;
pthread_create(&tid, nullptr, ReceiveMessage, nullptr);
while (true)
{
cout << "请输入信息:";
string message;
getline(cin, message); // 获取信息
// 不需要绑定socket,直接向文件写信息即可,client也有自己的属性,但IP和端口号不需要显式调用bind,客户端指定端口号可能会冲突,首次sendto会自动绑定
// 客户端自动bind,一个端口号只能bind一次,一个进程可以绑定多个端口号
sendto(sockfd, message.c_str(), message.size(), 0, // 根据打开的socket发送信息,向socket文件发送消息
(const sockaddr *)(&server), sizeof(server)); // 要访问的服务端的IP和端口号
// 发送信息的条件:向socket中写数据,socket和本机的IP和端口号绑定(显式绑定和自动绑定)
// const sockaddr *指向目的的IP和端口号,发送出去后自己socket里面的IP和端口号也会发出去,对方就能找到你
}
return 0;
}
(4)服务端设计
①服务端主程序的设计
服务端最主要的就是利用回调函数将User的函数方法传过去,当要进行用户管理或者转发消息时,下层就能通过回调函数跑回顶层,通过这个唯一的um进行管理。
全部代码
#include "UdpServer.hpp"
#include "Users.hpp"
int main()
{
shared_ptr<UsersManager> um = make_shared<UsersManager>(); // 用户管理模块,服务器启动,这用来管理用户
unique_ptr<UdpServer> server(make_unique<UdpServer>()); // 创建一个服务端对象
server->RegisterService(//这是回调函数,服务端启动创建管理用户的对象,再用lambda将这个对象传入服务端,这样服务端就能回调上层的um的函数
[&um](const myInetAddr &addr)
{
um->AddUser(addr);
},
[&um](int sockfd, const string &message)
{
um->Router(sockfd, message);
},
[&um](const myInetAddr &addr)
{
um->DeleteUser(addr);
});
// 服务端启动,注册服务,这里注册了三个服务,分别是添加用户、路由、删除用户,完成RegisterService后,回调函数保证server里面访问的UsersManager的对象都是um的
server->start(); // 启动服务端,之后会一直循环等待接收数据
return 0;
}
②服务器端成员变量
三个函数指针是回调函数的,用于调用上层的对象的函数,转发消息
③利用线程池实现服务端
后面的代码逻辑都比较简单,就是让服务器陷入start()循环中,一直接收消息并通过回调函数转发消息。这个转发消息的任务被推到线程池中完成(线程池的实现不再展示)。唯一需要注意的是推送任务中要匹配任务参数,可以通过bind或者lambda调整,后面可以多注意一下。
全部代码
#pragma once
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <sys/types.h>
#include <stdint.h>
#include <netinet/in.h>
#include <strings.h>
#include <arpa/inet.h>
#include <functional>
#include "myLog.hpp"
#include "myInetAddr.hpp"
#include "myCommon.hpp"
#include "myThreadPool.hpp"
using namespace std;
using namespace myLogModule; // 使用日志模块
using namespace myThreadPoolModule;
const uint16_t default_port = 8888; // 默认端口号,无需指定IP
const int max_size = 1024; // 存储信息的最大字节数
using adduser_t = function<void(const myInetAddr &addr)>;
using route_t = function<void(int sockfd, const string &message)>;
using delete_t = function<void(const myInetAddr &addr)>;
class UdpServer
{
public:
void RegisterService(adduser_t adduser, route_t route, delete_t del)
{
_adduser = adduser;
_route = route;
_deluser = del;
}
UdpServer(uint16_t port = default_port)
: _socket_fd(-1), // 服务端的socket文件描述符
_addr(port), // 服务端的端口号,默认为default_port,用于构造myInetAddr对象,自动初始化IP和端口号
_isrunning(false) // 服务端的运行状态
{
// socket创建网络通信的文件
_socket_fd = socket(AF_INET, SOCK_DGRAM, 0); // 网络通信,UDP通信方式,0默认
if (_socket_fd == -1) // 创建失败返回-1
{
LOG(FATAL) << "服务端启动失败"; // 输出错误信息
exit(SERVER_ERROR); // 直接退出程序,1表示服务端的异常退出
}
// 到这里创建了文件并写好了属性,接下来要bind
if (bind(_socket_fd, _addr.Get_Const_Sockaddr_ptr(), _addr.Get_Socklen()) != 0) // 将属性和文件绑定,绑定成功返回0
// 这个文件被绑定后,其它进程才能通过相应的IP和端口号找到这个文件
{
LOG(FATAL) << "绑定失败";
exit(SERVER_ERROR); // 直接退出程序,1表示服务端的异常退出
}
// socket打开文件,bind将和文件通信的条件写入,至此其它进程才能找到这个进程
LOG(INFO) << "服务端已绑定完毕,正在等待启动";
}
void start()
{
myThreadPool<task_t>::GetInstance()->StartAddTask(); // 启动线程池
LOG(INFO) << "服务端(端口8888)启动成功";
sockaddr_in client_addr; // sockaddr_in类型,这是用于获取第一次接收的用户端的数据所附带的IP、端口号信息
socklen_t client_addr_len = sizeof(client_addr);
_isrunning = true; // 启动服务器
while (_isrunning) // 服务器运行时一直循环
{
bzero(_read_buffer, sizeof(_read_buffer)); // 对读写数组清0,数组名就是数组的首地址
_write_buffer.clear(); // 对写数组清空
// 会被阻塞在这个函数,直到收到数据,这个函数会将收到的数据写入_read_buffer,还会将客户端的IP和端口号信息写入client_addr和client_addr_len
ssize_t n = recvfrom(_socket_fd, _read_buffer, sizeof(_read_buffer) - 1, 0, // 读到的数据存到_read_buffer里面
(struct sockaddr *)&client_addr, &client_addr_len); // 读到属性并写入client_addr和client_addr_len
// n不包含'\0',所以要手动加上
if (n > 0)
{
_read_buffer[n] = '\0'; // 读到的最后一个字符后面加上'\0',保证字符串安全
// 处理收到的属性,client_addr是sockaddr_in类型,需要转换为myInetAddr类型,这样就可以有了客户端的myInetAddr
myInetAddr client_inet_addr(client_addr); // 直接将客户端的IP和端口号信息传入myInetAddr对象,自动初始化IP和端口号
// 某一个用户开始向服务器发送数据,服务器开始接收数据,但先要添加用户
_adduser(client_inet_addr); // 添加用户,调用回调函数,服务器的main函数定义的um里面的list添加用户,函数内部查重,这里不操作
string _write_buffer = client_inet_addr.Get_Ip() + ":" + to_string(client_inet_addr.Get_Port()) + "发出消息:" + _read_buffer;
if (strcmp(_read_buffer, "QUIT") == 0)
{
// 移除该UsersManager里面保存的用户信息
task_t task_deluser = bind(UdpServer::_deluser, client_addr);
myThreadPool<task_t>::GetInstance()->AddTask(move(task_deluser));
// 这里不能return,服务器一直在start函数里循环,return会直接退出函数
//QUIT之后通知其它人
_write_buffer = client_inet_addr.Get_Ip() + ":" + to_string(client_inet_addr.Get_Port()) + "已退出聊天室";
}
// 将回调函数_route绑定到新的task_t中,这个类型没有参数,将这个专门封装的类型推送到线程池中
task_t execute_task = bind(UdpServer::_route, _socket_fd, _write_buffer);
myThreadPool<task_t>::GetInstance()->AddTask(move(execute_task)); // 将这个任务推送到线程池中,这个线程池会执行um的函数
}
}
}
void stop()
{
_isrunning = false; // 服务器停止,自动根据while退出循环
}
~UdpServer()
{
if (_socket_fd != -1)
{
close(_socket_fd);
LOG(INFO) << "已关闭服务器端";
}
}
private:
int _socket_fd; // 调用socket之后创建文件后返回的文件fd
bool _isrunning; // 记录当前服务端的运行状态
myInetAddr _addr;
string _write_buffer; // 服务器准备写出的信息
char _read_buffer[max_size]; // 服务器读到的信息
adduser_t _adduser; // 函数指针,用于添加用户
delete_t _deluser; // 函数指针,用于删除用户
route_t _route; // 函数指针,用于转发消息
};
(5)重定向文件
./test 1>test.txt可将输出重定向到文件中,标准输入、标准错误可以打印到不同文件中,方便我们进行debug,在这里我们也可以借此将聊天消息统一传到一个界面里面
注意:
1>log.txt 2>log.txt不可行,两个打开一个文件,其中一个流输出的内容会被清空,1>log.txt 2>>log.txt就可以了。
1>log.txt 2>&1也可以实现,把1的内容拷贝给2,让2也指向log.txt,这就意味着文件不是2打开的,只算做1打开文件,只是1单独将描述符私发给2,2不会清空文件