image

О чём речь?


Этим постом я планирую начать целый цикл статей об изоморфном фреймворке Catberry.js. В этих статьях я, как автор, подробно расскажу о внутреннем устройстве фреймворка, какие у него есть уникальные возможности, опишу некоторые рецепты и лучшие практики по разработке проектов на нём.

Вы могли видеть обзорную статью о Catberry.js, написанную еще в ноябре 2014. С тех пор много чего изменилось: фреймворк шагнул на две мажорных версии, обрел новые подходы и архитектуру, основанную на Flux и веб-компонентах.

Про Flux есть неплохой перевод статьи на хабре. А про веб-компоненты думаю стоит упомянуть, что их не так давно стали активно продвигать ребята из Google и создали реализацию под именем Polymer. В Catberry есть своя реализация этих двух подходов со своими особенностями, о которых и хочется рассказать в этом посте.

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

Почему Flux и Веб-компоненты?


Если вы читали упомянутую старую статью про Catberry.js, где было хвалебное описание подхода Service-Module-Placeholder использованного ранее, то первый вопрос, который у вас скорее всего возникнет: «А почему всё изменилось, ведь прежний подход так нравился?». Все достаточно просто – после написания большого проекта на той версии Catberry мы с командой осознали, что он недостаточно декомпозирует задачи. То есть модули становятся очень большими и в них, в конце концов, очень тяжело ориентироваться. Получается тот же самый «толстый контроллер», как это бывает в MVC. В инженерном деле, как я считаю, главное быть максимально объективным и уметь признавать свои ошибки, поэтому я начал исследовать, какие подходы появились в индустрии и наткнулся на Flux и веб-компоненты.

Мне подход к разработке на веб-компонентах очень понравился. Очень легко разработать маленький и независимый веб-компонент (контрол/виджет) для веб-приложения, отточить его функционал до совершенства и использовать как кирпичек в построении уже большого веб-приложения. Работать над маленьким и независимым блоком намного проще, чем держать большую сложную систему в голове, что совершенно очевидно. На момент написания этой статьи наша команда разработки уже написала достаточно большой проект, и этот подход себя полностью оправдал. К тому же, когда у нас появился новый член команды, через 2-3 рабочих дня он уже начал самостоятельную разработку фич большого проекта, до этого ничего не зная про Catberry, Flux и веб-компоненты. Я считаю это важный показатель.

Flux в Catberry.js


Flux – это, если коротко, такая методология, которая показывает нам как можно безопасно обрабатывать события в сложном приложении. Есть мнение, что это просто новое модное название для паттерна Observer и ничего нового это не вносит. Я считаю, что тут есть важный момент – Flux говорит нам о диспетчере, который контролирует весь поток событий и не дает им хаотично и неконтролируемо возникать и обрабатываться.

Вводятся такие вот абстракции и взаимодействие между ними:

image

image

В нашем случае на месте View будут веб-компоненты.

Что это значит на практике?

Представим, что у вас в catberry-приложении есть несколько компонентов на странице, которые зависят от одного и того же Store. При переходе в другое состояние приложения все эти компоненты одновременно вызовут получение обновленных данных из одного и того же Store. Благодаря диспетчеру в Catberry, до Store дойдет всего лишь первый запрос данных. Остальные веб-компоненты получат тот же самый ответ от первого запроса, потому что умный диспетчер обработает эту ситуацию. Если бы не было диспетчера событий, это повлекло бы за собой множество одинаковых запросов к RESTful API, откуда Store загружает данные, что избыточно. Второй важный момент где нужен диспетчер – это момент, когда Store решит сообщить о том, что он изменился. Это может произойти уже в процессе рендеринга, асинхронного запроса данных и в любой другой неудобный момент. Диспетчер в таком случае откладывает обработку этого события до удобного момента.

Думаю, что рассказывать про сам Flux подробно в очередной раз не имеет смысла, материалов в сети уже с избытком, я лично рекомендую прочитать первоисточник.

Stores в вашем catberry-приложении


Представим, что у вас есть приложение на Catberry.js, как в нем будут располагаться Stores и как они устроены?

Допустим, вы имеете вот такое дерево файлов в проекте:

./catberry_stores/
    group1/
        Store1.js
        Store2.js
    group2/
        Store1.js
        Store2.js
    Store1.js
    Store2.js

Тогда при старте вашего приложения Catberry загрузит Stores со следующими именами:

group1/Store1
group1/Store2
group2/Store1
group2/Store2
Store1
Store2

Каждый файл Store должен экспортировать функцию-конструктор, как это делается при классическом прототипном подходе в JavaScript. Вот стандартный пример реализации Store, сгенерированный CLI:

module.exports = Some;

/**
 * Creates a new instance of the "some" store.
 * @param {UHR} $uhr Universal HTTP request.
 * @constructor
 */
function Some($uhr) {
    this._uhr = $uhr;
}

/**
 * Current universal HTTP request to do it in isomorphic way.
 * @type {UHR}
 * @private
 */
Some.prototype._uhr = null;

/**
 * Current lifetime of data (in milliseconds) that is returned by this store.
 * @type {number} Lifetime in milliseconds.
 */
Some.prototype.$lifetime = 60000;

/**
 * Loads data from a remote source.
 * @returns {Promise<Object>|Object|null|undefined} Loaded data.
 */
Some.prototype.load = function () {
    // Here you can do any HTTP requests using this._uhr.
    // Please read details here https://github.com/catberry/catberry-uhr.
};

/**
 * Handles action named "some-action" from any component.
 * @returns {Promise<Object>|Object|null|undefined} Response to component.
 */
Some.prototype.handleSomeAction = function () {
    // Here you can call this.$context.changed() if you know
    // that remote data source has been changed.
    // Also you can have many handle methods for other actions.
};

Примечательно здесь несколько вещей:
  1. Можно увидеть в конструкторе аргумент $uhr – так работает Dependency Injection в Catberry.js. На месте этого аргумента окажется экземпляр модуля для универсальной отправки HTTP(S)-запросов, который работает как на сервере, так и в браузере, используя единый программный интерфейс.
  2. Есть такое поле как $lifetime – оно содержит количество миллисекунд, сколько загруженные данные Store считаются актуальными, чтобы не запрашивать их снова через метод load.
  3. Есть метод load, реализация которого может загружать данные с удаленного ресурса. Для запросов данных обычно используется ранее упомянутый UHR. Можно заметить из jsDoc, что вернуться из метода может как значение, так и Promise на него. Если ничего не возвращать или не реализовывать этот метод, ответ будет считаться пустым.
  4. В отличие от многих других реализаций Flux, в Catberry обработчики Action задаются именами методов Store. Например, какой-либо веб-компонент или другой Store отправил Action, в этом Store есть метод handleSomeAction, который будет обрабатывать отправленный Action, если его имя подходит под такие паттерны как «some-action», «someAction», «some_action», «some action» и так далее. Action также возвращает свой результат как синхронно, так и через Promise.
  5. Если вы в любой момент захотите заново загрузить данные через метод load и обновить все веб-компоненты, которые зависят от Store, можно вызвать метод this.$context.changed().

Несколько слов о возвращении Promise.

Весь API модулей в Catberry работает на Promises, в любом месте вы можете вернуть как значение, так и Promise на него, что позволяет удобно управлять асинхронными операциями и их композицией. В Catberry используются нативные Promises, которые присутствуют в большинстве свежих виртуальных машин JavaScript. В случае если вам не повезло с таковой, Catberry объявит Promise shim как глобальный тип, то есть беспокоиться не о чем.

Если у вас есть желание посмотреть более реальный пример Store, можете перейти по ссылке.

this.$context – что это?


Наверняка возник вопрос, что такое this.$context?
Если вы знакомились с реализациями Flux или серверного рендеринга React, то, наверное, слышали про проблему "здесь всё глобальный singleton". Для нас – разработчиков это означает, что мы не можем безопасно использовать такое решение при рендеринге данных для разных сессий пользователей. То есть данные одного пользователя теоретически могут попасть в страницу, которую мы отдаем другому пользователю. Хотя в мире React уже появились решения этой проблемы, как например Flummox, сам изначальный подход Flux не давал ответа на этот вопрос.

Как решается эта проблема в Catberry? Создаются экземпляры Stores и веб-компонентов на каждый серверный запрос. В каждый экземпляр назначается this.$context, в котором находятся методы, связанные с URL, Cookie, User Agent, Redirect, Referer и т.д. Таким образом, есть уверенность в изоляции сессий, только если разработчики сами не будут использовать в коде свои глобально объявленные объекты.

Параметризация Stores


Как передавать параметры из URL в Store, чтобы использовать их при запросах в методе load?
В Catberry есть система маршрутов, которая может показаться знакомой, особенно разработчикам на Ruby On Rails. В catberry-приложении есть файл routes.js, который может выглядеть, например, так:

module.exports = [
	'/:page[Pages]',
	'/:page[Pages]?query=:query[commits/Search]'
];

Параметры в URL выделяются символом ':' и передаются именованными в Stores, которые перечислены через запятую в квадратных скобках после параметра. В данном случае Pages может использовать параметр через this.$context.state.page. Если мы находимся на странице со списком коммитов и задан запрос поиска, commits/Search может использовать текст запроса вот так this.$context.state.query.

Еще один пример:

module.exports = [
	'/some/:id[store1,store2]/actions?a=:p1[store1]&b=:p2[store1]&c=:p3[store1]'
];


Стоит заметить, что query string параметры все опциональны, а вот если в URL path не хватает параметра, маршрут просто не сработает. Также есть возможность использовать пост-обработчики состояния в виде функций и регулярные выражения вместо выделения параметров через ':', это можно прочесть в документации.

Flux Dispatcher


В Catberry разработчики никак напрямую не взаимодействуют с диспетчером и вся коммуникация между веб-компонентами и Stores происходит через вызовы методов this.$context внутри фреймворка. В лучшем случае, разработчики приложений узнают о существовании диспетчера только из stacktrace ошибок в их Stores.

Веб-компоненты в Catberry.js


Пришло время обсудить вторую подтему статьи – веб-компоненты.

Отличия от стандарта Web-Components


Если вы посетите официальный сайт спецификации, то найдете там несколько основных принципов:
  • Custom Elements – возможность использовать собственные тэги HTML и при этом наследовать их от Element, добавляя в них свой функционал.
  • Templates – возможность задавать каждому такому тэгу свой шаблон с подстановками данных.
  • HTML imports – возможность импортировать и переиспользовать HTML–документы внутри других документов.
  • Shadow DOM – изоляция поддеревьев DOM друг от друга, например, чтобы вы могли использовать в разных компонентах одинаково названные CSS-классы.

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

Еще одно важное отличие – веб-компоненты, а также похожие решения вроде директив Angular, не предусматривают отрисовки своего HTML на сервере. В Catberry все веб-компоненты изоморфны, то есть могут рендериться на сервере при первом запросе HTML-документа пользователем и дальше уже делать это в браузере, работая как Single Page Application. Для работы в браузере используется относительно маленький runtime (~35KB gzipped). Все шаблоны и логика компонентов упаковывается фреймворком вместе с runtime в единую сборку bundle.js, которую просто нужно включить script-тэгом в HTML документ (в корневой шаблон).

Размещение в приложении


Ситуация схожа со Stores, только каждый веб-компонент – это директория, в которой есть файл с описанием – cat-component.json.

hello-world/
    assets/
        hello.svg
        world.svg
    index.js
    errorTemplate.hbs
    template.hbs
    cat-component.json

По умолчанию Catberry ищет такие директории с компонентами по путям:
  • catberry_components/**/cat-component.json
  • node_modules/*/cat-component.json

И это означает, что вы можете их публиковать и устанавливать из NPM. Эти параметры можно изменить в конфигурации и загружать компоненты откуда угодно.

Пример cat-component.json:
{
    "name": "hello-world",
    "description": "Some awesome and cool cat-component",
    "template": "./template.hbs",
    "errorTemplate": "./errorTemplate.hbs",
    "logic": "./index.js"
}

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

Как реализуются и используются веб-компоненты


В этом примере есть два файла-шаблона Handlebars. Вообще говоря, можно реализовать TemplateProvider для практически любого шаблонизатора, который умеет компилировать шаблоны в строки. Сейчас официально поддерживаются Handlebars, Jade и Dust. Так почему же их два? Один используется для рендеринга внутреннего HTML объявленного тэга, а второй – errorTemplate – используется как опциональная заглушка в случае непойманной ошибки в процессе рендеринга.

Вот так выглядит использование тэга:
<cat-hello-world id="uniq-id" 
  cat-store="some/Store"
  any-attribute="any-value">
</cat-hello-world>

Есть одно требование – вы должны задать ему уникальный ID. Также вы можете указать зависимость от любого Store и любые другие атрибуты, которые будут доступны через this.$context.attributes в коде веб-компонента. Шаблон веб-компонента может содержать такие же тэги других компонентов, и все они рекурсивно раскрываются в отрендеренные шаблоны. Сам же DOM-элемент веб-компонента будет доступен в коде через this.$context.element.

Ниже приведена реализация простого веб-компонента:
module.exports = HelloWorld;

/**
 * Creates new instance of the "hello-world" component.
 * @constructor 
 * @param {Logger} $logger The logger service.
 */
function HelloWorld($logger) {
    this._logger = $logger;
}

/**
 * Gets data context for template engine.
 * This method is optional.
 * @returns {Promise<Object>|Object|null|undefined} Data context
 * for template engine.
 */
HelloWorld.prototype.render = function () {
    return this.$context.getStoreData();
};

/**
 * Returns event binding settings for the component.
 * This method is optional.
 * @returns {Promise<Object>|Object|null|undefined} Binding settings.
 */
HelloWorld.prototype.bind = function () {
    return {
        click: {'.clickable', this._handleClick}
    };
};

/**
 * Does cleaning for everything that have NOT been set by .bind() method.
 * This method is optional.
 * @returns {Promise|undefined} Promise or nothing.
 */
HelloWorld.prototype.unbind = function () {

};

/**
 * Does click handling.
 * @param {Event} event DOM event object.
 */
HelloWorld.prototype._handleClick = function (event) {
    event.preventDefault();
    event.stopPropagation();
    this.$context.sendAction('item-like', this.$context.attributes['data-id']);
};

Что примечательного здесь:
  1. Также работает Dependency Injection, как и в Store.
  2. Методы render, bind, unbind – это этапы жизни компонента, которые опционально можно реализовывать.
  3. Метод render может вернуть данные для шаблона компонента, чаще всего он берет их из Store через метод this.$context.getStoreData().
  4. Метод bind может вернуть карту привязок событий DOM, в данном случае это клик на элемент в шаблоне веб-компонента, с CSS-классом «clickable».
  5. Метод unbind должен отвязать все события, которые были привязаны не картой привязок из метода bind. Например, событие «scroll» или «hashchange».


Все привязки событий из метода bind работают через делегирование событий. По сути привязка создается одна на элемент-корень веб-компонента и заданный обработчик перехватывает любые события внутри него.

Также у контекста веб-компонента есть несколько методов для обращения к другим веб-компонентам, их можно найти в документации.

Есть пару исключений из правил – это веб-компоненты «document» и «head»:
  • «document» – это корневой шаблон страницы с тэгами «body» и «head» и он не может зависеть от Store и получать из него данные.
  • «head» – это веб-компонент, корневым элементом которого всегда является «head», он может также зависеть от Store и рендерить свой шаблон внутри этого элемента.


Коммуникация между компонентами


Основной подход, который разработчики должны использовать в приложениях на Catberry – это получение объекта-компонента через this.$context.getComponentById('id') и вызов у него «публичных методов», которые уже манипулируют с DOM только внутри этого компонента. Таким образом, только сам веб-компонент должен работать со своим DOM-поддеревом. Возможность работать с DOM напрямую даёт неограниченную гибкость и скорость нативных методов DOM без всяческих прослоек.

В заключение


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

А в следующей статье будет описание того, как работает прогрессивный рендеринг в Catberry и почему этот подход отличает фреймворк от всех остальных решений.

Официальный сайт фреймворка catberry.org
Twitter twitter.com/catberryjs
Организация на Github github.com/catberry
Репозиторий фреймворка на Github github.com/catberry/catberry
Репозиторий сайта (как пример проекта на Catberry) github.com/catberry/catberry-homepage

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


  1. symbix
    22.07.2015 09:32
    +1

    Выглядит круто. Я примерно к тем же идеям пришел, начал недавно писать. Теперь пойду к вам :-)


  1. ElianL
    22.07.2015 14:47

    Когда то рассматривал вариант перехода с angular на Catberry, но в выбрал React.
    Но я рад что проект развивается.

    Правда меня смущает, что вы все еще предлагаете писать на ES5, в то время когда уже все уже переходят на ES6+. У вас нет в планах сделать реализацию на ES6+?


    1. pragmadash Автор
      22.07.2015 15:01

      К сожалению, мне одному не тягаться с маркетингом Facebook.

      Есть плагин для поддержки ES6.


      1. ElianL
        23.07.2015 09:33

        Да дело не в маркетинге, просто на тот момент, насколько я помню, ваш продукт не позволял описывать шаблоны в декларативном стиле, но здорово, что вы пришли к этому


      1. ShpuntiK
        28.07.2015 10:51

        Дело не только в маркетинге, а в коммьюнити и удобстве использования. Сколько примеров, идей, библиотек и тп есть для React/Flux, и сколько для Catberry? Просто ребята из FB сделали крутую штуку и людям это очень понравилось. Пусть имя компании сыграло не последнюю роль, но не стоит это приписывать маркетингу.


        1. pragmadash Автор
          28.07.2015 11:31

          Сколько примеров, идей, библиотек и тп есть для React/Flux, и сколько для Catberry?

          Вы же путаете причину со следствием. Их стало много потому, что React стал популярен а не наоборот.

          Просто ребята из FB сделали крутую штуку и людям это очень понравилось.

          Ребята, насколько я помню были из Instagram, пока FB не купил их, хотя могу и ошибаться.

          Понравилось далеко не всем людям. Я вот, например, помню первые статьи про React на англоязычных ресурсах, где люди в комментариях писали как ужасно выглядит JSX, что это противоречит разделению логики и предстваления и что виртуальный DOM – это неэффективное решение из-за фризов UI при больших и сложных страницах. По моему личному мнению, если бы ни бренд Facebook за плечами фреймворка к нему не было бы вообще столько внимания и его после первых же статей и забыли бы. Однако, когда у тебя столько людей в штате, то определенно есть кому писать посты в блогах, выступать на конференциях, создавая хайп, и даже организовывать целые конференции такого масштаба.

          Зря пытаетесь меня убедить, что не стоит все это приписывать маркетингу Facebook.

          Я считаю, что происходит обычно так: люди видят, о чем больше пишут/говорят и берут это. Никому не хочется вникать в сотни решений, как они устроены и выбирать самостоятельно, таких людей единицы. Намного проще посмотреть, что наслуху и придумать как это можно применить к своему проекту, даже если гвоздь не очень-то удобно забивается отверткой.

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


          1. ShpuntiK
            28.07.2015 12:41

            Я считаю, что происходит обычно так: люди видят, о чем больше пишут/говорят и берут это.

            Как вы правильно заметили — люди выбирают то, что на слуху, но нормальные разработчики не хватают всё подряд в проект, а сравнивают, пробуют и лишь затем делают выбор.

            Понравилось далеко не всем людям.

            Ниодна идея не может удовлетворить всех, всегда будут те кому нравится и кому — нет, но главное — обе стороны будут двигать всех вперёд. JSX не нравился многим и мне в том числе. Но если понимать что это несёт в себе — всё становится логичнее и правильнее.

            Так или иначе, я считаю, что React замечательная библиотека и одна из лучших вещей случившаяся в web за последнии пару лет. Как минимум это дало огромный толчок к развитию всей области. А отдавать все эти заслуги маркетингу, а не отличным идеям, реализованным в React — это нечестно, на мой взгляд.


            1. pragmadash Автор
              28.07.2015 13:11

              Как вы правильно заметили — люди выбирают то, что на слуху, но нормальные разработчики не хватают всё подряд в проект, а сравнивают, пробуют и лишь затем делают выбор.

              Как вы интересно говорите, «нормальные разработчики» – это меньшая часть коммьюнити, как показывает практика, бОльшая часть все-таки ничего не сравнивает и слепо следует моде. По крайней мере так показывает мой личный опыт.

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

              Хочу прояснить, мы ведем беседу про популярность, она невсегда коррелирует с отличными идеями.

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

              Если не рассказывать об идеях, не продвигать их – никто о них никогда сам не узнает. Поэтому многие из нас пишут статьи на Хабре о своих идеях.

              У Facebook, очевидно, очень большой информационный ресурс в этом плане. Не понимаю что я тут обидного сказал для фанатов React. Я сказал лишь, что благодаря Facebook огромное число людей узнало об идеях, заложенных в его основе, поэтому он стал наслуху, выросло коммьюнити и инфраструктура. Если бы любой другой фреймворк сделали ребята из Facebook его огласка была бы настолько же масштабной и он набрал бы сравнимую популярность. Потому что есть отдельные люди в компании, которые занимаются продвижением, это их работа и они делают ее хорошо.


              1. ShpuntiK
                28.07.2015 13:36

                Не понимаю что я тут обидного сказал для фанатов React.

                Я не фанат React, просто считаю его отличным инструментом, но в любой момент смогу переключиться на более лучший инструмент, если такой будет. Ничего обидного вы не сказали :) Просто вы подметили про маркетинг, а я не согласился.

                Вы говорите о маркетинге, что это всё FB, но есть пример как простой разработчик, за которым не стоит большая компания или команда, смог сделать свои идеи популярными. Он из React-сообщества (Dan Abramov) и создал множество пакетов/библиотек, которые собирают по 1000-чи звёзд (самый популярный из них сейчас — redux 3000 звёзд). Но главное — за ним не стоит FB, он всё делает сам и его инструменты популярны. Поэтому маркетинг не самое главное и не все сводится к нему, даже в React, я считаю.


                1. pragmadash Автор
                  28.07.2015 13:51

                  Ну я практически уверен, что Dan Abramov не молчал о своих проектах, а тоже занимался их продвижением. И его проекты бОльшую часть популярности получили засчет вовлеченности большого количества людей в React. В общем я вашу позицию понял, но у меня все же другое мнение.


            1. symbix
              28.07.2015 19:47

              Идея реакта не нова — это прежде всего CQRS (не знаю, правда, в курсе ли разработчики, или сами переизобрели), и это далеко не первая реализация. Не маркетинг, конечно, но лейбл фейсбука внес значительную лепту в популяризацию.


              1. ShpuntiK
                29.07.2015 08:21
                +1

                Вы наверное имеете ввиду Flux, а не React!? React умеет только отображать данные, но не изменять их.


                1. symbix
                  29.07.2015 19:35

                  Ага. Ну так весь профит от связки. Она так-то клевая, но отвращение к JSX побороть не могу :) Хотя, конечно, можно и без него — ничто не мешает компилировать handlebars-подобные шаблоны в JS.


                  1. ShpuntiK
                    29.07.2015 20:36

                    Тут я могу сказать только то, что я сам не любил JSX, а сейчас считаю отличной штукой. Да и это просто абстракция, причём очень удобная.
                    Убеждать тут кого-то думаю бесполезно :)


              1. ShpuntiK
                29.07.2015 10:31

                Вот тут дискуссия о том как Flux (хоть и репозиторий redux) соотносится с CQRS. Там Bill Fisher (один из создателей Flux) учавствует и может что-то прояснится.


  1. ShpuntiK
    28.07.2015 12:41

    del