- PVSM.RU - https://www.pvsm.ru -

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

Заинтересовавшись методологией построения SPA-приложений на Ruby on Rails, я пришел к некоторым идеям, которые реализуются теперь в каждом моем приложении и впоследствии даже были выделены в отдельный гем Oxymoron [1]. На данный момент на Oxymoron написано более 20 достаточно крупных коммерческих рельсовых приложений. Хочу вынести гем на общественный суд. Поэтому дальнейшее свое повествование буду вести уже на его основе.

Пример [2] готового приложения.

Какие задачи решает 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/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 [3] в качестве препроцессора и всем категорически советую.

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.full_messages, служит для отображения ошибок непосредственно самих полей формы.

redirect_to – выполнить редирект к необходимому стейту UI Router

redirect_to_options – если стейт требует опции, например, страница show требует id, то необходимо указать их в данном поле

redirect_to_url – выполнить переход по указаному урлу

reload – полностью перезагрузить страницу пользователю

Все данные действия происходят без перезагрузки страницы пользователя. Используется HTML5-роутинг на основе UI Router.

Теперь без link_to

Раньше приходилось использовать хелпер link_to, когда мы хотели определить ссылку в зависимости от названия роута. Теперь данный функционал реализует ui-sref [4] в привычной нам манере описания роута.

  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 далек от идеала, однако, надеюсь, что смог заинтересовать кого-нибудь своим подходом. Буду рад любой критике и любому позитивному участию в жизни гема.

Пример [2] готового приложения.

Автор: storuky

Источник [5]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/ruby/120242

Ссылки в тексте:

[1] Oxymoron: https://github.com/storuky/oxymoron

[2] Пример: https://github.com/storuky/oxymoron_app

[3] SLIM: http://slim-lang.com/

[4] ui-sref: http://angular-ui.github.io/ui-router/site/#/api/ui.router.state.directive:ui-sref

[5] Источник: https://habrahabr.ru/post/283214/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best