Не так давно я рассказывал о геме 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"



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


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



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

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

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

Комментарии (26)


  1. krimtsev
    17.05.2016 01:56
    +1

    Скорость работы форума на высоте! Будете продолжать развитие?


    1. storuky
      17.05.2016 01:59
      +2

      Развивать форум?) Разве что только для последующих статей. В планах автотесты и локализация.
      Посмотрим как зайдет эта статья.


      1. krimtsev
        17.05.2016 02:00

        в планах функционала?


        1. storuky
          17.05.2016 02:05
          +3

          Нет, я его написал только для статьи. Но любой может форкнуть и допилить до мвп)


  1. babylon
    17.05.2016 05:10

    У меня предложение. Можно же серилизаторы описать в формате JSON и один (один!) парсер к ним приставить. Изящнее же будет. Нет?


    1. storuky
      17.05.2016 09:52

      Можно даже пойти по пути protobuf и передавать инструкции по парсингу так же в ответе. Нужен комплексный подход.


      1. babylon
        17.05.2016 18:00
        -1

        Protobuf избыточен, IPROTO is a binary request/response protocol, используемый в tarantool мне симпатичнее. MsgPack…


  1. DeLuxis
    17.05.2016 06:29
    +2

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


  1. fuCtor
    17.05.2016 06:31

    Для рендеринга шаблонов можно использовать библиотеки:


    Единственное при таком подходе все статичные данные придется тянуть через Gon или еще как-нибудь.


  1. bonnzer
    17.05.2016 10:29

    Многие пишут «шустро»…
    Я как-то пробовал nodebb — тоже очень шустрый, выглядит красиво, пользоваться удобно + websocket's вкупе (вроде бы)…
    Однако, не наблюдаю повсеместного его использования. Везде динозавры phpbb, joomla с модулями соответсвующими, что-то самописное и т.д…

    Может, есть какие-то подводные камни окромя консерватизма разработчиков их клепающих? :)


    1. ZOXEXIVO
      17.05.2016 10:37

      Может потому что форум должен нормально индексироваться? До Angular 2 все слишком костыльно


      1. storuky
        17.05.2016 10:42

        Гуглобот нормально видит такие сайты. Яндекс, возможно, тоже скоро начнет.


        1. ZOXEXIVO
          17.05.2016 11:30

          Не видит и не начнет, пока не будет изоморфности в самом Angular, придется возится с _escaped_fragments.
          Когда ваш проект корректно проиндексируется, тогда и можно будет об этом заявлять.


          1. storuky
            17.05.2016 11:35

            Я об этом заявляю, потому что это не первый проект. И никогда не было проблем с индексацией в гугле. Даже через webconsole все прекрасно видно.


            1. ZOXEXIVO
              21.05.2016 12:55

              https://habrahabr.ru/post/301288/
              Вот, почитайте как он замечательно индексирует.


              1. storuky
                22.05.2016 12:17

                С каких пор частное стало общим? В этой статье нет вообще никакой конкретики. «У нас было не SPA, стало SPA и сайт стал плохо индексироваться». Откуда мы знаем как построено SPA в этом случае?
                Вот и вот. Как видно все прекрасно индексируется.
                И это еще при том, что я не делал сайтмэп.


                1. ZOXEXIVO
                  22.05.2016 13:23

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


                  1. storuky
                    22.05.2016 13:35

                    В той статье даже ссылки нет на ресурс.
                    Бот может неиндексировать что-то по двум причинам:
                    1) Очень медленный апи
                    2) Очень нагруженная клиентская часть

                    Это снапшот от машины, которая по своих характеристикам соответсвует той, на которой работает основной бот. Помимо основного есть еще много других ботов, которые не исполняют JS, и могут попадать на сайт из самых неожиданных источников. Основной все прекрасно исполняет. Я вас уверяю. Больше 20 проектов не испытывают вообще никаких проблем с индексацией в Гугле.
                    Нужно проводить эксперименты, выяснять почему, а не писать о том, что гугл кривой и обманывает.


      1. bonnzer
        17.05.2016 10:57

        Пререндер неплохо справляется с задачей…
        2 простых команды + конф nginx'a врядли можно назвать костылями…


  1. kozyabka
    17.05.2016 12:21

    Когда открываю форум, сперва, вижу сообщение что в теме нет топиков, и только потом появляются посты.


    1. storuky
      17.05.2016 14:41

      Спасибо, исправил


  1. riley_usagi
    17.05.2016 15:23

    Огромное вам спасибо!

    Как раз начал заниматься тем же самым, но даётся с трудом.
    Данная статья многое расставила по своим местам. Прям таки — дорожная карта.


  1. sergey_eryashev
    18.05.2016 10:20

    А как же сео параметры? Например вывод названия тем в тайтл и заполнение кейвордс и дескрипшен.


    1. storuky
      18.05.2016 10:20
      +1

      Помимо этого не сделано еще очень много. Это костяк приложения, исключительно для понимания.


  1. tmn4jq
    18.05.2016 16:38

    Спасибо за такую полную и развернутую статью!

    Хочу задать несколько вопросов:
    1. sqlite3 и mysql2 случайно затесались в Gemfile, или нарочно были оставлены?
    2. Как лично Вы относитесь к after_hooks? Не считаете ли их злом, не предпочитаете ли использовать интеракторы, чтобы уменьшить количество потецниального хаоса и неожиданных коллбэков?
    3. Хотел задать вопрос по поводу кириллицы в коде, но последняя секция «Итог» статьи ответила мне на этот вопрос)


    1. storuky
      18.05.2016 17:48

      1. thinking_sphinx использует mysql2 адаптером, а sqlite3 случайно остался
      2. Я позиционирую AR шире чем тот же Datamapper. AR позволяет – я использую:) Все дело в удобстве. Как мне удобно, так я и пишу. Но вообще коллбеки – это всегда плохо
      3. :)