Ще один приклад використання Backbone: адмінка сховища JS логів

Ця стаття описує в подробицях процес створення односторінкового веб-додатки з використанням Backbone.js. Туторіал розрахований на тих, хто вже пройшов перший знайомство Backbone, але хотів би побачити побільше практичних прикладів.
Тут будуть розглянуті такі практичні питання, як:
  • перевизначення методів
    sync
    та
    parse
    в моделях і колекціях для кастомних синхронізації з сервером через нестандартне API.
  • виділення атрибута-масиву з одиночної моделі в окрему колекцію зі своїм поданням (
    View
    ) і встановлення зв'язку такої колекції з моделлю-джерелом.
  • реалізація вибору (виділення) елемента колекції та його подання із збереженням свого стану.

Додаток, яке ми напишемо, являє собою адмін-панель для одного інструмента, зберігає JS логи інших веб-додатків на сервері. Я не буду детально розписувати роботу цього JS-логер, тому що це не має відношення до теми статті. Якщо коротко, то він перенаправляє висновок
console.log
в HTTP-запити і зберігає його вміст на сервері, групуючи його у файли з лог-сесій. Кожна лог-сесія починається в момент завантаження сторінки і закінчується при закритті вікна або після закінчення певного часу.

Наше додаток буде різновидом файлового менеджера. В його завдання буде входити:
  • реєстрація зовнішніх веб-додатків, що використовують JS-логгер.
  • висновок список зовнішніх веб-додатків.
  • видалення веб-додатків.
  • висновок і фільтрація списку лог-файлів, створених логером.
  • показ вмісту обраного лог-файлу.
  • видалення лог-файлів.
  • читання конфігурації (налаштувань) логер.
  • зміна конфігурації логер.
Кожне зовнішнє веб-додаток задається тільки його URL-адресою, тому, щоб не плутати їх з нашим додатком, далі будемо використовувати слово «URL» замість «веб-додаток».

Як видно, функціональність нашого менеджера дещо обмежена. Так, він не дозволяє, ні створювати, ні правити, ні копіювати/переміщати файли. Ці обмеження випливають з логіки роботи JS-логер. Він сам створює лог-файли і змінює їх вміст у міру надходження нових даних. Ми ж, як користувачі менеджера, зможемо тільки переглядати і видаляти непотрібні.

Це зовнішній вигляд нашого менеджера:


Ліва колонка містить список URL, середня — список лог-файлів, права — вміст вибраного лог-файлу. Верхня панель містить додаткові елементи управління: поле додавання/реєстрації нового URL і кнопки оновлення і фільтрації списку лог-файлів, а також кнопка виклику конфігурації логер у вигляді модального вікна. Список лог-файлів може бути відфільтрований з URL, вибраного у списку URL, та/або по IP, який як і URL відноситься до числа атрибутів лог-файлів.

Як випливає з його завдань, менеджер має справу з об'єктами 3:
  1. список URL;
  2. список лог-файлів;
  3. конфігурація логер.
і підтримує 3 види операцій:
  1. читання (конфігурації логер, списків URL і лог-файлів, а також вмісту лог-файлів);
  2. оновлення списку URL і конфігурації логер);
  3. видалення (лог-файлів).
Так у загальних рисах виглядає архітектура клієнтської частини програми, яку нам належить реалізувати.

API сервера
Але спочатку ми коротко розглянемо серверну частину програми, реалізація якої виходить за рамки даної статті. Припускаємо, що вона вже написана (на PHP, хоча мова реалізації в даному випадку неважливий) і нам відомо його API. На відміну від клієнтської, серверна частина менеджера має справу лише з двома об'єктами: конфігурація логер, яка включає в себе список URL, і список лог-файлів. Через ці два об'єкта здійснюється зв'язок (синхронізація) між серверною і клієнтською частинами програми.

API серверної частини складається з двох скріптів
"logger_cfg.php"
та
"logger_ctl.php"
і використовує нестандартний (Backbone) протокол взаємодії, який ми і розглянемо.

logger_cfg.php
відповідає за синхронізацію конфігурації логер. Він виконує два види операцій: читання оновлення.

Читання реалізується через
GET
запит:
GET /logger_cfg.php

У разі успішного виконання сервером повертається HTTP код
200
, у разі невдалого виконання —
404
.
Тіло відповіді сервера в разі успіху видається у форматі JSON (
application/json
).
Приклад успішної відповіді{
"dir": "logs\/",
"app_urls": [
"http:\/\/somedomain.com\/tests\/test1.html",
"http:\/\/somedomain.com\/tests\/test2.html"
],
"buff_size": 10000,
"interval": 1,
"interval_bk": 30,
"expire": 3,
"requests_limit": 0,
"log_timeshifts": 1,
"subst_console": 1,
"minify": 0
}

З усіх властивостей (опцій) об'єкта конфігурації нас в основному буде цікавити властивість
"app_urls"
, відповідне списком URL.

Оновлення реалізується через
POST
запит:
POST /logger_cfg.php

Тіло (дані) запиту передається у форматі JSON (
application/json
), воно має ту ж структуру, що і тіло відповіді на запит читання, при цьому будь-яка з властивостей об'єкта може бути опущено.
У разі успіху повертається HTTP код
200
, у разі невдачі —
404
.
Тіло відповіді в будь-якому випадку порожнє.

logger_ctl.php
відповідає за синхронізацію списку лог-файлів. Виконує два види операцій: читання видалити.

Читання реалізується через
GET
запит:
GET /logger_ctl.php

Запит може містити параметр з ім'ям
"stamp"
, через який передається часова мітка останнього запиту читання:
GET /logger_ctl.php?stamp=1411030306

У разі успіху повертається HTTP код
200
, у разі невдачі —
404
.
Тіло відповіді сервера в разі успіху видається у форматі JSON (
application/json
) у вигляді об'єкта, ім'я кожного властивості якого (крім останнього) містить ім'я лог-файлу, а значення — впорядкований масив атрибутів лог-файлу, в число яких входять:
  1. URL веб-додатки
  2. тимчасова мітка (timestamp) створення
  3. IP клієнта логер
  4. UserAgent клієнта логер
Останнє по порядку властивість об'єкта з ім'ям
"stamp"
містить мітку поточного запиту, яка використовується клієнтською частиною в наступних запитах читання.
Приклад успішної відповіді{
"5411d3dc39f14.log": [
"http:\/\/somedomain.com\/tests\/test1.html",
"1410454492",
"127.0.0.1",
"Mozilla\/5.0 (Windows NT 6.1; WOW64; rv:32.0) Gecko\/20100101 Firefox\/32.0"
],
"54198a97a8f6d.log": [
"http:\/\/somedomain.com\/tests\/test2.html",
"1410960023",
"127.0.0.1",
"Mozilla\/5.0 (Windows NT 6.1; WOW64; rv:32.0) Gecko\/20100101 Firefox\/32.0"
],
"stamp": 1411030306
}

Видалити реалізується через
POST
запит:
POST /logger_ctl.php

Тіло запиту передається у форматі
"application/x-www-form-urlencoded"
і містить тільки один параметр з ім'ям
"id"
, що відповідає імені видаляється лог-файлу.
Приклад тіла запиту:
id=5411d3dc39f14.log

Відповідь сервера в будь-якому випадку повертається з HTTP кодом
200
з порожнім тілом. Після запиту видалення незалежно від його результату клієнтська частина буде оновлювати список лог-файлів за допомогою запиту читання. Тому відповідь сервера на запит видалення не має значення.

Реалізація клієнтської частини
З серверною частиною розібралися, переходимо власне до предмету статті — реалізації клієнтської логіки нашої програми.

В якості основи для верстки будемо використовувати один з бутстраповских прикладів — Dashboard. Він добре підходить до нашої задачі. Потрібно тільки додати ще одну колонку і трохи підправити стилі.

html код програми
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#about" data-toggle="modal">Dashboard</a>
</div>
<div class="navbar-collapse collapse">
<form class="navbar-form navbar-left" id="controls" onsubmit="return false">
<input type="text" class="form-control" placeholder="New web app" title="Press < Enter > to register new web app with entered URL">
<button type="button" class="btn btn-primary filter" title="Filter log files by IP">
<span class="glyphicon glyphicon-filter"></span> IP<span class="value"></span>
</button>
<button type="button" class="btn btn-default refresh" title="Refresh the list of log files">
<span class="glyphicon glyphicon-refresh"></span>
</button>
<button type="button" class="btn btn-default remove" title="Remove all visible log files">
<span class="glyphicon glyphicon-remove"></span>
</button>
</form>
<ul class="nav navbar-nav navbar-right">
<li><a href="#config" data-toggle="modal"><span class="glyphicon glyphicon-cog"></span> Config</a></li>
</ul>
</div>
</div>
</div>

<div class="container-fluid">
<div class="row">
<div class="col-sm-4 col-md-3 sidebar">
<ul class="nav nav-pills nav-stacked" id="url-list"></ul>
</div>

<div class="col-sm-3 col-sm-offset-4 col-md-2 col-md-offset-3 sidebar">
<ul class="nav nav-pills nav-stacked" id="file-list"></ul>
</div>

<div class="col-sm-5 col-sm-offset-7 col-md-7 col-md-offset-5 main">
<div id="file-content"></div>
</div>
</div>
</div>

<script type="text/javascript" src="//code.jquery.com/jquery-1.11.1.min.js"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.2/underscore-min.js"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
<script type="text/javascript" src="manager.js"></script>

ul#url-list
буде містити список URL,
ul#file-list
— список лог-файлів, а
div#file-content
— вміст вибраного файлу. Модальне вікно для конфігурації логер ми додамо пізніше. В кінці ми підключаємо необхідні бібліотеки та код нашої програми (
"manager.js"
).

Як вже згадувалося, клієнтська частина менеджера має справу з 3 основними об'єктами: конфігурація логер, список URL і список лог-файлів. Реалізуємо ці об'єкти, використовуючи Backbone.

Конфігурація логер

Почнемо з конфігурації. Вона буде одиночної моделлю, що не входить в колекцію. Набір її атрибутів визначається API, яке ми вже розглянули. Ми не будемо будувати об'єкт
defaults
значень за замовчуванням, тому атрибутів досить багато, до того ж їх склад може і змінитися. Метод
initialize
нам теж поки що не потрібен. Що нам потрібно, так це метод sync, переопределяющий поведінка методу Backbone.sync для нашої моделі, оскільки у нас нестандартне API. Офіційний мануал не пояснює, як саме потрібно перекрити цей метод (воно і зрозуміло, адже це залежить від конкретного API). Втім нам це і не потрібно. Ми можемо використовувати оригінальну реалізацію цього методу, замінивши в ній ті частини, які залежать від API. Для цього нам доведеться заглянути в исходники Backbone.

Ось вихідний код методу Backbone.sync для версії 1.1.2, яку ми використовуємо (втім в інших версіях гілки 1.x він не сильно відрізняється). Нам потрібні тільки 3 останні рядки методу. Їх ми можемо скопіювати до себе в метод без змін. Все, що знаходиться вище, готує об'єкт
params
параметрів для обгортки методу
$.ajax
з розрахунком на REST API. Все це ми замінимо своїм кодом, розрахованим на наше API.

Клас і об'єкт моделі конфігурації
var Cfg = Backbone.Model.extend({
sync: function(method, model, options){
// будуємо об'єкт параметрів ajax-запиту до нашого API
var params = method == 'read'?
{
type: 'GET', url: 'logger_cfg.php', dataType: 'json'
} :
{// якщо не читати, то create/update
type: 'POST', url: 'logger_cfg.php',
contentType: 'application/json',
data: JSON.stringify(options.attrs || model.toJSON(options))
};
var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
model.trigger('request', model, xhr, options);
return xhr;
},
});
var cfg = new Cfg(deftCfg);

Метод sync приймає 3 параметри:
  • method
    — назва CRUD методу;
  • model
    — синхронізовану модель;
  • options
    — об'єкт опцій, що передається в
    sync
    .
У нас буде всього два CRUD методу:
"read"
— для читання і
"create"
для оновлення. Чому
"create"
, а не
"update"
? Тому що в нашій моделі немає атрибута
"id"
. Якщо у моделі на момент її збереження на сервер немає атрибута
"id"
, Backbone вважає, що це операція створення (
"create"
). Це цілком логічно, якщо врахувати, що CRUD будується на зв'язках з БД і розрахований насамперед на колекції, де кожен елемент (модель) має свій унікальний ідентифікатор. Але у нас не колекція, а одиночна модель. Тому нам не потрібен ID, щоб ідентифікувати її. Ну а оскільки нам не треба розрізняти операції
"create"
та
"update"
(ми реалізуємо тільки одну з них), то можна просто не звертати на це уваги: неважливо як називається операція на клієнта, важливо як вона перетворюється на запит до сервера.

Після оголошення класу моделі (
Cfg
) ми одразу ж створюємо його об'єкт/примірник у змінній
cfg
. Тут виникає питання: як нам заповнити цей об'єкт початковими даними? Є спокуса використати для цього виклик методу fetch, який запустить наш метод
sync
для синхронізації з сервером. Але оф.мануал переконує нас, що так робити некошерно. Замість цього нам потрібно завантажити початкові дані на місці, під час завантаження сторінки, і передати їх в конструктор моделі. Для цього ми робимо аналог запиту читання конфігурації всередині скрипта, що генерує html-сторінку, і виводимо його результат в JS змінну
deftCfg
, яку і передаємо в конструктор.

Перелік URL

Як уже згадувалося перед хабракатом, список URL буде колекцією, пов'язаної з атрибутом
"app_urls"
моделі конфігурації. Для чого потрібно виділяти атрибут моделі в колекцію? Для того, щоб він мав своє окреме подання (View). Виходячи з проекту нашого додатка модель конфігурації буде мати уявлення у вигляді модального вікна. Але ми не хочемо, щоб в нього входив список URL. Для цього атрибута ми хочемо побудувати окреме подання у вигляді колонки ліворуч від списку лог-файлів. На жаль Backbone поки не дозволяє мати окремі подання для різних атрибутів однієї моделі. Тому нам доведеться створити «фіктивну» колекцію, яка буде відображати стан обраного атрибута моделі-джерела. Ця колекція не буде мати зовнішньої синхронізації з сервером, замість цього буде використовуватися внутрішня синхронізація — зв'язок з моделлю-джерелом. Для забезпечення зв'язку з цим нам знову знадобиться свій метод
sync
переопределяющий поведінка методу Backbone.sync для нашої колекції. А конкретно, цей метод буде підміняти запити до сервера операціями всередині програми, що реалізують зв'язок між колекцією і моделлю-джерелом.

Але спочатку нам потрібно оголосити модель URL — основу для нашої колекції. Модель URL буде відповідати елементу масиву
"app_urls"
з моделі конфігурації, значення якого (тобто значення URL) буде міститися в одному з атрибутів моделі. Краще всього назвати цей атрибут ім'ям
"id"
. Нам все одно знадобиться атрибут з цим ім'ям, якщо ми хочемо мати підтримку операції видалити екземплярів цієї моделі, яка в іншому разі не буде працювати. Можна, звичайно, мати в якості ID окремий атрибут (наприклад числовий), крім URL. Але в такому разі нам доведеться подбати про його унікальність, що не так-то просто зробити, якщо врахувати, що джерелом даних є БД, а простий масив значень. З іншого ж боку URL по визначенню унікальний, що робить його найкращим кандидатом на роль ідентифікатора. Крім атрибута
"id"
, що містить значення URL, нам знадобиться додатковий атрибут
"title"
для виводу URL в скороченому вигляді в списку URL.

Клас моделі URL
var Url = Backbone.Model.extend({
defaults: {
id: ",
title: "
},
sync: function(method, model, options){
console.log('Url:sync():');
return cfg.syncUrls(method, model, options);
}
});

Тепер на основі цієї моделі ми створимо колекцію — Список адрес (URL. Почнемо з реалізації (перевизначення) методу
sync
. Метод
sync
колекції Список URL буде приймати ті ж параметри, що його аналог в моделі
Cfg
, з тією відмінністю, що в якості 2-го параметра буде приходити не модель, а колекція.

На цей раз ми вже не можемо використовувати оригінальну реалізацію
Backbone.sync
через відсутність у нас справжньої синхронізації з сервером. Наша колекція буде використовувати внутрішню синхронізацію без використання HTTP (AJAX) запитів. Приклад такої синхронізації можна знайти в розширенні Backbone localStorage.

Ось вихідний код методу Backbone.sync зі свіжої версії цього розширення, який служить для синхронізації моделей/колекцій з HTML5 localStorage. Його ми можемо використовувати в якості основи для нашого методу. Потрібно тільки замінити частини коду, що мають справу з HTML5 localStorage, своїм кодом, який буде мати справу з моделлю-джерелом.

Клас і об'єкт колекції Список URL
var UrlList = Backbone.Collection.extend({
model: Url,
initialize: function() {
this.listenTo(cfg, 'sync', this.onCfgSync);
},
onCfgSync: function(model, resp, options) {
// якщо відповідь (resp) не порожній, значить це операція читання
if (resp) {
запускаємо через виклик fetch наш кастомный метод sync
this.fetch();
}
},
sync: function(method, list, options){
var resp; // відповідь на запит синхрон-ції
var errMsg = 'Sync error'; // повідомлення про помилку
// об'єкт Deferred
var dfd = Backbone.$ ?
(Backbone.$.Deferred && Backbone.$.Deferred()) :
(Backbone.Deferred && Backbone.Deferred());
// якщо масив app_urls непорожній
if (cfg.get('app_urls')') {
// будуємо відповідь на запит читання:
// перетворимо масив app_urls в хеш з наборів атрибутів
resp = _.map(cfg.get('app_urls'), cfg.buildUrlAttrs);
}
if (resp) {
if (options && options.success) options.success(resp);
if (dfd) dfd.resolve(resp);
} else {
if (options && options.error) options.error(errMsg);
if (dfd) dfd.reject(errMsg);
}
if (options && options.complete) options.complete(resp);
return dfd && dfd.promise();
},
// будує набір значень атрибутів для ініціалізації моделі URL
buildUrlAttrs: function(url, i){
var title = url.substr(url.indexOf('://')+3);
if (title.length > 35) title = title.substr(0, 35)+ '...';
return {id: url, title: title, i: typeof i != 'undefined'? i+1 : 0};
}
});
var urlList = new UrlList();

У методі
initialize
ми встановлюємо обробник
onCfgSync
для події
sync
моделі конфігурації. Це потрібно для того, щоб Список URL оновлювався після кожної синхронізації моделі конфігурації з сервером. Цей обробник приймає 3 параметри:
  • model
    — синхронізовану модель, тобто
    Cfg
    ;
  • resp
    — відповідь сервера;
  • options
    — об'єкт опцій, який був переданий в
    sync
    .
З них використовується тільки параметр
resp
, що містить відповідь сервера на запит синхронізації, для визначення типу операції.

Параметр
method
методи
sync
може приймати тільки одне значення —
"read"
. Причина в тому, що метод
sync
у колекцій викликається тільки при операції читання даних з джерела (пояснення можна знайти на тут). Всі інші операції (
"create"
,
"update"
,
"delete"
) делегуються кожному елементу колекції, в нашому випадку — моделі URL.

Це означає, що для підтримки відсутніх операцій нам доведеться також перевизначити метод
sync
в моделі URL. З відсутніх операцій ми будемо підтримувати операції створення (
"create"
) та видалення (
"delete"
). Операцію оновлення (
"update"
) не підтримуємо, тому для URL вона у нас не використовується.

На даному етапі нам потрібно провести невеликий рефакторинг нашого коду, поки він ще не занадто розрісся. Справа в тому, що у нас тепер будуть два схожих методу
sync
в різних класах:
Url
та
UrlList
. Обидва вони будуть містити частково один і той же код досить великих розмірів, відрізнятися буде тільки код залежить від типу операції. Тут напрошується об'єднання цих методів в один з поділом логіки в залежності від типу операції (параметр
method
). Тим більше, що в обох методах не використовується покажчик
this
. Питання тільки в тому куди помістити цей метод? Саме розумне рішення — перенести його в клас
Cfg
, оскільки примірник останнього
cfg
використовується в цьому методі. Тоді нам необхідно перейменувати цей метод в інше ім'я (
syncUrls
), оскільки ім'я
sync
вже включена в даному класі, а також замінити в ньому всі посилання на
cfg
покажчиком
this
. Крім того, необхідно перенести туди ж метод
buildUrlAttrs
, який будує атрибути для ініціалізації моделі URL.
В результаті у нас вийде наступне:

Поточний код програми
var Cfg = Backbone.Model.extend({
sync: function(method, model, options){
// будуємо об'єкт параметрів ajax-запиту до нашого API
var params = method == 'read'?
{
type: 'GET', url: 'logger_cfg.php', dataType: 'json'
} :
{// якщо не читати, то create/update
type: 'POST', url: 'logger_cfg.php',
contentType: 'application/json',
data: JSON.stringify(options.attrs || model.toJSON(options))
};
var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
model.trigger('request', model, xhr, options);
return xhr;
},
// будує набір значень атрибутів для ініціалізації моделі URL
buildUrlAttrs: function(url, i){
var title = url.substr(url.indexOf('://')+3);
if (title.length > 35) title = title.substr(0, 35)+ '...';
return {id: url, title: title, i: typeof i != 'undefined'? i+1 : 0};
}
// об'єднаний метод sync класів Url і UrlList:
syncUrls: function(method, model, options){
var resp, errMsg = 'Sync error';
var dfd = Backbone.$ ?
(Backbone.$.Deferred && Backbone.$.Deferred()) :
(Backbone.Deferred && Backbone.Deferred());
var urls = this.get('app_urls'), url;
if (urls)
switch (method) {
case 'read':
// будуємо відповідь на запит читання:
// перетворимо масив app_urls в хеш з наборів атрибутів
resp = _.map(urls, this.buildUrlAttrs);
break;
case 'create': case 'update':
// атрибут URL нової моделі URL:
url = model.attributes.id;
// якщо новий URL не міститься в масиві app_urls:
if (_.indexOf(urls, url) < 0)
// додаємо його в масив app_urls:
this.save({app_urls: _.union(urls, url)});
break;
case 'delete':
// атрибут URL видаляється моделі URL:
url = model.attributes.id;
// якщо новий URL міститься в масиві app_urls:
if (_.indexOf(urls, url) >= 0)
// видаляємо його з масиву app_urls:
this.save({app_urls: _.without(urls, url)});
break;
default:
}
if (resp) {
if (options && options.success) options.success(resp);
if (dfd) dfd.resolve(resp);
} else {
if (options && options.error) options.error(errMsg);
if (dfd) dfd.reject(errMsg);
}
if (options && options.complete) options.complete(resp);
return dfd && dfd.promise();
}
});
var cfg = new Cfg(deftCfg);

var Url = Backbone.Model.extend({
defaults: {
id: ",
title: "
},
sync: function(method, model, options){
return cfg.syncUrls(method, model, options);
}
});

var UrlList = Backbone.Collection.extend({
model: Url,
initialize: function() {
this.listenTo(cfg, 'sync', this.onCfgSync);
},
sync: function(method, list, options){
//
return cfg.syncUrls(method, list, options);
},
onCfgSync: function(model, resp, options) {
// якщо відповідь (resp) не порожній, значить це операція читання
if (resp) {
запускаємо через виклик fetch наш кастомный метод sync
this.fetch();
}
}
});
var urlList = new UrlList();


далі буде...

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

0 коментарів

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