原创作者: jinhao7773   阅读:10686次   评论:2条   更新时间:2011-06-01    

在Django 1.0 版本后,文件上传的处理做了很大的改变,其中很重要的一点就是引入了 Upload Handlers 的概念。

Upload Handlers

这是个和Django中的Middleware差不多的东西,可以通过在 settings.py 文件中设置 FILE_UPLOAD_HANDLERS  定义一系列Upload Handlers,

和Middleware相似的地方主要表现为以下两点:

1、Django会按照 FILE_UPLOAD_HANDLERS 所定义的列表中各个Upload Handlers的先后顺序将上传的文件数据依次传递给Upload Handlers。

2、Upload Handlers是一个类,可以通过定义各种hook methods,在文件上传的各个阶段进行相应操作。同时可以通过返回操作后的数据或只返回None等形式,对是否需要后续的Upload Handlers进行处理进行控制。

 

通过以上两点,Django本身内置了2个Upload Handlers:

("django.core.files.uploadhandler.MemoryFileUploadHandler",
 "django.core.files.uploadhandler.TemporaryFileUploadHandler",)
第一个Handler是将上传的文件数据保存在内存中,如果上传的文件数据大小超过 FILE_UPLOAD_MAX_MEMORY_SIZE 所设置的值后,那么他只要将数据丢给后续的Handler(即TemporaryFileUploadHandler)即可。

通过此种处理方式,可以对文件上传进行详细的控制并进行各种有趣的操作,比如边上传边压缩,上传进度提示等。

 

 而这次我所要讲的Ajax上传,进度显示就是通过自定义Handler实现的。通过定义以下的Handler,并且将该Handler放置在所有其他Handler之前,就可以在文件上传的过程中记录某个文件表单域所对应的上传进度。

from django.core.files.uploadhandler import FileUploadHandler
from django.core.cache import cache
import time
class LogFileUploadHandler(FileUploadHandler):
    def __init__(self, request=None):
        super(LogFileUploadHandler, self).__init__(request)
        if 'formhash' in self.request.GET:
            self.formhash = self.request.GET['formhash']
            cache.add(self.formhash, {})
            self.activated =True
        else:
            self.activated = False
        
    def new_file(self, *args, **kwargs):
        super(LogFileUploadHandler, self).new_file(*args, **kwargs)
        if self.activated:
            fields = cache.get(self.formhash)
            fields[self.field_name] = 0
            cache.set(self.formhash, fields)
            
        
    def receive_data_chunk(self, raw_data, start):
#        time.sleep(5) for local test, it slow down the upload speed
        if self.activated:
            fields = cache.get(self.formhash)
            fields[self.field_name] = start
            cache.set(self.formhash, fields)
        return raw_data
    
    def file_complete(self, file_size):
        if self.activated:
            fields = cache.get(self.formhash)
            fields[self.field_name] = -1
            cache.set(self.formhash, fields)
        
    def upload_complete(self):
        if self.activated:
            fields = cache.get(self.formhash)
            fields[self.formhash] = -1
            cache.set(self.formhash, fields)

 

 

因为显示给每个用户每次浏览页面的时候,表单域的name都是相同的,为了区分每个页面,在表单提交的时候需要加上一个

formhash 的GET参数,以此来区分文件上传来源。

 

第一个问题,为什么formhash是一个GET参数,而不是POST参数,如果是POST的话不是更方便?因为只要在表单中加入一个hidden域即可。

那是因为如果在Handler尝试存取request.POST中的值,会导致又一次调用Upload Handler,从而形成无限递归,所以只能存取request.GET中的值。当然这并不是什么大问题,后面我会讲到用js将hidden域中的formhash提取出来作为GET参数。

 

