Пример использования Backbone: админка хранилища JS логов. Часть 2

Содержание

 

В этой части статьи мы закончим реализацию моделей и коллекций, начатую в предыдущей части.

Список лог-файлов

Список лог-файлов, в отличие от списка URL, будет «настоящей» коллекцией, связанной с сервером через нестандартное API.

Начнем с объявления модели лог-файла. Состав ее атрибутов в основном определяется API сервера. Поскольку наша коллекция будет поддерживать операцию удаления, модель должна иметь идентификатор — атрибут с именем "id". Для этой роли подойдет имя лог-файла, входящее в число API атрибутов. Кроме того, нам понадобятся два дополнительных атрибута: один — для фильтрации лог-файлов по URL и IP; другой — для отображения выбора лог-файла в списке лог-файлов.

В результате модель Лог-файл будет иметь следующие атрибуты:

  • id — имя лог-файла и по совместительству идентификатор модели;
  • url — URL веб-приложения;
  • time — временная метка (timestamp) создания;
  • ip — IP клиента;
  • useragent — UserAgent клиента;
  • visible — флаг отображения данного лог-файла в списке лог-файлов. Не входит в API;
  • selected — флаг выбора данного лог-файла. Не входит в API.

Т.к. API у нас нестандартное, то мы реализуем/переопределяем метод sync по рассмотренной ранее схеме. API сервера поддерживает 2 операции: чтение и удаление. Операция чтения будет реализована в классе коллекции. В модели же нам нужно реализовать операцию удаления. Это значит, что параметр method в методе sync модели Лог-файл будет принимать только одно значение: "delete".

Класс модели Лог-файл:

var File = Backbone.Model.extend({
  // объект значений по умолчанию
  defaults: {
    id: '',
    url: '',
    time: 0,
    ip: '',
    useragent: '',
    visible: true,
    selected: false
  },
  sync: function(method, model, options) {
    // строим объект параметров ajax-запроса удаления к нашему API
    var params = {
      type: 'POST', url: urlFilelist,
      data: {id: model.attributes.id}
    };
    var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
    model.trigger('request', model, xhr, options);
    return xhr;
  }
});

На основе этой модели создаем коллекцию — Список лог-файлов. В ней мы переопределяем метод sync, реализующий операцию удаления лог-файла. Также мы переопределяем метод parse, потому что формат ответа нашего API на запрос чтения не совпадает с форматом данных для коллекции. Backbone по умолчанию ожидает данные для коллекции в виде массива из наборов атрибутов. API же выдает данные в виде хэша (объекта) из пар «ключ-значение», где в качестве ключа (имени свойства) выступает имя лог-файла, а в качестве значения — массив, содержащий значения атрибутов лог-файла в определенном порядке (см. часть 1). Наш метод parse должен будет переводить данные от API в ожидаемый Backbone формат.

Кроме того, нам нужны вспомогательные методы для фильтрации вывода списка лог-файлов по атрибутам URL и IP, а также для удаления всех видимых (отфильтрованных) лог-файлов. Для фильтрации вывода используем атрибут visible модели лог-файла.

Наконец, поскольку лог-файлы создаются без участия менеджера, нам нужно позаботиться о регулярном обновлении списка лог-файлов. Т.е. необходимо периодически вызывать метод fetch нашей коллекции. Для этого нам придется использовать setTimeout. Можно было бы использовать один из Underscore методов — delay или throttle, но они не совсем подходят к нашему случаю. Дело в том, что, помимо периодических вызовов обновления, у нас также будет применяться немедленный вызов обновления. В таких случаях ближайший вызов через setTimeout должен быть отменен и заменен новым отложенным вызовом, с отчетом от текущего момента. Итак, нам нужен метод, который будет вызывать метод fetch коллекции, после чего будет вызывать сам себя через setTimeout.

Класс и объект коллекции Список лог-файлов:

var FileList = Backbone.Collection.extend({
  model : File,
  initialize: function() {
    // временная метка последнего запроса чтения:
    this.lastfetch = 0;
    // счетчик вызовов метода обновления:
    this.updCount = 0;
    // ID текущего таймаута:
    this.updId = 0;
    // флаг применения обновления:
    this.updFlag = true;
    // устанавливаем обработчик события sync для модели конфигурации:
    this.listenTo(cfg, 'sync', this.onCfgSync);
  },
  parse: function(response) {
    // response - ответ от API на запрос чтения - объект данных
    var aResp = []; // результат - ожидаемый Backbone ответ
    var i = 0; // номер лог-файла в списке лог-файлов
    for (var key in response) {
      // key - имя свойства
      if (typeof key != 'string') continue;
      if (key == 'stamp') {
        // сохраняем временную метку текущего запроса чтения
        this.lastfetch = response.stamp; continue;
      }
      // arr - значение свойства, содержащее значения атрибутов
      var arr = response[key];
      // добавляем набор атрибутов в результат
      aResp.push({
        id: key,
        url: arr[0],
        time: (new Date(arr[1]*1000)).toLocaleString(),
        ip: arr[2],
        useragent: arr[3]
      });
    }
    return aResp;
  },
  sync: function(method, list, options) {
    // параметр method принимает только одно значение - "read"
    // строим объект параметров ajax-запроса чтения к API
    var params = {
      type: 'GET', url: urlFilelist,
      data: {stamp: this.lastfetch},
      dataType: 'json'
    };
    var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
    list.trigger('request', list, xhr, options);
    return xhr;
  },
  onCfgSync: function(model, resp, options) {
    if (!resp) {
      // если ответ пустой, значит это операция сохранения (конфигурации);
      // обновляем список лог-файлов
      this.update();
    }
  },
  // нахождение элемента в списке лог-файлов по его ID
  findById: function(id) {
    return this.findWhere({'id': id});
  },
  // фильтрация списка лог-файлов по атрибуту URL
  filterByUrl: function(url) {
    // url - значение фильтра
    // перебираем элементы списка:
    this.each(function(model) {
      // присваиваем атрибуту visible текущего элемента значение true,
      // если его атрибут url совпадает со значением фильтра, иначе false:
      model.set('visible', !url || model.get('url') == url);
    });
  },
  // фильтрация списка лог-файлов по атрибуту IP
  filterByIp: function(ip) {
    // ip - значение фильтра
    if (!ip) return;
    // перебираем элементы списка:
    this.each(function(model) {
      if (model.get('visible'))
        // если текущий элемент виден в фильтре по URL, то 
        // присваиваем его атрибуту visible true,
        // если его атрибут ip совпадает со значением фильтра, иначе false:
        model.set('visible', model.get('ip') == ip);
    });
  },
  // удаление всех отфильтрованных лог-файлов
  destroyVisible: function() {
    // list - коллекция моделей, соотв-щих отфильтрованным лог-файлам
    var list = this.where({'visible': true});
    var n = list.length;
    if (!n) return;
    // count - счетчик завершенных (успешно или неудачно) запросов удаления
    var count = 0;
    // создаем синоним this, т.к. внутри обработчика always
    // указатель this теряется:
    var self = this;
    // перебираем элементы коллекции:
    _.each(list, function(model) {
      // делаем запрос удаления модели с сервера:
      model.destroy().always(function() {
        // увеличиваем счетчик завершенных запросов удаления;
        // если его значение достигло числа элементов,
        // то значит все запросы завершены,
        // вызываем немедленное обновление списка лог-файлов:
        if (++count >= n) self.update();
      });
    });
  },
  // периодическое/немедленное обновление списка лог-файлов
  update: function() {
    if (this.updId) clearTimeout(this.updId);
    if (this.updCount && this.updFlag) this.fetch({remove: true});
    this.updCount = 1;
    var fn = _.bind(this.update, this);
    this.updId = setTimeout(fn, intFilelistUpdate);
  },
  // установка флага применения обновления
  setUpdFlag: function(b) {
    this.updFlag = Boolean(b);
  }
});
var fileList = new FileList();

Метод update может вызываться, как отложенно (периодически) — через setTimeout, так и немедленно — при наступлении определенных событий, в частности, при удалении URL и лог-файлов. При немедленном вызове мы отменяем ближайший вызов через таймаут. Для этого мы сохраняем ID текущего таймаута (updId). При первом вызове метода обновление не должно применяться. Для этого мы используем счетчик вызовов updIdCount. Также обновление не должно применяться при включенном фильтре по IP. Для этого используется флаг применения обновления (updIdFlag), а также метод для установки этого флага (setUpdFlag), который будет использован позднее. Для передачи контекста this при вызове через setTimeout используем метод _.bind.

Переменная intFilelistUpdate, хранящая значение интервала между вызовами update будет передаваться скрипту из веб-страницы приложения.

Выбор URL и лог-файла. Содержимое выбранного лог-файла

По проекту нашего приложения выбор URL и выбор лог-файлов работают одинаково, поэтому и реализованы они будут по одной и той же схеме — с помощью специальной модели Выбор и представления для нее. Модель Выбор будет хранить значение выбранного элемента. Для выбора URL значением будет собственно URL, а для выбора лог-файла — имя лог-файла. Модель будет синхронизироваться с HTML5 localStorage, так что сделанный выбор будет сохраняться между загрузками/обновлениями страницы. Почему нужно хранить в модели именно значение, а не, скажем, номер выбранного элемента в списке? Для того, чтобы избежать ошибок при восстановлении сохраненного выбора. Нумерация элементов в списке меняется при каждом изменении состава списка, поэтому полагаться на номер элемента нельзя, т.к. при восстановлении мы не сможем гарантировать, что сохраненный номер будет соответствовать выбранному элементу. Значение же может однозначно идентифицировать выбранный элемент, так что, если при восстановлении состав элементов и изменится, то это никак не повлияет на результат восстановления.

Итак, у нас будет одна базовая модель Выбор, реализующая общую функциональность, и две производные от нее модели — Выбор URL и Выбор лог-файла — реализующие свои особенности. Общая функциональность прежде всего включает в себя синхронизацию данных модели с localStorage. Данные модели — это значение выбора. Под синхронизацией понимается сохранение данных в localStorage во время каждого их изменения и восстановление их во время создания/инициализация модели. Т.е. синхронизация у нас не «настоящая» в понимании Backbone. Значит, вместо переопределения метода sync нам нужно реализовать 2 собственные операции: сохранение и чтение из localStorage. Чтение будет выполняться в методе initialize модели, вызываемом во время создания модели. Для сохранения мы напишем отдельный метод, который будет вызываться вместо метода set для установки значения. Кроме того, в общую функциональность будет входить установка нового значения выбора и его применение.

Теперь немного забежим вперед и поговорим о представлениях. Модели выбора (URL и лог-файла) не будут иметь своих представлений. Выбор будет отображаться в представлении каждого элемента списка (URL или лог-файла) с помощью специального CSS-класса, присваиваемого DOM элементу, связанному с представлением. Для этого мы добавим в модели выбора URL и лог-файла дополнительный атрибут, который будет хранить состояние выбора, т.е. выбран ли данный элемент. Назовем этот атрибут selected. По сути именно с ним будет иметь дело модель выбора. Т.е. цепочка будет такой: пользователь делает выбор -> в соответствующей модели выбора изменяется значение выбора -> в соответствии с этим значением модель выбора находит модель элемента списка и устанавливает в нем значение атрибута selected в true -> представление модели элемента списка присваивает связанному DOM элементу CSS-класс.

Для отображения выбора мы будем использовать специальный CSS-класс .active, применяемый в Bootstrap для выделения элементов списка меню.
Выбор URL и лог-файла будем отображать с помощью специального CSS-класса, присваиваемого DOM элементу, соответствующему сделанному выбору (URL или лог-файла).

Класс модели Выбор:

var Selection = Backbone.Model.extend({
  defaults: {
    value: 0 // значение выбора
  },
  // инициализация общей функциональности:
  init: function(keyLs, list) {
    // keyLs - название параметра localStorage для хранения данных модели
    // list - коллекция - список элементов
    // присваиваем keyLs соответ-му свойству модели,
    // если localStorage доступно:
    this.keyLs = keyLs && 'localStorage' in window && window.localStorage?
      keyLs : 0;
    this.list = list;
    this.on('change', this.apply);
    this.listenTo(this.list, 'reset', this.apply);
    this.listenTo(this.list, 'sync', this.apply);
    var v;
    // читаем значение из localStorage, если оно доступно:
    if (this.keyLs && (v = localStorage.getItem(this.keyLs)))
      // и присваиваем это значение атрибуту модели:
      this.set('value', v);
    this.apply();
  },
  // установка нового значения выбора:
  select: function(value, bUseDesel) {
    // value - новое значение выбора
    // bUseDesel - флаг использования снятия выбора
    var b = this.get('value') != value;
    // если новое значение равно текущему
    // и при этом снятие выбора не используется,
    if (!b && !bUseDesel)
      // то завершить метод:
      return;
    // устанавливаем атрибут модели - присваиваем ему новое значение:
    this.set('value', b? value : 0);
    // сохраняем новое значение в localStorage:
    if (this.keyLs) localStorage.setItem(this.keyLs, this.get('value'));
  },
  // общее применение значения выбора:
  apply: function() {
      // значение выбора:
      var v = this.get('value');
      // перебираем элементы списка:
      this.list.each(function(model) {
        // присваиваем значение атрибута selected текущего элемента списка
        // (true для выбранного элемента, false для всех остальных):
        model.set('selected', v && model.get('id') == v);
      });
      this.applyExtra(found);
  },
  // специфическое применение значения выбора:
  applyExtra: function() {
  }
});

Метод applyExtra, переопределяемый в производных от Selection классах моделей, предназначен для выполнения специфических действий при установке/смене значения выбора.

В модели выбора URL специфические действия сводятся к фильтрации списка лог-файлов по значению выбора (URL).

Класс и объект модели Выбор URL:

var UrlSelection = Selection.extend({
  initialize: function() {
    // вызываем метод init базового класса
    this.init('sel-url', urlList);
  },
  applyExtra: function() {
    // вызываем метод фильтрации списка лог-файлов по URL
    fileList.filterByUrl(this.get('value'));
  }
});
var urlSelection = new UrlSelection();

В модели выбора лог-файла специфика применения значения выбора сводится к загрузке содержимого выбранного лог-файла.

Поэтому, перед реализацией модели выбора лог-файла, нам нужно реализовать модель содержимого выбранного лог-файла. Основой для нее будет расмотренная ранее модель лог-файла (File). Модель содержимого будет иметь те же атрибуты, что и модель лог-файла. Но к ним добавятся два дополнительных атрибута: один для хранения содержимого файла (content), другой для хранения полного пути к файлу (path). Все эти атрибуты будут выводиться через шаблон в колонке содержимого. Кроме того нам нужно добавить код загрузки содержимого файла. Этот код можно поместить в модель выбора лог-файла, где он будет вызываться, но будет логичнее и нагляднее, если он будет находиться в модели содержимого. Загружать файл будем с помощью jQuery метода ajax, т.к. стандартные методы Backbone для этого не годятся.

Класс и объект модели Содержимое выбранного лог-файла:

  var FileContent = File.extend({
    initialize: function() {
      // кэш значения выбора (имя лог-файла):
      this.value = 0;
      // кэш модели выбранного лог-файла:
      this.source = 0;
    },
    // загрузка содержимого выбранного лог-файла:
    apply: function(v) {
      // v - новое значение выбора
      // если раньше уже был сделан какой-то выбор,
      // остановить прослушивание событий модели выбранного лог-файла:
      if (this.source) this.stopListening(this.source);
      // находим модель выбора (выбранного лог-файла) по значению выбора,
      // и запоминаем ее:
      this.source = fileList.findById(v) || 0;
      if (!this.source) {
        // если модель выбора непустая,
        // обновляем атрибут visible у модели содержания:
        this.updateVisible();
        return;
      }
      // устанавливаем обработчик события change
      // для атрибута visible модели выбора:
      this.listenTo(this.source, 'change:visible', this.updateVisible);
      if (v == this.value) {
        // если значение выбора не изменилось,
        // обновляем атрибут visible у модели содержания:
        this.updateVisible();
        return;
      }
      // копируем все атрибуты из модели выбора в модель содержания,
      // кроме атрибута selected, и добавляем к ним атрибуты content и path:
      this.set(_.extend(
        _.omit(this.source.attributes, 'selected'),
        {'content': '', 'path': cfg.get('dir') + this.source.get('id')}
      ));
      // делаем AJAX запрос для получения содержимого выбранного лог-файла:
      Backbone.ajax({
        url: this.get('path'),
        type: 'GET', dataType: 'text', context: this,
        success: function(text) {
          // запоминаем значение выбора:
          this.value = v;
          // присваиваем атрибуту content содержимое выбранного лог-файла:
          this.set('content', text);
        }
      });
    },
    // обновление атрибута visible у модели содержания:
    updateVisible: function() {
      // копируем атрибут visible из модели выбора в модель содержания:
      this.set('visible', this.source? this.source.get('visible') : false);
    }
  });
  var fileContent = new FileContent({visible:false});

Теперь, когда у нас есть модель содержимого, мы можем реализовать модель выбора лог-файла.

Класс и объект модели Выбор лог-файла:

var FileSelection = Selection.extend({
  initialize: function() {
    // вызываем метод init базового класса
    this.init('sel-file', fileList);
  },
  applyExtra: function() {
    fileContent.apply(this.get('value'));
  }
});
var fileSelection = new FileSelection();

Фильтр лог-файлов по IP

Фильтр лог-файлов по IP будет реализован в виде модели, хранящей значение фильтра. Работать она будет примерно так же как и модель выбора, за исключением того, что здесь нам не нужно сохранять/восстанавливать значение фильтра. Модель Фильтр лог-файлов будет иметь 2 метода: один — для установки, другой — для сброса фильтра. При установке фильтра будет вызываться метод filterByIp коллекции Список лог-файлов. При сбросе фильтра будет вызываться метод apply модели Выбор URL.

Класс и объект модели Фильтр лог-файлов:

var FileFilter = Backbone.Model.extend({
  defaults: {
    value: false
  },
  initialize: function(){
    // устанавливаем обработчик события change
    // для атрибута visible коллекции списка лог-файлов
    this.listenTo(fileList, 'change:visible', this.reset);
  },
  // установка значения фильтра
  setVal: function(v) {
    this.set('value', v);
    fileList.setUpdFlag(v? true : false);
    v? fileList.filterByIp(v) : urlSelection.apply();
  },
  // сброс значения фильтра
  reset: function() {
    this.setVal('');
  }
});
var fileFilter = new FileFilter();

 

Продолжение следует…