Pull to refresh

Домашний файлообменник на базе Sinatra и DataMapper. Часть 2 — Advanced features.

Ruby *
В первой статье я рассказывал о том, как написать простое веб-приложение с применением Sinatra и DataMapper. В этот раз мы добавим множество новых фич и улучшим код в целом.

Предварительная подготовка


В этом разделе мы подготовим почву для всех будущих изысканий.

Итак, в прошлый раз мы получили приложение со следующей файловой структурой:

/
  myapp.rb
  views/
    list.erb
  files/


При этом весь код находится в одном файле myapp.rb. На мой взгляд, это весьма неудобно и некрасиво, поэтому в этот раз мы начнем с создания папок и файлов, часть из которых пригодится нам по ходу повествования. У меня получилась следующая структура:

/
  files/
  lib/
    tasks/
  manual/
  public/
  test/
  views/


В папке files у нас у нас лежат файлы, доступные извне. В папке lib — библиотеки. Папка tasks будет содержать rake-задачи (rake-tasks). В папку manual мы будем класть файлы, минуя веб-интерфейс. Public — хранилище файлов, отдаваемых браузеру напрямую. В test положим, как ни странно, тесты. Наконец, views нам уже знакома — там находятся шаблоны для генерации HTML страниц (пока он у нас всего один).

Теперь давайте установим Sinatra локально (прямо в папку с приложением) — это позволит нам не заботиться об установки Sinatra gem'а. Для этого загрузим код Синатры из git-репозитория. Для этого у вас должен быть установлен git:

cd lib
git clone git://github.com/bmizerany/sinatra.git
rm -rf sinatra/.git
rm sinatra/.gitignore
cd ..


Основной код нашего приложения разместим в файле init.rb, предварительно изменив его начало на такое:

require 'rubygems'
require File.expand_path(File.dirname(__FILE__) + '/lib/sinatra/lib/sinatra')
require File.expand_path(File.dirname(__FILE__) + '/lib/config')
require 'ftools'
require 'dm-core'
require 'dm-validations'
require 'dm-timestamps'


Этот код подключает Синатру и конфигурационный файл config.rb из папки lib — его-то мы сейчас и создадим и напишем в нем следующий (пока ничего не делающий) код:

configure do
  # Здесь будут параметры конфигурации для Синатры
end


Затем нам пригодится Rakefile — специальный файл для утилиты rake, который указывает ей, где брать задачи (tasks). Файл под именем Rakefile (без расширения), расположенный в корневой директории, должен содержать следующее:

require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'
 
Dir["#{File.dirname(__FILE__)}/lib/tasks/**/*.rake"].sort.each { |ext| load ext }


В нём мы просто загружаем все rake-файлы из каталога lib/tasks и его подкаталогов (для этого применяется регулярное выражение /**/*.rake).

Разделяем код


Код приложения, лежащий в одном файле — это не только неудобно, но и неправильно :) Поэтому сейчас мы начнем с того, что вынесем наш класс StoredFile в отдельный, простите за каламбур, файл. В папке lib у нас должен появиться файл stored_file.rb со следующим содержимым:

require 'dm-core'
require 'dm-validations'
require 'dm-timestamps'

DataMapper.setup(:default, "sqlite3://#{Dir.pwd}/files.sqlite3")

class StoredFile
  include DataMapper::Resource

  property :id,         Integer, :serial => true    # primary serial key
  property :filename,   String,    :nullable => false # cannot be null
  property :created_at, DateTime
  
  default_scope(:default).update(:order => [:created_at.desc])
end

DataMapper.auto_upgrade!


А в конец бесчисленных require в init.rb мы добавим строку

require 'lib/stored_file'


Функциональность нашего приложения не изменилась ни на йоту, но выглядит оно теперь немного поприличнее.

Viva la Haml!


В первой версии приложения мы использовали для генерации HTML страниц язык Erb (Embedded RuBy), который представляет собой HTML-код с включенными фрагментами Ruby-кода. Этот подход традиционен в Rails, но мы-то занимаемся маргинальными решениями :) В связи с этим я принял решение применять в качестве языка для шаблонов Haml.

Haml — это язык разметки, проповедующий красоту, как главное божество шаблона. И действительно, код на Haml, на мой взгляд, выглядит симпатичнее (а поговаривают, что и работает он быстрее). Еще одним важным свойством Haml'а является то, что в он не позволит вам не закрыть тег (потому что вы вообще не будете использовать HTML-теги в явной форме)

Установка проста:

sudo gem install haml


Давайте создадим файл list.haml в нашем каталоге views и займемся его наполнением. Haml похож на Python тем, что использует отступы в коде в качестве ограничителей блоков (в HTML для этого используются закрывающие теги, а в Erb ключевое слово end). Таким образом, HTML-код

<div>
<span>Some text</span>
</div>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>


На Haml выглядит следующим образом:

%div
  %span Some text
%ul
  %li Item 1
  %li Item 2


Что, согласитесь, гораздо короче.

Немного о синтаксисе, который нам пригодится:

%TAG

Создает тег <TAG /> или <TAG></TAG> — в зависимости от тега (для hr будет выбран первый вариант, для a — второй)

%TAG CONTENT

Создает тег <TAG>CONTENT</TAG>.

%TAG
  CONTENT

Превратится в <tag>CONTENT<tag>. При этом CONTENT может тоже содержать оператор % и любые другие операторы.

Для задания аттрибутов используется тот же синтаксис, что и для хешей в Ruby:

%span{ :class => "header", :id => "news_135", :style => "float:left"} Лось съел 3 килограмма собачьей еды и залаял!

Станет <span class=«header» id=«news_135» style=«float:left»>Лось съел 3 килограмма собачьей еды и залаял!</span>

На самом деле, для задания класса и id существует более простой синтаксис (весьма напоминающий CSS):
%span.header#news_135 Еще один желтый заголовок


А для создания div'ов с нужным классом, всё еще проще:
.content = %div.content = %div{ :class => "content" } = <div class="content">


Теперь вмешаем сюда Ruby. Для выполнения строки кода (аналог <% %> в Erb) поставьте перед ней знак "-":
- str = @my_object.get_some_string


Для превращения результата в строку используется оператор "=":
- now = Time.now
= now

Выведет текущее время.

Разумеется, можно использовать = в комплекте с операторами тегов:
%span#current_time= Time.now


И последнее, что нам нужно знать для написания нашего Haml-шаблона, это оператор "!!!". По умолчанию он создает DOCTYPE Transitional. А большего нам от него и не надо.

Пишем шаблон


У меня получился следующий шаблон для отображения списка файлов и формы загрузки нового файла:
!!!
%html{ :xmlns => "http://www.w3.org/1999/xhtml" }
  %head
    %title Файлопомойка имени меня
    %meta{ :"http-equiv" => "content-type", :content => "text/html;charset=UTF-8" }
  %body
    .main
		- if @files.empty?
		  %h1 Файлов нет.
		- else
		  .list
		    %h1 Список файлов
		    %table{ :cellspacing => 0}
		      %tr
		        %th Файл
		        %th Загружен
		        %th Удалить
		      - @files.each do |file|
		        %tr
		          %td.filename
		            %a{ :href => "/#{file.id}", :title => file.filename }= file.filename
		          %td.created_at= file.created_at.strftime("%d %b")
		          %td.delete
		            %a{ :href => "/#{file.id}/delete", :title => "Удалить" } Удалить
		.upload
		  %h1 Добавить
		  %form{ :name => "new_file", :enctype => "multipart/form-data", :method => "post", :action => "/" }
		    %input{ :name => "file", :type => "file" }
		    %br
		    %input{ :type => "submit", :value => "Закачать" }


Не могу не отметить, что получившийся шаблон весьма элегантен (особенно, если у вас есть текстовый редактор с подсветкой Haml-синтаксиса).

Отделяем макет от шаблона


Давайте представим, что нам могут понадобится еще и другие представления для нашего приложения (а они нам понадобятся) — не будем же мы в каждом писать общую шапку с "!!!", "%html" и так далее. Для решения этой проблемы в Sinatra, как и в Rails, есть механизм макетов (layouts). Макет можно рассматривать, как шаблон верхнего уровня — в макет вставляется содержимое шаблона и уже вместе они уходят пользователю.

Для использования макета нам надо просто поместить файл layout.haml в папку views и он автоматически начнет использоваться нашим приложением. Кстати, точно также мы могли бы создать макет layout.erb, который бы использовался для Erb шаблонов.

Из шаблона в макет перекочует следующий код:

!!!
%html{ :xmlns => "http://www.w3.org/1999/xhtml" }
  %head
    %title Файлопомойка имени меня
    %meta{ :"http-equiv" => "content-type", :content => "text/html;charset=UTF-8" }
  %body
    .main
      = yield

Новой тут является только строчка "= yield" в конце. Она, как, опять же, и в случае с Erb макетами, вызывает обработку Haml-шаблона в месте своего вызова (то есть внутри div'а с классом main). Код, который мы разместили в layout.haml, само собой, надо удалить из list.haml.

Всё! Теперь мы солидные люди и используем Haml в качестве языка для шаблонов. Осталась самая малость — указать Sinatra на то, что надо выводить не list.erb, а list.haml. Для этого идем в наш init.rb, и заменяем строчку «erb :list», на «haml :list». Вот теперь всё — мы используем Haml и радуемся жизни.

Хотя, я, пожалуй, готов поставить немного денег на то, что у вас ничего не получилось и Sinatra выдал вам сообщение об ошибке, связанной с отсупами в файле шаблона или макета. Дело вот в чем: Haml работает только с шаблонами и макетами, в которых в качестве отступа используется 2 пробела — именно пробела, а не знака табуляции, к примеру. И именно 2. Исправьте это и, скорее всего, ошибка исчезнет. В частности, если вы копировали код прямо со страницы, то у вас совершенно точно присутствуют знаки табуляции вместо пробелов.

Прячем ссылки от посторонних глаз


Я уже объяснял, почему пользователям не стоит давать возможность знать id файла, который они скачивают, поэтому останавливаться на этом не буду. Теперь пришла пора решить эту проблему. В качестве адресов для скачивания мы будем использовать сорокасимвольный SHA-1 дайджест (digest) от имени файла в качестве его идентификатора. Для этого проделаем следующее:
Добавим в stored_file.rb новое поле для хранения дайджеста:
property :sha,        String

В конце файла у нас написано «DataMapper.auto_upgrade!», а значит, база данных будет автоматически обновлена DataMapper'ом.

Откроем файл init.rb и изменим все блоки кроме первого (get '/'):
post '/' do
  tempfile = params['file'][:tempfile]
  filename = params['file'][:filename]  digest = Digest::SHA1.hexdigest(filename) # Вычисляем дайджест
  @file = StoredFile.create :filename => filename, :sha => digest # Записываем имя файла и дайждест в БД
  File.copy(tempfile.path, "./files/#{@file.id}.upload")
  redirect '/'
end

# download file 
  get '/:sha' do # теперь ищем файлы по значению sha, а не по id
  @file = StoredFile.first :sha => params[:sha]
  send_file "./files/#{@file.id}.upload", :filename => @file.filename, :type => 'Application/octet-stream'
end

# delete file
  get '/:sha/delete' do # и удаляем тоже по sha, а не по id
  @file = StoredFile.first :sha => params[:sha]
  File.delete("./files/#{@file.id}.upload")
  @file.destroy
  redirect '/'
end


Осталось изменить наш шаблон:
%td.filename
  %a{ :href => "/#{<strong>file.sha</strong>}", :title => file.filename }= file.filename
%td.created_at= file.created_at.strftime("%d %b")
%td.delete
  %a{ :href => "/#{<strong>file.sha</strong>}/delete", :title => "Удалить"} Удалить


Готово! Теперь наши ссылки выглядят вот так:
localhost:4567/1c62e8aa8072c8a3cd5df1ba49bb4513bc1d8a88


Добавляем счетчик скачиваний и указание размера файла


Это совсем простой пункт. Количество скачиваний и размер файла мы будем хранить в БД, а значит надо расширить наше описание класса StoredFile:
  property :downloads,  Integer, :default => 0
  property :filesize,   Integer, :nullable => false


Upload и download файла выглядят теперь так:

post '/' do
  tempfile = params['file'][:tempfile]
  filename = params['file'][:filename]
  digest = Digest::SHA1.hexdigest(filename)
  @file = StoredFile.create :filename => filename, :sha => digest, :filesize => File.size(tempfile.path)

  File.copy(tempfile.path, "./files/#{@file.id}.upload")
  redirect '/'
end

get '/:sha' do
  @file = StoredFile.first :sha => params[:sha]
  @file.downloads += 1
  @file.save

  send_file "./files/#{@file.id}.upload", :filename => @file.filename, :type => 'Application/octet-stream'
end


Отобразим мы новые данные следующим образом:
      %tr
        %th Файл
        %th Загружен
        %th Скачан

        %th Удалить
      - @files.each do |file|
        %tr
          %td.filename
            %a{ :href => "/#{file.sha}", :title => file.filename }= file.filename
            = "(#{file.filesize/1024} Kb)"
          
          %td.created_at= file.created_at.strftime("%d %b")
          %td.downloads= file.downloads
          
          %td.delete
            %a{ :href => "/#{file.sha}/delete", :title => "Удалить" } Удалить


Немного JavaScript


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

В папке public создадим файл script.js со следующим элементарным содержимым:
function ORLY() {
	return confirm('Вы уверены');
}


Затем подключим этот JavaScript-файл макете:
    %meta{ :"http-equiv" => "content-type", :content => "text/html;charset=UTF-8" }
    %script{ :type => "text/javascript", :src => "/script.js"}

  %body


А в нашем шаблоне внесем небольшую правку:
%td.delete
  %a{ :href => "/#{file.sha}/delete", :title => "Удалить", :onclick => "return ORLY();"} Удалить


Таким образом, при нажатии кнопки «Удалить» у нас будут спрашивать, уверены ли мы, а это лишний шанс одуматься.

Автоматически ищем и добавляем новые файлы


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

К счастью это несложно, если мы будем использовать Rake — аналог Make для Ruby. Эта утилита позволяет создавать так называемые задачи (tasks), которые выполняются командой rake ИМЯ_ЗАДАЧИ. При запуске rake ищет файл Rakefile в текущей директории и выполняет указанные в нем команды, потом ищет задачу с указанным именем и выполняет её.

Что же такое rake task? Это не более чем специальным образом оформленный Ruby-код. В нашем случае, rake task будет брать все файлы из каталога manual и имитировать их загрузку обычным образом (то есть копировать их в папку files и создавать необходимые записи в БД).

На случай, если у вас вдруг отсутствует Rake (что маловероятно), установка проводиться традиционным для Ruby образом:
sudo gem install rake


Итак, наш файл Rakefile, который мы создали в начале сообщает Rake, что ему необходимо загрузить все файлы с расширением .rake из каталога lib/tasks. Значит, именно там мы и создадим наш файл, который назовем manual_monitor.rake. Напишем же мы в него следующее:

require 'ftools'
require 'lib/datamapper/stored_file'

desc "Checks directory 'manual' and uploads all files from it"
task : manual_monitor do
  Dir["manual/*"].each do |path|
    file = File.new(path)
    filename = File.basename(file.path)
    digest = Digest::SHA1.hexdigest(filename)
    stored_file = StoredFile.create :filename => filename, :sha => digest, :filesize => File.size(file.path)
    File.move(path, "./files/#{stored_file.id}.upload")
    puts "File #{path} succesfully uploaded. ID = #{stored_file.id}"
  end
end


Сначала мы подключаем ftools (файловые утилиты) и наш stored_file.rb (описывающий класс StoredFile — понадобится нам для работы с БД. Далее идет описание задачи check_files. Как можно догадаться, в общем виде задача описывается так:
task :TASK_NAME do
  CODE
end

Необязательная команда desc перед задачей позволяет задать произвольное текстовое описание для задачи (его можно просмотреть по команде «rake --tasks»)

Внутри самой задачи мы делаем почти то же самое, что и в коде, загружающем файл через броузер, но с парой отличий:
  1. Мы работаем со всеми файлами из директории manual
  2. Мы перемещаем файл из manual в files (чтобы не обрабатывать его повторно)


Можно проверить, как работает наша задача, поместив в папку manual какой-либо файл и вызвав команду
rake manual_monitor

Rake должен сообщить, что файл скопирован успешно. Через веб-интерфейс мы можем убедиться, что так и есть.

Теперь нам надо научиться вызывать нашу команду автоматически. Я рассматривал два варианта: через crontab и посредством Ruby-демона. Мы с вами, конечно, извращенцы и маргиналы, но изобретать велосипед (и просто так расходовать системные ресурсы) в этом случае я не стал и просто воспользовался crontab'ом. Впрочем, если кому-то интересен вариант запуска через Ruby-демона, могу рассказать и про него.

Мой crontab выглядит так
*/1 * * * * cd /path/to/my/app/; rake manual_monitor

То есть команда выполняется каждую минуту.

Уфф!


Изначально я планировал уложиться в одну статью, но практика показала, что даже двух мало. В третьей части я планирую рассказать про:
  • Нормальную авторизацию (чтобы разрешить всем скачивать файлы, но запретить кому попало их загружать и удалять)
  • Вспомогательные методы (helpers)
  • Как добавить задержку при скачивании (чтобы можно было убедиться, что ты пришел по верной ссылке и не скачивать стомегабайтный файл по GPRS)
  • Конфигурирование
  • Создание CSS с помощью SASS
  • Тестирование кода
  • Оценки производительности


Вот сейчас написал список и уже сомневаюсь, что и трех частей мне будет достаточно… Но если вы хотите узнать что-то еще — пишите в комментариях, разберёмся :)

В очередной раз, спасибо за внимание!
Tags:
Hubs:
Total votes 26: ↑26 and ↓0 +26
Views 10K
Comments 16
Comments Comments 16