Статьи

Застегни это! Zip It Good с Rails и Rubyzip

В нашей повседневной деятельности мы часто взаимодействуем с архивами. Если вы хотите отправить своему другу пачку документов, вы, вероятно, сначала заархивируете их. Когда вы загружаете книгу из Интернета, она, вероятно, будет заархивирована вместе с сопроводительными материалами. Итак, как мы можем взаимодействовать с архивами в Ruby?

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

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

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

  • Текстовые файлы сжимаются очень хорошо. В зависимости от их содержания, соотношение составляет около 3: 1.
  • Некоторые изображения могут выиграть от сжатия, но при использовании формата, такого как .jpg, который уже имеет собственное сжатие, он не сильно изменится.
  • Бинарные файлы могут быть сжаты до 2 раз от их оригинального размера.
  • Аудио и видео, как правило, плохие кандидаты на сжатие.

Начиная

Создайте новое приложение Rails:

$ rails new Zipper -T 

Я использую Rails 5 beta 3 и Ruby 2.2.3 для этой демонстрации, но rubyzip работает с Ruby 1.9.2 или выше.

В нашем сценарии сегодня демо-приложение отслеживает животных. Каждое животное имеет следующие атрибуты:

  • name ( string )
  • age ( integer ) — конечно, вы можете использовать десятичную вместо
  • species ( string )

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

Создайте и примените соответствующую миграцию:

 $ rails g model Animal name:string age:integer species:string $ rake db:migrate 

Теперь давайте подготовим страницу по умолчанию для нашего приложения:

animals_controller.rb

 class AnimalsController < ApplicationController def index @animals = Animal.order('created_at DESC') end end 

просмотров / животные / index.html.erb

 <h1>My animals</h1> <ul> <% @animals.each do |animal| %> <li> <strong>Name:</strong> <%= animal.name %><br> <strong>Age:</strong> <%= animal.age %><br> <strong>Species:</strong> <%= animal.species %> </li> <% end %> </ul> 

конфиг / routes.rb

 [...] resources :animals, only: [:index, :new, :create] root to: 'animals#index' [...] 

Ницца! Перейдите к следующему разделу, и давайте сначала позаботимся о создании.

Создание животных из архива

Введите new действие:

animals_controller.rb

 [...] def new end [...] 

* Вид / животные / index.html.erb

 <h1>My animals</h1> <%= link_to 'Add!', new_animal_path %> [...] 

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

  • animals.zip
    • животных 1.json
    • животных 2.json

Каждый файл JSON будет иметь следующую структуру:

 { name: 'My name', age: 5, species: 'Dog' } 

Конечно, вы можете использовать другой формат, например, XML.

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

просмотров / животные / new.html.erb

 <h1>Add animals</h1> <p> Upload a zip archive with JSON files in the following format:<br> <code>{name: 'name', age: 1, species: 'species'}</code> </p> <%= form_tag animals_path, method: :post, multipart: true do %> <%= label_tag 'archive', 'Select archive' %> <%= file_field_tag 'archive' %> <%= submit_tag 'Add!' %> <% end %> 

Это базовая форма, позволяющая пользователю выбрать файл (не забудьте опцию multipart: true ).

Теперь действие контроллера:

animals_controller.rb

 def create if params[:archive].present? # params[:archive].tempfile ... end redirect_to root_path end 

Единственный интересующий нас параметр — это :archive . Пока он содержит файл, он отвечает на метод tempfile который возвращает путь к загруженному файлу.

Для чтения архива мы будем использовать метод Zip::File.open(file) который принимает блок. Внутри этого блока вы можете извлечь каждый заархивированный файл и либо извлечь его куда-нибудь, используя extract либо прочитать его в память с помощью get_input_stream.read . Нам на самом деле не нужно никуда извлекать наш архив, поэтому давайте вместо этого сохраним содержимое в памяти.

animals_controller.rb

 require 'zip' [...] def create if params[:archive].present? Zip::File.open(params[:archive].tempfile) do |zip_file| zip_file.each do |entry| Animal.create!(JSON.load(entry.get_input_stream.read)) end end end redirect_to root_path end [...] 

Довольно просто, не правда ли? entry.get_input_stream.read считывает содержимое файла, а JSON.load анализирует его. Нас интересуют только файлы .json , поэтому давайте ограничим область видимости, используя метод glob :

animals_controller.rb

 [...] def create if params[:archive].present? Zip::File.open(params[:archive].tempfile) do |zip_file| zip_file.glob('*.json').each do |entry| Animal.create!(JSON.load(entry.get_input_stream.read)) end end end redirect_to root_path end [...] 

Вы также можете извлечь часть кода в модель и ввести базовую обработку ошибок:

animals_controller.rb

 [...] def create if params[:archive].present? Zip::File.open(params[:archive].tempfile) do |zip_file| zip_file.glob('*.json').each { |entry| Animal.from_json(entry) } end end redirect_to root_path end [...] 

animal.rb

 [...] class << self def from_json(entry) begin Animal.create!(JSON.load(entry.get_input_stream.read)) rescue => e warn e.message end end end [...] 

