Любителям Ruby и Coffeescript — очередной велосипед?

image

Меня всегда привлекали отзывчивые, динамичные интерфейсы, созданные на Javascript, но каждый раз, пытаясь погрузиться в изучение этого языка, я превращал свой мозг в кашу и ел её оставлял это до «лучших» времен, возвращаясь к статичным страницам на клиенте и PHP на сервере. Время шло.

Год назад, шатаясь по страницам сети, я наткнулся на статью про Coffeescript. Хм, интересно… Пары примеров кода было достаточно, чтобы заразиться идеей где-то его применить, но что то меня напрягало — хотелось мне какой-то фреймворк, который сам заботился бы о компиляции coffee в js. Так я нашел Rails, а вместе с ним ruby, gems, sass и кучу всего того, что привело меня в экстаз критическую точку невозврата…

Доброго времени суток, господа! Меня зовут Денис, и в этой статье я хочу поделиться с вами своими взглядами на разработку front-end'a и небольшой историей изобретения одного велосипеда, а вот очередного или нет — судить вам.

Почему изобретение велосипеда? Наверное потому, что программированию и методике изучения языков меня никто никогда не учил, а желание «сесть за руль» всегда одерживает верх над желанием «получить права», учитывая отсутствие прямой угрозы жизни и здоровью разработчиков на «встречке», я смело занимаю место пилота с лозунгом — «по пути разберемся».

По себе зная, что только практикой, методом тыка и гуглоюзаньем у меня получается усвоить теорию и понять вообще что к чему я приступил к разработке, а именно созданию Javascript MVC фреймворка таким, каким я его вижу, и попутному изучению необходимых мне для этого языков.

Мечты в реальность, сказку в быль


Так как Javascript это прототипно-ориентированный язык, то я решил отказаться от класс-ориентированного подхода и освоить всю мощь прототипов.

Долго размышляя над тонкостями реализации клиентской части, я пришел к следующим выводам:

  • каждый запрос к приложению — это работа с определенной коллекцией моделей, которая заканчивается отображением необходимого представления вида этих моделей на странице;
  • контроллер в своем экшене должен иметь уже готовую, отобранную коллекцию моделей и параметры для своей работы;
  • коллекции – это наборы моделей, отфильтрованных по определенным параметрам, постоянно поддерживающие свой набор в актуальном состоянии;
  • модели тесно связаны с видами, все изменения модели должны сразу же отображаться в представлении видов;
  • вид — это объект знающий как отрисовать шаблон в html-представление в соответствии с параметрами своей модели, а также как и где его показать, кроме этого каждый кусок html кода страницы должен принадлежать какому-либо виду;
  • представление — это шаблон вида отрисованный в html-код, предназначенный для отображения на странице и взаимодействия с пользователем;
  • юзер взаимодействует с видами через представления, виды с моделями, и наоборот — модели с видами, виды через представление с юзером.

После своих размышлений я приступил к разработке и начал её с реализации моделей. Вдохновленный возможностями моделей ActiveRecord я постарался сделать клиентские модели похожими на них. В итоге были реализованы валидации, колбеки, связи belongs to, has one, has many, has one through, has many through, методы поиска, а также создания, обновления, удаления с сохранением на сервер и многое другое.

Виды я наделил возможностью отслеживать состояние моделей и оперативно реагировать на их изменения, создал для них шаблоны с предварительной компиляцией на сервере, наделил небольшим клиентским шаблонизатором и научил перерисовываться не полностью, а только в тех местах (нодах), где это необходимо, в зависимости от состояния моделей, их коллекций или других элементов системы, способных генерировать события изменения. Помимо вышеперечисленного было реализовано связывание данных полей форм со свойствами модели, прямой вызов методов вида или его модели при нажатии на ссылку или отправке формы, а также оборачивание представлений видов в макеты.

Коллекции я постарался сделать похожими на обычные массивы с расширенным функционалом для работы с моделями, добавил в них возможности сортировки, фильтрации моделей и проксирования их методов.

Контроллеры я реализовал так, что имена их экшенов одновременно являются маршрутами с параметрами выборки отображаемых моделей, а также добавил в них возможность определять before и after фильтры.

Помимо вышеперечисленного был создан небольшой объект для работы с cookie, еще один, отвечающий за работу с сервером посредством Websockets и роутер для маршрутизации запросов к экшенам контроллеров, использующий History API браузера. Для работы с DOM отлично вписалась маленькая библиотека jBone. Внутри конечно все максимально реализовано нативными средствами, она используется лишь для связывания методов вида с событиями DOM и для дальнейшего удобства разработчика, при необходимости легко заменяется на всеми любимый jQuery, т.к. имеет подобный ему синтаксис.

