Как стать автором
Обновить

Навигация: вариант реализации для корпоративного приложения

Время на прочтение19 мин
Количество просмотров14K
За мою карьеру от простого разработчика до архитектора приходилось работать над приложениями разного масштаба и степени сложности. Последние несколько лет я работал над системой управления школами и системой управления медицинскими препаратами. Приходилось решать различного рода проблемы, связанные с навигацией по приложению. Но в зависимости от используемых фреймворков, не всегда удавалось найти удобное решение. Всегда казалось, что чего-то не хватает.

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

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

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

Введение


У каждого корпоративного приложения есть своя доменная модель, в которой определяются связи между сущностями системы. Сущности могут быть связаны разными отношениями, один-к-одному, один-ко-многим, многие-ко-многим. Допустим, есть некоторая доменная модель приложения.

С каждой сущностью могут быть связаны разные представления, например, CRUD, отчёты, графики, списки в таблице. Также представление может отображать сразу несколько сущностей, например, как представлено на рисунке ниже B-представление работает с сущностями из классов B, D и E.

В соответствии со связями на уровне доменной модели пользователь может перемещаться между соответствующими представлениями, например, как показано на рисунке ниже, начиная с представления B переходить на представление C, потом на F, дальше на E.

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

Или ещё один вариант, начать работу с другой стороны, с представления E и по переходам дойти до представления A.

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

MVC или компонентный фреймворк


Выбирая веб фреймворк для следующего проекта, хотелось совместить преимущества разработки настольных приложений на основе компонентных фреймворков с преимуществами MVC фреймворков для создания веб приложений. Из компонентных фреймворков взять простоту создания разнообразных форм, таблиц, графиков и других элементов интерфейса и объединить это с возможностью управлять навигацией по приложению с помощью внешней конфигурации, по схожим принципам, как это реализовано в Struts или Spring Web Flow.

В качестве компонентного фреймворка был выбран Vaadin, в то время, как подходящей реализации для навигации, похожей на Spring Web Flow, не оказалось. Попытка самим интегрировать Vaadin и Spring Web Flow провалилась из-за существенных различий в механизме Request/Response моделей. Поэтому было принято решение реализовать свой вариант Web Flow, который бы не зависел от Request/Response модели.

За основу был взят UML Statechart и реализовали Lexaden Web Flow. В нём создали механизм связи Statechart с компонентной моделью таким образом, чтобы можно было в зависимости от состояний переключать визуальные компоненты в определённой области приложения.

На рисунке ниже представлены основные компоненты Lexaden Web Flow.


Event Processor

Event Processor — это процессор событий, который используется всеми компонентами в системе для их отправки. А также Event Processor используется для доступа к мета-информации, которая определяет, какие события доступны в данный момент и в каком состоянии находится процесс навигации.

Lexaden Web Flow engine

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

State Controller

Контроллер состояния пользовательского интерфейса реагирует на события, пришедшие от Lexaden Web Flow engine и встраивает представления, полученные от контроллеров, в layout приложения. Также он отвечает за передачу событий контроллерам о переходе из одного состояния навигации в другое.

Начало


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

Пример, как это реализуется в XML:
  <flow initial="Panel1" ...> 
            <controller id="Panel1">
                 <on event="panel2" to="Panel2"/>
            </controller>
            <controller id="Panel2">
                 <on event="panel1" to="Panel1"/>
                 <on event="ok" to="OK"/>
            </controller>
            <final id="OK"/>
  </flow>

По событию с контроллера «Panel1», приложение переключается и показывает «Panel2» и наоборот. Когда возникает «ok» событие, то выполнение программы заканчивается.

