О чём поёт Sinatra

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

Введение

Sinatra — DSL (Domain Specific language, предметно-специфичный язык), использующий интерфейс для разработки веб приложений Rack. Хотя в некоторых источниках его гордо именуют веб-фреймворком, на официальном сайте можно встретить следующую формулировку:

Sinatra is a DSL for quickly creating web applications in Ruby with minimal effort

Sinatra это DSL для быстрого создания веб-приложений на Ruby с минимальными усилиями

В природе имеются и полноценные фреймворки, базирующиеся на sinatra. Padrino, например.

Увы, об истории создания самого Sinatra миру неизвестно ничего, кроме того факта, что разработал его некий Blake Mizerany из солнечного штата Калифорния.

Первые шаги

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

$ mkdir sinatra && cd sinatra
$ touch main.rb config.ru Gemfile

При помощи main.rb мы будем взывать к самому sinatra, config.ru понадобится нам для запуска нашего минималистичного веб-сервера, а в Gemfile будут сосредоточены те джемы, которые потребуются для запуска всего этого хозяйства.

# main.rb

require 'sinatra' # подгрузить sinatra
# sinatra имеет модульную структуру
# что позволяет включать лишь необходимое:
# require 'sinatra/base'
# вывести на главной странице приветствие
get '/' do
  "<h2>Hello!</h2>"
end

Дальше заглянем в файл с нашими джемами:

# Gemfile

source 'https://rubygems.org' # откуда брать gem'ы
ruby '2.1.0' # версия руби

gem 'sinatra'
gem 'rake'

Можно установить эти джемы вручную или же при помощи bundle, ежели таковой у вас уже имеется:

$ gem install my_gem # ручная установка джемов
$ bundle install # рекомендуемый способ, bundle будет читать ваш Gemfile

Чтобы при установке пакетов не ставить документацию к ним можно создать в домашнем каталоге файл ~/.gemrc с таким содержанием:

# ~/.gemrc
gem: --no-rdoc --no-ri

Осталось главное: запустить наше приветствие. Сделаем это, обратившись к config.ru:

#\ -w -p 4000 # указать желаемый порт

require './main.rb' # подгрузить main.rb
run Sinatra::Application # запустить приложение

Отныне по адресу http://localhost:4000 доступна наша первая страничка. Введите в консоли команду rackup, чтобы запустить сервер разработки WEBrick и убедиться в этом.

Миграции

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

sinatra
├── app
│   ├── helpers.rb
│   ├── models.rb
│   └── routes.rb
├── config
│   ├── constants.rb
│   └── environments.rb
├── config.ru
├── Gemfile
├── main.rb
├── public
│   ├── favicon.ico
│   ├── images
│   │   ├── logo.png
│   └── style.css
├── Rakefile
└── views
    ├── index.erb
    └── layout.erb

Подключим наши новообразованные файлы:

# main.rb
# -*- coding: utf-8 -*-

require 'sinatra'
require 'sinatra/activerecord'
require 'redcarpet'

$LOAD_PATH.unshift(File.dirname(__FILE__) + '/app')
require 'models'  # здесь живут модели
require 'routes'  # маршруты
require 'helpers' # и хелперы

$LOAD_PATH.unshift(File.dirname(__FILE__) + '/config')
require 'environments' # настройки конфигурации
require 'constants'    # константы

# или короче
# Dir.glob('./{app,config}/*.rb').each {|file| require file}

Теперь все пути станем прописывать в файле routes.rb

# -*- coding: utf-8 -*-
# app/routes.rb

get '/' do
  @title = 'Заголовок индексной страницы'
  erb :index
end

В отдельном файле хранятся такие вещи как заголовок и url сайта:

# config/constants.rb
# -*- coding: utf-8 -*-

SITE_NAME = 'Mysite'
SITE_URL = 'http://0.0.0.0:4000'
SITE_SUBTITLE = 'IT Blog'

Для того, чтобы убедиться в работоспособности приложения изменим «вьюху»:

<%# /views/layout.erb %>

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">

  <% if @title  %>
    <%# /вывести заголовок страницы и имя сайта %>
    <title><%= @title + " | #{SITE_NAME}" %></title>
  <% else %>
    <title><%="#{SITE_NAME}" %></title>
  <% end %>

  <% if @summary %>
    <meta name="description" content="<%= @summary || @post.summary %>">
  <% end %>

  <%# путь к иконке из public %>
  <link rel="icon" href="/favicon.ico" type="image/x-icon">
  <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">

  <%# путь к css из public %>
  <link href="/style.css" rel="stylesheet" type="text/css" />
</head>
  <body>
      <main>
        <%= yield %>
      </main>
  </body>
</html>

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

<%= erb :'includes/footer', :layout => false %>

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

Но что же с моделью? Все наши записи мы будем хранить в базе данных, осуществлять же доступ к оной нам поможет ActiveRecord.

Для начала убедимся, что с базой данных не возникнет проблем. Изменим настройки нескольких файлов:

Установка связи с ActiveRecord:

# config/environments.rb
# настройки при разработке приложения

configure :development do
 set :database, 'sqlite3:///dev.db'
 set :show_exceptions, true
end

# настройки для production-окружения
configure :production do
 db = URI.parse(ENV['DATABASE_URL'] || 'postgres:///localhost/mydb')

 ActiveRecord::Base.establish_connection(
   :adapter  => db.scheme == 'postgres' ? 'postgresql' : db.scheme,
   :host     => db.host,
   :username => db.user,
   :password => db.password,
   :database => db.path[1..-1],
   :encoding => 'utf8'
 )
end

Таким образом в разных окружениях (development & production) будут использоваться разные базы данных. Также вносятся поправки в другие файлы:

# Rakefile
require './main'
require 'sinatra/activerecord/rake'

# app/models.rb
class Post < ActiveRecord::Base
end

Ниже приводится полное содержимое джемфайла.

# -*- coding: utf-8 -*-
# Gemfile

source 'https://rubygems.org'
ruby '2.1.0'

gem 'sinatra'
gem 'redcarpet'
gem 'pygments.rb'
gem 'activerecord'
gem 'sinatra-activerecord'

group :development do
  gem 'shotgun'
  gem 'tux'
  gem 'sqlite3'
end

group :production do
  gem 'pg'
end

group :test do
  gem 'rspec'
end

Доустановить недостающие зависимости без установки джемов для production-окружения можно командой

$ bundle install --without production

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

$ rake -T (отобразить справку по возможным действиям)
$ rake db:create_migration NAME=create_post (создать миграцию)

Если всё прошло успешно, в корневом каталоге проекта появится подкаталог db, содержащий миграцию вида db/migrate/xxx_create_post.rb. Изменим её:

# db/migrate/xxx_create_post.rb
# -*- coding: utf-8 -*-

class CreatePost < ActiveRecord::Migration
  def self.up
    # создание таблицы с заданными полями
    create_table :posts do |post|
      post.string   :title
      post.text     :content
      post.timestamps
    end

    # тестовые записи
    Post.create(title: "Example Post",
                content: "Content of my first post")
    Post.create(title: "Second Post",
                content: "Content of my second post")
  end
  def self.down
    # откатить миграцию (rake:db rollback)
    drop_table :posts
  end
end

Осталось лишь запустить миграцию, после чего в каталоге db автоматически появится файл schema.rb:

$ rake db:migrate

А в корневом каталоге должен появиться файл dev.db, представляющий собой базу с созданной в ней (указанной в миграции) таблицей.

Играем с tux'ом

Внимательный читатель наверняка заметил, что мы установили несколько новых джемов: tux, shotgun. Второй из них теперь можно запускать вместо команды rackup — при внесении любых изменений вам не потребуется перезапускать сервер, shotgun сделает всю рутинную работу за вас.

Первый из упомянутых джем окажет посильную помощь при разработке приложений на sinatra. Это консольная программа, являющаяся связующим звеном между разработчиком и sinatra-приложением. Давайте поиграем с ней.

$ tux
Loading development environment (Rack 1.2)
>> a = Post.all (получить все посты)
[DEBUG -- : Post Load (2.0ms)  SELECT "posts".* FROM "posts"
=> #<ActiveRecord::Relation
[#<Post id: 1, title: "What is sinatra?"]>
... вывод сокращён ...
>> a.count (количество постов)
DEBUG -- : (0.4ms)  SELECT COUNT(*) FROM "posts"
=> 4
>> a.first (получить данные первого поста)
=> #<Post id: 1, title: "What is sinatra?" content: "Sinatra is...">
>> a.first.title (заголовок первого поста)
=> "What is sinatra?"
>> a.first.id (его идентификатор)
=> 1
>> a.last.content(тело последнего поста)
=> "Oh, my last post!"

Надеюсь, читатель убедился в небесполезности tux'а: прежде, чем писать новую функциональность можно немного потестировать её в консоли.

CRUD

Дабы наконец развлечься каким-то практическим эффектом от содеянного ранее, займёмся написанием маршрутов и шаблонов: нужно ведь отобразить ту информацию, которую мы имеем. А заодно узнаем, что скрывается под аббревиатурой CRUD.

CRUD — так именуются четыре базовые функции для работы с данными и Sinatra вполне поддерживает их все.

сокращениезначениеsinatra
Ccreateput
Rreadget
Uupdatepost
Ddestroydelete

Ниже приводится пример создаваемых маршрутов сразу с комментариями.

# -*- coding: utf-8 -*-
# app/routes.rb

# получить главную страницу
# отобрать последние 10 постов
get '/' do
  # сортировка и указание лимита
  @posts = Post.order("created_at DESC").limit(10)
  # для отображения взять /views/index.erb
  erb :index
end

# получить отдельный пост по его ID
get '/posts/:id/' do
  # найти пост
  @post = Post.find(params[:id])
  @title = @post.title
  @content = @post.content
  erb :'posts/show'
end

# отобразить форму для создания нового поста
get '/posts/new' do
  @title = 'Create new post'
  erb :'posts/create'
end

# взять параметры из формы и сохранить пост
post '/posts/new' do
  params.delete 'submit'
  @post = Post.create(params)

  if @post.save
    redirect to '/'
  else
    'Post was not save'
  end
end

# страница about
# запись вида ('/about/?') позволит
# обращаться как к странице /about
# так и к странице /about/
get ('/about/?') do
  @title = 'About me'
  erb :about
end

# если страница не найдена
not_found do
  @title = 'Page not found'
  # создайте для неё шаблон 404.erb
  erb :'404'
end

# обработка ошибки сервера
error do
  @error = request.env['sinatra_error'].name
  erb :'500'
end

Надеюсь, комментарии смогли дать какое-то представление о маршрутизации в sinatra. Дальше напишем шаблоны страниц, содержимое которых будет динамическим.

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

<%# views/index.erb %>

<% @posts.each do |post| %>
  <a href="/posts/<%= post.id %>/"><%= post.title %></a>
<% end %>

Страница отдельного поста будет отображать его заголовок и тело.

<%# views/posts/show.erb %>

<h2><%= @post.title %></h2>
<p><%= @post.created_at %></p>
<%= @post.content %>

Для ручного добавления новых статей нам понадобится форма.

<%# views/posts/create.erb %>

<form method="post" action="/posts/new">
  <label for="title"><strong>Post title:</strong></label>
  <input id="title" type="text" name="title" value="" style="width: 250px;">

  <label for="content">Post content:</strong></label>
  <textarea id="content" name="content" style="height: 250px;"></textarea>

  <button type="submit">Create post</button>
</form>

Теперь можно запустить shotgun, проверить как отображаются «мигрированные» публикации, а также, добавив к адресу главной страницы posts/new, создать новый пост вручную.

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

Изменение/Обновление

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

# app/routes.rb
# получаем страницу с формой редактирования
get '/posts/:id/edit/' do
  @title = 'Update post'
  @post = Post.find(params[:id])
  erb :'posts/edit'
end

# обновляем данные
put '/posts/:id/edit/' do
  @post = Post.find(params[:id])
  if @post.update_attributes(params[:post])
    redirect to '/'
  else
    erb :'posts/edit'
  end
end

Вот как может выглядеть сама формочка:

<%# views/posts/edit.erb %>

<h3>Edit</h3>
<a href="/posts/<%= @post.id %>/delete/">delete this post</a>

<form action="/posts/<%= @post.id %>/edit/" method="post">
  <input name="_method" type="hidden" value="put" />

  <label for="title">Title:</label>
  <input type="text" name="post[title]" id="title" value="<%= @post.title %>">

  <label for="content"><strong>Post content:</label>
  <textarea id="content" name="post[content]" style="height: 250px;"><%= @post.content %></textarea>

  <button itype="submit">Save</button>
</form>

Теперь попробуйте обновить какую-либо статью и приступайте к последнему таинству — удалению.

Удаление

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

# app/routes.rb
# получить страницу
get '/posts/:id/delete/' do
  @title = 'Confirm deletion of article ##{params[:id]}'
  @post = Post.find(params[:id])
  erb :'posts/delete'
end

# удалить публикацию
delete '/posts/:id/' do
  Post.find(params[:id]).destroy
  redirect to '/'
end

Ни в каких формах мы более не нуждаемся, хотя неплохо было бы уточнить действительно ли пост должен быть удалён:

<%# views/posts/delete.erb %>

<% if @post %>
  <p>Are you sure?</p>
  <form action="/posts/<%= @post.id %>/" method="post">
    <input type="hidden" name="_method" value="delete">
    <input type="submit" value="Yes, Delete It!">
    <a href="<%="#{SITE_URL}"%>/posts/<%= @post.id %>/">Cancel</a>
  </form>
<% else %>
  <p>Post not found.</p>
<% end %>

Прежде, чем двинуться дальше, рекомендуется ещё немного поэкспериментировать с этим.

Валидация

Пришло время существенно улучшить наше приложение и воспользоваться ещё одной особенностью, данной нам ActiveRecord: валидацией данных. Иметь валидные данные — значит иметь проверенные данные. Например, очень важно, чтобы при создании новой публикации она не осталась безымянной. Статья просто обязана иметь заголовок!

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

# app/models.rb
class Post < ActiveRecord::Base
  # заголовок с максимальной длиной в 100 символов!
  validates :title, presence: true, length: {maximum: 100}
  validates :content, presence: true
end

Сейчас попробуйте сохранить новый пост без указания заголовка или с длиной, превышающей сотню символов. При попытке сохранения вы должны получить ошибку, а объект не будет сохранён в БД.

Отложенные публикации

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

Миграция

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

# db/migrate/xxx_create_post.rb
class CreatePost < ActiveRecord::Migration
  def self.up
    create_table :posts do |t|
      t.date     :published_at
    end
    Post.create(
      title: "Who I am?",
      published_at: "2086-03-05",
      content: "You don't show me!")
  end
end

После этого необходимо прогуляться в сторону модели:

# app/models.rb
class Post < ActiveRecord::Base
  scope :published_at, lambda { where("published_at <= ?", Time.now) }
  validates :published_at, presence: true
end

Поскольку данные пока не представляют собой никакой ценности (их попросту нет), для вступления в силу принятых изменений можно сделать ход конём:

$ rake db:rollback
$ rake db:migrate

Это откат прошлой миграции и создание новой. Все созданные вручную записи будут потеряны.

Вывод публикаций

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

# app/routes.rb
get '/' do
  @summary = 'Blog description'
  # отображать только опубликованные статьи
  @posts = Post.published_at.limit(10)
  erb :index
end

Это не единственный (и, возможно, не самый лучший) способ создать отложенные публикации, но первый, что пришёл в голову.

Отображение дат

Если читатель проделал на практике все шаги вплоть до этого момента, он мог заметить, что формат даты нельзя назвать «человекопонятным». Пришло время узнать зачем в sinatra используются хелперы.

# app/helpers.rb

helpers do
  def pretty_date(time)
    # форматируем дату
    time.strftime("%d %b %Y")
  end
end

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

<%# views/posts/show.erb %>

<h2><%= @post.title %></h2>
<p><%= pretty_date(@post.published_at) %></p>

Markdown и архив публикаций

На самом деле, эти вещи уже были описаны ранее.

Для того, чтобы получить возможность писать статью при помощи markdown-разметки, а сохранять и отображать её в виде html, достаточно взять блок кода из второго примера, где используется before_save. Код этот помещается в app/models.rb, в шаблоне вывода публикации ничего править не нужно.

То же самое с созданием архива. Один из способов описан в публикации об упорядочивании записей в rails. С нашей организацией проекта это будет выглядеть не совсем так:

# app/routes.rb
# Archive

[('/archive/?'), ('/posts/?')].each do |route|
  get route do
    @posts = Post.published_at
    @posts_by_year = Post.published_at.order("published_at ASC").limit(300).group_by { |post| post.published_at.beginning_of_year }
    @title = 'Archive'
    erb :archive
  end
end

Вывод страниц:

<h2><%= @title %></h2>

<% @posts_by_year.each do |year, posts| %>
  <h2><%= "#{year.strftime('%Y')}" %></h2>
  <dl>
    <% for post in posts %>
      <dd><a href="/posts/<%= post.id %>/"><%= post.title %></a>
       <%= pretty_date(post.published_at) %>
      </dd>
    <% end %>
  </dl>
<% end %>

Эффект обеспечен тот же самый.

Развёртывание приложения на Heroku

Прежде всего следует удостовериться, что файлы Gemfile и config.ru расположены в корне проекта, который вы собираетесь развёртывать. Затем создать аккаунт на heroku.com и установить Heroku Toolbelt:

$ wget -qO- https://toolbelt.heroku.com/install.sh | sh
$ echo 'PATH="/usr/local/heroku/bin:$PATH"' >> ~/.profile
$ heroku login

После чего сделать из нашего каталога git-репозиторий:

$ git init
$ git add .
$ git commit -am 'initial commit'
$ heroku create
$ git push heroku master
$ heroku run rake --trace db:migrate

Чтобы указать какую-то определённую миграцию используйте db:migrate VERSION=xxx

Если что-то пошло не так, посмотрите логи командой heroku logs

Надо также заметить, что строка, указывающая порт в config.ru должна быть в обязательном порядке удалена перед деплоем на heroku.

Послесловие

Сегодня было раскрыто многое из того, что можно сделать с помощью sinatra, но ещё больше осталось за кадром. Как кэшировать страницы, как защитить админку, что можно использовать кроме ActiveRecord, какой может быть архитектура приложения (а она имеет полное право разительно отличаться от того, что было представлено в заметке), как генерировать rss-ленту, как тестировать приложение. Ответы на эти и другие вопросы следует искать прежде всего в следующих источниках: