Пользователь
0,0
рейтинг
30 июня 2013 в 22:12

Разработка → Построение масштабируемых приложений на TypeScript. Часть 2 — События или зачем стоит изобретать собственный велосипед

В первой части статьи я рассказывал об асинхронной загрузке модулей при помощи Require.js и стандартных языковых средств TypeScript. Неосторожно я раньше времени задел тему организации работы с абстрактными событиями о чем мне очень быстро напомнили в комментариях. В частности, был задан вопрос, зачем придумывать собственный велосипед, если давно существуют проверенный и отлично работающий Backbone.Events и прочие аналоги.

Если вас интересует ответ на этот вопрос, альтернативная реализация на TypeScript и не пугает чтение кода, то прошу под кат.

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

Однако, это далеко не единственна проблема. Что гораздо хуже, в JS очень часто используются примеси, в частности весь Backbone построен на них. Нет, в них нет ничего плохого в самих по себе. Это вполне естественная и жизнеспособная практика в контексте чистого прототипного динамического JS и небольших проектов. В случае TS она приводит к ряду неприятных последствий, особенно при попытке создать приложение хоть сколько-нибудь серьезного размера:

  1. Аналогом примесей в классическом ООП является множественное наследование. Не хотелось бы вступать в «священную войну», но на мой взгляд множественное наследование всегда плохо. Особенно в условиях динамической типизации, без возможности контролировать поведение объектов хотя бы через явную или неявную реализацию интерфейсов а-ля C#. Естественно в JS об этом можно даже не мечтать, поэтому отладка, поддержка и рефакторинг подобного кода — полный кошмар.
  2. Если отвлечься от высоких материй, то TS просто не поддерживает подобное на уровне языка. Бэкбоновский extend это полный аналог наследования в TS и это вполне работает для Model и View, но абсолютно не подходит для событий. Нет, мы конечно можем унаследовать все классы в приложении от Backbone.Event или его аналога в зависимости от фреймворка и добиться результата, но это не решает 3-ей проблемы:
  3. События Backbone или любого другого JS фреймворка не типизированы. Прощай статический анализ и все преимущества TS.


Что вообще такое события и что вообще от них нужно


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

Но все меняется, если появляется некоторый контекст. В нашем случае контекстом является JavaScript, который без любых надстроек в виде TS уже является 100% ООП языком. В частности все сущности в JS это объекты. Так же объектом являются и DOMEvent, создаваемые браузером. Т.е., если продолжить аналогию, то любое событие является объектом.

Допустим, что в случае Backbone событие это тоже объект. Вопрос — а какой? По сути, у нас есть коллекция callback'ов, которые вызываются по тем или иным правилам. Коллекция универсальна. Она способна принять любые функции. Т.е., опять я на этом остановлюсь, у нас нет типизации.

Но постойте. Какова наша цель? Получить статический анализ кода. Значит, событие должно быть объектом и иметь тип — класс. Это первое требование, которое необходимо для достижения результата. События должны быть описаны классами, чтобы их можно было типизировать.

Отсюда вытекает второе требование — события должны обрабатываться и работать однотипно, т.е. наследоваться от базового класса. Если события наследуются, то даже не вникая в дебри SOLID и т.п., ясно, что наследоваться от них совсем плохая идея.

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

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

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

Собрав мысли в кучу и включив KMFDM я приступаю к решению созданных самому себе проблем.

Исходники, по-прежнему на Codeplex: https://tsasyncmodulesexampleapp.codeplex.com

Первые мысли


И так, любой объект, событие это класс и т.д. Это означает 2 вещи: во-первых у нас есть базовый класс Event:

export class Event
{
    //Реализацию временно опускаю
    Add(callback: any): void { /* Делаем полезную работу */ }
    Remove(callback: any): void { /* Делаем полезную работу */ }
    Trigger(): void { /* Делаем полезную работу */ }
}


Во-вторых использовать мы его будем примерно так, аккуратно украдкой посмотрев в сторону C# и вдохновившись его примером:

/// <reference path="Events.ts" />

import Events = require('Framework/Events');

export class MessagesRepo
{
    public MessagesLoaded: Events.Event = new Events.Event();
}

class SomeEventSubscriber
{
    //Не пинайте. Это просто пример
    private MessagesRepo = new MessagesRepo();

    public Foo()
    {
        this.MessagesRepo.MessagesLoaded.Add(function () { alert('MessagesLoaded'); });
    }
} 


Т.е. событие это просто публичный член класса. Не более и не менее. Что нам это дает:

  • События известны на стадии компиляции
  • События могут быть объявлены в любом классе
  • У нас есть минимально необходимый функционал, он сосредоточен в одном месте и легко модифицируется.
  • Все события реализуются одним классом или его наследниками, т.е. мы легко можем поменять логику их работы, например создав потомка SecureEvent, унаследованного от Event, выполняющего callback'и только при определенных условиях.
  • Нет типичного геморроя JS фреймворков с контекстом, который теперь строго зависит от экземпляров объектов, опечатками в названиях событий и т.д.


Чего у нас по-прежнему нет:

1. Строгой типизации
2. Из-за отсутствия контекста, невозможно выполнить отписку от события callback'а, заданного анонимной функцией, т.е. любой callback мы должны где-то сохранять, что неудобно.
3. Не типизированные параметры события

Строгая типизация


Разберемся с первой проблемой. Используем нововведение TypeScript 0.9 — обобщения (generics):

export class Event<Callback extends Function>
{
    //Реализацию все еще опускаю
    Add(callback: Callback): void { /* Делаем полезную работу */ }
    Remove(callback: Callback): void { /* Делаем полезную работу */ }
    Trigger(): void { /* Делаем полезную работу */ }
}


И посмотрим на применение:

/// <reference path="Events.ts" />

import Events = require('Framework/Events');

export class MessagesRepo
{
    public MessagesLoaded: Events.Event<{ (messages: string[]): void }> 
        = new Events.Event<{ (messages: string[]): void }>();
}

class SomeEventSubscriber
{
    //Не пинайте. Это просто пример
    private MessagesRepo = new MessagesRepo();

    public Foo()
    {
        this.MessagesRepo.MessagesLoaded.Add(function (messages: string[]) { alert('MessagesLoaded'); });
    }
}


При этом, следующий код:

public Foo()
{
    this.MessagesRepo.MessagesLoaded.Add(function (message: string) { alert('MessagesLoaded'); });
}


Выдаст ошибку:

Supplied parameters do not match any signature of call target:
Call signatures of types '(message: string) => void' and '(messages: string[]) => void' are incompatible:
Type 'String' is missing property 'join' from type 'string[]'


А callback без параметров (ну не нужны они нам), скомпилируется спокойно:

public Foo()
{
    this.MessagesRepo.MessagesLoaded.Add(function () { alert('MessagesLoaded'); });
}


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

Анонимные callback'и и возврат состояний подписки

Как я уже писал выше, при данной реализации мы не можем отписать анонимные callback'и, что приводит к абсолютно несвойственной для лаконичного JS с его анонимными функциями многословности и объявлению лишних пременных. Например:

private FooMessagesLoadedCallback = function () { alert('MessagesLoaded'); }

public Foo()
{
    this.MessagesRepo.MessagesLoaded.Add(this.FooMessagesLoadedCallback);
}


На мой взгляд это полный энтерпрайз головного мозга и убийство всех функциональных черт JS/TS.

Тем не менее, без отписки от событий не обойтись в любом более-менее сложном приложении, т.к. без этого невозможно корректно уничтожать сложные объекты и управлять поведением объектов, участвующих во взаимодействии через события. Например, у нас есть некоторый базовый класс формы FormBase, от которого унаследованы все формы в нашем приложении. Предположим, что у него есть некоторый метод Destroy, который очищает все ненужные ресурсы, отвязывает события и т.д. Классы-потомки переопределяют его при необходимости. Если все функции сохранены в переменных, то нет никакой проблемы передать их событию, а у события через равенство типов нет никакой проблемы определить callback и удалить его из коллекции. Данный сценарий невозможен при использовании анонимных функций.

Я предлагаю решать вторую проблему следующим путем:

export class Event<Callback extends Function>
{
    public Add(callback: Callback): ITypedSubscription<Callback, Event<Callback>>
    { 
        var that = this;

        var res: ITypedSubscription<Callback, Event<Callback>> =
        {
            Callback: callback,
            Event: that,
            Unsubscribe: function () { that.Remove(callback); }
        }

        /* Делаем полезную работу */

        return res;
    }
    public Remove(callback: Callback): void { /* Делаем полезную работу */ }
    public Trigger(): void { /* Делаем полезную работу */ }
}

/** Базовый интерфейс подписки на событие. Минимальная функциональность. Можем просто отписаться и все. */
export interface ISubscription
{
    Unsubscribe: { (): void };
}

/** Типизированная версия. Включает ссылки на событие и callback */
export interface ITypedSubscription<Callback, Event> extends ISubscription
{
    Callback: Callback;
    Event: Event;
}


Т.е просто возвращаем в методе Add ссылку на событие, callback и обертку для метода Remove. После этого остается реализовать элементарный «финализатор» у подписчика:

/** Кстати, такие комментарии опознаются IntelliSense ;) */
class SomeEventSubscriber
{
    private MessagesRepo = new MessagesRepo();

    /** Тут будем хранить все подписки нашего класса */
    private Subscriptions: Events.ISubscription[] = [];

    public Foo()
    {
        //Одним движение регистрируем подписку одного события
        this.Subscriptions.push(this.MessagesRepo.MessagesLoaded.Add(function () { alert('MessagesLoaded'); }));
        //И совершенно другого
        this.Subscriptions.push(this.MessagesRepo.ErrorHappened.Add(function (error: any) { alert(error); }));
    }

    /** Просто проходит по массиву подписок и отписывает все события независимо от типа */
    public Destroy()
    {
        for (var i = 0; i < this.Subscriptions.length; i++)
        {
            this.Subscriptions[i].Unsubscribe();
        }

        this.Subscriptions = [];
    }
}


Типизация параметров события


Все очень просто. Опять используем обобщения:

export class Event<Callback extends Function, Options>
{
    public Add(callback: Callback): ITypedSubscription<Callback, Event<Callback, Options>>
    { 
        var that = this;

        var res: ITypedSubscription<Callback, Event<Callback, Options>> =
        {
            Callback: callback,
            Event: that,
            Unsubscribe: function () { that.Remove(callback); }
        }

        /* Делаем полезную работу */

        return res;
    }
    public Remove(callback: Callback): void { /* Делаем полезную работу */ }
    public Trigger(options: Options): void { /* Делаем полезную работу */ }
}


Класс-издатель теперь будет выглядеть так:

export interface ErrorHappenedOptions
{
    Error: any;
}

export class MessagesRepo
{
    public MessagesLoaded: Events.Event<
        { (messages: string[]): void } //Callback
        , string[]> //Options
        = new Events.Event<{ (messages: string[]): void }, string[]>();
    public ErrorHappened: Events.Event<
        { (error: ErrorHappenedOptions): void }, //Callback
        ErrorHappenedOptions> //Options
        = new Events.Event<{ (error: ErrorHappenedOptions): void }, ErrorHappenedOptions>();
}


А вызов события так:

var subscriber: Messages.SomeEventSubscriber = new Messages.SomeEventSubscriber();

subscriber.MessagesRepo.MessagesLoaded.Trigger(['Test message 1']);
subscriber.MessagesRepo.ErrorHappened.Trigger({
    Error: 'Test error 1'
});


На этом мои хотелки к событиям заканчиваются. За полными исходными кодами и действующим примером прошу на Codeplex.