Время шло, основной задуманный функционал клиентской части был реализован, а вот серверная мне все больше не нравилась – меня не устраивал Rails, ну не для этого он создан, надо что-то своё…

Кофе, гугл, хабр, статьи, гугл — Sinatra! То, что надо! Добавим сюда sinarta-websockets, sprockets, active-record, thin, щепотку перечных настроек, капельку магии, тщательно перемешаем, станцуем с бубном и вуаля — свой собственный сервер синхронизации.

Долго не думая, я решил упаковать сие в ruby gem, статью нашел здесь же, пару пробных попыток и вот он nali-0.0.1.gem.

Еще на этапе создания гема возникла идея добавить в него генератор нового приложения, а также моделей и видов, что было незамедлительно реализовано.

А еще немногим позже я разработал:

  • систему распознавания вкладок браузера на сервере, позволяющую узнать, например, какие из подключенных клиентов являются различными браузерами, а какие вкладками одного и того же;
  • систему прав доступа клиентов к свойствам моделей, позволяющую легко разбить клиентов на группы по ролям и строго указать каждой параметры взаимодействия;
  • систему синхронизации моделей, позволяющую синхронизировать модель вместе с зависимыми моделями, отслеживать клиентов следящих за моделью и оперативно информировать их о всех её телодвижениях;
  • систему селекторов моделей, позволяющую легко загружать необходимые модели на клиент;
  • возможности вызова с клиента методов на сервере, и наоборот, с сервера на клиенте с передачей данных;
  • модель уведомлений, позволяющую легко настроить уведомление пользователя информационными сообщениями, с запуском их как с клиентской, так и с серверной части.
  • и многое другое…

Чем больше фреймворк начинал походить на что-то самостоятельное и завершенное, тем больше меня наполняла гордость за проделанную работу и желание поделиться им с общественностью. Для этого был создан репозиторий на Github, и там же в разделе Wiki была опубликована документация, а криком души стала эта статья.

Что же в итоге получилось?


А получилась на мой взгляд довольно интересная штука, позволяющая быстро, с помощью нескольких консольных команд, разворачивать рабочее окружение и приступать к написанию кода, например

$ gem install nali
# установит Nali в систему
$ nali new gallery && cd gallery
# создаст папку со структурой приложения и перейдет в неё
$ bundle install
# установит зависимости
$ bundle exec thin start
# запустит веб-сервер thin

После чего можно открыть браузер, перейти по адресу http://localhost:3000/ и увидеть первую подготовленную страницу «Welcome to Nali».

Структура создаваемых файлов и каталогов описана в документации, все файлы приложения четко разделены на клиентские и серверные. Встроенный генератор позволяет легко генерировать новые модели, контроллеры и виды, например следующая команда

$ nali model Photo

создаст клиентские и серверные файлы модели и управляющего контроллера

  • app/client/javascript/models/photo.js.coffee — клиентская модель
  • app/client/javascript/controllers/photos.js.coffee — клиентский контроллер
  • app/server/models/photo.rb — серверная модель
  • app/server/controllers/photos_controller.rb — серверный контроллер

а также изменит файл app/server/models/access.yml, добавив в него заготовку для настройки уровней доступа клиентов к свойствам модели.

Рассмотрим клиентский контроллер


После генерации его код выглядит следующим образом

# app/client/javascripts/controllers/photos.js.coffee

Nali.Controller.extend Photos:

  actions: {}

Определим в нем метод показывающий фотографии определенного альбома

Nali.Controller.extend Photos:

  actions: 

    before:
      # пред-фильтры, методы @stop и @redirect в них 
      # предотвращают запуск экшена
      preview: ->
        # переадресуем на домашнюю страницу, если коллекция пуста
        @redirect 'home' unless @collection.length
        # @collection - это коллекция фотографий, отфильтрованная по album_id

    'preview/album_id': ->
      # preview - название экшена, album_id - фильтр для моделей коллекции
      @Model.Photo.select album: id: @filters.album_id
      # загрузим с сервера фотографии альбома
      @collection.order by: 'created', desc: true
      # отсортируем их в обратном порядке по дате создания

Что мы тут понаписали?

  • во-первых, мы определили роутеру новый маршрут preview/album_id, который совпадает с /photos/preview/1, где 1 — это id альбома, а preview — это экшен и одновременно имя вида, который должны отобразить модели коллекции;
  • во вторых, мы определили before-фильтр, который запустится перед самим экшеном и, если коллекция отобранных фотографий будет пуста, прервет дальнейшее выполнение запроса и отправит роутер на домашнюю страницу;
  • в-третьих, в самом экшене мы определили порядок сортировки фотографий и отправили на сервер запрос на их получение.

