Мой последний file uploader

в 6:05, , рубрики: Без рубрики

image
Я — веб-разработчик с непрофильным образованием и ~10-летним стажем. Я делал для веба все, что только могло прийти в голову моим заказчикам и, иногда, начальству. Я люблю эту работу. Но все же есть несколько вещей, которые я делаю совсем не улыбаясь. Одна из них — это file uploader. From the very beginning — когда еще никому не приходило в голову делать его аяксовым — и до настоящего времени — когда он ресайзит картинки, загружает файлы в несколько потоков и многое-многое другое — он остается для меня одной из самых нелюбимых задач. Вроде как у меня получилось с этим справиться. Если интересно — добро пожаловать под кат.
Вас ждут немножко Coffeescript-а, совсем чуть-чуть жалоб на jQuery, краткое описание $.Deferred, один к месту и не к месту нечаянно примененный паттерн и отсылка к одной забавной и интересной книге.

Итак, перед нами задача — всунуть на сайт file uploader. Конечно же AJAX, конечно же с прогрессом и конечно же в уже готовый дизайн. Для начала — почему это проблема? Кросс-браузерность. Старые добрые приемы (a-la сабмит формы в iframe) не даст нам прогресса, а новые ( xhr.send ) не сработают в старых браузерах. Появившаяся было надежда на стандартизацию браузеров скоропостижно скончалась, так что проблема кросс-браузерности не умрет. Дополнительная задачка — Drag'n'Drop. Еще есть беда с внешним видом input'a.
Какие у нас есть варианты?

Путь №1, Google-driven-development

Шустренько нагуглить достаточно популярную jQuery библиотеку, реализующую все требуемые функции. Подход правильный при дефиците времени. Предельно быстро встраивается в проект, сама волнуется на тему совместимости с браузерами и даже выведет какой-никакой отформатированный DOM. Ведь нет же никаких проблем DOM переработать стилями (или даже с помощью jQuery, это ведь его основная специализация, правильно? ), дизайнеров втихую убедить немножко подвинуться («Да не могу я вам ТАК сделать, подите к черту!»), даже договориться с заинтересованными лицами по поводу функционала («Ну да, это мы не сделали. Но зато смотрите какая фишечка сбоку нечаянно получилось. Ведь правда супер?»). И вроде бы все супер, и вроде бы все здорово. За день вполне можно справиться и отдать в тестировку. Но потом… Первая ласточка прилетит от дизайнеров — вылез какой-то див (кто-то прервал загрузку, или создал другую, более экзотическую ситуацию). А вы-то этот див не видели, и поэтому на нем стили, мягко говоря, не совсем те. Перевариваем заслуженные упреки дизайнеров, наблюдаем приход делегации тестировщиков. Оказывается, в каком-то замечательном браузере, в котором из-за корпоративных стандартов работают аж XX процентов наших пользователей (причем самых ценных!) не отображается прогресс! Ужас. Вы смело посылаете тестеров к черту («Ну как я могу это сделать, если браузер не поддерживает???») и с победным видом пропускаете хук в челюсть: «Так вот же, Вконтакте в этом браузере upload с прогрессом работает!». И именно в этот момент Вы совершаете одно из самых страшных преступлений против проекта — вы лезете в исходный код этого замечательного плагина. (Как вариант — находите позапрошлогоднее сообщение об этой ошибке в трекере плагина и, если вам повезет, какой-нибудь костыль затыкающий эту конкретную проблему). На самом деле это не так уж и страшно для вас, как для программиста. Вы научитесь читать чужой код — иногда хороший, иногда… разный. Дебаггером лишний раз попользуетесь. Я ни в ком случае не хочу сказать ничего плохого про разработчиков плагинов для jQuery. Просто в большинстве встреченных мной случаев их код не рассчитан на саппорт постороннего человека. Но сроки по проекту могут начинать подгорать. Помните, мы выбрали этот вариант именно из-за нехватки времени?
И еще — если все таки всунете плагин — обязательно оставьте в репозитории проекта только минифицированную версию плагина. И не в коем случае нигде не оставляйте ссылок на сайт плагина.
Резюме: незнакомые плагины юзаем только если на дизайн и функциональность можно подзабить. Ну или для мести / дрессировки дизайнеров и прочих тестеров. Или в качестве тренировки навыков реверс-инжиниринга.

