Архітектура побудови Single Page Application на основі AngularJS і Ruby on Rails

Зацікавившись методологією побудови SPA-додатків на Ruby on Rails, я прийшов до деяким ідеям, які реалізуються тепер в кожному моєму додатку і згодом навіть були виділені в окремий гем Oxymoron. На даний момент на Oxymoron написано понад 20 достатньо великих комерційних рейкових додатків. Хочу винести гем на громадський суд. Тому подальше своє оповідання буду вести вже на його основі.

Приклад готового додатку.

Які завдання вирішує Oxymoron?
Для мене цей гем на порядок скорочує кількість рутинного коду і, як наслідок, значно підвищує швидкість розробки. Дозволяє легко побудувати взаємодію AngularJS + RoR.

  1. Автоматичне побудова AngularJS-роутінга на основі routes.rb
  2. Автогенерація AngularJS-ресурсів з routes.rb
  3. Завдання архітектурної строгості для AngularJS-контролерів
  4. Прописування постійно використовуваних конфіги
  5. Валідація форм
  6. FormBuilder автоматично проставляющий ng-model
  7. Нотифікація
  8. Часто використовувані директиви (ajax fileupload, click-outside, content-for, check-list)
  9. Реалізація компактного аналога JsRoutes
Як це працює?
Насамперед, необхідно підключити гем в Gemfile:

gem 'oxymoron'

Тепер, кожен раз при зміні routes.rb, або при перезапуску програми, у app/assets/javascript буде генеруватися файл 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 :do posts
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.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 далекий від ідеалу, проте, сподіваюся, що зміг зацікавити кого-небудь своїм підходом. Буду радий будь-якій критиці і будь-якому позитивному участі в житті гема.

Приклад готового додатку.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.