题目:IO多路复用版FTP


需求:


实现文件上传及下载功能

支持多连接并发传文件

使用select or selectors


前言:

实现多并发的方式,可以使用多进程,多线程,协程。

多进程启动慢,耗资源;多线程存在并发修改同一份数据死锁问题;协程实际上是单线程,无法利用多核资源,一遇到阻塞会将整个任务都阻塞,但并发数最高。

由于IO任务不耗CPU资源,CPU密集型任务适合使用多进程,IO密集型任务适合使用多进程,也可使用协程,没有多线程的死锁问题,但是代码会更复杂。

select、selectors是网络编程中的I/O multiplexing模块,实现了协程机制,通过轮询调度任务,可以达到单线程多并发操作。

selectors基于select,这里使用selectors来构建FTP服务器


设计说明:

1、server工作在non-blocking模式,client工作在blocking模式。

2、socket默认是blocking模式,send和recv操作在数据发送或者接收完成前会一直处于等待状态。而在non-blocking模式下,程序一遇到堵塞直接抛出BlockingIOError错误,server利用这个exception退出当前任务,再通过selectors调度执行其他任务,实现伪多并发操作。

3、selectors轮询调度任务需要先注册事件,命令是register(sock,EVENT,method),当sock激活时,自动执行method。若要改变sock对应事件,使用modify(sock,EVENT,new_method),不能使用同一个sock注册不同的事件。

4、EVENT分selectors.EVENT_READ和selectors.EVENT_WRITE,EVENT_READ需要client端有请求过来才会被激活,EVENT_WRITE是server端在轮询到该任务时会主动去探测client端口是否可写,需要区分正确。

5、register(sock,EVENT,method)如果方法中需要传入参数,将参数存放在一个字典里,要用的时候从里面取出来,用完后删除。(我想应该还可以使用(sock,EVENT,method(sock,args*))方式,但未验证)

6、non-blocking模式下,无法保证send的数据完整全面,send的过程中遇到堵塞,在缓存中被发送出去的数据可能只是一部分,切换到其他任务时缓存中的数据被清空。为保证传输文件的完整全面,利用conn.send()会返回成功发送字节数,通过累计这个值,定位下次文件读取的cursor位置。


server端

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import selectors
import socket
import json,os

class FtpServer(object):
    def __init__(self,sock):
        '''
        :param upload_dict 用于存放上传文件状态信息参数,在read()中有详细说明
        :param download_dict 用于存放下载文件状态信息参数,在read()中有详细说明
        '''
        self.sel = selectors.DefaultSelector()
        self.sock = sock
        self.upload_dict = {}
        self.download_dict = {}


    def start(self):
        '''
        :param key.data 指的是调用的方法
        :param key.fileobj  指的是传入的sock
        :param mask 无引用或指向,无实际意义。
        '''
        self.sock.listen(10)
        self.sock.setblocking(False)
        self.sel.register(sock, selectors.EVENT_READ, self.accept)
        while True:
            events = self.sel.select()
            for key, mask in events:
                callback = key.data
                callback(key.fileobj)

    def accept(self,sock):
        conn, addr = sock.accept()
        print('accepted', conn, 'from', addr)
        conn.setblocking(False)
        self.sel.register(conn, selectors.EVENT_READ, self.read)

    def read(self,conn):
        '''
        判断输入的命令,然后将sock状态设置为读或写,直到任务结束再由任务自己调整回来
        将文件读写open操作放在这里,是为了避免因BlockingIOError多次打开和关闭文件
        :param  upload_dict = {conn:{   "motion": "get",
                                        "filename": filename,
                                        "filesize":0,
                                        "md5":md5,
                                        "recv_size":recv_size,      已接收的字节数
                                        "wfd":fd                    打开用于写入的文件句柄
                                        }}
        
        :param download_dict = {conn:{   "motion": "get",
                                        "filename": filename,
                                        "filesize":0,
                                        "md5":md5,
                                        "send_size":send_size,      作为传输中断后再次传输时,cursor的锚位
                                        "rfd":fd                    打开用于读的文件句柄
                                        }}
        '''
        try:
            data = conn.recv(1024)
            print("read:", conn,"data:",data)
            if data:
                try:
                    msg = json.loads(data.decode("utf-8"))
                    #print("msg:",msg,"from conn:",conn)
                    motion = msg["motion"]
                    filename = msg["filename"]

                    if "put" == motion:
                        msg["recv_size"] = 0
                        wfd = open(filename, "wb")
                        msg["wfd"] = wfd
                        self.upload_dict[conn]=msg
                        conn.send(b'ready to receive')
                        self.sel.modify(conn, selectors.EVENT_READ, self.upload)


                    elif "get" == motion:
                        filesize = os.path.getsize(filename)
                        filemd5 = os.popen('md5sum %s' % filename).read().split()[0]
                        msg["filesize"] = filesize
                        msg["md5"] = filemd5
                        print(msg)
                        conn.send(json.dumps(msg).encode("utf-8"))
                        msg["send_size"] = 0
                        rfd = open(filename, "rb")
                        msg["rfd"] = rfd
                        self.download_dict[conn] = msg
                        self.sel.modify(conn, selectors.EVENT_WRITE, self.download)
                    else:
                        print('func matching miss:',msg)
                except Exception as e:
                    print('error:',e)
                    conn.send(data)
            else:
                print('closing:', conn,'data:',data)
                self.sel.unregister(conn)
                conn.close()
        except ConnectionResetError:
            print('client error,closing.', conn)
            self.sel.unregister(conn)
            conn.close()

    def upload(self,conn):
        '''
        非阻塞模式下,server在遇到等待,就会直接报出BlockingIOError: [Errno 11] Resource temporarily unavailable,利用该报错退出当前任务
        文件在writing的过程中可能会因BlockingIOError中断多次,每次中断时都将当期状态信息保存在upload_dict中
        '''
        filename = self.upload_dict[conn]["filename"]
        filesize = self.upload_dict[conn]["filesize"]
        f = self.upload_dict[conn]["wfd"]
        recv_size = self.upload_dict[conn]["recv_size"]
        while filesize > recv_size:
            if filesize - recv_size > 1024:
                n = 1024
            else:
                n = filesize - recv_size
            try:
                r_data = conn.recv(n)
                f.write(r_data)
                recv_size += len(r_data)
                self.upload_dict[conn]["recv_size"] = recv_size
                if filesize == recv_size:
                    f.close()
                    filemd5 = os.popen('md5sum %s' % filename).read().split()[0]
                    print("receive complete! filename:",filename,"filesize", filesize,"recv_filemd5:",filemd5,"original_filemd5:",self.upload_dict[conn]["md5"])
                    del self.upload_dict[conn]
                    self.sel.modify(conn, selectors.EVENT_READ, self.read)
            except BlockingIOError:
                break

    def download(self,conn):
        '''
        non-blocking模式下,发送一旦遇到exception,send操作可能只发送部分数据出去,不能用len(line)来统计发送的字节数,即使是放在send之后统计
        :param sendsize 发送出去的数据字节
        :return 遇到IO等待事件,让出队列,让其他任务执行
        :exception BlockingIOError  IO遇到堵塞报的错,退出当前任务
        :exception ConnectionResetError 客户端断开连接报的错,执行收尾工作
        '''
        f = self.download_dict[conn]["rfd"]
        f.seek(self.download_dict[conn]["send_size"])
        try:
            while self.download_dict[conn]["send_size"] < self.download_dict[conn]["filesize"]:
                    for line in f:
                        sendsize=conn.send(line)                            ####这步非常重要,sendsize不一定等于len(line)
                        self.download_dict[conn]["send_size"] += sendsize

                    if self.download_dict[conn]["send_size"] == self.download_dict[conn]["filesize"]:
                        f.close()
                        del self.download_dict[conn]
                        print("sending complete!")
                        self.sel.modify(conn, selectors.EVENT_READ, self.read)
                        break
        except BlockingIOError:
            return
        # except ConnectionResetError:
        #     print("lose connection:",conn)
        #     f.close()
        #     del self.download_dict[conn]
        #     self.sel.modify(conn, selectors.EVENT_READ, self.read)