Путь №2, исторический

Ха! Полгода назад мы уже писали что-то такое. Для голландцев (греков, австралийцев, персов ...). Там еще зелененький овальный прогресс-бар был. Открываем старый проект (другой комп, сгоревший репозитарий, и еще 100500 причин почему это не так просто). Выпиливаем uploader, курим сорцы. А там… Во первых — поддержка только последних браузеров. Во-вторых flash-ресайз на стороне клиента. В третьих этот самый памятный овальный (дизайнеры — сволочи!) прогресс жестко запилен в сам код uploader-а. В четвертых, в-пятых и т.д. Плюc куча специфики именно того проекта. С точки зрения удовольствия — тоже не айс. Вместо полета мыслями к горним высям современных технологий — откат на полгода назад.
Резюме: не интересно. Даже если вы полгода назад были гуру и выдавали только высококачественный код — сейчас-то вы уже по-любому круче.

Путь №3, самонадееный

Итак, давайте попробуем немного помечтать. Чего мы хотим? (помимо пива, соседку и котика). Как минимум — мы хотим сделать uploader. Как максимум — мы хотим сделать его один раз и надолго. Чтобы его было легко взять из текущего проекта и всунуть в следующий с минимальной модификацией. Желание, кстати, навеяно одной презабавненькой книжицей — Паттерны проектирования Эрика и Элизабет Фрименов. Нет, это не Та, Оригинальная Книга Банды Четырех, Которую Должен Прочитать Каждый. Эта другая, намного полегче. Не такая сильная, но и читается легче. Один из первых принципов, который ее авторы пытаются вбить читателю в голову — инкапсулируйте то, что изменяется. В нашем случае самым переменным аспектом архитектуры должен быть метод отправки данных на сервер (не только, но об этом чуть позже). Если помните, мы эту проблему обсуждали перед jQuery-плагином. Итак, если мы сделаем какой-нибудь пул механизмов отправки данных на сервер (если у вы предпочитаете более ООП-шные формулировки — пусть будет набор классов с общим интерфейсом ) и определим интерфейс взаимодействия c uploader'ом — одна большая проблема разбивается на несколько мелких. А их решать уже приятнее. Как это выглядит? Очень просто:

Uploader =
  senders: [xhrFile, formDataFile, formDataForm, iframe]
  send: (options)->
    stream = false
    $.each @senders, (i, func)=>
      if stream = func(options)
        false
    stream

В 2 словах — перебираем массив из функций (javascript же!), каждой подсовываем options (в большей степени потому что не знаем, что именно будем им на самом деле подсовывать) и, первая вернувшая непустой результат используется для отправки. (Как вариант — первая не сгенерировавшая исключение, так будет правильнее но пока что лень.) При этом в массиве самые «ценные» функции стоят первыми, а последняя — безотказная (тот самый submit-form-to-iframe способ). Да, мы уже догадываемся, что функции нам будут возвращать deferred — потом в интерфейсной части uploader'а мы навесим все интересующии нас листенеры (а не будем передавать их в options). Немного сумбурно описал $.Deferred под спойлером после примеров функций.
Теперь пару строк о качестве кода в этой статье:
— код завязан на jQuery не потому что автор считает, что любой проект все равно рано или поздно его будет использовать, а из-за некоторого количества «удобств», в том числе и Deferred, который мы будем весьма плотно использовать
— частично код взят из одного замечательного плагина. Если быть чесным, идея родилась с чтения этого плагина и всхлипов: «Ну почему, почему логику не отделили от реализации?»
— на данный момент код достаточно слабо протестирован, это просто иллюстрация к архитектуре.

Теперь примеры функций:

  iframe = (options)->
    return false unless options.input && options.input.value
    id = 'frame' + Math.random()
    $form = $ '<form>', method: options.method, enctype:'multipart/form-data', target: id, action: options.url
    $('<iframe>', name: id ).appendTo($form).on 'load', ->
      try
        response = @contents()
        throw new Error unless response.length && response[0].firstChild
        dfd.resolve response, name: options.input.value
      catch e
        dfd.reject response, name: options.input.value
    $form.hide().appendTo('body').submit()
    (dfd = $.Deferred()).promise()

