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

Велосипедостроение

MarkedText маркдаун здорового человека

08.01.2021 12:15:36 | Автор: admin

Здравствуйте, меня зовут Дмитрий Карловский и все свои статьи (и презентации) пишу я в MarkDown разметке. И знаете что? Она уже порядочно меня подзаелозила! Тексты я пишу на русском, но большая часть спецсимволов есть только в английской раскладке клавиатуры. А редактирование таблиц - это вечная пизанская башня из вертикальных линий. Короче, есть у него проблемы как с удобством редактирования, так и с наглядностью представления. Так что давайте попробуем спроектировать его с нуля, не таща за собой килотонны головоломных конструкций.

Вёрстка несколько поехала, так как на Хабре выкатили новый кривой визивиг. Так что теперь писать статьи в маркдауне, а потом выкладывать на Хабр будет крайне сложно. С нормальной вёрсткой эту статью вы можете почить на гитхабе: https://github.com/nin-jin/HabHub/issues/39

Принципы

  • Однозначность синтаксиса

  • Простота синтаксиса

  • Единообразность синтаксиса

  • Минимальное влияние на естественный вид текста

  • Удобство редактирования независимо от раскладки

  • Наглядность представления

  • Расширяемость

  • Быстрая и надёжная запоминаемость

В качестве спец-символов форматирования лучше использовать такие, которые есть в любой раскладке, а не только в английской. То есть при прочих равных лучше отдать предпочтение следующим символам: ! " ; % : ? * ( ) _ + / \ . , - =

Существующие решения

В английской Википедии есть сравнительный обзор легковесных языков разметки, так что не будем повторяться. Там приведены следующие языки: AsciiDoc, BBCode, Creole, GitHub Flavored Markdown, Markdown, Markdown Extra, MediaWiki, MultiMarkdown, Org-mode, PmWiki, POD, reStructuredText, Textile, Texy, txt2tag.

Почти все они придерживаются примерно похожих конструкций. Однако, BBCode не является легковесным - это практически HTML с квадратными скобками вместо угловых. Поэтому его мы далее рассматривать не будем. Также проигнорируем и POD, чей синтаксис слишком многословный и не слишком наглядный.

Текстовые блоки

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

Списки

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

Элементы неупорядоченного списка предваряются маркером. По нормам многих языков таким маркером выступает тире. В вебе в основном потребляются "буллеты". В облегчённых языках разметки в основном используются следующие:

- item* item+ item

Из них лучше всего подходит на эту роль именно дефис - он выглядит похоже на тире и легко набирается с клавиатуры. Маркер и текст элемента списка отделяется пробелом. Таким образом суммарный отступ текста слева получается равным 2 - именно такой у нас будет отступ у вложенных списков, чтобы они были выровнены с текстом, к которому относятся. То есть останавливаемся на таком варианте:

- first- second  - first of second    - first of first of second  - second of second - third
  • first

  • second

    • first of second

      • first of first of second

    • second of second

  • third

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

1. item2) item

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

# item

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

+ first+ second  + first of second    + first of first of second  + second of second + third
  1. first

  2. second

    1. first of second

      1. first of first of second

    2. second of second

  3. third

Цитаты

Цитаты объединяют произвольные блоки текста, показывая, что их автором является кто-то другой. Распространённая практика - выделять цитаты угловыми скобками:

> quote> - list in quote> > inner quote

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

" quote" - list in quote" " inner quote

Таблицы

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

|=  |= table |= header || a | table  | row     || b | table  | row     ||   | table | header ||---|-------|--------|| a | table | row    || b | table | row    |First Header | Second Header------------ | -------------Content from cell 1 | Content from cell 2Content in the first column | Content in the second column

Однако, такое представление сильно бьёт по удобству редактирования - приходится вручную постоянно выравнивать колонки.

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

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

|   | table                                                                                                                          | header || a | There are many variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour, or randomised words which don't look even slightly believable.  | row     || b | table                                                                                                                          | row     |

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

!   ! table    ! header! a  ! There are many variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour, or randomised words which don't look even slightly believable.    ! row! b  ! table     ! row

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

Заголовки

В некоторых языках заголовки выделяются разными типами подчёркиваний:

Level 1 Heading===============Level 2 Heading---------------Level 3 Heading~~~~~~~~~~~~~~~

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

Есть более удобная форма, где уровень заголовка определяется числом спецсимволов в префиксе:

# Level 1 Heading ### Level 2 Heading ##### Level 3 Heading ###

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

## Level 2 Heading== Level 2 Heading** Level 2 Heading!! Level 2 Heading++ Level 2 Heading

Решётка есть лишь в английской раскладке. Плюсы, звёздочки, восклицательные знаки мы задействуем для другой семантики. А вот массивные символы равенства - то, что надо. Заголовки делят текст на секции, так что пара горизонтальных линий отлично это иллюстрируют:

= Level 1 Heading== Level 2 Heading=== Level 3 Heading

Преформатированный текст

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

```markdownpreformatted         text```

Однако, если в таком тексте тоже нужно вывести эти кавычки, то получается облом. Поэтому предпочтительнее вариант с префиксом перед каждой строкой. Например, в качестве такого префикса может выступать отступ, равный 2 или 4 пробелам:

    preformatted             text

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

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

Поэтому мы разделим префикс на две части:

  • Маркер преформатированного текста из 2 пробелов.

  • Маркер форматирования строки из пары спец символов.

Выглядеть оно будет так:

    preformatted            text  --deleted  --   text  ++inserted  ++    text  **highlighted  **       text

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

Инлайн форматирование

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

  • 1 - слишком мало, высок риск, что символ обычного текста будет воспринят как форматирование.

  • 3 - слишком много, каждый раз трижды нажимать клавишу - слишком утомительно.

  • 2 - золотая середина, на этом и остановимся.

Акценты

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

Варианты сильного акцента:

*strong***strong**__strong__'''strong'''''strong''

Апострофы слишком похожи на кавычки и секунды, так что их сразу отбрасываем. Из оставшихся более распространён вариант с "массивными" звёздочкам - его и выберем.

**strong**

strong

Варианты слабого акцента:

'emphasis'''emphasis''_emphasis_/emphasis///emphasis//*emphasis*~emphasis~

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

//emphasis//

emphasis

Правки

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

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

_insertion___insertion__+insertion+

И удалений:

~deletion~~~deletion~~-deletion---deletion--

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

++insertion++--deletion--
  • insertion

  • deletion

Ссылки

Ссылки бывают двух видов:

  • Гиперссылка - она переносит вас к цели при клике на неё. Для неё задаётся отображаемое содержимое и урл для перехода.

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

Гиперссылки выглядят в разных языках так:

"Text":http://example.comhttp://example.com[Text]<http: example.com|text="">[Text|http://example.com][[Text|http://example.com]][[http://example.com|Text]][Text http://example.com][http://example.com Text][Text](http://personeltest.ru/away/example.com)`Text <http: example.com="">`_

А встраивания так:

![title](http://personeltest.ru/away/example.com/image.png){{http://example.com/image.png|title}}.. image:: /path/to/image.jpg

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

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

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

""Embedded image\http://example.org/favicon.ico""""Embedded video\https://youtube.com/video=1234""""Embedded site\https://marked.hyoo.ru/""

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

""http://example.org/favicon.ico""""http://example.org/favicon.ico\http://example.org/favicon.ico""

Для гиперссылок же воспользуемся \ во всех местах:

\\Clickable text\http://example.org/\\Clickable url: \\http://example.org/\\

Разумеется разные типы ссылок можно комбинировать. Например, сделать картинку-ссылку можно так:

\\""Example\http://example.org/favicon.ico""\http://example.org/\\

Инлайн код

Инлайн код выводится моноширинным шрифтом и выключает внутри себя любое форматирование. В каждом языке используется что-то своё:

+monospace text+`monospace text```monospace text`````monospace text```|monospace text|{{monospace text}}{{{monospace text}}}=code=~verbatim~@monospace text@@@monospace text@@

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

This is  monospace text  !

This is monospace text !

Резюме

Теперь давайте соберём все конструкции формата в одной короткой шпаргалке:

= MarkedTextФормат текста с **легковесным форматированием**.--== Принципы+ Синтаксис:- Однозначность- Простота- Единообразность+ Внешний вид:- Минимальное влияние на естественный вид текста- Наглядность форматирования+ Редактирование:- Независимость от раскладки- Быстрая и надёжная запоминаемость== Cравнение с альтернативами! **Язык**! **Плюсы**    ! **Минусы**! MarkedText! - Удобное редактирование таблиц.! - Поддержка сложного форматирования внутри ячеек.! - Простота реализации.! - Легко запоминающийся консистентный синтаксис.! - Удобство редактирования в русской раскладке.! - Колонки не расползаются далеко вправо за горизонтальный скроллинг и не переносятся на новую строку.    ! - Не поддерживается пока что никакими сторонними инструментами.! MarkDown! - Широкая поддержка различными инструментами.! - Наглядное представление таблиц.    ! - Сложности с редактированием таблиц.    ! - Сильно ограниченное содержимое ячеек.== Парсинг    const res = [ ... $hyoo_marked_line.parse( '**text**' ) ]--$mol_assert_equal( res[0].strong, '**text**' )++$mol_assert_equal( res[0].marker, '**' )**$mol_assert_equal( res[0].content, 'text' )== Отзывы" " " Типичный пользователь: Нигде не поддерживается, идите в --жопу-- ++Жодино++ с таким синтаксисом!" " " " Но мы же программисты, мы можем это исправить.. Для этого даже не надо быть экспертом ни по  C++  , ни по  D++  .." " Никому это не нужно (с) Диванный ЭкспертТем не менее, это полезное упражнение в проектировании.== Ссылки- Песочница: \\https://marked.hyoo.ru/\\- \\Статья о MarkedText\https://github.com/nin-jin/HabHub/issues/39\\- \\Парсер на TS\https://github.com/hyoo-ru/marked.hyoo.ru/\\- \\Конвертер в HTML на TS\https://github.com/hyoo-ru/marked.hyoo.ru/tree/master/to/html\\- ""Результат билда $mol_regexp\https://github.com/hyoo-ru/mam_mol/workflows/mol_regexp/badge.svg""

Ссылки

Подробнее..

Фрактальная шизофрения

30.08.2020 00:09:30 | Автор: admin


Нет, я не болен. По крайней мере так говорит голос в моей голове. Я наркоман. Вот уже более 15 лет я сижу на игле. Употребляю много, жёстко, до оборочного состояния. Докатился до того, что в последнее время не стесняюсь ни друзей, ни жены, ни детей Двоих детей! Не люблю бадяженый, люблю чистый, без примесей. За годы перепробовал многое, но в последнее время остановился в поисках. Забавно осознавать, что от одного и того же получаешь одновременно и боль, и радость. Мне бы в лечебку, я даже хочу, я даже знаю в какую. Знаете такие, где продолжаешь употреблять, но под присмотром?


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


Помню тот день, когда узнал о новой es-фишке генераторах и итераторах. Я только пересел с ruby на javascript, втянулся в него, как в голове что-то щёлкнуло а как же yield? Восторгу не было предела! Он есть, да ещё и работает по другому это что ж получается: можно код останавливать? можно возобновлять? а ещё и параметры по ходу выполнения вводить/выводить? Oh My God!


Теперь я пихал их везде. Извращался над ними, как мог, чувствовал в них что-то магическое, не понимал, что именно, но банальная генерация бесконечных коллекций казалась лишь вершиной айсберга их функционала. Посему упорно раз за разом я пытался вывернуть их наизнанку, ставил опыты, как, например, yield первого подать на ввод второго и обратно yield второго подать на ввод первого, зациклить, да ещё и какой-то КПД с этого получить. В общем изголялся настолько, насколько фантазии хватало. В какой-то момент идеи закончились, все подопытные были замучены насмерть, новый гибрид вывести не удалось...


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


Взгляды на фронтенд-разработку довольно сильно разнились с текущими трендами. Принципы MV* казались, не то что притянутыми вытянутыми за уши с сервера в браузер. Чёрт возьми да мы до сих пор с третьей буквой * определиться не можем! А стейт? Мы слепо верим, что скелет M должен висеть в шкафу, а плоть V лежать на полке и чем дальше друг от друга тем лучше, при этом оба должны обмениваться приветами и хлопать в ладоши, потому что нам лучше знать, как вам лучше жить!


С этими мыслями я вернулся в лабораторию генераторов, а заодно перешёл на typescript. Человек я разносторонний, попав в википедию, переходами по ссылкам могу погружаться в глубь стека до заветного Maximum call stack size exceeded с последующей потерей контекста и вопросом типа а чё меня сюда вообще понесло? Собственно эта разностороность сначала добавила в список моих интересов астрономию, квантовую механику и теорию эфира, а затем труды Бенуа Мандельброта и, соответственно, фракталы.


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


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


Безумные идеи а-ля "наш мир один большой фрактал" то и дело теребили разум и не только мой я задолбал этими разговорами жену и друзей, дети, к их счастью, не попадали под эту раздачу отборного бреда. Однажды я заявил супруге: "Заведу себе пса, назову Фрактал" долго смеялись "Фрактал! Ко мне!". Звучит! Не правда ли?)