После выполнения экшена, контроллер даст коллекции команду на отображение вида preview.

Серверный контроллер


Его код после генерации выглядит так

# app/server/controllers/photos_controller.rb

class PhotosController < ApplicationController

  include Nali::Controller

end

В нем нам надо реализовать селектор для выборки фотографий альбома

class PhotosController < ApplicationController

  include Nali::Controller

  before_only :album do check_album end
  # перед обращением в селектор album проверим наличие модели альбома

  selector :album do @album.photos end
  # селектор возвращает все фотографии альбома для дальнейшей синхронизации

  private

    def check_album
      stop unless @album = Album.find_by_id( params[ :id ] )
      # метод stop предотвратит выполнение селектора, если такого альбома не существует
    end

end

В теле селектора нам нужно просто вернуть коллекцию моделей, их синхронизацию с клиентом берет на себя Nali.

Модуль Nali::Controller расширяет класс контроллера своими методами, вот некоторые из них

  • params — хэш полученных с клиента параметров
  • client — текущий клиент
  • clients — список всех подключенных к серверу клиентов

Клиентская модель


Её заготовка, также как и остальные выглядит минимально

# app/client/javascripts/models/photo.js.coffee

Nali.Model.extend Photo:

  attributes: {}

Добавим необходимые атрибуты, валидации, связи и колбеки

Nali.Model.extend Photo:

    belongsTo: [ 'user', 'album' ] 
    # связь с юзером - автором и альбомом

    hasMany: [
        'comments' 
        # комментарии
        commentators: through: 'comments', key: 'user' 
        # юзеры-комментаторы "сквозь" комментарии
    ]

    attributes:
        user_id:
            presence: true
            format:   'number'
            # обязательно заполнено, число
        album_id:
            presence: true
            format:   'number'
            # обязательно заполнено, число
        name: 
            presence: true
            length:   in: [5..50]
            # обязательно заполнено, длиной от 5 до 50 символов
        format: 
            presence: true
            inclusion: [ 'jpg', 'gif', 'png' ]
            # обязательно заполнено, значение должно быть одно из списка

    onCreate: ->
        # сработает при создании новой модели

    onUpdate: ( changed ) ->
        # сработает при обновлении свойств модели
        # changed - список измененных свойств

    onUpdateName: ->
        # сработает при обновлении свойства name

    onDestroy: ->
        # сработает при уничтожении модели

Сразу же приведу несколько примеров работы с моделями

photo = Nali.Model.Photo.new user_id: 1, album_id: 2, name: 'test', format: 'jpg'
# создаем экземпляр модели
photo.save()
# сохраняем на сервер
photo = Nali.Model.Photo.create user_id: 1, album_id: 2, name: 'test', format: 'jpg'
# создаем и сразу сохраняем модель на сервер
photo.update name: 'test_update_name'
# обновляем свойство модели
photo.save()
# сохраняем на сервер
photo.upgrade name: 'test_upgrade_name'
# обновляем свойство и сразу сохраняем на сервер
photo.remove()
# удаляем модель с клиента
photo.destroy()
# удаляем и с клиента, и с сервера
Nali.Model.Photo.find 1
# вернет фото с id == 1
Nali.Model.Photo.where id: [1..10], name: /test/
# вернет коллекцию моделей с id от 1 до 10 и строкой "test" в названии
Nali.Model.Photo.all()
# вернет коллекцию всех фотографий

Серверная модель


Для связи модели с базой данных, необходимо создать соответствующую таблицу, сделать это можно с помощью миграций. Создать миграцию можно командой

$ bundle exec rake db:create_migration NAME=create_photos

В результате мы получим файл

# db/migrate/20150119080311_create_photos.rb

class CreatePhotos < ActiveRecord::Migration
  def change
  end
end

в который нужно добавить код создающий таблицу

class CreatePhotos < ActiveRecord::Migration
  def change
    create_table :photos do |t|
      t.belongs_to :user
      # принадлежит юзеру
      t.belongs_to :album
      # принадлежит альбому
      t.string     :name, limit: 50
      # название фотографии
      t.string     :format, limit: 3
      # формат
    end
  end
end

Затем необходимо запустить миграции командой

$ bundle exec rake db:migrate

Теперь можно приступать к настройке серверной модели, открыв её файл, мы увидим

# app/server/models/photo.rb