Это та самая наша безотказная функция для старых браузеров. Вкратце — создаем форму, нацеливаем на iframe, сабмитим. Возвращаем deferred, который отрезолвим или отреджектим по загрузке целевого iframe. Переданные в deferred аргументы получим в навешенные снаружи листенеры. Если мое описание deferred не до конца понятно — добро пожаловать под спойлер после следующей функции, хотя лучше, конечно, почитать что-нибудь, например пост на хабре и документацию.

  formDataFile = (options)->
    return false unless options.files && options.files.length && window.FormData
    $.when.apply $, options.files.map (f)->
      formData = new FormData()
      formData.append options.name, f
      xhr = new XMLHttpRequest()
      dfd = $.Deferred()
      xhr.onload = xhr.onerror = ->dfd[@status == 200 && 'resolve' || 'reject'] @response, file
      if xhr.upload
        xhr.upload.onprogress = (e)->dfd.notify e, file
      xhr.open options.method, options.url, true
      xhr.responseType = 'text'
      xhr.send formData
      dfd.promise()

Тут уже поинтереснее будет. Для каждого из заданных файлов (откуда взялось options.files и что в нем — чуть ниже) мы создаем отдельный XMLHttpRequest и deferred. Deferred мы, как и в первом примере, реджектим или резолвим по загрузке данных с сервера. В отличие от безотказного метода помимо resolve/reject мы через deferred будем передавать данные об upload progress ( xhr.upload.onprogress = (e)->dfd.notify e, file ) если браузер соизволит. Полученные deferred мы с помощью $.when группируем в один и возвращаем. В данном случае применение when не совсем оправдано, с удовольствием прочитаю в комментариях почему.

Deferred для самых маленьких

Грубо говоря, deferred — это механизм разделения добавления обработчиков событий от вызова этих обработчиков. Очень классно применять при асинхронных вызовах. Для примера приведу ajax-запрос, как наиболее часто используемый и близко знакомый большинству веб-программистов. Раньше было так:

    $.get('/some/url/for/ajax', function(){
        alert('got response');
    }, function(){
        alert('got error');
    })

Теперь так:

    var request = $.get('/some/url/for/ajax');
    request.done(function(){
        alert('got response');
    });
    request.fail(function(){
        alert('got error');
    })

что изменилось? Самое главное для нас — раньше вам в момент выполнения запроса нужно уже было знать что вы будете делать с ответом (функции-обработчики нужно было передавать в момент вызова $.get). Теперь не надо. Вы даже можете навесить на переменную request (в ней будет храниться deferred-объект) обработчики
после завершения запроса. Правда, тогда вам нужно быть готовыми к немедленному срабатыванию обработчика. Это клиентская («потребительская») сторона Deferred — мы получаем deferred от метода $.get и просто ждем когда он сменит состояние. Теперь «темная» сторона, почуствуйте себя jQuery:

    var def = $.Deferred();

    def.done(function(){ // "навешиваем" обработчик ("листенер") на событие "резолв" неотрезолвленного deferred-объекта
        console.log('done1', arguments)
    });

    //пока что в консоли чисто

    def.resolve('param12');//вот тут мы "отрезолвили" наш deferred и вызвали все его done-обработчики, передав в качестве параметра строку 'param12'

   //в консоли появилось что-то вроде ['done1', Arguments['param12']]

    def.done(function(){ // "навешиваем" обработчик ("листенер") на событие "резолв" отрезолвленного deferred-объекта
        console.log('done2', arguments)
    });
   //обработчик отрабатывает моментально и
   //в консоли появилось что-то вроде ['done2', Arguments['param12']]

Получаем пару методов doneresolve. Первый добавляет обработчик на переход в состояние 'resolved', второй — переводит объект в это состояние и, таким образом, вызывает все уже добавленные обработчики.
Вторая пара методов failreject работает аналогичным образом с состоянием 'rejected'
Третья пара методов progressnotify работает без смены состояния. Просто при вызове notify срабатывают все progress-обработчики.

Есть еще такая защитная штука, как promise(). Вернет вам «обрезанный» deferred. Обработчики на него вешать можно (done, fail, progress), а состояние менять нельзя. Именно его вам возвращает $.get. И правильно — зачем вам вручную менять состояние ajax-запроса?

