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

в 9:55, , рубрики: AngularJS, framework, gem, ruby, ruby on rails, single page application

Заинтересовавшись методологией построения 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/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.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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js