表单中的文件上传
基本的表单渲染,表单类设置等等就不多说了,参看另一个文章即可。但是那篇文章里没有提到对于FileField,也就是上传文件的表单字段是如何处理,后端又是如何实现接受上传过来的文件的。因为看到了一篇很好的文章【https://zhuanlan.zhihu.com/p/23731819?refer=flask】,所以我决定仔细学习一下。下面将按照那篇文章的脉络,由简至繁地说明表单中文件上传的办法。
■ 利用Flask原生的机制进行文件上传
首先在前端肯定有一个带有文件上传功能的表单。这个表单如果利用wtfoms来实现的话那就是在forms里面得有FileField这个字段。FileField还可以加上validators=[DataRequired()]参数来保证没有文件选择时不进行POST动作。在表单点击确定之后,后端可以这么操作来得到文件对象:
uploaded_file = request.files['file']
#其中'file'是指表单中type是file的那个input的name属性的值。另外也可以:
uploaded_file = uploadForm.file.data
可以通过request对象的files属性的file字段的值来获得,也可以和其他表单字段获取数据的方式一样通过form对象来获得,两者取得的是同一个对象。这个对象是一个FileStorage对象,这个对象的属性有下面这些:
filename 上传上来的文件的名字
headers 上传文件的头信息
name 表单字段的名字
其中filename值得一用,比如在后面我可以写filename = os.path.join(app.config['UPLOAD_FOLDER'],uploaded_file.filename)来得到一个上传后文件的绝对路径。然后调用uploaded_file.save(filename)就可以把上传上来的文件的内容固化到服务器的硬盘中了。
因为涉及到文件,还是需要注意相对路径和绝对路径这个坑。比如我之前的路径都写的是static/download_data,意思是让文件全部都保存到项目的static目录下面,但是并没有什么用,因为相对路径是从工作目录而非脚本所在目录开始寻找的。为了保证程序得到绝对路径的信息同时又不想在程序里面写死的话,就要活用os.path.abspath,os.path.join,os.path.dirname等这些方法了。
可能已经注意到,对于一些配置我们可以放到app.config中去,比如UPLOAD_FOLDER可以指定上传上来的文件放到哪个目录下,ALLOWED_EXTENDSIONS可以赋它一个集合的值,对于不属于这个集合的后缀名的文件就不允许上传,只是这个功能还需要我们手动实现;MAX_CONTENT_LENGTH可以指定文件最大可以有多少字节等等。
在我们的后台得到了上传上来的文件之后,也许我们还要考虑开放一个接口让访问者能供访问到这些文件。这可能会用到flask自带的一个send_file或者send_from_directory两个方法。以后者为例,接受两个参数,第一个是本地的一个目录名,第二个是文件名。函数会自动读取这个文件并返回文件的内容:
@app.route('/upload/<filename>')
def uploaded_file(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'],filename)
这样我们在表单中上传后的文件,可以通过 /upload/文件名 来访问文件的内容了。
顺带一提,如果在send_from_directory中加上as_attachment=True的参数的话,那么就可以将文件作为一个待下载的文件发送给客户端了
■ 利用flask-uploads扩展进行文件上传
以上虽然利用flask原生的一些工具进行了文件上传功能的实现,但是不够完善,而且文件扩展名验证之类的工作还是要我们自己手动编码来完成。根据以往其他一些功能的经验,我们自然想到了有没有一个扩展是可以支持flask方便地实现上传文件的功能呢?答案是有的,就是flask-uploads。
用flask-uploads上传文件主要用到flask_uploads中的这些类:
from flask_uploads import UploadSet,configure_uploads,AUDIO,IMAGE
其中最重要的是UploadSet。这个方法返回了一个UploadSet对象,即上传文件集合。他可以这么用 myfile = UploadSet('MYFILE')。参数的字符串为这个上传文件集取了一个名字,这个名字在后续还将出现再等号左边,这是比较神奇的一个设定。比如按照上面那样有了myfile这个对象之后,可以在合适的地方为app对象配置:
from flask_uploads import UploadSet,configure_upload,AUDIO
#... myfile = UploadSet('MYFILE')
app.config['UPLOADED_MYFILE_DEST'] = os.path.join(os.path.dirname(__file__),'static','download_data')
app.config['UPLOADED_MYFILE_ALLOW'] = AUDIO configure_upload(app,myfile)
#别忘了这一步,把上传文件集的设置和app关联起来
可以看到,在为app进行config配置的时候,UPLOADED_%s_DEST和UPLOADED_%s_ALLOW中间的那串字符串是由之前UploadSet创建的时候定义的,两者分别规定了这个上传文件集存放的位置以及允许哪些后缀名的文件。那么就可以推断AUDIO的具体内容是什么了,其实就是一些后缀名的元组。因为是AUDIO,所以是类似于('.mp3','.mp4','.avi'...)之类的。在flask_uploads中还有很多这种预设的后缀名元组,比如IMAGE,TEXT等等,就不举例了。当相关配置全部完成后,记得最后还要configure_upload(app,myfile)来使得myfile和app关联起来。之前看到网上有人采坑,把这一步没有写到views里面而写在了forms里面去,这导致了报没有app上下文的错,需要注意。
*如果觉得上面的UPLOAD_%s_ALLOWd的方式来配置允许的格式太麻烦的话,也可以在myfile=Upload('MYFILE',AUDIO)这样第二个参数来直接指定某个UploadSet对象限定的后缀名。
那么他为什么要把这个放到forms里面去?因为在forms中,在flask_wtf中存在一个file子模块,里面含有一些文件相关的表单提交前验证方法(就像表单那篇中提到的DataReqiured,EqualTo等validator一样)。flask.wtf.file里面的这个FileAllowed验证方法接受的参数第一个就是一个UploadSet对象,第二个和其他validator类似是messasge。因为需要这个UploadSet对象,所以为了方便,就把创建UploadSet对象的工作放到了forms里。但是为了避免缺少上下文的那个错误,建议还是放在views里面创建,然后让forms导入;或者把configure_upload和UploadSet两个方法分在两个文件中写,注意引用关系不报错即可。
这样一来,在views和forms以及前端表单的配合下,一个较为完善,模块化程度较高的上传文件功能就做好了。:
##########views.py中部分代码###########
from forms import myfile app.config['UPLOAD_MYFILE_DEST'] = os.path.join(os.path.dirname(__file__),'static','downloaddata')
app.config['UPLOAD_MYFILE_ALLOW'] = AUDIO configure_upload(app,myfile) @app.route('/form',methods=['GET','POST'])
def form():
uploadForm = UploadForm()
if uploadForm.validate_on_submit():
myfile.save(uploadForm.file.data,name=uploadForm.file.data.filename)
#请注意保存文件时是用了UploadSet对象调用了save方法,而且这个save方法的第一个参数是文件对象,第二个参数是文件名
return render_template('form.html',form=uploadForm) @app.route('/show/<filename>')
def show(filename):
return '<a href="{0}">文件</a>'.format(myfile.url(filename)) ##########forms.py中部分代码#########
from flask_wtf.file import FileField,FileAllowed,FileRequired
#请注意,如果要在FileField中加上FileAllowed等验证函数的话,就不能从wtforms中导入Field类,而是必须从flask_wtf.file中,否则会报错没有has_file方法。
myfile = UploadSet('MYFILE') class uploadForm():
file = FileField(u'上传文件',validators=[FileAllowed(myfile,u'文件格式不对'),FileRequired()])
submit = SubmitField(u'提交')
关于视图路由中的/show部分,调用了UploadSet对象的url方法,其实对于一个UploadSet对象有指定的存放文件的目录,这也就是说,经这个对象处理的文件不会有重名(经试验即使传了重名的,后一个文件会被命名成filename_1.ext这样子被存放到相关目录中)。那么就表名,UploadSet对象其实可以维护文件名和完整文件路径的关系。url方法返回的就是从http://开始包括域名在内的完整文件路径。
*在测试的时候还发现了一个flash_upload的小缺陷,就是后缀名大写的文件将不被识别为可接受文件。比如1.png可以被ALLOW是IMAGES的UploadSet对象接收,但是1.PNG就不行。我的解决办法是在UploadSet类中的extension_allowed方法中,加上一句ext = ext.lower()。
如果我们根据上传过来的文件的格式不同而进行不同的存储和处理,那么用UploadSet会是一个比较不错的办法。
■ 文件的管理
在架设了文件上传的功能之后,可能需要留出接口让用户可以通过web界面来进行文件管理。下面介绍一下一些方法来方便我们进行文件管理工作。
展示文件可以用os.listdir或者walk之类的方法,来获取后台所有文件的列表,然后和前端配合将这些文件列表展现出来。
如果我们需要留出删除这些文件的接口,那么在后台可调用os.remove(path)方法来删除一个文件。路径参数path可以通过myfile.path(filename)来获得。因为一个文件集中文件名是不能重复的,所以UploadSet对象的path方法可以返回一个完整的绝对路径。path方法和前面提到的url方法很容易混淆,两者都是通过一个filename来返回一串定位用的串,其中url返回的是通过web和HTTP方法来访问,所以是类似于http://ip:port/path/to/file这样的,而path方法返回的是类似于C:\path\to\file或者linux上的/path/to/file这样的。
重新再来看save方法,经过上面的调用可以看到save方法除了FileStorage对象之外还可以传一个name参数指定保存到服务器上文件的名字。除此之外save方法其实还有一个folder参数,可以指定在UPLOAD_XXX_DEST中文件还要存放到什么子级目录中去。比如folder=os.path.join('subdir','tempdata')的话,这个文件就会被放到UPLOAD_XXX_DEST指定目录下的subdir/tempdata目录中,若之前路径还不存在程序会自动创建没有的目录。
● 文件名点窜
关于上面对文件名的默认处理是保持原名,但是实际上这并不是很安全。可以参考的一个做法是提取myfile.path(filename)的md5散列值,然后把这个值的前N位或者整个值作为文件名保存下来。然后把真实文件名的path和这个文件名对应起来存到数据库中。这样用户在调取文件的时候可以从数据库中定位相关的真实文件名。
■ 多文件上传
在以上实现中,我们的FileField一次只能接受至多一个文件上传,也就是说点击浏览按钮弹出来的对话框中我们只能单选一个文件。
如果要多选,首先在前端的input标签中应该是要有multiple='multiple'这个属性。至于到了后端,该如何接受这些文件?按照上面的说明,后端接受文件可以有两种方式,第一种是通过flask.request对象,接受多个文件时用request.files.getlist('file'),这个方法返回一个FileStorage对象的列表。遍历这个列表,针对每一个FileStorage对象调用UploadSet对象对其执行save方法即可保存它们。getlist中的'file'其实是表单中文件上传的那个input的name属性的值,如果用的是wtforms实现的表单的话那么也是文件上传field的对象的变量名。
■ 拖曳上传
上面实现的文件上传表单,界面是很传统的那种一个浏览按钮加上一串提示字符串。稍微时髦一点的可以使用拖曳文件到浏览器窗口进行上传。要实现拖曳上传,需要用到Dropzone.js这个扩展JS。
【如何将这个JS整合进我们的项目我自己没有研究。。偷了个懒,上面那篇文章的作者写了一个flask扩展flask-dropzone,我们可以直接pip install之避免重复造*。这个扩展的用法:http://greyli.com/flask-dropzone-add-file-upload-capabilities-for-your-project/。这个扩展的github地址:https://github.com/wyzypa/flask-dropzone。】
简单来说,我们只要像其他扩展那样初始化:dropzone = Dropzone(app),然后去前端做一些相关的修改即可。前端的修改可以参考下面:
{% block head %}{# 注意,一定要在head中load,否则dropzone.js和相关css文件无法通过CDN方法导入#}
{{ dropzone.load() }}
{% endblock %} {% block page_content %}
{{ dropzone.create(action_view='form') }}
{{ dropzone.style('border:5px spotted black;width:100%;background:grey') }}
{# create方法接收一个action_view参数。dropzone创建的其实是一个表单,这个表单标签form的action属性就由这个参数的值决定,
也就是说要写相关视图函数的endpoint才行。这里写了form,意味着在views中一定存在@app.route('/something')下的def form():...#}
{# style方法允许手工调整dropzone区域的CSS样式 #}
{% endblock %}
这样一个基本的dropzone就已经完成了。如果想要对提示文字等作出更多调整可以参考上面那个用法说明中给出的可以配置哪些app.config的项。比如经过如下config设置之后
app.config['DROPZONE_DEFAULT_MESSAGE'] = u'点击或将文件拖曳到此区域来上传文件' 就可以改变默认的提示文字。
app.config['DROPZONE_MAX_FILE_SIZE'] = 3 设置了上传文件最大只能到3MB
app.config['DROPZONE_TOO_BIG'] = u'上传文件大小超过限制' 设置当上传文件大小超过限制时的提示文字
app.config['DROPZONE_INVALID_FILE_TYPE'] = u'文件类型不符合规定'
app.config['DROPZONE_SERVER_ERROR'] = u'服务器内部发生错误:{{ statusCode }}'
*如果要对上传文件的格式做出限制,之前一直傻傻地还去该uploadForm里面的FileField的FileAllowed的validator,后来突然意识到,现在已经和form没关系了。。通过flask-dropzone来设置格式限制是这样的:
app.config['DROPZONE_ALLOWED_FILE_CUSTOM'] = True
app.config['DROPZONE_ALLOWED_FILE_TYPE'] = '一些字符串值'。字符串的格式由dropzone.js规定。基本格式是类似于这样的:'.jpg, .png, .zip, .rar',请注意每个格式前面到逗号为止是要空出一格的。dropzone还提供了一些预设的比如image/*,audio/*等囊括一些后缀名的集合,也可以用。比如混用两者:'image/*, .rar, .zip'。
因为通过dropzone.js来实现的文件上传表单不通过wtforms渲染,所以就不能再views里面通过我们熟悉的from = XXForm(),然后if form.validate_on_submit()这样的方式在视图中控制了。所以我们要回归原始,采用if request.method == 'POST' and request.files.get('file')之后,利用UploadSet对象的save方法来进行文件存储。
顺便一提,最开始dropzone.js被开发出来时好像专门是针对图片上传用的,所以对图片的支持特别好。图片格式被上传之后,还会再页面上显示缩略图。比如下面这样:
■ 进度条!
进度条之于上传的重要性想必不用多讲了。其实在上面的dropzone.js中已经实现了一个小进度条了,不过考虑到更加*的进度条设置方式,还是有必要来看下这块内容。
网上现成的进度条解决方案不多,我搜到了这篇文章【http://www.jianshu.com/p/716d470d6434】(怎么感觉老是在copy然后总结别人的文章,果然还是自己水平太~低了【笑哭】)
利用了flask+jQuery+Bootstrap实现的进度条。正好这几个技术都还算熟悉,就用这个了。首先来说说他实现的原理,首先肯定是不能用dropzone.js之类已经包装好上传方法的组件了,记得paramiko的sftp在上传下载的时候提供了一个回调函数主要就是用来给开发人员做进度条用的,所以我们要自己实现上传方法。这里用了jQuery中的ajax的POST方法来实现文件的上传。另外还有必要来复习一下Bootstrap的进度条组件,BS中的进度条就是一个div包着另一个div。外面的div是.progress,里面的div是.progress-bar,通过改变.progress-bar的width CSS属性来实现进度条的走动。
至于文件上传方法,我们要做的是在ajax中设置XMLHttpRequest对象,并以此为基础添加事件来监听上传的进度。具体的前端代码如下(一部分):
{% block page_content %}
<form action="{{ url_for('form') }}" role="form" method="POST" enctype="multipart/form-data">
<input type="file" id="file" name="file" />
<input type="submit">
</form> //进度条
<div class="process" style="display:none;">
<div class="process-bar" style="width:0%;" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }} //千万别忘了super,否则jQuery和bootstrap.js就不被CDN方式导入了
<script type="text/javascript">
$("form").on("submit",function(event){
$(".progress").css("display","block"); //显示进度条
event.preventDefault(); //不是很懂这里是干嘛的,原文说是为了阻止表单提交
var formData = new FormData(this);
//如果需要为表单添加一些其他字段的数据可以调用formData.append('key','value')来实现
//开始用ajax上传文件
$.ajax({
xhr : function(){
var xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress' ,function(e){
if (e.lengthComputable){
var percent = Math.round(e.loaded * 100 / e.total);
$(".progress-bar").attr("aria-valuenow",percent).css("width",percent+"%");
}
});
return xhr;
},
type : 'POST',
url : '/form',
cache : false,
data : formData,
processData : false,
//这条主要是指出了jQuery不要去处理发送的数据
contentType : false})
//这里是说明不要ajax去设置Content-Type请求头,原因我也不懂。。
.done( //接在整个ajax请求方法后面,表示处理完成或失败时调用的函数
function(){alert('success');}).fail(
function(){alert('failed');}); });
</script>
{% endblock %}
通过这样的方法构造出来的一个上传部件就实现了进度条的功能了。要注意在ajax的参数列表中必须设置processData : false和contentType : false这两条。否则很可能在前端控制台报错说append方法用到了没有实现FormData接口的对象上。
另外为了提高用户体验,加强重复上传时的工作性能,可以在done和fail两个回调函数中再添加上比如$(".progress").hide("slow") //上传结束后进度条重新隐藏。$("progress-bar").css("width","0%").attr("aria-valuenow","0"); //上传结束后把进度条归零。
按照上面的代码打造出来的进度条很好,但是可能不能附带表单数据。如果需要表单数据那么需要额外在JS中为formData对象添加一些K-V对来表示表单数据。比如formData.append('name',$('input#formName').val());就是把当前页面表单中的某个字段的值赋予formData对象的某个Key。然后通过ajax请求上传的这个表单就既有文件上传又有表单数据了。还要提醒一句,formData是一个FormData原型的对象而不是一个简单的Object。所以在控制台里log出来的也是一个空对象,不要以为没有append进去,其实只有通过formData.get方法才能顺利取出数据的 。