Я также хочу добавить в белый список атрибуты, которые пользователь может назначить, чтобы он не мог переопределить поля id или created_at :

animal.rb

 [...] WHITELIST = ['age', 'name', 'species'] class << self def from_json(entry) begin Animal.create!(JSON.load(entry.get_input_stream.read).select {|k,v| WHITELIST.include?(k)}) rescue => e warn e.message end end end [...] 

Вместо этого вы можете использовать подход черного списка, заменив select на исключением , но белый список более безопасен.

Большой! Теперь создайте zip-архив и попробуйте загрузить его!

Создание и загрузка архива

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

Добавить новую ссылку на корневую страницу:

просмотров / животные / index.html.erb

 [...] <%= link_to 'Download archive', animals_path(format: :zip) %> 

Мы будем использовать то же действие index и оснастим его методом respond_to :

animals_controller.rb

 [...] def index @animals = Animal.order('created_at DESC') respond_to do |format| format.html format.zip do end end end [...] 

Чтобы отправить архив пользователю, вы можете либо создать его где-то на диске, либо создать его на лету. Создание архива на диске включает в себя следующие этапы:

  • Создайте массив файлов, которые должны быть помещены в архив:
 files << File.open("path/name.ext", 'wb') { |file| file << 'content' } 
  • Создать архив:
 Zip::File.open('path/archive.zip', Zip::File::CREATE) do |z| 
  • Добавьте ваши файлы в архив:
 Zip::File.open('path/archive.zip', Zip::File::CREATE) do |z| files.each do |f| z.add('file_name', f.path) end end 

Метод add принимает два аргумента: имя файла в том виде, в котором оно должно отображаться в архиве, а также путь и имя исходного файла.

  • Отправить архив:
     send_file 'path/archive.zip', type: 'application/zip', disposition: 'attachment', filename: "my_archive.zip" 

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

Вместо этого я хотел бы создать наш архив на лету и использовать метод send_data для отображения ответа в виде вложения. Это немного сложнее, но мы ничего не можем поделать.

Для выполнения этой задачи нам потребуется метод с именем Zip::OutputStream.write_buffer который принимает блок:

animals_controller.rb

 [...] def index @animals = Animal.order('created_at DESC') respond_to do |format| format.html format.zip do compressed_filestream = Zip::OutputStream.write_buffer do |zos| end end end end [...] 

Чтобы добавить новый файл в архив, используйте zos.put_next_entry , zos.put_next_entry имя файла. Вы даже можете указать каталог для zos.put_next_entry('nested_dir/my_file.txt') вашего файла, сказав zos.put_next_entry('nested_dir/my_file.txt') . Чтобы записать что-то в файл, используйте print :

animals_controller.rb

 compressed_filestream = Zip::OutputStream.write_buffer do |zos| @animals.each do |animal| zos.put_next_entry "#{animal.name}-#{animal.id}.json" zos.print animal.to_json(only: [:name, :age, :species]) end end 

Мы не хотим, чтобы такие поля, как id или created_at присутствовали в файле, поэтому говорим :only мы ограничиваем их name , age и species .

Теперь перемотайте поток:

 compressed_filestream.rewind 

И отправь это:

 send_data compressed_filestream.read, filename: "animals.zip" 

Вот результирующий код:

animals_controller.rb

 [...] def index @animals = Animal.order('created_at DESC') respond_to do |format| format.html format.zip do compressed_filestream = Zip::OutputStream.write_buffer do |zos| @animals.each do |animal| zos.put_next_entry "#{animal.name}-#{animal.id}.json" zos.print animal.to_json(only: [:name, :age, :species]) end end compressed_filestream.rewind send_data compressed_filestream.read, filename: "animals.zip" end end end [...] 

Идите и попробуйте ссылку «Скачать архив»!

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

animals_controller.rb

 [...] compressed_filestream = Zip::OutputStream.write_buffer(::StringIO.new(''), Zip::TraditionalEncrypter.new('password')) do |zos| [...] 

Настройка Rubyzip

Rubyzip предоставляет множество параметров конфигурации, которые могут быть предоставлены в блоке:

 Zip.setup do |c| end 

или один за другим:

 Zip.option = value 

Вот доступные варианты:

  • on_exists_proc — Должны ли существующие файлы быть перезаписаны во время извлечения? По умолчанию установлено значение false .
  • continue_on_exists_proc — Должны ли существующие файлы быть перезаписаны при создании архива? По умолчанию установлено значение false .
  • unicode_names — установите этот параметр, если вы хотите хранить имена файлов, отличные от Unicode, в Windows Vista и более unicode_names установлено значение false .
  • warn_invalid_date — должно ли отображаться предупреждение, если архив имеет неправильный формат даты? По умолчанию это true .
  • default_compression — Используемый по умолчанию уровень сжатия. Первоначально установлено значение Zlib::DEFAULT_COMPRESSION , другими возможными значениями являются Zlib::BEST_COMPRESSION и Zlib::NO_COMPRESSION .
  • write_zip64_support — следует ли отключить поддержку Zip64 для записи? По умолчанию установлено значение false .

Вывод

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

Как всегда, спасибо, что остаетесь со мной и до скорой встречи!