Для понимания кода также было бы неплохо также разобраться с $.when. Это «объединение» deferred-объектов. Работает так:

    var def = $.when(a, b, c ....); // собираем "составной" deferred
    def.done(function(){
        alert('resolved'); //сработает не более 1 раза, тогда, когда у всех исходных deferred вызовут resolve
    });
    def.fail(function(){
        alert('got error'); //сработает не более 1 раза, когда хотя бы у одного из исходных deferred вызовут reject
    })
    def.progress(function(){
        alert('some error'); //сработает каждый раз, когда хотя бы у одного из исходных deferred вызовут notify
    })

Если будете использовать $.when — обратите внимание на аргументы, передаваемые обработчикам составным объектом. Он их накапливает. Т.е. если были вызваны функции a.resolve(1) и b.resolve(2), то done-листенеры составного объекта получат 1, 2 в качестве аргументов. Можете поиграться на fiddle.

Но лучше все-таки почитать про Deferred — тут или хотя бы тут.

Весьма условно ситуацию можно описать так: мы несколько раз запускаем функцию отправки данных на сервер (по одному разу для каждого файла), оставляем в каждой по датчику (deferred) и забираем от этих датчиков индикаторы (promise). Датчики и индикаторы у нас трехканальные.

Опытный внимательный читатель уже должен воскрикнуть «Ба! Так это же цепочка обязанностей!» и таки да, будет на 100% прав (с учетом особенностей JS и желания сделать попроще). Скептики брезгливо поджимают губы, паттерны в js. Но мы не будем их слушать, для нас важно другое: танцуя от условий задачи и руководствуясь здравым смыслом мы самостоятельно почти что добрались до реализации паттерна, а значит, при желании, можем немного про него почитать, и быть заранее готовыми к характерным для него сюрпризам.

Одну проблему если и не решили, то нашли приемлимый путь к решению. Что у нас остается? Есть еще задача по чтению файлов. В зависимости от возможностей браузера мы либо можем получить объект выбранного в input файла, или даже сможем прочитать содержимое папки (рекурсивно при необходимости). Или не сможем ничего (угадайте в каком браузере) — только получить input. Сразу же напрашивается желание поступить аналогичгым образом — сформировать массив readers (цепочку обязанностей) и перебрать ее, выбрав подходящую функцию. Положить это все туда же, в наш объект Uploads. Теперь он будет выглядеть вот так:

Uploader =
  _responsibilityChain: (options, chain, name = false)->
    stream = false
    $.each chain, (i, func)=>
      if stream = func(options)
        # вот здесь устанавливаем подошедшую функцию методом для Uploader
        # при следующем вызове не будет нужды перебирать всю цепочку
        @[name] = func if name
        false
    stream
  readers: [entry, file, input]
  read: (options)->@_responsibilityChain options, @readers, 'read'
  senders: [xhrFile, formDataFile, formDataForm, iframe]
  send: (options)->@_responsibilityChain options, @senders, 'send'

Реализацию механизма вынесли в отдельную функцию Uploader'а. И прямо внутри перебора подошедшую функцию устанавливаем в качестве искомого метода (read или send) объекту Uploader. Теперь посмотрим сами функции для чтения (в большей степени взяты из кода плагина вместе с комментариями).

# наша новая безотказная функция
  input = (options)->
    return false unless options.input.value
    $.Deferred().resolve([]).promise()

# это промежуточный вариант - функция умеет читать файлы из input
# на самом деле никакой асинхронностью тут и не пахнет, Deferred используется для общего протокола с 3-й функцией
  file = (options)->
    files = $.makeArray $(options.input).prop 'files'
    return false unless files.length
    if (files[0].name == undefined && files[0].fileName)
      # File normalization for Safari 4 and Firefox 3:
      $.each files, (index, file)->
        file.name = file.fileName;
        file.size = file.fileSize;
    $.Deferred().resolve(files).promise()