class Photo < ActiveRecord::Base

  include Nali::Model

  def access_level( client )
    :unknown
  end

end

Это обычная модель ActiveRecord, расширенная модулем Nali::Model, добавляющим ей несколько методов, некоторые из которых:

  • sync( *watches ) — синхронизирует модель со всеми, следящими за ней, клиентами, плюс с теми, которые переданы в качестве аргументов;
  • call_method( name, params ) — во всех своих клиентских копиях вызывает вызывает метод name с параметрами params;
  • clients — возвращает массив всех подключенных к серверу клиентов.

Метод access_level особенный, он вызывается у модели в момент попытки какого-либо клиента создать, обновить, прочитать или удалить её. Объект этого клиента передается в качестве аргумента. В теле этого метода нужно реализовать механизм, суть работы которого, заключается в том, чтобы определить отношение клиента к модели и «сказать» кто это — «хозяин», «знакомый моего хозяина» или «я его незнаю» (или что-то еще). Реализуем это

class Photo < ActiveRecord::Base

  include Nali::Model

  # связи
  belongs_to :user
  belongs_to :album
  has_many   :comments
  has_many   :commentators, through: :comments, source: :user

  # валидации
  validates :user_id,  numericality: { only_integer: true }
  validates :album_id, numericality: { only_integer: true }
  validates :name,     length: { in: 5..50 }
  validates :format,   inclusion: { in: %w(jpg gif png) }

  # уровни доступа
  def access_level( client )
    return :owner       if client[ :user ] and self.user == client[ :user ]
    # возвращаем уровень owner (хозяин), если в хранилище клиента есть модель User (полученная,
    # например, на этапе аутентификации) и наша фотография принадлежит этой модели 
    return :commentator if client[ :user ] and self.commentators.include?( client[ :user ] )
    # возвращаем уровень commentator (комментатор), если в хранилище клиента есть модель User,
    # и эта модель входит в список комментаторов нашей фотографии
    :unknown
	# иначе говорим, что не знаем кто это
  end

end

Зачем нам это нужно? Нам нужно это для того, чтобы определить какие клиенты могут создавать/получать/обновлять/удалять модель и с какими свойствами, а какие не могут. Откроем файл app/server/models/access.yml, тут генератор уже подготовил для нас болванку

Photo:
  create:
  read:
  update:
  destroy:

В ней-то нам и надо определить параметры доступа

Photo:
  create:
    owner:       [ user_id, album_id, name, format ]
  read:
    owner:       [ user_id, album, name, format, comments ]
    commentator: [ +owner ]
  update:
    owner:       [ name ]
  destroy:
    owner:

А теперь по порядку:

  • В секции create мы определили, что создавать фотографию, а точнее сохранять клиентскую модель на сервер путем вызова new затем save или сразу create может только клиент с уровнем owner. Список [ user_id, album_id, name, format ] определяет принимаемые поля для создания модели;
  • В секции read мы определили, что получать модель с сервера могут только владельцы и комментаторы, список [ user_id, album, name, format, comments ] определяет получаемые свойства. Фишкой синхронизации является то, что если свойство возвращает модель или модели, то они будут также синхронизированы с клиентом, мало того, если модель является связью belongs_to, то автоматически будет передан ключ для связи, т.е. владелец получит модель альбома, модели всех комментариев и саму модель фотографии с полями [ user_id, album_id, name, format ]. Еще одной фишкой является смешивание уровней доступа, т.е. если комментаторы имеют те же параметры синхронизации, что и владельцы, то не нужно их «копипастить», достаточно их примешать вот так [ +owner ];
  • В секции update мы определили, что только владельцы (путем вызова на клиенте update затем save или сразу upgrade ) могут изменять свойства модели с сохранением на сервере, причем сохранению подлежит только свойство name;
  • В секции destroy мы указали, что только владелец может удалить фотографию с сервера путем вызова у клиентской модели метода destroy.

Виды


Вид это объект, который парсит шаблон в html-код (представление), наполняет его данными и помещает его в место назначения на странице приложения. Вид отвечает за своевременную отрисовку представления при изменении отображаемых в нем данных, а также несет в себе логику взаимодействия пользователя с представлением. Вид всегда принадлежит определенной модели.

Любой вид всегда имеет ссылку на свою модель в свойстве @model (для краткости имеется свойство-алиас @my), а в свойстве element находится представление ввиде объекта jBone (или jQuery если приложение использует его в качестве библиотеки для манипуляций с DOM).

Создать новый вид можно через терминал, с помощью команды

$ nali view PhotoPreview

где Photo — это модель, которой принадлежит вид, а Preview — это название вида. В результате работы команды мы получим следующие файлы

  • app/client/javascripts/views/photo/preview.js.coffee — сам вид
  • app/client/stylesheets/photo/preview.css.sass — его стили
  • app/client/templates/photo/preview.html — шаблон

Начнем их рассмотрение с шаблона, его файл после генерации пуст, заполним его

<!-- app/client/templates/photo/preview.html -->

<div class="name">{ @name }</div>
<!-- название фотографии -->
<div class="photo">{ +photoTag }</div>
<!-- тег картинки - результат работы хелпера photoTag -->
<div class="autor">Автор: { @user.name }</div>
<!-- имя юзера - владельца -->
<div class="album">Альбом: { @album.name }</div>
<!-- название альбома -->
<a href="/comments/{ @id }">Комментарии: { @comments.length }</a>
<!-- ссылка на комментарии с указанием их количества -->

На клиенте этот шаблон автоматически обернется в div.PhotoPreview и будет выглядеть так

<div class="PhotoPreview">
  <div class="name">{ @name }</div>
  <!-- ... -->
</div>

В Nali представления видов обернуты в тег, имеющий класс, записанный в стиле CamelCase, благодаря этому их легко можно различить во время отладки при просмотре исходного кода страницы. Подробнее об инструкциях шаблонов и обертывание можно прочитать на странице документации.

Если перед отдачей на клиент необходимо провести предварительную компиляцию шаблона на сервере, можно сделать это с помощью шаблонизатора ERB, просто переименовав расширение шаблона с preview.html на preview.html.erb и добавив в него соответствующие инструкции. К слову говоря, использовать можно и другие шаблонизаторы, например haml или slim, эти возможности предоставляет gem sprockets.

При вставке данных непосредственно в шаблон, с помощью инструкции { name }, точкой отсчета явлается модель вида, т.е. @ — это модель, name — название фотографии, @album — её альбом, @album.name его имя и т.д., цепочка может быть сколько угодно длинной, при её разборе вид доберется до конечного свойства, вставит его значение в представление и подпишется на событие его изменения.

Стили оформления по умолчанию обрабатываются препроцессором SASS и в нашем случае будут выглядеть примерно так

# app/client/stylesheets/photo/preview.css.sass

.PhotoPreview
  .name
    # ...
  .photo
    # ...
  .autor
    # ...
  .album
    # ...

Чтобы использовать препроцессор SCSS необходимо по аналогии с шаблоном, переименовать расширение файла стилей в preview.css.scss, а для обычного CSS в preview.css.

Сам вид после генерации выглядит следующим образом

# app/client/javascripts/views/photo/preview.js.coffee

Nali.View.extend PhotoPreview:

  events:  []
  # объект для связи событий представления с методами вида
  helpers: {}
  # хелперы, помогающие в отрисовке вида
  onDraw:  ->
  # колбек, выполняющийся при каждой отрисовке
  onShow:  ->
  # колбек, выполняющийся при вставке представления на страницу
  onHide:  ->
  # колбек, выполняющийся при удалении представления со страницы

Отредактируем его в соответствии с нашими требованиями

Nali.View.extend PhotoPreview:

  events:  'openFullSize on click at div.photo'
  # связываем событие клика на элемент div.photo с методом openFullSize,
  # синтаксис прост: 'метод[, метод... ] on событие[, событие...] at [ селектор ]'
  # если селектор не указан привязка произойдет к элементу представления

  helpers: 
    photoTag: ->
      '<img src="/photos/' + @my.id + '.' + @my.format + '" alt="" />'
      # возвращаем тег картинки с нашей фотографией

  openFullSize: ( event ) ->
    # выполнится при клике на фотографию

Теперь, чтобы посмотреть превьюшки всех фотографий альбома достаточно лишь перейти в браузере по адресу, например, /photos/preview/1, в результате на страницу (по умолчанию в тег body) будут вставлены представления

<body>
  <div class="PhotoPreview">...</div>
  <div class="PhotoPreview">...</div>
  <div class="PhotoPreview">...</div>
</body>

Для того, чтобы изменить место назначения, куда вставляются превьюшки, достаточно в виде PhotoPreview добавить простой метод, возвращающий селектор DOM-элемента или сам элемент (в момент вставки он должен существовать в дереве DOM)

Nali.View.extend PhotoPreview:

  insertTo: -> 'div.photosContainer'
  # ...

тогда результат вставки будет выглядеть так

<body>
  <div class="photosContainer">
    <div class="PhotoPreview">...</div>
    <div class="PhotoPreview">...</div>
    <div class="PhotoPreview">...</div>
  </div>
</body>

Более того, виды можно вставлять в макеты, т.е. создав простой вид, например AlbumIndex
Nali.View.extend AlbumIndex:

  insertTo: -> 'div.photosContainer'
  # ...

с шаблоном

<div class="name">Альбом: { @name }</div>
<div class="photos">{ yield }</div>

и заменив в виде PhotoPreview метод insertTo методом layout
Nali.View.extend PhotoPreview:

  layout: -> @my.album.viewIndex()
  # возвращаем вид index альбома
  # ...

мы увидим превьюшки в представлении альбома и на странице это будет выглядеть так

<body>
  <div class="photosContainer">
    <div class="AlbumIndex">
      <div class="name">Альбом: { @name }</div>
      <div class="photos">
        <div class="PhotoPreview">...</div>
        <div class="PhotoPreview">...</div>
        <div class="PhotoPreview">...</div>
      </div>
    </div>
  </div>
</body>

ключевая инструкция { yield } определяет место, куда они будут вставлены. Добавлю также, что вложенность видов в макеты не ограничена, ведь макет это обычный вид, поэтому для него также можно определить макет-родитель — это позволяет удобно дробить html-код страницы на составляющие.

Здесь на примерах я описал лишь некоторые возможности видов Nali, на самом деле их гораздо больше и они подробно описаны на странице документации.

Другой путь


Задачу описанную выше можно решить несколько иным способом, а именно рассмотрев фотографии со стороны альбомов, можно добавить экшен-маршрут в клиентский контроллер Albums
Nali.Controller.extend Albums:

  actions: 

    default: 'index'
    # экшен по умолчанию, позволяет сократить адрес обращения с /albums/index/1 до /albums/1

    'index/id': ->
      # index - название экшена, id - фильтр для моделей коллекции
      @Model.Photo.select album: id: @filters.id
      # загрузим с сервера фотографии альбома

затем добавить в клиентскую модель Album атрибуты, связь с фотографиями и их сортировку перед показом вида index альбома

Nali.Model.extend Album:

  hasMany: 'photos'

  attributes:
    name: null

  beforeShow:
    index: ->
      @photos.order by: 'created', desc: true

после чего немного изменить шаблон вида AlbumIndex
<div class="name">Альбом: { @name }</div>
<div class="photos">{ preview of @photos }</div>
<!-- меняем { yield } на { preview of @photos } -->
<!-- что означает вставить тут вид preview каждой модели из коллекции @photos -->

и получить тоже же результат по адресу /albums/1 (к слову говоря, роутер поймет и /album/1).

Коллекции


Коллекция — это набор моделей, отобранных по определенному фильтру. Всякий раз, когда мы используем метод where для поиска моделей, мы получаем их коллекцию. Методы коллекций подробно на примерах описаны в документации.

Особенностями коллекций Nali являются:

  • актуальность — единожды созданная коллекция всегда будет нести в себе только актуальный на данный момент набор моделей, если модель изменилась и перестала отвечать требованиям коллекции она пропадает из набора и наоборот, если модель стала отвечать требованиям коллекции он попадает в набор;
  • сортировка — если у коллекции заданы параметры сортировки, то модели в ней всегда упорядочены согласно этих параметров, кроме того, если коллекции была дана команда показать виды, то их представления располагаются на странице в соответствии с сортировкой, а если коллекцию пересортировать, они тоже пересортируются;
  • механизм адаптаций — коллекция помнит какие действия проводились с её моделями и, при добавлении в коллекцию новой модели, она автоматически подвергнется этим действиям в том порядке, в каком они проводились, а при удалении из коллекции над моделью будут проведены действия обратного характера (допустим коллекция показывает виды, новая модель, попав в нее, также покажет требуемый вид, при удалении модели из коллекции её вид пропадет со страницы);
  • проксирование методов модели — если в модели определить метод, имя которого начинается с двойного подчеркивания, то у коллекции таких моделей появится одноименный метод (без подчеркиваний), вызывающий его у всех моделей.

Небольшой пример:
# создадим модель юзера
Nali.Model.extend User: {}
# создадим вид
Nali.View.extend UserIndex: {}

шаблон вида
<div>User: { @name }, Age: { @age }</div>

создадим локальные экземпляры моделей
Nali.Model.User.new( name: 'Ted', age: 20 ).write()
Nali.Model.User.new( name: 'Bob', age: 25 ).write()
Nali.Model.User.new( name: 'Dan', age: 30 ).write()
# атрибуты name и age не объявлены, поэтому установятся обычные свойства
# write() производит запись в локальную таблицу клиента

произведем выборку коллекции моделей и покажем на экране представление их вида index
users = Nali.Model.User.where( age: [ 20..30 ] ).order( by: 'age' ).show 'index'

в итоге получим
<body>
  <div class="UserIndex">
    <div>User: Ted, Age: 20</div>
  </div>
  <div class="UserIndex">
    <div>User: Bob, Age: 25</div>
  </div>
  <div class="UserIndex">
    div>User: Dan, Age: 30</div>
  </div>
</body>

изменим возраст последнего
users.last().update age: 23

коллекция пересортируется автоматически, представления видов тоже
<body>
  <div class="UserIndex">
    <div>User: Ted, Age: 20</div>
  </div>
  <div class="UserIndex">
    div>User: Dan, Age: 23</div>
  </div>
  <div class="UserIndex">
    <div>User: Bob, Age: 25</div>
  </div>
</body>

изменим возраст первого на значение, выходящее за фильтр коллекции ( age: [ 20..30 ] )
user1 = users.first().update age: 19

модель пропадет из коллекции, скрыв представление вида
<body>
  <div class="UserIndex">
    <div>User: Dan, Age: 23</div>
  </div>
  <div class="UserIndex">
    <div>User: Bob, Age: 25</div>
  </div>
</body>

вернем ему значение подходящее под фильтр коллекции
user1.update age: 27

модель снова появися в коллекции и покажет представление вида index согласно параметров сортировки
<body>
  <div class="UserIndex">
    <div>User: Dan, Age: 23</div>
  </div>
  <div class="UserIndex">
    <div>User: Bob, Age: 25</div>
  </div>
  <div class="UserIndex">
    <div>User: Ted, Age: 27</div>
  </div>
</body>

пересортируем всю коллекцию и получим массив имен пользователей
users.order( by: 'name', desc: true ).pluck 'name'
# > [ 'Ted', 'Dan', 'Bob' ]
# представления видов также пересортируются
users.hide 'index'
# представления удалятся со страницы

Клиенты


При подключении нового клиента к серверу, для него создается объект класса EventMachine::WebSocket::Connection, в который Nali добавляет дополнительные возможности. В любом контроллере текущий клиент можно получить методом client, а список всех подключенных клиентов методом clients.

Каждый клиент имеет персональное хранилище, работа с которым довольно проста и умещается в небольшой пример

client[ :user ] = user
# сохраняем элемент
client[ :user ]
# получаем элемент 
client[].delete( :user )
# удаляем элемент
client[]
# получаем весь хэш хранилища

Для синхронизации моделей с клиентом имеется метод sync( *models ), который достаточно прост в применении

user.update city: 'Moskow'
client.sync user

Для получения других клиентов, являющимися вкладками одного браузера имеются методы all_tabs и other_tabs
client.all_tabs
# вернет массив клиентов, включая текущего, являющихся вкладками одного браузера
client.other_tabs
# вернет массив клиентов, кроме текущего, являющихся вкладками одного браузера

Это удобно в случаях, когда, например, у пользователя открыто несколько вкладок с сайтом, то при нажатии «выход» на одной из них можно легко произвести «выход» и на остальных, или когда пользователь запускает плеер на одной вкладке, а на других его надо остановить. Каждый из этих методов принимает блок кода, выполняющийся отдельно для каждого клиента в массиве.

В файле app/server/clients.rb определены три метода, позволяющие настроить реакции сервера на входящие сообщения и подключение/отключение клиентов.

module Nali::Clients

  def client_connected( client )
    # этот выполняется при подключении нового клиента, объект 
    # которого передается в переменной client
  end

  def on_message( client, message )
    # этот выполняется, когда на сервер приходит сообщение
    # переменные client и message это клиент-отправитель и 
    # его сообщение соответственно
  end

  def client_disconnected( client )
    # этот выполняется при отключении клиента, объект 
    # которого передается в переменной client
  end

end

Уведомления


В Nali встроена готовая модель Notice, имеющая 3 вида info, warning и error, отвечающая за показ уведомлений пользователю. Вид и оформление уведомлений легко настраивается под нужды разработчика. Показ уведомлений элементарен:

@Notice.info 'Hello World'
# покажет уведомление типа info, с текстом Hello World 
# по умолчанию уведомление скроется через 3 секунды
# данный вызов равноценен вызову
@Notice.info message: 'Hello World'

Создать собственный тип уведомлений также не составляет труда, этот процесс подробно описан в документации.

Инициировать показ уведомления можно и с серверной части следующим образом

client.info message: 'Hello World'
# на клиенте произойдет вызов 
@Notice.info message: 'Hello World'

Deployment


Nali позволяет cкомпилировать, минифицировать и объединить файлы клиентской части командой

$ bundle exec rake client:compile

Эта команда соберет

  • все Ваши javascript-файлы в public/client/application.js
  • все Ваши stylesheets-файлы в public/client/application.css
  • все Ваши html-шаблоны в public/index.html

что позволит в production окружении отдавать их уже готовыми. Для того, чтобы скомпилировать клиентские файлы для production окружения, находясь при этом в окружении development, необходимо явно задать это окружение в вызове команды

$ bundle exec rake client:compile RACK_ENV=production

Это удобно, когда на сервере назначения файловая система работает в режиме read-only, как на Heroku.

Кроме того, если пораскинуть мозгами, то можно придти к выводу, что поместив эти файлы в PhoneGap или CocoonJS можно легко и быстро получить кроссплатформенный клиент приложения.

Подведем итоги


Оглядываясь назад и вспоминая моменты прошедшего года, я считаю, что не зря потратил это время, пусть и вхолостую материально, но багаж полученных знаний для меня гораздо ценнее. Немогу сам себя оценить в плане того, насколько теперь высок уровень моих знаний и квалифицированности в области веб-разработки — эти выводы я сделаю для себя исходя из ваших комментариев, в которых, я надеюсь, вы ответите на главный вопрос — это очередной велосипед или в нем всё же есть что-то достойное внимания? Буду рад вашей критике, любой помощи и просто поддержке. Всем читателям, нашедшим время на чтение этой статьи, хочу сказать спасибо и на этом откланяться.

Аккумулятор ссылок:

Share post

Similar posts

Comments 20

    0
    Что-то пример не работает.
      0
      Вообще не открывается? или открывается но не стартует? Вебсокет соединение открывается?
        0
        image
          0
          Может фаервол блокирует? Попробуйте вот тут протестировать устанавливается ли соединение.
            0
            Соединение есть. Попробую попозже установить локально пример и посмотреть. А так, забыл сказать спасибо, мне как раз сейчас необходимо ваше решение.
              0
              Пожалуйста! Отпишите потом в чем заключался баг, самому интересно. С подобным я сталкивался, причина в фаерволе была, но при этом тест соединение тоже не устанавливалось.
      0
      В оффлайне не будет работать? То бишь, если загрузить страницу и интернет пропал?
        0
        Будет работать с теми данными, что уже загружены.
        0
        Как в рамках одного экшена работать с несколькими коллекциями?
        Как рендерить разные шаблоны в зависимости от параметров в экшене?
          0
          1. Внутри экшена всегда можно выбрать дополнительную коллекцию моделей
          users = @Model.User.where age: 20
          

          2. Например так
          Nali.Controller.extend Users:
          
            actions:
              'index/age/:view': ->
                @collection # тут у нас юзеры отфильтрованные по свойству age
                @params.view # имя вида который надо реально показать
                @collection.show @params.view
                @stop() 
                # остановим работу экшена иначе контроллер по умолчанию 
                # будет пытаться показать вид index, одноименный с экшеном
                @Router.changeUrl '/users/index/' + @filters.age + '/' + @params.view 
                # установим адрес страницы самостоятельно, т.к. @stop() прерывает автоматическую его установку
          

          Но лучше все же для каждого вида создать отдельный экшен, а в зависимости от параметров производить между ними переадресацию методом redirect( url )
          0
          А как обстоит дело с масштабированием? Что если будет несколько экземпляров запущено или даже несколько машин? В описании не увидел упоминания об этом, код еще не смотрел.

          Чтобы попробовать работу с websocket-ами тоже сделал анонимный чат и первое что решал это задачу горизонтального масштабирования. При этом в БД почти ничего не хранится (можно в принципе от нее отказаться даже).
            0
            И как решили задачу?
              0
              Для шины pub/sub на Redis + на нем же хранение данных кто на каком узле.
              Для облегчения работы абстракция над клиентами, чтобы не задумываться локальный или сторонний.
              Как приведу код в порядок, постараюсь оформить статью, может кому и пригодится.
            0
            Пока никак не обстоит. До этого ни руки, ни мозги мои еще не добрались.
            0
            Хочу выразить свою признательность и сказать большое спасибо anonymous за помощь в тестировании чата.
              0
              Если точнее то всем анонимам, тестировавшим чат ) «Непоняточка» вышла)
                0
                Не за что! :)

            Only users with full accounts can post comments. Log in, please.