EXO: Сторінка створення клієнтів

TL;DR Як написати свій перший сайт та свою першу сторінку на веб фреймворку N2O

Ця стаття показує загальну структуру усіх веб фреймворків і на прикладі фреймворку N2O показує як створювати активні сторінки та сайти на платформі Erlang/OTP та мові програмування Elixir. До прикладу береться система тарифікації споживачів EXO (для мови Elixir), або клієнт-банк FIN (подібний приклад для мови Erlang) та доповнюється сторінкою користувачів системи.

Репозиторій EXO реалізує ідіоматичний приклад — білінгову систему для телекомунікаційних операторів та операторів електро-енергії. Користувачі в цій системі бувають двох видів: 1) адміністратори, хто формує тарифні моделі та створює облікові записи користувачів та 2) споживачі, хто замовляє послуги на споживання згідно тарифним моделям.

У існуючому шаблонному додатку ми створимо: 1) статичний контейнер HTML (файл domains.htm); 2) модель користувача #client{} (файл client.hrl); 3) дві форми: для вводу користувача та його табличного відобраєження (модулі Client.Form та Client.Row); 4) контролер сторінки з реакцією на кнопки та первинною ініціалізацією сторінки (модуль EXO.Domains). Та підключимо це все в існуючий шаблон додатку (папки config, include, lib, priv/static).

Загальна структура веб-фреймворків

Веб-фреймворки можна умовно розділити на два великі класи: 1) клієнтські фреймворки, які будують сторінку виключно на клієнті, а по каналам передаються виключно дані та бізнес-об'єкти; 2) серверні фреймворки, які будуть сторінку або частини сторінки на сервері та пересилають по каналах зв'язку вже відрендерені форми. NITRO веб-фреймворк відноситься до другого класу.

Безвідносно до класів фреймворків, всі вони мають спільний стек, або архітектурні рівні, які відповідають рівням ISO 42010: 1) рівень даних, 2) рівень логіки, 3) рівень презентації, 4) рівень валідації. Умовно ці рівні зводять до двох: фронт-енд і бек-енд.

Дизайн (фронт-енд)

— Загальні стилі сторінки (BLANK.CSS)
— Стилі полів та форм (FORM.CSS)
— Стилі меню та сторінок адміністратора (ADMIN.CSS)

Веб-стек (фронт-енд)

— Статичний та динамічний роутер (N2O)
— Парсер URL параметрів (N2O)
— Сесійний рівень (серверний та клієнтський контексти) N2O
— Рівень контроллера (логіка веб сторінки NITRO)
— Презентаційний рівень (мова HTML елементів NITRO)
— Рівень представлення бізнес-об'єктів (мова X-FORMS)

Сховище та бізнес-логіка (бек-енд)

— Рівень моделі даних (KVS, MNESIA, SQL, Erlang HRL файли)
— Рівень бізнес-процесів (BPE)
— Рівень розмежування прав доступа (ABAC)

Для кожного архітектурного рівня модель N2O.DEV пропонує свою бібліотеку. Так для рівня схеми даних використовується бібліотека KVS, яка абстрагується над реляційними базами даних (MNESIA, SQL) та базами даних з єдиним простором ключів (RocksDB, Riak, Cassandra). Для презентаційного рівня (або модель UI) використовується NITRO бібліотека яка реалізує семантику HTML5 та пропонує свій спосіб визначення нових контрольних елементів. Для рівня валідації даних на рівні полів та автоматичної побудови форм використовується бібліотека FORM. Всі мережеві з'єднання та повідомлення в системі контролюються бібліотекою N2O. Для бізнес логіки використовується бібліотека BPE яка реалізує стандарт BPMN ISO 19510. Для розмежування прав доступу використовується бібліотека ABAC яка реалізує стандарт NIST на розмежування прав.

NOTE: Рівень розмежування прав доступу не використовується у цьому прикладі EXO. Рівень бізнес логіки використвується тільки в Адміністраторі BPE. Рівень дизайну використовується але не пояснюється, для детального огляду моделі CSS дивіться сторінку документації по стилям.

Створення сторіники користувачів у прикладі EXO

1. Створення статичного HTML контейнеру

