Apiway — новый способ клиент-серверной транспортировки данных

image
«Жить захочешь — не так раскорячишься» Особенности национальной охоты

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

Часто? Тогда возможно Вам будет интересна идея и реализация, описанные в этой статье.
Речь пойдет о Javascript, Ruby и Websockets.

Прелюдия


В начале этого года я публиковал статью об одном своем изобретении, в код которого вылил все свои мысли и видение (на тот момент) разработки как frontend'a, так и backend'a. Я не призывал никого им пользоваться, чего, собственно говоря, и не случилось, я просто задал один вопрос — это очередной велосипед?

Спустя какое-то время я присоединился в качестве frontend-разработчика к команде Luciding (молодой стартап, продвигающий идею осознанных снов). С тех пор и по сей день моя работа тесно связана с React, и, благодаря ему, мои взгляды на разработку кардинально поменялись. Сейчас я сам себе могу ответить — прошлое моё изобретение всего лишь очередной велосипед. Теперь я не вижу смысла в реализации клиентской части приложения в соответствии с паттерном MVC, клиентская часть — это зеркало отражающее действительность происходящую на сервере, ту действительность, которую пользователю дозволено видеть, а для этого нужны лишь данные и инструмент для их визуализации.

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

Доступ к данным
1. Отправка запроса на сервер на получение актуальных данных.
2. Создание обработчиков событий их изменения, которые приводят данные (после очередной манипуляции) в актуальное состояние.

Манипуляции с данными
1. Отправка запроса на сервер для изменения/удаления данных.
2. Отправка клиенту уведомлений (событий) о произведенных манипуляциях.

Зачатие


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

Роды


После энного количества времени я написал ruby gem для реализации серверной части и javascript npm package для клиентской. Назвал я свое детище Apiway.

Серверная часть


Построена поверх всем известного в ruby-сообществе фреймворка Sinatra, обладает генераторами для быстрого создания структуры нового приложения и его компонентов.

Компоненты:

  • Клиент — экземпляр Apiway::Client, персональный для каждого соединения;
  • Модель — обычная модель ActiveRecord;
  • Контроллер — предназначен для определения экшенов, манипулирующих данными;
  • Ресурс — отвечает за доступ к данным и их предоставление клиенту.


Подробнее о компонентах
Клиент
Осуществляет разбор входящих сообщений, управляет запуском контроллеров и синхронизацией ресурсов. Текущий клиент, всегда доступный в коде контроллера и ресурса, может быть получен методом client. Массив всех подключенных клиентов может быть получен вызовом Apiway::Client.all (кроме того данный метод принимает блок кода, применяемый к каждому клиенту).
Каждый клиент обладает своим персональным хранилищем, данные в котором хранятся на протяжении всего подключения.
# сохранение значения в хранилище
client[:user_id] = 1

# получение значения из хранилища
client[:user_id] # > 1

Кастомные обработчики событий клиентов
При генерации нового приложения создается файл app/base/client.rb, позволяющий настроить обработку событий подключения/отключения клиента, а также получения нового сообщения. Каждый обработчик вызывается в контексте клиента, событие которого обрабатывается.
module Apiway
  class Client

    on_connected do
      # ваш код обработки подключения нового клиента
      # например можно увеличить счетчик онлайна
    end

    on_message do |message|
      # тут можно обработать любое входящее сообщение
    end

    on_disconnected do
      # ваш код обработки отключения нового клиента
      # а здесь счетчик онлайна можно уменьшить
    end

  end
end

Модель
По умолчанию новая модель при генерации наследуется от ActiveRecord::Base, но это не обязательно, главное чтобы она была расширена модулем Apiway::Model. Данный модуль добавляет в неё единственный метод sync, вызов которого запускает процесс синхронизации ресурсов, зависящих от этой модели (автоматически вызывается у моделей ActiveRecord после сохранения/удаления модели).
class Test < ActiveRecord::Base

  include Apiway::Model

end

Контроллер
class UsersController < ApplicationController

  include Apiway::Controller

  # Фильтры аналогичны фильтрам в Rails

  # Before-фильры

  before_action :method_name
  before_action :method_name, only:   :action_name
  before_action :method_name, only:   [ :action_name, :action_name ]
  before_action :method_name, except: :action_name
  before_action :method_name, except: [ :action_name, :action_name ]

  # After-фильры

  after_action  :method_name
  after_action  :method_name, only:   :action_name
  after_action  :method_name, only:   [ :action_name, :action_name ]
  after_action  :method_name, except: :action_name
  after_action  :method_name, except: [ :action_name, :action_name ]

  # Определение экшена

  action :auth do
    # данный экшен запустится при вызове с клиента 
    # Api.query("Users.auth", {name: "Bob", pass: "querty"})
    #    .then(function( id ){ console.log("User id: ", id) })
    #    .catch(function( e ){ console.log("Error: ", e) })
    begin
      # метод params будет возвращать хэш переданных параметров
      user = User.find_by! name: params[ :name ], pass: params[ :pass ]
    rescue Exception => e
      # если юзер с указанным именем и паролем не найден, то метод error
      # остановит дальнейшее выполнение экшена и обещание на клиенте 
      # завершится неудачей, в консоли появится "Error: auth_error"
      error :auth_error
      # метод error можно вызывать и в фильтрах, тогда, например, при
      # вызове его в before-фильтре, экшен не запустится вообще и 
      # обещание на клиенте также завершится неудачей
    else
      # иначе с помощью метода client мы можем получить текущего
      # клиента (экземпляр Apiway::Client), и сохранить в его хранилище
      # id пользователя
      client[:user_id] = user.id
      # обещание на клиенте завершится успехом и в качестве параметров
      # будет передан результат работы экшена, в данном случае это будет id
      # в консоли появится "User id: 1"
    end
  end

end

Ресурс
# Данный ресурс будет открываться на клиенте следующим образом
# var userMessages = new Resource("UserMessages", {limit: 30});
# userMessages.onChange(function( data ){ console.log("New data", data) });
# userMessages.onError(function( e ){ console.log("Error", e) });
class UserMessagesResource < ApplicationResource

  include Apiway::Resource

  # Определение зависимостей
  depend_on Message, User
  # здесь мы указываем, что данные ресурса зависят от моделей Message и User, 
  # если в этих моделях произойдут изменения, то ресурс автоматически 
  # синхронизирует свои данные

  # Доступ к данным ресурса
  access do
    # данный код будет выполняться каждый раз перед синхронизацией данных ресурса
    # метод client также, как и в контроллере возвращает текущего клиента,
    # из хранилища которого мы можем получить значение :user_id
    error :auth_error unless client[:user_id]
    # вызов метода error предотвратит синхронизацию а клиентский ресурс сгенерирует 
    # событие "error" с параметром "auth_error", в консоли браузера
    # появится "Error: auth_error" при этом, ресурс останется "живым",
    # но "закрытым" и, как только клиент авторизуется, ресурс автоматически 
    # откроется и сгенерирует событие событие "change" с новыми данными
    # в консоли браузера появится "New data: [{mgs},{mgs},{mgs}...]"
  end

  # Определение возвращаемых данных
  # данный код должен быть построен так, чтобы в любой момент времени
  # возвращать актуальные данные
  data do
    # метод params будет возвращать хэш параметров ресурса
    Message.find_by(user_id: client[:user_id]).limit(params[:limit]).map do |msg|
      {
        text: msg.text,
        user: msg.user.name
      }
    end
  end

end



Клиентская часть


Предоставляет компоненты необходимые для взаимодействия с сервером. Каждый компонент унаследован от класса EventEmitter, его я вынес в отдельный пакет.

Компоненты:

  • Api — объект, предоставляющий методы для соединения с сервером и отправки запросов его контроллерам;
  • Resource — класс, инстансы которого открывают серверные ресурсы и всегда содержат в себе актуальные данные;
  • Store — пустой объект, предназначен для переноса «общих» данных между разными частями клиентской части приложения.


Подробнее о компонентах
События
Каждый объект клиентской части обладает следующими методами:
.on(event, callback[, context])  // устанавливает обработчик callback на событие event;
.one(event, callback[, context]) // аналогичен методу .on(), но обработчик сработает только один раз;
.off()                           // удаляет все обработчики всех событий;
.off(event)                      // удаляет все обработчики события event;
.off(event, callback)            // удаляет обработчик callback события event;
.off(event, callback, context)   // удаляет обработчи callback события event, 
                                 // установленный с контекстом context;


Api
Генерируемые события
  • ready — генерируется после установки соединения и успешного выполнения обещания beforeReadyPromise;
  • unready — генерируется после установки соединения и «провального» выполнения обещания beforeReadyPromise;
  • error — генерируется в случае возникновения ошибок в соединении с сервером;
  • disconnect — генерируется при отключении от сервера.

Методы
Api.connect(address[, options ]) 
// устанавливает соединение с серверной частью
// принимаемые опции:
// aliveDelay - интервал (в милисекундах) между отправками "ping" пакетов, необязателен, 
// но бывает необходим, если сервер закрывает соединение при неактивности

Api.query( "Messages.new", params ) 
// отправляет запрос в экшен new серверного MessagesController'a
// передает объект params с необходимыми данными

Api.disconnect()
// закрывает соединение с сервером

Api.beforeReadyPromise( callback )
// устанавливает метод, возвращающий обещание (Promise)
// данный метод будет вызван сразу после установки соединения
// и по результатам выполнения обещания сгенерируется событие
// "ready" - в случае успеха или "unready" - при неудаче
// данная возможность бывает полезна, например, для проведения
// авторизации перед запуском приложения

Api.onReady(callback, context)        // аналогичен Api.on("ready", callback, context)
Api.oneReady(callback, context)       // аналогичен Api.one("ready", callback, context)
Api.offReady(callback, context)       // аналогичен Api.off("ready", callback, context)

Api.onUnready(callback, context)      // аналогичен Api.on("unready", callback, context)
Api.oneUnready(callback, context)     // аналогичен Api.one("unready", callback, context)
Api.offUnready(callback, context)     // аналогичен Api.off("unready", callback, context)

Resource
Генерируемые события
  • change — генерируется при обновлении данных ресурса;
  • error — генерируется при возникновении ошибки (вызов метода error на сервере).

Методы
var resource = new Resource("Messages", {limit: 10})
// откроет серверный MessagesResource с параметрами {limit: 10}

resource.name 
// геттер к названию ресурса

resource.data
// геттер к данным ресурса

resource.get("limit")
// вернет значение параметра limit, в данном случае: 10

resource.set({limit: 20, order: "ask"})
// изменит значение параметра limit и установит новый параметр order
// данные ресурса будут автоматически синхронизированы

resource.unset("order")
// удалит параметр order
// данные ресурса будут автоматически синхронизированы

resource.onChange(callback, context)  // аналогичен resource.on("change", callback, context)
resource.oneChange(callback, context) // аналогичен resource.one("change", callback, context)
resource.offChange(callback, context) // аналогичен resource.off("change", callback, context)

resource.onError(callback, context)   // аналогичен resource.on("error", callback, context)
resource.oneError(callback, context)  // аналогичен resource.one("error", callback, context)
resource.offError(callback, context)  // аналогичен resource.off("error", callback, context)



Приручение


Рассмотрим создание простейшего консольного чата с помощью Apiway.

Серверная часть


Для начала установим Apiway и сгенерируем каркас приложения:

$ gem install apiway  # установка gem'a
$ apiway new Chat     # создание нового приложения
$ cd Chat             # переход в папку приложения


Посредством миграций создадим в базе данных таблицу Messages:

$ bundle exec rake db:create_migration NAME=create_messages


Добавим код, формирующий таблицу:

# db/mirgations/20140409121731_create_messages.rb
class CreateMessages < ActiveRecord::Migration
  def change
    create_table :messages do |t|
      t.text       :text
      t.timestamps null: true
    end
  end
end


И запустим миграции:

$ bundle exec rake db:migrate


Теперь создадим модель:

$ apiway generate model Message

# app/models/message.rb
class Message < ActiveRecord::Base

  include Apiway::Model

  # добавим валидации и переопределим стандартные сообщения об ошибках
  # делать это не обязательно, но сейчас нам это немного упростит задачу
  validates :text,  presence: { message: "blank" }, 
                    length:   { in: 1..300, message: "length" }

end


Затем ресурс:

$ apiway generate resource Messages

# app/resources/messages.rb
class MessagesResource < ApplicationResource

  include Apiway::Resource

  # указываем, что данные ресурса зависят от модели Message
  depend_on Message 

  # определяем метод, который в любой момент времени вернет актуальные данные
  data do
    Message.limit( params[ :limit ] ).order( created_at: :desc ).reverse.map do |message|
      {
        id:   message.id,
        text: message.text
      }
    end
    # params - хэш, содержащий в себе параметры, заданные при открытии ресурса
    # в итоге при params = {limit: 10} мы всегда имеем массив из 10 объектов типа
    # [{id: 10, text: "Hello 10"}, {id: 9, text: "Hello 9"}, {id: 8, text: "Hello 8"}, ...]
  end

end


И наконец — контроллер:

$ apiway generate controller Messages

# app/controllers/messages.rb
class MessagesController < ApplicationController

  include Apiway::Controller

  # определяем экшен, создающий новое сообщение
  action :new do
    begin
      # params - хэш с параметрами запроса
      current_user.messages.create! text: params[ :text ]
    rescue ActiveRecord::RecordInvalid => e
      # если всё плохо возвращаем клиенту ошибки
      error e.record.errors.full_messages
    else
      true # а это вернем, если все хорошо
    end
  end

end


На этом серверная часть завершена, запустим сервер командой:

$ apiway server


Клиентская часть


Думаю, что подавляющее большинство знает, что такое npm, gulp, grunt, browserify и т.д., поэтому о тонкостях сборки расписывать не буду, а просто опишу основные моменты.

Установка apiway:

npm install apiway --save


Собственно сама клиентская часть (проста как 3 копейки и умещается в несколько строк):

// source/app.js

import { Api, Resource } from "apiway";
// импортируем необходимые компоненты

var Chat = {

  run: function(){
    // при запуске чата открываем ресурс Messages с параметрами { limit: 10 }
    var messagesResource = new Resource( "Messages", { limit: 10 } );
    // как только данные в ресурсе изменились отрисовываем их методом render
    messagesResource.onChange( this.render );
    // добавляем метод send глобальному объекту window
    window.send = this.send;
  },

  render: function( messages ){
    // очищаем консоль перед новой отрисовкой
    console.clear();
    // выводим поочереди сообщения чата
    messages.forEach( function( item ){ console.log( item.text ) });
  },

  send: function( text ){
    // для отправки сообщения отправляем запрос в экшен new контроллера Messages
    Api.query( "Messages.new", { text: text } )
    // в случае неудачи выводим ошибки с помощью console.warn
      .catch( function( errors ){ console.warn( errors.join( ", " ) ) });
  }

};


Api
  // подключаемся к серверу с периодическим пингом для поддержки соединения
  .connect( "ws://localhost:3000", { aliveDelay: 5000 } )
  // как только соединение готово к работе, запускаем наш чат
  .oneReady( function( e ){ Chat.run() });
  // "навешиваем" обработчик запуска чата на однократное срабатывание
  // иначе, если мы будем использовать onReady(), при потере соединения
  // клиент переподключится и данный обработчик будет вызван еще раз


Вот и все. Теперь чтобы посмотреть результат, открываем браузер, затем консоль и видим десять последних сообщений чата, пишем send("Hello world") и снова видим десять последних сообщений, а точнее наше новое и девять предыдущих. Пробуем отправить пустое сообщение — видим ошибки.

Клиентская часть с использованием React
Для начала создадим компонент Message, он будет отвечать за отображение сообщения:
// ./components/Message.jsx
import React from "react";

class Message extends React.Component {

  render(){
    return (
      <div>
        <b>{ this.props.data.user }</b>
        <p>{ this.props.data.text }</p>
      </div>
    );
  }

}

export default Message;

Теперь непосредственно компонент Chat:
// ./components/Chat.jsx
import React             from "react";
import Message           from "./Message.jsx";
import { Api, Resource } from "apiway";

// переводим ошибки, вот где нам пригодились
// переопределенные в серверный модели сообщения об ошибках
let errorsMsg = {
  "Text blank":  "Сообщение не может быть пустым",
  "Text length": "Длина сообщения должна быть от 1 до 300 символов"
};

class Chat extends React.Component {

  constructor( props ){
    // определяем начальное состояние компонента
    super( props );
    this.state = { messages: [], errors: [] };
  }

  componentDidMount(){
    // при добавлении компонента на страницу, открываем 
    // ресурс Messages с параметрами { limit: 30 }
    this.MessagesResource = new Resource( "Messages", { limit: 30 } );
    // при изменении ресурса меняем состояние компонента
    this.MessagesResource.onChange(( messages )=>{ this.setState({ messages }) });
    // данный callback будет вызываться каждый раз при обновлении данных
  }

  componentWillUnmount(){
    // уничтожаем ресурс при удалении компонента
    this.MessagesResource.destroy();
  }

  onKeyUp( e ){
    // для отправки сообщений по Enter'у фильтруем нажатие любых клавиш, кроме него
    if( e.keyCode !== 13) return;
    // отправляем запрос в экшен new контроллера Messages, с параметрами {text: "введенный текст"}
    Api.query( "Messages.new", { text: e.target.value } )
    // получаем обещание (Promise)
      .then( ()=>{ this.setState({ errors: [] }) }, ( errors )=>{ this.setState({ errors }) });
    // в случае успеха - обнуляем ошибки, в случае неудачи - устанавливаем новые
  }

  render(){
    return (
      <div>
        // рендерим сообщения
        <div>
          { this.state.messages.map( ( message )=>{ return <Message data={ message } key={ message.id } />; } ) }
        </div>
        // поле ввода
        <input type="text" onKeyUp={ ( e )=> this.onKeyUp( e ) } />
        // переведенные ошибки
        <div>
          { this.state.errors.map( function( key ){ return errorsMsg[ key ]; }).join( ", " ) }
        </div>
      </div>
    );
  }

}

export default Chat;

И, наконец, отредактируем файл app.js
import React from "react";
import Chat  from "./components/Chat.jsx";

Api
  // подключаемся к серверу
  .connect( `ws://localhost:3000`, { aliveDelay: 5000 } )
  .oneReady( function( e ){
    // и, как только соединение готово к работе, запускаем наше приложение
    React.render( <Chat />, document.getElementById( "app" ) );
  });

Да что ж твориться-то?


Как видите мы ни разу напрямую не прибегли к синхронизации данных с клиентом — эту работу сделал за нас Apiway. Как сделал и в чем магия рассмотрим подробнее:

  • при отправке запроса в экшен серверного контроллера, Apiway запоминает все модели, которые подверглись изменениям во время выполнения экшена, в нашем случаем была создана новая модель Message;
  • после выполнения экшена запускается синхронизация всех ресурсов, зависящих от измененных моделей, в нашем случае это все экземпляры ресурса MessagesResource;
  • ресурс делает новую выборку моделей, формирует актуальные данные и преобразует их в JSON-строку (при этом он помнит JSON-строку, которая в данный момент находится на клиенте), затем происходит сравнение двух строк и вычисляется patch (минимальная инструкция для преобразования старых данных в новые — это позволяет значительно сэкономить трафик);
  • далее происходит «взвешивание» полных данных и patch'a, после чего клиенту отправляется то, что меньше по объему;
  • клиентских ресурс, получив новую инструкцию, применяет её к своим данным и генерирует событие "change".

Тонкости характера


Что будет при пропадании соединения с сервером?
Все будет хорошо! Клиент автоматически переподключится и, как только соединение будет налажено, все ресурсы синхронизируют свои данные до актуального состояния, а во время отсутствия «коннекта» будут доступны данные, загруженные ранее.

Какого формата должны быть данные формируемые ресурсом?
Любого! Всё, что может быть сконвертировано в JSON, будь то хэш, массив, строка, число или любой другой объект, у которого определен метод as_json.

Можно ли менять параметры открытого ресурса?
Можно! Типичный пример: подгрузить историю сообщений при прокрутке. Реализуется элементарным изменением параметра ресурса messageResource.set({limit: 50}) — ресурс загрузит 50 последних сообщений и сгенерирует событие "change".

Пятки Ахиллеса


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

Процедура вычисления patch'a относительно медленная
Да, чем больше объем данных, тем больше времени необходимо на вычисление. Можно конечно вообще вырезать этот функционал, но тогда значительно увеличится количество «гоняемого» трафика. Тут надо подумать над компромиссом.

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

Дайте пощупать!


Чат — чуть более сложный пример с простой аутентификацией.
Исходники чата на Github
Исходники серверной части Apiway на Github
Исходники клиентской части Apiway на Github
Заранее извиняюсь за «корявость» английского (на уровне интуиции) в readme репозиториев.

Спасибо!


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

© Habrahabr.ru