28 июня 2013 в 14:08

Построение масштабируемых приложений на TypeScript. Часть 1 — Асинхронная загрузка модулей из песочницы

Идея данной статьи родилась после тяжелого рабочего дня при 30 градусах в офисе и тяжких раздумий и холиваров на тему: «А как должно строиться современное веб-приложение?»

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

О чем пойдет речь в данной статье? Я напишу (не)большое приложение на TypeScript, которое будет реализовывать модульную архитектуру, асинхронную загрузку модулей, абстрактную событийную модель и обновление состояния модулей по наступлению определенных событий. Эта статья будет выступать как бы дневником и журналом моих действий и размышлений. Моя личная цель — создать некоторый рабочий прототип, опыт создания которого я потом мог бы использовать в рамках реального проекта. Код будет писаться максимально аккуратно и близко к требованиям реальной разработки. Пояснения будут даваться так, будто это потом будут читать работающий под моим руководством джуниоры, которые вообще до этого никогда такие системы не писали.

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

Итак, дав себе и сообществу эти обещания, включив AC/DC и собравшись с мыслями я приступаю.

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

Часть 2.5: Построение масштабируемых приложений на TypeScript — Часть 2.5. Работа над ошибками и делегаты

Используемое ПО и прочие замечания

В рамках данной статьи я буду использовать в качестве рабочего инструмента Visual Studio Express 2012 for Web со всеми последними обновлениями. Причина — это единственный IDE с адекватной поддержкой TypeScript на сегодня.

По поводу самого TypeScript. Трехмесячный опыт использования TS 0.8.3 показывает, что TS это реально работающий инструмент для создания действительно больших приложений для веб, насчитывающих десятки тысяч строк кода. Статический анализ кода реально уменьшает количество ошибок на порядок. Также с нами почти полноценный классический ООП, реальная модульность на уровне языка, позволяющая прозрачно интегрироваться с Require.js и Node.js, IntelliSense в Visual Studio да и вообще идеология явно отдающая C#, что крайне мне близко. Прозрачная трансформация TS в JS позволяет элементарно отлаживать код даже без помощи sourcemaps, хотя и они присутствуют. Для написания статьи я буду использовать последнюю версию — 0.9 с generics и прочими плюшками.

Require.js будет использоваться для реализации асинхронной загрузки. Node.js будет использоваться для имитации серверной части.

В качестве «стандартной библиотеки» в проект будут подключены jQuery 1.10 (Совместимость с IE8 нам нужна) и Underscore.js. Основой для интерфейса для нас послужит Backbone.js. Заголовочный d.ts файлы используем из стандартной поставки TS (jQuery) и из проекта DefinitelyTyped — github.com/borisyankov/DefinitelyTyped (Require.js, Underscore, Backbone, Node.js).

Для воспроизведения AC/DC используется WinAmp.

Чуть-чуть о TypeScript

Программа на TS состоит из набора *.ts и *.d.ts файлов. Их можно представить как некоторые аналоги *.cpp и *.h файлов из С++. Т.е. первые содержат реальный код, вторые описывают интерфейсы, которые предоставляет одноименный файл реализации. Для разработки чисто на TS заголовочный d.ts файлы не нужны. Также d.ts ни в чего не компилируется и не используется в рантайме. Но d.ts файлы незаменимы для описания существующего JS кода. Т.к. TS полностью преобразуется в JS, он может полноценно использовать любой JS код, но компилятору необходимо знать о тех типах, переменных и функциях, которые используются в JS. D.ts файлы как раз служат целям этого описания. Подробнее останавливаться не буду, все есть в спецификации TS.

В общем случае при компиляции example.ts могут быть созданы следующие файлы:
  • example.d.ts — заголовочный файл для использования в других проектах
  • example.js — JS для использования в рантайме
  • example.map.js — sourcemap для отладки


Структура проекта

Создадим проект TypeScript в Visual Studio:

image

Все исходники я буду публиковать на CodePlex: tsasyncmodulesexampleapp.codeplex.com.

Добавляем код по-умолчанию, создаем 2-й проект — Server, подключаем все необходимые исходные файлы библиотек и получаем следующую структуру:

image

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

Здесь следует чуть-чуть поговорить о том, как собирает проект TypeScript в Visual Studio и без него. В первом случае студия все делает за разработчика, передавая компилятору файлы по одному, если на них нет ссылок. Т.е. все ts файлы всегда будут скомпилированы. Если мы будем собирать проект из командной строки, то необходимо, чтобы сборка начиналась с файла имеющего ссылки на все остальные файлы. Обычно я создаю в корне проекта файл Build.d.ts, содержащий ////>, т.е. ссылки на все файлы проекта, которые необходимо собрать и передаю его компилятору через консоль, т.к. этот путь позволяет куда более гибко управлять настройками компилятора TS, нежели текущий плагин в студии, что нам обязательно потребуется в дальнейшем.

Node.exe и полный дистрибутив TS добавлены для того, чтобы не завязываться на студию и прочее установленное ПО при ознакомлении с проектом. Cmd файлы для удобного запуска и интеграции со студией я напишу позже.

Описание учебной задачи

В качестве учебного примера я буду писать простой клиент для системы личных сообщений на сайте, состоящей из нескольких независимых интерфейсных компонентов, некоторого промежуточного слоя для загрузки данных на сервер и фэйкового сервера для имитации бурной деятельности. Общение с сервером будет происходить через restful сервисы, качественное написание которых не является основной задачей. Достаточно, чтобы они просто работали. В системе будет 2 экрана — краткий список из последних 3-х сообщений и экран полноценного клиента. Также будет некоторое меню, которое позволит переключаться между ними. Авторизацию и т.п. в данной статье я рассматривать не буду.

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

Все очень просто. В файл App.ts проекта Client, который мы получили по-умолчанию добавляем следующий код:

export class App
{
    public static Main(): void
    {
        alert('Main');
    }
}


Создаем файл RequireJSConfig.ts в корне проекта:

/// <reference path="../Lib/require.d.ts" />

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

require(["App"], function(App: any) 
{
    App.App.Main();    
});


Default.htm:

<!DOCTYPE html>

<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>TSAsyncModulesExampleApp</title>
    <link rel="stylesheet" href="app.css" type="text/css" />
    <script src="js/jquery-1.10.1.min.js"></script>
    <script src="js/underscore-min.js"></script>
    <script src="js/backbone.js"></script>
    <script src="js/require.js" data-main="RequireJSConfig"></script>
</head>
<body>
</body>
</html>


Запускаем приложение:

image

Поздравляю, мы получили приложение с асинхронно загружаемыми модулями.

Остановимся на том, что мы сделали поподробнее.

Во-первых, стоит остановиться на том, как компилируются файлы в TS. Если файл содержит директиву export, то это означает, что он однозначно будет скомпилирован в модуль CommonJS или AMD в зависимости от настроек компилятора. По-умолчанию, в VS компиляция идет в формате AMD, что нас более чем устраивает в контексте использования Require.js. TS полностью избавляет нас от необходимости писать «жуткий» ручной код обертки AMD модулей и сам заботится о корректной установке зависимостей. Именно такое поведение мы наблюдаем для App.ts:

define(["require", "exports"], function(require, exports) {
    var App = (function () {
        function App() {
        }
        App.Main = function () {
            alert('Main');
        };
        return App;
    })();
    exports.App = App;
});
//@ sourceMappingURL=App.js.map


RequireJSConfig.ts не содержит директив export и компилируется в обычный «плоский» JS:

/// <reference path="../Lib/require.d.ts" />
/// <reference path="App.ts" />
require(["App"], function (App) {
    App.App.Main();
});
//@ sourceMappingURL=RequireJSConfig.js.map


Комментарии остаются в коде исключительно в целях отладки, т.к. у меня стоит Debug режим сборки приложения. В Release конфигурации все будет очищено от комментариев.

Но, вернемся к нашим бар… модулям. Что же произошло:

  1. Загрузился default.htm
  2. Загрузились css и статически заданные js файлы, которые нет никакого, на мой субъективный взгляд, смысла грузить асинхронно, т.к. они нужны почти всем модулям, которые мы будем создавать.
  3. Среди js в п.2 загрузился require.js
  4. Require.js прочитал значение аттрибута data-main=«RequireJSConfig» и загрузил соответствующий JS файл, который трактуется как стартовый.
  5. В RequireJSConfig мы первый и последний раз используем метод require в глобальном контексте. Далее все вызовы модулей должны происходить из других модулей.
  6. В функции require мы говорим, что после загрузки модуля App (первый параметр), необходимо вызвать callback, куда передать загруженный модуль в виде одноименной переменной. Тут мы идем на сделку с совестью и не типизируем ее в TS, т.к. в данном месте происходит конфликт между идеологией разбиения на модули в TS и конкретной реализаций Require.js, как менеджера асинхронной загрузки скриптов. Подробнее чуть ниже.
  7. Require.js загружает модуль App. Об соглашениях об именовании модулей детально можно прочитать в документации к Require.js. Если кратко, то указываем путь от корня, который мы можем указать отличным от корня сайта, который мы используем по-умолчанию, опуская расширение файла. Далее RequireJS загружает каждый зависимый файл как тэг script, используя head.appendChild(). Загрузка требуемого модуля происходит последней, т.е. после зависимостей, что означает, что мы всегда можем быть уверены в том, что все зависимости всегда загружены. Require.js и TS работают в данном вопросе полностью согласованно. Синтаксис и процесс компиляции TS специально адаптированы для данного сценария.
  8. Callback, переданный в метод require, вызывает статический метод Main, класса App, модуля App.
  9. Вызывается alert('Main');


Параметр callback'а метода require не типизируется из-за того, что данный тип не известен в данном контексте компилятору TS. Чтобы его типизировать, необходимо использовать конструкцию TS следующего вида:

import App = require('App');


Эта конструкция может быть использована только в контексте модуля для загрузки других модулей и приводит к формированию соответствующих зависимостей в обертке AMD/CommonJS модуля, которого у нас тут нет, т.к. у нас обычный плоский код, без оберток, т.е. получаем противоречие. Иными словами, TS полностью согласован с Require.js в области генерации кода модулей, но никак не поддерживает сценарий загрузки первого модуля. Поэтому в данной ситуации единственное, что нам остается — откатиться к использованию старого доброго подхода в стиле ванильного JS.

Загрузка дополнительных модулей

Итак, первый модуль мы загрузили, метод Main вызвали, приложение начало работать. Согласно нашему учебному ТЗ, мы должны уметь грузить произвольное количество модулей. Но перед тем, как приступить к загрузке, надо понять, что такое модуль в TS:

  • Во-первых, это автономная часть приложения, кусок кода реализующий паттерн модуля, экспортирующий функции и переменные наружу. При этом вся реализация скрыта от внешнего мира. Фактически, это инкапсуляция в чистом виде.
  • Во-вторых, модуль после загрузки присваивается переменной. Т.е. мы можем работать с ним, как с объектом. По-сути, модуль и есть объект в терминах JS.
  • В-третьих, модуль это файл.
  • В-четвертых, модуль это пространство имен для TS. Могут быть неэкспортируемые модули, объявленные внутри других модулей, реализующие пространства имен в чистом виде. Т.е. модули могут иметь неограниченное количество уровней вложенности, но только модуль верхнего уровня может быть преобразован в AMD/CommonJS модуль и приведен к автономному файлу.


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

Создадим новый файл Framework/Events.ts, который в дальнейшем будем использовать в качестве стартовой точки для реализации нашей абстрактной событийной модели:

export interface IEventPublisher
{
    On();
    Off();
    Trigger();
}

export class EventPublisherBase implements IEventPublisher
{
    On() { }
    Off() { }
    Trigger() { }

    constructor()
    {
        alert('EventPublisher');
    }
}


Пока все методы являются просто заглушками. Нам важен сам принцип работы.

Изменим App.ts:

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

export class App
{
    public static Main(): void
    {
        var e = new Events.EventPublisherBase();
    }
}


У нас появилась директива загрузки модуля:

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


Путь указывается от корня. Как было написано выше, модуль присваивается переменной. Далее мы создаем новый экземпляр класса EventPublisherBase, из пространства имен Events, в конструкторе которого у нас объявлен alert:

При этом переменная Events строго типизирована. Компилятор строго отслеживает типы загружаемых модулей. Следует отметить, что компилятору не нужна деректива reference для контроля типов в модулях, но она необходима ему для корректной компиляции. Т.е. компилятор не сможет отследить зависимости без нее. Сейчас за компилятор работу делает VS, подставляя ему имена файлов в явном виде. Решить это можно создав уже упоминавшийся файл Build.d.ts запуская компиляцию относительно него:

/// <reference path="Framework/Events.ts" />
/// <reference path="App.ts" />
/// <reference path="RequireJSConfig.ts" />


Пример cmd файла(Build-Client.cmd), который можно использовать для компиляции, приложен в корне решения.

Собственно, в части асинхронной загрузки это все. Все очень просто и просто работает.

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

Всем спасибо за то, что вы дочитали до конца!
Андрей Зорин @Keeperovod
карма
14,0
рейтинг 0,0
Похожие публикации
Самое читаемое Разработка

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

  • 0
    Спасибо, как раз хотел поразбираться с этим всем.

    Не очень только понятно, зачем вы делаете свой EventPublisherBase, если есть прекрасный Backbone.Events?
    • 0
      На самом деле бэкбон скорее всего будет использоваться, но строго как реализация. Примеси в стиле Js крайне плохо вписываются в классический ООП. Грубо говоря, мне надо объявить интерфейс, сделать фейковую его реализацию, а потом в рантайме примешивать реализацию. Это напрочь убивает все преимущества классического ООП и статической типизации в TS. На мой взгляд должна существовать некая обертка, позволяющая контролировать типы параметров событий и т.п. на уровне кода. Более развернуто постараюсь ответить на этот вопрос в продолжении статьи, как только хватит времени ее дописать.
      • 0
        Примеси в стиле Js крайне плохо вписываются в классический ООП.
        напрочь убивает все преимущества классического ООП и статической типизации в TS


        Ну вот вся суть как раз в том, что JS — не классический ООП. И как по мне, в погоне за статической типизацией и «преимуществами классического ООП» (а они точно здесь преимущества?) зачастую теряется экспрессия фич самого языка. Это другой язык — почему все вечно пытаются сделать из него «полноценное ООП»?
        • 0
          На мой взгляд, во-первых, js это полноценный ООП язык. У него свой уникальный подход, но наличия наследования, полиморфизма и инкапсуляции это не отменяет.

          Во-вторых, почему я хочу классическое ООП? Ровно из-за одного — статического анализа кода. Я хочу видеть ошибки еще до запуска приложения. Это на порядки экономит время разработки. В моей практике .net разработчика бывали ситуации, когда код, который писался по несколько дней, запускался и работал с первого раза и без ошибок. Если нет ошибок в алгоритме, то остаются только описки, которые и должен ловить компилятор. Поэтому я сразу и влюбился в TS. Динамические языки хороши, пока вы не пишете приложения, в которых формы не имеют по несколько тысяч строк кода. По моей практике получается, что +-500 строк на JS это предел объема, когда код еще читабелен. На языках с классами и статической типизацией эта цифра где-то на порядок выше. Для меня это важно.
          • 0
            >>> в которых формы не имеют по несколько тысяч строк кода

            Может быть дело в этом?
            • 0
              В каком смысле? Для каждого сценария свой инструмент. В «ванильном» виде JS очень слабо применим для реальных приложений, в которых объем кода исчисляется десятками тысяч строк. Для элементарного UI на пару десятков полей напротив нет смысла изобретать велосипед.

              • 0
                Да не важно количество — важно качество. Не спорю с тем, что для каждой задачи свой инструмент, но говорить, что тот или иной язык слабо применим для той же задачи, которую он решает, но если задача в большем масштабе — слишком уж спорный аргумент. Архитектура — наше все. Правильно спроектированное приложение — залог успеха. Не компилятор помогает писать хороший софт.
                • 0
                  Вы никоим образом не опровергаете того, что JS не был спроектирован для написания больших приложений и ошибки его проектирования мы сейчас вынуждены решать, подставляя различные костыли :)
          • 0
            Сам работал на 90% только с .NET 6 или 7 лет подряд, пока окончательно не перешел на альтернативные платформы, в частности, node.js. И чувствую себя прекрасно без статической типизации.

            во-первых, js это полноценный ООП язык

            По сути, так и есть, но существуют особенности языка, которые можно использовать себе во благо. Или которые не получится использовать, если смотреть на язык, как на «классическое ООП».

            Во-вторых, почему я хочу классическое ООП? Ровно из-за одного — статического анализа кода.

            И как это связано? JSHint/JSLint/Closure compiler, не?

            Динамические языки хороши, пока вы не пишете приложения, в которых формы не имеют по несколько тысяч строк кода.

            Это прям реально, реально проблема архитектуры, но никак не динамических языков, и JS в частности.
            • 0
              Инструмент зависит от задачи. В конкретно моей, из которой выросла статья, типизация реально помогает. М/б вам просто везет, что у вас нет рефакторинга из-за регулярных изменений требований.

              По сути, так и есть, но существуют особенности языка, которые можно использовать себе во благо. Или которые не получится использовать, если смотреть на язык, как на «классическое ООП».


              Безусловно, нюансы есть. Более того, я искренне люблю JS и не имею никаких предпочтений между ним и C#. Для каждой задачи свой инструмент. Поэтому вдвойне люблю TS, т.к. он их прекрасно сочетает.

              И как это связано? JSHint/JSLint/Closure compiler, не?


              Не холивора ради, но как любое из этих средств проверит соответствие типов? Если трезво подойти к проблеме статического анализа, то ни одна утилита не может проверить то, что не знает. Например, типы или обязательность параметров в функциях. Ну, или никогда не сможет проверить на 100%. Безусловно, все перечисленное вами ПО работает, но это только половина дела. вторую делает TS, вводя типы и т.д.

              Это прям реально, реально проблема архитектуры, но никак не динамических языков, и JS в частности.


              Проблема конкретно JS в том, что конкретно на нем большие листинги нечитаемы. Молчу про сплошные костыли с модульностью и т.п. Многое можно нивелировать архитектурой, стандартами кодирования и т.п., но в любом случае мы получим либо слабочитаемый листинг, либо тучу комментариев. Естественно, тут я немного преувеличиваю, но в реальном мире действительно хороший код на JS попадается исчезающе редко. Иначе — написать неудобочитаемый код на JS в разы легче чем на любом статически типизированном языке.
  • 0
    Почему не стали использовать knockout?
    • 0
      Мне он не нравится. Честно. Больше никаких причин. Статья получилась как бы потоком мыслей. Я не ставил себе цели детальное сравнение фреймворков. Есть мысль в будущем заняться сравнением с точки зрения совместимости фреймворков с классическим ООП typescript. Бэкбон, честно говоря, далеко не идеально сочетается из-за тотального использования примесей.

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