女神博客链接: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()
start_server.py
# -*- 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()
start_client.py

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())))
settings.py

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('注册')
auth.py
# -*- 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)
common.py
# -*- 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()
ftp_client.py
# -*- 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()
ftp_server.py
# -*- 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)
log.py

 这次作业还有好多需要优化的地方,但时间来不及了,先记一下,后续再继续优化。

1、断点续传

2、目录切换最上层判断

3、登录认证应该由客户端发送用户名和密码,服务器接收数据并验证。

4、文件操作不够友好,访问目录时,应该为分服务器的目录(异地)和本地客户端目录。

posted on 2019-09-06 15:24  雨之夜&秋  阅读(253)  评论(0)    收藏  举报