当前位置: 首页 > article >正文

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)、原始socketLinux提供了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不会清空文件


http://www.kler.cn/a/597950.html

相关文章:

  • FPGA学习(二)——基于DE2-115开发板的LED流水灯设计
  • 微调大模型:LoRA、PEFT、RLHF 简介
  • HTML图像
  • 如何搭建一个安全经济适用的TRS交易平台?
  • Ant Design Vue Select 选择器 全选 功能
  • 第41章:ConfigMap与环境配置最佳实践
  • 神聖的綫性代數速成例題15. 對稱矩陣、正交矩陣、二次型及其標準形
  • Java-模块二-2
  • [自动化] 【八爪鱼】使用八爪鱼实现CSDN文章自动阅读脚本
  • Rust函数、条件语句、循环
  • 局域网设备访问虚拟机 挂载NFS
  • AI 生成 PPT 网站介绍与优缺点分析
  • 【Golang】第七弹----map
  • 时态--01--⼀般现在时
  • 深度剖析:复制带随机指针的链表算法实现
  • 数据库MVCC详解
  • python 数据可视化mayavi库安装与使用
  • leetcode_双指针 15.三数之和
  • 【js逆向】某酒店模拟登录
  • Python 正则表达式超详细解析:从基础到精通