Далее давайте рассмотрим более сложный пример, возьмём доменную сущность «person» и реализуем для неё CRUD операции, таким образом, чтобы каждая операция соответствовала отдельному состоянию и управлялась своим контроллером.

 <flow  initial="list" ...>
     <module id="person">
        <controller id="list">
            <on event="create" to="create"/>
            <on event="read" to="read"/>
            <on event="update" to="update"/>
            <on event="delete" to="delete"/>
        </controller>
 
        <controller id="create">
             ...
        </controller>
 
        <controller id="read">
             ...
        </controller>
 
        <controller id="update">
             <on event="updated" to="list"/>
             <on event="canceled" to="list"/>
        </controller>
 
        <controller id="delete">
             ...
        </controller>
    </module>
 </flow>


Но тут есть задачка. Как переходить обратно из «create», «read», «update» и «delete» контроллеров в «list» контроллер? Самое очевидное — это было бы задать в каждом контроллере явные переходы на контроллер «list»:

<on event="updated" to="list"/>
<on event="canceled" to="list"/>


Но такой вариант связал бы «create», «read», «update» и «delete» контроллеры с контроллером «list», что не позволило бы повторно использовать «update» или «delete» контроллеры, например, из контроллера «read». Зачем переходить из «read» контроллера обратно на «list», чтобы потом попасть на «update», если можно сразу перейти из «read” на „update“ контроллер?

Решением же служит добавление „final“ состояний внутри каждого контроллера, чтобы по событиям переходить на них, а не на внешние контроллеры:

<controller id="create">
        <on event="created" to="created"/>
        <on event="canceled" to="canceled"/>
 
        <final id="created"/>
        <final id="canceled"/>
</controller>
 
<controller id="update">
        <on event="updated" to="updated"/>
        <on event="canceled" to="canceled"/>
 
        <final id="updated"/>
        <final id="canceled"/>
</controller>
 


При переходе в final состояние, Lexaden Web Flow сообщается, что контроллер прекращает свою работу и LWF передаёт управление в предыдущий контроллер при помощи сообщения, составленного из имени текущего контроллера и имени final состояния. Например, „update.updated“, „update.canceled“ или „create.created“, которые будут использоваться, как результат работы завершившего работать контроллера.

Базовый контроллер

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

Базовый контроллер состоит из „action“ и „view“ состояний. Action состояния отвечают за действия по инициализации представления и получения данных из доменной модели. View состояния предназначены для определения момента, когда необходимо отобразить представление в приложении.

    <controller id="controller" initial="initView">
 
        <!-- init view. create all components of the view. get ready to setup data  -->
        <action id="initView" extends="action">
            <on to="loadData"/>
        </action>
 
        <!-- setup data before displaying the view. get data from context attributes -->
        <action id="loadData" extends="action">
            <on to="displayView"/>
        </action>
 
        <view id="displayView" extends="view">
            <on event="ok" to="ok"/>
            <on event="close" to="close"/>
            <on event="cancel" to="canceled"/>
        </view>
 
        <final id="close" extends="action"/>
        <final id="canceled" extends="action"/>
        <final id="ok" extends="action"/>
 
    </controller>
  


Action — »initView" используется для инициализации представления в контроллере. После того, как контроллер создал представление, идёт переход на action «loadData», который служит для загрузки данных в представление. Состояние «displayView» указывает фреймворку вставить представление, взятое у контроллера в Layout приложения.

По событиям «ok», «close» или «cancel» контроллер из «displayView» переходит в соответствующие final состояния. Это означает, что контроллер закончил свою работу.



Чтобы привязать “action” состояния к методам в соответствующем Java контроллере используются аннотации:

    @OnEnterState(EventConstants.INIT_VIEW)
    public void initView(StateEvent event) {
        ...
    }


В этом случае, при переходе контроллера в action состояние «initView», вызывается метод initView, отмеченный аннотацией OnEnterState с названием состояния. И ему в качестве параметра передаётся событие, которое было брошено, другим контроллером. Событие может содержать в себе какие-либо данные, например, идентификатор выбранного объекта на предыдущем экране. И использоваться для загрузки связанных с идентификатором данных.

Таким же образом можно подписаться на события, когда приложение переходит во «view» состояние.

    @OnEnterState(EventConstants.DISPLAY_VIEW)
    public void refreshTable(StateEvent event) {
       …
    }


