# NetDisk **Repository Path**: akevery_day/net-disk ## Basic Information - **Project Name**: NetDisk - **Description**: 基于TCP的客户端-服务器 文件传输系统 - **Primary Language**: C - **License**: BSD-3-Clause - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 4 - **Forks**: 1 - **Created**: 2024-03-05 - **Last Updated**: 2025-03-26 ## Categories & Tags **Categories**: Uncategorized **Tags**: Linux, TCP, Socket, MySQL, MD5 ## README # 基于TCP的客户端-服务器 文件传输系统 ## 一、项目概述 项目开发环境为Ubuntu18.04.6和MySQL5.7。服务器架构采用线程池。服务器的文件管理系统采用虚拟文件表实现,使得从用户视角看到的文件目录是树形的目录结构,而从操作系统角度看到的是单级目录结构。服务端为客户端提供文件云存储的服务(类似百度网盘),支持文件秒传(通过虚拟文件表实现)、断点续传的功能。为确保用户的数据安全,服务端能确保用户只能看到自己上传的文件和自己创建的目录,而看不到其他用户的文件(通过虚拟文件表实现)。用户的注册和登录验证采用单向加密,在服务器的数据库中只保存了用户的盐值和密文。 通过客户端可以输入以下命令,与进行服务器进行交互: (1) cd 进入服务器对应目录 (2) ls 列出服务器上相应的目录和文件 (3) pwd 显示目前所在路径 (4) puts filename 将本地文件上传至服务器 (5) gets filename 下载服务器文件到本地 (6) rm filename 删除服务器上的某文件 (7) mkdir dirname 创建文件夹 (8) 非法命令 不响应 ## 二、服务器架构 线程池的基本结构: ![线程池的结构](https://foruda.gitee.com/images/1710231389624055531/ec6af91e_14001830.jpeg "屏幕截图 2024-03-11 172205.jpg") 使用进程池的思路来解决并发连接是一种经典的基于事件驱动模型的解决方案,但是由于进程天生具有隔离性,导致进程之间通信十分困难,一种优化的思路就是用线程来取代进程,即所谓的线程池。由于多线程是共享地址空间的,所以主线程和工作线程天然地通过共享文件描述符数值的形式共享网络文件对象,但是这种共享也会带来麻烦:每当有客户端发起请求时,主线程会分配一个空闲的工作线程完成任务,而任务正是在多个线程之间共享的资源,所以需要采用一定的互斥和同步的机制来避免竞争。我们可以将任务设计成一个队列,任务队列就成为多个线程同时访问的共享资源,此时问题就转化成了一个典型的生产者-消费者问题:任务队列中的任务就是商品,主线程是生产者,每当有连接到来的时候,就将一个任务放入任务队列,即生产商品,而各个工作线程就是消费者,每当队列中任务到来的时候,就负责取出任务并执行。主线程通过epoll(水平触发),来监听socket中是否有连接请求到来。一旦有新的客户端连接,那么主线程就会将新的任务加入任务队列,并且使用条件变量通知子线程。子线程在启动的时候,会使用条件变量使自己处于阻塞状态,一旦条件满足之后,就立即从任务队列中取出任务并且处理该事件。 ## 三、虚拟文件表 虚拟文件表(本质是数据库的表)结构如下: | **字段名** | **字段类型** | **注释** | | ---------- | ------------ | ------------------------------------------------- | | id | int | 文件id(主键) | | parent_id | int | 所在上一层目录的id(0 表示无父目录) | | filename | varchar | 文件名 | | owner_id | int | 文件属与某一个用户的 id(外键,参照用户表的主键) | | md5 | char | 文件对应的 MD5 码 | | filesize | int | 文件大小,单位为字节 | | type | int | 0表示普通文件,1表示目录文件 | 虚拟文件表的设计思想是,用数据库的表来模拟文件目录,查找文件的过程就是查询数据库的过程。在服务端,由数据库记录每一个用户的目录结构和文件,但每个文件的内容本身存储在服务器磁盘上,并**以 MD5 码进行命名**。所有用户上传的文件都存在服务器上的**某一个目录**里(即单级目录),如/netdisk/。在客户端,用户登录服务器后,每个用户只能看到自己的文件,不能看到其他人的文件。然后可以执行各种操作命令,包括查看文件信息,上传文件,下载文件,删除文件,创建文件夹等。 示例: 服务器磁盘中实际保存的文件: ![磁盘中实际的目录结构](https://foruda.gitee.com/images/1710231460071015147/29b10969_14001830.jpeg "屏幕截图 2024-03-11 182234.jpg") 虚拟文件表中的记录: ![虚拟文件表中的记录](https://foruda.gitee.com/images/1710231509543039627/5e4585b0_14001830.jpeg "屏幕截图 2024-03-11 182503.jpg") ### 文件秒传的实现 从上述两张图片中可以看出,在数据库有多条关于1.jpeg和2.jpeg两张图片的记录,这两张图片在不同的用户的不同目录下均出现过。虽然在虚拟文件表中一共有6条关于这两张图片的记录,但是实际上在服务器的磁盘中只保存了一份数据,第一张图中的两张用md5码命名的图片分别是服务器磁盘中保存的1.jpeg和2.jpeg。利用上述虚拟文件表的特点,我们可以实现文件秒传功能。上传文件的时候,客户端先将待上传的文件的md5码发送给服务器,然后服务器在数据库中查找是否有md5码相同的文件,如果有,就只需要在数据库中添加一条记录即可(类似文件的硬链接),不需要真的上传文件,这样就实现了文件秒传。当输入mkdir创建目录的时候,进程并没有真的在磁盘上创建一个目录,而是在虚拟文件表中添加一条记录来表示这个目录,让用户感觉自己真的创建了一个目录,实际上服务器存放文件的目录没有任何变化。 ### 用户目录结构独立性的实现 为确保用户的数据安全,服务器必须保证每一个用户的目录结构是相互独立的,即用户只能看到自己上传的文件和自己创建的目录,而看不到其他用户的文件。通过虚拟文件表中的owner_id字段可以区分文件的拥有者是谁,从而能轻易地实现用户目录结构的独立性。 ## 四、用户的注册和登录验证 ### 1.用户表设计 | **字段名** | **字段类型** | **注释** | | ----------- | ------------ | ------------- | | id | int(自增) | 用户 id,主键 | | username | varchar | 用户名 | | salt | char | 盐值 | | cryptpasswd | varchar | 加密密码 | | pwd | varchar | 当前工作目录 | ### 2.用户注册 ![注册](https://foruda.gitee.com/images/1710231564962140489/bd5186ab_14001830.jpeg "屏幕截图 2024-03-11 215015.jpg") ### 3.用户登录 ![登录](https://foruda.gitee.com/images/1710231595482382350/a547427c_14001830.jpeg "屏幕截图 2024-03-12 101721.jpg") ## 五、文件传输 ### 私有协议的设计 由于TCP 是面向连接的传输协议,它是以“流”的形式传输数据的,而“流”数据是没有明确的开始和结尾边界的,所以就会出现粘包和半包的问题。粘包和半包问题是数据传输中比较常见的问题,所谓的粘包问题是指数据在传输时,在一条消息中读取到了另一条消息的部分数据,这种现象就叫做粘包。比如发送了两条消息,分别为“ABC”和“DEF”,那么正常情况下接收端也应该收到两条消息“ABC”和“DEF”,但接收端却收到的是“ABCD”,像这种情况就叫做粘包。半包问题是指接收端只收到了部分数据,而非完整的数据的情况就叫做半包。比如发送了一条消息是“ABC”,而接收端却收到的是“AB”和“C”两条信息,这种情况就叫做半包。 为了解决粘包和半包问题,我们可以基于TCP在应用层上构建一个能区分消息边界的私有协议。这个协议的目的是规定TCP发送和接收的实际长度从而确定单个消息的边界。该协议在发送的消息由两部分组成,第一部分是4个字节的固定首部,第二部分是可变长的数据载荷,固定首部中的内容是数据载荷的长度。客户端在接收数据的时候先接收4个字节的固定首部,从首部中提取出数据载荷的长度length,然后再接收length个字节的数据。因此这4个字节的固定首部使得数据的接收方能区分消息的边界。 ### 文件的上传 客户端先发送文件名和文件的md5码,服务端接收到md5码后先在虚拟文件表中查询是否有md5码相同的记录,如果有说明服务器磁盘中有与待上传文件一模一样的文件,此时只需要在虚拟文件表中添加一条记录即可,不需要真正的上传文件(即文件秒传)。如果没有md5码相同的记录,则上传文件,并在上传完成后在虚拟文件表中添加记录。 ### 文件的下载 客户端先发送文件名,服务端收到文件名后先在虚拟文件表中查询当前目录下是否存在该文件,如果存在则传输该文件。 ### 文件断点续传的实现 断点续传的实现思路是使文件的接收方和发送方的文件读写指针的偏移量相同。假设接收方要接受一个文件,此时接收方的磁盘中有该文件的残余文件,即上一次传输未传输完成的文件。接收方先用stat获取残余文件的大小,然后调用lseek函数将文件读写指针移动到文件末尾,并将残余文件的大小发送给文件的发送方,发送方接收到文件大小后,也调用lseek函数将文件读写指针移动到与接收方相同的位置。此时就能够继续发送上一次未发送完的部分,即文件的断点续传。 ### 文件的删除 删除文件时先判断该文件是普通文件还是目录文件。如果是普通文件,则先用SQL语句“select count(*) from 虚拟文件表的表名 where md5=该文件的md5码”来查询该文件在数据库中有多少条记录(类似文件的硬链接计数),如果count>1,则只需要删除一条记录即可,如果count=1,则不仅要删除记录,还要删除磁盘中对应的文件。如果是目录文件则需要先采用递归算法先删除这个目录下的所有文件和子目录,最后再删除该目录。