Русский
Русский
English
Статистика
Реклама

Library

Из песочницы Делаем модальные окна для сайта. Заботимся об удобстве и доступности

18.09.2020 12:15:01 | Автор: admin

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


Вёрстка таких окон сначала кажется простой задачей. Модальные окна можно сделать даже без помощи JS только лишь с помощью CSS, но на практике они оказываются неудобными, и из-за маленьких недочетов модальные окна раздражают посетителей сайта.


В итоге было задумано сделать собственное простое решение.



Вообще говоря, есть несколько готовых скриптов, JavaScript библиотек, реализующих функционал модальных окон, например:


  • Arctic Modal,
  • jquery-modal,
  • iziModal,
  • Micromodal.js,
  • tingle.js,
  • Bootstrap Modal (из библиотеки Bootstrap) и др.

(в статье не рассматриваем решения на базе Frontend-фреймворков)


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


Что мы ждём от модальных окон? Отвечая на этот вопрос, я основывался на докладе Знакомьтесь, модальное окно Анны Селезнёвой, а так-же на относительно старой статье NikoX arcticModal jQuery-плагин для модальных окон.


Итак, чтобы нам хотелось видеть?


  • Окна должны открываться как можно быстрее, без тормозов браузера, с возможностью анимировать открытие и закрытие.
  • Под окном должен быть оверлей. Клик/тап по оверлею должен закрывать окно.
  • Страница под окном не должна прокручиваться.
  • Окон может быть несколько. Открытие одного определенного окна должно осуществляться кликом на любой элемент страницы с data-атрибутом, который мы выберем.
  • Окно может быть длинным прокручиваемым.
  • Желательно поработать над доступностью, а также с переносом фокуса внутрь окна и обратно.
  • Должно работать на IE11+

Дисклеймер: Прежде чем мы рассмотрим подробности, сразу дам ссылку на готовый код получившейся библиотеки (HystModal) на GitHub, а также ссылку на демо+документацию.


Начнём с разметки.


1. Разметка HTML и CSS


1.1. Каркас модальных окон


Как открыть окно быстро? Самое простое решение: разместить всю разметку модального окна сразу в HTML странице. Затем скрывать/показывать это окно при помощи переключения классов CSS.


Набросаем такую разметку HTML (я назвал этот скрипт hystmodal):


<div class="hystmodal" id="myModal">    <div class="hystmodal__window">        <button data-hystclose class="hystmodal__close">Close</button>          Текст модального окошка.        <img src="img/photo.jpg" alt="Изображение в окне" />    </div></div>

Итак, разместим перед закрывающим тегом </body> наш блок с окном (.hystmodal). Он будет фоном. Удобно указать уникальный атрибут id (например #myModal) каждому окну (ведь их у нас может быть несколько).


Сделаем так, чтобы .hystmodal растягивался на всё окно браузера и закрывал собой содержимое страницы. Чтобы этого добиться, установим фиксированное позиционирование в CSS и приравняем свойства top, bottom, left и right к нулю.


.hystmodal {    position: fixed;    top: 0;    bottom: 0;    right: 0;    left: 0;    overflow: hidden;    overflow-y: auto;    -webkit-overflow-scrolling: touch;    display: flex;    flex-flow: column nowrap;    justify-content: center; /* см. ниже */    align-items: center;    z-index: 99;    /* Чтобы окно не прилипало к границе    браузера установим отступы */    padding:30px 0;}

В этом коде сделаны ещё две вещи:


  1. Так как мы хотим центрировать окно внутри страницы, превращаем .hystmodal в flex-контейнер с выравниваем его потомков по центру по вертикали и горизонтали.
  2. Окно может быть больше высоты экрана браузера, поэтому мы устанавливаем overflow-y: auto, чтобы при переполнении возникала полоса прокрутки. Также, для сенсорных экранов (в основном для Safari) нам стоит установить свойство -webkit-overflow-scrolling: touch, чтобы сенсорная прокрутка работала именно на этом блоке а не на странице.

Теперь установим стили для самого окна.


.hystmodal__window {    background: #fff;    /* Установим по умолчанию ширину 600px    но она будет не больше ширины браузера */    width: 600px;    max-width: 100%;    /* Заготовка для будущих анимаций */    transition: transform 0.15s ease 0s, opacity 0.15s ease 0s;    transform: scale(1);}

Кажется возникли сложности.


Проблема 1. Если высота окна больше высоты окна браузера, то контент окна будет обрезан сверху.



Это возникает из-за свойства justify-content: center. Оно позволяет нам удобно выровнять потомков по основной оси (по вертикали), но если потомок становится больше родителя то часть его становится недоступной даже при прокручиваемом контейнере. Подробнее можно посмотреть на stackoverflow. Решение установить justify-content: flex-start, а потомку установить margin:auto. Это выровняет его по центру.


Проблема 2. В ie-11 если высота окна больше высоты окна браузера, то фон окна обрезается.


Решение: мы можем установить flex-shrink:0 потомку тогда обрезки не происходит.


Проблема 3. В браузерах кроме Chrome нет отступа от нижней границы окна (т.е. padding-bottom не сработал).


Сложно сказать баг это браузеров или наоборот соответствует спецификации, но решения два:


  • установить псевдоэлемент ::after после потомка и дать ему высоту вместо padding
  • обернуть элемент в дополнительный блок и дать отступы уже ему.

Воспользуемся вторым методом. Добавим обертку .hystmodal__wrap. Так мы заодно обойдём и проблему 1, а вместо padding у родителя установим margin-top и margin-top у самого .hystmodal__window.


Наш итоговый html:


<div class="hystmodal" id="myModal" aria-hidden="true" >    <div class="hystmodal__wrap">        <div class="hystmodal__window" role="dialog" aria-modal="true" >            <button data-hystclose class="hystmodal__close">Close</button>              <h1>Заголовок модального окна</h1>            <p>Текст модального окна ...</p>            <img src="img/photo.jpg" alt="Изображение" width="400" />            <p>Ещё текст модального окна ...</p>        </div>    </div></div>

В код также добавлены некоторые aria и role атрибуты для обеспечения доступности.


Обновленный код CSS для обертки и окна.


.hystmodal__wrap {    flex-shrink: 0;    flex-grow: 0;    width: 100%;    min-height: 100%;    margin: auto;    display: flex;    flex-flow: column nowrap;    align-items: center;    justify-content: center;}.hystmodal__window {    margin: 50px 0;    flex-shrink: 0;    flex-grow: 0;    background: #fff;    width: 600px;    max-width: 100%;    overflow: visible;    transition: transform 0.2s ease 0s, opacity 0.2s ease 0s;    transform: scale(0.9);    opacity: 0;}

1.2 Скрываем окно


Сейчас наше окно всегда видно. Когда говорят о скрытии элементов, первое что приходит на ум это переключать свойство display со значения none до нашего значения flex.


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


Нам поможет другое свойство visibility:hidden. Оно скроет окно визуально, хотя и зарезервирует под него место. А так как все будущие окна на странице имеют фиксированное
позиционирование они будут полностью скрыты и не повлияют на остальную страницу. Кроме того, на элементы с visibility:hidden нельзя установить фокус с клавиатуры, а от скрин-ридеров мы уже скрыли окна с помощью атрибута aria-hidden="true".


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


.hystmodal--active{    visibility: visible;}.hystmodal--active .hystmodal__window{    transform: scale(1);    opacity: 1;}

1.3 Оформление подложки


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


Просто разместим элемент .hysymodal__shadow прямо перед закрывающие </body>. В будущем, сделаем так, чтобы этот элемент создавался автоматически из js при инициализации библиотеки.


Его свойства:


.hystmodal__shadow{    position: fixed;    border:none;    display: block;    width: 100%;    top: 0;    bottom: 0;    right: 0;    left: 0;    overflow: hidden;    pointer-events: none;    z-index: 98;    opacity: 0;    transition: opacity 0.15s ease;    background-color: black;}/* активная подложка */.hystmodal__shadow--show{    pointer-events: auto;    opacity: 0.6;}

1.4 Отключение прокрутки страницы


Когда модальное окна открывается, мы хотим, чтобы страница под ним не прокручивалась.
Самый простой способ этого добиться повесить overflow:hidden для body или html, когда окно открывается. Однако с этим есть проблема:


Проблема 4. В браузере Safari на iOS страница будет прокручиваться, даже если на тег html или body повешен overflow:hidden.
Решается двумя способами, либо блокированием событий прокрутки (touchmove, touchend или touchsart) из js вида:


targetElement.ontouchend = (e) => {    e.preventDefault();};

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


ps: можно конечно применить библиотеку scroll-lock, в которую заложено это решение, но в статье было решено воспользоваться другим вариантом.


Другое решение основано частично на CSS. Пусть когда окно открывается, на элемент <html> будет добавляться класс .hystmodal__opened:


.hystmodal__opened {    position: fixed;    right: 0;    left: 0;    overflow: hidden;}

Благодаря position:fixed, окно не будет прокручиваться даже в safari, однако здесь тоже не всё гладко:


Проблема 5. При открытии/закрытии окна страница прокручивается в начало.
Действительно, это происходит из-за изменения свойства position, текущая прокрутка окна сбрасывается.


Для решения, нам нужно написать следующий JS (упрощенно):


При открытии:


// Находим тег html и сохраняем егоlet html = document.documentElement;//сохраним текущую прокрутку:let scrollPosition = window.pageYOffset;//установим свойство top у html равное прокруткеhtml.style.top = -scrollPosition + "px";html.classList.add("hystmodal__opened");

При закрытии:


html.classList.remove("hystmodal__opened");//прокручиваем окно туда где оно былоwindow.scrollTo(0, scrollPosition);html.style.top = "";

Отлично, приступим к JavaScript коду.


2. Код JavaScript


2.2 Каркас библиотеки


Нам нужна совместимость со старыми браузерами включая IE11 поэтому нам нужно выбрать из 2 вариантов кодинга:


  • Разрабатывать на старом стандарте ES5, и использовать только те фичи, которые поддерживают все браузеры.
  • Применить современный синтаксис ES6, но подключить транспайлер Babel, который автоматически преобразует код для всех браузеров и встроит необходимые полифилы.
    Было принято решение использовать второй вариант, с прицелом на будущее.
    Приступим.

Основа нашей библиотеки единственный класс HystModal. Ниже я приведу скелет кода с комментариями, а потом добавим остальной функционал.


class HystModal{    /**     * При создании экземпляра класса, мы передаём в него     * js-объект с настройками. Он становится доступен     * в конструкторе класса в виде переменной props     */    constructor(props){        /**         * Для удобства некоторые свойства можно не передавать         * Мы должны заполнить их начальными значениями         * Это можно сделать применив метод Object.assign         */        let defaultConfig = {            linkAttributeName: 'data-hystmodal',            // ... здесь остальные свойства        }        this.config = Object.assign(defaultConfig, props);        // сразу вызываем метод инициализации        this.init();    }    /**      * В свойство _shadow будет заложен div с визуальной     * подложкой. Оно сделано статическим, т.к. при создании     * нескольких экземпляров класса, эта подложка нужна только     * одна     */    static _shadow = false;    init(){        /**         * Создаём триггеры состояния, полезные переменные и.т.д.         */        this.isOpened = false; // открыто ли окно        this.openedWindow = false; //ссылка на открытый .hystmodal        this._modalBlock = false; //ссылка на открытый .hystmodal__window        this.starter = false, //ссылка на элемент "открыватель" текущего окна        // (он нужен для возвращения фокуса на него)        this._nextWindows = false; //ссылка на .hystmodal который нужно открыть        this._scrollPosition = 0; //текущая прокрутка (см. выше)        /**         * ... остальное         */        // Создаём только одну подложку и вставляем её в конец body        if(!HystModal._shadow){            HystModal._shadow = document.createElement('div');            HystModal._shadow.classList.add('hystmodal__shadow');            document.body.appendChild(HystModal._shadow);        }        //Запускаем метод для обработки событий см. ниже.        this.eventsFeeler();    }    eventsFeeler(){        /**          * Нужно обработать открытие окон по клику на элементы с data-атрибутом         * который мы установили в конфигурации - this.config.linkAttributeName         *          * Здесь мы используем делегирование события клика, чтобы обойтись одним         * лишь обработчиком события на элементе html         *          */        document.addEventListener("click", function (e) {            /**             * Определяем попал ли клик на элемент,             * который открывает окно             */             const clickedlink = e.target.closest("[" + this.config.linkAttributeName + "]");            /** Если действительно клик был на              * элементе открытия окна, находим              * подходящее окно, заполняем свойства             *  _nextWindows и _starter и вызываем             *  метод open (см. ниже)             */            if (clickedlink) {                 e.preventDefault();                this.starter = clickedlink;                let targetSelector = this.starter.getAttribute(this.config.linkAttributeName);                this._nextWindows = document.querySelector(targetSelector);                this.open();                return;            }            /** Если событие вызвано на элементе             *  с data-атрибутом data-hystclose,             *  значит вызовем метод закрытия окна             */            if (e.target.closest('[data-hystclose]')) {                this.close();                return;            }        }.bind(this));        /** По стандарту, в обработчике события в this         * помещается селектор на котором события обрабатываются.         * Поэтому нам нужно вручную установить this на наш          * экземпляр класса, который мы пишем с помощью .bind().         */         //обработаем клавишу escape и tab        window.addEventListener("keydown", function (e) {               //закрытие окна по escape            if (e.which == 27 && this.isOpened) {                e.preventDefault();                this.close();                return;            }            /** Вызовем метод для управления фокусом по Tab             * и всю ответственность переложим на него             * (создадим его позже)             */             if (e.which == 9 && this.isOpened) {                this.focusCatcher(e);                return;            }        }.bind(this));    }    open(selector){        this.openedWindow = this._nextWindows;        this._modalBlock = this.openedWindow.querySelector('.hystmodal__window');        /** Вызываем метод управления скроллом         * он будет блокировать/разблокировать         * страницу в зависимости от свойства this.isOpened         */        this._bodyScrollControl();        HystModal._shadow.classList.add("hystmodal__shadow--show");        this.openedWindow.classList.add("hystmodal--active");        this.openedWindow.setAttribute('aria-hidden', 'false');        this.focusContol(); //вызываем метод перевода фокуса (см. ниже)        this.isOpened = true;    }    close(){        /**         * Метод закрытия текущего окна. Код упрощён         * подробнее в статье далее.         */        if (!this.isOpened) {            return;        }        this.openedWindow.classList.remove("hystmodal--active");        HystModal._shadow.classList.remove("hystmodal__shadow--show");        this.openedWindow.setAttribute('aria-hidden', 'true');        //возвращаем фокус на элемент которым открылось окно        this.focusContol();        //возвращаем скролл        this._bodyScrollControl();        this.isOpened = false;    }    _bodyScrollControl(){        let html = document.documentElement;        if (this.isOpened === true) {            //разблокировка страницы            html.classList.remove("hystmodal__opened");            html.style.marginRight = "";            window.scrollTo(0, this._scrollPosition);            html.style.top = "";            return;        }        //блокировка страницы        this._scrollPosition = window.pageYOffset;        html.style.top = -this._scrollPosition + "px";        html.classList.add("hystmodal__opened");    }}

Итак, мы описали класс HystModal. Чтобы всё работало, нужно всего лишь подключить наш скрипт и создать экземпляр класса:


const myModal = new HystModal({    linkAttributeName: 'data-hystmodal', });

Тогда по клику по ссылке/кнопке с атрибутом data-hystmodal, например такой: <a href="#" data-hystmodal="#myModal">Открыть окно</a> будет
открываться окно. Однако у нас появляются новые нюансы:


Проблема 6: если в браузере есть фиксированный скроллбар (который влияет на ширину страницы), то при открытии/закрытии окна происходит сдвиг контента, когда полоса прокрутки то появляется то пропадает.



Действительно скроллбар пропадает и контент страницы перераспределяется. Чтобы решить эту проблему, можно добавить отступ справа к тегу html, равный ширине скроллбара когда он пропадает.


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


Дополним метод _bodyScrollControl()


//при открытии окнаlet marginSize = window.innerWidth - html.clientWidth;//ширина скроллбара равна разнице ширины окна и ширины документа (селектора html)if (marginSize) {    html.style.marginRight = marginSize + "px";} //при закрытии окнаhtml.style.marginRight = "";

Почему код метода close() упрощён? Дело в том, что просто убирая классы CSS у элементов, мы не можем анимировать закрытие окна.


Проблема 7. При закрытии окна, свойство visibility:hidden применяется сразу и не даёт возможности анимировать закрытие окна.


Причина этого известна: свойство visibility:hidden не анимируется. Конечно, можно обойтись без анимации, но, если она нужна, сделаем следующее.


  • Создадим дополнительный CSS-класс .hystmodalmoved почти такой-же как и .hystmodal--active

.hystmodal--moved{    visibility: visible;}

  • Затем при закрытии сначала добавим этот класс к окну и повесим обработчик события transitionend на модальном окне. Затем удалим класс `.hystmodalactive, таким образом вызывая css-переход. Как только переход завершится, сработает обработчик события transitionend, в котором сделаем всё остальное и удалим сам обработчик события.

Ниже: новая версия методов закрытия окна:


close(){    if (!this.isOpened) {        return;    }    this.openedWindow.classList.add("hystmodal--moved");    this.openedWindow.addEventListener("transitionend", this._closeAfterTransition);    this.openedWindow.classList.remove("hystmodal--active");}_closeAfterTransition(){    this.openedWindow.classList.remove("hystmodal--moved");    this.openedWindow.removeEventListener("transitionend", this._closeAfterTransition);    HystModal._shadow.classList.remove("hystmodal__shadow--show");    this.openedWindow.setAttribute('aria-hidden', 'true');    this.focusContol();    this._bodyScrollControl();    this.isOpened = false;}

Вы заметили, что мы создали ещё один метод _closeAfterTransition() и перенесли основную логику закрытия туда. Это нужно, чтобы удалить обработчик события transitionend после закрытия окна, ведь в метод removeEventListener необходимо передать именно ту функцию, которую мы привязывали.


Кроме того, если анимация не будет нужна, можно просто вызвать this._closeAfterTransition() не вешая его на событие.


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


//внутри конструктораthis._closeAfterTransition = this._closeAfterTransition.bind(this)

2.2 Закрытие окна по клику на оверлей


Нам нужно обработать ещё одно событие закрытие окна по клику на элемент подложки .hystmodal__wrap. Мы можем повесить обработчик клика на документ для делегирования события как при открытии и проверить что событие произошло на .hystmodal__wrap примерно так:


document.addEventListener("click", function (e) {    const wrap = e.target.classList.contains('hystmodal__wrap');    if(!wrap) return;    e.preventDefault();    this.close();}.bind(this));

Это будет работать, но есть один малозаметный недостаток.


Проблема 8. Если кнопку мыши нажать внутри окна, а отпустить за его пределами (над подложкой), окно закрывается.


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


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


Мы могли бы решить это изменением html, добавляя ещё один div сразу после .hystmodal__window и размещая его визуально под окном. Но нам бы не хотелось добавлять лишний пустой div ещё сильнее усложняя разметку.


Мы можем разбить наш addEventListener на два отдельных обработчика: для событий mousedown и mouseup и будем проверять чтобы оба события происходили именно на .hystmodal__wrap. Добавим новые обработчики событий в наш метод eventsFeeler()


document.addEventListener('mousedown', function (e) {    /**    * Проверяем было ли нажатие над .hystmodal__wrap,    * и отмечаем это в свойстве this._overlayChecker    */    if (!e.target.classList.contains('hystmodal__wrap')) return;    this._overlayChecker = true;}.bind(this));document.addEventListener('mouseup', function (e) {    /**    * Проверяем было ли отпускание мыши над .hystmodal__wrap,    * и если нажатие тоже было на нём, то закрываем окно    * и обнуляем this._overlayChecker в любом случае    */    if (this._overlayChecker && e.target.classList.contains('hystmodal__wrap')) {        e.preventDefault();        !this._overlayChecker;        this.close();        return;    }    this._overlayChecker = false;}.bind(this));

2.3 Управление фокусом


У нас заготовлено два метода для управления фокусом: focusContol() для переноса фокуса внутрь окна и обратно при его закрытии, а также focusCatcher(event) для блокирования ухода фокуса из окна.


Решения для фокуса были реализованы аналогично js-библиотеке Micromodal (Indrashish Ghosh). А именно:


1.В служебный массив сохраним все css селекторы на которых может быть установлен фокус (свойство помещаем в init()):


//внутри метода init или конструктораthis._focusElements = [    'a[href]',    'area[href]',    'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',    'select:not([disabled]):not([aria-hidden])',    'textarea:not([disabled]):not([aria-hidden])',    'button:not([disabled]):not([aria-hidden])',    'iframe',    'object',    'embed',    '[contenteditable]',    '[tabindex]:not([tabindex^="-"])'];

2.В методе focusContol() находим первый такой селектор в окне и устанавливаем на него фокус, если окно открывается. Если же окно закрывается то переводим фокус на this.starter:


focusContol(){    /** Метод переносит фокус с элемента открывающего окно     * в само окно, и обратно, когда окно закрывается     * см. далее в тексте.     */    const nodes = this.openedWindow.querySelectorAll(this._focusElements);    if (this.isOpened && this.starter) {        this.starter.focus();    } else {        if (nodes.length) nodes[0].focus();    }}

3.В методе focusCatcher() находим в окне и превращаем в массив коллекцию всех элементов на которых может быть фокус. И проверяем, если фокус должен был выйти на пределы окна, то вместо этого устанавливаем фокус снова на первый или последний элемент (ведь фокус можно переключать как по Tab так и по Shift+Tab в обратную сторону).


Результирующий код метода focusCatcher:


focusCatcher(e){    /** Метод не позволяет фокусу перейти вне окна при нажатии TAB     * элементы в окне фокусируются по кругу.     */    // Находим все элементы на которые можно сфокусироваться    const nodes = this.openedWindow.querySelectorAll(this._focusElements);    //преобразуем в массив    const nodesArray = Array.prototype.slice.call(nodes);    //если фокуса нет в окне, то вставляем фокус на первый элемент    if (!this.openedWindow.contains(document.activeElement)) {        nodesArray[0].focus();        e.preventDefault();    } else {        const focusedItemIndex = nodesArray.indexOf(document.activeElement)        if (e.shiftKey && focusedItemIndex === 0) {            //перенос фокуса на последний элемент            focusableNodes[nodesArray.length - 1].focus();        }        if (!e.shiftKey && focusedItemIndex === nodesArray.length - 1) {            //перерос фокуса на первый элемент            nodesArray[0].focus();            e.preventDefault();        }    }}

По сути мы реализовали все необходимое для успешного создания модальных окон, осталось ещё несколько дел:


Проблема 9. В IE11 не работают методы Element.closest() и Object.assign().


Для поддержки Element.closest, воспользуемся полифилами для closest и matches от MDN.


Можно их вставить просто так, но так как у нас проект всё равно собирается webpack, то удобно воспользоваться пакетом element-closest-polyfill который просто вставляет этот код.


Для поддержки Object.assign, можно воспользоваться уже babel-плагином @babel/plugin-transform-object-assign


3. Заключение и ссылки


Повторяя начало статьи, всё изложенное выше, я оформил в маленькую библиотеку hystModal под MIT-лицензией. Вышло 3 кБ кода при загрузке с gzip. Ещё написал для неё подробную документацию на русском и английском языке.


Что вошло ещё в библиотеку hystModal, чего не было в статье:


  • Настройки (вкл/выкл управление фокусом, варианты закрытия, ожидание анимации закрытия)
  • Коллбеки (функции вызывающиеся перед открытием окна и после его закрытия (в них передаётся объект модального окна))
  • Добавлен запрет на какие-либо действия пока анимация закрытия окна не завершится, а также ожидание анимации закрытия текущего окна перед открытием нового (если окно открывается из другого окна).
  • Оформление кнопки-крестика закрытия в CSS
  • Минификация CSS и JS плагинами Webpack.

Если вам будет интересна эта библиотека, буду рад звёздочке в GitHub, или напишите в Issues о найденных багах. (Особенно большие проблемы, наверное, в грамматике английской версии документации, так как мои знания языка пока на начальном уровне. Связаться со мной также можно в Instagram

Подробнее..

Фреймворк Webix Jet глазами новичка. Часть 2. Взаимодействие с интерфейсом

04.05.2021 12:13:46 | Автор: admin

В предыдущей статье Фреймворк Webix Jet глазами новичка. Часть 1. Композиция и навигация мы подробно разобрали создание интерфейса нашего приложения с помощью UI компонентов Webix и распределили полномочия между view-модулями и моделями внутри архитектуры Jet фреймворка.

В этой статье мы продолжим изучать Jet и библиотеку Webix и реализуем следующее:

  • добавим в уже известное вам приложение немного интерактива

  • организуем серверные модели с разными подходами к загрузке и сохранению данных.

С кодом готового приложения можно ознакомиться тут.

Коротко о Webix и Webix Jet

Не лишним будет напомнить, что Webix это библиотека UI компонентов. Для создания полноценного приложения на основе Webix UI вам не обязательно использовать дополнительные инструменты. Однако по мере расширения и увеличения количества кода могут возникнуть трудности с его организацией. Команда Webix позаботилась о решении этой проблемы и разработала собственный фреймворк для построения архитектуры приложения.

Фреймворк Webix Jet позволяет создавать очень гибкое и хорошо управляемое одностраничное приложение, используя паттерн Model-View. При таком подходе, логика работы с данными и отображение элементов интерфейса четко разделяются. Каждый модуль разрабатывается и тестируется отдельно от других. Также имеются готовые решения многих задач, которые реализуются через API, плагины и конфигурации. В этой статье мы постараемся детальнее разобраться как все работает и дополним наше приложение новым функционалом.

В документацияхWebix UIиWebix Jetможно ознакомиться с использованными в статье компонентами и API фреймворка.

Модель данных для фильмов

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

Интерфейс вкладки ПанельИнтерфейс вкладки Панель

Если 2 модуля используют одни и те же данные, хорошей практикой будет загрузить их 1 раз и импортировать в нужные модули. Для этих случаев у библиотеки Webix предусмотрена такая сущность как DataCollection. Именно ее мы будем использовать в качестве модели для работы с данными.

Давайте создадим нашу коллекцию, которая будет выступать этаким буфером между view-модулями и сервером:

export const data = new webix.DataCollection({  url:"../../data/film_data.js",  save:"rest->save/films"});

В качестве параметров мы передаем конструктору DataCollection объект со свойствами url и save. Свойство url хранит путь, по которому данные будут загружаться в коллекцию при ее инициализации. Свойство save хранит путь, по которому будут отправляться запросы на изменение данных (добавление, обновление и удаление) соответствующими методами (POST, PUT, DELETE).

Итак, хранилище данных у нас есть. Давайте импортируем созданную модель в каждый из модулей FilmsView и FormView с помощью следующей строки:

import { data } from "models/films";

В модуле FilmsView нам нужно загрузить данные коллекции в таблицу при помощи ее метода parse(). Лучше всего это делать после инициализации компонента. Для этого предусмотрен такой JetView метод как init():

init(){  this.datatable = this.$$("datatable"); //получаем доступ к таблице через ее localIdthis.datatable.parse(data); //загружаем данные в таблицу}

Взаимодействие между модулями FilmsView и FormView через URL

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

Есть несколько рекомендуемых способов коммуникации, и один из них реализуется через URL. При таком подходе, один модуль будет устанавливать определенное значение в параметр url, а другой считывать его. Давайте посмотрим как это реализуется на примере наших модулей FilmsView и FormView.

Настраиваем модуль FilmsView

Представим ситуацию, что пользователь пролистал таблицу и выбрал некий элемент под номером 100. Как зафиксировать это состояние? Можно решить эту проблему с помощью url. Расклад будет таким, что при выборе элемента в таблице мы будем устанавливать его id в качестве параметра url. Делается это с помощью такого JetView метода как setParam(). В качестве аргументов нужно передать название url-параметра и его значение (в нашем случае это id выбранного элемента таблицы), а также аргумент, который отвечает за отображение установленного url параметра (его нужно установить в значении true).

Чтобы все это реализовать, нужно задать обработчик события onAfterSelect для таблицы и там вызвать вышеупомянутый метод. Делаем это с помощью специального свойства on:

{  view:datatable,  columns:[  ],  on:{ onAfterSelect:(id) => this.setParam("id", id, true) }}

Теперь, при клике на любую запись таблицы, мы увидим ее id в параметре url. Выглядеть это будет следующим образом:

//значение url при выборе сотого элемента таблицыhttp://localhost:8080/#!/top/films?id=100 

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

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

Для этих целей JetView предусматривает специальный метод urlChange(), который вызывается при любых изменениях в url. В теле метода мы получаем значение id, установленного нами в параметре url с помощью такого JetView метода как getParam(). Далее, необходимо проверить наличие записи с таким идентификатором в нашей коллекции data, с которой связана таблица фильмов, а после выбрать ее в таблице.

Выглядит это следующим образом:

urlChange(){  const id = this.getParam("id");  if(data.exists(id)){ //проверка существования записи    this.datatable.select(id); //выбор записи  }}

Теперь можно усложнить задачу. Представим, что пользователь только загрузил страницу и еще не выбрал нужный ему элемент. Что будет в этом случае? Давайте сделаем так, чтобы выбирался первый элемент таблицы, которая связана с коллекцией.

Для этого нам нужно вернуться к методу urlChange() и немного его модернизировать. Чтобы получить id первого элемента, у коллекции предусмотрен такой метод как getFirstId(), который мы и будем использовать. Теперь код будет иметь следующий вид:

urlChange(){  const id = this.getParam("id") || data.getFirstId();  if(data.exists(id)){    this.datatable.select(id);    this.datatable.showItem(id);  }}

В таком состоянии метод сначала считывает и проверяет значение id c параметра url, а в случае, если он не установлен, получает id первого элемента коллекции. Далее он выбирает нужный нам элемент в таблице ее методом select(), а также прокручивает таблицу до выбранной записи с помощью метода showItem(). Это удобно, если таблица имеет много записей.

Теперь все работает красиво. Осталось учесть один небольшой нюанс. Если данные не успели загрузиться, наша задумка может не сработать, так как таблица будет еще пустой. Здесь нужно упомянуть, что у всех дата-компонентов, включая коллекцию, есть такое свойство как waitData, в котором хранится промис объект загружаемых данных. Давайте воспользуемся этим преимуществом и дождемся полной загрузки данных в коллекцию, а тогда уже выполним необходимые нам действия. В конечном итоге код метода urlChange() будет таким:

urlChange(){  data.waitData.then(() => {    const id = this.getParam("id") || data.getFirstId();    if(data.exists(id)){      this.datatable.select(id);      this.datatable.showItem(id);    }});}

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

Настройка модуля FormView

По нашему замыслу, если пользователь выбирает любую запись в таблице фильмов, соответствующие данные должны отображаться в полях формы модуля FormView.

Как вы помните, id выбранного элемента таблицы хранится в параметре url и меняется в соответствии с выбранной записью. Давайте поймаем его изменение в методе urlChange() нашего модуля и используем по назначению. Для этого нужно определить и настроить метод:

urlChange(){const id = this.getParam("id");if(id && data.exists(id)){const form_data = data.getItem(id);this.form.setValues(form_data);}}

Как и в примере с модулем FilmsView, мы получаем значение id из параметра url при помощи JetView метода getParam(). Дальше необходимо проверить, существует ли запись с указанным id в нашей коллекции data, которую мы ранее сюда импортировали. Если все условия соблюдены, остается получить данные из коллекции и установить их в соответствующие поля формы.

Чтобы получить необходимую запись коллекции по id, необходимо вызвать у нее метод getItem() и передать id нужного элемента в качестве параметра.

Дальше мы отправляем объект с данными в соответствующие поля формы с помощью ее метода setValues(). Он установит значения для всех полей сразу. Нужно учесть, что свойство name каждого поля формы должно соответствовать ключам, под которыми данные хранятся в объекте.

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

Работа с данными через коллекцию

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

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

export const data = new webix.DataCollection({url:"../../data/film_data.js",save:"rest->save/films"});

Но как коллекция сможет их отправить?

Как и в случае с другими дата-виджетами Webix, свойство save у коллекции обеспечивает автоматизацию серверных запросов, а именно, при любом добавлении, обновлении и удалении данных, коллекция сможет сама отправить запрос на сервер.

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

Теперь давайте перейдем непосредственно к самим операциям.

Начнем мы пожалуй с формы, которая хранится в модуле FormView. Напомню, что данные попадают в форму при выборе любой записи в таблице. Что должно произойти дальше? Правильно, пользователь должен их отредактировать и отправить в коллекцию при клике на кнопку Save. Чтобы это реализовать, нужно создать обработчик. Давайте этим и займемся:

saveFilmHandler(){  const values = this.form.getValues();  if(this.form.validate()){    if(data.exists(values.id)){      data.updateItem(values.id, values);    }  }}

Здесь мы получаем объект со значениями формы с помощью ее метода getValues(), запускаем валидацию методом validate() и, в случае успеха, проверяем наличие этих данных в коллекции соответствующим методом exists().

Для обновления данных в коллекции у нее предусмотрен такой метод как updateItem(). В качестве аргумента мы должны передать id, под которым хранятся данные и непосредственно сам объект с данными:

if(data.exists(values.id)){data.updateItem(values.id, values); //обновляет данные в коллекции по id}

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

{ view:"button", value:"Save", click:() => this.saveFilmHandler() }

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

С изменением уже существующих данных все понятно. А как же добавить новые спросите вы? А это еще проще. Давайте предусмотрим такой сценарий, чтобы после очищения формы, введенные данные добавлялись в начало таблицы. Сперва создадим обработчик, который будет очищать форму при клике по кнопке Clear:

clearFormHandler(){this.form.clear(); //очищаем все поля формыthis.form.clearValidation(); //убираем маркеры валидации}

После этого, устанавливаем обработчик на событие клика по соответствующей кнопке:

{ view:"button", value:"Clear", click:() => this.clearFormHandler() }

После очистки формы можно ввести новые данные и попробовать сохранить их. Результата не будет, потому что наш обработчик saveFilmHandler() настроен только на обновление существующей записи по ее id. Давайте его немного модифицируем и добавим метод add(), который будет отправлять новые данные в коллекцию. Теперь наш обработчик выглядит следующим образом:

saveFilmHandler(){const values = this.form.getValues();if(this.form.validate()){if(data.exists(values.id)){data.updateItem(values.id, values);}else{data.add(values, 0); //добавляет новую запись в первую позицию}}}

Методу add() мы передаем объект с данными и индекс, под которым эти данные должны добавиться. Теперь осталось только проверить результат. Очищаем форму, вводим новые данные и сохраняем их. В начале таблицы должны появиться новые данные.

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

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

Чтобы добавить иконку, нужно перейти к настройкам столбца и добавить следующий объект в массив свойства columns:

columns:[...{ template:"{common.trashIcon()}", width:50 }] 

Свойство template задает шаблон для ячейки столбца, а common.trashIcon() вставляет соответствующую иконку. В интерфейсе это выглядит следующим образом:

Шаблон спискаШаблон списка

Теперь давайте зададим поведение по клику на эту иконку. Для таких случаев у таблицы предусмотрено свойство onClick. C его помощью можно установить обработчик на любой элемент таблицы, которому присвоен соответствующий css класс. В нашем случае это "wxi-trash". Выглядит обработчик следующим образом:

onClick:{"wxi-trash":function(e, id){...}}

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

onClick:{"wxi-trash":function(e, id){data.remove(id);}}

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

Модель данных для пользователей

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

Интерфейс вкладки ПользователиИнтерфейс вкладки Пользователи

Для этих двух view-компонентов мы также используем серверные данные. Так как они находятся в пределах одного модуля, коллекция нам не нужна. Давайте пойдем другим путем и настроим загрузку/сохранение данных с помощью соответствующих функций модели:

  • getData()

  • saveData()

Функция getData() будет только загружать данные. Она имеет следующий вид:

export function getData(){return webix.ajax(load/users);}

Функция saveData() будет отправлять запросы для изменения данных на сервер разными методами (POST, PUT, DEL), в зависимости от типа операции. Она будет иметь следующий вид:

export function saveData(operation, data){const url = "save/users"; if(operation == "add"){return webix.ajax().post(url, data);}else if(operation == "update"){return webix.ajax().put(url, data);}else if(operation == "remove"){return webix.ajax().del(url, data);}}

Итак, с функциями модели данных мы определились. Сейчас нужно импортировать их в модуль UsersView при помощи следующей команды:

import { getData, saveData } from "models/users";

После этого необходимо загрузить данные в список при помощи метода parse() и синхронизировать диаграмму со списком ее методом sync(). Все это делаем после инициализации в методе init():

init(view){this.list = view.queryView("list"); //получаем доступ к спискуthis.list.parse(getData()); //загружаем данные в списокconst chart = view.queryView("chart"); //получаем доступ к диаграммеchart.sync(this.list); //синхронизируем диаграмму со списком}

Взаимодействие через методы классов и глобальные события

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

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

Также, Jet App позволяет вызывать какое-либо событие в одном модуле, а считывать его в любом другом. Звучит хорошо, поэтому давайте разбираться как это все можно применить к нашему приложению, в частности к модулю UsersView.

Сценарий предполагает, что при клике по кнопке Add на тулбаре модуля, в ответ должно появиться всплывающее окно с формой редактирования списка пользователей, куда нужно ввести соответствующие данные. Для начала давайте создадим это окно и научимся его отображать.

Создаем всплывающее окно с формой

Чтобы создать всплывающее окно, нам не нужно придумывать велосипед. Webix все предусмотрел и предоставил в наше распоряжение такой компонент как window. Давайте создадим отдельный модуль WindowView в файле views/window.js, в котором и опишем данный компонент.

Создаем класс WindowView и наследуем его от JetView. В методе config() описываем и возвращаем компонент window:

export default class WindowView extends JetView {config(){    const window = {      view:"window",       position:"center", //позиционируем компонент в центре экрана      close:true, //добавляем иконку для закрытия окна      head:_("EDIT USERS"), //добавляем название в шапке окна      body:{  } //определяем содержимое окна    }return window;}}

В браузере мы получим следующий результат:

Всплывающее окноВсплывающее окно

Теперь надо добавить непосредственно форму для редактирования данных пользователя. Реализуется это в объекте свойства body с помощью компонента form:

body:{view:"form",localId:"form",elements:[/*элементы формы*/]}

В массиве свойства elements определяем нужные нам поля формы:

elements:[{ view:"text", name:"name", label:"Name", required:true },{ view:"text", name:"age", label:"Age", required:true, type:"number" },{ view:"text", name:"country", label:"Country" },{margin:10, cols:[{ view:"button", value:"Save", css:"webix_primary" }]}]

Теперь всплывающее окно с формой будет иметь следующий вид:

Всплывающее окно с формойВсплывающее окно с формой

По умолчанию, окна в Webix создаются скрытыми. Нам же нужно будет отображать окно с формой при клике по кнопке Add new в тулбаре модуля UsersView. Давайте определим метод модуля WindowView, который будет это делать:

showWindow(){this.getRoot().show();}

В теле метода мы используем JetView метод getRoot(), чтобы получить доступ к view компоненту window, который хранится в этом модуле. Далее мы отображаем окно его методом show().

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

Настраиваем взаимодействие через методы

У нас есть модуль WindowView с интерфейсом всплывающей формы и методом для ее отображения. Нам нужно импортировать этот модуль в UsersView и создать там экземпляр окна.

В начале файла view/users.js импортируем модуль окна в UsersView:

import WindowView from "views/window";

В JetView методе init() класса UsersView создаем экземпляр окна с формой, а заодно получаем доступ к методу showWindow(), который отвечает за его отображение. Реализуется это следующим образом:

init(view){//создаем окно с формой, которое хранится в модуле WindowViewthis.form = this.ui(WindowView); }

Давайте воспользуемся этим преимуществом и вызовем доступный нам метод showWindow() в качестве обработчика на событие клика по кнопке Add new, которая находится в тулбаре модуля. Делаем мы это с помощью знакомого нам свойства click, которое предусмотрено у кнопок именно для таких случаев:

view:"toolbar",elements:[{ view:"button", value:"Add new", click:() => this.form.showWindow() },]

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

Настраиваем взаимодействие через глобальные события

Сейчас мы можем вызывать всплывающее окно с формой и вводить туда данные. Давайте передадим значения полей формы в модуль UsersView при клике на кнопку Save. А реализуем мы это с помощью глобальных событий.

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

saveUserHandler(){const data = this.form.getValues();this.app.callEvent("onDataChange", [data]);}

Как мы видим, реализуется это достаточно просто. Для создания и отправки события необходимо вызвать метод this.app.callEvent() в контексте всего приложения (this.app), а также передать название события и объект с данными в качестве параметров.

Чтобы получить объект со значениями полей формы, используем ее метод getValues(). Здесь также стоит упомянуть о том, что при создании формы мы указали свойство localId в значении form. Это и позволит нам получить доступ к форме. В нашем случае нужно будет несколько раз обращаться к ней, поэтому логичнее будет сохранить доступ в переменную, которая будет доступна только в пределах текущего модуля. Сделать это желательно в JetView методе init(), который хранит логику для выполнения после инициализации модуля:

init(){this.form = this.$$("form");}

Теперь форма доступна для всего модуля через переменную this.form.

Нужно учесть тот факт, что для формы заданы 2 правила (поля должны быть заполнены). Давайте проверим их методом validate(), который возвращает true или false, на успех или неудачу:

saveUserHandler(){if(this.form.validate()){    const data = this.form.getValues();    this.app.callEvent("onDataChange", [data]);}}

После всех проделанных действий нужно скрыть окно. Для этого у него предусмотрен метод hide(). В общем итоге, наш обработчик выглядит следующим образом:

saveUserHandler(){if(this.form.validate()){const data = this.form.getValues();this.app.callEvent("onDataChange", [data]);this.form.hide();}}

Давайте установим его на событие клика по кнопке Save. Напомню, что для таких случаев у Webix кнопок предусмотрено специальное свойство click:

{ view:"button", value:"Save", ... , click:() => this.saveUserHandler() }

Теперь, при клике по кнопке Save, модуль WindowView отправит глобальное событие вместе с объектом значений формы. Нам же необходимо поймать отправленное событие и получить этот объект в модуле UsersView, чтобы отправить данные на сервер с помощью функции модели. Этим мы сейчас и займемся.

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

init(view){...this.form = this.ui(WindowView);this.on(this.app, "onDataChange", (data) => {...});}

Использованный нами JetView метод this.on() ловит событие onDataChange на уровне всего приложения (this.app), получает объект со значениями формы и выполняет указанные в обработчике действия.

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

Работа с данными через функцию модели

Мы создали функции модели данных, импортировали их и настроили взаимодействие между модулями WindowView и UsersView с помощью методов и глобальных событий. Теперь давайте посмотрим на то, как на самом деле происходит обмен данными между компонентами интерфейса и сервером через нашу функцию сохранения.

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

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

Давайте посмотрим как реализуются операции с данными в модуле UsersView. И начнем мы с добавления новых пользователей.

Добавление данные новых пользователей

Итак, наш пользователь вызвал форму, ввел необходимые данные и кликнул по кнопке Save. С помощью глобального события данные пришли с модуля WindowView в модуль UsersView и доступны в обработчике, который поймал событие. Теперь нам нужно отправить данные на сервер. Как это сделать? Давайте воспользуемся функцией saveData(), которую мы создали и импортировали из модели. В качестве аргументов нужно передать название операции и сам объект с данными:

this.on(this.app, "onDataChange", (data) => {saveData("add", data).then((res) => this.list.add(res.data, 0));});

Сейчас метод модели может отправить данные на сервер и будет терпеливо ждать ответа. Если данные успешно сохранились на сервере, метод вернет промис, который содержит добавленный объект с серверным id. Этот объект мы добавляем в list, вызывая у него метод add(). Вторым аргументом мы передаем индекс позиции, в которую нужно добавить данные.

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

Обновляем данные пользователей

Для этого нам нужно перейти к настройкам компонента list и внести некоторые коррективы. А менять мы будем шаблон отображения списка, который задается через свойство template. Давайте зададим 2 иконки, для редактирования и удаления, на которые в дальнейшем установим соответствующие обработчики. В коде это выглядит следующим образом:

{  view:"list",  template:"#name#, #age#, #country#   <span class='remove_list_item_btn webix_icon wxi-trash'></span>   <span class='edit_list_item_btn webix_icon wxi-pencil'></span>"}

В браузере мы получим следующий результат:

Иконки Редактировать и Удалить в правой части спискаИконки Редактировать и Удалить в правой части списка

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

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

Для начала создадим и установим обработчик, который будет показывать окно с формой. У списка, также как и у таблицы, предусмотрено свойство onClick, с помощью которого можно установить обработчик на любой элемент с указанным css классом. Иконке редактирования мы присвоили класс edit_list_item_btn с которым и будем работать:

onClick:{edit_list_item_btn:(e,id) => {this.form.showWindow();}}

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

Чтобы получить объект с интересующими нас данными, воспользуемся методом списка getItem(). В качестве аргумента передадим id, который получили при клике по элементу:

onClick:{edit_list_item_btn:(e,id) => {const data = this.list.getItem(id);this.form.showWindow(data);}}

Но и это еще не все. Поля формы по прежнему остаются пустыми, так как метод showWindow() пока ничего не делает с нашим параметром. Чтобы это изменить, нужно вернуться в модуль WindowView и предусмотреть наличие объекта с данными в качестве параметра. Если он передан а на добавление он не передается необходимо установить данные в поля формы с помощью метода формы setValues():

showWindow(data){this.getRoot().show();if(data){this.form.setValues(data);}}

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

Давайте отправим эти данные на сервер при клике по кнопке Save, которая передаст их в функцию нашей модели. Здесь нужно установить дополнительную проверку, которая будет определять дальнейшие действия. Если id существует, метод обновляет данные, если нет добавляет новые. На практике это выглядит следующим образом:

this.on(this.app, "onDataChange", (data) => {  if(data.id){    saveData("update", data).then(      (res) => this.list.updateItem(res.data.id, res.data)    );  }else{    saveData("add", data).then((res) => this.list.add(res.data, 0));  }});

Обновление данных работает по аналогии с добавлением. Метод модели отправляет запрос на сервер и ждет ответ. Если все проходит гладко, метод возвращает промис объект с измененными данными, а мы обновляем их в компоненте с помощью такого метода списка как updateItem(), которому передаем id и объект с данными.

Удаляем данные пользователей

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

onClick:{edit_list_item_btn:(e,id) => {  },remove_list_item_btn:(e,id) => {const data = this.list.getItem(id);saveData("remove", data).then((res) => this.list.remove(res.data.id));}}

Здесь мы получаем объект с данными и отправляем запрос на удаление через ту же функцию модели saveData(). Если сервер одобрит наш запрос, удаляем данные из списка его методом remove(), которому передаем id нужного элемента.

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

Добавляем поиск и сортировку

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

Контролы сортировки и поискаКонтролы сортировки и поиска

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

Сортировка реализуется с помощью такого метода списка как sort(). Он будет сортировать данные списка по имени пользователя в указанном порядке (по возрастанию или убыванию).

Заключение

С помощью возможностей фреймворка Jet, а также методов и компонентов библиотеки Webix, мы создали и оживили полноценное одностраничное приложение. Интерфейс разделен на view модули, которые хранятся в отдельной директории sources/views, а логика работы с данными отделена от компонентов интерфейса и хранится в директории sources/models.

С кодом готового приложения можно ознакомиться тут.

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

Помимо этого, WebixJet имеет огромный набор инструментов и конфигураций, которые нереально охватить в одной статье и реализовать в одном приложении. Но есть факты, с которыми трудно спорить - использование библиотеки Webix и фреймворка Jet значительно упростят процесс разработки приложения любой сложности.

Подробнее..

Webix Datatable. От простой таблицы к сложному приложению

14.06.2021 10:08:37 | Автор: admin

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

Библиотека Webix и виджет DataTable

Webix UI это современная и производительная JavaScript библиотека, которая позволяет создавать дружественный интерфейс на основе собственных ui компонентов. Диапазон ее возможностей представлен виджетами различной сложности, начиная обычной кнопкой и заканчивая комплексными виджетами. Помимо самих компонентов, библиотека предоставляет множество дополнительных инструментов для работы с ними. Здесь стоит упомянуть механизм обработки событий, методы для работы с данными, взаимодействие с сервером, темы для стилизации и многое другое. Обо всем этом и не только, вы можете узнать в документации.

Виджет DataTable - это один из самых функциональных компонентов библиотеки Webix. С его помощью вы можете отображать данные в виде таблиц и очень гибко их настраивать. Этот мощный и одновременно простой в использовании инструмент со стильным дизайном поддерживает различные форматы данных (XML, JSON, CSV, JSArray, HTML tables) и довольно быстро работает с большими объемами информации. Секрет его скорости заключается в так называемом "ленивом подходе отрисовке данных". Это не значит, что ему лень отрисовывать данные. Хотя, без сомнений, крупица правды в этом есть. Суть же подхода заключается в том, что даже если вы загрузите в таблицу 1 000 000 рядов, выджет отрисует только выдимые в окне браузера элементы. Стоит также сказать, что среди своих конкурентов виджет удерживает лидирующее место по скорости отрисовки.

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

Можно много рассуждать об удобстве работы с таблицами Webix и их обширных возможностях, но я предлагаю оставить патетику ораторам и попробовать разобраться во всем на практике. Давайте создадим небольшое приложение, которое будет отображать таблицу данных об аренде автомобилей. На наглядном примере гораздо проще увидеть все преимущества работы с этим мощным инструментом. Успешное применение этого виджета также описано в статье "Создаем Booking приложение с Webix UI".

С готовым приложением можно ознакомиться тут.

Базовые приготовления

Для того чтобы использовать возможности библиотеки Webix в нашем приложении, необходимо подключить ее в главном файле index.html. Здесь стоит упомянуть о том, что существует 2 версии библиотеки: базовая и Pro-версия. Базовая версия бесплатная и предоставляет ограниченный набор возможностей, по сравнению с Pro-версией. Мы же воспользуемся тестовой лицензией расширенной Pro-версии, чтобы по максимуму реализовать возможности виджета DataTable. Необходимые файлы доступны через CDN по следующим ссылкам:

<script type="text/javascript" src="http://personeltest.ru/away/cdn.webix.com/site/webix.js"></script><link rel="stylesheet" type="text/css" href="http://personeltest.ru/away/cdn.webix.com/site/webix.css">

Нам остается только включить их в файл index.html нашего приложения. Теперь он будет так:

<!DOCTYPE html><html>  <head>    <title>Webix Booking</title>    <meta charset="utf-8">    <!--Webix sources -->    <script type="text/javascript" src="http://personeltest.ru/away/cdn.webix.com/site/webix.js"></script>    <link rel="stylesheet" type="text/css" href="http://personeltest.ru/away/cdn.webix.com/site/webix.css">  </head>  <body>    <script type="text/javascript">      //...    </script>  </body></html>

Внутри кода мы добавим теги <script>...</script>, где и будем собирать наше приложение.

Инициализация

Все основные действия будут разворачиваться внутри конструктора webix.ui(). Нам же нужно удостовериться в том, что код начнет выполняться после полной загрузки HTML страницы. Для этого необходимо обернуть конструктор в webix.ready(function(){}). Выглядит это следующим образом:

webix.ready(function(){  webix.ui({    /*код приложения*/  });});

Мы создали базовый index.html файл и подключили необходимые инструменты. Теперь самое время перейти непосредственно к настройке самого компонента DataTable.

Сила в простоте

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

const datatable = {  view:"datatable",  autoConfig:true,  url:"./data/data.json"}

Сам компонент DataTable объявляется с помощью выражения view:"datatable". Через свойство url мы задаем путь, по которому виджет загружает данные. Стоит уточнить, что по умолчанию виджет ожидает получить данные в формате JSON. Если данные приходят в другом формате (xml, jsarray или csv), нужно указать его через свойство datatype. В случае, когда данные находятся на клиенте в виде массива, их можно передать компоненту через свойство data или метод parse().

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

Теперь давайте отобразим компонент в браузере и посмотрим, что же у нас получилось. Для удобства, мы сохраним конструктор виджета в переменную datatable, которую будем использовать для сборки в файле index.html.

С помощью следующего кода мы подключаем файл с компонентом DataTable в файле index.html:

<!--App sources --><script src="js/datatable.js" type="text/javascript" charset="utf-8"></script>

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

<script type="text/javascript"> webix.ready(function(){  webix.ui( datatable ); });</script>

В браузере мы увидим следующий результат:

AвтонастройкаAвтонастройка

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

Тонкости настроек таблицы

Если автонастройка кажется вам слишком уж тривиальным вариантом, тогда давайте усложним задачу и зададим индивидуальные настройки для каждого столбца в отдельности. Сделать это можно в массиве свойства columns:[ ]. Для каждого столбца нужно задать объект с соответствующими настройками. Стоить учитывать то, что порядок отображения столбцов в таблице напрямую зависит от порядка объектов с настройками в массиве.

В первую очередь, для каждого столбца нужно задать уникальный id. Он должен соответствовать ключу, под которым данные хранятся в объекте (хранилище). Затем можно указать название для шапки столбца с помощью свойства header.

После этого можно задать ширину каждого столбца. Для этого предусмотрены такие свойства как width, minWidth, maxWidth и fillspace. Если мы хотим, чтобы ширина таблицы подстраивалась под ширину контейнера или под доступное ей место, нужно использовать свойство fillspace по крайней мере для одного столбца. Теперь настройки столбцов будут выглядеть так:

{  view:"datatable",  columns:[    { id:"rank", header:"Rank", width:45 },    //...    { id:"vin_code", header:"VIN", minWidth:50, width:180, maxWidth:300 },    //...    { id:"address", header:"Address", minWidth:200, fillspace:true },    //...  ],  url:"./data/data.json"}

В браузере мы получим следующий результат:

Индивидуальные настройки столбцовИндивидуальные настройки столбцов

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

Помимо индивидуальных настроек ширины для каждого столбца, вы можете задать глобальные настройки размеров для всех столбцов в конструкторе компонента. Для этого предусмотрено свойство autowidth, которое подстраивает ширину виджета к суммарной ширине всех столбцов таблицы, а также свойство columnWidth, которое задает фиксированную ширину для всех столбцов. По умолчанию стандартная ширина столбца равна 100px.

Для полноты картины давайте предоставим пользователям возможность изменять размеры столбцов. Для этого необходимо задать свойству resizeColumn значение true, а также установить границы для рядов, столбцов и их хедеров с помощью свойства css:"webix_data_border webix_header_border".

Так как у таблицы есть много столбцов, необходимо предусмотреть горизонтальную и вертикальную прокрутку. Сделать это можно с помощью свойства scroll, которое изначально установлено в значении false. Нам же нужно задать ему значение xy.

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

Настройка содержимого ячеек

Работа с шаблонами

По умолчанию, ячейки таблицы заполняются данными, ключ которых задан в качестве id в настройках столбца. Но виджет позволяет нам управлять их отображением. С помощью свойства template мы можем задать необходимый шаблон, по которому данные будут отображаться в ячейке. Значение можно указать как в виде строки, так и в виде функции. Чтобы использовать в строковом шаблоне входящие данные, их ключ нужно указать как #data_key#. У нас есть несколько столбцов, для которых необходимо задать шаблоны.

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

{  view:"datatable",  id:"car_rental_table",  //...  columns:[    { id:"stared", header:"",     template:function(obj){       return `<span class='webix_icon star mdi mdi-"+(obj.star ? "star" : "star-outline") + "'></span>`;     }, ...,    },     //...  ]}

Свойству template мы присваиваем функцию, которая возвращает элемент span с определенными классами. Классы star и star-outline мы будем менять динамически при клике по иконке. Давайте создадим функцию, которая будет менять классы для иконок этого столбца:

function selectStar(id){  const table = $$("car_rental_table");  const item = table.getItem(id);  const star = item.star?0:1;  item.star = star;}

В качестве аргумента функция принимает id выбранного ряда. Через метод $$("car_rental_table") мы получаем доступ к виджету по его id. С помощью метода таблицы getItem(), который принимает id элемента в качестве параметра, мы получаем объект данных ряда. Затем проверяем наличие ключа star и присваиваем ему значение 0 (если он существует) либо 1 (если его нет).

Чтобы переключение начало работать, необходимо установит эту функцию в качестве обработчика на событие клика по иконке со звездочкой. Для того чтобы установить обработчик на любой элемент таблицы с определенным css классом, у таблицы предусмотрено специальное свойство onClick:

//...url:"./data/data.json",onClick:{  "star":(e,id) => selectStar(id)},//...

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

Шаблон для столбца со звездочкамиШаблон для столбца со звездочками

На очереди у нас столбец с названием Available. В его ячейках хранятся значения true и false, которые обозначают доступность автомобиля в текущий момент времени. Давайте зададим шаблон, который будет менять входящее значения ячейки на соответствующий текст Yes или No.

Для этого мы создадим специальную функцию, которая и будет менять нужные нам значения. Выглядит она так:

function customCheckbox(obj, common, value){  if(value){    return "<span class='webix_table_checkbox checked'> YES </span>";  }else{    return "<span class='webix_table_checkbox notchecked'> NO </span>";  }}

Теперь нужно установить эту функцию в качестве шаблона для столбца Available:

columns:[  //...  { id:"active", header:"Available", template:customCheckbox, ...,},]

В браузере результат будет следующим:

Шаблон для столбца "Available"Шаблон для столбца "Available"

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

А сейчас давайте займемся столбцом под названием Color. Его значения отображаются в виде HEX кодов, которые обозначают цвет автомобиля. Для обычного пользователя такие значения ничего не говорят. Давайте добавим визуальное представление цвета для каждой ячейки. И сделаем мы это также с помощью шаблона. Код будет выглядеть так:

columns:[  //...  { id:"color", header:"Color", template:`<span style="background-color:#color#; border-radius:4px; padding-right:10px;">&nbsp</span> #color#`},  //...]

Здесь мы используем строковый шаблон, в котором задаем фон неразрывного пробела (&nbsp) с помощью входящего HEX кода.

В браузере результат будет следующим:

Шаблон для столбца "Color"Шаблон для столбца "Color"

Работа с коллекциями

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

Для примера, давайте рассмотрим столбец с названием "Сar make", в котором должны отображаться марки автомобилей. Данные для его ячеек хранятся в виде чисел от 1 до 24 под ключем "car_make":

//data.json[  { "id":1, "rank":1, "car_make":22, ..., "country":1, "company":1, ..., },  { "id":2, "rank":2, "car_make":10, ..., "country":2, "company":3, ..., },  { "id":3, "rank":3, "car_make":16, ..., "country":1, "company":2, ..., },  //...]

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

//car_make.json[  { "id":22, "value":"Toyota" }, ...,  { "id":10, "value":"GMC" }, ...,  { "id":16, "value":"Mazda" }, ...,  //...]

В настройки столбца необходимо добавить свойство collection и присвоить ему путь к нужному объекту (коллекции):

columns:[  //...  { id:"car_make", header:"Car make", collection:"./data/car_make.json", ...,},  //...]

Вот таким образом, вместо числовых значений, в ячейках столбца Car make будут отображаться названия автопроизводителей. По такому же принципу мы заменяем значения для столбцов Company, Country и Card.

В браузере результат будет следующим:

Коллекции для столбцовКоллекции для столбцов

Работа с форматами данных

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

columns:[  //...  { id:"date", header:"Date", format:webix.i18n.longDateFormatStr, ..., },  { id:"price", header:"Price", format:webix.i18n.priceFormat, ..., },  //...]

Данные о датах приходят в виде строк 05/26/2021. Нам же нужно получить дату в формате 26 May 2021. Метод webix.i18n.longDateFormatStr, который мы применили в настройках столбца Date, должен получать объект Date и возвращать строку в нужном формате. Но сейчас он получает только строку типа 05/26/2021, поэтому результат может быть неожиданным. Давайте изменим входящие данные и преобразуем строки в соответствующие Date объекты.

Для этого у таблицы предусмотрено свойство scheme. В объекте этого свойства мы меняем строковое значение даты на соответствующий объект с помощью метода webix.i18n.dateFormatDate. Код будет выглядеть следующим образом:

{  view:"datatable",  //...  scheme:{    $init:function(obj){      obj.date = webix.i18n.dateFormatDate(obj.date)    }  },  columns:[...]}

С форматированием даты мы разобрались. Теперь давайте посмотрим как изменить цену в столбце "Price". А здесь все еще проще. Метод webix.i18n.priceFormat получает число (например 199) и возвращает строку со знаком доллара в начале: $199. Вот и вся хитрость.

В браузере результат будет следующим:

Форматирование даты и ценыФорматирование даты и цены

Узнать больше о возможностях форматирования данных библиотеки Webix можно в этой статье.

Сортировка данных

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

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

  • "int" - сравнивает числовые значения

  • "date" - сравнивает даты

  • "string" - сравнивает строковые значения в том виде, в котором они загружаются

  • "text"- сравнивает текст элемента, который отображается в ячейке (включая темплейт)

    columns:[  { id:"car_model", header:"Model", width:120, ..., sort:"string", }, ...,  { id:"car_year", header:"Year", width:85, ..., sort:"int" }, ...,{ id:"country", header:"Country", width:140, ..., sort:"text" }, ...,{ id:"date", header:"Date", width:150, ..., sort:"date" }, ...,]
    

Теперь данные будут сортироваться при клике по хедеру определенного столбца. Более того, мы можем установить режим, который позволяет сортировать данные по нескольким критериям одновременно. Для этого нужно задать свойству sort значение "multi" в конструкторе виджета. Чтобы отсортировать данные по нескольким условиям, нужно нажать клавишу Ctrl/Command и кликнуть по хедерам нескольких столбцов.

Если вы хотите применить другое поведение сортировки (отличное от встроенного), вы можете задать нужную логику в отдельной функции и присвоить ее атрибуту sort.

Если же нужно отсортировать данные таблицы при клике по какой либо внешней кнопке, вы можете воспользоваться методом таблицы sort(). Подробнее о сортировке данных вы можете узнать в этой статье.

Фильтрация данных

Сложно представить себе полноценную таблицу без возможности фильтровать в ней данные. И такая возможность предусмотрена в таблице Webix. С помощью свойства content, мы можем добавить один из нескольких встроенных фильтров или же задать собственные условия фильтрации. Это позволит нам фильтровать данные на клиенте или сервере по одному или нескольким критериям.

В зависимости от типа данных в столбцах, мы будем применять для них разные типы фильтров. Давайте добавим фильтр selectFilter с выпадающим списком опций в хедер столбца с названием Company. Для этого же столбца мы ранее указали свойство collection, которое заменяет числовые значения соответствующими названиями компаний. Фильтр сам подтянет данные этой коллекции и сформирует из них список опций. В настройках столбца это выглядит так:

columns:[  //...  {    id:"company",     header:["Company",{content:"selectFilter"}],     collection:"./data/company.json", ...,  }, ...,]

В браузере результат будет следующим:

Фильтр selectFilterФильтр selectFilter

Для столбца с названием Car make мы добавим фильтр textFilter. Он представляет собой обычное поле для ввода данных. Фильтр будет сравнивать введенные значения с данными столбца. Хочу напомнить, что данные приходят сюда в виде чисел, которые преобразуются в соответствующие названия моделей авто. Дело в том, что фильтр будет сравнивать введенные значения именно с числами, а это нас не совсем устраивает. Давайте изменим поведение фильтра по умолчанию и сделаем так, чтобы введенные значения сравнивались с данными из коллекции. Для этого мы добавим к фильтру специальную функцию для сравнения:

columns:[  //...  { id:"car_make", header:["Car make", {    content:"textFilter", placeholder:"Type car make",    compare:function(item, value, data){       const colValue = cars_make_data.getItem(item).value;      const toFilter = colValue.toLowerCase();      value = value.toString().toLowerCase();      return toFilter.indexOf(value) !== -1;    } }], collection:cars_make_data, ...,  },  //...]

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

columns:[  //...  { id:"car_model", header:["Model", {content:"textFilter", placeholder:"Type model"}, ...,],  //...]

В браузере результат будет следующим:

Фильтр textFilterФильтр textFilter

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

columns:[  //...  { id:"car_year", header:[{text:"Year", content:"excelFilter", mode:"number"}], ...,},  //...]

С помощью свойства mode мы указываем, что будем сравнивать числа. Вместо поля ввода, возле хедера появится специальная иконка, при клике по которой откроется окно для ввода условий. В браузере результат будет следующим:

Фильтр excelFilterФильтр excelFilter

Среди наших столбцов есть и такой, в котором отображаются доступные даты аренды автомобилей. Для работы с датами у виджета есть специальный фильтр datepickerFilter. Он позволяет выбрать необходимую дату в компактном всплывающем календаре. Настройки столбца будут выглядеть так:

columns:[  //...  { id:"date", header:["Date", {content:"datepickerFilter"}], ..., },  //...]

В браузере результат будет следующим:

Фильтр datepickerFilterФильтр datepickerFilter

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

Редактирование данных

Функционал виджета позволяет редактировать данные непосредственно в ячейках таблицы. Чтобы активировать эту опцию, необходимо задать свойству editable значение true в конструкторе таблицы. Также можно определить действие, по которому будет открываться редактор ячейки. По умолчанию, редактор открывается при клике по ячейке. Можно также определить открытие по двойному клику (dblclick) или указать собственное действие (custom).

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

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

{  view:"datatable",  //...  editable:true,  editaction:"dblclick",  columns:[    { id:"rank", header:"Rank", editor:"text", ..., },    { id:"car_model", header:"Model", editor:"text", ..., },    { id:"manager", header:"Manager", editor:"text", ..., },    //...  ],  //...}

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

Редактор "text"Редактор "text"

Если в ячейке будет находиться большой текст, то редактировать его в маленьком поле будет не очень удобно. Для таких случаев предусмотрен редактор popup. Он позволяет редактировать данные в специальном всплывающем окне. По умолчанию ширина и высота окна равны 250px и 50px соответственно. Давайте добавим этот редактор в настройки столбца Address:

columns:[  { id:"address", header:"Address", editor:"popup", ...,},  //...],//...

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

Редактор "popup"Редактор "popup"

Теперь перейдем к столбцу c названием Available. Как вы помните, для него мы задали шаблон, который превращает значения true и false в соответствующие строки YES и NO. Давайте сделаем так, чтобы пользователь смог переключаться между этими значениями. Для этого мы используем специальный редактор inline-checkbox. Он позволяет менять значения в ячейке при клике по ней. Но для работы этого редактора также необходимо задать свойству checkboxRefresh значение true. Это свойство обновляет данные, полученные из checkbox-редакторов в таблице. Настройки столбца будут выглядеть так:

{  //...  checkboxRefresh:true  columns:[    //...    { id:"active", header:"Available", editor:"inline-checkbox", template:customCheckbox, ..., },  //...  ],  //...}

При клике по любой ячейке этого столбца, его значение изменится на противоположное.

А на очереди у нас столбцы, данные для которых подтягиваются из коллекций. Для них лучше всего подойдет редактор combo. Он позволяет выбирать нужную опцию из выпадающего списка. Список формируется автоматически исходя из данных коллекции столбца. Настройки столбцов будут выглядеть так:

columns:[  { id:"company", header:"Company", editor:"combo",    collection:"./data/company.json", ..., },  { id:"car_make", header:"Car make", editor:"combo",    collection:cars_make_data, ..., },  { id:"country", header:"Country", editor:"combo",   collection:"./data/country.json", ..., },  //...],//...

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

Редактор "combo"Редактор "combo"

Особого внимания заслуживает столбец под названием Color. Хочу напомнить, что входящие данные представляют собой HEX коды различных цветов. У таблицы Webix есть специальный редактор, который позволяет выбрать необходимый цвет в специальном всплывающем окне, а его код отобразится в ячейке столбца. Речь идет о таком редакторе как color. Настройки столбца будут выглядеть так:

columns:[  { id:"color", header:"Color", editor:"color", template:..., },  //...], //...

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

Редактор "color"Редактор "color"

Теперь нам осталось разобраться с редактированием данных в столбце Date. Для работы с датами у таблицы предусмотрен специальный редактор date. Как и в случае с фильтром, возле ячейки появится компактный календарь, где вы сможете выбрать нужную дату, которая сразу отобразится в редактируемой ячейке. Настройки столбца будут выглядеть так:

columns:[  {     id:"date", header:"Date", editor:"date",     format:webix.i18n.longDateFormatStr, ...,   },  //...], //...

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

Редактор "date"Редактор "date"

Более подробно о редактировании таблицы можно узнать в соответствующей статье документации. А мы двигаемся дальше.

Валидация

После редактирования данных, они должны сохраняться на сервере. Здесь хорошим тоном будет проверить измененную информацию перед тем как ее сохранить. Как это сделать спросите вы? Да все донельзя просто. У библиотеки Webix есть специальные правила, которые можно применить для каждого редактируемого поля. В зависимости от типа данных, мы будем применять разные правила.

У нас есть несколько столбцов с числовыми значениями, для которых мы установим правило webix.rules.isNumber. Таблица будет проверять, является ли значение в текстовом поле числом.

Также у нас есть столбец, который содержит адреса электронной почты. Его значения мы будем проверять правилом webix.rules.isEmai.

Для столбца с ценами необходимо задать собственные условия проверки. Давайте сделаем так, чтобы пользователь мог ввести только данные в диапазоне от 20 до 500 долларов. Для этого мы создадим специальную функцию:

function(obj){ return (obj>20 && obj<500) }

Все остальные столбцы мы будем проверять правилом webix.rules.isNotEmpty. Это значит, что в любом случае они должны быть заполнены.

Чтобы применить все эти правила, у таблицы есть специальное свойство rules. Внутри объекта этого свойства необходимо указать id нужных столбцов и присвоить им соответствующие правила. Выглядит это так:

column:[...],rules:{  rank:webix.rules.isNumber,  company:webix.rules.isNotEmpty,  email:webix.rules.isEmail,  price:function(obj){ return(obj>20 && obj<500) },  // правила для других столбцов}

По умолчанию, валидация начнется после закрытия редактора. Если введенные значения не соответствуют правилам, то целый ряд подсветится красным, а в правом верхнем углу невалидной ячейки появится красная метка:

ВалидацияВалидация

Хедеры и футеры

Если вы работаете с большими данными, которые разделяются на множество столбцов, часто возникает необходимость объединить их названия в определенные категории. Такой подход помогает структурировать таблицу и упрощает поиск нужной информации. Виджет DataTable позволяет объединять хедеры с помощью свойств colspan и rowspan, которые немного похожи на настройки обычной HTML таблицы. Для примера, давайте посмотрим как объединить столбцы Price, Card и IBAN в категорию Payment information. Для этого нужно немного изменить свойство header вышеуказанных столбцов:

column:[  //...  { id:"price", header:[{text:"Payment information", colspan:3}, "Price"], ..., },  { id:"credit_card", header:["","Card"], ..., },  { id:"iban", header:["","IBAN"], ..., },  //...]

В браузере мы получим следующий результат:

Объединяем хедерыОбъединяем хедеры

Если хедеры подключены по умолчанию, то футеры нужно активировать отдельно. Для этого необходимо задать свойству footer значение true в конструкторе виджета. Давайте определим название футера для первого столбца и объединим его с футером второго столбца при помощи свойства colspan. А в футере столбца Available, где хранятся данные о доступных автомобилях, мы будем подсчитывать и отображать активные варианты. Настройки столбцов будут выглядеть так:

column:[  //...  { id:"stared", header:[...], ..., footer:{ text:"Available:", colspan:2 } },  //...  { id:"active", header:[...], ..., footer:{content:"summColumn"}, ..., },//...]

Элемент, заданный как {content:"summColumn"} , будет подсчитывать все значения равные true и отобразит их количество в футере. Все изменения в ячейках столбца Available незамедлительно отобразятся в его футере. В браузере мы получим следующий результат:

ФутерыФутеры

Управление видимостью столбцов

Если ваша таблица состоит из множества столбцов, то было бы очень удобно спрятать ненужные в данный момент варианты. Разработчики Webix позаботились о решении этого вопроса и реализовали такую опцию как headerMenu. С ее помощью мы можем управлять видимостью столбцов через специальный интерфейс. По желанию, его можно настроить по своему усмотрению.

Давайте добавим иконку headerMenu в качестве хедера для первых двух столбцов и настроим всплывающее окно меню. Код будет выглядеть так:

//...headermenu:{  width:210,  data:[     { id:"car_year", value:"Year" },    { id:"color", value:"Color" },    { id:"vin_code", value:"VIN" },    { id:"phone_number", value:"Phone" },    //...  ]},column:[  { id:"stared", header:[{ content:"headerMenu", colspan:2, ...,}], ..., },  { id:"rank", header:["",""], ..., },  //...]

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

В браузере мы увидим такой результат:

Опция headermenuОпция headermenu

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

Пагинация для таблицы

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

Для начала, давайте создадим модуль пагинации в файле pager.js. Его код будет выглядеть так:

//pager.jsconst pager = {  view:"pager",  id:"pager",  size:20,  group:5,  template:`{common.first()} {common.prev()} {common.pages()} {common.next()} {common.last()}`};

С помощью свойств size и group мы устанавливаем количество элементов на странице (20) и число видимых кнопок для пагинатора (5). Вот, в принципе, и все настройки. Также можно задать свойство template, которое определяет кнопки для переключения страниц (помимо кнопок с цифрами).

Теперь давайте подключим модуль с компонентом в файл index.html и добавим переменную с пагинатором в конструктор приложения:

//index.html<!--App sources --><script src="js/datatable.js" type="text/javascript" charset="utf-8"></script><script src="js/pager.js" type="text/javascript" charset="utf-8"></script>//...<script type="text/javascript">  webix.ready(function(){    webix.ui({      rows:[        datatable,        {cols:[          {},pager,{}        ]}      ]    });  });</script>

Ну и в завершение, давайте свяжем пагинатор с таблицей, чтобы при переключении кнопок, данные таблицы менялись в соответствии с настройками (по 20 рядов на странице). Для этого нужно задать свойству pager значение id созданного нами пагинатора в конструкторе таблицы. Вот такими нехитрыми манипуляциями реализуется пагинация для таблицы в библиотеке Webix. В браузере мы увидим следующий результат:

ПагинацияПагинация

Операции с рядами таблицы

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

Стоит отметить, что в библиотеке Webix есть несколько способов добавлять иконки. Можно использовать иконки из встроенного шрифта (<span class='webix_icon wxi-drag'></span>), или специальные встроенные элементы (common.trashIcon()).

Чтобы это реализовать, нужно перейти к массиву свойства columns и добавить следующие настройки:

column:[  //...  {     header:[{text:"<span class='webix_icon wxi-plus-circle'></span>", colspan:2}],     width:50, template:"<span class='webix_icon wxi-drag'></span>"   },  { header:["",""], width:50, template:"{common.trashIcon()}" }]

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

Иконки для операций с рядамиИконки для операций с рядами

Иконки у нас готовы. Теперь давайте установим обработчики на событие клика по этим иконкам. Чтобы поймать событие клика по любому элементу таблицы с определенным css классом, необходимо воспользоваться свойством onClick. В объекте этого свойства нужно указать класс иконки и присвоить ему соответствующий обработчик. В нашем случае, мы ловим клик по иконкам с классами wxi-plus-circle и wxi-trash:

onClick:{  "wxi-plus-circle":() => addNewElement(), //добавляет элемент  "wxi-trash":(e,id) => removeElement(id), //удаляет элемент  //...,}

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

function addNewElement(){  const table = $$("car_rental_table"); //получаем доступ к таблице  //добавляем данные  const id_new_elem = table.add({"active":0,"color":"#1c1919","date":new Date()});   table.showItem(id_new_elem); //показываем новый элемент в таблице}

С помощью метода таблицы add() мы можем добавить в нее новые данные. Этот метод возвращает id новой записи, который мы передаем другому методу таблицы showItem(), чтобы показать (проскролить) этот элемент в таблице.

Функция для удаления записи будет выглядеть так:

function removeElement(id){  $$("car_rental_table").remove(id);}

Метод таблицы remove() получает id выбранного элемента в качестве параметра и удаляет его из таблицы.

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

Сейчас перетаскивать элемент можно за любую его часть. Давайте ограничим зону перетаскивания на специально созданной иконке с классом wxi-drag .

Для этого мы воспользуемся свойством on, в объекте которого и установим обработчик на событие onBeforeDrag:

on:{  onBeforeDrag:function(data, e){     return (e.target||e.srcElement).className == "webix_icon wxi-drag";  }}

В браузере мы увидим следующий результат:

Перетаскивание рядовПеретаскивание рядов

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

Тулбар с дополнительными опциями

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

В файле toolbar.js мы создаем компонент toolbar, внутри которого определяем кнопки Reset filters, Add column и Export to Excel. Выглядит это так:

const toolbar = {  view:"toolbar",  css:"webix_dark",  height:50,  //...  cols:[    //...    { view:"button", label:"Reset filters", click:resetFilters },    { view:"button", label:"Add column", click:addColumn },    { view:"button", label:"Export to Excel", click:exportToExcel }  ]};

С помощью свойства click мы устанавливаем обработчики на событие клика по кнопкам. Давайте создадим эти обработчик. А начнем мы из функции, которая будет сбрасывать фильтрацию данных таблицы и сами фильтры. Код будет выглядеть так:

function resetFilters(){  const table = $$("car_rental_table");  table.filter();   table.showItem(table.getFirstId());   table.setState({filter:{}}); }

Метод filter(), вызванный для таблицы без параметров, отображает данные в первоначальном порядке. С помощью метода таблицы setState() мы очищаем значения полей фильтров.

Следующей на очереди у нас функция, которая будет добавлять новые столбцы. Код будет выглядеть так:

function addColumn(){  const table = $$("car_rental_table");  table.config.columns.splice(3,0,{    id:"c"+webix.uid(),    header:`<span class="webix_icon wxi-close-circle" webix_tooltip="Delete column"></span>Extra column`,    editor:"text",    width:120  });  table.refreshColumns();}

С помощью свойства таблицы config.columns мы получаем массив с настройками столбцов и добавляем туда объект с настройками нового столбца в 4 позицию. Для этого используем js метод splice(). Когда данные изменены, нужно обновить представление столбцов с помощью метода таблицы refreshColumns().

И у нас осталась только функция, которая будет экспортировать данные таблицы в формате Excel. Код будет выглядеть так:

function exportToExcel(){  webix.toExcel("car_rental_table", {    filename:"Car Rental Table",    filterHTML:true,    styles:true  });}

Внутри функции мы используем метод webix.toExcel(), которому передаем id таблицы и объект с необходимыми настройками. Вот и вся хитрость.

Когда все уже готово, нужно включить файл toolbar.js в файл index.html и добавить переменную toolbar в конструктор приложения:

webix.ui({  rows:[    toolbar,    datatable,    {cols:[    {},pager,{}    ]}  ]});

В браузере наше приложение будет выглядеть следующим образом:

Тулбар с кнопками Тулбар с кнопками

Теперь мы можем сбрасывать фильтрацию данных, добавлять новые столбцы, а также экспортировать данные в формате Excel.

Остался еще один нюанс. При создании нового столбца, в его хедер мы добавляем иконку с классом wxi-close-circle. Нам нужно установить обработчик на событие клика по этой иконке, который будет удалять столбец. Сделать это можно в объекте свойства onClick:

onClick:{  //...  "wxi-close-circle":(e,id) => deleteColumn(id)}

Теперь давайте создадим этот обработчик:

function deleteColumn(id){  const table = $$("car_rental_table");  table.clearSelection();  table.editStop();  table.refreshColumns(table.config.columns.filter(i=>i.id !== id.column));}

Через свойство config.columns мы получаем массив настроек столбцов, отфильтровываем из него ненужный элемент и передаем обновленный массив методу таблицы refreshColumns().

В браузере мы увидим следующий результат:

Добавляем и удаляем новый столбецДобавляем и удаляем новый столбец

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

Заключение

С кодом готового приложения можно ознакомиться тут.

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

Подробнее..

Перевод Удобная платформа для подбора библиотек и фреймворков JavaScript openbase

15.10.2020 14:05:55 | Автор: admin
image

Что за зверь?


openbase.io

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

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

Как я обычно выбираю себе библиотеку



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

  1. Поискать в npm
  2. Подобрать айтемы, подходящие по описанию, имеющие достаточное количество загрузок и получавшие обновления в последние несколько месяцев.
  3. Проверить доступность документации и readme на GitHub; иногда проверять наличие обновлений по ключевым вопросам.
    Кстати, я стараюсь не принимать решение только по наличию или отсутствию документов. Как правило, они могут находиться в процессе релиза или экстренных правок, о чем можно узнать на issue board, где разработчики и юзеры могут контактировать друг с другом.

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

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

Плюсы openspace Ревью


Для моих изысканий идеально подошел сервис openbase.io

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

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

Например, на React оставлено более 570 отзывов.

Общая информация


image

Ревью


image

Плюсы openbase Можно сразу найти туториалы


На боковой панели есть вкладка tutorial, в которой вам все подробно разъяснят, в том числе через ролики на YouTube.

image

image

Плюсы openbase информация об альтернативных пакетах


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

image

Спасибо за прочтение!

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

Biscuit-store еще один взгляд на state-management в JavaScript приложениях

02.03.2021 02:04:15 | Автор: admin

Приветствую дамы и господа! В этой статье я расскажу о JavaScript библиотеке Biscuit-store.

Описание

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

Основные цели статьи

  • Рассказать о biscuit-store и его целях;

  • Провести сравнение с другими подобными инструментами;

  • Дать краткий обзор функционала.

Здесь я не буду погружаться под капот, а лишь проведу краткий обзор.

Плюсы biscuit-store

  • Стремление к простоте исполнения;

  • Поддержка React;

  • Стремление к единому подходу;

  • Асинхронность из коробки;

  • Простое расширение через Middleware;

  • Отсутствие зависимостей;

  • Гибкая модульная архитектура;

  • Оптимальное соотношение размера библиотеки и количества функций;

  • Встроенный отладчик.

Характеристики

  • Вес core- 18Kb, Gzip: 6.2кб (скомпилировано в CommonJs);

  • Вес react модуля 6.8, Gzip: 2.0кб;

  • Вес adapter модуля 9.6, Gzip: 3.5кб (скомпилировано в CommonJs);

  • Проверено в браузерах:

    • Internet-explorer 11+;

    • Chrome 48+;

    • Opera 25+;

    • Mozilla firefox 40+;

    • Safari 9+.

  • Включена поддержка TypeScript.

Для чего создавалась эта библиотека и зачем она нужна?

Чтобы понять мотивы создания библиотеки, надо посмотреть на существующие популярные инструменты для JavaScript state-management, а именно: redux и mobx.

Redux

Это легковесная библиотека, которая весит всего 2kB и представляет единый контейнер управляемых состояний для js приложения. Основными плюсами redux являются его малый вес и гибкость. C помощью redux можно разрабатывать приложения любого размера. Мне лично нравится эта библиотека.

Но дьявол, как известно, в деталях.

Все не так просто, как кажется

Когда смотришь пример кода на GitHub кажется, что все понятно. Но, когда дело касается применения в реальном проекте и если вы новичок, то у вас может вскипеть мозг. Вы просто не понимаете что, куда и как Судорожно начинаете искать в интернете статьи и видео-уроки чтобы понять, как собрать все паттерны воедино. Redux-utils конечно сглаживает эту проблему, но лишь частично.

Отсутствие асинхронности из коробки.

Вероятно, в 2015, когда создавалась эта библиотека, это не было столь значимо. Сейчас на дворе 2021 и асинхронность повсюду во вселенной JavaScript. Конечно, эта проблема частично решается через middleware, такие как redux-saga и redux-thunk. Но это порождает еще две проблемы: отсутствие единого подхода и увеличение зависимостей проекта.

Отсутствие единого подхода

Redux лентяй и прокрастинатор Он хочет что бы работу за него делали другие. Вам нужна асинхронность - подключайте отдельные библиотеки для слайд-эффектов, нужно избавится от лишних перерисовок - подключайте reselect, бесит писать reducers через switch подключайте что-то типа redux-actions. Весь этот зверинец и порождает отсутствие единообразия.

Лишние зависимости от сторонних библиотек

Тут буду немногословен: лишние зависимости - не есть хорошо.

Вывод

Redux - хороший инструмент, но, я бы сказал, что он слишком много сваливает на разработчика.

Mobx

Основной лозунг mobx:

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

Если redux - это лентяй и прокрастинатор, за которого нужно думать, то mobx - это скорее нарциссичный профессор в мире state-management. Он говорит: Я все сделаю за тебя, а ты только включи творчество и нарисуй архитектуру.
Автоматика это хорошо, особенно, если вы делайте одноразовый, быстрый проект, который после релиза уйдет в пыльный ящик или будет поддерживаться небольшой командой. Но если вы разрабатываете более крупный проект, над котором работают смеженные команды, то от всей этой архитектурной свободы вы скорее всего получите максимум боли Иной раз вы просто не сможете понять что, откуда тянется.

Ни в коем случае не воспринимайте всё вышесказанное как хейт. Это всего лишь моё субъективное мнение, о плюсах и минусах данных инструментов.

Теперь можно поговорить о Biscuit

Цель biscuit-store - как раз заполнить нишу между прокрастинатором redux и профессором mobx. Создать некоего работягу, который ходит на завод и вытачивает заготовки. Он однообразно делает свою работу, не задавая лишних вопросов.

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

Перейдем к практике

Вы наверняка заметили утку на превью к статье и задались вопросом: Причем тут утка?, речь же о бисквите Тут нет каких-то безумных аналогий, просто я предпочитаю описывать создание контейнера состояний в biscuit по принципу трех шагов создания утки:

  1. Создайте утку;

  2. Донесите до утки, что она утка и должна, крякать, летать и плавать;

  3. Научите утку крякать, летать и плавать.

Хватит слов, давайте поиграем в утиного бога

Итак, создадим нашу утку (хранилище состояний).

Если вы ранее использовали redux, то вы привыкли, что у вас есть одно хранилище состояний для всего приложения. Напротив, Biscuit-store поощряет создание нескольких контейнеров для разных абстрактных сегментов вашего приложения.

import { createStore } from '@biscuit-store/core';export const { store, actions } = createStore({  name: 'duck',  initial: { value: '' },});

Мы создали хранилище с минимально необходимыми настройками. То есть у нас теперь есть утка. Но она еще не осознает, что она утка и не умеет быть таковой.

Теперь мы должны донести до нее, что она утка и должна делать вещи, присущие уткам.

import { createStore } from '@biscuit-store/core';import { adapter } from './adapter';export const { store, actions } = createStore({  name: 'duck',  initial: { value: '' },  actions: {    duckSwim: 'duck/swim',    duckFly: 'duck/fly',    duckQuack: 'duck/quack',  },  middleware: [adapter]});

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

import { createAdapter } from '@biscuit-store/adapter';const { action, connect } = createAdapter();action('duck/swim', () => {    return { value: 'duck flews' };});action('duck/fly', () => {    return { value: 'duck flews' };});action('duck/quack', (payload, state, { send }) => {    // This is an asynchronous way of transmitting the payload    send({ value: 'duck quacks' });});export const adapter = connect;

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

Наша утка готова отправится в большой мир.

Давайте проверим, на что она способна.

import { actions, store } from './store/duck'const { duckQuack } = actions;store.subscribe((state) => {    console.log(state.value); // 'duck quacks'})duckQuack.dispatch();

А так это будет выглядит в React.

import { observer, useDispatch } from '@biscuit-store/react';import { actions } from './store/duck';const { duckQuack } = actions;export default observer(  ({ value }) => {    const [setQuack] = useDispatch(duckQuack);    return (      <div className='DuckWrapper'>        <p>action: {value}</p>        <button onClick={setQuack}>Duck quacks</button>      </div>    );  },  [duckQuack]);

Вот небольшое демо.

На этом все, спасибо за внимание!

Web-сайт проекта

Biscuit-store молод и нуждается в поддержке

Biscuit еще очень молод и находится в стадии бета-тестирования.
Если вам понравилась эта библиотека, помогите ей развиваться звездочкой в GitHub'

Подробнее..
Категории: Javascript , React , Reactjs , Management , Library , Immutable , Statemachine

FSTB работа с файлами в Node.js без боли

19.04.2021 04:23:17 | Автор: admin

Когда я работаю с файлами в Node.js, меня не оставляет мысль, что я пишу очень много однотипного кода. Создание, чтение и запись, перемещение, удаление, обход файлов и подкаталогов, всё это обрастает неимоверным количеством бойлерплейта, который еще усугубляется странными названиями функций модуля fs. Со всем этим можно жить, но меня не оставляла мысль, что можно сделать удобнее. Хотелось, чтобы такие элементарные вещи, как, например, чтение или запись текста (или json) в файл можно было написать в одну строчку.

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

Предыстория

Работа с файлами в ноде проходит в несколько этапов: отрицание, гнев, торг... сначала мы получаем каким-либо образом путь к объекту файловой системы, потом проверяем его существование (при необходимости), потом работаем с ним. Работа с путями в ноде вообще вынесена в отдельный модуль. Самая классная функция для работы с путями, это path.join. Реально крутая штука, которая, когда я стал ей пользоваться, сэкономила мне кучу нервных клеток.

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

Главная проблема, это то, что объект файловой системы может иметь любое имя из разрешённых символов. Если, я сделаю у этого объекта методы для работы с ним, то получится, что, например, такой код: root.home.mydir.unlink будет двусмысленным - а что, если у в директории mydir есть директория unlink? И что тогда? Я хочу удалить mydir или обратиться к unlink?

Однажды я экспериментировал с яваскриптовым Proxу и придумал интересную конструкцию:

const FSPath = function(path: string): FSPathType {  return new Proxy(() => path, {    get: (_, key: string) => FSPath(join(path, key)),  }) as FSPathType;};

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

FSPath(__dirname).node_modules //работает аналогично path.join(__dirname, "node_modules")FSPath(__dirname)["package.json"] //работает аналогично path.join(__dirname, "package.json")FSPath(__dirname)["node_modules"]["fstb"]["package.json"] //работает аналогично path.join(__dirname, "node_modules", "fstb", "package.json")

Как результат, получаем функцию, которая при вызове возвращает сформированный путь. Например:

const package_json = FSPath(__dirname).node_modules.fstb["package.json"]console.log(package_json()) // <путь к скрипту>/node_modules/fstb/package.json

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

Так и появилась библиотека FSTB расшифровывается как FileSystem ToolBox.

Пробуем в деле

Установим FSTB:

npm i fstb

И подключим в проект:

const fstb = require('fstb');

Для формирования пути к файлу можно воспользоваться функцией FSPath, либо использовать одно из сокращений: cwd, dirname, homeили tmp(подробнее про них смотрите в документации). Также пути можно подтягивать из переменных окружения при помощи метода envPath.

Чтение текста из файла:

fstb.cwd["README.md"]().asFile().read.txt().then(txt=>console.log(txt));

FSTB работает на промисах, так что можно использовать в коде async/await:

(async function() {  const package_json = await fstb.cwd["package.json"]().asFile().read.json();  console.log(package_json);})();

Здесь мы десериализуем json из файла. На мой взгляд неплохо, мы одной строчкой объяснили, где лежит, что лежит и что с этим делать.

Если бы я писал это с помощью стандартных функций, получилось бы что-то такое:

const fs = require("fs/promises");const path = require("path");(async function() {  const package_json_path = path.join(process.cwd(), "package.json");  const file_content = await fs.readFile(package_json_path, "utf8");  const result = JSON.parse(file_content);  console.log(result);})();

Это конечно не тот код, которым стоит гордиться, но на это примере видно, какой многословной получается работа с файлами при помощи стандартной библиотеки.

Другой пример. Допустим нужно прочитать текстовый файл построчно. Тут мне даже придумывать не надо, вот пример из документации Node.js:

const fs = require('fs');const readline = require('readline');async function processLineByLine() {  const fileStream = fs.createReadStream('input.txt');  const rl = readline.createInterface({    input: fileStream,    crlfDelay: Infinity  });  // Note: we use the crlfDelay option to recognize all instances of CR LF  // ('\r\n') in input.txt as a single line break.  for await (const line of rl) {    // Each line in input.txt will be successively available here as `line`.    console.log(`Line from file: ${line}`);  }}processLineByLine();

Теперь попробуем сделать это при помощи FSTB:

(async function() {  await fstb.cwd['package.json']()    .asFile()    .read.lineByLine()    .forEach(line => console.log(`Line from file: ${line}`));})();

Да, да я читер. В библиотеке есть эта функция, и под капотом работает тот самый код из документации. Но здесь интересно, что на ее выходе реализован итератор, который умеет filter, map, reduce и т.д. Поэтому, если надо, например, читать csv, просто добавьте .map(line => line.split(',')).

Запись в файл

Естественно, куда же без записи. Здесь тоже все просто. Допустим у нас есть строка и мы ее хотим записать в файл:

(async function() {  const string_to_write = 'Привет хабр!';  await fstb.cwd['habr.txt']()    .asFile()    .write.txt(string_to_write);})();

Можно дописать в конец файла:

await fstb.cwd['habr.txt']()    .asFile()    .write.appendFile(string_to_write, {encoding:"utf8"});

Можно сериализовать в json:

(async function() {  const object_to_write = { header: 'Привет хабр!', question: 'В чем смысл всего этого', answer: 42 };  await fstb.cwd['habr.txt']()    .asFile()    .write.json(object_to_write);})();

Ну и можно создать стрим для записи:

(async function() {  const file = fstb.cwd['million_of_randoms.txt']().asFile();  //Пишем в файл  const stream = file.write.createWriteStream();  stream.on('open', () => {    for (let index = 0; index < 1_000_000; index++) {      stream.write(Math.random() + '\n');    }    stream.end();  });  await stream;  //Проверяем количество записей  const lines = await file.read.lineByLine().reduce(acc => ++acc, 0);  console.log(`${lines} lines count`);})();

Кстати, ничего странного не заметили? Я об этом:

await stream; // <= WTF?!!

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

Что еще можно делать с файлами

Итак, мы посмотрели, как можно писать и читать из файлов. Но что еще можно с ними делать при помощи FSTB? Да все тоже, что при помощи стандартных методов модуля fs.

Можно получить информацию о файле:

const stat = await file.stat()console.log(stat);

Получим:

  Stats {    dev: 1243191443,    mode: 33206,    nlink: 1,    uid: 0,    gid: 0,    rdev: 0,    blksize: 4096,    ino: 26740122787869450,    size: 19269750,    blocks: 37640,    atimeMs: 1618579566188.5884,    mtimeMs: 1618579566033.8242,    ctimeMs: 1618579566033.8242,    birthtimeMs: 1618579561341.9297,    atime: 2021-04-16T13:26:06.189Z,    mtime: 2021-04-16T13:26:06.034Z,    ctime: 2021-04-16T13:26:06.034Z,    birthtime: 2021-04-16T13:26:01.342Z }

Можно посчитать хэш-сумму:

const fileHash = await file.hash.md5();console.log("File md5 hash:", fileHash);// File md5 hash: 5a0a221c0d24154b850635606e9a5da3

Переименовывать:

const renamedFile = await file.rename(`${fileHash}.txt`);

Копировать:

//Получаем путь к директории, в которой находится наш файл и // создаем в ней директорию "temp" если она не существуетconst targetDir = renamedFile.fsdir.fspath.temp().asDir()if(!(await targetDir.isExists())) await targetDir.mkdir()  //Копируем файлconst fileCopy = await renamedFile.copyTo(targetDir)  const fileCopyHash = await fileCopy.hash.md5();console.log("File copy md5 hash:", fileCopyHash);// File md5 hash: 5a0a221c0d24154b850635606e9a5da3

И удалять:

await renamedFile.unlink();

Также можно проверить, существует ли файл, доступен ли он на чтение и запись:

console.log({     isExists: await file.isExists(),     isReadable: await file.isReadable(),     isWritable: await file.isWritable() });

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

Директории: вишенка на торте и куча изюма

На мой взгляд, самая вкусная часть проекта это работа с директориями. Когда я ее реализовал и попробовал в деле, мне самому жутко понравился результат. Давайте посмотрим, что может делать FSTB с директориями. Для работы с каталогами используется объект FSDir, а получить его можно таким вот образом:

//Создем объект FSDir для node_modules:const node_modules = fstb.cwd.node_modules().asDir();

Что можно с этим делать? Ну во-первых, мы можем итерировать подкаталоги и файлы в директории:

// Выводим в консоль все имена подкаталоговawait node_modules.subdirs().forEach(async dir => console.log(dir.name));

Здесь доступны методы filter, map, reduce, forEach, toArray. Можно, для примера посчитать объем подкаталогов, названия которых начинаются с символа @ и отсортировать их по убыванию.

const ileSizes = await node_modules  .subdirs()  .filter(async dir => dir.name.startsWith('@'))  .map(async dir => ({ name: dir.name, size: await dir.totalSize() })).toArray();fileSizes.sort((a,b)=>b.size-a.size);console.table(fileSizes);

Получим что-то в этом роде:

 (index)          name           size       0           '@babel'        6616759     1     '@typescript-eslint'  2546010     2           '@jest'         1299423     3           '@types'        1289380     4       '@webassemblyjs'    710238      5          '@nodelib'       512000      6          '@rollup'        496226      7           '@bcoe'         276877      8           '@xtuc'         198883      9        '@istanbuljs'       70704     10          '@sinonjs'        37264     11         '@cnakazawa'       25057     12        '@size-limit'       14831     13           '@polka'         6953   

Бабель, конечно же, на первом месте ))

Усложним задачу. Допустим нам надо посмотреть, в каких модулях при разработке используется typescript и вывести версии. Это немного посложнее, но тоже получится довольно компактно:

const ts_versions = await node_modules  .subdirs()  .map(async dir => ({    dir,    package_json: dir.fspath['package.json']().asFile(),  }))  //Проверяем наличие package.json в подкаталоге  .filter(async ({ package_json }) => await package_json.isExists())  // Читаем package.json  .map(async ({ dir, package_json }) => ({    dir,    content: await package_json.read.json(),  }))  //Проверяем наличие devDependencies.typescript в package.json  .filter(async ({ content }) => content.devDependencies?.typescript)  // Отображаем имя директории и версию typescript  .map(async ({ dir, content }) => ({    name: dir.name,      ts_version: content.devDependencies.typescript,    }))    .toArray();  console.table(ts_versions);

И получим:

     (index)             name                   ts_version               0                'ajv'                   '^3.9.5'              1             'ast-types'                 '3.9.7'              2             'axe-core'                 '^3.5.3'              3             'bs-logger'                  '3.x'               4               'chalk'                  '^2.5.3'              5        'chrome-trace-event'            '^2.8.1'              6             'commander'                '^3.6.3'              7          'constantinople'              '^2.7.1'              8             'css-what'                 '^4.0.2'              9             'deepmerge'                '=2.2.2'             10             'enquirer'                 '^3.1.6'        ...

Что же еще можно делать с директориями?

Можно обратиться к любому файлу или поддиректории. Для этого служит свойство fspath:

//Создаем объект FSDir для node_modules:const node_modules = fstb.cwd.node_modules().asDir();//Получаем объект для работы с файлом "package.json" в подкаталоге "fstb"const package_json = node_modules.fspath.fstb["package.json"]().asFile()

Для того, чтобы не засорять временными файлами рабочую директорию иногда имеет смысл использовать каталог для временных файлов в директории temp операционной системы. Для этих целей в FSTB есть метод mkdtemp.

Создание директории производится с помощью метода mkdir. Для копирования и перемещения директории есть методы copyTo и moveTo. Для удаления - rmdir (для пустых директорий) и rimraf (если надо удалить директорию со всем содержимым).

Давайте посмотрим на примере:

// Создадим временную директориюconst temp_dir = await fstb.mkdtemp("fstb-");if(await temp_dir.isExists()) console.log("Временный каталог создан")// В ней создадим три директории: src, target1 и target2const src = await temp_dir.fspath.src().asDir().mkdir();const target1 = await temp_dir.fspath.target1().asDir().mkdir();const target2 = await temp_dir.fspath.target2().asDir().mkdir();//В директории src создадим текстовый файл:const test_txt = src.fspath["test.txt"]().asFile();await test_txt.write.txt("Привет, хабр!");  // Скопируем src в target1const src_copied = await src.copyTo(target1);// Переместим src в target2const src_movied = await src.moveTo(target2);// Выведем получившуюся структуру // subdirs(true)  для рекурсивного обхода подкаталогов await temp_dir.subdirs(true).forEach(async dir=>{  await dir.files().forEach(async file=>console.log(file.path))})// Выведем содержимое файлов, они должны быть одинаковы console.log(await src_copied.fspath["test.txt"]().asFile().read.txt())console.log(await src_movied.fspath["test.txt"]().asFile().read.txt())// Удалим временную директорию со всем содержимымawait temp_dir.rimraf()if(!(await temp_dir.isExists())) console.log("Временный каталог удален")

Получим следующий вывод в консоли:

Временный каталог созданC:\Users\debgger\AppData\Local\Temp\fstb-KHT0zv\target1\src\test.txtC:\Users\debgger\AppData\Local\Temp\fstb-KHT0zv\target2\src\test.txtПривет, хабр!Привет, хабр!Временный каталог удален

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

Заключение

Когда я начинал писать эту библиотеку, моей целью было упростить работу с файловой системой в Node.js. Считаю, что со своей задачей я справился. Работать с файлами при помощи FSTB гораздо удобнее и приятнее. На проекте, в котором я ее обкатывал, объем кода, связанный с файловой системой, уменьшился раза в два.

Если говорить о плюсах, которые дает FSTB, можно выделить следующее:

  • Сокращается объем кода

  • Код получается более декларативный и менее запутанный

  • Снижается когнитивная нагрузка при написании кода для работы с файловой системой.

  • Библиотека хорошо типизирована, что при наличии поддержки тайпингов в вашей IDE заметно упрощает жизнь.

  • Нет внешних зависимостей, так что она не притащит за собой в ваш проект ничего лишнего

  • Поддержка Node.js начиная с 10-й версии, поэтому можно использовать даже в проектах с довольно старой кодовой базой

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

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

Исходный код библиотеки доступен в GitHub: https://github.com/debagger/fstb

С документацией можно ознакомиться здесь: https://debagger.github.io/fstb/

Благодарю за внимание!

Подробнее..
Категории: Javascript , Typescript , Node.js , Toolkit , Library , Filesystem

Подменяем Runtime permissions в Android

07.12.2020 14:17:59 | Автор: admin

Здравствуйте, меня зовут Виталий.

Мне 25 лет, закончил магистратуру СПБГЭТУ ЛЭТИ в своем родном городе. Уже 10 лет занимаюсь программированием, из которых 4 пишу под Android. Автор многих Homebrew программ, известный под ником VITTACH, для Sony PlayStation Portable (PSP).

Сегодня я бы хотел обсудить с вами проблему безопасности мобильных приложений. Разработчики из Google постоянно улучшают Android, находя и исправляя уязвимости не без помощи обширного сообщества, собранного благодаря программе Android Security Rewards, о которой мы поговорим позже. Тем не менее, проблемы все еще остаются, и наша общая задача как коммьюнити сообщать о них, чтобы их своевременно исправляли.

Уязвимость о которой я буду говорить, относится к классу с Priority: P2 и Severity: S2, что согласно таблице в широком смысле означает:

  • Проблему, которую необходимо решить в разумные сроки;

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

Runtime permission

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

Это невозможно

Не один раз задавался подобный вопрос на специализированных форумах, в частности на StackOverflow. Единственным правильным ответом было то, что это невозможно. И это действительно так: невозможно подменить текст в самом системном диалоге, но возможно его перекрыть своим.

Что под капотом

Runtime Permission впервые появились в Android 6.0 в ответ на потребность повышенного внимания в области выдачи dangerous-разрешений. Фактически основная идея состоит в том, чтобы взаимодействовать с пользователем при запросе разрешений через всплывающее окно. Поэтому теперь разрешения из списка dangerous необходимо запрашивать у пользователя как только они понадобятся приложению.

Dangerous permissions
  • android.permission_group.CALENDAR

    • android.permission.READ_CALENDAR

    • android.permission.WRITE_CALENDAR

  • android.permission_group.CAMERA

    • android.permission.CAMERA

  • android.permission_group.CONTACTS

    • android.permission.READ_CONTACTS

    • android.permission.WRITE_CONTACTS

    • android.permission.GET_ACCOUNTS

  • android.permission_group.LOCATION

    • android.permission.ACCESSFINELOCATION

    • android.permission.ACCESSCOARSELOCATION

  • android.permission_group.MICROPHONE

    • android.permission.RECORD_AUDIO

  • android.permission_group.PHONE

    • android.permission.READPHONESTATE

    • android.permission.CALL_PHONE

    • android.permission.READCALLLOG

    • android.permission.WRITECALLLOG

    • android.permission.ADD_VOICEMAIL

    • android.permission.USE_SIP

    • android.permission.PROCESSOUTGOINGCALLS

  • android.permission_group.SENSORS

    • android.permission.BODY_SENSORS

  • android.permission_group.SMS

    • android.permission.SEND_SMS

    • android.permission.RECEIVE_SMS

    • android.permission.READ_SMS

    • android.permission.RECEIVEWAPPUSH

    • android.permission.RECEIVE_MMS

    • android.permission.READCELLBROADCASTS

  • android.permission_group.STORAGE

    • android.permission.READEXTERNALSTORAGE

    • android.permission.WRITEEXTERNALSTORAGE

Для отображения системного диалога в Android есть GrantPermissionsActivity, который запускается после запроса на выдачу разрешений и отображает нам диалог со знакомым интерфейсом.

ActivityCompat.requestPermissions(    MainActivity.this,    arrayOf(Manifest.permission.READ_CONTACTS),    PERMISSION_REQUEST_CODE)

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

Теперь давайте посмотрим на пример:

Пусть есть Activity с флагом android:windowIsTranslucent=true (чтобы сделать Activity с прозрачным фоном, позволяющим видеть, что за ним стоит) и оно запускается другим Activity , который я назову фоновым. Визуально вы все еще можете видеть некоторую часть фонового Activity через прозрачные пиксели в Activity переднего плана.

Синий это активное Activity с полупрозрачным окном, а фиолетовый Activity прямо под ним. Что произойдет с Activity, если вы поместите приложение в фоновый режим?

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

Фоновая Activity создается, а затем onResume и onPause вызываются по порядку. Затем оживает передний план Activity.

Идея заключается в том, чтобы перекрыть эту Activity в стеке своей собственной с кастомным диалогом, но так чтобы оставить возможность взаимодействовать с кнопками системного диалога. И вот это уже возможно!

Попробуй сам, это не сложно

Для демонстрации использован язык программирования Kotlin

  • Создать стиль

    К сожалению, невозможно настроить эти параметры иначе как из стилей

    <style name="Theme.Transparent" parent="AppTheme"><item name="android:windowBackground">@android:color/transparent</item><item name="android:windowIsTranslucent">true</item></style>
    
  • Прописать в манифесте Activity со стилем

    ...<activity android:name=".PermissionActivity"          android:theme="@style/Theme.Transparent">
    
  • Создать PermissionActivity со своим layout

    В методе onCreate написать код:

    window.addFlags(  FLAG_NOT_FOCUSABLE or FLAG_NOT_TOUCH_MODAL or FLAG_NOT_TOUCHABLE)
    

    Здесь мы использую трюк с использованием следующих флагов:

    • FLAG_NOT_FOCUSABLE: window, в которой установлен параметр FLAG_NOT_FOCUSABLE, не может взаимодействовать с методом ввода;

    • FLAG_NOT_TOUCH_MODAL: разрешит отправку любых событий за пределы окна в окна позади него, в противном случае он сам будет использовать все события указателя, независимо от того, находятся ли они внутри окна;

    • FLAG_NOT_TOUCHABLE: это окно никогда не может получать события касания.

  • В MainActivity вызвать запрос

    ActivityCompat.requestPermissions(    MainActivity.this,    arrayOf(Manifest.permission.READ_CONTACTS),    REQUEST_CODE)
    
  • И сразу после этого из MainActivity запустить другую активити: PermissionActivity.

    startActivity(Intent(this, PermissionActivity::class.java))
    

    PermissionActivity с прозрачным фоном и отсутствием фокуса и прокидыванием событий на Activity позади себя запустится и позволит добиться перекрытия системного диалога с сохранение полного взаимодействия с ним. Результат достигнут!

Android >= 7.1.1

Хотя Runtime Permission появились в версии Android 6.0, но до версии 7.1.1 эту уязвимость использовать не получится, т.к. ранние версии Android ругаются на обнаружение перекрытия при попытке нажатия на кнопку Разрешить.

Если вы попробуете запустить мой код на версии Android 6.0, то получите это предупреждение. По неизвестной мне причине, Google отказался от этих ограничений в новых версиях.

Android Rewards Programm

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

А как проще?

Для удобства эксплуатации уязвимости мною была написана библиотека

Подробнее..

Вы всё ещё ловите исключения? Тогда мы к вам

31.01.2021 04:11:28 | Автор: admin

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

И как следствие, писать обработку ошибок - прямая обязанность любого ответственного программиста. Некоторые считают, что если вы не предусмотрели все варианты поведения - вы не программист, и диплом вам не дадут (если речь о студентах).


Но с другой стороны, обрабатывать ошибки всегда лениво и напряжно. Поэтому существует много инструментов для облегчения этой задачи. Стандартный механизм обработки ошибок в Java - Exceptions. Я не буду сейчас расписывать скучные описания, как бы отвечая на скучные вопросы со многих собеседований. Скажу лишь, что Исключение - это на мой взгляд, неправильный перевод понятия Exception. Я считаю, что исключение, то есть исключительная ситуация - это про другого представителя летающих монстров, java.lang.Error.

А Exception - это не исключение, это часть правила. Это всегда один из возможных исходов. Я бы перевёл этот класс не иначе как Отклонение. В смысле отклонение от прямого курса. Потому как перехватив это отклонение, можно курс выправить и продолжить работу. А Исключение - это для безответственных разработчиков.

Так вот. Давайте теперь перехватывать эти отклонения. Как можно это сделать?

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

Это вносит неудобства, да. Надо их везде ловить. Как упростить задачу? Обычно советуют тупо оборачивать исключение в java.lang.RuntimeException. Но такой подход чреват тяжёлыми последствиями, когда приложение выходит в релиз и на бой. Я как человек, имеющий ещё и плюсовый бэкграунд, прекрасно знаю, что некоторые ситуации невозможно предугадать даже очень внимательно вглядываясь в код. А потом искать их причины днями, неделями, иногда месяцами. Потому что кто-то в самом начале, в месте возникновения ошибки не объявил о возможности их возникновения.

Ну да ладно. Не будем о грустном. Лучше попробует сделать нашу жизнь проще, написав утилитку, которая с одной стороны, позволяет обрабатывать ошибки. А с другой стороны не привносит глобальных неудобств и не захламляет код бесконечными (часто вложенными друг в друга) блоками try-catch-finally.

Что предлагается?

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

  2. Исключением из правила (простите за каламбур) могут будут только специализированные фреймворки, и то не всегда. Они могут игнорировать ошибки, которые от них не зависят. Но лучше делать это через туннелирование ошибок, то есть обернуть на влёте - развернуть на вылете.

  3. Во-вторых, надо бы упростить синтаксис обработки ошибок. Тут я поймал себя на мысли, что необходимость в этом возникает, так как мэйнстрим постепенно, медленно, но всё-таки верно потихоньку уползает от практик процедурного программирования. А блоки try-catch, по-моему, являются наследием именно этой парадигмы. И ООП , и тем более функциональщина и даже стримы Java-8 как-то плохо сочетаются с этими блоками, фигурными скобками.

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

Здесь я попробовал сразу около трёх подходов реализовать.

Первый - просто статичная обёртка над небезопасным кодом, которая напечатает в лог и в случае чего прокинет ошибку своего местного разлива. Это метод errorToWarn.

Второй имел целью дальнейшее снижение шаблонного кода: вынесение инициализации логгеров в одно отдельное место. Для работы нужна только одна статическая функция err2Warn.

И затем я решил пойти ещё дальше и сделать описание ошибок в удобном формате, одновременно предлагая ленивую логику и флюент (как по-русски это?) интерфейс. Это вылилось в вызов, который уже возвращает своеобразный билдер, который затем можно настраивать по типу jmock или spring security api. Выглядеть это стало примерно как табличка состояний, примерно вот так:

К слову первый подход вылился во что-то наподобие

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

Подробнее..

Разукрашиваем вывод в консоли теория и практика

23.05.2021 14:09:40 | Автор: admin


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


Управляющие последовательности ANSI


ANSI escape sequences или Управляющие последовательности ANSI это стандарт, дающий возможность управлять курсором, цветами, начертание в текстовых консолях. Такие последовательности воспринимаются отрисовщиком терминала, как команды отображать последующий текст в определенном формате. Есть также последовательность, которая сбрасывает предыдущие команды, и отображение текста становиться обычным. Существует несколько форматов управляющих последовательностей, различающихся возможностями и появившимися в разных версиях кодировок. Поговорим об этих форматах подробнее.


8 основных цветов и стили


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


Чтобы изменить текущий цвет шрифта или фона можно использовать следущий синтаксис:


  • Начинается управляющая последовательность с любого из этих трёх представлений: \x1b[ (hex) или \u001b[ (Unicode) или \033[ (oct)
  • Далее следуют аргументы, разделённые между собой ;(можно указывать в любом порядке)
  • В конце ставится буква m

Возможные аргументы


  • Изменения стиля


    Модификатор Код
    1 Жирный
    2 Блеклый
    3 Курсив
    4 Подчёркнутый
    5 Мигание
    9 Зачёркнутый

  • Изменения цвета шрифта


    Цвет Код
    30 Чёрный
    31 Красный
    32 Зелёный
    33 Жёлтый
    34 Синий
    35 Фиолетовый
    36 Бирюзовый
    37 Белый

  • Изменения цвета фона


    Цвет Код
    40 Чёрный
    41 Красный
    42 Зелёный
    43 Жёлтый
    44 Синий
    45 Фиолетовый
    46 Бирюзовый
    47 Белый


Бонус: другие интересные модификаторы, которые могут поддерживаться не всеми платформами


Модификатор Код
38 RGB цвет (см. раздел "Совсем много цветов")
21 Двойное подчёркивание
51 Обрамлённый
52 Окружённый
53 Надчёркнутый

Пример корректного синтаксиса: \033[3;36;44m. После вывода этой конструкции стиль будет изменён для всего последующего текста. Чтобы вернуться к изначальному состоянию можно использовать \033[0m, тогда весь текст с этого места вернётся к изначальному форматированию.


Давайте поэкспементируем. Для примеров я буду использовать Python.



Важно заметить, что форматирование повлияло и на консоль питона, а не только на ее вывод. Именно поэтому очень важно закрывать все "тэги" изменения форматирования.


Часто используемые сочетания (copy-paste-able)


Код Описание
\033[0m вернуться к начальному стилю
\033[31m <your text goes here> \033[0m красный текст для обозначения ошибок
\033[1;31m <your text goes here> \033[0m жирный красный текст для обозначения критических ошибок
\033[32m <your text goes here> \033[0m зеленый текст успешное выполнение
\033[3;31m <your text goes here> \033[0m красный курсив текст ошибки
\033[43m <your text goes here> \033[0m выделение основного, как будто жёлтым маркером

Больше цветов: аж целых 256


Некоторые терминалы поддерживают вывод целых 256 цветов. Если команда echo $TERM выводит xterm-256color, то ваш терминал всё корректно обработает.


В этом формате синтаксис немного другой:



Для генерации кодов цветов можно использовать генератор.


А палитру доступных цветов можно увидеть на картинке ниже.


Палитра цветов


Совсем много цветов


Этот формат не всегда поддерживается стандартными консолями.


Некотрые будут негодовать: "256 цветов и нет моего любимого терракотового, какой ужас!". Для таких ценителей существует формат, который уже поддерживает 24 битные цвета (3 канала RGB по 256 градаций).
Для не ценителей поясню, что терракотовый кодируется как (201, 100, 59) или #c9643b.
Синтаксис в этом формате выглядит вот так:


  • \033[38;2;r;g;bm цвет текста
  • \033[48;2;r;g;bm цвет фона


Python: Использование библиотеки Colorama


Библиотека Colorama позволяет форматировать текст, не запоминая коды цветов. Рассмотрим её использование на примере:


from colorama import init, Fore, Back, Styleinit()print(Fore.RED + 'some red text\n' + Back.YELLOW + 'and with a yellow background')print(Style.DIM + 'and in dim text\n' + Style.RESET_ALL + 'back to normal now')

Вывод программы:



Style позволяет изменить стиль, Fore цвет шрифта, Back цвет фона. Использовать переменные из colorama нужно также, как и коды изменения стиля. Но плюс использования библиотеки в том, что Fore.RED более читаем, чем \033[0;31m


Если в colorama.init() указать параметр autoreset=True, то стиль будет автоматически сбрасываться (в конец каждого print будут добавлены сбрасывающие стили последовательности), поэтому вам не придётся об этом каждый раз вспоминать.


А что не так с Windows?


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


Но colorama.init() сделает всё за вас в большинстве версий Windows. Однако если вы используете другую операционную систему, то функцию init() вызывать в начале программы не обязательно. Также некоторые IDE на Windows (например, PyCharm) тоже поддерживают цвета без каких-либо махинаций.
А еще Windows не поддерживает многие модификаторы, такие как жирный текст. Подробнее можно почитать на странице Colorama


Termcolor


Ещё одна библиотека для вывода цветного текста с более удачным, на мой взлгяд, синтаксисом.


from termcolor import colored, cprinttext = colored('Hello, Habr!', 'red', attrs=['blink'])print(text)cprint('Hello, Habr!', 'green', 'on_red')


Кстати, проблему с Windows всё ещё можно починить с помощью colorama.init()


Выводы


Стандартные 8 цветов позволяют разнообразить вывод в консоль и расставить акценты. 256 цветов намного расширяют возможности, хотя и поддерживаются не всеми консолями. Windows, к сожалению, не поддерживает многие основные модификаторы, например, курсив. Также есть некоторые цвета, которые не прописаны в стандартах, но могут поддерживаться вашей операционной системой. Если вы хотите больше цветов, то вы можете поискать их в Гугле.
Пока что не любой терминал поддерживает 24-битные цвета и все модификаторы, но мы вряд ли увидим сильные изменения в этой сфере. Так что пока нам остаётся выбирать самые красивые варианты из тех, что доступны в любимом терминале.


Источники





Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!


Подробнее..

Крупнейшая свободная электронная библиотека выходит в межпланетное пространство

14.10.2020 22:06:16 | Автор: admin

Library Genesis - настоящий бриллиант Интернета. Онлайн-библиотека, предоставляющая свободный доступ более чем к 2.7 миллионам книг, на этой неделе сделала долгожданный шаг. Одно из веб-зеркал библиотеки теперь дает возможность скачать файлы через IPFS.

За этим скрывается очевидный факт - коллекция книг Library Genesis загружена в IPFS, запинена и соединена с поиском. А это значит, что теперь лишить людей доступа к нашему общему культурному и научному наследию стало немного тяжелей.

О LibGen

В начале нулевых в пока ещё свободном от регулирования интернете лежали дюжины сборников научных книг. Крупнейшие коллекции из тех, что я могу вспомнить - KoLXo3, mehmat и mirknig - содержали к 2007 году десятки тысяч учебников, публикаций и других важных djvuшек и pdfок для студентов.

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

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

В 2008 году на rutracker.ru (тогда torrents.ru) энтузиастом были опубликованы торренты, скомпоновавшие существовавшие сборники книг в одну большую кучу. В этом же треде нашелся человек, начавший кропотливую работу по систематизации выложенных файлов и созданию веб-интерфейса. Так появился Library Genesis.

Все это время с 2008 года и до текущего момента LibGen развивался и пополнял собственные книжные полки силами соообщества. Метаданные книг редактировались, а затем сохранялись и распространялись в виде дампов MySQL для всех желающих. Альтруистическое отношение к метаданным привело к появлению большого количества зеркал и повышению выживаемости всего проекта, несмотря на возросшую фрагментацию.

Важной вехой в жизни библиотеки стало зеркалирование базы данных Sci-Hub, стартовавшее в 2013 году. Благодаря колаборации двух систем в одном месте оказался сконцентрирован небывалый по качеству набор данных - научные и художественные книги вместе с научными публикациями. У меня есть предположение, что одного дампа совместной базы LibGen и Sci-Hub будет достаточно для восстановления научно-технического прогресса цивилизации в случае его утраты в ходе катастрофы.

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

LibGen в IPFS

И хотя социальная значимость LibGen очевидна, столь же очевидны причины, из-за которых библиотека постоянно находится под угрозой закрытия. Именно это сподвигает мейнтенеров зеркал искать новые пути обеспечения устойчивости. Одним из таких путей стала публикация коллекции в IPFS.

IPFS появился относительно давно. На технологию при её появлении возлагались большие надежды и не все из них оправдались. Тем не менее, развитие сети продолжается, а появление в ней LibGen может усилить приток свежих сил и сыграть на руку самой сети.

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

Некоторое время назад участники LibGen анонсировали IPFS-хеши и встали на раздачу файлов. На этой неделе ссылки на файлы в IPFS стали появляться в результатах поиска некоторых зеркал LibGen. Кроме того, благодаря действиям активистов команды Internet Archive и освещению происходящего на reddit, сейчас идет наплыв дополнительных сидеров как в IPFS, так и на разадчу оригинальных торрентов.

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

P.S. Для желающих помочь проекту создан ресурс freeread.org, на нем живут инструкции как настроить IPFS.

Подробнее..

Логирование в телеграм, или история о том, как я сделал питон библиотеку

24.03.2021 16:21:32 | Автор: admin

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

Intro

Давным-давно, а точнее несколько месяцев назад, накануне Нового года, я сидел дома и решал задачу по машинному обучению. Связана она была с нейронными сетями и классификацией текстов, поэтому я естественно пользовался бесплатным GPU от гугла (colab). За окном шел снег, а модели обучались ну уж очень долго. Обучать модель оставалось всего несколько минут, как вдруг появляется уведомление, что подключение к runtime потеряно, а это значит, что обученную модель и сабмиты из этого runtime скачать я не смогу, и все придется начинать заново.

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

Копировал я этот код из ноутбука (jupyter notebook) в ноутбук, а потом осознал, что это можно встроить в модуль logging и завернуть в библиотеку, чтобы не таскать каждый раз большие куски кода, а использовать всего пару строк.

Logging.handlers

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

Tg-logger

И вот, держа в голове все вышеописанные идеи, я отправился писать свою библиотеку. Вот, что получилось.

Для тех, кому лень запускать код, но хочется понять, как это будет работать, я сделал бота @tg_logger_demo_bot.

Чтобы воспользоваться библиотекой нужно:

  • создать телеграмм бота (как это сделать описано здесь)

  • получить свой user_id (это можно сделать через @tg_logger_demo_bot с помощью команды /id)

Установим библиотеку через pip.

pip install tg-logger

Рассмотрим код примера

import loggingimport tg_logger# Telegram datatoken = "1234567890:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"users = [1111111111]# Base loggerlogger = logging.getLogger('foo')logger.setLevel(logging.INFO)# Logging bridge setuptg_logger.setup(logger, token=token, users=users)# Testlogger.info("Hello from tg_logger by otter18")

Особо интересна для нас строка, в которой подключается логирование в телеграмм.

# Logging bridge setuptg_logger.setup(logger, token=token, users=users)

В функцию setup() нужно просто передать тот logger, к которому вы хотите подключить мост. Если заглянуть в документацию, то можно посмотреть на другие параметры функции setup(). С помощью них можно, в частности, настроить формат, в котором логи будут отправлены.

Outro

Подробнее..
Категории: Python , Python3 , Logging , Telegram , Library , Logger , Handler

Путешествие в unmanaged code туда и обратно

20.02.2021 10:19:11 | Автор: admin

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

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

...#include <linux/netfilter_ipv4/ip_tables.h>#include <libiptc/xtcshared.h>#ifdef __cplusplusextern "C" {#endif#define iptc_handle xtc_handle#define ipt_chainlabel xt_chainlabel#define IPTC_LABEL_ACCEPT  "ACCEPT"#define IPTC_LABEL_DROP    "DROP"#define IPTC_LABEL_QUEUE   "QUEUE"#define IPTC_LABEL_RETURN  "RETURN"/* Does this chain exist? */int iptc_is_chain(const char *chain, struct xtc_handle *const handle);/* Take a snapshot of the rules.  Returns NULL on error. */struct xtc_handle *iptc_init(const char *tablename);/* Cleanup after iptc_init(). */void iptc_free(struct xtc_handle *h);...

Представим, что вам повезло, и документация есть. Здесь описываются сигнатуры функций, используемые структуры, псевдонимы, а также указаны ссылки на другие используемые заголовки. Первый квест найти библиотеку в ОС. Её название может отличаться от ожидаемого:

~$ find /usr/lib/x86_64-linux-gnu/ -maxdepth 1 -name 'libip*'/usr/lib/x86_64-linux-gnu/libip6tc.so.0.1.0/usr/lib/x86_64-linux-gnu/libip4tc.so/usr/lib/x86_64-linux-gnu/libiptc.so.0/usr/lib/x86_64-linux-gnu/libip4tc.so.0.1.0/usr/lib/x86_64-linux-gnu/libip6tc.so.0/usr/lib/x86_64-linux-gnu/libiptc.so.0.0.0/usr/lib/x86_64-linux-gnu/libip4tc.so.0/usr/lib/x86_64-linux-gnu/libiptc.so/usr/lib/x86_64-linux-gnu/libip6tc.so

Цифровой суффикс означает разные версии библиотек. В общем случае нам требуется оригинал libip4tc.so. Можно заглянуть внутрь одним глазком и убедиться, что дело стоящее:

~$ nm -D /usr/lib/x86_64-linux-gnu/libip4tc.so...0000000000206230 D _edata0000000000206240 B _end                 U __errno_location                 U fcntl000000000000464c T _fini                 U __fprintf_chk                 U free                 U getsockopt                 w __gmon_start__0000000000001440 T _init0000000000003c80 T iptc_append_entry0000000000003700 T iptc_builtin0000000000004640 T iptc_check_entry0000000000003100 T iptc_commit0000000000002ff0 T iptc_create_chain00000000000043f0 T iptc_delete_chain...

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

public static class Libiptc4{        /* Prototype: iptc_handle_t iptc_init(const char *tablename) */        [DllImport("libip4tc.so")]        public static extern IntPtr iptc_init(string tablename);} 

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

/* Prototype: iptc_handle_t iptc_init(const char *tablename) */[DllImport("libip4tc.so")]public static extern IntPtr iptc_init(IntPtr tblPtr);...var tblPtr = Marshal.StringToHGlobalAnsi("filter");var _handle = Libiptc4.iptc_init_ptr(tblPtr);Marshal.FreeHGlobal(tblPtr);

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

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

struct ipt_entry {struct ipt_ip ip;/* Mark with fields that we care about. */unsigned int nfcache;/* Size of ipt_entry + matches */__u16 target_offset;/* Size of ipt_entry + matches + target */__u16 next_offset;/* Back pointer */unsigned int comefrom;/* Packet and byte counters. */struct xt_counters counters;/* The matches (if any), then the target. */unsigned char elems[0];};

Обратите внимание на поле unsigned char elems[0] прототипа. Если я не ошибаюсь, это указатель на байтовый массив переменной длины, и его не нужно явно указывать в реализации. В упрощенном виде наш объект устроен следующим образом:

******************************************** ip_entry                                ** 112 bytes                               ********************************************* matches                                 ** target_offset - 112 bytes               ********************************************* target                                  ** next_offset - target_offset - 112 bytes ********************************************

Динамическая часть объекта (matches и target) пристыковывается к заголовку ip_entry. Создание такого объекта разбивается на два этапа:

  1. Выделение памяти требуемого размера.

  2. Последовательная запись элементов в нужные участки памяти.

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

[StructLayout(LayoutKind.Sequential)]public struct IptEntry{ public IptIp ip;public uint nfcache;public ushort target_offset;public ushort next_offset;public uint comefrom;public IptCounters counters;};

Размер реализации вычисляется как Marshal.SizeOf<IptEntry>()и равен 112 байт. Затем вычисляются размеры всех составных объектовmatches и target ( которые тоже могут быть динамическими). Нюанс: при работе с библиотекойlibiptc я столкнулся с требованием округлять размеры объектов в большую сторону по модулю 8 ( размер long), так что часть байт в хвосте объектов будет не востребована. Видимо, такой подход ускоряет чтение объектов. Функция выравнивания может выглядеть следующим образом:

static readonly int  _WORDLEN = Marshal.SizeOf<long>();public static int Align(int size){return ((size + (_WORDLEN - 1)) & ~(_WORDLEN - 1));}

После того как размер объекта вычислен, необходимо определить смещения в памяти entry.target_offset и entry.next_offset, выделить память и записать объекты:

IntPtr entryPtr = Marshal.AllocHGlobal(size);Marshal.StructureToPtr<IptEntry>(entryPtr, entry, false);Marshal.StructureToPtr<Match>(entryPtr + 112, match, false);

Чтение объекта происходит в обратном порядке: читаем заголовок, вычисляем смещение, читаем динамическую часть:

var entry = Marshal.PtrToStructure<IptEntry>(point);var match = Marshal.PtrToStructure<Match>(point + 112)

Помимо структур, на вашем пути может встретиться такой зверь как union:

struct xt_entry_match {union {struct {__u16 match_size;/* Used by userspace */char name[XT_EXTENSION_MAXNAMELEN];__u8 revision;} user;struct {__u16 match_size;/* Used inside the kernel */struct xt_match *match;} kernel;/* Total length */__u16 match_size;} u;unsigned char data[0];};

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

В описании прототипа можно встретить псевдонимы для дефолтных значений:

#define XT_EXTENSION_MAXNAMELEN   29...char name [XT_EXTENSION_MAXNAMELEN]

Как вытащить дефолтное значение в управляемый код для меня осталось загадкой. Поэтому приходится искать значения на просторах header файлов и устанавливать вручную.

Постарайтесь не злить магов, иначе получите проклятье в спину. Ваши ushort, uint и long будут хранить совсем не то, что ожидаете. Все дело в порядке байт. Привычным является прямой порядок: слева старший байт, справа меньший. Тем не менее при работе с сетевыми адресами и номерами портов может понадобиться обратный порядок байт. Для знаковых типов есть готовый метод. Для беззнаковых типов снимать проклятье придется самим:

byte [] convArray = BitConverter.GetBytes(value);Array.Reverse(convArray);ushort reverseEndian = BitConverter.ToUInt16(convArray,0);
ushort reverseEndian = (ushort)((value << 8) | (value >> 8));

В конце нашего путешествия пришло время поговорить о перехвате ошибок. При работе с unmanaged code он работает не совсем так как мы привыкли. Функции могут возвращать флаг успех/неудача, а номер ошибки будет содержать переменная errno. В явном виде ее нигде нет. Поэтому берем дополнительный квест, и добавляем к атрибуту настройку:

[DllImport("libip4tc.so", SetLastError = true)]

Теперь, если нас постигнет неудача, можно вызвать:

int errno = Marshal.GetLastWin32Error();var errPtr = Libiptc4.iptc_strerror(errno);string errStr = Marshal.PtrToStringAnsi(errPtr);

И это сработает даже в Linux c net.core (видимо, не успели переименовать/забили). Также необходимо обращать внимание на сборку библиотек: могут быть как кросс-платформенные, так и отдельно 32/64 битные версии, для многих библиотек есть готовые порты в Windows . Поэтому ошибки времени запуска чаще всего решаются выбором подходящей версии библиотеки.

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

Подробнее..

SafetyNet Attestation описание и реализация проверки на PHP

11.02.2021 20:09:20 | Автор: admin

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

После многочасовых поисков и скрупулёзного изучения официальной документации Google решил поделиться полученным опытом. Потому что, кроме официальной документации, я нашел только отрывочные описания частных примеров реализации на разных ЯП. И ни намека на комплексное объяснение особенностей проверки по SafetyNet на сервере.

Статья будет полезна разработчикам, которые хотят подробнее разобраться с технологией верификации устройств по протоколу SafetyNet Attestation. Для изучения описательной части не обязательно знать какой-либо язык программирования. Я сознательно убрал примеры кода, чтобы сфокусироваться именно на алгоритмах проверки. Сам пример реализации на PHP сформулирован в виде подключаемой через composer библиотеки и будет описан ниже.

Дисклеймер: материал в явном виде содержит перевод официальной документации от Google с разъяснениями и описанием особенностей реализации, с которыми я столкнулся.

О технологии

Технология SafetyNet Attestation разработана Google как средство предоставления разработчикам мобильных приложений информации о надёжности приложения клиента при взаимодействии с сервером, который обслуживает мобильное приложение. Для этого в протоколе взаимодействия предусмотрен удостоверяющий сервис от Google, обеспечивающий верификацию, и представлены рекомендации по проверке ответа от удостоверяющего центра на стороне сервера.

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

Что позволяет проверить технология:

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

  2. Что в процессе взаимодействия клиента и сервера нет больше никого, кроме вашего приложения и сервера.

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

В каких случаях механизм не применим или не имеет смысла:

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

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

  3. Если требуется детальное понимание статусов модификации системы, на которой работает мобильное приложение. В протокол заложен механизм однозначного определения модификации устройства. Он состоит из двух переменных: ctsProfileMatch и basicIntegrity. Об их назначении чуть ниже.

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

Схематично процесс проверки клиента можно представить в виде схемы:

Рассмотрим поэтапно процесс верификации устройств по протоколу:

  1. Инициация процесса проверки со стороны клиента.Отправка запроса от клиента на Backend на генерацию уникального идентификатора проверки (nonce) сессии. В процессе выполнения запроса на сервере генерируется ключ (nonce) сессии, сохраняется и передаётся на клиент для последующей проверки.

  2. Генерация JSW-токена на стороне удостоверяющего центра.Клиент, получив nonce, отправляет его на удостоверяющий центр вместе со служебной информацией. Затем в качестве ответа клиенту возвращается JWS, содержащий информацию о клиенте, время генерации токена, информацию о приложении (хеши сертификатов, которыми подписывается приложение в процессе публикации в Google Store), информацию о том, чем был подписан ответ (сигнатуру). О JWS, его структуре и прочих подробностях расскажу дальше в статье.

  3. Затем клиент передаёт JWS в неизменном виде на Backend для проверки. А на стороне сервера формируется ответ с информацией о результате прохождения аттестации.После получения статуса проверки JWS на стороне сервера обычно сохраняют факт проверки и, опираясь на него, влияют на функциональность положительным или негативным образом. Например, клиентам, не прошедшим аттестацию, можно отключить возможность писать комментарии, голосовать за рейтинг, совершать иные активности, которые могут повлиять на качество контента приложения или создавать угрозу и неудобство другим пользователям.

Описание процесса верификации на стороне сервера JWS от удостоверяющего центра

Документация Google в рамках тестирования на сервере предлагает организовать online-механизм верификации JWS, при котором с сервера приложения отправляется запрос с JWS на удостоверяющий сервис Google. А в ответе от сервиса Google содержится полный результат проверки JWS.

Но данный метод проверки JWS для промышленного использования не рекомендуются. И даже больше: для каждого приложения существует ограничение в виде 10 000 запросов в сутки (подробнее об ограничениях здесь), после которых вы выгребите квоту и перестанете получать от него вменяемый ответ. Только информацию об ошибке.

Далее расскажу обо всём алгоритме верификации JWS, в том числе о верификации самих сертификатов (проверке цепочки сертификатов).

Подробнее о JWS

JWS представляет собой три текстовых (base64 зашифрованных) выражения, разделенные точками (header.body.signature):

Например:

eyJhbGciOiJSUzI1NiIsICJ4NWMiOiBbInZlcnlzZWN1cmVwdWJsaWNzZXJ0Y2hhaW4xIiwgInZlcnlzZWN1cmVwdWJsaWNzZXJ0Y2hhaW4yIl19.ewogICJub25jZSI6ICJ2ZXJ5c2VjdXJlbm91bmNlIiwKICAidGltZXN0YW1wTXMiOiAxNTM5ODg4NjUzNTAzLAogICJhcGtQYWNrYWdlTmFtZSI6ICJ2ZXJ5Lmdvb2QuYXBwIiwKICAiYXBrRGlnZXN0U2hhMjU2IjogInh5eHl4eXh5eHl4eXh5eHl5eHl4eXg9IiwKICAiY3RzUHJvZmlsZU1hdGNoIjogdHJ1ZSwKICAiYXBrQ2VydGlmaWNhdGVEaWdlc3RTaGEyNTYiOiBbCiAgICAieHl4eXh5eHl4eXh5eHl4eXh5eD09PT09Lz0iCiAgXSwKICAiYmFzaWNJbnRlZ3JpdHkiOiB0cnVlCn0=.c2lnbmF0dXJl

В данном примере после расшифровки base64 получим:

Header :

json_decode(base64_decode(eyJhbGciOiJSUzI1NiIsICJ4NWMiOiBbInZlcnlzZWN1cmVwdWJsaWNzZXJ0Y2hhaW4xIiwgInZlcnlzZWN1cmVwdWJsaWNzZXJ0Y2hhaW4yIl19))={"alg":"RS256","x5c":["verysecurepublicsertchain1","verysecurepublicsertchain2"]}

Body:

json_decode(base64_decode(ewogICJub25jZSI6ICJ2ZXJ5c2VjdXJlbm91bmNlIiwKICAidGltZXN0YW1wTXMiOiAxNTM5ODg4NjUzNTAzLAogICJhcGtQYWNrYWdlTmFtZSI6ICJ2ZXJ5Lmdvb2QuYXBwIiwKICAiYXBrRGlnZXN0U2hhMjU2IjogInh5eHl4eXh5eHl4eXh5eHl5eHl4eXg9IiwKICAiY3RzUHJvZmlsZU1hdGNoIjogdHJ1ZSwKICAiYXBrQ2VydGlmaWNhdGVEaWdlc3RTaGEyNTYiOiBbCiAgICAieHl4eXh5eHl4eXh5eHl4eXh5eD09PT09Lz0iCiAgXSwKICAiYmFzaWNJbnRlZ3JpdHkiOiB0cnVlCn0=))={"nonce":"verysecurenounce","timestampMs":1539888653503,"apkPackageName":"very.good.app","apkDigestSha256":"xyxyxyxyxyxyxyxyyxyxyx=","ctsProfileMatch":true,"apkCertificateDigestSha256":["xyxyxyxyxyxyxyxyxyx=====/="],"basicIntegrity":true}

Signature

json_decode(base64_decode(c2lnbmF0dXJl))= signature

Остановимся на том, что именно содержится во всем JWS.

Header:

  • alg алгоритм, которым зашифрованы Header и Body JWS. Нужен для проверки сигнатуры.

  • x5c публичная часть сертификата (или цепочка сертификатов). Также нужен для проверки сигнатуры.

Body:

  • nonce произвольная строка полученная с сервера и сохранённая на нём же.

  • timestampMs время начала аттестации.

  • apkPackageName название приложения, которое запросило аттестацию.

  • apkDigestSha256 хеш подписи приложения, которое загружено в Google Play.

  • ctsProfileMatch флаг, показывающий прошло ли устройство пользователя верификацию в системе безопасности Google (основной и самый жёсткий критерий, по которому можно понять было ли устройство заручено и прошло ли оно сертификацию в Google).

  • apkCertificateDigestSha256 хеш сертификата (цепочки сертификатов), которыми подписано приложение в Google Play.

  • basicIntegrity более мягкий (по сравнению с ctsProfileMatch) критерий целостности установки.

Signature

Бинарная сигнатура, с помощью которой можно сделать заключение, что тело сообщения JWS было подписано с использованием сертификатов (цепочки сертификатов) указанных в Header, и с использованием известного нам приватного ключа. Ключевое позволяет понять, что в цепочке взаимодействия нет никого, кроме нас и удостоверяющего центра Google.

Проверка сертификатов

Перейдём к непосредственной проверки каждой части полученного JWS. Начнём с сертификатов и алгоритма шифрования:

1. Проверяем, что алгоритм, с помощью которого подписано тело, нами поддерживается:

[$checkMethod, $algorithm] = JWT::$supported_algs[$statement->getHeader()->getAlgorithm()];if ($checkMethod != 'openssl') {   throw new CheckSignatureException('Not supported algorithm function');}

2. Проверяем, что сертификат (цепочка сертификатов), содержащиеся в Header (поле x5c), удовлетворяют нас по содержимому (загружаются в качестве публичных ключей):

private function extractAlgorithm(array $headers): string{   if (empty($headers['alg'])) {       throw new EmptyAlgorithmField('Empty alg field in headers');   }   return $headers['alg'];}private function extractCertificateChain(array $headers): X509{   if (empty($headers['x5c'])) {       throw new MissingCertificates('Missing certificates');   }   $x509 = new X509();   if ($x509->loadX509(array_shift($headers['x5c'])) === false) {       throw new CertificateLoadError('Failed to load certificate');   }   while ($textCertificate = array_shift($headers['x5c'])) {       if ($x509->loadCA($textCertificate) === false) {           throw new CertificateCALoadError('Failed to load certificate');       }   }   if ($x509->loadCA(RootGoogleCertService::rootCertificate()) === false) {       throw new RootCertificateError('Failed to load Root-CA certificate');   }   return $x509;}

3. Валидируем сигнатуру сертификата (цепочки сертификатов):

private function guardCertificateChain(StatementHeader $header): bool{   if (!$header->getCertificateChain()->validateSignature()) {       throw new CertificateChainError('Certificate chain signature is not valid');   }   return true;}

4. Сверяем hostname подписавшего сервера с сервером аттестации Google (ISSUINGHOSTNAME = 'attest.android.com'):

private function guardAttestHostname(StatementHeader $header): bool{   $commonNames = $header->getCertificateChain()->getDNProp('CN');   $issuingHostname = $commonNames[0] ?? null;   if ($issuingHostname !== self::ISSUING_HOSTNAME) {       throw new CertificateHostnameError(           'Certificate isn\'t issued for the hostname ' . self::ISSUING_HOSTNAME       );   }   return true;}

Верификация тела JWS

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

1. Проверка nounce.

Тут все просто. Распаковали JWS, получили в Body nonce и сверили с тем, что у нас сохранено на сервере:

private function guardNonce(Nonce $nonce, StatementBody $statementBody): bool{   $statementNonce = $statementBody->getNonce();   if (!$statementNonce->isEqual($nonce)) {       throw new WrongNonce('Invalid nonce');   }   return true;}

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

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

Есть два параметра, на основе которых можно принимать решение о надежности устройства: ctsProfileMatch и basicIntegrity. ctsProfileMatch более строгий критерий, он определяет сертифицировано ли устройство в Google Play и верифицировано ли устройство в сервисе проверки безопасности Google. basicIntegrity определяет, что устройство не было скомпрометировано.

private function guardDeviceIsNotRooted(StatementBody $statementBody): bool{   $ctsProfileMatch = $statementBody->getCtsProfileMatch();   $basicIntegrity = $statementBody->getBasicIntegrity();   if (empty($ctsProfileMatch) || !$ctsProfileMatch) {       throw new ProfileMatchFieldError('Device is rooted');   }   if (empty($basicIntegrity) || !$basicIntegrity) {       throw new BasicIntegrityFieldError('Device can be rooted');   }   return true;}

3. Проверяем время начала прохождения аттестации.

Тоже ничего сложного. Нужно проверить, что с момента ответа от сервера Google прошло немного времени. По сути, нет чётких критериев прохождения теста с реферальным значением нужно определиться самим.

private function guardTimestamp(StatementBody $statementBody): bool{   $timestampDiff = $this->config->getTimeStampDiffInterval();   $timestampMs = $statementBody->getTimestampMs();   if (abs(microtime(true) * 1000 - $timestampMs) > $timestampDiff) {       throw new TimestampFieldError('TimestampMS and the current time is more than ' . $timestampDiff . ' MS');   }   return true;}

4. Проверяем подпись приложения.

Здесь тоже два параметра: apkDigestSha256 и apkCertificateDigestSha256. Но apkDigestSha256 самой Google помечен как нерекомендуемый способ проверки. С марта 2018 года они начали добавлять мета-информацию в приложения из-за чего ваш хеш подписи приложения может не сходиться с тем, который будет приходить в JWS (подробнее здесь).

Поэтому единственным способом проверки остается проверка хеша подписи приложения apkCertificateDigestSha256. Фактически этот параметр нужно сравнить с теми sha1 ключа, которым подписываете apk при загрузке в Google Play.

private function guardApkCertificateDigestSha256(StatementBody $statementBody): bool{   $apkCertificateDigestSha256 = $this->config->getApkCertificateDigestSha256();   $testApkCertificateDigestSha256 = $statementBody->getApkCertificateDigestSha256();   if (empty($testApkCertificateDigestSha256)) {       throw new ApkDigestShaError('Empty apkCertificateDigestSha256 field');   }   $configSha256 = [];   foreach ($apkCertificateDigestSha256 as $sha256) {       $configSha256[] = base64_encode(hex2bin($sha256));   }   foreach ($testApkCertificateDigestSha256 as $digestSha) {       if (in_array($digestSha, $configSha256)) {           return true;       }   }   throw new ApkDigestShaError('apkCertificateDigestSha256 is not valid');}

5. Проверяем имя приложения, запросившего аттестацию.

Сверяем название приложения в JWS с известным названием нашего приложения.

private function guardApkPackageName(StatementBody $statementBody): bool{   $apkPackageName = $this->config->getApkPackageName();   $testApkPackageName = $statementBody->getApkPackageName();   if (empty($testApkPackageName)) {       throw new ApkNameError('Empty apkPackageName field');   }   if (!in_array($testApkPackageName, $apkPackageName)) {       throw new ApkNameError('apkPackageName ' . $testApkPackageName. ' not equal ' . join(", ", $apkPackageName));   }   return true;}

Верификация сигнатуры

Здесь нужно совершить одно действие, которое даст нам понимание того, что Header и Body ответа JWS подписаны сервером авторизации Google. Для этого в исходном виде склеиваем Header c Body (с разделителем в виде ".") и проверяем сигнатуру:

protected function guardSignature(Statement $statement): bool{   $jwsHeaders = $statement->getRawHeaders();   $jwsBody = $statement->getRawBody();   $signData = $jwsHeaders . '.' . $jwsBody;   $stringPublicKey = (string)$statement->getHeader()->getCertificateChain()->getPublicKey();   [$checkMethod, $algorithm] = JWT::$supported_algs[$statement->getHeader()->getAlgorithm()];   if ($checkMethod != 'openssl') {       throw new CheckSignatureException('Not supported algorithm function');   }   if (openssl_verify($signData, $statement->getSignature(), $stringPublicKey, $algorithm) < 1) {       throw new CheckSignatureException('Signature is invalid');   }   return true;}

Вместо заключения. Библиотека на PHP

Уже после решения задачи и отдельно от нашей кодовой базы, я разработал библиотеку на PHP, которая обеспечивает полный цикл верификации JWS.

Её можно скачать из Packagist и использовать в своих прое

Подробнее..

Борьба за жизни переменных. Или как я попытался упростить жизнь Android разработчикам

17.03.2021 02:16:03 | Автор: admin

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

Идея

Идея появилась из проблемы. Проблема появилась из негодования.

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

И не хотелось сильно размазывать код этими переменными...

Каким образом решить эту задачу? Сразу вспоминаешь решения, которые уже есть: bundle и т.д.

Но мне одному все эти решения кажутся неудобными? Мне одному кажется, что они выписываются из общего кода, когда начинаешь с ними работать?

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

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

Реализация

Ну тут все просто (имхо)

Например, мы уже определились со скоупом наших значений, которые мы хотим хранить. Тогда просто создаем класс в котором просто декларируем их и помечаем этот класс аннотацией @Unkillable

Например вот так:

@Unkillabledata class SampleFragmentState(    val testValue: Double,    val testLiveData: MutableLiveData<Double>) : EmptyState()

Также мы можем там указать и произвольные классы, но только они уже должны быть с Parcelize (Подробней).

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

class SampleViewModel(    application: Application,    savedStateHandle: SavedStateHandle) : AndroidStateViewModel(application, savedStateHandle) {    override fun provideState() = createState<UnkillableSampleFragmentState>()}

UnkillableSampleFragmentState сгенерируется у Вас сразу после запуска билда проекта.

Естественно, наша ViewModel должна быть проинициализированна, но не совсем так как обычно. А так, как предлагает Google для использования SavedStateHandle.

activity?.application?.let { application ->     viewModel = ViewModelProvider(this, SavedStateViewModelFactory(application, this))        .get(SampleViewModel::class.java) }

Это все. Теперь можно использовать по назначению! Просто записываем туда данные и достаем их. Отмечу, что для сохранения класса необходимо делать его @Parcelize (Подробнее тут).

Вот таким образом можно воспользоваться библиотекой.

init {    // get values example    Log.d("StateLog", "0 value ${state.testValue}")    Log.d("StateLog", "1 value ${state.testLiveData?.value}")}fun onSetDataClicked() {    // set values example    state.testValue = 2.2    state.updateTestLiveDataValue(3.3) // yourLiveData.value = 3.3    state.postUpdateTestLiveDataValue(3.3) // yourLiveData.postValue(3.3)}

Таким образом, мы защищаемся от подчистки приложения из памяти операционной системой.

Итог

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

GitHub

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru