Всем привет! В эфире Маркетплейс МоегоСклада. В прошлый раз мы рассказывали о том, как мы запустили маркетплейс приложений в SaaS-сервисе МойСклад. Сегодня продолжим о том, как мы даем возможность приложениям расширять пользовательский интерфейс сервиса. Наверное, многие сталкивались в десктопных приложениях с подобными плагинами, которые при подключении добавляют в приложение какие-то свои кнопочки, пункты меню и даже целые наборы новых окон и диалогов, а также встраивают свои собственные UI-блоки в существующие экраны. А как сделать такое в SaaS-сервисе, UI которого работает в браузере?

Зачем вообще нужно встраивание в UI?

На старте Маркетплейса для приложений общего назначения единственный доступный способ интеграции приложений с МоимСкладом — это интеграция по данным через общее JSON API. Через это API серверные части вендорских приложений могут получать и изменять пользовательские данные. Таким образом, на старте у нас была возможность только интеграции бэкендов приложений и МоегоСклада между собой. Добавить кнопочку или виджет на форму редактирования приложения не могли.

Изначально было очевидно, что этого не достаточно для предоставления конечным пользователям максимального удобства в использовании приложений. С другой стороны, внешним разработчикам и интеграторам приходилось колхозить свои механизмы в UI — у них был выбор или непосредственно встраиваться в HTML веб-приложения МоегоСклада через плагины браузера или делать отдельные интерфейсы (UI) на своей стороне, во многом дублирующие функциональные возможности экранов в МоемСкладе (например, списки документов с фильтрами).

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

В общем, уже на старте было понятно, что UI-плагины — это must have фича, которую надо делать.

Для начала — на самом старте — мы сделали простейшую возможность приложению встраивать свой iframe на отдельной вкладке. Этого позволяло закрыть такие базовые сценарии как, например, настройка приложения пользователем после установки. Но хотелось большего. 

И вот пришло время занятся UI-плагинами для приложений уже по-серьезному.

Виджеты. Начало

Мы начали с того, что сформулировали общее видение возможных вариантов встраивания, зафиксировали базовые технические требования и посмотрели как обстоит дело в других SaaS-сервисах.

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

Пример виджета на экране редактирования контрагента:

Основной сценарий работы с виджетами выглядит так:

  1. Разработчик приложения указывает в дескрипторе приложения в какие места он хочет вставить виджеты

  2. Пользователь МоегоСклада устанавливает это приложение

  3. Пользователь заходит в то место, в которое приложение встраивает свой виджет

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

Из важных технических требований мы определили следующие:

  • Изоляция виджетов. Виджеты не должны иметь техническую возможность сломать верстку основной страницы. Как понятно, это ограничивает гибкость встраивания, но зато позволяет оставить за собой (в бОльшей степени) контроль за стабильностью работы UI (очень важно для наших пользователей) и его удобством (UX).

  • Отсутствие дергания при загрузке страницы. При открытии экрана с виджетами не должно быть дергания элементов на экране. То есть, например, виджеты должны открываться на экране сразу в нужном размере, а не “скакать” на экране при подгрузке в них кода и контента.

  • Однократная загрузка/инициализация кода виджета. Учитывая, что UI МоегоСклада — это SPA, то хотелось бы воспользоваться преимуществами этого и для виджетов: загружать код виджета и строить DOM-дерево виджета один раз за время жизни вкладки браузера, тем самым ускоряя работу UI в целом — по сравнению с полной загрузкой виджета с нуля при очередном открытии формы редактирования документа с виджетом. Тем более, что для наших внутренних компонентов и экранов у нас уже использовался подобный подход с кэшированием.

Как у других?

Для анализа мы выбрали несколько зарубежных SaaS-сервисов (Jira, Salesforce, Zendesk) и несколько наших российских (amoCRM, Битрикс24, InSales). Задача была посмотреть с технической точки зрения как устроено там и учесть этот опыт в нашем решении вопроса.

На что мы обращали внимание при анализе:

  1. Что собой представляет само приложение: SPA ли это как у нас или что-то другое?

  2. Как устроено взаимодействие виджетов и хост-окна (хост-окном называем родительское окно, куда встраивается iframe с виджетом), как разработчик указывает куда именно встраивать виджет, есть ли SDK для разработчиков (в части встраивания в UI)?

  3. Обеспечивается ли изоляция виджетов, откуда загружается код виджетов?

  4. Каким образом передается информация о текущем контексте в виджет. Под контекстом понимаем информацию о текущем пользователе, открытом экране и открываемом/редактируемом объекте или объектах.

Подробное изложение особенностей реализации в каждой системе тянет на отдельную статью. Здесь приведем некоторые общие выводы, которые мы сделали по результатам анализа.

В основном, рассматриваемые системы представляют собой SPA-приложения или “частичные” SPA (“частичные” — это когда, например, в рамках некоторых экранов UI работает без перезагрузки как SPA, но в каких-то случаях навигации по приложению может произойти и полная перезагрузка страницы — хотя внешне бывает сложно отличить полную перезагрузку страницы от частичной). UI МоегоСклада — полноценное классическое SPA. 

Почти везде виджеты сторонних разработчиков работают изолировано внутри iframe’ов. При этом с точки зрения степени изоляции здесь возможны два варианта:

  1. Содержимое виджета загружается в iframe с того же домена, что и сам сервис (что обычно требует размещения клиентского кода на серверах сервиса, хотя это не обязательно — можно просто проксировать). И если при этом в атрибуте sandbox iframe’a указать ключевое слово allow-same-origin, то в этом случае есть возможность доступа к DOMу виджета из хост-окна и наоборот. Кто-то из сервисов использует это, например, для передачи контекста открытия виджету через JavaScript-переменную. Нам такой вариант изоляции не подходит (слабоват).

  2. Содержимое виджета загружается в iframe с домена вендора (или отсутствует указание allow-same-origin) — в этом случае обеспечивается наиболее полная изоляция, при которой отсутствует доступ к DOM-дереву хост-окна из виджета и наоборот. Это как раз нужный нам уровень изоляции. В этом случае взаимодействие хост-окна с виджетом возможно только сообщениями через postMessage, что, конечно, менее прямолинейно, чем прямые JavaScript-вызовы между хост-окном и виджетом. Тем не менее, если завернуть на стороне виджета вызовы postMessage и прием сообщений в удобное JS SDK — то это не будет практически ничем отличаться от прямых JavaScript-вызовов.

И большинство проанализированных сервисов имеют такое JS SDK. У нас тоже были планы сразу начать делать такое JS SDK и предоставлять вендорам API взаимодействия в его терминах, но ограниченность ресурсов и насущная потребность выкатить виджеты на прод внесли свою корректировку и мы решили начать делать наше API на “голом” postMessage и сериализуемых JavaScript-сообщениях.

На фоне остальных выделяется amoCRM, в котором сторонние виджеты встраиваются непосредственно в DOM-документ хост-окна. При таком подходе, конечно, можно достичь большей гибкости встраивания, но это влечет и соответствующие риски — знающие люди рассказывали нам о случаях, когда виджеты приложений ломали верстку общего UI. И потом поди еще разберись, какой из виджетов в этом виноват. Тем не менее, такой подход несомненно имеет право на жизнь и, если вы выбираете его, то можно посмотреть на веб-компоненты как технологическую основу реализации виджетов приложений.

Что касается описания параметров встраивания виджетов (например, куда встраивать и прочие требуемые системой параметры) — для этого обычно используется JSON-структура (манифест). Мы здесь пошли немного другим путем и применяем XML — технические параметры интеграции приложений с МоимСкладом разработчики описывают в виде XML-дескриптора (являющегося аналогом JSON-манифеста). Почему мы предпочли XML в эру повсеместного распространения JSON’a — об этом расскажем ниже.

XML-дескриптор приложения

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

<ServerApplication xmlns="https://online.moysklad.ru/xml/ns/appstore/app/v2"             
                    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"             
                    xsi:schemaLocation="https://online.moysklad.ru/xml/ns/appstore/app/v2      
                    https://online.moysklad.ru/xml/ns/appstore/app/v2/application-v2.xsd">
    <iframe>
        <sourceUrl>https://example.com/iframe.html</sourceUrl>
        <expand>true</expand>
    </iframe>
    <vendorApi>
        <endpointBase>https://example.com/dummy-app</endpointBase>
    </vendorApi>
    <access>
        <resource>https://online.moysklad.ru/api/remap/1.2</resource>
        <scope>admin</scope>
    </access>
    <widgets>        
        <entity.counterparty.edit>            
            <sourceUrl>https://example.com/widget.php</sourceUrl>            
            <height>                
                <fixed>150px</fixed>            
            </height>
            <supports>
                <open-feedback/>
                <save-handler/>
            </supports>
            <uses>
                <good-folder-selector/>
            </uses>                  
        </entity.counterparty.edit>    
    </widgets>
    <popups>
        <popup>
            <name>somePopup</name>
            <sourceUrl>https://example.com/popup.php</sourceUrl>
        </popup>
        <popup>
            <name>somePopup2</name>
            <sourceUrl>https://example.com/popup-2.php</sourceUrl>
        </popup>
    </popups>
