Учитывая ошибки прошлых статей, я зарегистрировал доменное имя и арендовал сервер для разворачивания приложений для хабра.
Репозиторий с полным исходным кодом
Развернутое приложение
Задача
Написать форум обладающий следующей функциональностью:
- Пользователи должны иметь возможность зарегистрироваться и авторизоваться в системе
- Пользователи имеют роли. На данный момент в системе предусмотрены 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
Содержимое миграций:
class CreateGroups < ActiveRecord::Migration
def change
create_table :groups do |t|
t.string :title
t.timestamps null: false
end
end
end
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
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
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
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
class CreateAvatars < ActiveRecord::Migration
def change
create_table :avatars do |t|
t.string :body
t.timestamps null: false
end
end
end
Организация моделей
Укажем все необходимые связи и валидации для наших моделей:
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
class Theme < ActiveRecord::Base
has_many :topics, dependent: :destroy
has_many :posts, dependent: :destroy
belongs_to :group
end
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
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
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
class Avatar < ActiveRecord::Base
belongs_to :user
end
Для моделей Topic и Theme необходимо устанавливать в поле last_post последний созданный пост. Сделать это лучше всего в каллбэке after_create модели Post:
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
а после создания топика, необходимо создать в нём первый пост, содержащий заголовок и содержимое топика:
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):
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 МБайт:
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:
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
Теперь создадим индекс для постов. Индексироваться должны заголовок и контент. Результат будем сортировать в обратном порядке от даты создания, поэтому ее необходимо указать в виде фильтра:
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 нашего приложения.
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:
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
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 мс.
Все что Вы прочитаете дальше, может показаться вам чуждым, странным и неправильным
Идея заключается в том, чтобы на клиент передавать только выбранные поля по сджойненым таблицам напрямую из базы. Вместе с этим отправлять на клиент название сериалайзера, который необходимо применить к ответу. Ангуляр позволяет перехватить все запросы и ответы, и изменить их по своему желанию. Следовательно, мы можем сериализовать объект на клиенте, при этом не загромаждая запросы каллбэками.
Перехватчик запросов выглядит следующим образом:
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, который возвращает поля для селекта:
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
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
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
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
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 выглядит очень необычно:
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. Аналогично создадим остальные контроллеры:
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
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
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
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, поэтому его опишем полностью:
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 для нашего приложения исходя из требований:
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
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
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
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
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, чтобы сразу после загрузки страницы у нас сразу была вся необходимая информация о нём:
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-контроллеры так же являются однотипными и рассматривались в той самой статье. Опишем их:
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;
})
}])
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;
})
}])
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;
})
}])
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;
}
}])
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()
})
}])
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"
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:
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})
}
}
};
}]);
.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:
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;
})
}
}
};
}]);
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 минут посылать запрос на обновление онлайна пользователя:
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 я написал сериайлайзеры для всех моделей:
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
}
function ThemeSerializer (collection) {
var result = [];
_.each(collection, function (item) {
result.push({
id: item[0],
title: item[1]
})
})
return result
}
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
}
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
}
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:
$layout_cache = "layout_#{Time.now.to_i}"
Итог
Вот так очень просто шаг за шагом мы написали вполне работоспособный Single Page Application форум, со странным, но хорошо оптимизированным API. За кадром остались тесты и интернационализация приложения. Об этом расскажу в следующих статьях.
Репозиторий с полным исходным кодом
Развернутое приложение
Комментарии (26)
babylon
17.05.2016 05:10У меня предложение. Можно же серилизаторы описать в формате JSON и один (один!) парсер к ним приставить. Изящнее же будет. Нет?
DeLuxis
17.05.2016 06:29+2Очень шустро все открывается и грузится. Именно таким должен стать интернет в дальнейшем.
fuCtor
17.05.2016 06:31Для рендеринга шаблонов можно использовать библиотеки:
Единственное при таком подходе все статичные данные придется тянуть через Gon или еще как-нибудь.
bonnzer
17.05.2016 10:29Многие пишут «шустро»…
Я как-то пробовал nodebb — тоже очень шустрый, выглядит красиво, пользоваться удобно + websocket's вкупе (вроде бы)…
Однако, не наблюдаю повсеместного его использования. Везде динозавры phpbb, joomla с модулями соответсвующими, что-то самописное и т.д…
Может, есть какие-то подводные камни окромя консерватизма разработчиков их клепающих? :)ZOXEXIVO
17.05.2016 10:37Может потому что форум должен нормально индексироваться? До Angular 2 все слишком костыльно
storuky
17.05.2016 10:42Гуглобот нормально видит такие сайты. Яндекс, возможно, тоже скоро начнет.
ZOXEXIVO
17.05.2016 11:30Не видит и не начнет, пока не будет изоморфности в самом Angular, придется возится с _escaped_fragments.
Когда ваш проект корректно проиндексируется, тогда и можно будет об этом заявлять.
storuky
17.05.2016 11:35Я об этом заявляю, потому что это не первый проект. И никогда не было проблем с индексацией в гугле. Даже через webconsole все прекрасно видно.
ZOXEXIVO
21.05.2016 12:55https://habrahabr.ru/post/301288/
Вот, почитайте как он замечательно индексирует.storuky
22.05.2016 12:17С каких пор частное стало общим? В этой статье нет вообще никакой конкретики. «У нас было не SPA, стало SPA и сайт стал плохо индексироваться». Откуда мы знаем как построено SPA в этом случае?
Вот и вот. Как видно все прекрасно индексируется.
И это еще при том, что я не делал сайтмэп.ZOXEXIVO
22.05.2016 13:23В комментах все расписано. И люди говорят, что консоль это одно, а бот гугла это другое. Гугл просто индексирует голый шаблон и все.
storuky
22.05.2016 13:35В той статье даже ссылки нет на ресурс.
Бот может неиндексировать что-то по двум причинам:
1) Очень медленный апи
2) Очень нагруженная клиентская часть
Это снапшот от машины, которая по своих характеристикам соответсвует той, на которой работает основной бот. Помимо основного есть еще много других ботов, которые не исполняют JS, и могут попадать на сайт из самых неожиданных источников. Основной все прекрасно исполняет. Я вас уверяю. Больше 20 проектов не испытывают вообще никаких проблем с индексацией в Гугле.
Нужно проводить эксперименты, выяснять почему, а не писать о том, что гугл кривой и обманывает.
bonnzer
17.05.2016 10:57Пререндер неплохо справляется с задачей…
2 простых команды + конф nginx'a врядли можно назвать костылями…
riley_usagi
17.05.2016 15:23Огромное вам спасибо!
Как раз начал заниматься тем же самым, но даётся с трудом.
Данная статья многое расставила по своим местам. Прям таки — дорожная карта.
sergey_eryashev
18.05.2016 10:20А как же сео параметры? Например вывод названия тем в тайтл и заполнение кейвордс и дескрипшен.
storuky
18.05.2016 10:20+1Помимо этого не сделано еще очень много. Это костяк приложения, исключительно для понимания.
tmn4jq
18.05.2016 16:38Спасибо за такую полную и развернутую статью!
Хочу задать несколько вопросов:
1. sqlite3 и mysql2 случайно затесались в Gemfile, или нарочно были оставлены?
2. Как лично Вы относитесь к after_hooks? Не считаете ли их злом, не предпочитаете ли использовать интеракторы, чтобы уменьшить количество потецниального хаоса и неожиданных коллбэков?
3. Хотел задать вопрос по поводу кириллицы в коде, но последняя секция «Итог» статьи ответила мне на этот вопрос)storuky
18.05.2016 17:481. thinking_sphinx использует mysql2 адаптером, а sqlite3 случайно остался
2. Я позиционирую AR шире чем тот же Datamapper. AR позволяет – я использую:) Все дело в удобстве. Как мне удобно, так я и пишу. Но вообще коллбеки – это всегда плохо
3. :)
krimtsev
Скорость работы форума на высоте! Будете продолжать развитие?
storuky
Развивать форум?) Разве что только для последующих статей. В планах автотесты и локализация.
Посмотрим как зайдет эта статья.
krimtsev
в планах функционала?
storuky
Нет, я его написал только для статьи. Но любой может форкнуть и допилить до мвп)