Не так давно я рассказывал о геме Oxymoron, позволяющем очень просто и быстро строить современные Single Page Application на AngularJS и Ruby on Rails. Статья была встречена весьма позитивно, поэтому пришло время написать более-менее сложное приложение, чтобы показать все возможности гема.
Учитывая ошибки прошлых статей, я зарегистрировал доменное имя и арендовал сервер для разворачивания приложений для хабра.
Репозиторий с полным исходным кодом
Развернутое приложение
Написать форум обладающий следующей функциональностью:
В качестве базы данных я буду использовать PostgreSQL, так как мне необходима возможность хранения массивов и хэшей. Для поиска будет использоваться поисковой движок Sphinx. Процессинг изображений на сервере по старой доброй традиции будет идти через ImageMagick.
В данной статье я не буду использовать вспомогательные кеширующие инструменты и постараюсь обойтись только возможностями Rails и Postgresql.
Исходя из требований, можно нарисовать приблизительную схему базы данных:

Создадим соответствующие модели и миграции к ним:
Генерацию модели пользователей выполним с помощью гема Devise:
Содержимое миграций:
Для решения вопросов загрузки файлов на сервер я всегда создаю отдельную модель и на нее ставлю uploader. В данном случае это модель Avatar:
Укажем все необходимые связи и валидации для наших моделей:
Для моделей Topic и Theme необходимо устанавливать в поле last_post последний созданный пост. Сделать это лучше всего в каллбэке after_create модели Post:
а после создания топика, необходимо создать в нём первый пост, содержащий заголовок и содержимое топика:
Приступим к модели Avatar. Первым делом сгенерируем uploader, который будет использоваться для обработки загружаемых аватарок. Я использую carrierwave:
Укажем нашему аплоадеру, что он должен сжимать все загружаемые картинки до версии thumb(150х150рх), и делать это он будет через MiniMagick (враппер для ImageMagick):
Теперь подключим AvatarUploader к модели Avatar и укажем, что размер загружаемого файла должен быть не более 2 МБайт:
В Oxymoron есть директива fileupload. Для того, чтобы отправить файл на сервер, необходимо ее определить на теге input[type=«file»]
В ответ ожидается массив. При необходимости можно указать аттрибут multiple. Если multiple не указан, то в переменную result_from_server будет положен первый элемент массива, в ином случае – весь массив.
Сгенерируем UploadsController, отвечающий за загрузку файлов на сервер:
Создадим метод avatar, который будет управлять логикой загрузки аватарки:
Для полнотекстового поиска я использую Sphinx и гем thinking_sphinx. Первым делом необходимо создать файл конфига для thinking_sphinx, который будет транслирован в sphinx.conf. Итак, нам нужен стиминговый поиск с возможностью поиска по звёздочке(автокомплит) и минимальным запросом в 3 символа. Опишем это в thinking_sphinx.yml:
Теперь создадим индекс для постов. Индексироваться должны заголовок и контент. Результат будем сортировать в обратном порядке от даты создания, поэтому ее необходимо указать в виде фильтра:
Выполняем генерацию sphinx-конфига и запускаем демон searchd одной командой:
Если ребилд пройдет успешно, то вы увидите в консоли сообщение о том, что демон стартовал удачно.
Добавим в модель Post метод для поиска. Так как метод search занял thinking_sphinx, я использовал look_for:
Сгенерируем контроллер, отвечающий за поиск и определим метод index, который будет обрабатывать логику поиска:
Данный метод мы определим позже.
Дабы не отставать от моды, подключим в свое приложение новую reCAPTCHA. После регистрации, вам будет доступно 2 ключа: публичный и приватный. Оба этих ключа мы положим в secrets.yml. Туда же будем складировать все возможные api-key нашего приложения.
Напишем protected метод в ApplicationContoller, который верифицирует капчу
Теперь этот метод доступен у всех контроллеров, унаследованных от ApplicationController.
У нас чистое SPA-приложение. Страницу мы не перезагружаем даже при логине/разлогине. Создадим контроллеры для управления сессией и регистрацией на основе JSON API:
Здесь комментарии излишни. Разве что стоит обратить внимание на set_csrf_headers. Так как страница у нас не обновляется, нам необходимо получать «свежие» CSRF-токены с сервера, чтобы не быть уязвимыми к CSRF-атакам. Для ActionController это делает автоматически Oxymoron. Для всех остальных контроллеров, работающих в обход ActionController необходимо устанавливать в cookies['XSRF-TOKEN'] актуальное значение CSRF-токена.
Теперь нам нужно заблокировать все страницы, требующие авторизации. Для этого нам прийдется переопределить метод authenticate_user!. Сделаем это в ApplicationController:
Сразу опишем файл routes.rb, чтобы больше к нему не возвращаться. Итак, у нас есть 5 ресурсов: users, groups, themes, topics и posts. Так же имеются роуты /uploads/avatar и /search. Помимо этого, нам необходимы методы на ресурсе users для определения онлайна пользователя, получения его рейтинга и прочей статистики.
Мне нравится философия сериализиции ActiveModelSerializer, но я очень стеснен в серверных мощностях, особенно перед Хабраэффектом. Поэтому пришлось придумать механизм максимально быстрой сериализации, которая только возможна в рамках текущего проекта. Основной критерий, предъявляемый мной перед сериализацией состоит в том, что она не должна занимать больше 5-10 мс.
Все что Вы прочитаете дальше, может показаться вам чуждым, странным и неправильным
Идея заключается в том, чтобы на клиент передавать только выбранные поля по сджойненым таблицам напрямую из базы. Вместе с этим отправлять на клиент название сериалайзера, который необходимо применить к ответу. Ангуляр позволяет перехватить все запросы и ответы, и изменить их по своему желанию. Следовательно, мы можем сериализовать объект на клиенте, при этом не загромаждая запросы каллбэками.
Перехватчик запросов выглядит следующим образом:
На клиент мы будем передавать результат селекта по сджойненным таблицам. Например, мы хотим передать вместе с постом еще и пользователя его создавшего:
Результатом будет таблица с соответствующими колонками, или в JSON-представлении – это массив, состоящий из массивов. Напишем сериалайзер:
Данный сериалайзер будет автоматически применён к collection и в response любого $http-запроса мы увидим уже сериализованный результат.
Теперь необходимо для каждой модели создать метод pluck_fields, который возвращает поля для селекта:
Эти методы мы будем использовать в контроллерах, для передачи их в метод pluck.
В своей предыдущей статье «Архитектура построения Single Page Application на основе AngularJS и Ruby on Rails» я приводил пример «типичного Rails-контроллера». Типичный – значит нам нет необходимости описывать каждый раз одну и ту же логику. Достаточно написать наиболее общий контроллер, унаследоваться от него и переопределить, либо доопределить необходимые методы. Писать такой контроллер я не стал и просто вынес всю общую логику в concern.
Итоговый concern выглядит очень необычно:
Итак, создадим на его основе PostsController:
Это и есть весь код контроллера, которым он отличается от Spa. Аналогично создадим остальные контроллеры:
Контроллер SearchController не использует concern Spa, поэтому его опишем полностью:
Я сознательно не использовал Rolify для организации ролей пользователей, посколько в данном случае это не оправдано. На форуме нет комбинированных ролей. Все управление идет через поле role. Для разграничения прав доступа я использую Pundit. В описании гема есть вся информация по его использованию.
Напишем все policies для нашего приложения исходя из требований:
По умолчанию Pundit кидает исключение Pundit::NotAuthorizedError, если проверка не пройдена, поэтому нам необходимо настроить его на работу посредством JSON API. Для этого в ApplicationController обработаем это исключение:
Перед тем, как перейти к клиентской части, давайте закончим с ApplicationController, передав текущего пользователя в Gon, чтобы сразу после загрузки страницы у нас сразу была вся необходимая информация о нём:
Я не буду описывать шаги по подключению Oxymoron, так как это всё уже было в этой статье.
AngularJS-контроллеры так же являются однотипными и рассматривались в той самой статье. Опишем их:
Я сверстал вьюшки, они не нуждаются особо в комментариях. Отдельно стоит рассмотреть компонентный подход в организации шаблонов директив.
В app/views/components есть render.html.slim со следующим содержимым:
Данный шаблон рендерит внутри себя все паршалы, лежащие в директории components и оборачивает их в тег шаблонов для AngularJS. Его необходимо отредерить в основном лейауте. Это автоматизирует работу с директивами.
Давайте создадим директиву post:
Шаблон _post.html.slim автоматически будет подтягиваться директивой post.
По той же аналогии создадим директиву rating:
Мы вынесли пост и рейтинг в отдельные директивы по той причине, что они содержат изолированную логику работы и используются в разных местах внутри приложения. Это позволяет лучше следовать паттерну DRY.
Для тре��инга онлайна создадим online.js, где по таймеру будем раз в 5 минут посылать запрос на обновление онлайна пользователя:
На серверной стороне мы внедрили reCAPTCHA. Теперь пришло время сделать это и на клиентской. Я использовал скрипт Angular Recaptcha, который содержит внутри себя директиву для удобной работы с рекапчей. В общем виде это выглядит следующим образом:
Нам осталось написать клиентские сериалайзеры и проект готов. На основе примера ExampleSerializer я написал сериайлайзеры для всех моделей:
Как вы наверняка заметили, вьюхи нашего приложения не имеют серверных элементов шаблонизации. За исключением Gon. Следовательно мы можем закешировать весь лейаут для production-окружения:
Для того, чтобы сбрасывать кеш при перезапуске сервера/деплое создадим инициалайзер с динамической глобальной переменной $layout_cache, которая будет выполнять функции cache_key:
Вот так очень просто шаг за шагом мы написали вполне работоспособный Single Page Application форум, со странным, но хорошо оптимизированным API. За кадром остались тесты и интернационализация приложения. Об этом расскажу в следующих статьях.
Репозиторий с полным исходным кодом
Развернутое приложение
Учитывая ошибки прошлых статей, я зарегистрировал доменное имя и арендовал сервер для разворачивания приложений для хабра.
Репозиторий с полным исходным кодом
Развернутое приложение
Задача
Написать форум обладающий следующей функциональностью:
- Пользователи должны иметь возможность зарегистрироваться и авторизоваться в системе
- Пользователи имеют роли. На данный момент в системе предусмотрены 2 роли: администратор и модератор
- Администратор может создавать группы, наполнять эти группы темами
- Пользователи могут создавать топики в существующих темах и писать в эти топики свои посты
- Модератор может удалять сообщения и топики пользователей
- Модератор и администратор могут блокировать нерадивых пользователей
- Каждый пользователь имеет свой рейтинг, определяемый другими участниками
- Пользователи должны иметь возможность загрузить аватарку
- На форуме должен быть предусмотрен поиск
Выбранные технологии
В качестве базы данных я буду использовать PostgreSQL, так как мне необходима возможность хранения массивов и хэшей. Для поиска будет использоваться поисковой движок Sphinx. Процессинг изображений на сервере по старой доброй традиции будет идти через ImageMagick.
В данной статье я не буду использовать вспомогательные кеширующие инструменты и постараюсь обойтись только возможностями Rails и Postgresql.
Используемые гемы
gem 'active_model_serializers', '0.9.4' gem 'pg' gem 'slim' gem 'slim-rails' gem 'devise' gem 'gon' gem 'carrierwave' gem 'mysql2', '~> 0.3.18', :platform => :ruby gem 'thinking-sphinx', '~> 3.1.4' gem 'mini_magick' gem "oxymoron" gem 'kaminari' gem 'oj' gem 'file_validators'
Описание базы данных
Исходя из требований, можно нарисовать приблизительную схему базы данных:

Создадим соответствующие модели и миграции к ним:
rails g model Group rails g model Theme rails g model Topic rails g model Post
Генерацию модели пользователей выполним с помощью гема Devise:
rails g devise:install rails g devise User
Содержимое миграций:
create_groups.rb
class CreateGroups < ActiveRecord::Migration def change create_table :groups do |t| t.string :title t.timestamps null: false end end end
create_themes.rb
class CreateThemes < ActiveRecord::Migration def change create_table :themes do |t| t.string :title t.integer :group_id t.index :group_id t.integer :posts_count, default: 0 t.integer :topics_count, default: 0 t.json :last_post t.timestamps null: false end end end
create_topics.rb
class CreateTopics < ActiveRecord::Migration def change create_table :topics do |t| t.string :title t.text :content t.integer :user_id t.index :user_id t.integer :group_id t.index :group_id t.integer :theme_id t.index :theme_id t.json :last_post t.integer :posts_count, default: 0 t.timestamps null: false end end end
create_posts.rb
class CreatePosts < ActiveRecord::Migration def change create_table :posts do |t| t.string :title t.text :content t.integer :user_id t.index :user_id t.integer :topic_id t.index :topic_id t.integer :theme_id t.index :theme_id t.boolean :delta, default: true t.timestamps null: false end end end
create_users.rb
class DeviseCreateUsers < ActiveRecord::Migration def change create_table :users do |t| ## Database authenticatable t.string :email, null: false, default: "" t.string :encrypted_password, null: false, default: "" ## Recoverable t.string :reset_password_token t.datetime :reset_password_sent_at ## Rememberable t.datetime :remember_created_at ## Trackable t.integer :sign_in_count, default: 0, null: false t.datetime :current_sign_in_at t.datetime :last_sign_in_at t.string :current_sign_in_ip t.string :last_sign_in_ip ## Confirmable # t.string :confirmation_token # t.datetime :confirmed_at # t.datetime :confirmation_sent_at # t.string :unconfirmed_email # Only if using reconfirmable ## Lockable # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts # t.string :unlock_token # Only if unlock strategy is :email or :both # t.datetime :locked_at t.string :name t.boolean :banned, default: false t.integer :avatar_id t.string :avatar_url, default: "/default_avatar.png" t.integer :rating, default: 0 t.integer :votes, array: true, default: [] t.integer :posts_count, default: 0 t.integer :topics_count, default: 0 t.string :role t.timestamps null: false end add_index :users, :email, unique: true add_index :users, :reset_password_token, unique: true # add_index :users, :confirmation_token, unique: true # add_index :users, :unlock_token, unique: true end end
Для решения вопросов загрузки файлов на сервер я всегда создаю отдельную модель и на нее ставлю uploader. В данном случае это модель Avatar:
rails g model avatar
create_avatar.rb
class CreateAvatars < ActiveRecord::Migration def change create_table :avatars do |t| t.string :body t.timestamps null: false end end end
Организация моделей
Укажем все необходимые связи и валидации для наших моделей:
models/group.rb
class Group < ActiveRecord::Base has_many :themes, ->{order(:id)}, dependent: :destroy has_many :topics, through: :themes, dependent: :destroy has_many :posts, through: :topics, dependent: :destroy end
models/theme.rb
class Theme < ActiveRecord::Base has_many :topics, dependent: :destroy has_many :posts, dependent: :destroy belongs_to :group end
models/topic.rb
class Topic < ActiveRecord::Base has_many :posts, dependent: :destroy belongs_to :theme, :counter_cache => true belongs_to :user, :counter_cache => true validates_presence_of :theme, :title, :content end
models/post.rb
class Post < ActiveRecord::Base belongs_to :topic, :counter_cache => true belongs_to :theme, :counter_cache => true belongs_to :user, :counter_cache => true validates :content, presence: true, length: { in: 2..300 } end
models/user.rb
class User < ActiveRecord::Base devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable belongs_to :avatar has_many :posts has_many :topics validates :name, :uniqueness => {:case_sensitive => false}, presence: true, length: { in: 2..10 } end
models/avatar.rb
class Avatar < ActiveRecord::Base belongs_to :user end
Для моделей Topic и Theme необходимо устанавливать в поле last_post последний созданный пост. Сделать это лучше всего в каллбэке after_create модели Post:
models/post.rb
class Post < ActiveRecord::Base belongs_to :topic, :counter_cache => true belongs_to :theme, :counter_cache => true belongs_to :user, :counter_cache => true validates :content, presence: true, length: { in: 2..300 } after_create :set_last_post private def set_last_post last_post = self.as_json(include: [:topic, :user]) topic.update(last_post: last_post) theme.update(last_post: last_post) end end
а после создания топика, необходимо создать в нём первый пост, содержащий заголовок и содержимое топика:
models/topic.rb
class Topic < ActiveRecord::Base has_many :posts, dependent: :destroy belongs_to :theme, :counter_cache => true belongs_to :user validates_presence_of :theme, :title, :content after_create :create_post private def create_post self.posts.create self.attributes.slice("title", "content", "user_id", "theme_id") end end
Приступим к модели Avatar. Первым делом сгенерируем uploader, который будет использоваться для обработки загружаемых аватарок. Я использую carrierwave:
rails g uploader Avatar
Укажем нашему аплоадеру, что он должен сжимать все загружаемые картинки до версии thumb(150х150рх), и делать это он будет через MiniMagick (враппер для ImageMagick):
uploaders/avatar_uploader.rb
class AvatarUploader < CarrierWave::Uploader::Base include CarrierWave::MiniMagick storage :file def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end version :thumb do process :resize_to_fill => [150, 150] end def extension_white_list %w(jpg jpeg gif png) end end
Теперь подключим AvatarUploader к модели Avatar и укажем, что размер загружаемого файла должен быть не более 2 МБайт:
models/avatar.rb
class Avatar < ActiveRecord::Base mount_uploader :body, AvatarUploader belongs_to :user validates :body, file_size: { less_than: 2.megabytes }, file_content_type: { allow: ['image/jpg', 'image/jpeg', 'image/png', 'image/gif'] } end
Загрузка файлов на сервер
В Oxymoron есть директива fileupload. Для того, чтобы отправить файл на сервер, необходимо ее определить на теге input[type=«file»]
input type="file" fileupload="'/uploads/avatar'" ng-model="result_from_server" percent-completed="percent"
В ответ ожидается массив. При необходимости можно указать аттрибут multiple. Если multiple не указан, то в переменную result_from_server будет положен первый элемент массива, в ином случае – весь массив.
Сгенерируем UploadsController, отвечающий за загрузку файлов на сервер:
rails g controller uploads
Создадим метод avatar, который будет управлять логикой загрузки аватарки:
class UploadsController < ApplicationController before_action :authenticate_user! def avatar avatar = Avatar.new(body: params[:attachments].first) if avatar.save avatar_url = avatar.body.thumb.url current_user.update(avatar_id: avatar.id, avatar_url: avatar_url) render json: Oj.dump([avatar_url]) else render json: {msg: avatar.errors.full_messages.join(", ")} end end end
Поиск по постам
Для полнотекстового поиска я использую Sphinx и гем thinking_sphinx. Первым делом необходимо создать файл конфига для thinking_sphinx, который будет транслирован в sphinx.conf. Итак, нам нужен стиминговый поиск с возможностью поиска по звёздочке(автокомплит) и минимальным запросом в 3 символа. Опишем это в thinking_sphinx.yml:
config/thinking_sphinx.yml
development: &generic mem_limit: 256M enable_star: 1 expand_keywords: 1 index_exact_words: 1 min_infix_len: 3 min_word_len: 3 morphology: stem_enru charset_type: utf-8 max_matches: 100000 per_page: 100000 utf8: true mysql41: 9421 charset_table: "0..9, A..Z->a..z, _, a..z, \ U+410..U+42F->U+430..U+44F, U+430..U+44F, U+401->U+0435, U+451->U+0435" staging: <<: *generic mysql41: 9419 production: <<: *generic mysql41: 9450 test: <<: *generic mysql41: 9418 quiet_deltas: true
Теперь создадим индекс для постов. Индексироваться должны заголовок и контент. Результат будем сортировать в обратном порядке от даты создания, поэтому ее необходимо указать в виде фильтра:
app/indices/post_index.rb
ThinkingSphinx::Index.define :post, {delta: true} do indexes title indexes content has created_at end
Выполняем генерацию sphinx-конфига и запускаем демон searchd одной командой:
rake ts:rebuild
Если ребилд пройдет успешно, то вы увидите в консоли сообщение о том, что демон стартовал удачно.
Добавим в модель Post метод для поиска. Так как метод search занял thinking_sphinx, я использовал look_for:
def self.look_for query return self if query.blank? or query.length < 3 search_ids = self.search_for_ids(query, {per_page: 1000, order: 'created_at DESC'}) self.where(id: search_ids) end
Сгенерируем контроллер, отвечающий за поиск и определим метод index, который будет обрабатывать логику поиска:
rails g controller search index
Данный метод мы определим позже.
Капча reCAPTCHA
Дабы не отставать от моды, подключим в свое приложение новую reCAPTCHA. После регистрации, вам будет доступно 2 ключа: публичный и приватный. Оба этих ключа мы положим в secrets.yml. Туда же будем складировать все возможные api-key нашего приложения.
config/secrets.yml
apikeys: &generic recaptcha: public_key: your_recaptcha_public_key secret_key: your_recaptcha_secret_key # generate your_secret_key_base by `rake secret` development: <<: *generic secret_key_base: your_secret_key_base test: <<: *generic secret_key_base: your_secret_key_base production: <<: *generic secret_key_base: your_secret_key_base
Напишем protected метод в ApplicationContoller, который верифицирует капчу
protected def verify_captcha response result = RestClient.post( "https://www.google.com/recaptcha/api/siteverify", secret: Rails.application.secrets[:recaptcha]["secret_key"], response: response) JSON.parse(result)["success"] end
Теперь этот метод доступен у всех контроллеров, унаследованных от ApplicationController.
Авторизация и регистрация
У нас чистое SPA-приложение. Страницу мы не перезагружаем даже при логине/разлогине. Создадим контроллеры для управления сессией и регистрацией на основе JSON API:
controllers/auth/sessions_controller.rb
class Auth::SessionsController < Devise::SessionsController skip_before_action :authenticate_user! after_filter :set_csrf_headers, only: [:create, :destroy] def create if verify_captcha(params[:user][:recaptcha]) self.resource = warden.authenticate(auth_options) if self.resource sign_in(resource_name, self.resource) render json: {msg: "Вы успешно авторизовались в системе", current_user: current_user.public_fields} else render json: {msg: "Email не найден, либо пароль неверен"}, status: 401 end else render json: {msg: "Проверка каптчи не пройдена"}, status: 422 end end def destroy Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name) render json: {msg: "Вы успешно вышли"} end protected def set_csrf_headers cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery? end end
controllers/auth/registrations_controller.rb
class Auth::RegistrationsController < Devise::RegistrationsController skip_before_action :authenticate_user! def create if verify_captcha(params[:user][:recaptcha]) build_resource(sign_up_params) resource.save unless resource.persisted? render json: { msg: resource.errors.full_messages.first, errors: resource.errors, }, status: 403 else sign_up(resource_name, resource) render json: { msg: "Вы успешно зарегистрировались!", current_user: current_user.public_fields } end else render json: {msg: "Проверка каптчи не пройдена"}, status: 422 end end private def sign_up_params params.require(:user).permit(:name, :email, :password) end end
Здесь комментарии излишни. Разве что стоит обратить внимание на set_csrf_headers. Так как страница у нас не обновляется, нам необходимо получать «свежие» CSRF-токены с сервера, чтобы не быть уязвимыми к CSRF-атакам. Для ActionController это делает автоматически Oxymoron. Для всех остальных контроллеров, работающих в обход ActionController необходимо устанавливать в cookies['XSRF-TOKEN'] актуальное значение CSRF-токена.
Теперь нам нужно заблокировать все страницы, требующие авторизации. Для этого нам прийдется переопределить метод authenticate_user!. Сделаем это в ApplicationController:
before_action :authenticate_user!, only: [:create, :update, :destroy, :new, :edit] private def authenticate_user! unless current_user if request.xhr? render json: {msg: "Вы не авторизованы"}, status: 403 else redirect_to root_path end end end
Роутинг приложения
Сразу опишем файл routes.rb, чтобы больше к нему не возвращаться. Итак, у нас есть 5 ресурсов: users, groups, themes, topics и posts. Так же имеются роуты /uploads/avatar и /search. Помимо этого, нам необходимы методы на ресурсе users для определения онлайна пользователя, получения его рейтинга и прочей статистики.
Rails.application.routes.draw do root to: 'groups#index' devise_for :users, controllers: { sessions: 'auth/sessions', registrations: 'auth/registrations', } post "uploads/avatar" => "uploads#avatar" get "search" => "search#index" resources :groups resources :themes resources :topics resources :posts resources :users, only: [:index, :show] do collection do get "touch" # touch для current_user, чтобы обновить время онлайна get "metrics" # разнообразная статистика end member do put "rate" # Изменение рейтинга put "ban" # Забанить put "unban" # Разбанить end end end
Сериализация
Мне нравится философия сериализиции ActiveModelSerializer, но я очень стеснен в серверных мощностях, особенно перед Хабраэффектом. Поэтому пришлось придумать механизм максимально быстрой сериализации, которая только возможна в рамках текущего проекта. Основной критерий, предъявляемый мной перед сериализацией состоит в том, что она не должна занимать больше 5-10 мс.
Все что Вы прочитаете дальше, может показаться вам чуждым, странным и неправильным
Идея заключается в том, чтобы на клиент передавать только выбранные поля по сджойненым таблицам напрямую из базы. Вместе с этим отправлять на клиент название сериалайзера, который необходимо применить к ответу. Ангуляр позволяет перехватить все запросы и ответы, и изменить их по своему желанию. Следовательно, мы можем сериализовать объект на клиенте, при этом не загромаждая запросы каллбэками.
Перехватчик запросов выглядит следующим образом:
javascripts/serializers/interceptor.js
app.factory('serializerInterceptor', ['$q', function ($q) { return { response: function (response) { // Если в ответе найден сериалайзер, то if (response.data.serializer) { // Находим его в нашей глобальной области видимости var serializer = window[response.data.serializer]; // Если он найден, то if (serializer) { // применяем его var collection = serializer(response.data.collection); // если результат ожидается как массив, то кладем его в поле collection, иначе в resource if (response.data.single) { response.data.resource = collection[0] } else { response.data.collection = collection; } } else { console.error(response.data.serializer + " is not defined") } } // Возвращаем измененный ответ с сервера return response || $q.when(response); } }; }]) // Кладем serializerInterceptor в стек перехватчиков для http-запросов, выполненных посредством Angular .config(['$httpProvider', function ($httpProvider) { $httpProvider.interceptors.push('serializerInterceptor'); }])
На клиент мы будем передавать результат селекта по сджойненным таблицам. Например, мы хотим передать вместе с постом еще и пользователя его создавшего:
collection = Post.joins(:user).pluck("posts.id", "posts.title", "posts.content", "users.id", "users.name") render json: { collection: collection, serializer: "ExampleSerializer" }
Результатом будет таблица с соответствующими колонками, или в JSON-представлении – это массив, состоящий из массивов. Напишем сериалайзер:
Пример сериалайзера
function ExampleSerializer (collection) { var result = []; collection.forEach(function(item) { id: item[0], title: item[1], content: item[2], user: { id: item[3], name: item[4] } }) return result }
Данный сериалайзер будет автоматически применён к collection и в response любого $http-запроса мы увидим уже сериализованный результат.
Теперь необходимо для каждой модели создать метод pluck_fields, который возвращает поля для селекта:
models/group.rb
class Group < ActiveRecord::Base has_many :themes, ->{order(:id)}, dependent: :destroy has_many :topics, through: :themes, dependent: :destroy has_many :posts, through: :topics, dependent: :destroy def self.pluck_fields ["groups.id", "groups.title", "themes.id", "themes.title", "themes.posts_count", "themes.topics_count", "themes.last_post"] end end
models/post.rb
class Post < ActiveRecord::Base belongs_to :topic, :counter_cache => true belongs_to :theme, :counter_cache => true belongs_to :user, :counter_cache => true after_create :set_last_post validates :content, presence: true, length: { in: 2..300 } def self.pluck_fields ["posts.id", "posts.title", "posts.content", "users.id", "users.created_at", "users.name", "users.rating", "users.posts_count", "users.avatar_url", "topics.id", "topics.title"] end def self.look_for query return self if query.blank? or query.length < 3 search_ids = self.search_for_ids(query, {per_page: 1000000, order: 'created_at DESC'}) self.where(id: search_ids) end private def set_last_post last_post = self.as_json(include: [:topic, :user]) topic.update(last_post: last_post) theme.update(last_post: last_post) end end
models/theme.rb
class Theme < ActiveRecord::Base has_many :topics, dependent: :destroy has_many :posts, dependent: :destroy belongs_to :group def self.pluck_fields [:id, :title] end end
models/topic.rb
class Topic < ActiveRecord::Base has_many :posts, dependent: :destroy belongs_to :theme, :counter_cache => true belongs_to :user validates_presence_of :theme, :title, :content after_create do Post.create(title: title, content: content, user_id: user_id, theme_id: theme_id, topic_id: id) end def self.pluck_fields ["topics.id", "topics.title", "topics.last_post", "topics.posts_count", "users.id", "users.name", "themes.id", "themes.title"] end end
models/user.rb
class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable belongs_to :avatar has_many :posts validates :name, :uniqueness => {:case_sensitive => false}, presence: true, length: { in: 2..10 } def self.pluck_fields [:id, :created_at, :updated_at, :name, :avatar_url, :posts_count, :rating, :banned] end def public_fields self.attributes.slice("id", "email", "rating", "name", "created_at", "updated_at", "posts_count", "banned") end end
Эти методы мы будем использовать в контроллерах, для передачи их в метод pluck.
Контроллеры
В своей предыдущей статье «Архитектура построения Single Page Application на основе AngularJS и Ruby on Rails» я приводил пример «типичного Rails-контроллера». Типичный – значит нам нет необходимости описывать каждый раз одну и ту же логику. Достаточно написать наиболее общий контроллер, унаследоваться от него и переопределить, либо доопределить необходимые методы. Писать такой контроллер я не стал и просто вынес всю общую логику в concern.
Итоговый concern выглядит очень необычно:
controllers/concern/spa.rb
module Spa extend ActiveSupport::Concern # @model – модель (со всем чейнингом), к которой идет обращение # @resource – текущий ресурс # Все методы имеют свое дефолтное состояние и переопределяются при необходимости included do before_action :set_model before_action :set_resource, only: [:show, :edit, :update, :destroy] def index respond_to do |format| format.html format.json { collection = @model.where(filter_params) if params[:filter] render json: Oj.dump({ total_count: collection.count, serializer: serializer, collection: collection.page(params[:page]).per(10).pluck(*pluck_fields), page: params[:page] || 1 }) } end end def show respond_to do |format| format.html format.json { @resource = @model.where(id: params[:id]).pluck(*pluck_fields) render json: Oj.dump({ collection: @resource, serializer: serializer, single: true }) } end end def new new_params = resource_params rescue {} @resource = @model.new(new_params) authorize @resource, :create? respond_to do |format| format.html format.json { render json: Oj.dump(@resource) } end end def edit authorize @resource, :update? respond_to do |format| format.html format.json { render json: Oj.dump(@resource) } end end def create @resource = @model.new resource_params authorize @resource if @resource.save @collection = @model.where(id: @resource.id).pluck(*pluck_fields) result = { collection: @collection, serializer: serializer, single: true, }.merge(redirect_options[:update] || {}) render json: Oj.dump(result) else render json: {errors: @resource.errors, msg: @resource.errors.full_messages.join(', ')}, status: 422 end end def update authorize @resource if @resource.update(resource_params) render json: {resource: @resource, msg: "#{@model.name} успешно обновлен"}.merge(redirect_options[:update] || {}) else render json: {errors: @resource.errors, msg: @resource.errors.full_messages.join(', ')}, status: 422 end end def destroy authorize @resource @resource.destroy render json: {msg: "#{@model.name} успешно удален"} end private def set_resource @resource = @model.find(params[:id]) end def pluck_fields @model.pluck_fields end def redirect_options {} end def filter_params params.require(:filter).permit(filter_fields) end def serializer serializer = "#{@model.model_name}Serializer" end end end
Итак, создадим на его основе PostsController:
class PostsController < ApplicationController include Spa private # Устанавливаем модель для консёрна def set_model @model = Post.joins(:user, :topic).order(:created_at) end # Указываем поля, по которым можно производить фильтрацию def filter_fields [:theme_id, :topic_id] end # Определяем поля, которые допустимы при сабмите формы def resource_params # Топик нам нужен для того, чтобы устанавливать его заголовок, как дефолтный заголовок для постов topic = Topic.find(params[:post][:topic_id]) title = params[:post][:title] params.require(:post).permit(:content, :title, :topic_id) .merge({ theme_id: topic.theme_id, user_id: current_user.id, title: title.present? ? title : "Re: #{topic.title}" }) end end
Это и есть весь код контроллера, которым он отличается от Spa. Аналогично создадим остальные контроллеры:
controllers/groups_controller.rb
class GroupsController < ApplicationController include Spa private def set_model @model = Group.joins("LEFT JOIN themes ON themes.group_id = groups.id").order("groups.id") end def redirect_options { create: { redirect_to_url: root_path }, update: { redirect_to_url: root_path } } end def resource_params params.require(:group).permit(:title) end end
controllers/themes_controller.rb
class ThemesController < ApplicationController include Spa private def set_model @model = Theme.order(:created_at) end def redirect_options { create: { redirect_to_url: root_path }, update: { redirect_to_url: root_path } } end def resource_params params.require(:theme).permit(:title, :group_id) end end
controllers/topics_controller.rb
class TopicsController < ApplicationController include Spa private def set_model @model = Topic.joins(:theme, :user).order("topics.updated_at DESC") end def filter_fields [:theme_id] end def redirect_options { create: { redirect_to_url: topic_path(@resource) }, update: { redirect_to_url: topic_path(@resource) } } end def resource_params params.require(:topic).permit(:title, :content, :theme_id) .merge({ user_id: current_user.id }) end end
controllers/users_controller.rb
class UsersController < ApplicationController include Spa def touch current_user.touch if current_user render json: {} end def rate if current_user.votes.include?(params[:id].to_i) return render json: {msg: "Вы уже влияли на репутацию пользователя"}, status: 422 end current_user.votes.push(params[:id].to_i) current_user.save set_resource if params[:positive] @resource.increment!(:rating) else @resource.decrement!(:rating) end render json: {rating: @resource.rating} end def metrics result = current_user.attributes.slice("posts_count", "rating") if current_user render json: result || {} end def ban authorize @resource @resource.update(banned: true) render json: {msg: "Пользователь был забанен"} end def unban authorize @resource, :ban? @resource.update(banned: false) render json: {msg: "Пользователь был разбанен"} end private def set_model @model = User end end
Контроллер SearchController не использует concern Spa, поэтому его опишем полностью:
SearchController
class SearchController < ApplicationController def index respond_to do |format| format.html format.json { collection = Post.look_for(params[:q]).joins(:user, :topic).order("created_at DESC") render json: Oj.dump({ total_count: collection.count, serializer: "PostSerializer", collection: collection.page(params[:page]).per(10).pluck(*Post.pluck_fields), page: params[:page] || 1 }) } end end end
Разграничение прав доступа
Я сознательно не использовал Rolify для организации ролей пользователей, посколько в данном случае это не оправдано. На форуме нет комбинированных ролей. Все управление идет через поле role. Для разграничения прав доступа я использую Pundit. В описании гема есть вся информация по его использованию.
Напишем все policies для нашего приложения исходя из требований:
app/policies/group_policy.rb
class GroupPolicy def initialize(user, group) @user = user @group = group end def create? @user.role == "admin" end def update? @user.role == "admin" end def destroy? @user.role == "admin" end end
app/policies/post_policy.rb
class PostPolicy def initialize(user, post) @user = user @post = post end def create? true end def update? ["admin", "moderator"].include?(@user.role) || @user.id == @post.user_id end def destroy? ["admin", "moderator"].include?(@user.role) || @user.id == @post.user_id end end
app/policies/theme_policy.rb
class ThemePolicy def initialize(user, theme) @user = user @theme = theme end def create? @user.role == "admin" end def update? @user.role == "admin" end def destroy? @user.role == "admin" end end
app/policies/topic_policy.rb
class TopicPolicy def initialize(user, topic) @user = user @topic = topic end def create? true end def update? @user.id == @topic.user_id || ["admin", "moderator"].include?(@user.role) end def destroy? @user.id == @topic.user_id || ["admin", "moderator"].include?(@user.role) end end
app/policies/user_policy.rb
class UserPolicy def initialize(user, resource) @user = user @resource = resource end def ban? ["admin", "moderator"].include? @user.role end end
По умолчанию Pundit кидает исключение Pundit::NotAuthorizedError, если проверка не пройдена, поэтому нам необходимо настроить его на работу посредством JSON API. Для этого в ApplicationController обработаем это исключение:
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized private def user_not_authorized if request.xhr? render json: {msg: "Нет прав на данное действие"}, status: 403 else redirect_to root_path end end
Перед тем, как перейти к клиентской части, давайте закончим с ApplicationController, передав текущего пользователя в Gon, чтобы сразу после загрузки страницы у нас сразу была вся необходимая информация о нём:
controllers/application_controller.rb
class ApplicationController < ActionController::Base include Pundit protect_from_forgery with: :exception rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized before_action :authenticate_user!, only: [:create, :update, :destroy, :new, :edit] # Выключаем лейаут для всех ajax-запросов layout proc { if request.xhr? false else set_gon "application" end } protected def verify_captcha response result = RestClient.post("https://www.google.com/recaptcha/api/siteverify", secret: Rails.application.secrets[:recaptcha]["secret_key"], response: response) JSON.parse(result)["success"] end private def set_gon gon.current_user = current_user.public_fields if current_user end def authenticate_user! unless current_user if request.xhr? render json: {msg: "Вы не авторизованы"}, status: 403 else redirect_to root_path end end end def user_not_authorized if request.xhr? render json: {msg: "Нет прав на данное действие"}, status: 403 else redirect_to root_path end end end
Клиентская часть
Я не буду описывать шаги по подключению Oxymoron, так как это всё уже было в этой статье.
AngularJS-контроллеры так же являются однотипными и рассматривались в той самой статье. Опишем их:
javascripts/controllers/groups_ctrl.js
app.controller('GroupsCtrl', ['$scope', 'Group', 'action', 'Theme', function ($scope, Group, action, Theme) { var ctrl = this; action('index', function () { ctrl.groups = Group.get(); ctrl.destroy_theme = function (theme) { if (confirm("Вы уверены?")) Theme.destroy({id: theme.id}) } ctrl.destroy_group = function (group) { if (confirm("Вы уверены?")) Group.destroy({id: group.id}) } }) action('new', function () { ctrl.group = Group.new(); ctrl.save = Group.create; }) action('edit', function (params) { ctrl.group = Group.edit(params); ctrl.save = Group.update; }) }])
javascripts/controllers/themes_ctrl.js
app.controller('ThemesCtrl', ['$scope', 'Theme', 'Topic', 'action', '$location', function ($scope, Theme, Topic, action, $location) { var ctrl = this; action('show', function (params) { var filter = { theme_id: params.id } ctrl.theme = Theme.get(params); ctrl.query = function (page) { Topic.get({ filter: filter, page: page }, function (res) { ctrl.topics = res; }); } ctrl.query($location.search().page || 1) ctrl.destroy = function (topic) { if (confirm("Вы уверены?")) Topic.destroy({id: topic.id}) } }) action('new', function () { Theme.new(function (res) { ctrl.theme = res; ctrl.theme.group_id = $location.search().group_id; }); ctrl.save = Theme.create; }) action('edit', function (params) { ctrl.theme = Theme.edit(params); ctrl.save = Theme.update; }) }])
javascripts/controllers/topics_ctrl.js
app.controller('TopicsCtrl', ['$scope', '$location', 'Topic', 'action', 'Post', 'Theme', function ($scope, $location, Topic, action, Post, Theme) { var ctrl = this; action('show', function (params) { var filter = { topic_id: params.id } ctrl.post = { topic_id: params.id } ctrl.topic = Topic.get(params); ctrl.query = function (page, callback) { Post.get({filter: filter, page: page}, function (res) { ctrl.posts = res; if (callback) callback(); }); } ctrl.query(1) ctrl.send = function () { Post.create({post: ctrl.post}, function (res) { ctrl.post = { topic_id: params.id } ctrl.query(Math.ceil(ctrl.posts.total_count/10)) }) } }) action('new', function () { var theme_id = $location.search().theme_id; ctrl.theme = Theme.get({id: theme_id}); ctrl.topic = Topic.new({topic: {theme_id: theme_id}}); ctrl.save = Topic.create; }) action('edit', function (params) { ctrl.topic = Topic.edit(params, function (res) { ctrl.theme = Theme.get({id: res.theme_id}); }); ctrl.save = Topic.update; }) }])
javascripts/controllers/users_ctrl.js
app.controller('UsersCtrl', ['$scope', 'User', 'action', function ($scope, User, action) { var ctrl = this; action('index', function () { ctrl.query = function (page) { User.get({page: page}, function (res) { ctrl.users = res; }); } ctrl.query(1) }) action('show', function (params) { ctrl.user = User.get(params); }) ctrl.ban = function (user) { User.ban({id: user.id}) user.banned = true; } ctrl.unban = function (user) { User.unban({id: user.id}) user.banned = false; } }])
javascripts/controllers/search_ctrl.js
app.controller('SearchCtrl', ['$scope', '$location', '$http', function ($scope, $location, $http) { var ctrl = this; ctrl.query = function (page) { var params = { page: page || 1 } if (ctrl.q) { params.q = ctrl.q } $http.get(Routes.search_path(params)).then(function (res) { ctrl.posts = res.data; }) } $scope.$watch(function () { return $location.search().q }, function (q) { ctrl.q = q; ctrl.query() }) }])
javascripts/controllers/sign_ctrl.js
app.controller('SignCtrl', ['$scope', '$http', '$interval', 'User', function ($scope, $http, $interval, User) { var ctrl = this; ctrl.title = { in: "Вход", up: "Регистрация" } ctrl.sign = { in: function () { $http.post(Routes.user_session_path(), {user: ctrl.user}) .success(function (res) { gon.current_user = res.current_user; }) }, out: function () { $http.delete(Routes.destroy_user_session_path()) .success(function () { gon.current_user = undefined; }) }, up: function () { $http.post(Routes.user_registration_path(), {user: ctrl.user}) .success(function (res) { gon.current_user = res.current_user; }) } } $scope.$watch(function () { return gon.current_user }, function (current_user) { if (current_user) { ctrl.method = 'user'; ctrl.title.user = current_user.name; } else { ctrl.method = 'in'; } }) $interval(function () { User.metrics(function (res) { angular.extend(gon.current_user, res); }) }, 10000) }])
Я сверстал вьюшки, они не нуждаются особо в комментариях. Отдельно стоит рассмотреть компонентный подход в организации шаблонов директив.
В app/views/components есть render.html.slim со следующим содержимым:
- Dir[File.dirname(__FILE__) + '/_*.html*'].each do |partial| script type="text/ng-template" id="#{File.basename(partial).gsub('.slim', '').gsub(/^_/, '')}" = render file: partial
Данный шаблон рендерит внутри себя все паршалы, лежащие в директории components и оборачивает их в тег шаблонов для AngularJS. Его необходимо отредерить в основном лейауте. Это автоматизирует работу с директивами.
= render template: "components/render"
application/layout.html.slim
html ng-app="app" head title Форум base href="/" = stylesheet_link_tag 'application' body ng-controller="MainCtrl as main" ng-class="gon.current_user.role" .layout.body .search input.form-control placeholder="Поиск" type="text" ng-model="main.search" ng-model-options="{debounce: 300}" .bredcrumbs ng-yield="bredcrumbs" .wrapper .content ui-view .sidebar = render "layouts/sidebar" = render template: "components/render" = Gon::Base.render_data script src="https://www.google.com/recaptcha/api.js?onload=vcRecaptchaApiLoaded&render=explicit" async="" defer="" = javascript_include_tag 'application'
Давайте создадим директиву post:
javascripts/directives/post_directive.js
app.directive('post', ['Post', function(Post){ return { scope: { post: "=" }, restrict: 'E', templateUrl: 'post.html', replace: true, link: function($scope, iElm, iAttrs, controller) { $scope.gon = gon; $scope.destroy = function () { if (confirm("Вы уверены?")) Post.destroy({id: $scope.post.id}) } } }; }]);
components/_post.html.slim
.post.clearfix .post__user .middle-ib a.post__user-avatar ui-sref="user_path(post.user)" img ng-src="{{post.user.avatar_url}}" width="75" .middle-ib .post__user-name a.link.text-red ui-sref="user_path(post.user)" ng-bind="post.user.name" rating user="post.user" .post__user-role .text-gray ng-bind="post.user.role" .post__user-metrics.text-gray .post__user-metric span.bold Постов: | span ng-bind="post.user.posts_count" .post__user-metric span.bold На сайте с: | span ng-bind="post.user.created_at | date:'dd.MM.yyyy'" .post__content a.post__title ng-bind="post.title" ui-sref="topic_path(post.topic)" div ng-bind="post.content" .post__actions.only-moderator a.btn.btn-danger.btn-sm ng-click="destroy()" Удалить
Шаблон _post.html.slim автоматически будет подтягиваться директивой post.
По той же аналогии создадим директиву rating:
javascripts/directives/rating_directive.js
app.directive('rating', ['User', function (User) { return { scope: { user: "=" }, restrict: 'E', templateUrl: 'rating.html', replace: true, link: function($scope, iElm, iAttrs, controller) { $scope.rate = function (positive) { User.rate({id: $scope.user.id, positive: positive}, function (res) { $scope.user.rating = res.rating; }) } } }; }]);
components/_rating.html.slim
span.rating span.text-red.rating__control ng-click="rate()" | ▼ span.rating__count ng-bind="user.rating" span.text-green.rating__control ng-click="rate(true)" | ▲
Мы вынесли пост и рейтинг в отдельные директивы по той причине, что они содержат изолированную логику работы и используются в разных местах внутри приложения. Это позволяет лучше следовать паттерну DRY.
Для тре��инга онлайна создадим online.js, где по таймеру будем раз в 5 минут посылать запрос на обновление онлайна пользователя:
javascripts/online.js
app.run(['$interval', 'User', function ($interval, User) { User.touch(); $interval(function () { User.touch(); }, 5*60*1000) }])
На серверной стороне мы внедрили reCAPTCHA. Теперь пришло время сделать это и на клиентской. Я использовал скрипт Angular Recaptcha, который содержит внутри себя директиву для удобной работы с рекапчей. В общем виде это выглядит следующим образом:
div ng-model="ctrl.user.recaptcha" vc-recaptcha="" key="'#{Rails.application.secrets[:recaptcha]["public_key"]}'"
Нам осталось написать клиентские сериалайзеры и проект готов. На основе примера ExampleSerializer я написал сериайлайзеры для всех моделей:
javascripts/serializers/post_serializer.js
function PostSerializer (collection) { var result = []; _.each(collection, function (item) { result.push({ id: item[0], title: item[1], content: item[2], user: { id: item[3], created_at: item[4], name: item[5], rating: item[6], posts_count: item[7], avatar_url: item[8] || "/default_avatar.png" }, topic: { id: item[9], title: item[10] } }) }) return result }
javascripts/serializers/theme_serializer.js
function ThemeSerializer (collection) { var result = []; _.each(collection, function (item) { result.push({ id: item[0], title: item[1] }) }) return result }
javascripts/serializers/topic_serializer.js
function TopicSerializer (collection) { var result = []; _.each(collection, function (item) { result.push({ id: item[0], title: item[1], last_post: item[2], posts_count: item[3], user: { id: item[4], name: item[5] }, theme: { id: item[6], title: item[7] } }) }) return result }
javascripts/serializers/user_serializer.js
function UserSerializer (collection) { var result = []; _.each(collection, function (item) { result.push({ id: item[0], created_at: item[1], updated_at: item[2], name: item[3], avatar_url: item[4], posts_count: item[5], rating: item[6], banned: item[7] }) }) return result }
javascripts/serializers/group_serializer.js
function GroupSerializer (collection) { var result = [], groups = _.groupBy(collection, function (el) { return el[0] }); _.each(groups, function (group) { result.push({ id: group[0][0], title: group[0][1], themes: _.map(group, function (item) { return { id: item[2], title: item[3], posts_count: item[4], topics_count: item[5], last_post: item[6] } }) }) }) return result }
Кеширование
Как вы наверняка заметили, вьюхи нашего приложения не имеют серверных элементов шаблонизации. За исключением Gon. Следовательно мы можем закешировать весь лейаут для production-окружения:
Заголовок спойлера
= cache_if Rails.env.production?, $cache_key html ng-app="app" head title Форум base href="/" = stylesheet_link_tag 'application' body ng-controller="MainCtrl as main" ng-class="gon.current_user.role" .layout.body .search input.form-control placeholder="Поиск" type="text" ng-model="main.search" ng-model-options="{debounce: 300}" .bredcrumbs ng-yield="bredcrumbs" .wrapper .content ui-view .sidebar = render "layouts/sidebar" = render template: "components/render" script src="https://www.google.com/recaptcha/api.js?onload=vcRecaptchaApiLoaded&render=explicit" async="" defer="" = javascript_include_tag 'application' = Gon::Base.render_data
Для того, чтобы сбрасывать кеш при перезапуске сервера/деплое создадим инициалайзер с динамической глобальной переменной $layout_cache, которая будет выполнять функции cache_key:
config/initializers/layout_cache.rb
$layout_cache = "layout_#{Time.now.to_i}"
Итог
Вот так очень просто шаг за шагом мы написали вполне работоспособный Single Page Application форум, со странным, но хорошо оптимизированным API. За кадром остались тесты и интернационализация приложения. Об этом расскажу в следующих статьях.
Репозиторий с полным исходным кодом
Развернутое приложение
