Пишем форум с нуля на Ruby on Rails и AngularJS

в 22:22, , рубрики: AngularJS, ruby, ruby on rails, single page application

Не так давно я рассказывал о геме Oxymoron, позволяющем очень просто и быстро строить современные Single Page Application на AngularJS и Ruby on Rails. Статья была встречена весьма позитивно, поэтому пришло время написать более-менее сложное приложение, чтобы показать все возможности гема.

Учитывая ошибки прошлых статей, я зарегистрировал доменное имя и арендовал сервер для разворачивания приложений для хабра.

Репозиторий с полным исходным кодом
Развернутое приложение

Задача

Написать форум обладающий следующим функционалом:

  • Пользователи должны иметь возможность зарегистрироваться и авторизоваться в системе
  • Пользователи имеют роли. На данный момент в системе предусмотрены 2 роли: администратор и модератор
  • Администратор может создавать группы, наполнять эти группы темами
  • Пользователи могут создавать топики в существующих темах и писать в эти топики свои посты
  • Модератор может удалять сообщения и топики пользователей
  • Модератор и администратор могут блокировать нерадивых пользователей
  • Каждый пользователь имеет свой рейтинг, определяемый другими участниками
  • Пользователи должны иметь возможно загрузить аватарку
  • На форуме должен быть предусмотрен поиск

Выбранные технологии

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

Используемые гемы

source 'https://rubygems.org'

gem 'rails', '4.2.5.1'
gem 'sqlite3'
gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.1.0'

group :development do
  gem 'web-console', '~> 2.0'
  gem 'spring'
  gem 'railroady'
  gem 'capistrano'
  gem 'capistrano-bundler'
  gem 'capistrano-rails'
  gem 'capistrano-rails-console'
  gem 'capistrano-rvm'
  gem 'capistrano-sidekiq'
  gem 'pry'
  gem 'pry-remote'
  gem 'pry-rails'
  gem 'pry-stack_explorer'
  gem 'pry-byebug'
  gem 'quiet_assets'
  gem 'rename'
  gem 'bullet'
  gem "awesome_print"
end

gem 'thin'
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 'oj_mimic_json'
gem 'file_validators'
gem 'whenever', :require => false
gem "rolify"
gem 'ancestry'
gem "pundit"
gem "rest-client"

Описание базы данных

Исходя из требований, можно нарисовать приблизительную схему базы данных:

Пишем форум с нуля на Ruby on Rails и AngularJS - 1

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

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. За кадром остались тесты и интернационализация приложения. Об этом расскажу в следующих статьях.

Репозиторий с полным исходным кодом
Развернутое приложение

Автор: storuky

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js