Всем спасибо за положительную оценку первой части.

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

Работа над ошибками согласно комментариям:
Часть 2.5: Построение масштабируемых приложений на TypeScript — Часть 2.5. Работа над ошибками и делегаты
Андрей Зорин @Keeperovod
карма
14,0
рейтинг 0,0

Самое читаемое Разработка

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

  • 0
    Интересно. Пишите еще. Особенно про управление памятью ).
    • 0
      Думаю, что смайлик тут лишний) Откровений не обещаю, но в асинхронном коде «висячие» ссылки на объекты — дикая проблема. Про нее, как минимум, надо помнить.
  • +1
    А нельзя ли как-то указать что тип Options — это параметрический тип Callback?
  • +2
    А почему класс события параметризируется типом Callback'a? Почему бы не параметризовать его типа данных, с которыми вызывается callback? Типа того:
    interface IEvent<Data> { on(fn:(Data) => boolean): ISubscribe { } trigger(d: Data): boolean { } }
    • 0
      Жаль, мне кармы не хватает, чтобы вам ее поднять. Хоть кто-то заметил.

      Будет часть 2.5 с исправлением. Там все чуть сложнее, чем вы написали. Сам только утром увидел. Клятвенно обещаюсь больше на ночь глядя сложные статьи не писать.
      • 0
        Нет, пишите; лучшее — враг хорошего и велика вероятность, что статья так и не была бы написана.
      • 0
        А я уж думал, что это какая-то особая типовая дотнет-магия, которую нам, html-программистам, не понять :)
        • 0
          Магия есть. И не мало. См. Часть 2.5. Но ничего, что было бы за пределами понимания :)
  • 0
    Круто! Сам параллельно прохожу аналогичный процесс разработки.

    По коду: лучше бы не писать аннотацию типа там, где она очевидна, например: «public MessagesLoaded: Events.Event = new Events.Event(); », и уж тем паче с обобщенными типами.
    • 0
      В итоге тоже к этому пришел. TS без проблем по правой части выражения вычисляет. В любом случае, спасибо за замечание.
      • 0
        И не только по правой части. Я себе реализовал аналог Linq и при этом получается вот такой типизированный код на TS:

        data.document.p.where(paragraphCondition)
            .selectMany(p => p.s.where(sentenceCondition))
            .selectMany(s => s.l)
            .select(l => data.vocabularyItems.firstOrDefault(vi => matchesLexem(vi, l)))
            .where(vocabularyLexem => vocabularyLexem && vocabularyLexem.level < 10 && !alreadyPresent(vocabularyLexem))
            .distinct(vocabularyLexem => vocabularyLexem.descriptor.presentation)
            .foreach(vocabularyLexem => this.vocabularyItems.push(vocabularyLexem));
        


        Где и в последней строчке TS знает правильный тип vocabularyLexem.
        • 0
          Честно говоря, тут уже рождается вопрос про велосипеды и их изобретение. А зачем в TS/JS LINQ? Сто лет есть underscore.js, который все тоже самое делает, типизирован в TS 0.9 и понятен любому фронтэнд разработчику.
          • 0
            Да, наверное :) Я не фронтенд-разработчик, ну и тут дело вкуса, есть и других портов Linq куча.
            • 0
              Не сказал бы, что совсем уж вкуса. При переносе каких-то решений из мира .Net в веб есть один очень серьезный подводный камень — в JS нет типов и, как следствие, рефлексии.

              Поэтому если LINQ долежен переноситься вполне адекватно, т.к. это чисто compile time, то многие другие, опирающиеся на рефлексию подсистемы нет. Например, при все схожести шаблонизации ASP.Net MVC c underscore.template по своему подходу, перенести те же практики «в лоб» проблематично из-за невозможности использовать рефлексию для определения типов полей модели, и, как следствие, перехода к куда более тяжелому синтаксису, вместо Html.EditorFor. Хотя о моих размышлениях на эту тему скорее всего как раз следующая статья и будет.

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