文章目录
代码工程文件
本代码是用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是多少啊?为什么我用本机IP
172.?.x.x
、192.168.x.x
作为服务端,其他主机连不上。
科普一下常识:一般来说,10.x.x.x
,172.?.x.x
,192.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))))
。原理就不多说了,有兴趣的可以自己分析下。当然,要是看不惯我这种指针满天飞的操作,也可以把我的代码改一下。