Кожна сторінка веб-фреймворку NITRO/N2O містить стандартну прелюдію, яка підключає CSS файли стилів та JavaScript частину бібліотек N2O/NITRO/FORM. Зазвичай ця частина спільна для усіх сторінок.

<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="description" content="" /> <title>LOGIN</title> <link rel="stylesheet" href="css/blank.css" /> <link rel="stylesheet" href="css/forms.css" /> <link rel="stylesheet" href="css/admin.css" /> </head> <body> <nav><a href='login.htm'>LOGIN</a></nav> <aside></aside> <main></main> <script src='https://ws.n2o.dev/priv/utf8.js'></script> <script src='https://ws.n2o.dev/priv/bert.js'></script> <script src='https://ws.n2o.dev/priv/heart.js'></script> <script src='https://ws.n2o.dev/priv/ieee754.js'></script> <script src='https://ws.n2o.dev/priv/n2o.js'></script> <script src='https://ws.n2o.dev/priv/ftp.js'></script> <script src='https://nitro.n2o.dev/priv/js/nitro.js'></script> <script>host = location.hostname; port = 8051;</script> <script>protos = [$bert]; N2O_start();</script> </body> </html>

Такі сторінки називаються статичними ресурсами які можуть віддаватися статичним HTTP сервером або через CDN. Після того як сторінка завантажилась, вона запускає функію N2O_start, яка створює WebSocket з'єднання та спілкується з сервером (бек-ендом) відповідно до протоколу N2O/NITRO, згідно якого клієнт (веб-браузер) виконує команди сервера, які модифікують сторінку (додають, змінюються або видаляють DOM елементи).

На цьому етапі придумуються унікальні імена DOM елементів (виділено червоним) на статичній HTML сторінці, які у подальшому будуть використовуватися в логіці контролера сторінки для їх модифікації.

<main> <article> <section> <h2>КОРИСТУВАЧІ</h2> <p>Додати користувачів в систему.</p> <br> <div id=ctrl</div> <div id=frms</div> <br> </section> <div class="table"> <div id=tableHead class="trGroup"> </div> <div id=tableRow class="trGroup"> </div> </div> <br> </article> </main>

Тут ми розміщуємо одну панель div для кнопки яка буде відкривати форму вводу клієнта ctrl, одну панель div для самої форми вводу. І дві панелі для шапки та тіла таблиці клієнтів.

2. Підключення контролера сторінки в роутер

Основне завдання роутера довільного веб-фреймворку — це визначити контролер сторінки який буде обробляти запит за наданою URL адресою звернення. Роутер визначається на рівні бібліотеки N2O та повинен містит дві обов'язкові фунції init та finish які будуть викликатися при кожному зверненні по URL в браузері. Задача функції init покласти в контекст N2O (об'єкт N2O.cx) ім'я модуля яке визначажться по URL запиту path за допомогою функції route. Зверніть увагу що запити можуть здійснюватися як по протоколу HTTP так і по протоколу WebSocket (які йдуть з префіксом /ws), тому ми додатково це перевіряємо у фунції url.

defmodule EXO.Route do require N2O require Logger def finish(state, ctx), do: {:ok, state, ctx} def init(state, context) do %{path: path} = N2O.cx(context, :req) {:ok, state, N2O.cx(context, path: path, module: url(path))} end def url(<<"/ws/",p::binary>>), do: route(p) def url(<<"/", p::binary>>), do: route(p) def url(p), do: route(p) def route(<<"app/backoffice/domains", _::binary>>), do: EXO.Domains def route(<<"app/login", _::binary>>), do: EXO.Login def route(""), do: EXO.Login def route(_) , do: EXO.Login end

3. Створення контроллера сторінки

Тепер треба створити контролер з іменем EXO.Domains який повинен містити лише одну обов'язкову функцію event, параметр якої і є повідомленням, отриманим з веб сторінки.

defmodule EXO.Domains do require EXO require BPE require NITRO def event(:init), do: [] def event(:create), do: [] def event({:"CreateClient", _}), do: [] def event({:"Close",[]}), do: [] def event(_), do: :ok end

Тут придумуюємо три додаткових протокольних повідомлення: 1) :create — постбек для кнопки "Новий", що знаходить в панелі ctrl; 2) {:"CreateClient",_} — постбек для кнопки "Створити" на формі, що знаходиться в панелі frms. 3) {:"Close",[]} — постбек для кнопки "Відміна" на формі, що знаходиться в панелі frms.

Тут придумуємо також що при натисненні на кнопку "Новий" ми ховаємо панель ctrl і показуємо форму, що вже побудована на панелі frms при ініціалізації сторінки (повідомлення :init), та була прихована до натиску на кнопку "Новий". Після натиску на кнопку "Створити" або "Відміна" ми ховаємо форму frms, та знову показуємо кнопку "Новий" на панелі ctrl.

4. Створення бізнес-об'єктів та ініціалізація схеми

Перед тим як створювати форми необхідно визначити модель обʼєкту, який буде відображатися на формі. N2O.DEV та ERP.UNO використовують HRL файли на мові Erlang для визначення типової інформації полів бізнес-обʼєктів. Це дає змогу використовувати моделі як для проектів на мові Erlang так і для проектів на мові Elixir. Крім того наявність бізнес-моделей у HRL форматі дозволяє безпосередньо зберігати дані у рідному для Erlang/OTP сховищі Mnesia.

Для детальної специфікації мови, яка може бути використана всередині HRL файлі дивіться офіційну документацію мови Ерланг, розділ TypeSpec.

-ifndef(CLIENT_HRL). -define(CLIENT_HRL, "client_hrl"). -record(client, { id = kvs:seq([],[]), next = [],prev = [], bank = [], iban = [], local = [], type = consumer, status = online, program = [], amount = [], default_account, accounts = "/exo/:bank/:id/accounts", default_card = [], cards = "/exo/:bank/:id/cards", phone = <<>>, tax = [], names = <<>>, surnames = <<>>, date = [], display_name = [], registration = [] }). -endif.

Після визначення бізнес-обʼєкту його необхідно підключити в загальну схему метаінформації, яка є спільною для таких бібліотек як KVS та FORM. Для цього ми створюємо файл schema.ex, де будемо перелічувати (@schema) усі HRL файли в каталозі include, для яких будуть ініціалізовані таблиці. Це відбувається в процесі компіляції, і рекорди з Erlang імпортуються в Elixir автоматично за допомогою макроса Record.extract_all, що звертається до файлової системи в момент компіляції. Для кожного файлу в каталозі include буде викликатися цей макрос. Обовʼязкова функція metainfo буде викликатися кожного разу при старті і містить метаінформацію KVS.table для всіх таблиць перелічених в @schema.

defmodule EXO do defmodule EXO require KVS require FORM require Record @schema [ :account, :client, :card, :transaction, :currency, :phone, :field, :program ] def metainfo(), do: KVS.schema( name: :exo, tables: exo()) def exo(), do: :lists.map(fn x -> table(x) end, @schema) def table(name) do exo_fields = :application.get_env(:exosculat, :exo_fields, []) {a,b} = :lists.unzip(:proplists.get_value(name, exo_fields, [])) KVS.table(name: name, fields: a, instance: b) end end

Зазвичай ви не змінюєте логіку schema.ex, а лише міняєте перелік HRL файлів у @schema. Тут ми показуємо, що ми добавили в цей список імʼя нашого бізнес-обʼєкту у вигляді атома :client.

$ mix clean $ iex -S mix > require EXO > EXO.client()

Для перевірки гіпотез та вивчення та інтроспекції функції зручно використовувати Ерланг консоль. У ній можна писати коротки вирази на мові Еліксір, не визначаючи повністю модулі. Наприклад аби подивитися як виглядає бізнес-об'єкт в консолі потрібно виконати require EXO після чого можна викликати функції модуля які повертають інстанси бізнес об'єктів.

5. Створення форми вводу, налаштування postback та sources

Форма — це візуальне представлення бізнес-обʼєкту, яких може бути декілька: для вводу інформаці, для редагування, для пошуку, для read-only перегляду, та для відображення у вигляді рядка для табличного представлення. Повний перелік видів форм згідно бібліотеки FORM такий: none, create, edit, search, view.

Кожен веб-фреймворк, який реалізує специфікацію X-FORMS повинен містити специфікацію на метаінформацію форм, необхідну для їх рендерінгу. Наприклад: влкючене чи виключене поле, значення за замовчуванням, ширина поля, тип поля, ім'я, модуль на сервері з логікою, фід де зберігаються можливі значення цього поля для словників, тошо.

Модель FORM визначена в каталозі include відповідної бібліотеки та містить FORM.document (форма), FORM.but (кнопки), FORM.field (поля). Сама форма-документ FORM.document містить чотири головні частини: імʼя, перелік секції, перелік кнопок, перелік полів у сексіях.

Коли ми хочемо зробити елемнти форми, такі як кнопки чи поля активними, тобто такими, які будуть певні свої події (як onclick) передавати на сервер, ми визначаємо для цього елементу форми властивість postback. Саме це значення буде попадати в визначений до цього контролер сторінки при виникненні цієї події. При відсиланні на сервер повідомлення-постебеку {:"CreateClient",_} будуть також передані всі значення полів форми, що знаходились в цей момент в контексті браузера, перелічені в полі sources. Імена sources мають трьохкомпонентну структуру X_Y_Z, де X — це імʼя поля, Y — імʼя бізнес-обʼєкту, Z — тип форми (none, create, edit, search, view). У даному прикладі використовуються форма без модифікаторів (none).

Зверніть увагу, що id властивості елемнтів FORM.field повинні відповідати полям визначеним в бізнес-обʼєкті EXO.client, інакше рендерер форми не зможе знайти інформацію про типи. Кожна форма повинна визначати три обовʼязкові функції: 1) doc, яка повертає просто рядок пояюснюючий одним реченням для чого написана ця форма; 2) new, яка має три параметри: імʼя форми, бізнес-обʼєкт який потрібно візуалізувати, та можливі опції і повертає FORM.document і описом всіх полів, кнопок та секції; 3) id, яка повертає еталонний одиничний бізнес-обʼєкт за замовчуванням.

defmodule Client.Form do require EXO require NITRO require FORM require BPE def doc(), do: "Форма вводу користувача системи" def id, do: EXO.client() def new([], _, _), do: [] def new(name, _client, _) do :erlang.put(:type_client_none, :consumer) FORM.document( name: :form.atom([:client,name]), sections: [ FORM.sec(name: ["Створити користувача: " ])], buttons: [ FORM.but(id: :decline, name: :decline, title: "Відміна", class: [:cancel], postback: {:"Close",[]} ), FORM.but(id: :proceed, name: :proceed, title: "Створити", class: [:button,:sgreen], sources: [:surnames_client_none, :names_client_none, :phone_client_none, :type_client_none], postback: {:"CreateClient", :form.atom([:client,name])})], fields: [ FORM.field(id: :surnames, name: :surnames, type: :string, title: "Прізвища:", labelClass: :label), FORM.field(id: :names, name: :names, type: :string, title: "Імена", labelClass: :label), FORM.field(id: :phone, name: :phone, type: :string, title: "Телефон", labelClass: :label), FORM.field( id: :type, name: :type, title: "Тип:", type: :select, default: :consumer, options: [ FORM.opt(name: :consumer, checked: true, title: "Споживач"), FORM.opt(name: :admin, title: "Адміністратор"),] ) ] ) end end

6. Створення табличної форми

Якщо бізнес-об'єкт потрібно швидко відобразити в таблиці, можна не використовувати FORM, а напряму створити відображення використовуючи HTML DSL, де ви конструюєте елменти за допомогою відповідних Ерланг записів (records). З другого параметру функції new, де передбачається EXO.client ми екстрагуємо поля phone, names, surnames, type, status, date, і показуємо їх в панелі з 5 колонками.

def header() do NITRO.panel(id: :header, class: :th, body: [ NITRO.panel(class: :column20, body: "ПІБ"), NITRO.panel(class: :column20, body: "Тип"), NITRO.panel(class: :column20, body: "Дата"), NITRO.panel(class: :column20, body: "Телефон"), NITRO.panel(class: :column10, body: "Статус") ] ) end

Заголовок таблиці дивіться як функцію header в модулі контролера сторінки EXO.Domains. Зверніть увагу, що класи для панелей однакові для колонок заголовка та рядків таблиці.

defmodule Client.Row do require EXO require NITRO def doc(), do: "Форма-рядок для відображення користувача системи." def id(), do: EXO.client() def new(name, client, _) do phone = EXO.client(client, :phone) names = EXO.client(client, :names) surnames = EXO.client(client, :surnames) type = EXO.client(client, :type) status = EXO.client(client, :status) date = EXO.client(client, :date) NITRO.panel(id: :form.atom([:tr,name]), class: :td, body: [ NITRO.panel(class: :column20, body: NITRO.link(href: "user.htm?p=" <> :nitro.to_binary(phone), body: names <> " " <> surnames)), NITRO.panel(class: :column20, body: :nitro.to_binary type), NITRO.panel(class: :column20, body: :nitro.compact date), NITRO.panel(class: :column20, body: :nitro.compact phone), NITRO.panel(class: :column10, body: :nitro.to_binary status) ]) end end

Зазвичай табличні форми не містять активних елементів з postback і sources, як у цьому прикладі.

7. Налаштування конфігурації

Після того як ми визначили контейнер, контролер, форми, бізнес-об'єкт, потрібно тепер це всьо підключити в нашу систему за допомогою конфігураційного файлу config.exs. Сторінки ми просто кладемо в папку priv/static, роутер прописуємо в змінну n2o.routes, а форми додаємо в змінну form.registry. Модуль схеми, який реалізує функцію metainfo ми додаємо в схему даних, змінну kvs.schema. Інші змінні залишаємо як є.

config :n2o, pickler: :n2o_secret, app: :exosculat, mq: :n2o_syn, port: 8051, tables: [:cookies, :file, :caching, :async], protocols: [:n2o_heart, :nitro_n2o, :n2o_ftp], routes: EXO.Route config :kvs, dba: :kvs_rocks, dba_st: :kvs_st, schema: [:kvs, :kvs_stream, :bpe_metainfo, EXO] config :form, module: :form_backend, registry: [Client.Row,Client.Form]

Якшо є необхідність перевизначити конфігураційні змінні без перезапуску сервера, коли зазвичай воні всі читаються, можна скористатися функцією application.set_env:

> :application.set_env :form, :registry, [Client.Row,Client.Form] > :application.get_env :form, :registry {:ok, [Client.Row, Client.Form]}

8. Протокол init контролера сторінки

def event(:init) do :nitro.clear(:tableHead) :nitro.clear(:tableRow) :nitro.insert_top(:tableHead, header()) :nitro.clear(:frms) :nitro.clear(:ctrl) mod = Client.Form :nitro.insert_bottom(:frms, :form.new(mod.new(mod,mod.id(), []), mod.id(), [])) :nitro.insert_bottom(:ctrl, NITRO.link(id: :creator, body: "Новий", postback: :create, class: [:button, :sgreen])) :nitro.hide(:frms) :lists.map(fn x -> :nitro.insert_top(:tableRow, Client.Row.new(:form.atom([:row, EXO.client(x, :id)]), x, [])) end, :kvs.all('/exo/clients')) end

9. Реакції на кнопки Закрити та Створити

def event({:"Close",[]}) do :nitro.hide(:frms) :nitro.show(:ctrl) end
def event({:"CreateClient", _}) do date = :calendar.now_to_datetime :erlang.timestamp type = :nitro.q(:type_client_none) names = :nitro.q(:names_client_none) phone = :nitro.q(:phone_client_none) surnames = :nitro.q(:surnames_client_none) id = :kvs.seq([],[]) client = EXO.client(id: id, phone: phone, names: names, surnames: surnames, status: :online, type: type, date: date) nitro = :form.new( Client.Row.new( :form.atom([:row,id]), client, []), client, []) :kvs.append client, '/exo/clients' :nitro.insert_top(:tableRow, nitro) :nitro.hide(:frms) :nitro.show(:ctrl) end

10. Direct повідомлення

> direct(tuple(atom('Close'),nil()))

11. Публікація додатку в пакетний менеджер hex

$ mix hex.publish