Windows下用C++的socket编程实现多用户网盘系统的研制(含文档实时同步)

代码工程文件

本代码是用dec C++写的,如果用其他软件开发,里面的.dev文件是没有用的。

注意:本代码中可能有大量bug,这是由于我各个版本的代码交错混杂,导致我也不知道哪个代码是哪个版本的了。。。

本人也只是一个学生,水平有限。里面很多操作都不对,比如不该用CreateThread,也只为每个用户创建了一个线程(如果要交互的话,应该收发一样一个)。还望大家多多海涵。也欢迎在评论区下留言指出该代码的不足之处。

链接:https://pan.baidu.com/s/1p8sT9_BvVgA5odQrk6KPNw
提取码:0721
复制这段内容后打开百度网盘手机App,操作更方便哦

基本原理介绍

想要做一个网盘系统,最基本的功能就是上传和下载。上传和下载无非就是客户端向服务端发送/接收数据,其中最关键的就是:如何让客户端和服务端建立起通信

如果你想要给其他人打电话,那你需要知道对方的电话号码。同样的,在互联网世界,IP地址就是我们的“电话号码”。IP分为IPv4和IPv6,理论上,IPv4最多可以分配232(约43亿)个地址,看起来似乎很多,但这点数量连全地球人手一个公网IP都做不到。

为了解决这个问题,我们也有NAT等手段,也可以拥抱IPv6的协议(2128)。本网盘系统是基于IPv4进行的研制,而实现方式则是利用了Windows的socket库。

一些常见问题

在介绍本功能之前,先要说一些常见的问题,及其解决方案。

问题一:服务端IP是多少?

我主机的IP是多少啊?为什么我用本机IP172.?.x.x192.168.x.x作为服务端,其他主机连不上。

科普一下常识:一般来说,10.x.x.x172.?.x.x192.168.x.x(?代表16到31)是内网IP。

举个通俗的例子,每个学生在学校里有个学号(相当于内网IP),老师看到学号就知道你对应哪个学生。如果你在学校外,别人问你,你得报身份证号(相当于公网IP),你报学号,其他人怎么可能认识你?

问题二:为啥我查到了我电脑的公网IP,还是连接不了?

为啥我查到了我电脑的公网IP,还是连接不了?

先说下,前面你查到的内网IP一般是在cmd里用ipconfig /all查到的,你的电脑的公网IP其实也不能直接用,还得做一个内网穿透。这个自己百度吧,不想讲了。用之前记得先用cmd检测一下对应的IP能不能ping通。

问题三:这些我都不会,有没有零基础就能搞定的办法?

这些我都不会,有没有零基础就能搞定的办法?

很简单,去网上买个云服务器当主机就行了。阿里云和腾讯云都可以,学生价挺便宜的,99一年。反正我买的阿里云是自带公网IP的。

原理详解

其实就是用Winsock2.h的函数socket编程,通过IP地址访问连接。“talk is cheap, show you the code”,说这么多还不如直接把代码扔上来。(只挑重点讲,详细代码见百度网盘链接

服务端初始化函数代码——

其实这些没有什么需要注意的地方,直接看我的注释就行了。只有一点,就是ioctlsocket函数,这个是将socket设置为非阻塞式(默认为阻塞式)。举个例子,cin函数就相当于阻塞式的,如果用户一直不输入,那就会一直阻塞在这里。这里设置为非阻塞式,是为了多用户多线程操作。如果不需要多用户,单用户单线程收发,那就无所谓了。但是吧,既然是个网盘系统,怎么着还是得多用户同时上传下载吧。

#include <WinSock2.h>

// 初始化Socket 
bool InitSocket(){
	
	// 声明+初始化一个Server的地址结构
	sockaddr_in saServerAddr;
	saServerAddr.sin_family = AF_INET;  								// IPv4
	saServerAddr.sin_addr.S_un.S_addr = INADDR_ANY;
	saServerAddr.sin_port = htons(PORT);
 
	// 初始化异步socket dll
	WSADATA wsaData;
    WORD wSocketVersion = MAKEWORD(2, 0);								// 00000000_00000010
	if(WSAStartup(wSocketVersion, &wsaData) != 0)						// 检查版本 
		return ErrDeclare("Windows Sockets DLL初始化失败!");			// return 0
 
	// 创建套接字sServer 
    if(SOCKET_ERROR == (sServer = socket(AF_INET, SOCK_STREAM, 0)) )	// sServer是外部变量的套接字,是初始化的关键 
		return ErrDeclare("Socket创建失败!");							// return 0
    
	// 设置套接字sServer为非阻塞模式
	unsigned long ultemp = 1;
	if(SOCKET_ERROR == ioctlsocket(sServer, FIONBIO, &ultemp))
		return ErrDeclare("设置Socket非阻塞模式失败!");				// return 0
 
	// 绑定socket和服务端(本地)地址
	if(SOCKET_ERROR == bind(sServer, (LPSOCKADDR)&saServerAddr, sizeof(saServerAddr)) ){
		return ErrDeclare("服务端绑定失败:");							// return 0
	}
 
	// 监听
	if(SOCKET_ERROR == listen(sServer, 10))
		return ErrDeclare("服务端监听失败:", WSAGetLastError() );		// return 0
	
	// 初始化成功 
	return 1;
}

其中ErrDeclare函数就是用来输出报错信息的

bool ErrDeclare(string strErrorContent){
	std::cout<<strErrorContent<<std::endl;
	return 0;
}
bool ErrDeclare(string strErrorContent, int nErrorNum){
	std::cout<<strErrorContent<<nErrorNum<<std::endl;
	return 0;
}

客户端初始化函数代码——

这里面有一步重要的操作,在于你需要制定服务端的SERVER_IP。一般来说,如果本机到本机的通信,127.0.0.1就是本机的IP地址。如果是在网络上的通信,需要将这里的127.0.0.1更改为服务端的公网IP。(客户端代码才需要改,服务端代码就无所谓了)

还有一点是connect函数,每个客户端的connect函数是和服务端的accept函数配对的。但是一个accept只能接受一个客户端,那么如何实现多用户呢?这就要用到多线程的知识了。我们只需要创建一个监听线程,这个线程的工作是:反复执行accept函数,如果监听到用户的connect,就为这个用户分配一个对象,该对象中包含一个收发线程。(本代码里收发线程共用的一个线程,这会导致网盘是半双工。如果你要实现实时同步等全双工操作,请为收和发分别创建一个线程!

#define SERVER_IP				"127.0.0.1"

// 初始化Socket 
bool InitClientSocket(){

	// 初始化异步socket dll
	WSADATA wsaData;
    WORD wSocketVersion = MAKEWORD(2, 0);								// 00000000_00000010
	if(WSAStartup(wSocketVersion, &wsaData) != 0)						// 检查版本 
		PauseExitServer("Windows Sockets DLL初始化失败!");				// exit(1)
 
	// 创建套接字sServer 
    if(SOCKET_ERROR == (sServer = socket(AF_INET, SOCK_STREAM, 0)) )	// sServer是外部变量的套接字,是初始化的关键 
		PauseExitServer("Socket创建失败!");							// exit(1)
	
	// 声明+初始化一个Server的地址结构
	sockaddr_in saServerAddr;
	saServerAddr.sin_family = AF_INET;  								// IPv4
	saServerAddr.sin_addr.S_un.S_addr = inet_addr(SERVER_IP);
	saServerAddr.sin_port = htons(PORT);
	
	// 连接到Server 
	if(SOCKET_ERROR == connect(sServer, (LPSOCKADDR)&saServerAddr, sizeof(saServerAddr)) )
		PauseExitServer("无法连接到服务端的IP!");						// exit(1)
	
	// 初始化成功 
	return 1;
}

服务端监听线程的创建

服务端初始化完后,执行CreateListenThread()函数进行监听线程的创建(建议将我的代码里的CreateThread函数改掉,网上说这个代码可能引起内存泄漏

// 创建监听线程 
bool CreateListenThread(){
	unsigned long uListenThreadId;
	HANDLE hThreadListen = CreateThread(NULL, 0, ListenThread, NULL, 0, &uListenThreadId);
	
	if(NULL == hThreadListen)											// 如果创建线程失败
		return ErrDeclare("创建监听线程失败!");						// return 0
	else
		CloseHandle(hThreadListen);
	
	ShowServerInfo(true);												// 创建成功,显示欢迎界面 
	return 1;
}

其中CreateThread(NULL, 0, ListenThread, NULL, 0, &uListenThreadId)函数是创建一个监听线程,我们只需要关注它的第3、4个参数就可以了。这个代码本质上就是将程序分裂为两个并行不悖的线程,一个执行ListenThread()函数,另一个继续向下执行。参数3ListenThread代表执行的线程函数名,参数4NULL代表给ListenThread传入的参数

服务端的ListenThread函数:

可以看到这里面用到了CClient(我自己定义的一个客户类)的指针数组clClientList。我们先暂且不管它。我们看看函数的主结构,就是一个while(true)死循环,没有break条件。

其中反复执行accept函数,如果没有监听到用户,那么就Sleep()一会继续监听。如果成功监听到,就new一个客户对象,将其添加到指针数组clClientList中,执行对应的StartRunning函数然后继续回来监听。

CClient*			clClientList[MAX_CLIENT];							// 客户端指针数组 

// 如果监听到客户端的请求,就将客户端添加到链表 
DWORD _stdcall ListenThread(void* lParam){
	
	SOCKET sClient;
	SOCKADDR_IN saClientAddr;

	while(true){
		int len = sizeof(SOCKADDR);
		ZeroMemory(&saClientAddr, len);
		
		sClient = accept(sServer, (SOCKADDR*)&saClientAddr, &len);
		if(INVALID_SOCKET == sClient ){
			if(WSAEWOULDBLOCK == WSAGetLastError() ){					// 如果暂时没有客户端的连接请求,则Sleep(50)
				Sleep(SMALL_DELAY);
				continue;
			}
			else
				return (DWORD) ( ErrDeclare("监听时出现错误!") );		// return 0
		}
		else{															// 构造客户端类并添加至客户端链表
			ShowClientInfo(saClientAddr, true);
			CClient *pNewClient = new CClient(sClient, saClientAddr,((void*)clClientList) , pnNowClient);
			EnterCriticalSection(&csClientList);						// 数组互斥访问 
			for(int i=0;i<MAX_CLIENT;i++){								// 添加用户 
				if(clClientList[i] == NULL){
					clClientList[i] = pNewClient;
					(*pnNowClient) = (*pnNowClient)+1;
					goto BIGBREAK;
				}
			}
			cout<<"用户已满"<<endl;
			continue;
			BIGBREAK: ;
			LeaveCriticalSection(&csClientList);
			pNewClient->StartRunning();									// 让进程开始执行任务 
		}
	}
	return (DWORD) ( ErrDeclare("监听时异常退出!") );					// return 0
}

CClient类与StartRunning函数

ListenThread函数在创建CClient类,并执行StartRunning函数后,就回去继续坚挺了。所以,这里先给大家介绍一下我自己定义的用户对象类CClient:

class CClient{
	
private:
	SOCKET				m_sSocket;										// 对该客户端的连接套接字
	sockaddr_in			m_saAddr;										// 该客户端的地址
	CRITICAL_SECTION	m_cs;											// 对m_data互斥访问
	HANDLE				m_hThread;										// 子线程的句柄 
	void*				m_clientList;									// 使用时强制转换为(CClent*) 
	int*				m_pnClientNum;									// 用户数量指针 
	
	int					m_nState;										// 标明客户的状态(0代表退出,1代表菜单状态,2代表服务器向用户发,3代表用户向服务器写,4代表共同编辑) 
	char				m_Buffer[BUFFER_SIZE];							// 数据缓冲 (接收和发送共同访问) 
	int					m_nIsShare;										// 0代表不共享,1代表共享,2代表共享同步接收 
	
	
protected:
	static DWORD _stdcall Running(void *lParam);						// 执行任务 
	
public:
	CClient(const SOCKET sockClient, const sockaddr_in addrClient, void *clientList , int* pnClientNum);
	virtual ~CClient();
	
	void StartRunning();												// 把用户安排到新的线程Running里,然后继续监听
	void Quit();														// 结束该对话(m_nState==0) 
	void Menu();														// 菜单选项(m_nState==1) 
	void SendToClient();												// 用户上传(m_nState==2) 
	void RecvFromClient();												// 用户下载(m_nState==3)
	void UserEditShareContent();										// 共同编辑TheShareContent.md(m_nState==4)
	void UserUpdateShareContent();										// 上传TheShareContent.md,并通知其他用户打开(m_nState==5)
	void UserRecvShareContent();										// 下载并让状态4的用户打开TheShareContent.md(m_nState==6)
	
	int ChrToInt(char tmpChr0, char tmpChr1);							// 因为菜单少于10种,只要用户第一位为数字且第二位为\0就返回,否则直接返回255 
	
};

里面最核心的函数,其实就只有StartRunning函数和Running线程了。让我们来看看——

其实很简单,StartRunning也只是干一件事情,就是创建了Running线程,并且将this指针作为参数传进去。然后Running线程就反复执行switch(pClient->m_nState)m_nState相当于菜单栏,根据用户的选择而变化。比如用户想要下载,就输入2,此时服务端收到2,就将m_nState置为2,然后执行SendToClient函数,执行完后回到m_nState=1,继续等待用户的操作。因此,这个过程是半双工的,如果要实时同步,请将收发设置为两个独立的线程。

// 创建Running线程,并回到main函数 
void CClient::StartRunning(){
	
	// 把用户安排到新的线程Running里,然后继续监听 
	unsigned long ThreadId;
	if( !(m_hThread=CreateThread(NULL, 0, Running, this, 0, &ThreadId)) )
		cout<<"子线程创建失败!";
}

// 客户对象的主函数,在里面无限循环,直到Quit() 
DWORD CClient::Running(void *lParam){
	
	CClient* pClient = (CClient*)lParam;
	// 进入菜单选项 
	while(true){
		switch(pClient->m_nState){ 
			case 0:														// 退出 
				pClient->Quit();break;									// 结束该对话 
			case 1:														// 菜单状态 
				pClient->Menu();break;
			case 2:														// 用户下载 
				pClient->SendToClient();break;
			case 3:														// 用户上传 
				pClient->RecvFromClient();break;
			case 4:														// 共同编辑TheShareContent.md 
				pClient->UserEditShareContent();break;
			case 5:														// 上传TheShareContent.md,并通知其他用户打开 
				pClient->UserUpdateShareContent();break;
			case 6:														// 下载并让状态4的用户打开TheShareContent.md 
				pClient->UserRecvShareContent();break;
			default:
				pClient->m_nState = 1;break;
		}
	}
	return 0;
}

文档实时同步

说是实时同步,其实是伪同步。其实是一个用户选择进入接受共享文档状态,然后其他用户想要发送共享文档,只需要发送,这个用户就会告诉服务器,然后服务器就告诉准备接受的用户,让用户接收并自动打开TheShareContent文档。本质上就是把发送和接收缝合了一下。

唯一用到的骚操作,就是:

CClient* pTemp = ((CClient*)(*((long long unsigned int*)(m_clientList+8*i))))

这个是我半夜花了3个小时才想出的解决方案,有兴趣的可以慢慢分析一下我这个操作的原理,没兴趣的直接用也可以。大概讲一下,就是用户类里面,储存了CClient* clClientList[MAX_CLIENT]指针数组的头指针。因为数组相当于一重指针,这里就相当于二重指针。而我用户类中将其强制类型转换为了void*类型,相当于一重指针。想要用void* m_clientList来访问m_clientList[i],就先要强制类型转换CClient* pTemp = ((CClient*)(*((long long unsigned int*)(m_clientList+8*i))))。原理就不多说了,有兴趣的可以自己分析下。当然,要是看不惯我这种指针满天飞的操作,也可以把我的代码改一下。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值