if __name__ =="__main__":
    sock = socket.socket()
    sock.bind(('0.0.0.0',9999))
    s = FtpServer(sock)
    s.start()


client端

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import socket
import sys
import os
import json

def ProcessBar(part, total):  ####进度条模块,只能在Linux下使用,并且窗口宽度要足够宽,否则会变成多行显示
    if total != 0:
        i = round(part * 100 / total)
        sys.stdout.write(
            '[' + '>' * i + '-' * (100 - i) + ']' + str(i) + '%' + ' ' * 3 + str(part) + '/' + str(total) + '\r')
        sys.stdout.flush()

class FtpClient(object):
    def __init__(self,conn):
        self.conn = conn

    def get(self,filename):
        msg_send = {"motion": "get",
                    "filename": filename,
                    "filesize":0,
                    "md5":''}
        self.conn.send(json.dumps(msg_send).encode("utf-8"))
        msg_recv = self.conn.recv(1024)
        msg=json.loads(msg_recv.decode("utf-8"))
        filesize = msg["filesize"]
        filemd5 = msg["md5"]
        recv_size = 0
        f = open(filename,"wb")
        while filesize > recv_size:
            if filesize - recv_size > 1024:
                n = 1024
            else:
                n = filesize - recv_size
            r_data = self.conn.recv(n)
            f.write(r_data)
            recv_size += len(r_data)
            ProcessBar(recv_size, filesize)
        f.close()
        recv_filemd5 = os.popen('md5sum %s' % filename).read().split()[0]
        print("filename:", filename, "filesize", filesize, "recv_size:", recv_size,"recv_filemd5:",recv_filemd5,"original_filemd5:",filemd5)

    def put(self,filename):
        filesize = os.path.getsize(filename)
        filemd5 = os.popen('md5sum %s' % filename).read().split()[0]
        msg = {"motion":"put",
               "filename":filename,
               "filesize":filesize,
               "md5":filemd5}
        self.conn.send(json.dumps(msg).encode("utf-8"))
        self.conn.recv(1024)    ####防止粘包信号
        sendsize = 0
        f = open(filename, "rb")
        for line in f:
            self.conn.send(line)
            sendsize += len(line)
            ProcessBar(sendsize, filesize)
        f.close()
        print(filename," transfer complete!")
    def interactive(self):
        while True:
            cmd = input(">>>:").strip()
            if len(cmd) == 0:
                continue
            elif cmd == "exit":
                exit()
            else:
                motion = cmd.split()[0]
                if hasattr(self,motion):
                    filename = cmd.split()[1]
                    func = getattr(self,motion)
                    func(filename)
                else:
                    self.conn.send(json.dumps(cmd).encode("utf-8"))
                    msg = self.conn.recv(1024)
                    print("Server feedback,command error:",json.loads(msg.decode("utf-8")))

if __name__ == "__main__":
    sock = socket.socket()
    sock.connect(('127.0.0.1',9999))
    c = FtpClient(sock)
    c.interactive()