Как-то февральским вечером я стоял на балконе и втыкал в звёзды. Небо на удивление было необычайно чистым. Сириус, самая яркая, всегда в это время висящая над крышей дома напротив, казалось, была близка, как никогда. А что если рвануть навстречу звезде? И, чисто гипотетически что если, во время приближения, с определенного расстояния мы обнаружим, что это не звезда, а целое созвездие или система звёзд? Ведь с Сириусом так и было до середины 19 века считалось, что это самостоятельная звезда, пока в 1844 немецкий астроном Фридрих Бессель не предположил, а в 1862 американский астроном Альван Кларк не подтвердил, что Сириус это система из двух звёзд, вращающихся вокруг общего центра масс. Впоследствии они получили названия Сириус А и Сириус В. Две близко расположенных звезды, свет которых ввиду огромного расстояния воспринимается нами, как излучаемый одним источником. А что если выбрать одну из этих звёзд и лететь уже навстречу ей, а с какого-то расстояния опять обнаружить созвездие? А что если так будет повторяться бесконечно? Мы бесконечно будем видеть поток света, проецируемый на сетчатку нашего глаза, но при всём желании не сможем достичь его источника, а именно так и получается при постоянном погружении во множество Мандельброта, Жулиа Мы постоянно видим Поток? Проекций? Фрактала?


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


Должен признаться, что на сбор этого паззла ушло несколько месяцев, 3 репозитория, ~800 git-веток и тысячи строк экспериментального кода. Теперь мне не давал покоя yield*. Да-да, со звёздочкой. Вы часто используете return внутри генератора? С вашего позволения я разбавлю этот эпос небольшим куском кода, в котором, используя генераторы, мы опишем формирование проекции Сириуса в созвездии Большого Пса.