Это может использоваться для обновления данных в таблице или форме каждый раз, когда пользователь возвращается в “displayView” состояние.

Modules

Так как контроллеров в корпоративном приложении может быть достаточно много, их лучше объединить в модули. Сделать так, чтобы модули были независимы между собой и обменивались информацией только посредством событий. Например, из модуля «orders», можно перейти в модуль «addresses» по событию «go_addresses».



Так, например, набор контроллеров для CRUD операций можно объединить в модуль, например:

<module id="addresses" initial="list">
   ... 
   <controller id="list" … >
         <on event="go_create" to="create"/>
         <on event="go_read" to="read"/>
         <on event="go_update" to="update"/>
         <on event="go_delete" to="delete"/>
   </controller>
   <controller id="create" … >
        …
   </controller>
   <controller id="read" … >
         …
   </controller>
   <controller id="update" … >
         …
   </controller>
   <controller id="delete" … >
         …
   </controller>
   ...
</module>


Заходя в модуль «addresses», приложение переходит на «list» контроллер. К контроллерам из Lexaden Web Flow привязываются соответствующие Java контроллеры, например, таким кодом:

    flowControllerContext.bindController("addresses/list", “content”, new AddressesListController ());
    flowControllerContext.bindController("addresses/create", “content”, new AddressesCreateController ());
    flowControllerContext.bindController("addresses/read", “content”, new AddressesReadController ());
    flowControllerContext.bindController("addresses/update", “content”, new AddressesUpdateController ());
    flowControllerContext.bindController("addresses/delete", “content”, new AddressesDeleteController ());


С контроллером AddressesListController ассоциировано определённое представление (AddressesListView), которое становится активным, когда приложение переходит в состояние «addresses/list». Таким же образом привязываются другие контроллеры со своими представлениями к «create», «read», «update», «delete» состояниям. По событиям из контроллера, привязанного к состоянию «list», приложение переходит в соответствующие состояния «create», «read», «update» или «delete» и отображает соответствующие представления пользователю.



CRUD: основные операции над доменными объектами


Так как большинство доменных объектов в системе может поддерживать одни и те же операции, такие как List, Create, Read, Update, Delete, то было бы неплохо определить навигационную логику в отдельному модуле — “crud”, а потом наследуясь от него, создавать модули для разных доменных объектов с автоматической поддержкой CRUD операций.

     <module id="crud" initial="list" ...>
 
        <controller id="list" extends="controller">
            <on event="create" to="create"/>
            <on event="read" to="read"/>
            <on event="update" to="update"/>
            <on event="delete" to="delete"/>        
        </controller>
 
        <controller id="create" extends="controller">
            …
        </controller>
 
        <controller id="read" extends="controller">
            …
        </controller>
 
        <controller id="update" extends="controller">
            …
        </controller>
 
        <controller id="delete" extends="controller">
            …
        </controller>
     </module>
 
    <module id="orders" extends="crud" >
          <on event="go_account" to="account"/>
          <on event="go_addresses" to="addresses"/>
    </module>
 
    <module id="account" extends="crud"/>
 
    <module id="addresses" extends="crud"/>
 


Модули “orders”, “account”, “addresses” наследуются он модуля “crud”, определённого выше и получают в своё распоряжение копию логики переходов между CRUD состояниями. Теперь создание новых модулей получается довольно лаконичным, что позволяет легко добавлять новые модули в процессе разработки системы.

Readonly + CRUD: разграничение прав на просмотр и редактирование