第二个问题是如何保存文件上传进度的数据,用数据库来保存显然是开销太大了,因为一次文件上传的过程中需要反复读写记录的数据。最终我这里选择使用了django的cache组件来进行保存,因为保存的数据格式相对比较简单,并且对速度的要求比较高。(相关文章:http://www.djangosnippets.org/snippets/678/      http://www.djangosnippets.org/snippets/679/

 

第三个问题,经实践测试,upload handler中的self.content_length 似乎一直都为None,所以在文件没有完整上传之前是没法获得总的数据长度的。因此此handler只记录了已上传的字节数,并且以 -1 表示上传结束。同样,为了表示一个表单中的所有文件上传域的文件上传完毕,通过将 formhash 作为key,值为 -1 来表示。

 

声明:JavaEye文章版权属于作者,受法律保护。没有作者书面许可不得转载。更多精彩内容,访问 http://jinhao.iteye.com/

 

如何提交表单

在记录了文件上传进度数据后,接着的问题就是如何在文件边上传的过程中边间隔一定时间对进度进行刷新,通常的做法是将表单提交到一个隐藏的iframe中,这里也不例外。

 

为了将原本不具有Ajax功能的表单变成异步的,并且需要在表单提交后不断查询处理进度,具体的过程是大致这样的:

1、在表单中添加一个hidden域 formhash,要求尽可能唯一,用来验证表单提交的来源。

2、通过js在表单的action地址末尾加上 "?formhash=hashstr" 形式的查询字符串,hashstr通过hidden域的formhash提取而来。

3、创建一个隐藏的iframe,并且将表单的 target 设置为该 iframe 的name。

4、写一个专门用来刷新表单文件上传进度的js函数,通过setTimeout将该函数在过一定时间后执行,同时在该函数内部根据服务器返回的进度数据决定是否需要递归调用setTimeout,以便再次刷新。

 

根据以上描述,我写了一个widget,该widget继承自django的HiddenInput,但是加上了上面描述所需的js代码。

 

# -*- coding: utf-8 -*-
# Create your views here.
from django import forms
import datetime, md5
from django.core.cache import cache
from django.utils.safestring import mark_safe
from django.http import HttpResponse, HttpResponseRedirect
from django.utils import simplejson
from django.shortcuts import render_to_response
from django.core.urlresolvers import reverse
try:
    from functools import wraps
except ImportError:
    from django.utils.functional import wraps  # Python 2.3, 2.4 fallback.
from uploadhandler import LogFileUploadHandler

class AjaxHiddenWidget(forms.HiddenInput):
    def __init__(self, attrs=None):
        super(AjaxHiddenWidget, self).__init__(attrs)
        if 'interval' not in self.attrs:
            self.attrs['interval'] = 4000
        if 'hidden_iframe_name' not in self.attrs:
            self.attrs['hidden_iframe_name'] = 'hidden_iframe'
            
    def render(self, name, value, attrs=None):
        formhash = value = md5.new(str(datetime.datetime.now())).hexdigest()
        process = reverse('uploads-process')
        final_attrs = self.build_attrs(attrs, name=name, formhash= formhash, process = process)
        result = super(AjaxHiddenWidget, self).render(name, value, attrs)
        js = u"""
        <script type="text/javascript" src="/media/jquery-1.3.1.min.js"></script>
        <script type="text/javascript">
            var default_interval = %(interval)s;
            function refresh(){
                var formhash = "%(formhash)s";
                var rnd = Math.random();
                $.getJSON('%(process)s', {
                    'formhash': formhash,
                    'rnd': rnd,
                }, function(data, textStatus){
                    var continue_refresh = true;
                    for (field_name in data) {
                        continue_refresh = continue_refresh && field_name != formhash;
                        if (data[field_name] == -1) {
                            var msg = "upload done";
                        }
                        else {
                            num = parseInt(data[field_name] / 1024);
                            var msg = "uploaded " + num + " KB";
                        }
                        $("input[name='" + field_name + "']").next().text(msg);
                    }
                    
                    if (continue_refresh) {
                        setTimeout(refresh, default_interval);
                    }
                })
            };
            
            function bind_submit(){
                $("form").submit(function(eventObject){
                    this['action'] = this['action'] + '?formhash=%(formhash)s' ;
                    $('<iframe name="%(hidden_iframe_name)s" id="id_%(hidden_iframe_name)s" style="display:none"></iframe>').appendTo("body");
                    this['target'] = '%(hidden_iframe_name)s';
                    $("input[type='file']").after("<span></span>");
                    setTimeout(refresh, default_interval);
                });
            }
            $(document).ready(function(){
                bind_submit();
            });
            
            function replace_iframe(){
                $('body').html($('#id_%(hidden_iframe_name)s').contents().find('body').html());
                bind_submit();
            }
        </script>
        """ % final_attrs
        
        return mark_safe(result+js)
    
#    class Media:
#        js = ('/media/jquery-1.3.1.min.js', )

class AjaxHiddenField(forms.CharField):
    widget = AjaxHiddenWidget(attrs = {'interval': 5000, 'hidden_iframe_name':'custom_name'})
    def __init__(self, *args, **kwargs):
        super(AjaxHiddenField, self).__init__(*args, **kwargs)
        
class UploadFileForm(forms.Form):
    title = forms.CharField(max_length=50)
    file  = forms.FileField()
    file2  = forms.FileField()
    formhash = AjaxHiddenField()

 

 

上面的代码同时定义了一个AjaxHiddenField,该域使用了AjaxHiddenWidget,这样如果你需要修改AjaxHiddenWidget的某些参数只需要像下面这样就行了:

class AjaxHiddenField(forms.CharField):
    widget = AjaxHiddenWidget(attrs = {'interval': 5000, 'hidden_iframe_name':'custom_name'})
    def __init__(self, *args, **kwargs):
        super(AjaxHiddenField, self).__init__(*args, **kwargs)

 

上面的js代码使用JQuery,你可以根据需要修改为自己喜欢的js库,以及更个性化的进度显示。

同时上面的AjaxHiddenWidget定义使用了django 的form media的声明语法,但是后来又被注释掉了,你可以根据需要自己选择是使用 form media还是直接将 js文件的导入 直接写在render方法中。

 

可能上面js代码中的 replace_iframe 函数看上去很奇怪,也没什么用,但是接下来要说的就是和这个函数有关的了。

 

声明:JavaEye文章版权属于作者,受法律保护。没有作者书面许可不得转载。更多精彩内容,访问 http://jinhao.iteye.com/

修改view

由于将表单提交到了一个隐藏的iframe中,那么就出现了另外一个问题,提交的表单填写有误的话该怎么反馈这些错误,表单成功处理后该如何反馈使用户知道表单已经成功提交了。先来看django处理表单提交的一般模式。

def upload_file(request):
    if request.method == 'POST':
        print request.FILES
        form = UploadFileForm(request.POST, request.FILES)
        if form.is_valid():
            # code handle form and file data
            return HttpResponseRedirect('/someplace/')
    else:
        form = UploadFileForm()
    return render_to_response('upload.html', {'form': form})

 

基本上所有处理都在一个view里,该view的内部逻辑可以分类3种情况:

1、直接的GET访问,这时只需要显示表单即可。

2、POST提交过来的,但是表单中包含错误数据,这时显示的页面需要同时包含这些错误提示。

3、POST提交过来的,并且表单成功处理,返回HttpResponseRedirect对象。

 

为了最小对原有view的修改,只要您的表单处理符合以上模式,那么只要应用下面这个decorator,view中的代码不需要进行任何修改。

 

def make_ajax(view_func):
    def _wrapped_view_func(request, *args, **kwargs):
        request.upload_handlers.insert(0, LogFileUploadHandler(request))
        response = view_func(request, *args, **kwargs) 
        if 'formhash' in request.GET and isinstance(response, HttpResponseRedirect):
            #situation 3 
            location = response['Location']
            return HttpResponse('<script type="text/javascript">window.parent.location.href="%s"</script>' % location)
        elif 'formhash' in request.GET and  isinstance(response, HttpResponse) and request.method == 'POST':
            #situation 2
            js = """
            <script type="text/javascript">
            $(document).ready(function(){ window.parent.replace_iframe(); })
            </script>
            """
            return HttpResponse(response.content + js)
        else:
            return response
    return wraps(view_func)(_wrapped_view_func)

 

值得注意的是该decorator的开始部分:

request.upload_handlers.insert(0, LogFileUploadHandler(request))

一般情况下,我们是将自定义的Upload Handlers添加设置定义在settings.py 中的 FILE_UPLOAD_HANDLERS 变量。

但是实际上,我们还可以针对单个view进行Upload Handlers的设置,就像上面这样在存取request.POST和request.FILES之前修改request.upload_handlers。当然这里需要注意不要重复添加同一个Upload Handler。

 

该decorator主要的工作就是针对view返回的response,根据表单处理的view的3种处理逻辑,当该反馈只是被发送到一个隐藏的iframe时,进行js的特殊处理,使其的行为就像是非iframe提交时的行为。

而前面提到的replace_iframe 函数的作用就是将原本显示在隐藏iframe中的内容显示在top页面上,替换原top页面的所有内容。

 

从上面的描述中还可以看到,实际上如果你的view符合表单处理的一般模式,那么你只需要在你的原有Form类中添加一个AjaxHiddenField,以及将make_ajax decorator 应用到该view,那么不管你原来的表单是否处理上传文件,你的表单都已经变成了Ajax的异步模式。而且和原来的用户反馈相比没有任何变化。

 

代码总结

为了更完整的理解代码,下面是以上所贴代码的一个总结:

 

# -*- coding: utf-8 -*-
# Create your views here.
from django import forms
import datetime, md5
from django.core.cache import cache
from django.utils.safestring import mark_safe
from django.http import HttpResponse, HttpResponseRedirect
from django.utils import simplejson
from django.shortcuts import render_to_response
from django.core.urlresolvers import reverse
try:
    from functools import wraps
except ImportError:
    from django.utils.functional import wraps  # Python 2.3, 2.4 fallback.
from uploadhandler import LogFileUploadHandler

class AjaxHiddenWidget(forms.HiddenInput):
    def __init__(self, attrs=None):
        super(AjaxHiddenWidget, self).__init__(attrs)
        if 'interval' not in self.attrs:
            self.attrs['interval'] = 4000
        if 'hidden_iframe_name' not in self.attrs:
            self.attrs['hidden_iframe_name'] = 'hidden_iframe'
            
    def render(self, name, value, attrs=None):
        formhash = value = md5.new(str(datetime.datetime.now())).hexdigest()
        process = reverse('uploads-process')
        final_attrs = self.build_attrs(attrs, name=name, formhash= formhash, process = process)
        result = super(AjaxHiddenWidget, self).render(name, value, attrs)
        js = u"""
        <script type="text/javascript" src="/media/jquery-1.3.1.min.js"></script>
        <script type="text/javascript">
            var default_interval = %(interval)s;
            function refresh(){
                var formhash = "%(formhash)s";
                var rnd = Math.random();
                $.getJSON('%(process)s', {
                    'formhash': formhash,
                    'rnd': rnd,
                }, function(data, textStatus){
                    var continue_refresh = true;
                    for (field_name in data) {
                        continue_refresh = continue_refresh && field_name != formhash;
                        if (data[field_name] == -1) {
                            var msg = "upload done";
                        }
                        else {
                            num = parseInt(data[field_name] / 1024);
                            var msg = "uploaded " + num + " KB";
                        }
                        $("input[name='" + field_name + "']").next().text(msg);
                    }
                    
                    if (continue_refresh) {
                        setTimeout(refresh, default_interval);
                    }
                })
            };
            
            function bind_submit(){
                $("form").submit(function(eventObject){
                    this['action'] = this['action'] + '?formhash=%(formhash)s' ;
                    $('<iframe name="%(hidden_iframe_name)s" id="id_%(hidden_iframe_name)s" style="display:none"></iframe>').appendTo("body");
                    this['target'] = '%(hidden_iframe_name)s';
                    $("input[type='file']").after("<span></span>");
                    setTimeout(refresh, default_interval);
                });
            }
            $(document).ready(function(){
                bind_submit();
            });
            
            function replace_iframe(){
                $('body').html($('#id_%(hidden_iframe_name)s').contents().find('body').html());
                bind_submit();
            }
        </script>
        """ % final_attrs
        
        return mark_safe(result+js)
    
#    class Media:
#        js = ('/media/jquery-1.3.1.min.js', )

class AjaxHiddenField(forms.CharField):
    widget = AjaxHiddenWidget(attrs = {'interval': 5000, 'hidden_iframe_name':'custom_name'})
    def __init__(self, *args, **kwargs):
        super(AjaxHiddenField, self).__init__(*args, **kwargs)
        
class UploadFileForm(forms.Form):
    title = forms.CharField(max_length=50)
    file  = forms.FileField()
    file2  = forms.FileField()
    formhash = AjaxHiddenField()

def make_ajax(view_func):
    def _wrapped_view_func(request, *args, **kwargs):
        request.upload_handlers.insert(0, LogFileUploadHandler(request))
        response = view_func(request, *args, **kwargs) 
        if 'formhash' in request.GET and isinstance(response, HttpResponseRedirect):
            #situation 3 
            location = response['Location']
            return HttpResponse('<script type="text/javascript">window.parent.location.href="%s"</script>' % location)
        elif 'formhash' in request.GET and  isinstance(response, HttpResponse) and request.method == 'POST':
            #situation 2
            js = """
            <script type="text/javascript">
            $(document).ready(function(){ window.parent.replace_iframe(); })
            </script>
            """
            return HttpResponse(response.content + js)
        else:
            return response
    return wraps(view_func)(_wrapped_view_func)


@make_ajax
def upload_file(request):
    if request.method == 'POST':
        form = UploadFileForm(request.POST, request.FILES)
        if form.is_valid():
            # code handle form and file data
            return HttpResponseRedirect('/uploads/succ/')
    else:
        form = UploadFileForm()
    return render_to_response('upload.html', {'form': form})

def process(request):
    formhash = request.GET['formhash']
    status = cache.get(formhash)
    print status
    return HttpResponse(simplejson.dumps(status))
    

 

 

题外话:

1、由于Django自带的Development server是单线程模式的,所以由于以上的代码需要同时处理文件上传和进度显示,你可以有很多种选择:

(1):稍微修改一下Django的源码,使之变为多线程的,见 讨论 http://code.djangoproject.com/ticket/3357 和 修改方法 http://code.djangoproject.com/attachment/ticket/3357/devserver_multithread_trunk_r9532.patch

(2):将cherrypy作为Development server,具体方法见我的另外一篇文章 http://jinhao.iteye.com/blog/336549

(3):将Django部署到apache之类的服务器上,即可同时处理多个请求。

 

2、我一直不肯定当把Django部署到像apache这样的服务器上之后,apache是在收到完整请求后还是一收到请求就把请求处理转交给django,通过此次将这个Ajax文件上传的demo,我把他放到浪点的主机上后,文件上传的进度显示的数据也是一点点增长上去的,所以apache应该是在一收到请求后就把处理交给django了,也就是django 1.0中的文件上传组件对于需要处理大量数据上传的Web应用是很有意义的。

 

 

Demo地址: 

http://www.playdjango.cn/uploads/

请手下留情,不要上传太大的文件进行测试。

评论 共 2 条 请登录后发表评论
2 楼 jinhao7773 2009-03-04 22:40
我又试了是可以的啊,可能你用的是IE6,js代码有点问题,IE7和FF应该都可以的。
1 楼 gaochong_do 2009-03-04 19:56
http://www.playdjango.cn/uploads/ 有错误

发表评论

您还没有登录,请您登录后再发表评论

文章信息

  • jinhao7773在2009-03-02创建
  • jinhao7773在2011-06-01更新
  • 标签: django, ajax, file uploads, progress, jquery, upload handlers
Global site tag (gtag.js) - Google Analytics