</ServerApplication>

Итак, почему же мы выбрали именно XML, а не JSON? Основной наш поинт был в том, что для XML есть стандартизованный широко распространенный формальный способ описания схемы — XML Schema. Для JSON тоже есть аналоги — например, JSON Schema. Но для JSON-схем (в отличие от XML-схем) нам были не совсем понятны их текущий статус развития, уровень поддержки в библиотеках и прочих инструментах, таких как IDE. Также мы не знали, насколько гибкие JSON-схемы функционально. Все это требовало дополнительного анализа, в то время как опыт работы с XML-схемами у нас уже был. В итоге мы решили не тратить ресурс на изучение возможностей JSON-схем, расценив, что сама по себе форма текста XML или JSON особой разницы для наших целей не имеет, а гибкости XML-схем нам должно хватить до определенного уровня.

Какие же преимущества дает нам наличие формальной схемы описания дескриптора? Основных два:

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

  2. Имея поддержку code completion для схемы в IDE мы по сути получаем “на халяву” UI для редактирования дескрипторов вендорами. Это позволяет нам использовать контекстно-зависимые структуры в дескрипторе (об этом ниже), максимизируя преимущество первого пункта без ущерба для удобства написания дескриптора вендором.

Вот, например, Intellij IDEA нам подсказывает, какие вообще точки расширения доступны для виджетов:

Так выглядит в IDE точка расширения только с поддержкой протокола open-feedback (что такое протоколы - расскажем ниже):

А так выглядит точка расширения с более полным набором доступных протоколов:

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

Модель описания виджетов в XML-дескрипторе

Приложения могут встраивать виджеты в разные места (точки расширения) системы. Например, у нас пару десятков (или даже больше) типов документов. Для каждого типа документа свой экран редактирования — и это отдельные точки встраивания. Хотелось бы добавлять новые точки расширения итеративно, так как каждая новая точка расширения требует разработки и тестирования. Оптимально было бы в первую очередь сделать и зарелизить точки расширения, наиболее востребованные вендорами. И уже потом доделывать остальные. Или может даже отложить доделку в пользу более приоритетных продуктовых задач. Хорошо бы иметь возможность на уровне схемы дескриптора задавать какие точки расширения есть в наличии на сейчас.   

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

Учитывая два вышеизложенных соображения мы остановили свой выбор на следующей структуре описания, в которой имя точки расширения является XML-тегом:

<widgets>
    <some.extension.point1>...</some.extension.point1>
    <some.extension.point2>...</some.extension.point2>
</widgets>

Вместо, например, такой возможной:

<widgets>
    <widget location="some.extension.point1">...</widget>
    <widget location="some.extension.point2">...</widget>
</widgets>

Выбранный вариант позволяет нам явно и просто определять в XML-схеме свои собственные структуры (“подсхемы”) для каждой точки расширения по отдельности. Если для каких-то точек расширения “подсхемы” одинаковые — то эти точки расширения в схеме могут ссылаться на один и тот же тип. С учетом этого, а также возможности наследования для сложных типов в XML-схеме, мы эффективно избавляемся от дублирования кода в самой XML-схеме.

О каком функционале в точках расширения идет речь и зачем вообще это выносить в дескриптор? Почему бы просто для каждой точки расширения в документации не указать, что может делать виджет, какой функционал ему доступен и какие параметры он может задать для виджета в этой точки расширения?

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

<document.customerorder.edit>
    <sourceUrl>https://example.com/widget.php</sourceUrl>
    <height>
        <fixed>150px</fixed>
    </height>
    <supports>...</supports>
    <uses>...</uses>
</document.customerorder.edit>

Здесь sourceUrl и height являются общими параметрами для всех виджетов. Параметр sourceUrl задает откуда будет загружен код виджета в iframe, а height описывает высоту блока виджета (как вы помните, у нас есть требование про отсутствие дергания — поэтому начали мы с поддержки только фиксированный высоты для виджета, которую пока нельзя изменять, а ширина у нас одинаковая для всех виджетов по дизайну UI).

Если мы захотим сделать поддержку динамической высоты (определяемой виджетом при открытии страницы с ним) в какой-то специфичной точке расширения (где, например, дёргание страницы несущественно), то мы сможем на уровне схемы дескриптора сделать так, что указывать <height><dynamic/></height> можно будет только для этой точки расширения. И вендор уже на этапе написания дескриптора будет знать — это работает только здесь. Это снижает риск того, что вендор не уследит, сделает виджет в расчете на <dynamic/> и обнаружит, что оно не работает в требуемой точке расширения на более позднем этапе разработки при тестировании или отладке. 

С supports и uses ситуация интересней. Об этом далее.

Протоколы и сообщения

Для описания интерфейсов взаимодействия МоегоСклада с виджетами мы используем понятия протоколов и сообщений (на самом деле модель описания API чуть сложнее, но в рамках данной статьи опустим второстепенные детали):

Рассмотрим на примере следующего описания виджета в дескрипторе:

<document.customerorder.edit>
    <sourceUrl>https://example.com/widget.php</sourceUrl>
    <height>
        <fixed>150px</fixed>
    </height>
    <supports>
        <open-feedback/>
        <save-handler/>
        <change-handler>
            <expand>agent</expand>
            <expand>positions.assortment</expand>
        </change-handler>
    </supports>
    <uses>
        <good-folder-selector/>
    </uses>
</document.customerorder.edit>

Итак.

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

Протоколы мы делим на несколько типов.

Основной протокол — это протокол, который обязательно должен поддерживать виджет/плагин определенного типа (зависит от типа плагина). Не требует явного объявления виджетом. Например, для виджетов основной протокол включает в себя указание параметров sourceUrl и height, загрузку кода виджета в iframe по HTTP с передачей контекста текущего пользователя и отправку хост-окном postMessage-сообщения Open с указанием идентификатора открываемой пользователем сущности или документа.

Пример встраивания виджета в DOM-дерево страницы МоегоСклада:

Пример сообщения Open:

{
  "name": "Open",
  "messageId": 12345,
  "extensionPoint": "entity.counterparty.edit",
  "objectId": "8e9512f3-111b-11ea-0a80-02a2000a3c9c",
  "displayMode": "expanded"
}

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

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

оpen-feedback (протокол без параметров) — поддержка виджетом этого протокола означает, что при открытии экрана редактирования система закрывает виджет заглушкой и убирает заглушку, отображая содержимое виджета, только при явном получении сообщения OpenFeedback от виджета. Это нужно для того, чтобы виджет успел перерисовать свое содержимое для вновь открываемого объекта. Иначе вследствии кэширования виджета пользователь может какое-то время видеть состояние для объекта, который пользователь открывал до этого.

Заглушка выглядит как простая рамка с таким же серым фоном как и сама страница:

Пример сообщения OpenFeedback:

{
  "name": "OpenFeedback",
  "correlationId": 12345
}  

save-handler (протокол без параметров) — если виджет указывает поддержку этого протокола, то хост-окно при нажатии пользователем кнопки “Сохранить” отправляет виджету сообщение Save.

Пример сообщения Save:

{
  "name": "Save",
  "messageId": 32109,
  "extensionPoint": "entity.counterparty.edit",
  "objectId": "8e9512f3-111b-11ea-0a80-02a2000a3c9c"
}

сhange-handler (протокол с параметрами) — на примере этого протокола, который на момент написания статьи еще находится в разработке, можно видеть, что есть возможность определять параметры для дополнительных протоколов (аналогично параметрам для основных протоколов). Виджет, объявляющий поддержку протокола change-handler, начинает получать от хост-окна данные о редактируемом пользователем объекте в режиме “онлайн” — при изменении какого-либо атрибута объекта (например, при добавлении нового товара в заказ покупателя) хост-окно отправляет виджету сообщение Change с JSONом с данными объкета. С помощью параметров протокола expand вендор может дополнительно запросить замену ссылок на объекты аналогично тому, как это делается у нас в JSON API (в первом релизе change-handler будет без поддержки допольнительных параметров expand).

Сервисный протокол — это протокол, который реализует хост-окно с целью предоставления плагину некоторого сервисного функционала. Перечень доступных сервисных протоколов зависит от точки расширения. Для использования сервисного протокола плагином требуется явное объявление указание сервисного протокола в теге uses в дескрипторе (в блоке плагина). Сервисы похожи на дополнительные протоколы, но отличаются своей узкой направленностью взаимодействия виджет -> хост-окно в режиме запрос-ответ.

Пример сервисного протокола — good-folder-selector. Этот сервис виджетам приложений переиспользовать существующий в МоемСкладе селектор группы товаров с получением виджетом результата выбора пользователя. Работает это так:

1. Виджет отправляет хост-окну сообщение SelectGoodFolderRequest (например, при нажатии пользователем какой-либо кнопки в виджете):

{
  "name": "SelectGoodFolderRequest",
  "messageId": 12345
}

2. Хост-окно открывает встроенный в МойСклад селектор:

3. Пользователь совершает выбор и хост-окно возвращает виджету результат в сообщении SelectGoodFolderResponse:

{
  "name": "SelectGoodFolderResponse",
  "correlationId": 12345,
  "selected": true,
  "goodFolderId": "8e9512f3-111b-11ea-0a80-02a2000a3c9c"
}

Обязуя вендора статически указывать для виджета поддерживаемые дополнительные протоколы и используемые виджетом сервисы мы получаем следующие возможности и преимущества:

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

  2. С помощью XML-схемы дескриптора вендор может узнать в каких точках расширения какие протоколы поддерживаются, а мы можем статически провалидировать дескриптор на корректность при (при загрузке вендором дескриптора в систему). 

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

  4. Зная, какой именно дополнительный и сервисный функционал используют виджеты мы можем оптимизировать работы хост-окна. Например, если виджеты на экране не используют change-handler, то хост-окно может вообще не инициализировать соответствующий функционал, экономя ресурсы компьютера пользователя (чтобы может быть важно для больших масштабных SPA-приложений типа нашего).

Альтернативой статического указания протоколов для виджета в дескрипторе могло бы быть динамическая регистрация при загрузке виджета (например, через какое-нибудь сообщение Init через postMessage, которое бы виджет отправлял при загрузке своего кода в iframe. Конечно, все вышеперечисленные пункты возможностей и преимуществ статического способа можно повторить и в динамическом, но это может потребовать больше ресурсов для разработки (нужно собирать метрики из браузера), больше ресурсов в рантайме (процессор, память на клиенте и сетевой трафик). При этом, ориентируясь только на динамические метрики, мы уже не так достоверно сможем определять что именно используют виджеты — например, если где-то в уголке лежит редко используемое пользователями приложение, запускаемое раз в месяц (и которое запускалось последний раз месяц назад). Тем не менее, динамические метрики тоже полезны и хорошо дополняют статические в плане реальной картины происходящего на проде.

Как работают виджеты

Как это работает всё вместе и какие именно функциональные возможности есть у наших виджетов на текущий момент можно прочитать в документации. Дублировать это здесь наверное будет излишним.

Что дальше?

Что у нас есть еще на текущий момент и какие дальнейшие планы?

Из интересного хотелось бы отметить кастомные попапы. Это возможность виджету открыть большое попап-окно с содержимым, определяемым приложением. Попап-окно по завершению своей работы может вернуть результат виджету. Кастомные попапы являются основным протоколом аналогично виджетам. В открываемом попапе находится iframe, в который загружается код попапа аналогично коду виджета. Виджет взаимодействует с попапом используя postMessage-сообщения через посредничество хост-окна. Подробнее о кастомных попапах с примерами можно посмотреть в документации.

На момент написания статьи активно работаем над упомянутом выше протоколом change-handler. В планах дальнейшее развитие этого направления — протокол update, который позволит виджетам обновлять данные редактируемого объекта в хост-окне.

Идеи на перспективу:

  • Завернуть “голое” взаимодействие через postMessage в удобное JavaScript/TypeScript Widget SDK для вендоров

  • Постепенное распространение поддержки виджетов и соответствующих протоколов на всех экранах МоегоСклада

  • Новые типы UI-плагинов (новые типы точек расширения) — например, такие как:

    • Кнопки действий, пункты в выпадающих или контекстных меню

    • Столбцы в гридах

    • Кастомные вкладки

  • Стандартные модальные диалоги

  • Дальнейшее развитие возможности использования виджетами существующих интерфейсных объектов МоегоСклада

  • Взаимодействие между виджетами одного приложения

  • Навигация внутри UI МоегоСклада

  • Доступ к эндпоинтам RESP API через Widget SDK

Объем работы по расширению возможностей интеграции в UI нашего сервиса огромный — мы планомерно двигаем наш SaaS-продукт в сторону гибкой, максимально расширяемой и удобной платформы для разработчиков приложений.

При этом МойСклад активно помогает вендорам с продвижением и продажей приложений — рассказывает пользователям МоегоСклада о новых приложениях в email-рассылках и соцсетях, проводит совместные с вендорами вебинары для пользователей МоегоСклада, продвигает приложения баннерами внутри сервиса.

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