Заинтересовавшись методологией построения SPA-приложений на Ruby on Rails, я пришел к некоторым идеям, которые реализуются теперь в каждом моем приложении и впоследствии даже были выделены в отдельный гем Oxymoron. На данный момент на Oxymoron написано более 20 достаточно крупных коммерческих рельсовых приложений. Хочу вынести гем на общественный суд. Поэтому дальнейшее свое повествование буду вести уже на его основе.
Пример готового приложения.
Какие задачи решает Oxymoron?
Для меня этот гем на порядок сокращает количество рутинного кода и, как следствие, значительно повышает скорость разработки. Позволяет очень легко построить взаимодействие AngularJS + RoR.
- Автоматическое построение AngularJS-роутинга на основе routes.rb
- Автогенерация AngularJS-ресурсов из routes.rb
- Задание архитектурной строгости для AngularJS-контроллеров
- Прописывание постоянно используемых конфигов
- Валидация форм
- FormBuilder автоматически проставляющий ng-model
- Нотификация
- Часто используемые директивы (ajax fileupload, click-outside, content-for, check-list)
- Реализация компактного аналога JsRoutes
Как это работает?
Первым делом, необходимо подключить гем в Gemfile:
gem 'oxymoron'
Теперь, каждый раз при изменении routes.rb, либо при перезапуске приложения, в app/assets/javascripts будет генерироваться файл oxymoron.js, содержащий в себе весь необходимый функционал для построения приложения.
Следующим этапом необходимо произвести настройку ассетов. В простейшем случае это выглядет вот так:
Для application.js:
/*
= require oxymoron/underscore
= require oxymoron/angular
= require oxymoron/angular-resource
= require oxymoron/angular-cookies
= require oxymoron/angular-ui-router
= require oxymoron/ng-notify
= require oxymoron
= require_self
= require_tree ./controllers
*/
Для application.css:
/*
*= require oxymoron/ng-notify
*= require_self
*/
Мы используем UI Router, значит необходимо определить тег ui-view в нашем лейауте. Поскольку приложение будет использовать HTML5-роутинг, необходимо указать тег base. В нашем случае это application.html.slim. Я использую SLIM в качестве препроцессора и всем категорически советую.
html ng-app="app"
head
title Блог
base href="/"
= stylesheet_link_tag 'application'
body
ui-view
= javascript_include_tag 'application'
Для всех AJAX-запросов необходимо выключить layout. Для этого в ApplicationController пропишем необходимую логику:
layout proc {
if request.xhr?
false
else
"application"
end
}
Для корректной обработки форм и простановки ng-model необходимо создать инициалайзер, переопределяющий дефолтный FormBuilder на OxymoronFormBuilder.
ActionView::Base.default_form_builder = OxymoronFormBuilder
Последним делом необходимо заинжектить модуль oxymoron в ваше приложение и сообщить UI Router, что будет использоваться автоматически сгенерированный роутинг:
var app = angular.module("app", ['ui.router', 'oxymoron']);
app.config(['$stateProvider', function ($stateProvider) {
$stateProvider.rails()
}])
Все готово для создания полноценного SPA-приложения!
Напишем простейший SPA-блог
Итак. Первым делом подготовим модель Post и RESTful-контроллер для управления этой моделью. Для этого в консоли выполним команды:
rails g model post title:string description:text
rake db:migrate
rails g controller posts index show
В routes.rb создадим ресурс posts:
Rails.application.routes.draw do
root to: "posts#index"
resources :posts
end
Теперь опишем методы нашего контроллера. Часто, один и тот же метод может возвращать в ответе как JSON-структуры, так и HTML-разметку, такие методы необходимо обернуть в respond_to.
class PostsController < ActiveRecord::Base
before_action :set_post, only: [:show, :edit, :update, :destroy]
def index
respond_to do |format|
format.html
format.json {
@posts = Post.all
render json: @posts
}
end
end
def show
respond_to do |format|
format.html
format.json {
render json: @post
}
end
end
def new
respond_to do |format|
format.html
format.json {
render json: Post.new
}
end
end
def edit
respond_to do |format|
format.html
format.json {
render json: @post
}
end
end
def create
@post = Post.new post_params
if @post.save
render json: {post: @post, msg: "Post successfully created", redirect_to: "posts_path"}
else
render json: {errors: @post.errors, msg: @post.errors.full_messages.join(', ')}, status: 422
end
end
def update
if @post.update(post_params)
render json: {post: @post, msg: "Post successfully updated", redirect_to: "posts_path"}
else
render json: {errors: @post.errors, msg: @post.errors.full_messages.join(', ')}, status: 422
end
end
def destroy
@post.destroy
render json: {msg: "Post successfully deleted"}
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :description)
end
end
Каждому Rails-контроллеру соответствует AngularJS-контроллер. Правило соответствия очень простое:
PostsController => PostsCtrl
Admin::PostsController => AdminPostsCtrl # для контроллеров внутри namespace Admin
Создадим соответствующий контроллер в app/javascripts/controllers/post_ctrl.js:
app.controller('PostsCtrl', ['Post', 'action', function (Post, action) {
var ctrl = this;
// Код отработает только для '/posts'
action('index', function(){
ctrl.posts = Post.query();
});
// Вызовется для паттерна '/posts/:id'
action('show', function (params){
ctrl.post = Post.get({id: params.id});
});
// Только для '/posts/new'
action('new', function(){
ctrl.post = Post.new();
// Присваивание каллбека создания, который будет вызван автоматически при сабмите формы. См. ниже.
ctrl.save = Post.create;
});
// Для паттерна '/posts/:id/edit'
action('edit', function (params){
ctrl.post = Post.edit({id: params.id});
// Аналогичное присваивание для каллбека обновления
ctrl.save = Post.update;
})
// Общий код. Вызовется для двух методов edit и new.
action(['edit', 'new'], function(){
//
})
action(['index', 'edit', 'show'], function () {
ctrl.destroy = function (post) {
Post.destroy({id: post.id}, function () {
ctrl.posts = _.select(ctrl.posts, function (_post) {
return _post.id != post.id
})
})
}
})
// Так же внутри ресурса routes.rb можно создать свой кастомный метод. Вызовется для: '/posts/some_method'
action('some_method', function(){
//
})
// etc
}])
Обратите внимание на фабрику action. С помощью нее очень удобно разделять код между страницами приложения. Фабрика резолвится через сгенерированный стейт в oxymoron.js и, как следствие, знает текущий рельсовый метод контроллера.
action(['edit', 'new'], function(){
// код выполнится только на страницах posts/new и posts/:id/edit
})
Далее следует обратить внимание на фабрику Post. Данная фабрика генерируется автоматически из ресурса, определенного в routes.rb. Для правильной генерации, у ресурса должен быть определен метод show. Из коробки доступны следующие методы работы с ресурсом:
Post.query() // => GET /posts.json
Post.get({id: id}) // => GET /posts/:id.json
Post.new() // => GET /posts/new.json
Post.edit({id: id}) // => GET /posts/:id/edit.json
Post.create({post: post}) // => POST /posts.json
Post.update({id: id, post: post}) // => PUT /posts/:id.json
Post.destroy({id: id}) // => DELETE /posts/:id.json
Кастомные методы ресурса (member и collection) работаю точно так же. Например:
resources :posts do
member do
get "comments", is_array: true
end
end
Создаст соответствующий метод для AngularJS-ресурса:
Post.comments({id: id}) //=> posts#comments
Устанавливайте опцию is_array: true, если ожидается, что в ответ ожидается массив. В противном случае, AngularJS выкинет исключение.
Осталось создать недостающие вьюхи.
h1 Posts
input.form-control type="text" ng-model="search" placeholder="Поиск"
br
table.table.table-bordered
thead
tr
th Date
th Title
th
tbody
tr ng-repeat="post in ctrl.posts | filter:search"
td ng-bind="post.created_at | date:'dd.MM.yyyy'"
td
a ui-sref="post_path(post)" ng-bind="post.title"
td.w1
a.btn.btn-danger ng-click="ctrl.destroy(post)" Удалить
a.btn.btn-primary ui-sref="edit_post_path(post)" Редактировать
.small ng-bind="ctrl.post.created_at | date:'dd.MM.yyyy'"
a.btn.btn-primary ui-sref="edit_post_path(ctrl.post)" Редактировать
a.btn.btn-danger ng-click="ctrl.destroy(ctrl.post)" Удалить
h1 ng-bind="ctrl.post.title"
p ng-bind="ctrl.post.description"
h1 New post
= render 'form'
h1 Edit post
= render 'form'
= form_for Post.new do |f|
div
= f.label :title
= f.text_field :title
div
= f.label :description
= f.text_area :description
= f.submit "Save"
Особое внимание стоит обратить на результат генерации хелпера form_for.
<form ng-submit="formQuery = ctrl.save({form_name: 'post', id: ctrl.post.id, post: ctrl.post}); $event.preventDefault();"></form>
Достаточно определить метод ctrl.save внутри контроллера и он будет выполнятся каждый раз при сабмите формы и передавать параметры, которые вы видите. Но поскольку эти параметры идеально подходят в качестве аргументов для методов ресурса update и create, мы можем в нашем контроллере написать всего лишь ctrl.save = Post.create. В листинге PostsCtrl этот момент помечен соответствующим комментарием.
Для тегов text_field и text_area был автоматически добавлен атрибут ng-model. Правило составления ng-model следующее:
ng-model="ctrl.название_модели.название_поля"
Функционал render json: {}
В листинге рельсового PostsController вы наверняка заметили поля msg, redirect_to и тд в методе render. Для этих полей работает специальный перехватчик, который производит необходимое действие до передачи результата в контроллер.
msg – содержимое будет показано в всплывашке зеленого цвета в верхней части экрана. Если передать в render статус какой либо ошибки, то цвет изменится на красный
errors – принимает объект errors.full_messages, служит для отображения ошибок непосредственно самих полей формы.
redirect_to – выполнить редирект к необходимому стейту UI Router
redirect_to_options – если стейт требует опции, например, страница show требует id, то необходимо указать их в данном поле
redirect_to_url – выполнить переход по указаному урлу
reload – полностью перезагрузить страницу пользователю
Все данные действия происходят без перезагрузки страницы пользователя. Используется HTML5-роутинг на основе UI Router.
Теперь без link_to
Раньше приходилось использовать хелпер link_to, когда мы хотели определить ссылку в зависимости от названия роута. Теперь данный функционал реализует ui-sref в привычной нам манере описания роута.
a ui-sref="posts_path" Все посты
a ui-sref="post_path({id: 2})" Пост №2
a ui-sref="edit_post_path({id: 2})" Редактирование поста №2
a ui-sref="new_post_path" Создание нового поста
Легковесный аналог js-routes. КОНФЛИКТ
В глобальной области видимости вы можете найти переменную Routes. Она работает практически точно так же, как и js-routes. Отличие лишь в том, что данная реализация принимает только объект и не имеет сахара в виде аргумента-числа. Возможен конфликт, поэтому рекомендую отключить js-routes.
Routes.posts_path() // => "/posts"
Routes.new_post_path() // => "/post/new"
Routes.edit_posts_path({id: 1}) // => "/post/1/edit"
// Параметры по умолчанию
Routes.defaultParams = {id: 1}
Routes.post_path({format: 'json'}) // => "/posts/1.json"
Итог
Мы написали примитивное SPA-приложение. При этом код выглядит абсолютно рельсовым, логики описано минимум, а та, что есть уже, является максимально общей. Я понимаю, что нет предела совершенству и, что Oxymoron далек от идеала, однако, надеюсь, что смог заинтересовать кого-нибудь своим подходом. Буду рад любой критике и любому позитивному участию в жизни гема.
Пример готового приложения.
Автор: storuky