女神博客链接:https://www.cnblogs.com/Eva-J/articles/8244551.html
作业链接:https://www.cnblogs.com/Eva-J/articles/7642557.html
需求分析
1. 多用户同时登陆
2. 用户登陆,加密认证
3. 上传/下载文件,保证文件一致性
4. 传输过程中现实进度条
5. 不同用户家目录不同,且只能访问自己的家目录
6. 对用户进行磁盘配额、不同用户配额可不同
7. 用户登陆server后,可在家目录权限下切换子目录
8. 查看当前目录下文件,新建文件夹
9. 删除文件和空文件夹
10. 充分使用面向对象知识
11. 支持断点续传

代码目录结构:

bin目录:
# -*- coding: utf-8 -*- __author__ = 'caiqinxiong_cai' # 2019/9/2 15:21 import os import sys BASE_DIR = os.path.dirname(os.path.dirname(__file__)) sys.path.append(BASE_DIR) from core import ftp_server as fs if __name__ == '__main__': fs.runServer()
# -*- coding: utf-8 -*- __author__ = 'caiqinxiong_cai' # 2019/9/2 15:20 import os import sys BASE_DIR = os.path.dirname(os.path.dirname(__file__)) sys.path.append(BASE_DIR) from core.ftp_client import FtpClient as fc from core.log import Log as log from core.auth import Auth as at def main(): '''主逻辑''' head = '*' * 20 + '\n欢迎来到FTP系统!\n' + '*' * 20 print('\033[35;1m%s\033[0m' % head) opt_list = [('登录','login'),('注册','register'),('退出','quit')] while True: for index,opt in enumerate(opt_list,1):print('\033[35;1m%s、%s\033[0m' % (index, opt[0])) # 打印操作列表信息 try: num = int(input( '请输入您要选择的操作序号:')) if hasattr(at(),opt_list[num-1][1]):return getattr(at(),opt_list[num-1][1])() # 反射 except ValueError as e: log.error('%s不是效数字!!' % e) except IndexError as e: log.error('%s\n请输入1-12的有效数字!!' % e) if __name__ == '__main__': ret = main() if ret:fc(ret).clientView()
conf目录:
# -*- coding: utf-8 -*- __author__ = 'caiqinxiong_cai' # 2019/9/2 10:30 import os import time BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # 服务器ip地址和端口 IP_PORT = ('127.0.0.1',8888) # 默认用户磁盘配额大小为1G,化为字节 QUOTA = '1073741824' # 数据库路径 DB_PATH = r'%s/db' % BASE_DIR if not os.path.exists(DB_PATH):os.makedirs(DB_PATH) # 用户信息文件 USER_FILE = r'%s/users_info' % DB_PATH # 用户家目录 USER_HOME = lambda name:'%s/users_home/%s' % (DB_PATH,name) # 用户客户端目录 USER_CLIENT = lambda name:'%s/client_home' % USER_HOME(name) # 用户服务器目录 USER_SERVER = lambda name:'%s/server_home' % USER_HOME(name) # 日志文件路径 LOG_PATH = os.path.join(BASE_DIR,'log') if not os.path.exists(LOG_PATH):os.makedirs(LOG_PATH) LOG_FILE = r'%s/%s-log' % (LOG_PATH,time.strftime('%Y-%m-%d', time.localtime(time.time())))
core目录:
# -*- coding: utf-8 -*- __author__ = 'caiqinxiong_cai' # 2019/9/2 10:33 import os import hashlib from conf import settings as ss from core.log import Log as log class Auth: '''身份验证类''' def __init__(self): pass @staticmethod def writeInfo(file,content): '''写入信息''' with open(file,mode='a',encoding='utf-8') as f: f.write(content) @staticmethod def readInfo(file): '''读取信息''' if not os.path.exists(ss.USER_FILE):return with open(file,mode='r',encoding='utf-8') as f: for line in f: usr, pwd, quota= line.strip().split('|') yield usr, pwd, quota @staticmethod def changeMD5(content,name): '''MD5加密''' md5 = hashlib.md5(('MD5加盐,加上用户%s' % name).encode('utf-8')) md5.update(content.encode('utf-8')) return md5.hexdigest() def __auth(self,kind): '''身份认证''' for i in range(3): name = input('请输入用户名:').strip() password = Auth.changeMD5(input('请输入密码:').strip(),name) for n,p,q in Auth.readInfo(ss.USER_FILE): if kind == '登录' and name == n and password == p: log.readAndWrite('%s%s成功!' %(name,kind)) return name elif kind == '注册' and name == n: log.warning('%s用户已存在,请重新注册!' % name) break else: if kind == '注册': content = name + '|' + password + '|' + ss.QUOTA + '\n' Auth.writeInfo(ss.USER_FILE,content) log.readAndWrite('%s%s成功!' %(name,kind)) return name log.debug('%s%s失败!' % (name,kind)) return False def login(self): '''登录''' return self.__auth('登录') def register(self): '''注册''' return self.__auth('注册')
# -*- coding: utf-8 -*- __author__ = 'caiqinxiong_cai' # 2019/9/3 14:35 import struct import json import os import sys import hashlib from core.auth import Auth as at from core.log import Log as log class Common: '''公共类''' def __init__(self): pass @staticmethod def mySend(conn,msgb): '''发送数据时,解决粘包问题''' len_msg = len(msgb) pack_len = struct.pack('i', len_msg) conn.send(pack_len) conn.send(msgb) @staticmethod def myRecv(conn): '''接收数据时,解决粘包问题''' pack_len = conn.recv(4) #struct机制,在发送数据前,加上固定长度4字节的头部 len_msg = struct.unpack('i', pack_len)[0] # 解包,得到元组。 msg = conn.recv(len_msg) return msg @staticmethod def processBar(num, total): '''打印进度条''' rate = num / total rate_num = int(rate * 100) bar = ('>' * rate_num, rate_num,) # 展示的进度条符号 r = '\r%s>%d%%\n' % bar if rate_num == 100 else '\r%s>%d%%' % bar sys.stdout.write(r) # 覆盖写入 return sys.stdout.flush # 实时刷新 @staticmethod def updateQuota(file,name,quota_new): '''更新磁盘配额''' with open(file,mode='r',encoding='utf-8') as f1,open(file + '.bak',mode='w',encoding='utf-8') as f2: for line in f1: if line.strip(): if name in line: usr, pwd, quota_old= line.split('|') line = usr + '|' + pwd + '|' + quota_new + '\n' f2.write(line) os.remove(file) os.rename(file+'.bak',file) @staticmethod def checkQuota(file,name,size): '''检查磁盘配额''' for n,p,q in at.readInfo(file): if name == n: log.debug('用户%s当前磁盘配额剩余:%s字节\n下载文件大小为:%s字节' % (name,q,size)) num = int(q) - int(size) if num < 0: log.warning('磁盘配额不足!') return False else: return str(num) @classmethod def getFile(cls,conn): '''接收文件''' file_dic = cls.myRecv(conn) # 接收数据,解决粘包函数 dic = json.loads(file_dic.decode()) # 将接收到的二进制先转换成字符串,再loads还原字典 md5 = hashlib.md5() # 接收数据时,添加MD5校验,就不用再单独打开一次文件做校验了 total = dic['filesize'] num = 0 with open(dic['filename'],mode='wb') as f: while dic['filesize']>0: file_content = cls.myRecv(conn) dic['filesize'] -= len(file_content) # 剩余接收文件大小 f.write(file_content) num += len(file_content) # 累计发送文件大小 cls.processBar(num,total) # 进度条 md5.update(file_content) ret = md5.hexdigest() # 自己的MD5值 cls.mySend(conn,ret.encode())# 发送MD5值给对方做校验 ret_r = cls.myRecv(conn).decode() # 接收对方的MD5值 check = 'MD5校验OK,文件传输成功!' if ret == ret_r else 'MD5不一致,文件传输失败!' return (ret,dic['filename'],check) @classmethod def putFile(cls,conn,file,put_path,name): '''发送文件''' # 输入需要发送的文件,获取并发送文件大小 put_path = put_path(name) # 文件存储路径 if not os.path.exists(put_path):os.makedirs(put_path) file_name = os.path.join(put_path,os.path.basename(file)) file_size = os.path.getsize(file) # 获取文件总字节大小 dic = {'filename':file_name,'filesize':file_size} dic_b = json.dumps(dic).encode() # 将字典dumps成字符串,再转换成byte。网络传输只能传输byte哦。 cls.mySend(conn,dic_b)# 发送数据,解决粘包函数 md5 = hashlib.md5() # 发送数据时,添加MD5校验,就不用再单独打开一次文件做校验了 num = 0 with open(file,mode = 'rb') as f: for line in f: # if line.strip():不能添加判断,要不导致发送的数据不全,文件内容不管是什么都给发送过去就行 cls.mySend(conn,line) num += len(line) # 累计发送文件大小 cls.processBar(num,file_size) md5.update(line) ret = md5.hexdigest() # 自己发送数据的MD5值 cls.mySend(conn,ret.encode())# 发送MD5值给对方做校验 ret_r = cls.myRecv(conn).decode() # 接收对方的MD5值 check = 'MD5校验OK,文件传输成功!' if ret == ret_r else 'MD5不一致,文件传输失败!' return (ret,file_name,check)
# -*- coding: utf-8 -*- __author__ = 'caiqinxiong_cai' # 2019/9/2 15:23 import sys,os,socket,hashlib,time,json import struct from conf import settings as ss from core.log import Log as log from core.common import Common as cn class FtpClient: '''FTP客户端类''' def __init__(self,name): self.name = name self.sk = socket.socket() self.sk.connect(ss.IP_PORT) self.pwd_path = ss.USER_HOME(self.name) if not os.path.exists(self.pwd_path):os.makedirs(self.pwd_path) os.chdir(self.pwd_path) def putFile(self): '''上传文件到服务器''' file = input('请输入要上传到服务器的文件路径:').strip() if not os.path.isfile(file): cn.mySend(self.sk,b'error') return log.error('%s文件不存在!' % file) opt_dict = {'action':'getFile', 'file_path':file, 'name':self.name} dic_str = json.dumps(opt_dict) dic_b = dic_str.encode() cn.mySend(self.sk,dic_b) # 将执行命令发送给服务器,服务执行相应函数 log.debug('开始上传%s到服务器!' % file) ret = cn.putFile(self.sk,file,ss.USER_SERVER,self.name) if not ret[-1].find('失败') < 0: log.warning(ret[-1]) else: log.readAndWrite('%s\n文件已上传至服务器,路径如下:\n%s\nMD5值为:%s' % (ret[-1],ret[1],ret[0])) def getFile(self): '''接收从服务器下载的文件''' file = input('请输入要从服务器下载的文件路径:').strip() if not os.path.isfile(file): cn.mySend(self.sk,b'error') return log.error('%s文件不存在!' % file) file_size = os.path.getsize(file) # 获取文件总字节大小 quota = cn.checkQuota(ss.USER_FILE,self.name,file_size) if not quota:return log.debug('磁盘配额不足,文件传输失败!') opt_dict = {'action':'putFile', 'file_path':file, 'name':self.name} dic_str = json.dumps(opt_dict) dic_b = dic_str.encode() cn.mySend(self.sk,dic_b) # 将执行命令发送给服务器,服务执行相应函数 log.debug('开始服务器中下载文件!' ) ret = cn.getFile(self.sk) if not ret[-1].find('失败') < 0: log.warning(ret[-1]) else: cn.updateQuota(ss.USER_FILE,self.name,quota) # 更新磁盘配额 log.readAndWrite('%s\n磁盘配额剩余%s字节\n文件已从服务器下载到:\n%s\nMD5值为:%s' % (ret[-1],quota,ret[1],ret[0])) def viewDir(self): '''查看当前目录''' log.debug('%s目录信息如下:' % self.pwd_path) for index,name in enumerate(os.listdir(self.pwd_path),1): path = os.path.join(self.pwd_path,name) if os.path.isfile(path): log.debug('文件%s:%s' % (index, name)) elif os.path.isdir(path): log.debug('目录%s:%s' % (index, name)) def mkdir(self): '''创建目录''' name = input('请输入新建文件夹名称:') if os.path.exists(os.path.abspath(name)): log.warning('%s目录已存在!' % name) else: os.mkdir(name) log.readAndWrite('%s目录创建成功!' % os.path.abspath(name)) def rmdir(self): '''删除空目录''' name = input('请输入要删除的空文件夹名称:') try: os.rmdir(os.path.abspath(name)) log.readAndWrite('%s目录删除成功!' % name) except OSError as e: log.warning('目录不存在或目录不为空!\n%s' % e) def rmfile(self): '''删除文件''' name = input('请输入要删除的文件名称:') name = os.path.abspath(name) if os.path.isfile(name): os.remove(name) log.readAndWrite('%s文件删除成功!' % name) else: log.warning('%s文件不存在!' % name) def changeDir(self): '''切换子目录''' name = input('请输入切换目录名称:') name = os.path.abspath(name) if os.path.isdir(name): os.chdir(name) self.pwd_path = name log.debug('已切换到%s' % name) else: log.warning('%s目录不存在!' % name) def quit(self): log.debug('谢谢使用!') cn.mySend(self.sk,b'exit') self.sk.close() exit(-1) def clientView(self): '''客户端视图''' head = '*' * 20 + '\n欢迎使用FTP服务器!\n' + '*' * 20 opt_list = [('上传文件','putFile'),('下载文件','getFile'),('查看当前目录信息','viewDir'),('创建目录','mkdir'), ('删除空目录','rmdir'),('删除文件','rmfile'),('切换子目录','changeDir'),('退出','quit')] while True: print('\033[35;1m%s\033[0m' % head) for index,opt in enumerate(opt_list,1):print('\033[35;1m%s、%s\033[0m' % (index, opt[0])) # 打印操作列表信息 try: num = int(input( '请输入您要选择的操作序号:')) if hasattr(self,opt_list[num-1][1]):getattr(self,opt_list[num-1][1])() # 反射 except ValueError as e: log.error('%s不是效数字!!' % e) except IndexError as e: log.error('%s\n请输入1-12的有效数字!!' % e) print('3秒后自动跳转回主页面!') time.sleep(3) # f = FtpClient('caiqinxiong') # f.clientView()
# -*- coding: utf-8 -*- __author__ = 'caiqinxiong_cai' # 2019/9/2 15:24 import socketserver import json from conf import settings as ss from core.log import Log as log from core.common import Common as cn class FtpServer(socketserver.BaseRequestHandler): def getFile(self,opt_dict): '''接收客户端上传的文件''' ret = cn.getFile(self.request) log.debug(ret[1]+'\n'+ret[2]+'\nMD5值:'+ret[0]) def putFile(self,opt_dict): '''从服务器下载文件到客户端''' ret = cn.putFile(self.request,opt_dict['file_path'],ss.USER_CLIENT,opt_dict['name']) log.debug(ret[1]+'\n'+ret[2]+'\nMD5值:'+ret[0]) def handle(self): '''方法重写,重写handle函数,启动socketserver时执行该函数,看一下socketserver源码''' while True: try: log.readAndWrite("客户端%s已链接!" % (self.client_address[0])) log.debug('等待客户端发送操作命令...') dic_str = cn.myRecv(self.request).decode()# 接收客户端发送指令 if not dic_str.find('exit') < 0: log.readAndWrite("客户端%s已主动断开链接!" % (self.client_address[0])) break elif not dic_str.find('error') < 0: log.warning('客户端操作错误!')# 客户端报错时,服务器不进行任何操作! else: opt_dict = json.loads(dic_str) if hasattr(self,opt_dict['action']):getattr(self,opt_dict['action'])(opt_dict) except ConnectionResetError as e: log.error("%s客户端已断开%s"%(self.client_address,e)) break def runServer(): '''启动FTP服务器''' server = socketserver.ThreadingTCPServer(ss.IP_PORT,FtpServer) server.serve_forever()
# -*- coding: utf-8 -*- __author__ = 'caiqinxiong_cai' # 2019/8/26 15:37 import logging import sys import time from logging import handlers from conf import settings as ss class Log(object): ''' https://cloud.tencent.com/developer/article/1354396 ''' now_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())) sh = logging.StreamHandler() # 既打印输入又写入文件 # rh = handlers.RotatingFileHandler(ss.log_file, maxBytes=1024,backupCount=5) # 按大小切换日志,保留5份 fh = handlers.TimedRotatingFileHandler(filename=ss.LOG_FILE, when='D', backupCount=5, interval=5,encoding='utf-8') # 按时间切割日志 logging.basicConfig(level=logging.WARNING, # 打印日志级别 handlers=[fh, sh], datefmt='%Y-%m-%d %H:%M:%S', format='%(asctime)s - %(name)s - %(levelname)s -%(module)s: %(message)s') # [%(lineno)d] 只显示当前文件的行号 @staticmethod def writeOnly(content): '''自定义函数,只写入日志文件''' with open(ss.LOG_FILE, mode='a', encoding='utf-8') as f: f.write(Log.now_time + '\t' + str(content) + '\n') @staticmethod def readOnly(content): '''自定义函数,只打印日志''' print('\033[36;1m%s\033[0m' % content) @classmethod def readAndWrite(cls,content): '''自定义函数,既打印信息又记录log文件''' cls.readOnly(content) cls.writeOnly('[INFO]\t' + content) @classmethod def debug(cls, content): # return logging.debug(content) return cls.readOnly(content) @classmethod def info(cls, content): # return logging.info(content) return cls.writeOnly('[INFO]\t' + content) # info信息直接写入log文件 @staticmethod def warning(content): return logging.warning(content) @staticmethod def error(content): # 获取调用函数的文件名和行数 head = '%s line%s error!\n' % (sys._getframe().f_back.f_code.co_filename, sys._getframe().f_back.f_lineno) return logging.error(head + content) @staticmethod def critical(content): head = '%s line%s critical!\n' % (sys._getframe().f_back.f_code.co_filename, sys._getframe().f_back.f_lineno) return logging.critical(head + content)
这次作业还有好多需要优化的地方,但时间来不及了,先记一下,后续再继续优化。
1、断点续传
2、目录切换最上层判断
3、登录认证应该由客户端发送用户名和密码,服务器接收数据并验证。
4、文件操作不够友好,访问目录时,应该为分服务器的目录(异地)和本地客户端目录。
浙公网安备 33010602011771号