Заинтересовавшись методологией построения SPA-приложений на Ruby on Rails, я пришел к некоторым идеям, которые реализуются теперь в каждом моем приложении и впоследствии даже были выделены в отдельный гем Oxymoron. На данный момент на Oxymoron написано более 20 достаточно крупных коммерческих рельсовых приложений. Хочу вынести гем на общественный суд. Поэтому дальнейшее свое повествование буду вести уже на его основе.
Пример готового приложения.
Для меня этот гем на порядок сокращает количество рутинного кода и, как следствие, значительно повышает скорость разработки. Позволяет очень легко построить взаимодействие AngularJS + RoR.
Первым делом, необходимо подключить гем в Gemfile:
Теперь, каждый раз при изменении routes.rb, либо при перезапуске приложения, в app/assets/javascripts будет генерироваться файл oxymoron.js, содержащий в себе весь необходимый функционал для построения приложения.
Следующим этапом необходимо произвести настройку ассетов. В простейшем случае это выглядет вот так:
Для application.js:
Для application.css:
Мы используем UI Router, значит необходимо определить тег ui-view в нашем лейауте. Поскольку приложение будет использовать HTML5-роутинг, необходимо указать тег base. В нашем случае это application.html.slim. Я использую SLIM в качестве препроцессора и всем категорически советую.
Для всех AJAX-запросов необходимо выключить layout. Для этого в ApplicationController пропишем необходимую логику:
Для корректной обработки форм и простановки ng-model необходимо создать инициалайзер, переопределяющий дефолтный FormBuilder на OxymoronFormBuilder.
Последним делом необходимо заинжектить модуль oxymoron в ваше приложение и сообщить UI Router, что будет использоваться автоматически сгенерированный роутинг:
Все готово для создания полноценного SPA-приложения!
Итак. Первым делом подготовим модель Post и RESTful-контроллер для управления этой моделью. Для этого в консоли выполним команды:
В routes.rb создадим ресурс posts:
Теперь опишем методы нашего контроллера. Часто, один и тот же метод может возвращать в ответе как JSON-структуры, так и HTML-разметку, такие методы необходимо обернуть в respond_to.
Каждому Rails-контроллеру соответствует AngularJS-контроллер. Правило соответствия очень простое:
Создадим соответствующий контроллер в app/javascripts/controllers/post_ctrl.js:
Обратите внимание на фабрику action. С помощью нее очень удобно разделять код между страницами приложения. Фабрика резолвится через сгенерированный стейт в oxymoron.js и, как следствие, знает текущий рельсовый метод контроллера.
Далее следует обратить внимание на фабрику Post. Данная фабрика генерируется автоматически из ресурса, определенного в routes.rb. Для правильной генерации, у ресурса должен быть определен метод show. Из коробки доступны следующие методы работы с ресурсом:
Кастомные методы ресурса (member и collection) работаю точно так же. Например:
Создаст соответствующий метод для AngularJS-ресурса:
Устанавливайте опцию is_array: true, если ожидается, что в ответ ожидается массив. В противном случае, AngularJS выкинет исключение.
Осталось создать недостающие вьюхи.
Особое внимание стоит обратить на результат генерации хелпера form_for.
Достаточно определить метод ctrl.save внутри контроллера и он будет выполнятся каждый раз при сабмите формы и передавать параметры, которые вы видите. Но поскольку эти параметры идеально подходят в качестве аргументов для методов ресурса update и create, мы можем в нашем контроллере написать всего лишь ctrl.save = Post.create. В листинге PostsCtrl этот момент помечен соответствующим комментарием.
Для тегов text_field и text_area был автоматически добавлен атрибут ng-model. Правило составления ng-model следующее:
В листинге рельсового PostsController вы наверняка заметили поля msg, redirect_to и тд в методе render. Для этих полей работает специальный перехватчик, который производит необходимое действие до передачи результата в контроллер.
msg – содержимое будет показано в всплывашке зеленого цвета в верхней части экрана. Если передать в render статус какой либо ошибки, то цвет изменится на красный
errors – принимает объект errors, служит для отображения ошибок непосредственно самих полей формы.
redirect_to – выполнить редирект к необходимому стейту UI Router
redirect_to_options – если стейт требует опции, например, страница show требует id, то необходимо указать их в данном поле
redirect_to_url – выполнить переход по указаному урлу
reload – полностью перезагрузить страницу пользователю
Все данные действия происходят без перезагрузки страницы пользователя. Используется HTML5-роутинг на основе UI Router.
Раньше приходилось использовать хелпер link_to, когда мы хотели определить ссылку в зависимости от названия роута. Теперь данный функционал реализует ui-sref в привычной нам манере описания роута.
В глобальной области видимости вы можете найти переменную Routes. Она работает практически точно так же, как и js-routes. Отличие лишь в том, что данная реализация принимает только объект и не имеет сахара в виде аргумента-числа. Возможен конфликт, поэтому рекомендую отключить js-routes.
Мы написали примитивное SPA-приложение. При этом код выглядит абсолютно рельсовым, логики описано минимум, а та, что есть уже, является максимально общей. Я понимаю, что нет предела совершенству и, что Oxymoron далек от идеала, однако, надеюсь, что смог заинтересовать кого-нибудь своим подходом. Буду рад любой критике и любому позитивному участию в жизни гема.
Пример готового приложения.
Пример готового приложения.
Какие задачи решает 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.
Пример типичного Rails-контроллера
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:
Пример типичного AngularJS-контроллера
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 выкинет исключение.
Осталось создать недостающие вьюхи.
posts/index.html.slim
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)" Редактировать
posts/show.html.slim
.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"
posts/new.html.slim
h1 New post
= render 'form'
posts/edit.html.slim
h1 Edit post
= render 'form'
posts/_form.html.slim
= 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, служит для отображения ошибок непосредственно самих полей формы.
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 далек от идеала, однако, надеюсь, что смог заинтересовать кого-нибудь своим подходом. Буду рад любой критике и любому позитивному участию в жизни гема.
Пример готового приложения.
Поделиться с друзьями
Комментарии (5)
k2m30
10.05.2016 18:20В Rails 5 рекомендуется делать SPA через ActionCable. Каналы, подписчики и т.д.
Чем Angular будет лучше? Или не будет?storuky
10.05.2016 18:37+1Не понял вопроса… ActionCable служит для интеграции веб-сокетов без сторонних решений, таких как Faye, Pusher и тд, а AngularJS – это клиентский JavaScript-фреймворк.
Или имеется ввиду такая архитектура, как в N2O на Erlang? В этот вопрос я не погружался.
inf
11.05.2016 16:21Полезно. Искал тутрториалы по интеграции ангуляра и рейлс. Внятного мало. Сей гем наверное когда-то попробую)) Пока в избранное.
alprk
Круто, спасибо, буду пробовать. А без asset pipeline будет работать?
storuky
Изначально затачивалось именно под assets pipeline, но так как в итоге имеем обычный JavaScript-файл, то ничего не мешает подключить его через любой другой сборщик ассетов. Возможно, имеет смысл создать настройку, которая указывает, куда сохранять итоговый oxymoron.js.