前言
因为想要做一个能够满足多用户同时上传/下载文件的功能,并且带有权限功能,网上查了一些资料,尝试了用HTTP协议传输文件,用socket异步IO,多进程传输等方案,从中也学到了很多的东西,这里所述就是使用selectors 和socket模拟FTP协议实现文件的传输,最多可以同时满足10个传输链接。
1.HTTP传输
因为用Django,Flask,Tornado等web框架可以轻松实现文件的上传/下载,这种方法无疑是最容易实现的,我是使用Django做过尝试,但这种方案的问题在于:
- 大文件传输过程中可能链接中断
- 文件续传的实现比较复杂
- 大文件一个链接占用的时间过长
对于大文件上传时可以通过分包的方式上传,让每个包的大小适中,但是HTTP协议毕竟无状态,如果分包,后台任然需要对其进行整合,所以需要频繁的记录传输进度。尝试中文件大小超过20M就可能再传输过程中出现异常(尽管超过20M的可能性很小),从这里引发了思考,想通过别的方式来实现。
2.FTP传输
FTP专为传输文件而生,与HTTP不同,一次传输需要两个TCP链接,一个是命令链接,一个是传输链接,主动模式下,在命令链接进行权限确认等工作,完成后打开传输链接端口并告诉客户端传输链接所在端口,客户端链接传输端口,开始数据传输,这里最大的问题就在与并发问题,命令链接可以用异步IO的方式实现并发,但是传输过程是一对一的,所以要实现多个链接同时上传/下载就需要开启多个端口,小应用还好,大应用就头疼了。被动模式下传输端口由客户端决定,服务器去链接客户端规定的端口,切不说客户端防火墙的问题,这种客户端监听服务器的模式本来就存在很大的问题,但是解决了并发问题。
还有一点我觉得安全问题,感觉打开数据连接时可能会被别的链接窃取,当然也可以在数据链路加入一些认证方法。
3.多线程,多进程的异步方式和异步IO
多线程/多进程 简单粗暴,每个链接fork一个子进程或者开启一个子线程来处理,但是这种方法会浪费很多资源在监听链接请求上,并且进程或线程过多,CPU切换线程/进程开销就会加大很多。
异步IO 使用select,poll,epoll来实现异步IO,这种方法通过事件驱动的模式确实能够很好处理并发问题,但是:
比如使用epoll模型,那么每一次传输或者接收到数据后得记录当前传输进度并重新注册事件,实现起来很麻烦。
4.实现
经过思考后,我选择模拟FTP协议,命令链接使用异步IO进行验证和部分信息的获取,获取后开一个线程开启一个数据传输链路进行连接传输。逻辑框图如下:
命令链路和传输链路分别占用一个进程,进程间通信使用两个消息队列Queue一个队列中存放端口值(9000,9001……)另外一个存放相关的数据字典,包括文件名,文件大小等,下文简称端口队列,数据队列
命令端口获得连接后,查询端口队列是否为空,不为空则表示任有空闲的传输端口,之后将获得数据放入数据队列,此时传输端口进程监听到数据队列有数据,开启一个线程,线程中套接字绑定到对应传输端口等待客户端的链接,之后进行文件的传输,传输完成后,将本次链接端口号放入端口队列,退出线程。
代码如下:
class FtpServer:
def __init__(self,**kwds):
self.IpAddr=kwds['ipaddr']
self.Port=kwds['port']
self.QueuePorts=kwds['QueuePorts']
self.QueueInfors=kwds['QueueInfors']
self.sel=DefaultSelector()
self.Socket=socket.socket()
def connect(self):
self.Socket.bind((self.IpAddr,self.Port))
self.Socket.listen(100)
self.Socket.setblocking(False)
self.sel.register(self.Socket,EVENT_READ,self.accpet)
while True:
events=self.sel.select()
for key,mask in events:
callback = key.data
callback(key.fileobj, mask)
def accpet(self,sock,mask):
conn,addr=sock.accept()
conn.setblocking(False)
self.sel.register(conn,EVENT_READ,self.interface)
def Task_Upload(self,conn,size,name):
Error={}
if not self.QueuePorts.empty():
tmp = {}
tmp['port'] = self.QueuePorts.get()
tmp['action'] = 'Upload'
tmp['file_size']=size
tmp['extra']=name
self.QueueInfors.put(tmp) # 将信息传入消息队列 本次连接结束
conn.send(json.dumps({'status': 'True', 'port': tmp['port'],'size':size}).encode('utf-8')) # 将本次连接结果返回客户端
else:
Error['status'] = False
Error['reason'] = '服务器资源已占满,请稍后再试'
package = json.dumps(Error)
conn.send(package.encode('utf-8'))
def Task_Download(self,conn,path):
Error = {}
if os.path.isfile(path)==False:
Error['status'] = False
Error['reason'] = '不存在该路径'
package = json.dumps(Error)
conn.send(package.encode('utf-8'))
return
if not self.QueuePorts.empty():
tmp = {}
tmp['port'] = self.QueuePorts.get()
tmp['action'] = 'Download'
tmp['extra'] = path
tmp['file_size']=os.path.getsize(path)
self.QueueInfors.put(tmp) # 将信息传入消息队列 本次连接结束
conn.send(json.dumps({'status': 'True', 'port': tmp['port'],'size':tmp['file_size']}).encode('utf-8')) # 将本次连接结果返回客户端
else:
Error['status'] = False
Error['reason'] = '服务器资源已占满,请稍后再试'
package = json.dumps(Error)
conn.send(package.encode('utf-8'))
def interface(self,conn,mask):
Error={}
date=None
try:
date=conn.recv(1024)
except Exception as e:
print(e)
if date:
package=json.loads(date.decode('utf-8'))
action=package.get('action',None)
paths=package.get('path',None)
if action=='Download':
self.Task_Download(conn,paths)
elif action=='Upload':
filesize=package.get('filesize',None)
self.Task_Upload(conn,filesize,package['filename'])
else:
Error['status'] = False
Error['reason'] = '错误的指令'
package = json.dumps(Error)
conn.send(package.encode('utf-8'))
else:
print('lost connect')
self.sel.unregister(conn)
conn.close()
这个是命令链路,进行相关数据的收集,判断是否由空闲端口,并返回一个结果到客户端,包括数据端口等信息。
class TransServer:#需要修的太多 不继承了
def __init__(self,**kwd):
self.IpAddr=kwd['ipaddr']
self.Port=kwd['port']
self.action=kwd['action']
self.extra=kwd['extra']
self.file_size=kwd['file_size']
self.extra=kwd['extra']
self.sel = DefaultSelector()
self.Socket = socket.socket()
self.block=True
def connect(self):
self.Socket.bind((self.IpAddr,self.Port))
self.Socket.listen(10)
self.Socket.setblocking(True)
self.sel.register(self.Socket,EVENT_READ,self.accpet)
while self.block:
events=self.sel.select()
for key,mask in events:
callback = key.data
callback(key.fileobj, mask)
print('端口关闭')
self.sel.close()#只进行一次连接
def accpet(self,sock,mask):
conn,addr=sock.accept()
conn.setblocking(True)
if(self.action=='Download'):
self.sel.register(conn,EVENT_WRITE,self.Download)
elif(self.action=='Upload'):
self.sel.register(conn, EVENT_WRITE, self.Upload)
def BeforeUpload(self,conn):#即将进行上传前
pass
def BeforeDownload(self,conn):#即将进行下载前
pass
def Upload(self,conn,mask):#处理上传问题
file_size = self.file_size
file_name = self.extra
file = open(SAVEPATH + file_name, 'wb') # 打开文件夹
rest_size = file_size
conn.send('ACK'.encode('utf-8'))
while rest_size > 0:
try:
part = conn.recv(1024 * 1024)
if part:
file.write(part)
rest_size -= len(part)
print('rest', rest_size)
else:
raise ValueError('传输错误')
except Exception as e:
print(e)
break
print('task complete')
self.block = False
def Download(self,conn,mask):#处理下载问题
#改进 这里不再需要获取文件相关信息,只需要进行下载
file = open(self.extra, 'rb')
file_size=self.file_size
try:
date=conn.recv(1024).decode('utf-8')
if date!='ACK':
raise ValueError('返回信号不正确')
except Exception as e:
print(e)
self.block=False
return
now_size = 0
for line in file:
while True:
try:
conn.send(line)
now_size += len(line)
print('all:%s rest:%s' % (file_size, int(file_size) - now_size))
break
except Exception as e:
print(e)
self.block = False
return
print('compelet')
self.block = False
def AfterUpload(self,conn):#上传完成后
pass
def AfterDownload(self,conn):#下载完成后
pass
数据传输类,完成数据的上传和下载,该链接中只需要一个客户端的响应信号就可以进行传输,不再需要验证和交互,为了减少文件所占用的内存,所以使用分片传输的方法,这里顺带一提的是文件分片传输file.read(size)可以指定读取大小,但是可能出现文件缺失问题,并且内存回收也不好,所以全权交给解释器去完成。
使用方法
def Task_Ftp(*args):
Ports=args[1]
infor=args[0]
x=FtpServer(ipaddr=Host,port=8001,QueuePorts=Ports,QueueInfors=infor)
try:
x.connect()
except Exception as e:
print(e)
print('连接中断')
def open_server(*args):
print('open server',args)
server=TransServer(ipaddr=Host,port=args[1],action=args[2],extra=args[3],file_size=args[4])
server.connect()
print('放回端口到队列中')
args[0].put(args[1])#端口已经用完了 放回去
def Task_Trans(*args):
Ports=args[1]
infor=args[0]#path action
while True:
if not infor.empty():
date=infor.get()
print('Get Task:%s'%date)
x = Thread(target=open_server,args=[Ports, date['port'], date['action'], date['extra'],date['file_size']])
x.start()
time.sleep(0.2)
if __name__=='__main__':
s=Queue()#文件路径信息
b=Queue()#可用端口
s.empty()
for i in range(10):#放入开放的端口
b.put(i+9000)
CmdServer=Process(target=Task_Ftp,args=[s,b])
Trans=Process(target=Task_Trans,args=[s,b])
CmdServer.start()
Trans.start()
CmdServer.join()
这里包括了消息队列的创建,线程的启动。
客户端
简单的实现文件:
from socket import socket
import json
import os,sys
import time
SAVEPATH='d://Resource/'
class MyClient:
def __init__(self,**kwds):
self._Cmd=socket()
self._Trans=socket()
print('Client system init!')
def _Connect(self,**kwds):
try:
self._Cmd.connect((kwds['Ipaddr'],int(kwds['Port'])))
self.ipaddr=kwds['Ipaddr']
return True
except Exception as e:
print(e)
return False
def _CheckPath(self,Path):
if os.path.isfile(Path):
return True
else:
return False
def _Cmd_Upload(self,Path,filename):
date={}
date['action'] = 'Upload'
date['filesize']=os.path.getsize(Path)#上传文件大小
date['filename']=filename
date=json.dumps(date)#打包成JSON
self._Cmd.send(date.encode('utf-8'))
callback=self._Cmd.recv(1024)#接受反馈数据
callback=json.loads(callback.decode('utf-8'))
if callback['status']:
print('服务器响应正确')
time.sleep(0.5)
try:
self._Trans.connect((self.ipaddr,callback['port']))
except Exception as e:
print(e)
print('链接错误')
return False
callback=self._Trans.recv(1024)
if callback.decode('utf-8')!='ACK':
print('服务器响应不正确 ')
self._Trans.close()
return False
file_size=os.path.getsize(Path)
file=open(Path,'rb')
for i in file:
try:
self._Trans.send(i)
file_size -= len(i)
print('剩余:%s' % file_size)
except Exception as e:
print(e)
time.sleep(0.1)
self._Trans.close()
print('本次传输完成!')
else:
print('Error!')
print('Reason',callback['reason'])
return False
def _Cmd_Download(self,Path):
date={}
date['action']='Download'
date['path']=Path
package=json.dumps(date)
package=package.encode('utf-8')
self._Cmd.send(package)
callback=self._Cmd.recv(1024)
callback=callback.decode('utf-8')
callback=json.loads(callback)
if callback['status']=='True':
file_size=callback['size']
time.sleep(1)
try:
self._Trans.connect((self.ipaddr,callback['port']))
except Exception as e:
print(e)
print('链接失败')
return
file=open(SAVEPATH+'test.sm','wb')
time.sleep(0.5)
self._Trans.send('ACK'.encode('utf-8'))
while file_size>0:
try:
date=self._Trans.recv(1024*1024)
if date:
file_size-=len(date)
file.write(date)
print('reset:%s'%file_size)
else:
raise ValueError('传输错误')
except Exception as e:
print(e)
print('lost connect')
break
self._Trans.close()
file.close()
else:
print('ERROR')
print(callback)
print('reason',callback['reason'])
return
def Shell(self):
while True:
Ipaddr=input('输入服务器所在ip地址:')
Port=input('输入服务器端口:')
print('尝试连接......')
if self._Connect(Ipaddr=Ipaddr,Port=Port):
break
print('连接成功')
while True:
Ord=input('1.上传文件\n2.下载文件\n3.退出\n')
if Ord=='1':
Path=input('输入本地文件地址(绝对路径):')
if self._CheckPath(Path):
filename = input('输入上传文件名:')
self._Cmd_Upload(Path,filename)
self._Trans.close()
self._Trans=socket()
else:
print('该路径不存在,请检查')
elif Ord=='2':
Path=input('输入需要下载的文件地址(绝对路径):')
self._Cmd_Download(Path)
self._Trans.close()
self._Trans=socket()
elif Ord=='3':
return
else:
print('无效指令')
if __name__=='__main__':
if os.path.exists(SAVEPATH)==False:
print('指定的下载存放路径不正确')
sys.exit(0)
x=MyClient()
x.Shell()
print('Bye!')
sys.exit(0)
运行后即可按照规定的协议与服务器进行交互。
GitHub
git@github.com:shuhaveabigdream/ftpserver.git
思考
对该项目的测试,我最多只做到了同时3个链接上传/下载,文件最大位1.02GB,都没有什么问题,所以在性能足够的情况下,最多的并发数量应该有端口队列决定,对于身份验证这里并没有进行,可以在FtpServer类中进行扩展,断点传输实现也不困难通过file.seek(ptr)就可以实现,会在后续添加该功能,目前该项目最大的问题在与数据连接的安全问题,如果有第三方不断监听数据链接端口,那么就可能盗取本应由客户端提取的数据,并且传输过程中的控制也并没有实现,不能像如同,监听数据队列的进程依然会占用CPU时间片导致运行效率不高,不能实现FTP命令中的那些流程控制,这些我会在后面继续研究。
尽管做的很青涩,但是从思考到实现的过程中学到了不少的东西,从协议到IO模型到一些性能思考,所以想一万遍始终不如做一遍,用别人做好的东西虽然能够做的更好,更快,但却越过了本应该发现的问题和获得的进步。
425



被折叠的 条评论
为什么被折叠?