Чтобы иметь возможность в приложении разграничивать доступ к определённым операциям над доменными объектами, можно разделить CRUD на два модуля «readonly» и «crud». При этом «readonly» будет использоваться только для чтения, а «crud» для полноценного редактирования сущностей приложения.

    <module id="readonly" initial="list" extends="module">
 
        <controller id="list" extends="controller">
            <on event="read" to="read"/>
        </controller>
 
        <controller id="read" extends="controller"/>
 
    </module>
 
   <module id="crud" initial="list" extends="readonly">
 
           <controller id="list" extends="readonly/list">
                   <on event="create" to="create"/>
                  <on event="update" to="update"/>
                   <on event="delete" to="delete"/>
                    ...
           </controller>
           <controller id="create" extends="controller">
               …
           </controller>
 
           <controller id="read" extends="controller">
               …
           </controller>
 
           <controller id="update" extends="controller">
               …
           </controller>
 
           <controller id="delete" extends="controller">
               …
           </controller>
 
   </module>
 
    <module id="orders" extends="readonly" >
          <on event="go_account" to="account"/>
          <on event="go_addresses" to="addresses"/>
    </module>
 
    <module id="account" extends="crud"/>
 
    <module id="addresses" extends="crud"/>
 


Так как модуль«orders» наследуется от модуля «readonly», то теперь пользователю можно будет видеть список ордеров и просматривать каждый ордер индивидуально, но создавать, редактировать или удалять их он не сможет. Модули «account» и «addresses», наследуясь от «crud» модуля, позволят пользователю просматривать, создавать, обновлять и удалять сущности.

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

В приложении может использоваться множество разнообразных таблиц с определёнными настройками. Они, как правило, привязываются к «list» состояниям из CRUD модулей. Но их конфигурация задаётся таким образом, что при событии «read», он переходит в контроллер «read». Используя наследование, можно повторно использовать таблицы не только для просмотра списка объектов, но и для выбора значений из таблицы. Для этого в «crud» модуль можно добавить «picker» контроллер, который будет наследоваться от «crud/list» контроллера:

    <module id="crud" initial="list"...>
        …
        <controller id="picker" extends="crud/list">
            <on event="read" to="picked"/>
            <final id="picked" extends="action"/>
        </controller>
        
    </module>
 


Контроллер — «picker» наследуется от «crud/list» и переопределяет событие «read», перенаправляя в final состояние — «picked». Это позволяет по нажатию на строчку в таблице вернуться обратно на предыдущий экран и получить событие «picker.picked» с идентификатором выбранного объекта. Отлавливая его в контроллере предыдущего экрана, обновить содержимое, например, выпадающего списка.



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

    <module id="orders" extends="readonly" >
          <on event="go_account" to="account"/>
          <on event="go_addresses" to="addresses"/>
          
          <on event="select.account" to="account/picker"/>
          <on event="select.addresses" to="addresses/picker"/>
    </module>
 
    <module id="account" extends="crud"/>
 
    <module id="addresses" extends="crud"/>
 


По событиям «select.account» и «select.addresses» из модуля «orders» идут переходы на «account/picker» и на «addresses/picker», позволяя выбрать из таблиц нужные объекты.



Механизм pickers позволяет не только повторно использовать таблицу для выбора значений, но и создавать, обновлять или удалять сущности в списке по мере необходимости, используя возможности CRUD модуля.

Profiles — группируем модули

Так как корпоративная система может состоять из десятков или сотен разных доменных объектов, то и навигационных модулей может оказаться достаточно много. Разный набор модулей может использоваться различными ролями (администраторами, менеджерами и другими сотрудниками организации), для этого они объединяются в профайлы, например, как показано на рисунке ниже:



Профайл — это часть приложения, привязанная к определённой роли в системе. Например, профайл администратора имеет доступ ко всему функционалу в системе, когда customer имеет доступ только к ограниченному функционалу, например, сделать заказ, посмотреть статус заказа.



Ниже показан пример конфигурации модулей в профайл:

   <profile id="customer" ...>
          …
        <module id="orders"  … >
              <on event="go_account" to="account"/>
              <on event="go_addresses" to="addresses"/>
        </module>
        <module id="account" … >
        </module>
        <module id="addresses" … >
        </module>
        …
   </profile>
 


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

Чтобы одни и те же модули можно было использовать одновременно в различных профайлах в Lexaden Web Flow поддерживается наследование. Например, определив модуль «t_addresses» на верхнем уровне, можно его включить в разные профайлы.

<flow>
    ...
    <profile id="t_admin" ...>
        …
         <module id="addresses" extends="t_addresses">
               <controller id="list" extends="addresses/list">
                      <on event="go_export" to="export"/>
               </controller>
               <controller id="export" >
                      ...
               </controller>
         </module>
        …
    </profile>
 
    <profile id="t_customer" ...>
        …
         <module id="addresses" extends="t_addresses"/>
        …
    </profile>
 
    <module id="t_addresses" initial="list">
       ...
    </module>
    ...
</flow>


Профайл «t_admin» и профайл «t_customer» получают копии модуля «t_addresses», определённого на верхнем уровне.

Также, при помощи наследования в профайле «t_admin» модуль «address» расширяет возможности «list» контроллера, добавляя событие «go_export», по которому приложение перейдет в состояние «admin/addresses/export». К «addresses/export» привязывается соответствующий контроллер с представлением для экспорта. Получается наследование состояний с полиморфизмом, что позволяет выборочно менять поведение и структуру модулей, беря за основу базовый шаблон.

Дальше профайлы при помощи того же наследования включаются в «application».

     <application id="application" ...>
            … 
            <profile id="admin"  extends="t_admin"/>
            <profile id="manager"   extends="t_manager" />
            <profile id="team_leader"   extends="t_manager" >
                  <on event="go_team" to="team"/>
                  <module id="team"…> … </module>
             </profile>
            <profile id="employee" extends="t_employee"/>
            <profile id="customer"  extends="t_customer"/>
            …
    </application>
 
    <profile id="t_admin" ...> ... </profile>
    <profile id="t_manager" ...>... </profile>
    <profile id="t_employee" ...>... </profile>
    <profile id="t_customer" ...>... </profile>


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

Главное состояние «application» служит для привязывания application layout, который задаёт базовую структуру пользовательского интерфейса. Делается это следующим образом:

    flowControllerContext.bindController("application", new ApplicationLayoutController());


Используя внешний контекст flowControllerContext к “application” состоянию привязывается ApplicationLayoutController, который внутри содержит структуру пользовательского интерфейса приложения. Внутри этой структуры определяются так называемые “placeholders”, задача которых разметить Layout приложения на определённые части, куда будут вставляться разнообразные представления в ходе навигации по приложению.



Например, Left SideBar может служить для размещения меню приложения, Header для логотипа, строки поиска, кнопки логина. Right SideBar может служить для размещения разного рода вспомогательных окон. Content служит для размещения основой информации приложения.

Когда контроллеры привязываются к определённому состоянию, для каждого из них указывается ещё и placeholder.

    flowControllerContext.bindController("addresses/list", “content”, new AddressesListController ());
    flowControllerContext.bindController("addresses/create", “content”, new AddressesCreateController ());
    flowControllerContext.bindController("addresses/read", “content”, new AddressesReadController ());
    flowControllerContext.bindController("addresses/update", “content”, new AddressesUpdateController ());
    flowControllerContext.bindController("addresses/delete", “content”, new AddressesDeleteController ());


Все контроллеры, привязанные к “addresses” модулю будут привязаны к placeholder – “content”. В процессе работы программы представления этих контроллеров будут отображаться в соответствующем месте layout приложения.

Flows: процесс выполнения

Чтобы была возможность осуществлять навигацию по системе, начиная с разных доменных объектов, в Lexaden Web Flow используется механизм flows – потоков. Для запуска flow, в теге «on» указывается атрибут type=«flow» на уровне профайлов. Когда приложение бросает событие с типом «flow», Lexaden Web Flow, либо стартует новый поток, либо переключается на уже активный поток.

    <profile id="manager"   … />
       <on type="flow" event="orders.flow" to="orders"/>
       <on type="flow" event="account.flow" to="account"/>
       <on type="flow" event="addresses.flow" to="addresses"/>
 
       <module id="orders" extends="readonly" >
             <on event="go_account" to="account"/>
             <on event="go_addresses" to="addresses"/>
       </module>
 
       <module id="account" extends="crud"/>
 
       <module id="addresses" extends="crud"/>
    </profile>


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



В приложении потоки привязываются к закрывающимся вкладкам, а сам путь навигации внутри активного flow отображается при помощи Breadcrumb компонента.



Контроллеры функционируют, как асинхронные функции. Для завершения своей работы они должны переходить в одно из нескольких внутренних конечных состояний типа final. Переходя в это состояние, Lexaden Web Flow формирует новое событие, состоящее из имени контроллера и имени конечного состояния внутри контроллера. Например, для контроллера «read» с внутренним конечным состоянием «ok», LWF сгенерирует событие «read.ok», бросая это событие предыдущему flow контроллеру, позволяя ему обработать результат выполнения.

<controller id="list" extends="controller">
   
    <on event="read" to="read"/>
    <on event="read.ok".../>
      ...
</controller>
 
<controller id="read" extends="controller">
       
       <on event="update" to="update"/>
       <on event="update.updated" to="updated"/>
 
       <action id="updated" extends="action"/>
 
       <!-- this part already exists in the "read" controller such as 
              it is inherited from the parent "controller" state
       <view id="displayView" extends="view">
              <on event="ok" to="ok"/>
             ...
       </view>
       <final id="ok" extends="action"/>
       -->
 
               …
</controller>
 
 <controller id="update" extends="controller">
      
            <on event="updated" to="updated"/>
            <final id="updated" extends="action"/>
 
          <!-- this part already exists in the "update" controller such as
                      it is inherited from the parent "controller" state
             <view id="displayView" extends="view">
                   <on event="cancel" to="canceled"/>
                  ...
            </view>
           <final id="canceled" extends="action"/>
            -->
 </controller>
 


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

Поток выполнения завершается, когда последний оставшийся в потоке контроллер переходит в одно из конечных состояний. При этом бросается событие с типом «endFlow». Оно обрабатывается следующим образом:

@OnEvent(type = EventConstants.END_FLOW)
    public void onCloseTab(StateEvent event) {
      ...
    }


И может использоваться, например, для закрытия вкладки, связанной с flow.

Результат работы приложения Lexaden Administration, основанного на Lexaden Web Flow, можно посмотреть на видео, начиная со второй минуты:



Возможные преимущества использования этой технологии:

  • Можно использовать, как основу для описания Use Cases в приложении
  • Для компонентных фреймворков частично решает проблему с памятью, позволяя создавать страницы по запросу, а не загружать все экраны приложения одним махом.
  • Может решить проблему с навигацией для крупных систем администрирования школ, больниц, банков
  • Упрощает понимание системы клиентами, унифицируя навигацию по всему приложению, что в последствии может снизить затраты на поддержку в дальнейшем
  • Упрощает юнит тестирование отдельных экранов приложения
  • Разработка системы может вестись довольно большой командой разработчиков параллельно
  • Систему можно будет проще адаптировать под различных заказчиков или под различные рынки разных стран


Недостатки:

  • Необходимость изучать новый фреймворк
  • В некоторых случаях, отладку навигации приходится осуществлять на уровне исходников Lexaden Web Flow
  • Не подходит для маленьких приложений
  • Нет визуального редактора, позволяющего менять конфигурации через приложение
  • Нет компилятора, позволяющего проверять синтаксис навигации на этапе компиляции программы
  • Демонстрационное приложение пока доступно только для компонентной архитектуры Vaadin, хотя Lexaden Web Flow не зависит от определённого компонентного фреймворка


В случае, если кому интересна эта технология, то Lexaden Web Flow, а также Lexaden Administration можно свободно использовать в своих коммерческих проектах, т.к. они распространяются под Apache 2.0 лицензией.

Статья получилась довольно объёмной. Спасибо за время, потраченное на её прочтение. Буду рад ответить на ваши вопросы.
Теги:
Хабы:
+13
Комментарии5

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн