Механизм агрегации объектов — одна из замечательных возможностей моего JavaScript-фреймворка jWidget, которой нет в большинстве других фреймворков. Хочу подробнее о ней рассказать, потому что она помогает с легкостью решать широкий спектр типичных задач, стоящих перед разработчиками клиентов веб-приложений по архитектуре Model-View. Будет мало картинок, зато много интересного кода.

Я кратко описал механизм агрегации объектов в разделе 1. Классы и объекты прошлой статьи. Статья вышла полтора года назад. С тех пор произошло 4 крупных обновления фреймворка, но философия сохранилась. С опытом, было создано несколько потрясающих паттернов структурирования кода на базе механизма агрегации, позволяющих значительно сократить объем кода и сделать его проще. Для начала, напомню, что это такое.



Что такое механизм агрегации объектов



Это подход к контролю над процессом уничтожения объектов. Цитата из прошлой статьи:

Эту идею я почерпнул из введения к книге Приёмы объектно-ориентированного проектирования. Паттерны проектирования от «банды четырех». Там рассказывается, что все указатели на объекты делятся на два типа: агрегирование и осведомленность. Осведомленность обозначает, что объект, владеющий указателем, не несет никакой ответственности за объект, на который он ссылается. Он просто имеет доступ к его публичным полям и методам, но время жизни этого объекта не под его контролем. Агрегирование же обозначает, что объект, владеющий ссылкой, несет ответственность за уничтожение объекта, на который он ссылается. Как правило, агрегируемый объект живет, пока жив объект-владелец, хотя бывают и более сложные случаи.

В jWidget агрегирование реализуется через метод own класса JW.Class. Передав объект B в метод own объекта A, вы сделали объект A владельцем объекта B. При уничтожении объекта A объект B будет уничтожен автоматически. Для удобства, метод own возвращает объект B.


Чтобы извлечь максимальную пользу от механизма агрегации, фреймворк/язык должен предоставлять его реализацию на уровне самого ядра. Так, в C++ механизм агрегации зашит на уровне синтаксиса языка, а в jWidget он зашит на уровне самого базового класса JW.Class, от которого наследуются все остальные классы. Любой объект должен уметь агрегировать, и любой объект можно заагрегировать.

Пример



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

var Book = function() {
    Book._super.call(this);
    this.cover = this.own(new Cover());
};

JW.extend(Book, JW.Class, {
	destroyObject: function() {
		console.log("Destroying book");
		this._super();
	}
});

var Cover = function() {
    Cover._super.call(this);
};

JW.extend(Cover, JW.Class, {
	destroyObject: function() {
		console.log("Destroying cover");
		this._super();
	}
});


В конструкторе книги создается обложка. Обложка агрегируется в книге, поэтому при уничтожении книги автоматически уничтожится и обложка.

var book = new Book();
book.destroy();

// В консоль выведется:
// Destroying cover
// Destroying book


Настоящий пример



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

var MyView = function() {
	MyView._super.call(this);
	$(window).resize(JW.inScope(this._layout, this));
};

JW.extend(MyView, JW.Class, {
	_layout: function() {
		// ...
	}
});


Правда взорвется?
Однажды мы разрабатывали дополнение к SketchUp, и у нас в команде был внештатный документатор. Как-то раз он документировал раздел «Известные баги» в одном из релизов, и прочитал в задаче, что при группировке объектов особого вида они могут неправильно экспортироваться в файл. Он задал мне вопрос: «Ну а что все-таки произойдет, если попытаться экпортировать такие объекты в файл?» Я в шутку ответил: «Ну, не знаю, наверно, все взорвется.» Ну, он так и записал. С тех пор в официальной документации так и написано «Если вы так сделаете, то все взорвется.»


В этом коде, очевидно, не хватает отписки от события «resize» при уничтожении объекта MyView. Уничтожая объект MyView, мы должны быть уверены, что он не оставит за собой никаких следов.

var view = new MyView();
view.destroy();


Но поскольку мы не отписались от события «resize», по нему метод "_layout" мертвого объекта вызываться будет, что может породить side-эффекты неожиданные. Исправим оплошность:

var MyView = function() {
	MyView._super.call(this);
	$(window).bind("resize", JW.inScope(this._layout, this));
};

JW.extend(MyView, JW.Class, {
	_layout: function() {
		// ...
	},
	
	destroyObject: function() {
		$(window).unbind("resize", JW.inScope(this._layout, this));
		this._super();
	}
});


Я намеренно допустил еще одну типичную ошибку: метод JW.inScope каждый раз создает новый экземпляр функции, так что метод «unbind» не сделает ничего. Исправим и это:

var MyView = function() {
	this._layout = JW.inScope(this._layout, this);
	MyView._super.call(this);
	$(window).bind("resize", this._layout);
};

JW.extend(MyView, JW.Class, {
	_layout: function() {
		// ...
	},
	
	destroyObject: function() {
		$(window).unbind("resize", this._layout);
		this._super();
	}
});


Надоело… Куча кода ради такой мелочи? Покончим с этим с помощью агрегации!

var MyView = function() {
	MyView._super.call(this);
	this.own($(window).jwon("resize", this._layout, this));
};

JW.extend(MyView, JW.Class, {
	_layout: function() {
		// ...
	}
});


Метод jwon возвращает подписку на событие, уничтожение которой, очевидно, влечет отписку от события.

Агрегирующие свойства и коллекции



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

var property = new JW.Property().ownValue();
property.set(new SampleValue(1));
property.set(new SampleValue(2)); // SampleValue(1) неявно уничтожается
property.destroy();               // SampleValue(2) неявно уничтожается


Аналогично, вызов метода ownItems у коллекции заставляет ее агрегировать всякий элемент, который в нее добавляют.

var array = new JW.Array().ownItems();
array.add(new SampleValue(1));
array.add(new SampleValue(2));
array.set(new SampleValue(3), 0); // SampleValue(1) неявно уничтожается
array.remove(1);                  // SampleValue(2) неявно уничтожается
array.destroy();                  // SampleValue(3) неявно уничтожается


В последующих примерах мы будет активно пользоваться этими возможностями.

Теперь о паттернах.

Паттерн 1: Простое обновление объекта



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

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

var MyView = function(event) {
    MyView._super.call(this);
    this.content = null;
    this.initContent();
    this.own(event.bind(this.refreshContent, this));
};

JW.extend(MyView, JW.Class, {
    destroyObject: function() {
        this.doneContent();
        this._super();
    },
    
    initContent: function() {
        this.content = new Content();
    },
    
    doneContent: function() {
        this.content.destroy();
    },
    
    refreshContent: function() {
        this.doneContent();
        this.initContent();
    }
});


Если воспользоваться агрегирующим свойством по паттерну простого обновления объекта, этот код можно сократить в 2 раза:

var MyView = function(event) {
    MyView._super.call(this);
    this.content = this.own(new JW.Property()).ownValue(); // ПАТТЕРН ЗДЕСЬ
    this.refreshContent();
    this.own(event.bind(this.refreshContent, this));
};

JW.extend(MyView, JW.Class, {
    refreshContent: function() {
        this.content.set(new Content());
    }
});


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

    refreshContent: function() {
        this.content.set(null);
        this.content.set(new Content());
    }


Паттерн 2: Простая отмена операции



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

Часто, особенно при использовании роутера на базе location.hash или History API, возникает необходимость отменять загрузку покинутой страницы, если пользователь быстро переключается между страницами. Как правило, загрузка представляет собой последовательное выполнение AJAX-запросов, анимаций и прочих асинхронных операций. Кроме того, сама страница время от времени инициирует перезагрузку своих данных. Если пользователь переходит на другую страницу во время выполнения одной из таких операций, ее необходимо отменить.

Для решения этой задачи заведем агрегирующее свойство «currentOperation» и будем помещать туда текущую асинхронную операцию.

var MyPage = function() {
    MyPage._super.call(this);
    this.currentOperation = this.own(new JW.Property()).ownValue(); // ПАТТЕРН ЗДЕСЬ
    this.dataHunkIndex = 0;
};

JW.extend(MyPage, JW.UI.Component, {
    afterRender: function() {
        this._super();
        this._loadData();
    },
    
    _loadData: function() {
        this.currentOperation.set(new Request("/api/data", {hunk: this.dataHunkIndex}, this._onDataLoad, this));
    },
    
    _onDataLoad: function(result) {
        this.currentOperation.set(new Animation("fade in", this._onAnimationFinish, this));
    },
    
    _onAnimationFinish: function() {
        this.currentOperation.set(null);
    },
    
    renderLoadNextHunkButton: function(el) {
        el.jwon("click", this._loadNextHunk, this);
    },
    
    _loadNextHunk: function() {
        ++this.dataHunkIndex;
        this._loadData();
    }
});


Теперь нам не надо беспокоиться о целостности состояния приложения: при переключении между страницами и при нажатии на кнопку «Load next hunk» текущая операция будет отменена, и загрузка новых данных начнется с чистого листа.

Естественно, для каждой такой операции необходимо завести класс, уничтожение которого отменяет операцию. Пример реализации класса Request для AJAX-запроса:

var Request = function(url, data, success, scope) {
    Request._super.call(this);
    this.aborted = false;
    this.success = success;
    this.scope = scope;
    this.ajax = $.ajax({
        url     : url,
        data    : data,
        success : this._onSuccess,
        error   : this._onError,
        context : this
    });
};

JW.extend(Request, JW.Class, {
    destroyObject: function() {
        // При вызове метода "abort" jQuery вызывает обработчик ошибки,
        // что нас никак не устраивает. Заводим для этого флаг
        this.aborted = true;
        
        // Если запрос уже завершился, вызов метода "abort" не навредит.
        // Если нет - завершаем его
        this.ajax.abort();
        
        this._super();
    },
    
    _onSuccess: function(result) {
        this.success.call(this.scope, result);
    },
    
    _onError: function() {
        if (!this.aborted) {
            alert("Request has failed =((");
        }
    }
});


Паттерн 3: Массовое уничтожение объектов



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

Предположим, при обновлении объекта создается множество объектов, которые надо уничтожить при последующем обновлении. Можно ли использовать механизм агрегации, чтобы сократить объем кода в этом случае? Конечно, воспользовавшись паттерном простого обновления объекта несколько раз, мы можем завести несколько свойств, по одному для каждого подъобъекта. Но что делать, если вы не знаете заранее, сколько объектов нужно создать? Это весьма частая ситуация, если объекты создаются какой-то сторонней фабрикой. Рассмотрим следующий код в качестве примера:

var Client = function(event, factory) {
    Client._super.call(this);
    this.factory = factory;
    this.objects = null; // Array
    this.initObjects();
    this.own(event.bind(this.refreshObjects, this));
};

JW.extend(Client, JW.Class, {
    destroyObject: function() {
        this.doneObjects();
        this._super();
    },
    
    initObjects: function() {
        this.objects = this.factory.createObjects();
    },
    
    doneObjects: function() {
        for (var i = this.objects.length - 1; i >= 0; --i) {
            this.objects[i].destroy();
        }
        this.objects = null;
    },
    
    refreshObjects: function() {
        this.doneObjects();
        this.initObjects();
    }
});

var Factory = {
    createObjects: function() {
        return [
            new Object1(),
            new Object2(),
            new Object3()
        ];
    }
};


Очень много непонятного кода: напрашивается рефакторинг. Пусть фабрика возвращает обычный JW.Class, который агрегирует внутри себя все 3 объекта.

var Client = function(event, factory) {
    Client._super.call(this);
    this.factory = factory;
    this.objects = this.own(new JW.Property()).ownValue();
    this.refreshObjects();
    this.own(event.bind(this.refreshObjects, this));
};

JW.extend(Client, JW.Class, {
    refreshObjects: function() {
        this.objects.set(this.factory.createObjects());
    }
});

var Factory = {
    createObjects: function() {
        // ПАТТЕРН ЗДЕСЬ
        var objects = new JW.Class();
        objects.own(new Object1());
        objects.own(new Object2());
        objects.own(new Object3());
        return objects;
    }
};


Паттерн 4: Уничтожение драйвера объекта



Если функция возвращает объект, который имеет свой драйвер, заагрегируйте этот драйвер в самом объекте.

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

Приведу пример драйвера. Пусть где-то на странице есть контрол динамического переключения между цветовыми схемами приложения. Цветовая схема задается словарем «ключ — цвет». Каждая цветовая схема неизменна, но между ними можно переключаться. Подписка на событие изменения выбранной цветовой схемы — это драйвер свойства, содержащего цвет с заданным ключом. Ключ, который нам нужен, мы знаем зараннее; цвет же зависит от выбранной цветовой схемы. Кроме того, при изменении выбранной цветовой схемы свойство с цветом должно автоматически обновляться. Напишем класс такого драйвера.

var ColorDriver = function(selectedScheme, colorKey, color) {
    ColorDriver._super.call(this);
    this.selectedScheme = selectedScheme; // JW.Property<Dictionary>
    this.colorKey = colorKey;
    // Делаем аргумент color необязательным
    this.color = color || this.own(new JW.Property());
    this._update();
    this.own(this.selectedScheme.changeEvent.bind(this._update, this));
};

JW.extend(ColorDriver, JW.Class, {
    _update: function() {
        this.color.set(this.selectedScheme.get()[this.colorKey]);
    }
});


Примечание
В jWidget то же самое можно сделать и без создания класса, если воспользоваться методом selectedScheme.$$mapValue. Но на низком уровне, этот метод все равно использует паттерн уничтожения драйвера, используя JW.Mapper в качестве драйвера. Поэтому в целях демонстрации паттерна вполне разумно отказаться от применения данного метода.


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

var ColorSchemeManager = function() {
    ColorSchemeManager._super.call(this);
    this.selectedScheme = this.own(new JW.Property());
};

JW.extend(ColorSchemeManager, JW.Class, {
    getColorDriver: function(key) {
        return new ColorDriver(this.selectedScheme, key);
    }
});


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

var Client = function(colorSchemeManager) {
    Client._super.call(this):
    this.colorSchemeManager = colorSchemeManager;
};

JW.extend(Client, JW.UI.Component, {
    renderBackground: function(el) {
        var color = this.own(this.colorSchemeManager.getColorDriver("background")).color;
        this.own(el.jwcss("background-color", color));
    }
});


Это будет работать, но у такого решения есть несколько недостатков:

  1. Драйвер совсем не интересует клиента — клиенту нужен лишь цвет
  2. Трудности с полиморфизмом: если в некоторых случаях драйвер нужен, а в других — не нужен, — то для обеспечения полиморфизма вам придется писать драйвер-пустышку, который ничего не делает, а лишь хранит ссылку на цвет
  3. Этот код трудно читать


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

var ColorSchemeManager = function() {
    ColorSchemeManager._super.call(this);
    this.selectedScheme = this.own(new JW.Property());
};

JW.extend(ColorSchemeManager, JW.Class, {
    getColor: function(key) {
        // ПАТТЕРН ЗДЕСЬ
        var color = new JW.Property();
        color.own(new ColorDriver(this.selectedScheme, key, color));
        return color;
    }
});

var Client = function(colorSchemeManager) {
    Client._super.call(this):
    this.colorSchemeManager = colorSchemeManager;
};

JW.extend(Client, JW.UI.Component, {
    renderBackground: function(el) {
        var color = this.own(this.colorSchemeManager.getColor("background"));
        this.own(el.jwcss("background-color", color));
    }
});


Код клиента стал проще и понятнее.

Заключение



С механизмом агрегации объектов вам больше не нужно явно реализовывать и вызывать деструкторы объектов. Реализация деструкторов остается лишь в низкоуровневых классах, таких как AJAX-запрос, подписка на событие и т.д. Классы jWidget реализуют большинство необходимых деструкторов, так что от вас требуется лишь правильно связывать объекты отношением агрегации.

Заменой всех явных вызовов деструкторов классов механизмом агрегации объектов на одном из проектов мне удалось удалить 1000 строк кода из 15000 (выигрыш 7%).

Комментарии (4)


  1. lusever
    15.11.2015 02:59

    Тот же механизм есть в google closure library в методе registerDisposable или addChild. Если сделать dispose родителя, то все потомки уничтожатся. Но там еще ко всему можно переместить ребенка не уничтожая его. В этой реализации я так понимаю нельзя будет сделать.


    1. enepomnyaschih
      15.11.2015 06:59
      +1

      Про registerDisposable не знал. Громкое заявление про уникальность jWidget убрал.

      Давайте обсудим. Судя по реализации, в google closure library ассоциированные объекты уничтожаются в порядке добавления. На мой взгляд, это неудобно. Пример:

      this.registerDisposable(this.p = new Provider()); // какой-то провайдер
      this.registerDisposable(this.c = new Consumer(this.p)); // какой-то консюмер
      this.dispose();
      


      Сначала будет уничтожен провайдер, а потом консюмер. Между этими двумя действиями у консюмера будет битая ссылка на провайдер. Не проблема, если можно просто поменять местами два вызова registerDisposable, но это не всегда можно сделать так просто. Чем они обосновали такую реализацию?

      Есть ли аналог агрегирующего свойства и коллекции?

      Переместить ребенка, не уничтожая его, можно. Как это правильно сделать, зависит от того, куда вы планируете его перемещать. Если просто хотите поменять порядок компонентов в едином контейнере, поместите их в агрегирующий массив и воспользуйтесь методом move. Если вы, скажем, реализуете drag & drop между различными контейнерами, создайте все перетаскиваемые элементы заранее в компоненте страницы или формы, зарегистрируйте их в агрегирующем словаре, а потом сделайте, чтобы контейнеры брали перетаскиваемые элементы из этого словаря. Поскольку элементы из словаря не удаляются при перетаскивании, все будет работать хорошо.


      1. ntstv
        15.11.2015 23:26

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

        Можно написать так:
        this.registerDisposable(this.p = new Provider()); // какой-то провайдер
        this.c = new Consumer(this.p); // какой-то консюмер
        this.p.registerDisposable(this.c);
        this.dispose();
        


        1. enepomnyaschih
          16.11.2015 07:19

          Согласен, но пришлось немного подумать, чтобы обнаружить проблему и найти ее решение. Из-за такой мелочи код на продакшене может упасть в самый неожиданный момент. Уничтожай GCL ассоциированные объекты в обратном порядке, registerDisposable был бы надежнее. Просто делюсь мнением.