# самая желанная функция-reader. Умеет читать содержимое папок. Асинхронная, т.е. возвращает
# неотрезолвленный Deferred. Сложная и длинная функция, но вроде работает
  entry = (options)->
    roots = $(options.input).prop('webkitEntries') || $(options.input).prop('entries')
    return false unless roots && roots.length > 0
    readEntries = (entries, path='')->
      $.when.apply($, $.map entries, (entry)->
        dfd = $.Deferred()
        errorHandler = (e)->
          e.entry = entry if e && !e.entry
          # Since $.when returns immediately if one
          # Deferred is rejected, we use resolve instead.
          # This allows valid files and invalid items
          # to be returned together in one set:
          dfd.resolve [e]
        resolveHandler = (file)->
          # Workaround for Chrome bug #149735
          file.relativePath = path
          dfd.resolve file
        if entry.isFile
          entry._file && resolveHandler(entry._file) || entry.file resolveHandler, errorHandler
        else if entry.isDirectory
          entry.createReader().readEntries((entries)->
            readEntries(entries, path + entry.name + '/'
            ).done((files)->dfd.resolve files
            ).fail(errorHandler)
          , errorHandler)
        else
          # Return an empy list for file system items other than files or directories:
          dfd.resolve([]);
        dfd.promise()
      #we do need this pipe here bc we do resolve some files scoped
      ).pipe -> Array.prototype.concat.apply [],arguments

    readEntries(roots).promise()

Вкратце — каждая из функций пытается по-своему изнасиловать input из options и вернуть deferred, который будет отрезолвлен со списком прочитанных файлов.

Внимательный и опытный читатель должен где-то здесь воскрикнуть: «Не тот паттерн!». И опять-таки будет на 100% прав. Внутренняя программерская чуйка говорит, что что-то здесь не так. Результат работы некоторых ридеров можно будет отдать для отправки не всем сендерам (например после ридера input сендер xhrFile не сможет отправить). Ну и леший с ними. У нас эту ситуацию обработает return false неподходящих сендеров. Но читать данные из input прямо внутри сендеров не айс — не забыли, у нас еще будет Drag'n'Drop? Пока что оставим как есть, вдруг случится чудо и в комментариях появится дельный совет?

Еще одно замечание — ридер input не делает ничего — возвращает отрезолвенный пустым массивом deferred. Это просто значение по умолчанию, можно этот функционал перенести внутрь Uploader.read. Оставлено для красоты — безобразно, но единообразно.

Дальше — нам нужно увязать read и send и добавить кое-какие дефолтные значения для options. Функцию опять-таки всунем в наш объект Uploader:

Uploader:
  #... тут функции, которые вы уже видели раньше
  upload: (options)->
    options = $.extend
      method: 'POST'
      name: options.input && options.input.name
    , options

    @read(options).then (files)=>$.when.apply $, @send $.extend options, files:files

Тут все просто — считываем файлы из инпута, полученные значения добавляем к options и получившийся объект отдаем на отправку в Uploader.send. Полученные из send deferred'ы опять-таки группируем с помощью when и отдаем клиенту сгруппированный deferred.

Как это все использовать:

  $('#some-file-input').change ->
    Uploader.upload(input: this, url: '/files/upload' )
    .done -> # функция будет вызвана при успешной загрузке всех файлов
        alert "#{arguments.length} files uploaded"
    .fail -> # этот листенер отработает если хоть одна из загрузок сорвется
        alert "Some files were not uploaded"
    .progress -> # каждый раз при вызове прогресс (~ 50 мс), если поддерживается браузером
        console.log arguments
Summary
Uploader целиком

Senders =
  _makeForm: (options)->
    $('<form>',
      method: options.method,
      enctype:'multipart/form-data'
    ).append(options.input.clone())

  _sendData: (options, data, file)->
    xhr = new XMLHttpRequest()
    dfd = $.Deferred()
    xhr.onload = xhr.onerror = ->dfd[@status == 200 && 'resolve' || 'reject'] @response, file
    if xhr.upload
      xhr.upload.onprogress = (e)->dfd.notify e, file
    else
      options.no_progress = true
    xhr.open options.method, options.url + '?name=' + (options.file_name || file.name), true
    xhr.responseType = 'text'
    xhr.send data
    dfd.promise()

  iframe: (options)->
    return false unless options.input && options.input.value
    options.no_progress = true
    id = 'frame' + Math.random()
    $form = Senders._makeForm(options).attr target: id, action: options.url
    $('<iframe>', name: id ).appendTo($form).on 'load', ->
      try
        response = @contents()
        throw new Error unless response.length && response[0].firstChild
        dfd.resolve response, name: options.input.value
      catch e
        dfd.reject response, name: options.input.value
    $form.submit()
    (dfd = $.Deferred()).promise()

  formDataForm: (options) ->
    return false unless options.input && options.input.value && window.FormData
    form = options.input.form || Senders._makeForm(options).get 0
    Senders._sendData options, new FormData(form), name: options.input.value


  formDataFile: (options)->
    return false unless options.files && options.files.length && window.FormData

    options.files.map (f)->
      formData = new FormData()
      formData.append options.name, f
      Senders._sendData $.extend(options, file_name:f.name), formData, f

  xhrFile : (options)->
    return false unless options.files && options.files.length && window.ProgressEvent && window.FileReader
    $.map options.files, (file)-> Senders._sendData options, file, file

Readers =
  input: (options)->
    return false unless options.input.value
    $.Deferred().resolve([]).promise()

  file: (options)->
    files = $.makeArray $(options.input).prop 'files'
    return false unless files.length
    if (files[0].name == undefined && files[0].fileName)
      # File normalization for Safari 4 and Firefox 3:
      $.each files, (index, file)->
        file.name = file.fileName;
        file.size = file.fileSize;
    $.Deferred().resolve(files).promise()

  entry: (options)->
    roots = $(options.input).prop('webkitEntries') || $(options.input).prop('entries')
    return false unless roots && roots.length > 0
    readEntries = (entries, path='')->
      $.when.apply($, $.map entries, (entry)->
        dfd = $.Deferred()
        errorHandler = (e)->
          e.entry = entry if e && !e.entry
          # Since $.when returns immediately if one
          # Deferred is rejected, we use resolve instead.
          # This allows valid files and invalid items
          # to be returned together in one set:
          dfd.resolve [e]
        resolveHandler = (file)->
          # Workaround for Chrome bug #149735
          file.relativePath = path
          dfd.resolve file
        if entry.isFile
          entry._file && resolveHandler(entry._file) || entry.file resolveHandler, errorHandler
        else if entry.isDirectory
          entry.createReader().readEntries((entries)->
            readEntries(entries, path + entry.name + '/'
            ).done((files)->dfd.resolve files
            ).fail(errorHandler)
          , errorHandler)
        else
          # Return an empy list for file system items other than files or directories:
          dfd.resolve([]);
        dfd.promise()
      #we do need this pipe here bc we do resolve some files scoped
      ).pipe -> Array.prototype.concat.apply [],arguments

    readEntries(roots).promise()

Uploader:
  _responsibilityChain: (options, chain, name = false)->
    stream = false
    $.each chain, (i, func)=>
      if stream = func(options)
        # вот здесь устанавливаем подошедшую функцию методом для Uploader
        # при следующем вызове не будет нужды перебирать всю цепочку
        @[name] = func if name
        false
    stream
  readers: [entry, file, input]
  read: (options)->@_responsibilityChain options, @readers, 'read'
  senders: [xhrFile, formDataFile, formDataForm, iframe]
  send: (options)->@_responsibilityChain options, @senders, 'send'
  upload: (options)->
    options = $.extend
      method: 'POST'
      name: options.input && options.input.name
    , options
    @read(options).then (files)=>$.when.apply $, @send $.extend options, files:files

В качестве решения проблемы с отображением input[type=file] предлагаю такой трюк — сам инпут прячем с помощью position:absolute далеко за краем контейнера, а стилизируем его label.

Мы имеем небольшой, легко расширяемый file-uploader. В случае появления нового механизма upload — мы его легко добавляем в сортированный список senders и вуалля. Формат данных, курсирующих между функциями фиксированный, $.Deferred. Но:
— нет ограничения на количество одновременных загрузок
— нет механизма отмены загрузки
— неудобные события на стороне клиента — хотелось бы иметь событие «файл загружен», «ошибка файла». В событии progress нет встроенной возможности узнать, сколько файлов загружено

Ежели будет интересно — покажу как избавиться от недостатков (и достоинств заодно). На этом разрешите откланяться, пиво, баш, соседка.

Список используемой литературы:
1. Код плагина jQuery-File-Upload — github.com/blueimp/jQuery-File-Upload/blob/master/js/jquery.fileupload.js
2. Документация по jQuery.Deferred — api.jquery.com/category/deferred-object/
3. Книга «Паттерны проектирования» Фриманов — www.ozon.ru/context/detail/id/20216992/

Автор:

Источник

Поделиться

* - обязательные к заполнению поля