async function* SiriusA() {    return '*A' // Проекция звезды Сириус А}async function* SiriusB() {    return '*B' // Проекция звезды Сириус В}async function* Sirius() {    // ['*A', '*B'] Проекция звёздной системы Сириус    return [yield* SiriusA(), yield* SiriusB()]}async function* CanisMajor() {    const sirius = yield* Sirius() // ['*A', '*B']    // а где-то тут создается проекция созвездия Большого Пса}

И так до бесконечности можно собирать всё более и более сложные структуры-проекции. Вы скажете: "Что тут особенного? Все тоже самое можно описать обычными функциями" и будете правы, но одна особенность тут всё же есть. Вся фишка в yield*. Дело в том, что на пути следования к return-значению данное выражение попутно будет "выкидывать наверх" встречающиеся yield-значения, что можно задействовать в служебных целях скрытно, за кулисами, определить контекст выполнения, режим работы и прочие внутренние системные параметры. Не буду утомлять вас подробностями реализации на самом деле, как сказал Йозеф Геббельс: "Всё гениальное просто", а все мои танцы с бубном в итоге вылились в ~300 строк элементарного кода. Нет я не гений, это самокритика, из-за которой какое-то время я чувствовал себя идиотом, ведь как оказалось решение лежало на поверхности, нужно было лишь взглянуть под нужным углом. Видать угол долго искал.


Всё есть фрактал


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


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



@fract/core это реализация небольшая библиотека, предоставляющая два простых строительных блока: fractal и fraction. С их помощью можно описать сколь угодно сложную я надеюсь структуру, которая будет являться фрактальным приложением. Последнее можно запустить в браузере или на сервере, а можно упаковать в библиотеку, поделиться ею в npm и подключить через yield*.


Простой фрактальный Hello world может выглядеть, например, так


import { fractal, fraction } from '@fract/core'const Title = fraction('Hello world')const HelloWorld = fractal(async function* () {    while (true) yield `App: ${yield* Title}`})

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


Жизненный цикл


Внутри генератора ключевое слово yield определяет, а выражение yield* извлекает текущую проекцию фрактала, другими словами если представить всё в виде трубопровода, то yield* это тянуть снизу, а yield толкать наверх. Этакий pull & push.



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


  1. с помощью генератора создается новый итератор
  2. итератор выполняется до ближайшего yield или return полученное значение будет принято за текущую проекцию, во время этой работы все вызовы yield* автоматически устанавливают наблюдаемые зависимости; как только новая проекция сгенерирована, фрактал переходит в режим ожидания обновлений, а вышестоящий фрактал узнаёт об изменениях
  3. узнав об изменениях в нижестоящих фракталах, список зависимостей очищается и, если на предыдущем шаге проекция была получена с помощью yield, фрактал продолжает свою работу со второго шага; если же был return работа продолжается с первого шага

Таким образом return делает то же самое, что и yield определяет текущую проекцию, но при этом происходит "перезагрузка" и всё начинается с начала.


Проекции и потоки


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


Обычный фрейм соответствует следующему интерфейсу и содержит в себе текущую проекцию фрактала


interface Frame<T> {    data: T;}

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


interface LiveFrame<T> extends Frame<T> {    next: Promise<LiveFrame<T>>;}

Живой фрейм это и есть тот самый поток проекций фрактала, иными словами: последовательность снимков его состояния.


Методы запуска


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


import { exec, live } from '@fract/core'

exec<T>(target: Fractal<T> | AsyncGenerator<T>): Promise<Frame<T>>


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


const frame = await exec(HelloWorld)frame.data // 'App: Hello world'

live<T>(target: Fractal<T> | AsyncGenerator<T>): Promise<LiveFrame<T>>


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


const frame = await live(HelloWorld)frame.data // 'App: Hello world'Title.use('Fractal Demo')const nextFrame = await frame.nextnextFrame.data // 'App: Fractal Demo'

Фрактальное приложение


Опишем фрактал некоторого пользователя, имеющего имя, возраст и банковскую карту


const Name = fraction('John')const Age = fraction(33)const Balance = fraction(100)const Card = fractal(async function* () {    while (true) {        yield {            balance: yield* Balance,        }    }})const User = fractal(async function* () {    while (true) {        yield {            name: yield* Name,            age: yield* Age,            card: yield* Card,        }    }})

Каждый описанный выше фрактал является полноценным приложением и может входить в состав более сложных фракталов


const frame = await exec(Balance)frame.data //> 100

const frame = await exec(Card)frame.data //> {balance: 100}

const frame = await exec(User)frame.data/*> {    name: 'John',    age: 33,    wallet: {        balance: 100    }}*/

С помощью exec получаются разовые снимки текущего состояния, а с помощью live живые


const frame = await live(User)console.log(frame.data)/*> {    name: 'John',    age: 33,    card: {        balance: 100    }}*/Name.use('Barry')Balance.use(200)const nextFrame = await frame.nextconsole.log(nextFrame.data)/*> {    name: 'Barry',    age: 33,    card: {        balance: 200    }}*/

Чтобы получать живые проекции и не заниматься перебором цепочки фреймов, можно написать фрактал, который будет передавать проекцию от User потребителю (выводить в консоль например), а в качестве своей отдавать undefined (выше всё равно уже никого нет)


const App = fractal(async function* () {    while (true) {        console.log(yield* User)        yield    }})live(App) // запускаем приложение

Именование


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


const User = fractal(async function* _User() {    /*...*/})User.name // '_User'

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


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



const user = yield* User

Реактивность


Система реактивности фрактала сама по себе фрактал, по своей структуре напоминающий фрактал Кантора



Построена она на обещаниях и их гонках, да да то самое ненавистное порой состояние гонки тут работает на нас.


В каждом живом фрейме промис следующего фрейма является по сути, промисом актуальности текущей проекции, пока он не зарезолвился проекция жива. Список зависимостей, собираемый на втором шаге жизненного цикла, это массив таких промисов Promise<LiveFrame<T>>[], именуемый racers, когда он составлен, создается гонка Promise.race(racers) эта гонка тоже промис racer текущей проекции, и в вышестоящем фрактале он опять попадает в массив racers петля замыкается. Визуально это можно выразить так



То же самое в коде будет выглядеть следующим образом



Promise.race([    // level 1     Promise.race([/* ... */]),    Promise.race([/* ... */]),    Promise.race([        // level 2        Promise.race([/* ... */]),        Promise.race([/* ... */]),        Promise.race([/* ... */]),        Promise.race([/* ... */]),        Promise.race([/* ... */]),         Promise.race([             // level 3            Promise.race([/* ... */]),            Promise.race([/* ... */])         ])    ])])

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


const Name = fraction('John')const User = fractal(async function* () {    while (true) {        yield `User ${yield* Name}`    }})const Title = fraction('Hello')const Post = fractal(async function* () {    while (true) {        delay(5000) // что-то долго делаем        yield `Post ${yield* Title}`    }})const App = fractal(async function* () {    while (true) {        console.log(`App | ${yield* User} | ${yield* Post}`)        yield    }})live(App)//> 'App | User John | Post Hello'Name.use('Barry')Title.use('Bye')//> 'App | User Barry | Post Hello'// через 5 секунд//> 'App | User Barry | Post Bye'

Здесь мы одновременно внесли изменения во фракции Name и Title, после чего фракталы User и Post начинают обновлять свои проекции, User сделает это первым, затем App обновится не дожидаясь обновления Post на самом деле App вообще не знает, что Post сейчас обновляется. App обновится ещё раз после того, как Post завершит работу над своей новой проекцией. Ключевой момент тут в том, что один медленный фрактал не "вешает" работу всего приложения.


Временные проекции


Довольно простой в употреблении и очень полезный механизм. Он позволяет организовать фоновое выполнение работы в то время, как вышестоящий фрактал довольствуется временным результатом. Создаются временные проекции с помощью функции tmp(data), а отдаются как обычные с помощью yield.


Один из вариантов использования организация "лоадеров". Работая во фронтенде, я всегда люто ненавидел лепить эти крутилки-вертелки, хотя понимал их необходимость.


import { fractal, tmp } from '@fract/core'const User = fractal(async function* () {    yield tmp('Loading...')    delay(5000) // что-то долго делаем    while (true) {        yield `User John`    }})const App = fractal(async function* () {    while (true) {        console.log(yield* User)        yield    }})live(App)//> 'Loading...'// через 5 секунд//> 'User John'

Здесь фрактал User является "медленным", прежде чем отдать свою проекцию ему надо сходить на сервер, по пути зайти в магазин и т.д. А кто-то сверху в это время ждёт его проекцию. Так вот, чтобы не заставлять себя ждать User отдаёт временную проекцию 'Loading...' и продолжает генерировать основную, которую отдаст по мере готовности, т.е. код генератора после yield tmp(...) продолжает выполняться, но уже в фоне.


Это ещё не всё вот так, например, можно сделать фрактал-таймер


import { fractal, tmp } from '@fract/core'const Timer = fractal(async function* () {    let i = 0    while (true) {        yield tmp(i++)        await new Promise((r) => setTimeout(r, 1000))    }})const App = fractal(async function* () {    while (true) {        console.log(yield* Timer)        yield    }})live(App)//> 0//> 1//> 2//> ...

Здесь фрактал Timer отдаёт текущее значение переменной i в качестве своей временной проекции и продолжает вычисление следующей, в процессе чего инкрементит i, дожидается окончания задержки в 1 секунду и цикл повторяется. Кстати говоря фракция именно так и устроена она отдаёт временную проекцию с текущим значением, и ждёт завершения промиса, который зарезолвится новым значением переданным в метод .use(data), после чего цикл повторится.


Делегирование


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


Допустим у нас есть фабрика newEditor, которая создает фрактал, отвечающий за редактирование профиля пользователя. Также у нас есть фрактал Manager, который в зависимости от фракции ProfileId переключает редактируемый профиль.



function newEditor(id) {    return fractal(async function* () {        const { name } = await loadUserInfo(id)        const Name = fraction(name)        while (true) {            // где-то в глубине этого фрактала генерируется            // интерфейс редактирования имени пользователя            yield <input                 placeholder="Input name"                 value={yield* Name}                 onChange={(e) => Name.use(e.target.value)}             />        }    })}const ProfileId = fraction(1)const Manager = fractal(async function* () {    while (true) {        const id = yield* ProfileId        const Editor = newEditor(id)        yield Editor // <-- делегируем работу фракталу Editor    }})const App = fractal(async function* () {    while (true) {        yield yield* Manager    }})

Фрактальное дерево будет производить пересборку проекций изнутри-наружу каждый раз, когда где-то в его глубине при редактировании будут происходить изменения, в данном примере во фракции Name. Пересборка неизбежно будет перезапускать циклы while(true) на всех уровнях до самого корня App, за исключением фрактала Manager. Последний делегирует работу над своей проекцией фракталу Editor, и как бы выталкивается из цепочки регенерации.



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


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


const ProfileId = fraction(1)const Manager = fractal(async function* () {    let lastProfileId    let Editor    while (true) {        const id = yield* ProfileId        if (id !== lastProfileId) {            lastProfileId = id            Editor = newEditor(id)        }        yield yield* Editor    }})

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


const BarryName = fractal(async function* () {    while (true) yield 'Barry'})const Name = fraction('John')const App = fractal(async function* () {    while (true) {        console.log(yield* Name)        yield    }})live(App)//> 'John'Name.use(BarryName)//> 'Barry'

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


Факторы


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



import { factor } from '@fract/core'const API_VERSION = factor('v2') // 'v2' | 'v3'// необязательное значение ^^^^ по умолчанию/* далее код из тела генератора */yield* API_VERSION('v3')    // устанавливаем значение фактораyield* API_VERSION          // 'v3' - получаемyield* API_VERSION.is('v3') // boolean - сравниваем// установка без аргументов эквивалентна// сброcу до значения по умолчаниюyield* API_VERSION()yield* API_VERSION          // 'v2' 

Через факторы можно передавать абсолютно любые данные, никаких ограничений нет. Рассмотрим пример, в котором мы плавно мигрируем со старого api на новое и с помощью фактора API_VERSION определяем фракталам какую версию api использовать в своей работе.


const Page = fractal(async function* () {    const apiVersion = yield* API_VERSION    while (true) {        yield `Work on api "${apiVersion}"`    }})const Modern = fractal(async function* () {    yield* API_VERSION('v3')    // всем нижележащим фракталам изпользовать api v3    while (true) {        yield yield* Page    }})const Legacy = fractal(async function* () {    yield* API_VERSION('v2')    // всем нижележащим фракталам изпользовать api v2    while (true) {        yield yield* Page    }})const App = fractal(async function* () {    while (true) {        console.log(`            Modern: ${yield* Modern}            Legacy: ${yield* Legacy}        `)        yield    }})live(App)/*> `    Modern: Work on api "v3"    Legacy: Work on api "v2"`*/

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



const Top = fractal(async function* () {    yield* API_VERSION('v3')    while (true) {        yield yield* Middle    }})const Middle = fractal(async function* () {    yield* API_VERSION       // 'v3' - определено во фрактале Top    yield* API_VERSION('v2') // переопределяем, но для нижних уровней    yield* API_VERSION       // на своем уровне у нас остается 'v3'    while (true) {        yield yield* Bottom    }})const Bottom = fractal(async function* () {    yield* API_VERSION       // 'v2' - переопределено в Middle    while (true) {        yield /*...*/    }})

И кость и плоть


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



const APP_STORE = 'APP'function newApp({ name = 'Hello world' } /* AppState {name: string} */) {    const Name = fraction(name)    return fractal(async function* App() {        while (true) {            switch (yield* MODE) {                case 'asString':                    yield `App ${yield* Name}`                    continue                case 'asData':                    yield { name: yield* Name } // as AppState {name: string}                    continue            }        }    })}const Dispatcher = fractal(async function* () {    // берем сохраненное состояние из локального хранилища    const data = JSON.parse(localStorage.getItem(APP_STORE) || '{}')    // создаем фрактал нашего приложения    const App = newApp(data)    // создаем фрактал с предопределенным режимом работы 'asString'    const AsString = fractal(async function* () {        yield* MODE('asString')        while (true) yield yield* App    })    // создаем фрактал с предопределенным режимом работы 'asData'    const AsData = fractal(async function* () {        yield* MODE('asData')        while (true) yield yield* App    })    while (true) {        const asString = yield* AsString // это мы выведем на экран        const asData = yield* AsData     // а это сохраним в хранилище        // выводим        console.log(asString)        // сохранияем        localStorage.setItem(APP_STORE, JSON.stringify(asData))        yield    }})

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



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


MV* MVVM MVP


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



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


Фрактальный модуль


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


import { fractal, factor } from '@fract/core'// app.jsexport const API_URI = factor()export const THEME = factor('light')export const App = fractal(async function* () {    const apiUri = yield* API_URI    const theme = yield* THEME    if (!apiUri) {        // обязательный фактор        throw new Error('Factor API_URI is not defined')    }    while (true) {        /*...*/    }})

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


Асинхронность и code splitting


Под капотом вся работа фрактала происходит асинхронно, тем не менее внутри генератора её довольно просто контролировать с помощью привычного await. Фракталы, находящиеся в разных ветвях "фрактального дерева" работают не зависимо друг от друга и теоретически могут делать свою работу одновременно. Теоретически потому что всем нам извесно, что js однопоточен, а асинхронные операции всё равно происходят в порядке очереди. Сюда бы воркеры задействовать, но это уже отдельная тема для размышлений...


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



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


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


// ./user.jsexport const User = fractal(async function* () {    while (true) yield `User John`})// ./app.jsexport const App = fractal(async function* () {    // импортируем зависимость, когда она нам действительно нужна    const { User } = await import('./user')    while (true) yield `User ${yield* User}`})

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


Смотри вглубь


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


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


После усвоения этого правила, демка была написана довольно быстро. В азарте от гордости за проделанную работу я написал ещё парочку, чтоб придти сюда не с пустыми руками. Все они написаны с использованием react и styled-components это привычные для меня элементы стека разработки и надеюсь, что их присутствие не повлияет на ваше восприятие, поскольку использованы они исключительно в целях визуализации.


  • Todos думаю этот пример не нуждается в представлении
  • Loadable пример, показывающий работу временных проекций, в исходниках можно увидеть, как с помощью yield tmp(...) организуется показ лоадеров в то время, как в фоне производится загрузка, я специально добавил там небольшие задержки для того, чтоб немного замедлить процессы
  • Factors работа в разных условиях. Один и тот же фрактал в зависимости от установленного в контексте фактора отдаёт три разные проекции, а также поддерживает их актуальность. Попробуйте поредактировать имя и возраст.
  • Antistress просто игрушка, щёлкаем шарики, красим их в разные цвета и получаем прикольные картинки. По факту это фрактал, который показывает внутри себя кружок, либо три таких же фрактала вписанных в периметр круга. Клик покрасить, долгий клик раздавить, долгий клик в центре раздавленного кружка возврат в исходное состояние. Если раздавить кружки до достаточно глубокого уровня, можно разглядеть треугольник Серпинского

Планы на будущее


  • попробовать внедрить возможность использования синхронных генераторов для повышения производительности в тех местах приложения, где асинхронность не требуется
  • написать рендер jsx -> html заточенный именно под фрактальную структуру, react годится только для демок, ибо по факту вся его работа заключается только в том, чтобы вычислить diff и применить изменения, остальной код простаивает
  • а может даже рассмотреть вариант создания собственной фрактальной системы компонентов и их стилизации амбициозно не правда ли? ещё амбициознее то, что браузер также может быть фракталом, как и другие приложения операционной системы, как и сама операционная система в целом
  • поэкпериментировать с grahpql, где-то на горизонте мне мерещится элегантное решение с организацией подписок на события сервера а-ля yield* gql'...'
  • связать свою жизнь с open source это и есть та самая лечебка, о которой я говорил в самом начале
  • выучить английский :) Кстати, поскольку у меня с ним сейчас трудности буду рад любой помощи по переводу и дополнению readme

Попробовать


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


Разрабатывать сейчас коммерческий продукт с нуля на фрактале я бы не рекомендовал, всё таки пока что это в первую очередь идея и концепт, требующий ухода и заботы для правильного роста во что-то зрелое, а вот попробовать где-то на кусочке своего react-приложения можно, для этого я создал простой компонент и оформил его ввиде библиотеки @fract/react-alive


import { fractal } from '@fract/core'import { Alive } from '@fract/react-alive'const App = fractal(async function* () {    while (true) {        yield <div>Hello world</div>    }})function Render() {    return <Alive target={App} />}

Насчёт библиотек я говорил, что можно создать фрактальное приложение, упаковать его в библиотеку и подключить через yield*? В качестве примера я сделал библиотеку @fract/browser-pathname. Она экспортирует фрактал, проекцией которого является параметр window.location.pathname, и метод redirect(p: string) позволяющий его менять. Её исходники находятся тут, а то, как с её помощью можно организовать простейший роутер, можно увидеть в исходниках главной демо-страницы.


Напоследок


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



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


С уважением, Денис Ч.


"Большая часть моих трудов это муки рождения новой научной дисциплины" Бенуа Мандельброт

Подробнее..

Фрактальная шизофрения. Whats up?

01.04.2021 04:04:20 | Автор: admin


По некоторым источникам еще в IV до нашей эры Аристотель задался одним простым вопросом Что было раньше? Курица или яйцо? Сам он в итоге пришел к выводу, что и то, и другое появилось одновременно вот это поворот! Не правда ли?


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


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


Сегодня я не буду терзать ваш разум водопадом отборного бреда. Выпуск будет чуть суровее ибо лирики меньше, а кода больше. Тем не менее местами он будет вкуснее ибо вишенки я вам приготовил отменные. Поехали?


What's up guys?


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


npm i whatsup

Знакомьтесь фронтенд фреймворк вдохновленный идеями фракталов и потоков энергии. С реактивной душой. С минимальным api. С максимальным использованием нативных конструкций языка.


Построен он на генераторах, из коробки даёт функционал аналогичный react + mobx, не уступает по производительности, при этом весит менее 5kb gzip.


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


Cause & Conse


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


Начнём со следствия. По сути это одноимённая причина, которой из вне можно задавать значение возвращаемого следствия (маслянистое определение получилось, но чисто технически всё так и есть).


const name = conse('John')// И мы ему такие - What`s up name?whatsUp(name, (v) => console.log(v))// а он нам://> "John"name.set('Barry')//> "Barry"

Пример на CodeSandbox


Ничего особенного, правда? conse создает поток с начальным значением, whatsUp "вешает" наблюдателя. С помощью .set(...) меняем значение наблюдатель реагирует в консоли появляется новая запись.


На самом деле Conse это частный случай потока Cause. Последний создается из генератора, внутри которого выражение yield* это "подключение" стороннего потока к текущему, иными словами обстановку внутри генератора можно рассмотреть так, как будто бы мы находимся внутри изолированной комнаты, в которую есть несколько входов yield* и всего один выход return (конечно же yield ещё, но об этом позже)


const name = conse('John')const user = cause(function* () {    return {        name: yield* name,        //    ^^^^^^ подключаем поток name        //           пускаем его данные в комнату    }})// И мы ему такие - What`s up user? :)whatsUp(user, (v) => console.log(v))// а он нам://> {name: "John"}name.set('Barry')//> {name: "Barry"}

Пример на CodeSandbox


Помимо извлечения данных yield* name устанавливает зависимость потока user от потока name, что в свою очередь также приводит к вполне ожидаемым результатам, а именно меняем name меняется user реагирует наблюдатель консоль показывает новую запись.


И в чем тут соль генераторов?


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


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


const name = conse('John')let revision = 0const user = cause(function* () {    return {        name: yield* name,        revision: revision++,    }})whatsUp(user, (v) => console.log(v))//> {name: "John", revision: 0}name.set('Barry')//> {name: "Barry", revision: 1}

Пример на CodeSandbox


Что-то подсказывает мне, что так не красиво revision выглядит оторваной от контекста и незащищенной от воздействия из вне. Этому есть решение мы можем поместить определение этой переменной в тело генератора, а для отправки нового значения в поток (выхода из комнаты) использовать yield вместо return, что позволит нам не завершать выполнение генератора, а приостанавливать и возобновлять с места последней остановки при следующем обновлении.


const name = conse('John')const user = cause(function* () {    let revision = 0    while (true) {        yield {            name: yield* name,            revision: revision++,        }    }})whatsUp(user, (v) => console.log(v))//> {name: "John", revision: 0}name.set('Barry')//> {name: "Barry", revision: 1}

Пример на CodeSandbox


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


Расширенный пример


Функции cause и conse это шорты для создания потоков. Существуют одноименные базовые классы, доступные для расширения.


import { Cause, Conse, whatsUp } from 'whatsup'type UserData = { name: string }class Name extends Conse<string> {}class User extends Cause<UserData> {    readonly name: Name    constructor(name: string) {        super()        this.name = new Name(name)    }    *whatsUp() {        while (true) {            yield {                name: yield* this.name,            }        }    }}const user = new User('John')whatsUp(user, (v) => console.log(v))//> {name: "John"}user.name.set('Barry')//> {name: "Barry"}

Пример на CodeSandbox


При расширении нам необходимо реализовать метод whatsUp, возвращающий генератор.


Контекст и диспозинг


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


Для избежания ненужных и повторных вычислений все зависимости между потоками отслеживаются динамически. Когда наступает момент, при котором у потока отсутствуют наблюдатели, происходит автоматическое уничтожение генератора. Наступление этого события можно обработать, используя стандартную языковую конструкцию try {} finally {}.


Рассмотрим пример потока-таймера, который с задержкой в 1 секунду, используя setTimeout, генерирует новое значение, а при уничтожении вызывает clearTimeout для очистки таймаута.


const timer = cause(function* (ctx: Context) {    let timeoutId: number    let i = 0    try {        while (true) {            timeoutId = setTimeout(() => ctx.update(), 1000)            // устанавливаем таймер перезапуска с задержкой 1 сек            yield i++            // отправляем в поток текущее значение счетчика            // заодно инкрементим его        }    } finally {        clearTimeout(timeoutId)        // удаляем таймаут        console.log('Timer disposed')    }})const dispose = whatsUp(timer, (v) => console.log(v))//> 0//> 1//> 2dispose()//> 'Timer disposed'

Пример на CodeSandbox


Мутаторы всё из ничего


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


const increment = mutator((i = -1) => i + 1)const timer = cause(function* (ctx: Context) {    // ...    while (true) {        // ...        // отправляем мутатор в поток        yield increment    }    // ...})

Пример на CodeSandbox


Мутатор устроен очень просто это метод, который принимает предыдущее значение и возвращает новое. Чтобы он заработал нужно всего лишь вернуть его в качестве результата вычислений, вся остальная магия произойдет под капотом. Поскольку при первом запуске предыдущего значения не существует, мутатор получит undefined, параметр i по умолчанию примет значение -1, а результатом вычислений будет 0. В следующий раз ноль мутирует в единицу и т.д. Как вы уже заметили increment позволил нам отказаться от хранения локальной переменной i в теле генератора.


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


class EqualArr<T> extends Mutator<T[]> {    constructor(readonly next: T[]) {}    mutate(prev?: T[]) {        const { next } = this        if (            prev &&             prev.length === next.length &&             prev.every((item, i) => item === next[i])        ) {            /*            Возвращаем старый массив, если он эквивалентен новому,             планировщик сравнит значения, увидит,             что они равны и остановит бессмысленные пересчеты            */            return prev        }        return next    }}const some = cause(function* () {    while (true) {        yield new EqualArr([            /*...*/        ])    }})

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


Также как cause и conse функция mutator это шорт для краткого определения простого мутатора. Более сложные мутаторы можно описать, расширяя базовый класс Mutator, в котором необходимо реализовать метод mutate.


Смотрите вот так можно создать мутатор dom-элемента. И поверьте элемент будет создан и вставлен в body однократно, всё остальное сведётся к обновлению его свойств.


class Div extends Mutator<HTMLDivElement> {    constructor(readonly text: string) {        super()    }    mutate(node = document.createElement('div')) {        node.textContent = this.text        return node    }}const name = conse('John')const nameElement = cause(function* () {    while (true) {        yield new Div(yield* name)    }})whatsUp(nameElement, (div) => document.body.append(div))/*<body>    <div>John</div></body>*/name.set('Barry')/*<body>    <div>Barry</div></body>*/

Пример на CodeSandbox


Так это ж стейт менеджер на генераторах


Да с одной стороны WhatsUp это стейт менеджер на генераторах, в нём есть аналоги привычных observable, computed, reaction. Есть и action, позволяющий внести несколько изменений и провести обновление за один проход. Пока что ничего необычного, но то что вы увидите дальше, выгодно отличает его от других систем управления состоянием.


Фракталы


Я же говорил, что сохранил идею :) Особенность фрактала заключается в том, что для каждого потребителя он создает персональный генератор и контекст. Он как по лекалу создает новую, параллельную вселенную, в которой своя жизнь, но те же правила. Контексты соединяются друг с другом в отношения parent-child получается дерево контекстов, по которому организуется спуск данных вниз к листьям и всплытие событий вверх к корню. Контекст и система событий, Карл! Пример ниже длинный, но наглядно демонстрирует и то, и другое.


import { Fractal, Conse, Event, Context } from 'whatsup'import { render } from '@whatsup/jsx'class Theme extends Conse<string> {}class ChangeThemeEvent extends Event {    constructor(readonly name: string) {        super()    }}class App extends Fractal<JSX.Element> {    readonly theme = new Theme('light');    readonly settings = new Settings()    *whatsUp(ctx: Context) {        // расшариваем поток this.theme для всех нижележащих фракталов        // т.е. "спускаем его" вниз по контексту        ctx.share(this.theme)        // создаем обработчик события ChangeThemeEvent, которое можно        // инициировать в любом нижележащем фрактале и перехватить тут        ctx.on(ChangeThemeEvent, (e) => this.theme.set(e.name))        while (true) {            yield (<div>{yield* this.settings}</div>)        }    }}class Settings extends Fractal<JSX.Element> {    *whatsUp(ctx: Context) {        // берем поток Theme, расшаренный где-то в верхних фракталах        const theme = ctx.get(Theme)        // инициируем всплытие события, используя ctx.dispath        const change = (name: string) =>             ctx.dispath(new ChangeThemeEvent(name))        while (true) {            yield (                <div>                    <h1>Current</h1>                    <span>{yield* theme}</span>                    <h1>Choose</h1>                    <button onClick={() => change('light')}>light</button>                    <button onClick={() => change('dark')}>dark</button>                </div>            )        }    }}const app = new App()render(app)

Пример на CodeSandbox


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


Метод ctx.on принимает конструктор события и его обработчик, ctx.dispatch в свою очередь принимает инстанс события и инициирует его всплытие вверх по контексту. Для отмены обработки события существует ctx.off, но в большинстве случаев им не приходится пользоваться, поскольку все обработчики уничтожаются автоматически при уничтожении генератора.


Я настолько заморочился, что написал свой jsx-рендер и babel-плагин для трансформации jsx-кода. Уже догадываетесь что под капотом? Да мутаторы. Принцип тот же, что и в примере с мутатором dom-элемента, только тут создается и в дальнейшем мутируется определенный фрагмент html-разметки. Создания и сравнения всего виртуального dom (как в react, например) не происходит. Всё сводится к локальным пересчетам, что даёт хороший прирост в производительности. Иными словами в примере выше, при изменении темы оформления, перерасчеты и обновление dom произойдут только во фрактале Settings (потому что yield* theme поток подключен только там).


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


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


Обработка ошибок


Возникновение исключения на уровне фреймворка является стандартной ситуацией. Ошибка распространяется по потокам, как и любые другие данные, а обрабатывается стандартной конструкцией try {} catch {}. При этом система реактивности сохраняет состояние зависимостей таким образом, что при исправлении ситуации и исчезновении ошибки, потоки пересчитывают свои данные и возвращаются в нормальное рабочее состояние.


import { conse, Fractal } from 'whatsup'import { render } from '@whatsup/jsx'class CounterMoreThan10Error extends Error {}class App extends Fractal<JSX.Element> {    *whatsUp() {        const clicker = new Clicker()        const reset = () => clicker.reset()        while (true) {            try {                yield (<div>{yield* clicker}</div>)            } catch (e) {                // ловим ошибку, если "наша" - обрабатываем,                // иначе отправляем дальше в поток и даем возможность                 // перехватить её где-то в вышестоящих фракталах                if (e instanceof CounterMoreThan10Error) {                    yield (                        <div>                            <div>Counter more than 10, need reset</div>                            <button onClick={reset}>Reset</button>                        </div>                    )                } else {                    throw e                }            }        }    }}class Clicker extends Fractal<JSX.Element> {    readonly count = conse(0)    reset() {        this.count.set(0)    }    increment() {        const value = this.count.get() + 1        this.count.set(value)    }    *whatsUp() {        while (true) {            const count = yield* this.count            if (count > 10) {                throw new CounterMoreThan10Error()            }            yield (                <div>                    <div>Count: {count}</div>                    <button onClick={() => this.increment()}>increment</button>                </div>            )        }    }}const app = new App()render(app)

Пример на CodeSandbox


Мне банально непонятен весь этот звездочный код


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


  • yield* подключить поток и извлечь из него данные
  • yield отправить данные в поток
  • return отправить данные в поток и пересоздать генератор
  • throw отправить ошибку в поток и пересоздать генератор

Производительность


Естественно этот вопрос нельзя обойти стороной, поэтому я добавил whatsup в проект js-framework-benchmark. Думаю кому-то он известен, но вкратце поясню суть этого проекта заключается в сравнении производительности фреймворков при решении различных задач, как то: создание тысячи строк, их замена, частичное обновление, выбор отдельной строки, обмен двух строк местами, удаление и прочее. По итогам тестирования собирается подробная таблица результатов. Ниже приведена выдержка из этой таблицы, в которой видно положение whatsup на фоне наиболее популярных библиотек и фреймворков таких, как inferno, preact, vue, react и angular



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


Прочие тактико-технические характеристики


Размер


Менее 3 kb gzip. Да это размер самого whatsup. Рендер добавит еще пару кило, что в сумме даст не более 5-ти.


Glitch free


Распространением обновлений занимается специальный планировщик, который производит оценку зависимостей и перерасчет данных в потоках в топологическом порядке. Это позволяет избежать двойных вычислений и "глюков". Не буду дублировать сюда информацию и примеры из википедии, просто оставлю ссылку на статью (Раздел Glitches).


Глубина связей


В комментариях к прошлой статье JustDont справедливо высказался по этому поводу:


Глубина связей данных не может превышать глубину стека вызовов. Да, для современных браузеров это не то, чтоб прямо очень страшно, поскольку счёт идёт минимум на десятки тысяч. Но, например, в хроме некоторой степени лежалости глубина стека вызовов всего лишь в районе 20К. Наивная попытка запилить на этом объемный граф может легко обрушиться в maximum call stack size exceeded.

Я поработал над этим моментом и теперь глубина стека не играет никакой роли. Для сравнения я реализовал один и тот же пример на mobx и whatsup (названия кликабельны). Суть примера заключается в следующем: создаётся "сеть", состоящая из нескольких слоёв. Каждый слой состоит из четырёх ячеек a, b, c, d. Значение каждой ячейки рассчитывается на основе значений ячеек предыдущего слоя по формуле a2 = b1, b2 = a1-c1, c2 = b1+d1, d2 = c1. После создания "сети" происходит вычисление значений ячеек последнего слоя. Затем значения ячеек первого слоя изменяются, что приводит к лавинообразному пересчету во всех ячейках "сети".


Так вот в Chrome 88.0.4324.104 (64-бит) mobx вывозит 1653 слоя, а дальше падает в Maximum call stack size exceeded. В своей практике я однажды столкнулся с этим в одном огромном приложении это был долгий и мучительный дебаг.


Whatsup осилит и 5, и 10 и даже 100 000 слоёв тут уже зависит от размера оперативной памяти компьютера ибо out of memory всё же наступит. Считаю, что такого запаса более чем достаточно. Поиграйтесь в примерах со значением layersCount.


Основу для данного теста я взял из репозитория реактивной библиотеки cellx (Riim спасибо).


О чем я ещё не рассказал


Делегирование


Простой и полезный механизм, благодаря которому один поток может поручить свою работу другому потоку. Всё, что для этого нужно это вызвать yield delegate(otherStream).


Асинхронные задачи


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


Роутинг


Вынесен в отдельный пакет @whatsup/route и пока что содержит в себе всего пару методов route и redirect. Для описания шаблона маршрута используются регулярные выражения, не знаю как вам, но в react-router третьей версии мне порой этого очень не хватало. Поддерживаются вложеные роуты, совпадения типа ([0-9]+) и их передача в виде потоков. Там действительно есть прикольные фишки, но рассказывать о них в рамках этой статьи мне кажется уже слишком.


CLI


Не так давно к разработке проекта подключился парень из Бразилии Andr Lins. Наличие интерфейса командной строки для быстрого старта whatsup-приложения целиком и полностью его заслуга.


npm i -g @whatsup/cli# thenwhatsup project

Попробовать


WhatsUp легко испытать где-то на задворках react-приложения. Для этого существует небольшой пакет @whatsup/react, который позволяет сделать это максимально легко и просто.


Примеры


Todos всем известный пример с TodoMVC


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


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


Sierpinski перфоманс тест, который команда реакта показывала презентуя файберы


Напоследок


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


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


Кроме того хочу выразить слова благодарности всем тем, кто меня поддерживал, писал в личку, на e-mail, в vk, telegram. Я не ожидал такой реакции после публикации первой статьи, это стало для меня приятной неожиданностью и дополнительным стимулом к развитию проекта. Спасибо!



С уважением, Денис Ч.


"Большая часть моих трудов это муки рождения новой научной дисциплины" Бенуа Мандельброт

Подробнее..

Мониторим парк ИБП. Ч.3, заключительная

16.06.2021 12:16:05 | Автор: admin

Или что пригодится знать и уметь, если замена ИБП после поломки урон профессиональной гордости.

Часть 1
Часть 2
TL;DR

И вновь приветствую, уважаемые коллеги и читатели. За пару лет достаточно плотной работы с бесперебойниками я совершил для себя много "открытий чудных". Спешу поделиться с вами.

Disclaimer
  1. Речь идёт в основном о ИБП мощностью 400-800VA, "линейно-интерактивных", со свинцовыми батареями 12В;

  2. Бесперебойное обеспечение в основном офисных "печатных машинок": ЦП мощностью до 100 Вт и SSD в качестве системных дисков, без дискретных видеокарт;

  3. Централизованный ИБП отсутствует.

Решаемые задачи:

  1. Минимум: иметь хотя бы общее представление о состоянии парка устройств;

  2. Хорошо: менять устройства ДО возникновения сбоя, обеспечивая тем самых их фактический и экономический смысл. Да-да, просто поставить ИБП совершенно недостаточно;

  3. Отлично: иметь наглядные данные что было сделано IT-службой и почему это было необходимо сделать (потратив на это деньги и человекочасы).

Об офисных UPS

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

1. Качество соединения
На основании двухлетнего наблюдения могу с уверенностью сказать, что это беда.
Если не следить за подключениями достаточно внимательно, большинство из бесперебойников поотваливаются в течение полугода. Такое поведение сильно зависит от конкретных условий и меняется от машины к машине. Основные причины потери соединения (насколько мне удалось разобраться):
а) чисто физические. Сюда входят ненадёжные разъёмы (и они случаются чаще, чем хотелось бы), случайные нарушения соединений (уборщица слишком ретива, пользователь дёргает ногами или двигает системник) и, внезапно, качество соединительного кабеля похоже, что ИБП довольно чувствительны к этому параметру, особенно при активном опросе;
б) на втором месте не "глючные драйверы", что удивительно, а электроника самих ИБП. Похоже, бесперебойникам из нижнего ценового сегмента не очень нравится, когда их часто "дёргают".

чуть подробнее

вообще, обмен данным с ИБП идёт постоянно, но всё же не раз в миллисекунду. Драйвер usbhid-ups получает данные раз в 2 секунды (видно в дебаг-режиме, если запускать руками с параметром -D+), что-то похожее, наверняка, и в стандартном драйвере Windows и WMI. Но это только "частичное" обновление, "полное" обновление происходит раз в 30 секунд

есть вероятность, что "полный" запрос сильно нагружает электронику ИПБ, и при частых запросах она начинает подглючивать. Это напрямую касается реализации сервера, о котором идёт речь в статьях. Чем это чревато и что с этим делать см. далее

2. Работа внутренних систем, сенсоров и логики UPS
Во-первых, ИБП от разных производителей обращаются с батареей по-разному. В моей практике хуже всех обращаются с батареями ИБП Powercom, лучше всех IPPON (далее по батарее см. п.3). Отличие не принципиальное, Powercom тоже через пол-года не дохнут, но оно есть и весьма заметно, если анализировать накопленные данные за достаточно длительный период. Здесь переходим к во-вторых: наиболее интересные параметры, которые ИБП сам считает и выдаёт:
а) нагрузка (load)
б) предсказание времени работы от батареи (runtime), вычисляемое на основе нагрузки
в) текущий заряд батареи (charge)

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

К сожалению, есть куда больше параметров, которые анализировать, считать и даже просто получать стоило бы, но это не реализовано.
Простейший пример: просадка напряжения на батарее, когда пропадает питание. Это показательнейший параметр, на основании которого можно куда точнее сделать вывод об убитой батарее. Постоянная память EEPROM есть сейчас где угодно, и ИБП запросто может записывать такие данные самостоятельно. Но ни одного ИБП с таким функционалом мне не попалось.
Другой пример: Powercom'ы после потери питания и разряда батареи до 30 процентов могут "зарядить" 12-вольтовую батарею за 10 минут и утверждать, что всё прекрасно, а Self-test passed.

если вас ничего не смущает

время полного заряда 12 В свинцовой батареи даже в "агрессивном" режиме составит не меньше часа, и батарея после такого долго не живёт

Это прямо натуральный epic fail.
Те же Powercom не умеют в вольтаж батареи вообще, только в проценты. На основании чего там эти проценты внутри считаются покрыто китайской мракой.

Очень важного параметра "температура" (батареи, внутри корпуса, да хоть чего-нибудь) не найти днём с огнём в офисных ИБП, хотя датчик копеечный и вообще встроен в почти все микроконтроллеры. Тут переходим к третьему пункту раздела.

3. Батарея и её состояние
Из того зоопарка, с которым я имел дело, "посредственно" справляется с задачей анализа состояния батареи серверный ИБП IPPON. Потуги остальных иначе как "бессмысленно" назвать не могу. Очень важный параметр батареи кривая разряда просто игнорируется.

немного о кривой разряда

Свинцовые батареи ИБП разряжаются нелинейно. Проще говоря, чем меньше заряда осталось в батарее, тем быстрее она разряжается. Ещё проще: время разряда со 100% до 90% будет в разы больше, чем с 20% до 0%. Но и это ещё не всё. Кривая разряда становится круче в более старой батарее и/или в зависимости от внешних условий (та же температура). В итоге это выглядит так:

Чувствуете подвох? Угадайте, проводит ли ИБП запись скорости последнего разряда, анализ, учитывает ли дату установки батареи?

спойлер

ИБП ваще пофигу, не анализирует, не учитывает. Лучшее, что я видел через три года начинает орать "чота батарея старая".

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

Запись и расчёт параметров реализован в очередном обновлении сервера мониторинга. В частности, теперь видно:

  • просадку заряда батареи непосредственно после потери питания. Если этот показатель низкий, сервер выдаст предупреждение;

  • скорость разряда батареи. Этот параметр требует для расчёта не менее 2 мин наблюдаемого разряда ИБП.

Для корректного учёта этих параметров требуется довольно частый опрос ИБП. Соответствующие изменения внесены в код сервера (см. раздел "NUT и сервер мониторинга"). К сожалению, здесь мы упираемся в ранее озвученный п. "б" ч. 1 текущего раздела при частых обращениях за данными возможны глюки. Более того, в первые секунды после потери питания ИБП данные не отдаёт вообще, а вместо этого передаёт "WAIT". По-хорошему, после потери питания ИБП для целей мониторинга нужно бы опрашивать как можно чаще. На данный момент частота опроса от 10 до 30 секунд, в целом для более-менее приличного анализа этого хватает. Более интенсивные опросы не тестировались достаточно долго, чтобы делать какие-то однозначные выводы.

Краткие итоги раздела:

  • обязательно нужен мониторинг самого факта подключения ИБП к машине;

  • нужно собирать и хранить данные о датах установки батарей;

  • в идеале нужно очень детально мониторить процесс разряда и заряда батарей.

NUT и сервер мониторинга

Изначально NUT был выбран как кроссплатформенное универсальное решение. В целом функции свои выполняет. Без нюансов, впрочем, не обошлось:
а) Не слишком дружелюбная и неочевидная установка на клиентских машинах, под Windows особенно. В частности:

  • иногда отказывается видеть библиотеки;

  • встроенный драйвер не обновлялся давно и половину ИБП не знает. Драйвер надо ставить вручную, и это отдельная песня;

  • на клиентах под Windows служба стартует "невовремя", раньше, чем фактически нужно (похожая история есть при поиске домена на Windows с некоторыми сетевыми адаптерами). Из-за этого приходится городить костыли.

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

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

По результатам изысканий решено было оставить NUT в качестве основного решения для сервера. При этом "сильно больше данных" в какой-то момент обернулось базами, раздутыми по 100 Мб на бесперебойник, что повлияло на производительность. В итоге, сервер был перенесён из среды Windows в Linux, и:

  • написан соответствующий скрипт-демон на bash для непрерывного опроса;

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

В саму веб-морду сервера, как уже говорилось, добавлено:

  • отображение заряда батареи сразу после последней потери питания и соответствующий алерт;

  • расчёт времени работы от батареи на основе реальных данных. Некоторым образом это тоже синтетика, т.к. реальная кривая разряда не строится. Однако, отлично себя показала следующая формула: рассчитанное на основе последних реальных данных время / 1+ лет с десятыми.

Итоги, советы, планы

Для тех, кто хочет "сделать всё правильно", исходя из полученного опыта, дам следующие советы:

  1. Тщательно выберите марку и модель ИБП и по возможности используйте только выбранный ИБП во всем офисе;

  2. Запишите серийные номера и/или присвойте каждому ИБП уникальный номер. Запишите дату установки батареи в каждый ИБП. Меняйте батареи раз в два года, и сразу, заранее, закладывайте эти расходы в бюджеты для бизнеса запланированные расходы гораздо удобнее внезапных;

  3. Любым способом следите за тем, чтобы машины, обеспеченные ИБП, имели с ним постоянную связь;

  4. Раз в пол-года обновляйте параметры battery low и battery warning в используемом вами драйвере/решении, прибавляя к ним от 5% до 20% в зависимости от опыта использования вашей модели ИБП, вручную или скриптами;

  5. По возможности проводите ручное тестирование ИБП (отключить от розетки и подождать) раз в квартал-полгода.

Это то, что нужно делать обязательно.

Использование постоянного и подробного мониторинга, в целом, скорее чрезмерно, хотя и может оказаться полезным и наглядным. Заводить для этого отдельный сервер или нет, решать вам. Некоторые параметры можно мониторить в том же Zabbix (и даже посредством WMI), однако лично у меня в целом не очень удачный опыт с Zabbix active parameters; к тому же, сложность реализации некоторых описанных решений внутри Zabbix по сложности приближается, на мой вкус, к сложности реализации представленного сервера мониторинга.

Я удивлён и рад, что довольно много читателей задумалось о мониторинге ИБП после первой статьи, и многие посоветовали довести решение "до энтерпрайза" после второй статьи. Благодарю за отклики и ответы!

Учитывая накопленный опыт, реализация "до энтерпрайза" может быть довольно длительной. Есть проблемы и на клиентской стороне, и в самом NUT; в веб-интерфейсе многое надо выносить в бэкэнд, нет даже банальной авторизации. Можно было бы даже в таком виде запихнуть всё это в контейнер Docker в качестве версии 0.1 alpha, но мой энтузиазм к теме несколько поугас. Если у кого-то энтузиазм найдётся пишите, буду рад поработать вместе!

Рад, если мой опыт вам пригодится. Спасибо всем, кто прочитал!

Подробнее..

Из песочницы Продвинутое велосипедостроение или клиент-серверное приложение на базе C .Net framework

03.09.2020 14:17:53 | Автор: admin

Вступление


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

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

Часть 1. Прототипирование рамы


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

Изучение начал с просмотра статей и документации по C# .Net. Тут я нашёл разнообразные способы для выполнения задачи. Здесь есть множество механизмов взаимодействия с сетью, от полноценных решений вроде ASP.Net или служб Azure, до прямого взаимодействия с Tcp\Http подключениями.

Сделав первую попытку с ASP я его сразу же отмёл, на мой взгляд это было слишком тяжёлым решением для нашего сервиса. Мы не будем использовать и трети возможностей этой платформы, поэтому я продолжил поиски. Выбор встал между TCP и Http клиент-сервером. Здесь же, на Хабре, я наткнулся на статью про многопоточный сервер, собрав и протестировав который, я решил остановиться именно на взаимодействии с TCP подключениями, почему то я посчитал, что http не позволит создать мне кроссплатформенное решение.

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

Здесь немного кода
Основной поток, в бесконечном цикле принимающий клиентов:

using System;using System.Net.Sockets;using System.Net;using System.Threading;namespace ClearServer{    class Server    {        TcpListener Listener;        public Server(int Port)        {            Listener = new TcpListener(IPAddress.Any, Port);            Listener.Start();            while (true)            {                TcpClient Client = Listener.AcceptTcpClient();                Thread Thread = new Thread(new ParameterizedThreadStart(ClientThread));                Thread.Start(Client);            }        }        static void ClientThread(Object StateInfo)        {            new Client((TcpClient)StateInfo);        }        ~Server()        {            if (Listener != null)            {                Listener.Stop();            }        }        static void Main(string[] args)        {            DatabaseWorker sqlBase = DatabaseWorker.GetInstance;            new Server(80);        }    }}

Сам обработчик клиентов:

using System;using System.IO;using System.Net.Sockets;using System.Text;using System.Text.RegularExpressions;namespace ClearServer{    class Client    {        public Client(TcpClient Client)        {            string Message = "";            byte[] Buffer = new byte[1024];            int Count;            while ((Count = Client.GetStream().Read(Buffer, 0, Buffer.Length)) > 0)            {                Message += Encoding.UTF8.GetString(Buffer, 0, Count);                if (Message.IndexOf("\r\n\r\n") >= 0 || Message.Length > 4096)                {                    Console.WriteLine(Message);                    break;                }            }            Match ReqMatch = Regex.Match(Message, @"^\w+\s+([^\s\?]+)[^\s]*\s+HTTP/.*|");            if (ReqMatch == Match.Empty)            {                ErrorWorker.SendError(Client, 400);                return;            }            string RequestUri = ReqMatch.Groups[1].Value;            RequestUri = Uri.UnescapeDataString(RequestUri);            if (RequestUri.IndexOf("..") >= 0)            {                ErrorWorker.SendError(Client, 400);                return;            }            if (RequestUri.EndsWith("/"))            {                RequestUri += "index.html";            }            string FilePath = $"D:/Web/TestSite{RequestUri}";            if (!File.Exists(FilePath))            {                ErrorWorker.SendError(Client, 404);                return;            }            string Extension = RequestUri.Substring(RequestUri.LastIndexOf('.'));            string ContentType = "";            switch (Extension)            {                case ".htm":                case ".html":                    ContentType = "text/html";                    break;                case ".css":                    ContentType = "text/css";                    break;                case ".js":                    ContentType = "text/javascript";                    break;                case ".jpg":                    ContentType = "image/jpeg";                    break;                case ".jpeg":                case ".png":                case ".gif":                    ContentType = $"image/{Extension.Substring(1)}";                    break;                default:                    if (Extension.Length > 1)                    {                        ContentType = $"application/{Extension.Substring(1)}";                    }                    else                    {                        ContentType = "application/unknown";                    }                    break;            }            FileStream FS;            try            {                FS = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.Read);            }            catch (Exception)            {                ErrorWorker.SendError(Client, 500);                return;            }            string Headers = $"HTTP/1.1 200 OK\nContent-Type: {ContentType}\nContent-Length: {FS.Length}\n\n";            byte[] HeadersBuffer = Encoding.ASCII.GetBytes(Headers);            Client.GetStream().Write(HeadersBuffer, 0, HeadersBuffer.Length);            while (FS.Position < FS.Length)            {                Count = FS.Read(Buffer, 0, Buffer.Length);                Client.GetStream().Write(Buffer, 0, Count);            }            FS.Close();            Client.Close();        }    }}

И первая база данных построенная на local SQL:

using System;using System.Data.Linq;namespace ClearServer{    class DatabaseWorker    {        private static DatabaseWorker instance;        public static DatabaseWorker GetInstance        {            get            {                if (instance == null)                    instance = new DatabaseWorker();                return instance;            }        }        private DatabaseWorker()        {            string connectionStr = databasePath;            using (DataContext db = new DataContext(connectionStr))            {                Table<User> users = db.GetTable<User>();                foreach (var item in users)                {                    Console.WriteLine($"{item.login} {item.password}");                }            }        }    }}

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

Глава 2. Прикручивание колёс


Протестировав работу сервера, я пришёл к выводу, что это будет отличным решением(спойлер: нет), для нашего сервиса, поэтому проект начал обрастать логикой.
Шаг за шагом начали появляться новые модули и функционал сервера разрастался. Сервер обзавёлся тестовым доменом и ssl шифрованием соединения.

Ещё немного кода, описывающий логику работы сервера и обработку клиентов
Обновлённый вариант сервера, включающий в себя использование сертификата.

using System;using System.Net;using System.Net.Sockets;using System.Reflection;using System.Security;using System.Security.Cryptography.X509Certificates;using System.Security.Permissions;using System.Security.Policy;using System.Threading;namespace ClearServer{    sealed class Server    {        readonly bool ServerRunning = true;        readonly TcpListener sslListner;        public static X509Certificate serverCertificate = null;        Server()        {            serverCertificate = X509Certificate.CreateFromSignedFile(@"C:\ssl\itinder.online.crt");            sslListner = new TcpListener(IPAddress.Any, 443);            sslListner.Start();            Console.WriteLine("Starting server.." + serverCertificate.Subject + "\n" + Assembly.GetExecutingAssembly().Location);            while (ServerRunning)            {                TcpClient SslClient = sslListner.AcceptTcpClient();                Thread SslThread = new Thread(new ParameterizedThreadStart(ClientThread));                SslThread.Start(SslClient);            }                    }        static void ClientThread(Object StateInfo)        {            new Client((TcpClient)StateInfo);        }        ~Server()        {            if (sslListner != null)            {                sslListner.Stop();            }        }        public static void Main(string[] args)        {            if (AppDomain.CurrentDomain.IsDefaultAppDomain())            {                Console.WriteLine("Switching another domain");                new AppDomainSetup                {                    ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase                };                var current = AppDomain.CurrentDomain;                var strongNames = new StrongName[0];                var domain = AppDomain.CreateDomain(                    "ClearServer", null,                    current.SetupInformation, new PermissionSet(PermissionState.Unrestricted),                    strongNames);                domain.ExecuteAssembly(Assembly.GetExecutingAssembly().Location);            }            new Server();        }    }}

А так же новый обработчик клиента с авторизацией по ssl:

using ClearServer.Core.Requester;using System;using System.Net.Security;using System.Net.Sockets;namespace ClearServer{    public class Client    {        public Client(TcpClient Client)        {            SslStream SSlClientStream = new SslStream(Client.GetStream(), false);            try            {                SSlClientStream.AuthenticateAsServer(Server.serverCertificate, clientCertificateRequired: false, checkCertificateRevocation: true);            }            catch (Exception e)            {                Console.WriteLine(                    "---------------------------------------------------------------------\n" +                    $"|{DateTime.Now:g}\n|------------\n|{Client.Client.RemoteEndPoint}\n|------------\n|Exception: {e.Message}\n|------------\n|Authentication failed - closing the connection.\n" +                    "---------------------------------------------------------------------\n");                SSlClientStream.Close();                Client.Close();            }            new RequestContext(SSlClientStream, Client);        }    }}



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

Парсер
using ClearServer.Core.UserController;using ReServer.Core.Classes;using System;using System.Collections.Generic;using System.Linq;using System.Net.Security;using System.Net.Sockets;using System.Text;using System.Text.RegularExpressions;namespace ClearServer.Core.Requester{    public class RequestContext    {        public string Message = "";        private readonly byte[] buffer = new byte[1024];        public string RequestMethod;        public string RequestUrl;        public User RequestProfile;        public User CurrentUser = null;        public List<RequestValues> HeadersValues;        public List<RequestValues> FormValues;        private TcpClient TcpClient;        private event Action<SslStream, RequestContext> OnRead = RequestHandler.OnHandle;        DatabaseWorker databaseWorker = new DatabaseWorker();        public RequestContext(SslStream ClientStream, TcpClient Client)        {            this.TcpClient = Client;            try            {                ClientStream.BeginRead(buffer, 0, buffer.Length, ClientRead, ClientStream);            }            catch { return; }        }        private void ClientRead(IAsyncResult ar)        {            SslStream ClientStream = (SslStream)ar.AsyncState;            if (ar.IsCompleted)            {                Message = Encoding.UTF8.GetString(buffer);                Message = Uri.UnescapeDataString(Message);                Console.WriteLine($"\n{DateTime.Now:g} Client IP:{TcpClient.Client.RemoteEndPoint}\n{Message}");                RequestParse();                HeadersValues = HeaderValues();                FormValues = ContentValues();                UserParse();                ProfileParse();                OnRead?.Invoke(ClientStream, this);            }        }        private void RequestParse()        {            Match methodParse = Regex.Match(Message, @"(^\w+)\s+([^\s\?]+)[^\s]*\s+HTTP/.*|");            RequestMethod = methodParse.Groups[1].Value.Trim();            RequestUrl = methodParse.Groups[2].Value.Trim();        }        private void UserParse()        {            string cookie;            try            {                if (HeadersValues.Any(x => x.Name.Contains("Cookie")))                {                    cookie = HeadersValues.FirstOrDefault(x => x.Name.Contains("Cookie")).Value;                    try                    {                        CurrentUser = databaseWorker.CookieValidate(cookie);                    }                    catch { }                }            }            catch { }        }        private List<RequestValues> HeaderValues()        {            var values = new List<RequestValues>();            var parse = Regex.Matches(Message, @"(.*?): (.*?)\n");            foreach (Match match in parse)            {                values.Add(new RequestValues()                {                    Name = match.Groups[1].Value.Trim(),                    Value = match.Groups[2].Value.Trim()                });            }            return values;        }        private void ProfileParse()        {            if (RequestUrl.Contains("@"))            {                RequestProfile = databaseWorker.FindUser(RequestUrl.Substring(2));                RequestUrl = "/profile";            }        }        private List<RequestValues> ContentValues()        {            var values = new List<RequestValues>();            var output = Message.Trim('\n').Split().Last();            var parse = Regex.Matches(output, @"([^&].*?)=([^&]*\b)");            foreach (Match match in parse)            {                values.Add(new RequestValues()                {                    Name = match.Groups[1].Value.Trim(),                    Value = match.Groups[2].Value.Trim().Replace('+', ' ')                });            }            return values;        }    }}


Суть его заключается в том, что бы при помощи регулярных выражений разбить запрос на части. Получаем сообщение от клиента, выделяем первую строку, в которой содержится метод и url запроса. Затем читаем заголовки, которые загоняем в массив вида ИмяЗаголовка=Содержимое, а так же находим, если имеется, сопроводительный контент (например querystring) который так же загоняем в аналогичный массив. К тому же, парсер выясняет, авторизован ли текущий клиент и сохраняет в себе его данные. Все запросы от авторизованных клиентов содержат хэш авторизации, который хранится в куках, благодаря этому можно разделять дальнейшую логику работы для двух типов клиентов и отдавать им правильные ответы.

Ну и небольшая, приятная фича, которую стоило бы вынести в отдельный модуль, преобразование запросов вида site.com/@UserName в динамически генерируемые страницы пользователей. После обработки запроса в дело вступают следующие модули.

Глава 3. Установка руля, смазывание цепи


Как только парсер отработал, в дело вступает обработчик, отдающий дальнейшие указания серверу и разделяющий управление на две части.

Простой обработчик
using ClearServer.Core.UserController;using System.Net.Security;namespace ClearServer.Core.Requester{    public class RequestHandler    {        public static void OnHandle(SslStream ClientStream, RequestContext context)        {            if (context.CurrentUser != null)            {                new AuthUserController(ClientStream, context);            }            else             {                new NonAuthUserController(ClientStream, context);            };        }    }}


По сути здесь всего одна проверка на авторизацию юзера, после чего начинается обработка запроса.

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

Неавторизованный пользователь
using ClearServer.Core.Requester;using System.IO;using System.Net.Security;namespace ClearServer.Core.UserController{    internal class NonAuthUserController    {        private readonly SslStream ClientStream;        private readonly RequestContext Context;        private readonly WriteController WriteController;        private readonly AuthorizationController AuthorizationController;        private readonly string ViewPath = "C:/Users/drdre/source/repos/ClearServer/View";        public NonAuthUserController(SslStream clientStream, RequestContext context)        {            this.ClientStream = clientStream;            this.Context = context;            this.WriteController = new WriteController(clientStream);            this.AuthorizationController = new AuthorizationController(clientStream, context);            ResourceLoad();        }        void ResourceLoad()        {            string[] blockextension = new string[] {"cshtml", "html", "htm"};            bool block = false;            foreach (var item in blockextension)            {                if (Context.RequestUrl.Contains(item))                {                    block = true;                    break;                }            }            string FilePath = "";            string Header = "";            var RazorController = new RazorController(Context, ClientStream);                        switch (Context.RequestMethod)            {                case "GET":                    switch (Context.RequestUrl)                    {                        case "/":                            FilePath = ViewPath + "/loginForm.html";                            Header = $"HTTP/1.1 200 OK\nContent-Type: text/html";                            WriteController.DefaultWriter(Header, FilePath);                            break;                        case "/profile":                            RazorController.ProfileLoader(ViewPath);                            break;                        default://в данном блоке кода происходит отсечение запросов к серверу по прямому адресу страницы вида site.com/page.html                            if (!File.Exists(ViewPath + Context.RequestUrl) | block)                            {                                RazorController.ErrorLoader(404);                                                           }                                                        else if (Path.HasExtension(Context.RequestUrl) && File.Exists(ViewPath + Context.RequestUrl))                            {                                Header = WriteController.ContentType(Context.RequestUrl);                                FilePath = ViewPath + Context.RequestUrl;                                WriteController.DefaultWriter(Header, FilePath);                            }                                                        break;                    }                    break;                case "POST":                    AuthorizationController.MethodRecognizer();                    break;            }        }    }}


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

WriterController
using System;using System.IO;using System.Net.Security;using System.Text;namespace ClearServer.Core.UserController{    public class WriteController    {        SslStream ClientStream;        public WriteController(SslStream ClientStream)        {            this.ClientStream = ClientStream;        }        public void DefaultWriter(string Header, string FilePath)        {            FileStream fileStream;            try            {                fileStream = new FileStream(FilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);                Header = $"{Header}\nContent-Length: {fileStream.Length}\n\n";                ClientStream.Write(Encoding.UTF8.GetBytes(Header));                byte[] response = new byte[fileStream.Length];                fileStream.BeginRead(response, 0, response.Length, OnFileRead, response);            }            catch { }        }        public string ContentType(string Uri)        {            string extension = Path.GetExtension(Uri);            string Header = "HTTP/1.1 200 OK\nContent-Type:";            switch (extension)            {                case ".html":                case ".htm":                    return $"{Header} text/html";                case ".css":                    return $"{Header} text/css";                case ".js":                    return $"{Header} text/javascript";                case ".jpg":                case ".jpeg":                case ".png":                case ".gif":                    return $"{Header} image/{extension}";                default:                    if (extension.Length > 1)                    {                        return $"{Header} application/" + extension.Substring(1);                    }                    else                    {                        return $"{Header} application/unknown";                    }            }        }        public void OnFileRead(IAsyncResult ar)        {            if (ar.IsCompleted)            {                var file = (byte[])ar.AsyncState;                ClientStream.BeginWrite(file, 0, file.Length, OnClientSend, null);            }        }        public void OnClientSend(IAsyncResult ar)        {            if (ar.IsCompleted)            {                ClientStream.Close();            }        }    }


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

RazorController
using ClearServer.Core.Requester;using RazorEngine;using RazorEngine.Templating;using System;using System.IO;using System.Net;using System.Net.Security;namespace ClearServer.Core.UserController{    internal class RazorController    {        private RequestContext Context;        private SslStream ClientStream;        dynamic PageContent;        public RazorController(RequestContext context, SslStream clientStream)        {            this.Context = context;            this.ClientStream = clientStream;        }        public void ProfileLoader(string ViewPath)        {            string Filepath = ViewPath + "/profile.cshtml";            if (Context.RequestProfile != null)            {                if (Context.CurrentUser != null && Context.RequestProfile.login == Context.CurrentUser.login)                {                    try                    {                        PageContent = new { isAuth = true, Name = Context.CurrentUser.name, Login = Context.CurrentUser.login, Skills = Context.CurrentUser.skills };                        ClientSend(Filepath, Context.CurrentUser.login);                    }                    catch (Exception e) { Console.WriteLine(e); }                }                else                {                    try                    {                        PageContent = new { isAuth = false, Name = Context.RequestProfile.name, Login = Context.RequestProfile.login, Skills = Context.RequestProfile.skills };                        ClientSend(Filepath, "PublicProfile:"+ Context.RequestProfile.login);                    }                    catch (Exception e) { Console.WriteLine(e); }                }            }            else            {                ErrorLoader(404);            }        }        public void ErrorLoader(int Code)        {            try            {                PageContent = new { ErrorCode = Code, Message = ((HttpStatusCode)Code).ToString() };                string ErrorPage = "C:/Users/drdre/source/repos/ClearServer/View/Errors/ErrorPage.cshtml";                ClientSend(ErrorPage, Code.ToString());            }            catch { }        }        private void ClientSend(string FilePath, string Key)        {            var template = File.ReadAllText(FilePath);            var result = Engine.Razor.RunCompile(template, Key, null, (object)PageContent);            byte[] buffer = System.Text.Encoding.UTF8.GetBytes(result);            ClientStream.BeginWrite(buffer, 0, buffer.Length, OnClientSend, ClientStream);        }        private void OnClientSend(IAsyncResult ar)        {            if (ar.IsCompleted)            {                ClientStream.Close();            }        }    }}



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

Модуль авторизации
using ClearServer.Core.Cookies;using ClearServer.Core.Requester;using ClearServer.Core.Security;using System;using System.Linq;using System.Net.Security;using System.Text;namespace ClearServer.Core.UserController{    internal class AuthorizationController    {        private SslStream ClientStream;        private RequestContext Context;        private UserCookies cookies;        private WriteController WriteController;        DatabaseWorker DatabaseWorker;        RazorController RazorController;        PasswordHasher PasswordHasher;        public AuthorizationController(SslStream clientStream, RequestContext context)        {            ClientStream = clientStream;            Context = context;            DatabaseWorker = new DatabaseWorker();            WriteController = new WriteController(ClientStream);            RazorController = new RazorController(context, clientStream);            PasswordHasher = new PasswordHasher();        }        internal void MethodRecognizer()        {            if (Context.FormValues.Count == 2 && Context.FormValues.Any(x => x.Name == "password")) Authorize();            else if (Context.FormValues.Count == 3 && Context.FormValues.Any(x => x.Name == "regPass")) Registration();            else            {                RazorController.ErrorLoader(401);            }        }        private void Authorize()        {            var values = Context.FormValues;            var user = new User()            {                login = values[0].Value,                password = PasswordHasher.PasswordHash(values[1].Value)            };            user = DatabaseWorker.UserAuth(user);            if (user != null)            {                cookies = new UserCookies(user.login, user.password);                user.cookie = cookies.AuthCookie;                DatabaseWorker.UserUpdate(user);                var response = Encoding.UTF8.GetBytes($"HTTP/1.1 301 Moved Permanently\nLocation: /@{user.login}\nSet-Cookie: {cookies.AuthCookie}; Expires={DateTime.Now.AddDays(2):R}; Secure; HttpOnly\n\n");                ClientStream.BeginWrite(response, 0, response.Length, WriteController.OnClientSend, null);            }            else            {                RazorController.ErrorLoader(401);            }        }        private void Registration()        {            var values = Context.FormValues;            var user = new User()            {                name = values[0].Value,                login = values[1].Value,                password = PasswordHasher.PasswordHash(values[2].Value),            };            cookies = new UserCookies(user.login, user.password);            user.cookie = cookies.AuthCookie;            if (DatabaseWorker.LoginValidate(user.login))            {                Console.WriteLine("User ready");                Console.WriteLine($"{user.password} {user.password.Trim().Length}");                DatabaseWorker.UserRegister(user);                var response = Encoding.UTF8.GetBytes($"HTTP/1.1 301 Moved Permanently\nLocation: /@{user.login}\nSet-Cookie: {user.cookie}; Expires={DateTime.Now.AddDays(2):R}; Secure; HttpOnly\n\n");                ClientStream.BeginWrite(response, 0, response.Length, WriteController.OnClientSend, null);            }            else            {                RazorController.ErrorLoader(401);            }        }    }}


А так выглядит обработка базы данных:

База данных
using ClearServer.Core.UserController;using System;using System.Data.Linq;using System.Linq;namespace ClearServer{    class DatabaseWorker    {        private readonly Table<User> users = null;        private readonly DataContext DataBase = null;        private const string connectionStr = @"путькбазе";        public DatabaseWorker()        {            DataBase = new DataContext(connectionStr);            users = DataBase.GetTable<User>();        }        public User UserAuth(User User)        {            try            {                var user = users.SingleOrDefault(t => t.login.ToLower() == User.login.ToLower() && t.password == User.password);                if (user != null)                    return user;                else                    return null;            }            catch (Exception)            {                return null;            }        }        public void UserRegister(User user)        {            try            {                users.InsertOnSubmit(user);                DataBase.SubmitChanges();                Console.WriteLine($"User{user.name} with id {user.uid} added");                foreach (var item in users)                {                    Console.WriteLine(item.login + "\n");                }            }            catch (Exception e)            {                Console.WriteLine(e);            }                    }        public bool LoginValidate(string login)        {            if (users.Any(x => x.login.ToLower() == login.ToLower()))            {                Console.WriteLine("Login already exists");                return false;            }            return true;        }        public void UserUpdate(User user)        {            var UserToUpdate = users.FirstOrDefault(x => x.uid == user.uid);            UserToUpdate = user;            DataBase.SubmitChanges();            Console.WriteLine($"User {UserToUpdate.name} with id {UserToUpdate.uid} updated");            foreach (var item in users)            {                Console.WriteLine(item.login + "\n");            }        }        public User CookieValidate(string CookieInput)        {            User user = null;            try            {                user = users.SingleOrDefault(x => x.cookie == CookieInput);            }            catch            {                return null;            }            if (user != null) return user;            else return null;        }        public User FindUser(string login)        {            User user = null;            try            {                user = users.Single(x => x.login.ToLower() == login.ToLower());                if (user != null)                {                    return user;                }                else                {                    return null;                }            }            catch (Exception)            {                return null;            }        }    }}


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

Глава 4. Выбрасывание велосипеда


Что бы сократить трудозатраты на написание двух приложений под две платформы, я решил сделать кроссплатформу на Xamarin.Forms. Опять же, благодаря тому, что она на C#. Сделав тестовое приложение, которое просто отсылает серверу данные, я столкнулся с одним интересным моментом. Для запроса от устройства я для интереса реализовал его на HttpClient и кинул на сервер HttpRequestMessage в котором содержатся данные из формы авторизации в формате json. Особо ничего не ожидая, открыл лог сервера и увидел там реквест с девайса со всеми данными. Лёгкий ступор, осознание всего, что было проделано за последние 3 недели томных вечером. Для проверки верности отправленных данных собрал тестовый сервер на HttpListner. Получив очередной запрос уже на нём, я за пару строк кода разобрал его на части, получил KeyValuePair данных из формы. Разбор запроса уменьшился до двух строк.

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

Подключение к чату
 private static async void HandleWebsocket(HttpListenerContext context)        {            var socketContext = await context.AcceptWebSocketAsync(null);            var socket = socketContext.WebSocket;            Locker.EnterWriteLock();            try            {                Clients.Add(socket);            }            finally            {                Locker.ExitWriteLock();            }            while (true)            {                var buffer = new ArraySegment<byte>(new byte[1024]);                var result = await socket.ReceiveAsync(buffer, CancellationToken.None);                var str = Encoding.Default.GetString(buffer);                Console.WriteLine(str);                for (int i = 0; i < Clients.Count; i++)                {                    WebSocket client = Clients[i];                    try                    {                        if (client.State == WebSocketState.Open)                        {                                                        await client.SendAsync(buffer, WebSocketMessageType.Text, true, CancellationToken.None);                        }                    }                    catch (ObjectDisposedException)                    {                        Locker.EnterWriteLock();                        try                        {                            Clients.Remove(client);                            i--;                        }                        finally                        {                            Locker.ExitWriteLock();                        }                    }                }            }        }



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

На стороне девайса и на стороне сайта два клиента обменивались сообщениями, всё это логировалось. Никаких огромных парсеров, замедляющих работу сервера, ничего этого не требовалось. Время отклика сократилось с 200мс до 40-30мс. И я пришёл к единственному верному решению.

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

Вывод


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

Recovery mode Типобезопасная работа с массивами PHP, часть 2

07.10.2020 02:18:32 | Автор: admin

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

На днях расширил функционал парой методов, и хочу поделиться с вами этими новостями.

И конечно я напишу о работе над ошибками.

Для тех кто не знал и забыл, что такое ArrayHandler

Spoiler

Ответим на вопрос: "что такое типобезопасная работа с массивами в PHP ?"

Типобезопасная это:

  • Когда мы можем получить элемент массива без опасности получить эксепшен о не существующем индексе;

  • Когда мы можем передать полученный элемент в метод и мы точно не получим эксепшен несоответствия типов;

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

$a = 0;if (key_exists($key, $collection)) {$a = (int) $collection[$key];}

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

$a = (int) $collection[$key] ?? 0;

Мне от этого не полегчало, потому что вложенность у массивов бывает "мама не горюй".

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

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

Либа устанавливается через Композер:

composer require sbwerewolf/language-specific

Есть версии для PHP 5.6 / 7.0 / 7.2 .

Это было долгое вступление теперь по существу.

Обновления

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

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

Оказывается можно делать так:

yield $key => $value;

И в foreach() будет возвращён индекс элемента. Эврика!

Теперь IArrayHandler::pulling() возвращает и новый IArrayHandler от элемента массива, и индекс этого элемента. Я был счастлив, кажется теперь ArrayHandler стал идеальной библиотекой для работы с массивами (в том виде как я обозначил в начале статьи).

На эмоциях я запилил ещё два метода. То есть ещё один метод - IArrayHandler::getting(), плюс я добавил поддержку интерфейса Iterator и теперь экземпляр ArrayHandler можно использовать в foreach() как обычный массив.

Теперь IArrayHandler::pulling() возвращает ArrayHandler для каждого вложенного массива (будут проигнорированы элементы исходного массива, которые не являются массивами). Название метода "pulling" образовалось от названия другого метода - IArrayHandler::pull(), с помощью которого можно получить экземпляр ArrayHandler от элемента массива.

IArrayHandler::getting() возвращает IValueHandler для всех элементов массива, не являющимися массивами. Название метода "getting" образовалось от названия другого метода - IArrayHandler::get(), с помощью которого можно получить экземпляр IValueHandler от элемента массива.

Теперь у нас IArrayHandler::pulling() для массивов, и IArrayHandler::getting() для всех остальных типов.

Более наглядно:

$data = new ArrayHandler(    [        'first' => ['A' => 1],        'next' => ['B'=>2],        'last' => ['C'=>3],        4=>[5,6],        7,        8,        9    ]);echo 'arrays'.PHP_EOL;foreach ($data->pulling() as $key => $value) {    echo "[$key] => class is ".get_class($value).' '.PHP_EOL;}echo 'values'.PHP_EOL;foreach ($data->getting() as $key => $value) {    echo "[$key] => {$value->asIs()} , class is ".get_class($value).' '.PHP_EOL;}

Вывод скрипта:

arrays[first] => class is LanguageSpecific\ArrayHandler [next] => class is LanguageSpecific\ArrayHandler [last] => class is LanguageSpecific\ArrayHandler [4] => class is LanguageSpecific\ArrayHandler values[5] => 7 , class is LanguageSpecific\ValueHandler [6] => 8 , class is LanguageSpecific\ValueHandler [7] => 9 , class is LanguageSpecific\ValueHandler

Если нам надо пробежаться по всем элементам исходного массива, мы используем обычный foreach():

$data = new ArrayHandler(    [        'first' => ['A' => 1],        'next' => ['B'=>2],        'last' => ['C'=>3],        4=>[5,6],        7,        8,        9    ]);echo 'ALL'.PHP_EOL;foreach ($data as $key => $value) {    $type = gettype($value->asIs());    echo "[$key] => value type is $type , class is ".get_class($value).PHP_EOL;}

Вывод скрипта:

ALL[first] => value type is array , class is LanguageSpecific\ValueHandler[next] => value type is array , class is LanguageSpecific\ValueHandler[last] => value type is array , class is LanguageSpecific\ValueHandler[4] => value type is array , class is LanguageSpecific\ValueHandler[5] => value type is integer , class is LanguageSpecific\ValueHandler[6] => value type is integer , class is LanguageSpecific\ValueHandler[7] => value type is integer , class is LanguageSpecific\ValueHandler

Соответственно, если мы какие то элементы хотим обработать как элементы, а какие то как массивы, то мы в foreach(), делаем так:

foreach ($data as $key => $value) {    /* @var \LanguageSpecific\ValueHandler $value */    if($value->type() === 'array'){        $handler = new ArrayHandler($value->array());        /* some code */    }}

Работа над ошибками

Метод IValueHandler::default() перестал быть статическим, его опасность до меня пытался донести @GreedyIvan, до меня дошло через неделю, спасибо.

Метод ArrayHandler::simplify() был удалён, потому что на самом деле

Зачем ArrayHandler->simplify(), когда есть array_column? (c) @olegmar

Cпасибо @olegmar.

Метод IArrayHandler::next() был заменён на IArrayHandler::pulling(), этот метод перебирает все вложенные массивы (первый уровень вложенности). Не то что бы комент от @Hett меня прямо убедил, но к размышлениям подтолкнул.

Спасибо @ReDev1L за поддержку в коментах.

Был добавлен метод IArrayHandler::raw(), что бы можно было получить исходный массив. Раньше, когда не было возможности получить индекс элемента, приходилось перебирать исходный массив, сейчас по опыту использования, бывает необходимость добавить/убавить элементы массива и создать из изменённого массива новый ArrayHandler.

На этом всё. Спасибо за чтение.

Подробнее..

Http-stubs поиск идеального инструмента

05.10.2020 14:22:06 | Автор: admin

Http-stubs поиск идеального инструмента



Всем хорошего дня, я backend-разработчик компании Uma.Tech. Сегодня я хочу рассказать, как однажды нашему отделу разработки поступила задача от отдела тестирования: локально развернуть сервис создания заглушек для http-запросов. Если интересно, как проходил поиск, сравнение разных opensource и не только инструментов, до чего мы в итоге докатились и причём тут попугай на картинке прошу под кат.


Оговоримся сразу: мы не рекламируем ни один из приведенных ниже инструментов, а просто делимся своим опытом.


TLDR: Если нет желания читать много букв, в конце общая сводная таблица по всем инструментам.


Начало


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


В нашем подразделении Uma.Tech мы занимается всем, что связано с видеоконтентом. На нашей платформе работают проекты холдинга Газпром-Медиа. Если вы используете приложение PREMIER, смотрите на сайте телеканал 2x2 или, предположим, предпочитаете онлайн-трансляции дерби Спартак-ЦСКА с сайта или в приложении Матч ТВ значит, вы тоже пользователь нашей видеоплатформы.


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


Подробнее, если интересно, расскажем в отдельных статьях.


Основной язык разработки для нашей команды Python разных версий. Все сервисы общаются между собой по разным каналам передачи информации, как синхронным, так и асинхронным. Для взаимодействия в числе прочих используются webhook'и и обычные REST API.


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


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


В поисках идеала


Оговорюсь сразу, я тут перечислю не все инструменты, из которых мы выбирали, наверняка, какие-то просто не попались на глаза. Буду благодарен, если в комментариях вы поделитесь другими подобными сервисами. Я обязательно их проверю и добавлю интересные в статью.


Наличие необходимых функций было далеко не последним требованием. При выборе приходилось учитывать много факторов:


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

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


webhook.site


Начнем с того, что использовали исторически webhook.site


Ссылка: https://github.com/fredsted/webhook.site


Это хороший и продуманный сервис, имеет достаточно функций для тестирования: здесь http-заглушки, фейковые email адреса и т.д.


У сервиса есть платная версия, расширяющая и без того небедный набор возможностей, которые легко можно найти в документации. Важный фактор в плюс: у webhook.site открыт исходный код на GitHub и распространяется он по лицензии MIT.


Основная страница webhook.site выглядит так:



Из минусов, которые оказались значимыми для нас: непрофильные языки для нашей команды PHP для backend и javascript для frontend.


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


postbin


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


Ссылка: https://github.com/ashishbista/postbin


Его страничка выглядит примерно так:



Распространение по лицензии ISC, ныне не очень популярной, но почти эквивалентной MIT.
У postbin доступна публично развёрнутая версия, а вот с функционалом совсем скудно есть только http-заглушки.
Стек чистый javascript, для frontend и backend. В общем смотрим дальше.


httplive


Ещё один инструмент, попавший в обзор это httplive.


Ссылка: https://github.com/gencebay/httplive


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


Скриншот интерфейса httplive мы брали из документации:



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


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


irontest


Следующий продукт irontest.


Ссылка: https://github.com/zheng-wang/irontest


Целый комбайн написанный на java с множеством функций: от тестирования ftp до IBM Integration Bus.
Скриншот интерфейса предоставлять не буду, потому что страниц и настроек там множество, все это есть подробно в документации.
Распространение по лицензии Apache 2.


Инструмент интересный, но всё же слишком нагруженный для нашей задачи. Кроме того, java язык совсем не из нашего стека.
Вероятно, когда понадобится тестировать что-то кроме http, мы повторно вернёмся к irontest.


duckrails


В наших поисках дошли и до инструмента, написанного на Ruby duckrails.


Ссылка: https://github.com/iridakos/duckrails


Распространяется по лицензии MIT.
Скриншотов также не будет, так как их много в readme-проекта.
По функциональности есть нужное нам: инструмент позволяет создавать http-заглушки со всем необходим. Есть и важная для нас киллер фича создание своих кастомных скриптов, но либо на Ruby, либо JavaScript.


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


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


Наш выбор


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


В качестве стека были выбраны максимально простые и знакомые технологии: Python и web-фреймворк Django. С фронтом возиться не хотелось, так как наша команда это backend-разработка, поэтому был найден визуально очень хороший плагин на административную панель simpleui.


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


И теперь, ко всеобщему вниманию, ещё один инструмент в сфере интеграционного тестирования Parrot!


Parrot


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


Ссылка: https://github.com/Uma-Tech/parrot


Проект распространяется по лицензии Apache 2.
Свободен для коммерческого использования, имеет репозиторий на GitHub со множеством автопроверок кода: автотестами, статическими анализаторами, линтингом и дружелюбен для контрибьютинга.


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


Вот так, на данный момент, выглядит наш маленький попугай:



Интерфейс хоть и изменён визуально, но не сильно отличается от стандартного интерфейса Django.
В разделе Authentication and Authorization можно управлять пользователями, группами и правами для них.


Основной раздел HTTP Stubs, в нём можно создавать заглушки и просматривать логи запросов. Из интересного: для URL можно использовать regex-выражение, остальное плюс-минус стандартно, как в прочих инструментах.


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


Сейчас функционал уступает некоторым рассмотренным инструментам, но он развивается и дополняется. Будем рады открытым запросам на необходимые пользователям функции.
В ближайших обновлениях: шаблонизация ответа, выполнение кастомных скриптов на запрос и полноценное проксирование к внешнему сервису, с обработкой полученного ответа, такой своего рода Man In The Middle.


Общее сравнение


webhook.site postbin httplive irontest duckrails parrot
Язык бекенда php javascript golang java ruby python
Лицензия MIT ISC MIT Apache2 MIT Apache2
Коммит меньше месяца назад - - - + - +
Тестирование email-сообщений + - - - - -
Настройка заголовков ответа + - - + + +
Настройка кода ответа + - - + + +
Настройка тела ответа + - + + + +
Установка задержки для ответа + - - - - +
Шаблонизация ответа + - - - - -
Выполнение кастомных скриптов - - - - + -
Настройка пути для http-заглушки - - + + + +
Regex-выражение для пути - - - - - +
Режим Man In The Middle - - - - - -

Что в итоге


Приложением стали активно пользоваться наши тестировщики, а после его презентации на внутреннем демо, Parrot заинтересовал и другие команды из компании Uma.Tech.
Увидев, что инструмент удобен нам и интересен другим, мы решили поделиться им со всем сообществом. Будем очень рады обратной связи и вашим pull requests.
Нам эта история показала, что иногда лучше изобрести свой велосипед, чем ехать на чужом.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    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