Записки Вредного программиста

enjoy, motherfuckers ;)

Ruby для начинающих CMS

Данная заметка посвящена целиком Sinatra фреймворку на Ruby, а точнее написанию небольшой, простой CMS. Это ведь следующая стадия после ‘hello, world’ в изучении нового языка. Поехали!

План по реализации CMS

  1. Написание требований (куда без них)
  2. Используемые гемы
  3. Создание необходимых моделей
  4. Написание кода экшнов
  5. Наведения марафета
  6. Деплой на хероку
1. Написание требований

Целью данной CMS должно быть введение новичков, таких же как и я, в богатый и интересный мир Ruby. Поэтому приложение не должно быть перенасыщено функциональстью, чтобы не отвлекать от главных моментов. “Рюшечки” мы всегда сумеем прикрепить. Поэтому основные требования заключаются в следующем:

  • Backend
    • Создание/редактирование/удаление статичных страниц
      • должна будет отображаться форма с текстовыми полями, контентное поле должно быть заменено wysiwyg, я выбрал для этих целей redactor.js (давно хотел с ним познакомиться, видимо, время пришло)
      • у каждой страницы будет две метки времени: метка создания (заполняется автоматически при создании новой страницы) и метки обновления (время изменения страницы)
    • Отображение списка страниц в возможностью из него удалить или редактировать любую.
    • Должна быть реализована базовая аутентификация, чтобы люди с улицы не смогли проникнуть в “админку”
    • Приятный интерфейс
    • Редирект на список страниц после создания/редактирования или удаления
  • Frontend
    • Динамически строемое меню, содержащее все страницы в виде ссылок, чтобы можно было “погулять” по разделам
    • Приятный интерфейс
    • Ссылки вида /contacts.html, чтобы выглядело эстетично.
    • При заходе на главную (/) пользователь видит домашнюю страницу, также со списком всех имеющихся страниц.
2. Используемые гемы

Теперь перейдем непосредственно к реализации написанной выше функциональности. В первую очередь нам понадобится data_mapper, гем который очень облегчает жизнь разработчикам, которые используют базы данных, правда некоторые выбирают Active Record, но разъяснение разницы лежит за пределами этой заметки. Итак, Datamapper – прослойка между БД и объектом – не нужно писать ни строчки sql кода, создавать таблицы напрямую и изменять их структуру. Datamapper возьмет на себя всю эту грязную работу. Сначала установка sudo apt-get install libsqlite3-dev.

На локальной машине мы будем работать с backend’ом в виде sqlite3, на хероку – postgres. Но нам по этому поводу не нужно переживать: всю грязную работу сделают datamapper и heroku.

Устанавливаем необходимый гем командой gem install data_mapper.

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

1
2
3
4
5
source :rubygems
gem 'data_mapper'
gem 'sinatra'
gem 'dm-postgres-adapter'
gem 'dm-sqlite-adapter'
3. Создание необходимых моделей

Модель, спешу я вас обрадовать или огорчить, будет у нас всего одна – это Страница. Для нашего скромного и минималистичного приложения ее будет более чем достаточно. Сначала отвлечемся на config.ru – файл, без которого наше приложения на запустится (о нем я писал в заметке о деплое ruby приложения). Собственно config.ru:

1
2
require './bootstrap.rb'
run Sinatra::Application

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

1
2
3
4
5
6
7
# coding: utf-8

require 'rubygems'
require 'sinatra'
require 'data_mapper'

DataMapper.setup(:default, ENV['DATABASE_URL'] || 'sqlite:./db/page.db')

Первая строчка необходима для того, чтобы можно было в исходный код писать символы в utf-8 кодировке. Далее тремя последующими строчками мы подключаем необходимые файлы, один из которых Sinatra, другой – Datamapper. Последняя строчка самая интересная. Она сообщает Datamapper’у, что если не установлена переменная окружения DATABASE_URL (значит, мы на heroku сервере), то использовать локальную базу данных, которая будет находится в папке db.

Теперь непосредственно модель, которая представляет собой класс (неожиданно, неправда ли?), к котому строкой include DataMapper::Resource добавляется множество методов для манипуляции самого Datamapper’а. Точнее вся это функциональность добавляется не к самому класса, к инстансам этого класса. Код модели представлен чуть ниже:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Page
  include DataMapper::Resource

  property :id,               Serial
  property :name,             String
  property :alias,            String
  property :short,            Text
  property :full,             Text
  property :seo_title,        String
  property :seo_keywords,     String
  property :seo_description,  String
  property :created_at,       DateTime
  property :updated_at,       DateTime
end

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

1
2
DataMapper.finalize
#DataMapper.auto_migrate!

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

4. Написание кода экшнов

На стороне backend’а у нас будет 6 так называемых экшнов, т.е. действий, которые будут выполняться в зависимости от контекста.

Создание страницы:
1
2
3
get '/admin/create' do
  erb :create_form
end

Здесь все просто, когда пользователь запрашивает страницу /admin/create (метод get), ему нужно отобразить форму для добавления новой страницы (erb :create_form – рендерит ./views/create_form.erb). Код формы выглядит очень просто:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<h3>Форма страницы</h3>
<form method="post" action="/admin/create">
  <label for="name">Имя страницы:</label>
  <input id="name" type="text" name="name" value="">
  <br>

  <label for="alias">Псевдоним:</label>
  <input id="alias" type="text" name="alias" value="">
  <br>

  <label for="short">Краткое описание:</label>
  <input id="short" type="text" name="short" value="">
  <br>

  <label for="full">Контент:</label>
  <textarea id="full" class="wysiwyg" name="full"></textarea>
  <br>

  <label for="seo_title">SEO заголовок:</label>
  <input id="seo_title" type="text" name="seo_title" value="">
  <br>

  <label for="seo_keywords">SEO ключевые слова:</label>
  <input id="seo_keywords" type="text" name="seo_keywords" value="">
  <br>

  <label for="seo_description">SEO описание:</label>
  <textarea name="seo_description" id="seo_description" rows="2"></textarea>
  <br>

  <input type="submit" name="submit" value="Создать" class="btn btn-primary">


</form>

Единственно место, на которое стоит обратить внимание это свойства тега form, а именно <html method="post" action="/admin/create">, тем самым мы устанавливаем метод отправки формы в post (по умолчанию все формы отправляются get-методом) на страницу /admin/create, где мы полученные данные и будем поджидать, чтобы записать в базу данных.

Сохранение страницы:
1
2
3
4
5
6
post '/admin/create' do
  params.delete 'submit'
  params[:updated_at] = params[:created_at] = Time.now
  @page = Page.create(params)
  redirect '/admin/pages'
end

params – содержит все переменные нам переданные. Для начала нам необходимо исключить свойство submit (это кнопка отправки формы), которого нет в нашей модели. Дальше мы присваеваем меткам и создания, и обновления текущее время и дату, а команда Page.create(params) – создает новую запись в таблице и сохраняет ее с переданными нами из формы данными. Последняя строчка перенаправляет пользователя на страницу, где отображаются все записи, мы к ней еще вернемся.

Редактирование страницы:

С этим моментом все очень похоже, кроме нескольких моментов: 1) перед отправкой формы нам необходимо ее заполнить уже имеющимися данными, 2) применить не метод create (как при создании), а присвоить атрибуты напрямую и сохранить.

1
2
3
4
get '/admin/edit/:id' do
  @page = Page.get(params[:id])
  erb :edit_form
end

Если пользователь нажал на ссылку из списка страниц (/admin/edit/1, к примеру) и мы допускаем, что запись в таблице с таким id содержится (проверка на корректность “раздула” бы код и усложнила бы его понимание, поэтому данный момент я оставлю на вашей совести). @page = Page.get(params[:id]) – получаем запись из таблицы с заданным id, дальше нам остается отрендерить страницу ./views/edit-form.erb и ждать пока пользователь нажмет на кнопку submit. А пока ./views/edit_form.erb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<h3>Форма страницы</h3>
<form method="post" action="/admin/edit/<%= @page.id %>">
  <label for="name">Имя страницы:</label>
  <input id="name" type="text" name="name" value="<%= @page.name %>">
  <br>

  <label for="alias">Псевдоним:</label>
  <input id="alias" type="text" name="alias" value="<%= @page.alias %>">
  <br>

  <label for="short">Краткое описание:</label>
  <input id="short" type="text" name="short" value="<%= @page.short %>">
  <br>

  <label for="full">Контент:</label>
  <textarea id="full" class="wysiwyg" name="full"><%= @page.full %></textarea>
  <br>

  <label for="seo_title">SEO заголовок:</label>
  <input id="seo_title" type="text" name="seo_title" value="<%= @page.seo_title %>">
  <br>

  <label for="seo_keywords">SEO ключевые слова:</label>
  <input id="seo_keywords" type="text" name="seo_keywords" value="<%= @page.seo_keywords %>">
  <br>

  <label for="seo_description">SEO описание:</label>
  <textarea name="seo_description" id="seo_description" rows="2"><%= @page.seo_description %></textarea>
  <br>

  <input type="submit" name="submit" value="Сохранить" class="btn btn-primary">

</form>

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

1
2
3
4
5
6
7
8
9
10
11
post '/admin/edit/:id' do
  @page = Page.get(params[:id])
  params.delete 'submit'
  params.delete 'id'
  params.delete 'splat'
  params.delete 'captures'
  params[:updated_at] = Time.now
  @page.attributes = params
  @page.save
  redirect '/admin/pages'
end

Он у меня получился самым раздутым, скорее всего, из-за не знания языка, но я только учусь, а как говорится лучшее – враг хорошего, поэтому не обращаем на это внимание и продолжаем двигаться дальше. После первой строки @page = Page.get(params[:id]), @page – является строкой в таблице, следующие 4 строки удаляют ненужные параметры, пришедшие к нам из формы, или из фреймворка (последние 2 подсовывает нам Sinatra). params[:updated_at] = Time.now – обновляет временную метку, почти не отличается от строки в создании новой странице, только тут мы не трогаем дату и время создания, потому что она остается неизменной на протяжении всей жизни записи. Строки @page.attributes = params и @page.save обновляют свойства и записывают уже измененную модель в базу данных. Последняя строка вам уже известна, она перенаправляет пользователя на страницу списка записей. Тут, по-хорошему, нужно было добавить проверку на успешность записи и вслывающее сообщение после редиректа, пусть будет это для вас как домашнее задание, кому интересно.

Удаление страницы:
1
2
3
4
get '/admin/delete/:id' do
  Page.get(params[:id]).destroy
  redirect '/admin/pages'
end

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

Сейчас разберем список страниц, который отбражается на странице /admin/pages

1
2
3
4
get '/admin/pages' do
  @pages = Page.all
  erb :pages
end

После этих панипуляций @pages содержит все записи из таблицы, и эта переменная доступна нам в шаблоне, а шаблон, как вы, наверное, уже успели догадаться, называться будет ./views/pages.erb, вот его код:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<h6>Список страниц</h6>
<table class="table-striped table">
  <thead>
    <tr>
      <th>Имя</th>
      <th>Псевдоним</th>
      <th>Заголовок</th>
      <th>Создана</th>
      <th>Изменена</th>
      <th>&nbsp;</th>
    </tr>
  </thead>
  <tbody>
    <% @pages.each do |page| %>
      <tr>
   <td><a href="/admin/edit/<%= page.id %>"><%= page.name %></a></td>
   <td><%= page.alias %></td>
   <td><%= page.seo_title %></td>
   <td><%= page.created_at.strftime("%d.%m.%Y %H:%M") %></td>
   <td><%= page.updated_at.strftime("%d.%m.%Y %H:%M") %></td>
   <td><a href="/admin/delete/<%= page.id %>" onclick="javascript: return confirm('Вы уверены в этом?')"><i class="icon-remove"></i></a></td>
      </tr>
    <% end %>
  </tbody>
</table>

<h5><a href="/admin/create">Создать новую страницу</a></h5>

По сути это проста таблица, каждая строка которой является строкой в таблице в базе данных. Строкой @pages.each do |page| мы начинаем цикл по всем записям переменной @pages, помните, которую мы объявили немногим ранее? Прозорливые умы, наверное, догадались, что означает конструкция page.created_at.strftime("%d.%m.%Y %H:%M") Вы правы, этой строкой мы превращаем дату и время из формата, в котором она хранится в базе данных в удобочитаемый формат понятный и приятный глазу человеческому.

1
onclick="javascript: return confirm('Вы уверены в этом?')"

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

С backend’ом почти разобрались, остались небольшие штришки, давайте перейдем к frontend’у, чтобы можно было посмотреть на результат своей нелегкой деятельности.

Отображаем главную страницу
1
2
3
4
5
get '/' do
  @page = Page.first(:alias => 'mainpage')
  @pages = Page.all(:alias.not => 'mainpage')
  erb :page
end

Небольшое отступление: Поиск страниц у нас будет осуществляться по полю alias, т.е. псевдоним, в базе данных он будет храниться в виде строки, к примеру, mainpage, contacts, about и т.д. Mainpage с маленькой буквы я буду использовать для задания главной страницы, которая и будет отбражаться по адресу ‘/’.

1
@page = Page.first(:alias => 'mainpage')

находит первую попавшуюся страницу, в поле alias которой стоит значение ‘mainpage’ (по-хорошему, нужно было на это поле поставить индекс и сделать его уникальным, но не об этом речь). Следующая строка получает все страницы, кроме mainpage (будем строить простейшее меню на сайте).

И отображаем страницу ./views/page.erb:

1
2
3
4
5
6
<div class="row-fluid">
  <div class="span3">
    <h1><%= @page.name %></h1>
  </div>
  <div class="span7"><%= @page.full %></div>
</div>

Где все остальное спросите вы, не торопитесь, отвечу я, все будет :). Что за странные классы с непонятными именами предчувствую вопрос и сразу отвечу на него. Т.к. из меня верстальшик, тем более дизайнер, некудышний (я уже смирился с этим), приходится использовать готовые решения, такие как css-фреймворки, в данном проект используется twitter bootstrap – аккуратный, легкий, приятный глазу, что несказанно облегчает жизнь таким как я разработчикам, которые хотят разрабатывать проекты, но “верстальным” талантом их обделили высшие силы :). Twitter, с тебя $10 за рекламу :).

Давайте сначала разберем последний на стороне backend’а экшн и перейдем непосредственно к шаблонам. Итак,

1
2
3
4
5
6
7
8
9
10
11
get '/:alias.html' do
  @page = Page.first(:alias => params[:alias])
  not_found 'Страница не найдена' if @page.nil?
  @pages = Page.all(:alias.not => 'mainpage')
  erb :page
end


not_found do
  erb :'404', {:layout => false}
end

Получилось сразу два: первый почти ничем не отличается от предыдущего, рассмотренного нами, кроме вызова not_found, если @page.nil? (если страница не найдена), второй является хелпером, который вызывается, если страница не найдена.

1
erb :'404', {:layout => false}

рендерит ./views/404.erb с отключенным шаблоном.

Пару слов о шаблонах

Шаблон представляет собой двухуровневый рендеринг: сначала рендерится непосредственно сама страница экшна, указанного нами в конструкции erb :page, дальше, если не указано обратное, рендерится файл ./views/layout.erb и в часть, где указано =yield, вставляется первый кусок, что заметно облегчает жизнь, сокращая код представлений экшнов. Самое главное в этом моменте правильно вычленить повторяющиеся во всех страницах элементы от изменяющихся. Раз уж коснулись шаблонов, приведу шаблон, который рендерится на стороне frontend’а, ./views/layout.erb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="description" content="<%= @page.seo_description %>">
    <meta name="keywords" content="<%= @page.seo_keywords %>">
    <meta name="author" content="@vredniy">
    <title><%= @page.seo_title %></title>
    <link rel="stylesheet" href="/css/bootstrap.css">
  </head>
  <body>
    <div class="container-fluid">
      <div class="row-fluid">
    <div class="span2">
      <h3>Главное меню</h3>
      <ul>
      <li><a href="/">Главная</a></li>
      <% @pages.each do |page| %>
   <li><a href="/<%= page.alias %>.html"><%= page.name %></a></li>
      <% end %>
    </ul>
    </div>
    <div class="span10">
      <%= yield %>
    </div>
  </div>
</div>
  </body>
</html>

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

Шаблон для административной части я не буду приводить здесь, для экономии места, в конце будет ссылка на работающий репозиторий, который вы можете склонировать и запустить у себя локально или задеплоить на heroku.

Осталось еще пара недосказанных моментов: аутентификация и изменения шаблона для закрытой (backend) части. Что касается первого, то тут все предельно просто, будем использовать базовую аутентификацию, которую нам представляет прослойка в виде Rack. Реализуем, как рекомендуют на сайте документации Sinatra, через хелперы, вот и сам код:

1
2
3
4
5
6
7
8
9
10
11
12
13
helpers do
  def protected!
    unless authorized?
      response['WWW-Authenticate'] = %(Basic realm="Cms's restricted area")
      throw(:halt, [401, "Not authorized\n"])
    end
  end

  def authorized?
    @auth ||= Rack::Auth::Basic::Request.new(request.env)
    @auth.provided? && @auth.basic? && @auth.username && @auth.credentials == ['admin', 'password']
  end
end

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

Т.к., мы, программисты, люди ленивые, нам сложно писать для каждого экшна /admin* слово protected!, поэтому мы пойдем другим путем, а именно используя фильтр before, который вызывается перед каждым запросом, напишем две строки кода.

1
2
3
4
before '/admin/*' do
  protected!
  @default_layout = :admin
end

Первая строка, как и упоминали “защищает” от посторонних глаз все закрытые части приложения, вторая же для всех закрытых частей меняет шаблон по умолчанию на admin, т.е. ./views/admin.erb

5. Наведения марафета

Чтобы придать немного эстетичности нашему простому приложению мы будем использовать сторонние инструменты, в частности twitter bootstrap и redactor от imperavi. Все эти файлы уже имеются в репозитории на github, тут они будут лишь занимать ценное место. Еще приведу структуру папок данного приложения, чтобы вы не путались что и куда.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.
├── bootstrap.rb
├── config.ru
├── db
│   └── page.db
├── Gemfile
├── Gemfile.lock
├── public
│   ├── css
│   ├── favicon.ico
│   ├── img
│   └── js
├── README
└── views
    ├── 404.erb
    ├── admin.erb
    ├── create_form.erb
    ├── edit_form.erb
    ├── layout.erb
    ├── page.erb
    └── pages.erb
6. Деплой на хероку

Деплой почти ни чем не отличается от деплоя из предыдущей заметки, кроме переноса локальной базы данных на удаленный сервер heroku heroku db:push sqlite://db/page.db.

Молодцы heroku и эту возможность предусмотрели, после успешного выполнения данной команды, локальная база данных из sqlite3 скопируется в удаленную postgres.

если у вас на локальной машине стоял ruby 1.9.3, как у меня, то придется откатиться до версии 1.9.2. Это нужно для того, чтобы запушить локальную базу данных на хероку. Если вам это не нужно, то пропустите это шаг.

Заключение

Этой заметкой я закрпеляю небольшой логический этап в освоении мною нового для меня языка ruby. Язык мне очень нравится, но сразу переключиться на него попросту не могу. Синтаксис приятный глазу, легкие конструкции, шустрые фреймворки, работающие из коробки. Осталось только поделиться ссылкой на репозиторий на github и работающий проект на heroku. Оставляйте комментарии, задавайте вопросы, поправляйте, если найдете неточности – я открыт для обсуждений, на этом я прощаюсь с вами и до новых встреч :) P.S.: вот так должно выглядеть приложение в конце

Комментарии