Статьи

Асинхронная загрузка файлов в Rails

Снимок экрана 2014-06-29 09.38.45

Пару недель назад я написал статью под названием « Лучшая загрузка файлов с помощью Dragonfly», в которой объясняется, как настроить Dragonfly — жемчужину для обработки вложений. В посте было создано демо-приложение и встроен Dragonfly, что позволяет нашим пользователям обмениваться фотографиями.

Сегодня мы рассмотрим немного больше о загрузке файлов, например, как реализовать асинхронную загрузку файлов с помощью Rails, а также загрузку нескольких файлов с использованием AJAX.

Мы рассмотрим два решения: Remotipart и File Upload.

Некоторые другие вещи, которые будут рассмотрены в этой статье:

  • Создание индикатора выполнения для отслеживания хода загрузки файла и отображения битрейта.
  • Реализация проверки на стороне клиента.
  • Добавление «Dropzone» с некоторыми хорошими CSS3-эффектами, позволяющими пользователям перетаскивать файлы для загрузки.

Все это будет сделано за пять итераций. Давайте начнем!

Рабочую демонстрацию можно найти по адресу https://sitepoint-async-upload.herokuapp.com .

Исходный код доступен на GitHub .

Подготовка проекта

Для этой демонстрации я использую Rails 4.1.1, но такое же решение можно реализовать с помощью Rails 3.

На первой итерации мы создадим новый проект и очень быстро интегрируем в него Dragonfly (более подробное руководство см. В моей предыдущей статье ).

Создайте новое приложение без набора тестов по умолчанию:

$ rails new async_uploader -T 

Поместите несколько драгоценных камней в свой Gemfile:

Gemfile

 gem 'dragonfly' gem 'dragonfly-s3_data_store' group :production do gem 'rack-cache', :require => 'rack/cache' end gem 'bootstrap-sass' gem 'remotipart', '~> 1.2' 

dragonfly — это драгоценный камень, который будет обрабатывать всю магию загрузки, а dragonfly-s3_data_store позволит нам хранить изображения с помощью Amazon S3 (это понадобится нам в работе). rack-cache добавляет простой механизм кеширования для производственной среды. bootstrap-sass оснастит наше приложение Twitter Bootstrap. remotipart будет использоваться в следующей итерации.

Теперь подключите стили и скрипты Bootstrap (не забывайте, что для реальных приложений выбирайте только необходимые компоненты):

таблицы стилей / application.css.scss

 @import "bootstrap"; @import 'bootstrap/theme'; 

JavaScripts / application.js

 [...] //= require bootstrap 

Теперь запустите генератор Dragonfly, чтобы создать файл инициализатора:

 $ rails g dragonfly 

Откройте его и замените настройки datastore умолчанию следующими:

конфиг / Инициализаторы / dragonfly.rb

 [...] if Rails.env.development? || Rails.env.test? datastore :file, root_path: Rails.root.join('public/system/dragonfly', Rails.env), server_root: Rails.root.join('public') else datastore :s3, bucket_name: 'your_bucket_name', access_key_id: ENV['AWS_KEY'], secret_access_key: ENV['AWS_SEC'], url_scheme: 'https' end 

Для access_key_id и secret_access_key вам необходимо зарегистрироваться в Amazon, а затем открыть « YourAccount — Security Credentials», развернуть раздел «Ключи доступа» и создать новую пару. Контейнер можно создать с помощью консоли управления AWS .

Эта пара ключей должна храниться в безопасности, поэтому не включайте ее в систему контроля версий. Я использую переменные среды Heroku .

Также, если вы находитесь на Rails 4, включите rack-cache для производственной среды:

конфигурации / среда / production.rb

 [...] config.action_dispatch.rack_cache = true [...] 

Мы почти закончили настройку Dragonfly. Последнее, что нужно сделать, это создать модель и добавить требования к загрузке файлов. Допустим, мы хотим, чтобы пользователи загружали свои фотографии. Помимо полей по умолчанию наша таблица photos будет иметь следующие столбцы:

  • image_uid ( string ) — этот столбец будет хранить URI изображения
  • author ( string ) — этот столбец будет содержать имя автора

Отлично, теперь выполните эти команды, чтобы создать и применить необходимую миграцию:

 $ rails g model Photo image_uid:string title:string $ rake db:migrate 

На модели:

модели / photo.rb

 class Photo < ActiveRecord::Base dragonfly_accessor :image validates :image, presence: true validates_size_of :image, maximum: 500.kilobytes, message: "should be no more than 500 KB", if: :image_changed? validates_property :format, of: :image, in: [:jpeg, :jpg, :png, :bmp], case_sensitive: false, message: "should be either .jpeg, .jpg, .png, .bmp", if: :image_changed? end 

На этом мы закончили с Dragonfly. Конечно, нам также понадобится контроллер и соответствующий вид. Контроллер покажется простым:

Контроллеры / photos_controller.rb

 class PhotosController < ApplicationController def new @photos = Photo.order('created_at DESC') @photo = Photo.new end def create @photo = Photo.new(photo_params) @photo.save redirect_to new_photo_path end private def photo_params params.require(:photo).permit(:image, :title) end end 

Как видите, «Новая фотография» позволит не только создать новую фотографию, но и отобразит список всех загруженных фотографий.

Маршруты:

конфиг / routes.rb

 resources :photos, only: [:new, :create] root to: 'photos#new' 

Расположение:

макеты / application.html.erb

 [...] <body> <div class="navbar navbar-inverse" role="navigation"> <div class="container"> <div class="navbar-header"> <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse"> <span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> <a class="navbar-brand" href="#">Uploader</a> </div> <div class="collapse navbar-collapse"> <ul class="nav navbar-nav"> <li><%= link_to 'Upload one', root_path %></li> </ul> </div> </div> </div> <div class="container"> <%= yield %> </div> </body> [...] 

И мнение:

фото / new.html.erb

 <h1>List of photos</h1> <ul class="row" id="photos-list"> <%= render @photos %> </ul> <%= form_for @photo do |f| %> <div class="form-group"> <%= f.label :author %> <%= f.text_field :author, class: 'form-control' %> </div> <div class="form-group"> <%= f.label :image %> <%= f.file_field :image, required: true %> </div> <%= f.submit 'Submit', class: 'btn btn-primary btn-lg' %> <% end %> 

Я использую render @photos для рендеринга всех фотографий с использованием частичного _photos.html.erb , поэтому создайте его также:

фото / _photo.html.erb

 <li class="col-xs-3"> <%= link_to image_tag(photo.image.thumb('180x180#').url, alt: photo.author, class: 'img-thumbnail'), photo.image.remote_url, target: '_blank' %> <p><%= photo.author %></p> </li> 

И, наконец, немного стиля:

таблицы стилей / application.css.scss

 #photos-list { padding: 0; margin: 0; margin-top: 30px; clear: both; li { list-style-type: none; padding: 0; margin: 0; min-height: 272px; max-height: 272px; margin-bottom: 10px; text-align: center; img { margin-bottom: 10px; } p { margin: 0; height: 40px; overflow: hidden; } } } 

Фу, теперь мы закончили с первой итерации! На этом этапе запустите сервер и загрузите пару фотографий, чтобы убедиться, что все работает.

Делая это асинхронным

Наши пользователи могут загружать свои фотографии, но страница перезагружается при каждой отправке формы. Как насчет того, чтобы сделать его асинхронным, чтобы предварительный просмотр загруженной фотографии отображался мгновенно? Это можно сделать легко.

Вы можете подумать, что все, что нам нужно сделать, это добавить remote: true к помощнику формы, например так:

фото / new.html.erb

 [...] <%= form_for @photo, remote: true do |f| %> [...] 

Но это не сработает, потому что Rails не знает, как отправлять составные формы асинхронно. К счастью, решение простое. Помните, мы добавили gem 'remotipart', '~> 1.2' в наш Gemfile. Этот драгоценный камень , созданный Стивом Шварцем и Грегом Леппертом, позволяет загружать файлы AJAX с Rails 3 и 4, что именно то, что нам нужно.

Единственное, что нужно сделать, это добавить одну строку в файл application.js :

JavaScripts / application.js

 [...] //= require jquery.remotipart 

И теперь наше приложение знает кунг-фу! Довольно круто, не правда ли?

Контроллер нуждается в незначительной настройке, чтобы он отвечал на запросы AJAX:

Контроллеры / photos_controller.rb

 [...] def create respond_to do |format| @photo = Photo.new(photo_params) @photo.save format.html { redirect_to new_photo_path } format.js end end [...] 

Не забудьте создать соответствующий вид:

фото / create.js.erb

 <% if @photo.new_record? %> alert('The photo could not be uploaded: <%= j @photo.errors.full_messages.join(', ').html_safe %>'); <% else %> $('#photos-list').prepend('<%= j render @photo %>'); <% end %> 

if @photo.new_record? условие фактически проверяет, была ли фотография успешно сохранена (мы добавили несколько проверок, помните?). Если это не так, предоставьте пользователю базовое окно с предупреждением об ошибках.

Если запись была сохранена, используйте метод prepend jQuery, чтобы добавить еще один элемент в неупорядоченный список #photos-list list. Метод j необходим, потому что в противном случае весь HTML из частичного _photo.html.erb будет _photo.html.erb .

Это все! Теперь вы можете попробовать несколько фотографий и проверить, работает ли асинхронная загрузка.

Ну, это было легко. Давайте перейдем к третьей итерации и реализуем функциональность, позволяющую пользователям загружать несколько фотографий одновременно, асинхронно.

Асинхронная загрузка нескольких фотографий

Создайте другое представление и соответствующий метод контроллера, чтобы мы не испортили ранее написанный код.

Контроллеры / photos_controller.rb

 [...] def new_multiple @photos = Photo.order('created_at DESC') @photo = Photo.new end [...] 

Как видите, этот метод на самом деле такой же, как new . Разница во взгляде:

* Фото / new_multiple.html.erb

 <h1>List of photos</h1> <ul class="row" id="photos-list"> <%= render @photos %> </ul> <h3>Upload multiple photos</h3> <%= form_for @photo do |f| %> <div class="form-group"> <%= f.label :author %> <%= f.text_field :author, class: 'form-control' %> </div> <div class="form-group"> <%= f.label :image %> <%= f.file_field :image, required: true %> </div> <% end %> 

Обратите внимание, что в этом случае нам не нужно указывать remote: true для помощника по форме. Наконец, настройте маршрут:

конфиг / routes.rb

 [...] get '/photos/new_multiple', to: 'photos#new_multiple', as: :new_photo_multiple 

Чтобы разрешить загрузку нескольких файлов, мы используем плагин jQuery File Upload , созданный Sebastian Tschan. Этот плагин имеет множество опций и интересных функций. Я призываю вас проверить его документацию .

Мы также будем использовать jquery-fileupload-rails от Tors Dalid для интеграции этого плагина в наше приложение. К сожалению, этот драгоценный камень не обновлялся с 2012 года, поэтому давайте вместо этого будем использовать раздвоенную версию:

Gemfile

 gem 'jquery-fileupload-rails', github: 'Springest/jquery-fileupload-rails' 

Плагин загрузки файлов поставляется в двух версиях: пользовательский интерфейс и базовая версия. Мы собираемся реализовать базовую версию, так как она обеспечивает больший контроль и мы можем стилизовать форму так, как мы хотим.

JavaScripts / application.js

 [...] //= require jquery-fileupload/basic 

Теперь внесите некоторые изменения в представление:

фото / new_multiple.html.erb

 [...] <div class="form-group"> <%= f.label :image %> <%= f.file_field :image, required: true, multiple: true, name: 'photo 
'%> </ DIV> [...] <Скрипт> $ (документ) .ready (function () { $ ('# new_photo'). fileupload ({dataType: 'script'}); }); </ Скрипт>

Опция multiple: true сообщает файловому полю, что пользователь может выбрать несколько файлов, удерживая клавишу CTRL (или Shift).

Снимок экрана 2014-06-29 09.48.05

жестко кодирует имя поля, потому что в противном случае Rails назовет это поле

Снимок экрана 2014-06-29 09.50.57
и файлы будут представлены в виде массива. Мы хотим, чтобы они были отправлены один за другим, и выгрузка файлов позаботится об этом.

$('#new_photo').fileupload(); снабжает нашу форму магией загрузки файла, а dataType: 'script' указывает, что мы ожидаем JavaScript в ответе.

Также обратите внимание, что в нашей форме нет кнопки «Отправить». Это потому, что как только пользователь выбирает файлы, форма отправляется автоматически (вы можете написать короткое предупреждающее сообщение для пользователей по этому поводу).

В этот момент загрузите пару фотографий одновременно — метод create контроллера не требует изменений! Все должно работать, кроме имени автора. Фотографии будут загружены, но поле author будет пустым. Это связано с тем, что при загрузке файлов по умолчанию отправляются только файлы. Чтобы это исправить, нам нужно настроить скрипт:

фото / new_multiple.html.erb

 <script> $(document).ready(function() { var multiple_photos_form = $('#new_photo'); multiple_photos_form.fileupload({dataType: 'script'}); multiple_photos_form.on('fileuploadsubmit', function (e, data) { data.formData = {'photo[author]': $('#photo_author').val()}; }); }); </script> 

Здесь мы прослушиваем событие fileuploadsubmit (список обратных вызовов доступен здесь ) и привязываем значение поля автора к данным формы.

Добавление проверки на стороне клиента и индикатора выполнения

Мы готовы перейти к четвертой итерации. Прямо сейчас наша форма полностью функциональна, но у нее есть некоторые проблемы:

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

Решения просты: добавить проверку на стороне клиента и индикатор выполнения.

Давайте начнем с проверки. Настройте скрипт:

фото / new_multiple.html.erb

 [...] multiple_photos_form.fileupload({ dataType: 'script', add: function (e, data) { types = /(\.|\/)(gif|jpe?g|png|bmp)$/i; file = data.files[0]; if (types.test(file.type) || types.test(file.name)) { data.submit(); } else { alert(file.name + " must be GIF, JPEG, BMP or PNG file"); } } }); [...] 

Мы проверяем, имеет ли каждый файл правильный формат. Если это так, отправьте форму с помощью data.submit(); в противном случае предоставьте пользователю сообщение об ошибке. Теперь пользователь будет знать, являются ли файлы приемлемым форматом.

На индикатор выполнения. Bootstrap уже предоставляет красиво стилизованную панель, поэтому давайте использовать ее:

фото / new_multiple.html.erb

 [...] <div class="form-group"> <%= f.label :image %> <%= f.file_field :image, required: true, multiple: true, name: 'photo 
'%> </ DIV> <div class = "progress-wrapper"> <div class = "progress"> <div class = "progress-bar" role = "progressbar"> 0% </ DIV> </ DIV> </ DIV> [...]

Мы не хотим, чтобы индикатор выполнения отображался до тех пор, пока форма фактически не будет отправлена, поэтому немного измените стили:

таблицы стилей / application.css.scss

 [...] .progress-wrapper { display: none; } 

Чтобы отследить общий процесс загрузки файла, сценарий должен прослушать событие fileuploadprogressall , получить количество загруженных фрагментов и настроить индикатор выполнения. Кроме того, индикатор выполнения должен отображаться после начала загрузки, дополняющего событие fileuploadstart . Наконец, панель должна быть снова скрыта после завершения загрузки, и в этом случае помогает fileuploaddone .

Вот модифицированный скрипт:

фото / new_multiple.html.erb

 [...] var wrapper = multiple_photos_form.find('.progress-wrapper'); var progress_bar = wrapper.find('.progress-bar'); multiple_photos_form.on('fileuploadstart', function() { wrapper.show(); }); multiple_photos_form.on('fileuploaddone', function() { wrapper.hide(); progress_bar.width(0); // Revert progress bar's width back to 0 for future uploads }); multiple_photos_form.on('fileuploadprogressall', function (e, data) { var progress = parseInt(data.loaded / data.total * 100, 10); progress_bar.css('width', progress + '%').text(progress + '%'); }); [...] 

data.loaded получает количество загруженных байтов, тогда как data.total содержит общее количество загружаемых байтов.

Последнее, что нужно сделать, это показать битрейт (скорость загрузки) пользователю. Это легко:

фото / new_multiple.html.erb

 [...] <div class="progress-wrapper"> <p>Bitrate: <span class="bitrate"></span></p> <div class="progress"> <div class="progress-bar" role="progressbar"> 0% </div> </div> </div> [...] 

И сценарий:

 [...] var bitrate = wrapper.find('.bitrate'); multiple_photos_form.on('fileuploadprogressall', function (e, data) { bitrate.text((data.bitrate / 1024).toFixed(2) + 'Kb/s'); var progress = parseInt(data.loaded / data.total * 100, 10); progress_bar.css('width', progress + '%').text(progress + '%'); }); 

Наша форма выглядит довольно круто и предоставляет полезную информацию для пользователя. Давайте перейдем к пятой и последней итерации.

Добавление Dropzone

Плагин File Upload предоставляет способ настроить так называемую «Dropzone» — область, где пользователи могут перетаскивать свои файлы, чтобы начать загрузку немедленно (проверьте поддержку браузера ).

Это хорошая альтернатива файловому диалогу по умолчанию. Кажется более естественным просто оставить некоторые файлы для загрузки.

Давайте добавим новый элемент в нашу разметку:

фото / new_multiple.html.erb

 [...] <h3>Upload multiple photos</h3> <div id="dropzone" class="fade">Drop files here</div> <%= form_for @photo do |f| %> <div class="form-group"> <%= f.label :author %> <%= f.text_field :author, class: 'form-control' %> </div> <div class="form-group"> <%= f.label :image, 'Choose files here' %> <%= f.file_field :image, required: true, multiple: true, name: 'photo 
'%> </ DIV> <div class = "progress-wrapper"> <p> Битрейт: <span class = "битрейт"> </ span> </ p> <div class = "progress"> <div class = "progress-bar" role = "progressbar"> 0% </ DIV> </ DIV> </ DIV> <% end%> [...]

Сценарий:

фото / new_multiple.html.erb

 [...] multiple_photos_form.fileupload({ dataType: 'script', dropZone: $('#dropzone'), add: function (e, data) { types = /(\.|\/)(gif|jpe?g|png|bmp)$/i; file = data.files[0]; if (types.test(file.type) || types.test(file.name)) { data.submit(); } else { alert(file.name + " must be GIF, JPEG, BMP or PNG file"); } } }); [...] 

Мы также можем применить некоторые CSS3-переходы к рабочей зоне (здесь нам помогут события File Upload). Вот несколько стилей, которые вы можете использовать:

таблицы стилей / application.css.scss

 @import "compass/css3/transition"; @import "compass/css3/opacity"; @import "compass/css3/border-radius"; [...] #dropzone { background: palegreen; width: 150px; text-align: center; font-weight: bold; height: 50px; line-height: 50px; border: 1px solid darken(palegreen, 10%); @include border-radius(10px); } #dropzone.in { width: 600px; height: 200px; line-height: 200px; font-size: larger; } #dropzone.hover { background: lawngreen; border: 1px solid darken(lawngreen, 10%); } #dropzone.fade { @include transition-property(all); @include transition-duration(0.5s); @include transition-timing-function(ease-out); @include opacity(1); } 

Я использую некоторые утилиты Compass (мне лень указывать все префиксы вендоров для атрибутов CSS3), поэтому не забудьте добавить эти строки в Gemfile:

Gemfile

 [...] gem 'compass-rails' gem 'compass' [...] 

и запустите bundle install .

Вот сценарии, которые позаботятся об анимации дропзоны:

фото / new_multiple.html.erb

 $(document).bind('dragover', function (e) { var dropZone = $('#dropzone'), timeout = window.dropZoneTimeout; if (!timeout) { dropZone.addClass('in'); } else { clearTimeout(timeout); } var found = false, node = e.target; do { if (node === dropZone[0]) { found = true; break; } node = node.parentNode; } while (node != null); if (found) { dropZone.addClass('hover'); } else { dropZone.removeClass('hover'); } window.dropZoneTimeout = setTimeout(function () { window.dropZoneTimeout = null; dropZone.removeClass('in hover'); }, 100); }); - $(document).bind('dragover', function (e) { var dropZone = $('#dropzone'), timeout = window.dropZoneTimeout; if (!timeout) { dropZone.addClass('in'); } else { clearTimeout(timeout); } var found = false, node = e.target; do { if (node === dropZone[0]) { found = true; break; } node = node.parentNode; } while (node != null); if (found) { dropZone.addClass('hover'); } else { dropZone.removeClass('hover'); } window.dropZoneTimeout = setTimeout(function () { window.dropZoneTimeout = null; dropZone.removeClass('in hover'); }, 100); }); 

Вы можете прочитать больше об этом здесь .

Теперь вы можете проверить форму и увидеть, что все это работает. ?

Вывод

Мы рассмотрели два решения для асинхронной загрузки файлов. Надеюсь, вы нашли эту статью интересной и полезной.

Вы когда-нибудь пытались реализовать асинхронную загрузку файлов в своих приложениях? Какие решения вы использовали? С какими проблемами вы столкнулись? Поделитесь своим опытом в комментариях!