Так не бывает рассказ на дзен

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

Меня зовут Дмитрий Руднев. Обычно я пишу в хабы «Электроника для начинающих», «Схемотехника», «DIY или Сделай сам» и некоторые другие. Мне очень нравится просто и понятно писать о разработке радиоэлектронных устройств. Примером в этом для меня служат В.Г. Борисов (автор «Юного радиолюбителя»), В.Т. Поляков (RA3AAE, в представлении не нуждается), Б.Г. Степанов (RU3AX, ex. UW3AX, редактор журнала «Радио»).

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

Смотрим в разделе «Для авторов»:

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

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

Написание туториала – серьёзный вызов для профессионала. Только профессионал может писать по делу, кратко, понятно и «без воды».

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

Разработка вышеперечисленной текстовой документации должна производиться на основании требований разделов 4.10 «Пояснительная записка» и 4.11 «Программа и методика испытаний» ГОСТ Р 2.106-2019, требований разделов 5 «Руководство по эксплуатации» и 6 «Инструкция по монтажу, пуску, регулированию и обкатке изделия» ГОСТ Р 2.610-2019, требований РД 50-34.698-90 в части описания автоматизированных систем, далее АС.

Разрабатываемые документы должны иметь структуру согласно вышеперечисленным требованиям. Вводная часть должна содержать информацию о назначении документа и актуальности раскрываемой темы. Содержание разделов основной части должно точно соответствовать раскрываемой теме. Изложение материала должно быть сжатым, логичным и не допускать неоднозначных трактовок. Оформление списка используемых источников должно соответствовать требованиям ГОСТ 7.1-2003.

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

Когда я решил начать публиковаться на Хабре, я понял, что должен поменять стиль. Для этого я стал читать статьи из раздела «Радио – начинающим» журнала «Радио», перечитывать «Юного радиолюбителя» Борисова и заново открывать для себя Полякова. Это замечательные образцы того, как можно понятно объяснять сложные вещи. Любителям.

Моё становление как радиолюбителя шло на фоне тотального дефицита: необходимость постоянно ремонтировать телевизоры и радиоприёмники; самостоятельное изготовление и наладка любительской радиостанции; самостоятельная сборка усилителей и акустических систем, цветомузыкальных приставок, автоматических определителей номеров и компьютеров ZX Spectrum, наконец. В результате мне пришлось знать и ламповую электронику, и транзисторную, я разбираюсь и в аналоговой электронике, и в дискретной. А ещё я умею пилить, строгать и писать программы на C для микроконтроллеров.

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

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

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

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

Применение терминологии очень хитрая вещь: «Wien bridge» это «мост Вина», но «Darlington transistor» — не «транзистор Дарлингтона», а «составной транзистор». И так сложилось исторически, что «push-pull output» переводится с английского на русский как «двухтактный выходной каскад».

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

Опубликованные любителями туториалы могут содержать фактические ошибки, в них могут быть непроверенные решения со спорным обоснованием, но их читают и «плюсуют». Их обсуждают, критикуют в комментариях, предлагают альтернативные решения.

Иногда мне кажется, что плашка «Tutorial» в публикации любителя означает не столько учебный материал, не столько справочное пособие, сколько призыв посмотреть на то, что получилось. А оно получилось! Поэтому там и история о себе, и рассказ о трудностях выбора решения, и о том как перепутал полярность питания и всё сжёг, и о том что китайцы прислали не те комплектующие…

Получается, что все эти «туториалы» – живой рассказ о себе, о своей истории успеха, а не сухая инструкция по ГОСТ.

Окончательно я утвердился в этом мнении, когда несколько лет назад на Хабре был парад публикаций на тему «Как я сделал метеостанцию на Arduino».

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

Слово «радиолюбитель» составлено из слов «радио» и «любить». Я всецело поддерживаю всё, что объединяет людей. Если увлечённый человек напишет задорный рассказ о том, как он набил себе шишек, изобретая очередной «велосипед», и приклеит к нему плашку «Tutorial», я только порадуюсь за него.

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

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

Мне – совсем не хочется, вот поэтому я и не люблю писать туториалы.

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

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

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

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

Эти соединения что-то захватывало.

Речь идёт о кодовой базе в миллионы строк кода, которой в течение нескольких лет занимались десятки программистов. Может — проблема заключается в неразрешённых промисах? Я решил исследовать этот вопрос.

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

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

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

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

Типичное решение проблемы останова заключается в том, чтобы добавить в систему временные ограничения. Если функция не завершает работу в пределах N секунд — мы считаем, что она где-то застопорилась. Может, это и не так, но мы решаем, что это так, и «убиваем» программу.

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

Но «убийство» процесса сервера при каждом недоразумении — это решение не идеальное. И, кроме того, оснащение каждого промиса в кодовой базе тайм-аутом — это, в лучшем случае, очень сложно.

Но не так всё плохо! Проблему останова можно решить для подмножества распространённых паттернов.

Паттерны проблемных промисов

В прочитанной мной публикации «Нахождение проблемных промисов в асинхронных JavaScript-программах» Алимадади с соавторами выделили распространённые паттерны, которые приводят к появлению неразрешённых промисов, и представили программу PromiseKeeper. Эта программа находит потенциально неразрешаемые промисы с использованием графа промисов.

▍Краткий рассказ о JavaScript-промисах

Та публикация начинается с обзора JavaScript-промисов.

Промисы представляют асинхронные вычисления и могут пребывать в трёх состояниях: pending (ожидание), fulfilled (успешное разрешение) и rejected (отклонение). Вначале они оказываются в состоянии pending.

Код, реагирующий на результат работы промиса (обработчик определённого события), регистрируют с помощью метода .then().

// Немедленно разрешается со значением 17
const promise = Promise.resolve(17)
promise.then(
  function fulfilledReaction(value) {
    console.log({ value })
  },
  function rejectedReaction(error) {
    console.log({ error })
    throw error
  }
)

В коде, на практике, опускают второй параметр для того чтобы сосредоточиться на коде, который выполняется при успешном разрешении промиса (fulfilledReaction). Обычно так поступают при построении цепочек промисов:

// Немедленно разрешается со значением 17
const promise = Promise.resolve(17)
promise.then(value => value + 1)
       .then(value => value + 1)
       .then(function (value) => { console.log(value) })

Каждый вызов .then() приводит к созданию нового промиса, который разрешается возвращаемым значением кода, реагирующего на завершение работы предыдущего промиса. Обратите внимание на то, что последний вызов неявно разрешается со значением undefined. Дело в том, что в JavaScript функция, которая не возвращает что-либо, неявным образом возвращает undefined.

Важная деталь.

В цепочку промисов можно добавить механизм обработки ошибок с использованием .catch():

// Немедленно разрешается со значением 17
const promise = Promise.resolve(17)
promise.then( ... )
       .then( ... )
       .then( ... )
       .catch(err => ...)

Каждый промис, созданный .then(), неявно определяет код, реагирующий на отклонение промиса, аналогичный конструкции err => throw err. Это значит, что .catch() в конце цепочки промисов может отреагировать на ошибки, возникшие в любом из предыдущих промисов.

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

// Немедленно разрешается со значением 17
const promise = Promise.resolve(17)
promise
  .then(undefined) // используется стандартный механизм value => value
  .then((value) => console.log(value))

Полагаю, подобное чаще происходит случайно, чем намеренно.

Промисы можно связывать, используя один промис для разрешения другого промиса:

const p0 = Promise.resolve(17) // Немедленно разрешается
const p1 = Promise.reject("foo") // Немедленно отклоняется
p0.then(function (v) {
  return p1
})

Здесь состояние промиса p0 связано с p1. То есть — неименованный промис, созданный в строке 3, отклоняется со значением foo.

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

▍Паттерн №1: необработанное отклонение промиса

Обычным источником ошибок при работе с промисами являются необработанные отклонения промисов.

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

promise.then(function (val) {
  if (val > 5) {
    console.log(val)
  } else {
    throw new Error("Small val")
  }
})

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

Исправить это можно, использовав метод .catch():

promise
  .then(function (val) {
    if (val > 5) {
      console.log(val)
    } else {
      throw new Error("Small val")
    }
  })
  .catch((err) => console.log(err))

Теперь у нас появляется возможность обработать ошибку.

Но мы не перевыбросили эту ошибку! Если другой промис, связанный с этим, или объединённый с ним в цепочку, полагается на этот код, ошибка будет оставаться «проглоченной». Код продолжит выполняться.

Попробуйте следующее в консоли браузера:

const p = Promise.resolve(17)
p.then(function (val) {
  throw new Error("Oops")
  return val + 1
})
  .catch(function (err) {
    console.log(err)
  })
  .then(function (val) {
    console.log(val + 1) // prints NaN
  })

Можно ожидать, что в этом коде выполняются вычисления 17 + 1 = 18, но, из-за неожиданной ошибки, мы получаем NaN. Промис, неявно созданный .catch(), неявно разрешается (а не отклоняется) со значением undefined.

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

▍Паттерн №2: незавершённые промисы

Новые промисы пребывают в состоянии pending до тех пор, пока не будут успешно разрешены или отклонены (то есть — завершены). Но если промис не завершается, его можно назвать остановившимся промисом. Он навсегда останется в состоянии pending, не давая выполняться коду, полагающемуся на его завершение.

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

Авторы вышеупомянутой публикации показывают пример проблемы из node-promise-mysql, где connection.release() возвращает промис, который никогда не разрешается.

Этот пример сложно свести к нескольким строкам кода, поэтому вот — кое-что попроще:

const p0 = new Promise((resolve, reject) => null)
const p1 = Promise.resolve(17)
p0.then((result) => p1)
  .then((value) => value + 1)
  .then((value) => console.log(value)) // ожидается 18

Последний промис соединён цепочкой из .then() с промисом p0, который никогда не разрешается и не отклоняется. Этот код может выполняться вечно, но он никогда не выведет никакого значения.

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

▍Паттерн №3: неявные возвраты и код, реагирующий на результаты работы промиса

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

Эта проблема похожа на ту, связанную с «проглатыванием» ошибок, о которой я уже рассказывал. Вот фрагмент кода из Google Assistant, который приводят Алимадади с соавторами:

handleRequest (handler) {
    if (typeof handler === 'function') {
        const promise = handler(this)
        if (promise instanceof Promise) {
            promise.then(result => {
                debug(result)
                return result
            }).catch(reason => {
                this.handleError('function failed')
                this.tell(!reason.message ? ERROR_MESSAGE : reason.message)
                return reason
            })
        }
    }
}

Метод handleRequest() использует объект Map с обработчиками, предоставленными разработчиком, для организации асинхронной работы с запросами Assistant. Объект handler (обработчик) может быть либо коллбэком, либо промисом.

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

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

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

Поиск антипаттернов с помощью графа промисов

Алимадади с соавторами создали программу PromiseKeeper, которая динамически анализирует кодовую базу на JavaScript и рисует графы промисов.

Граф промисов

Мне не удалось запустить эту программу, поэтому передам то, о чём рассказали авторы публикации.

▍Графы промисов

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

  • Вершины-промисы (p) представляют собой случаи запуска промисов.
  • Вершины-значения (v) представляют значения, с которыми разрешаются или отклоняются промисы. Это могут быть функции.
  • Вершины-функции (f) — это функции, зарегистрированные для обработки разрешения или отклонения промиса.
  • Вершины-механизмы синхронизации (s) представляют собой все использованные в коде вызовы Promise.all() или Promise.race().
  • Рёбра разрешения или отклонения промиса (v)->(p) указывают на связи вершин-значений с вершинами-промисами. Они помечены как resolve или reject.
  • Рёбра регистрации обработчиков (p)->(f) указывают на связи между промисом и функцией. Они помечены как onResolve или onReject.
  • Рёбра связей (p1)->(p2) показывают взаимоотношения между связанными промисами.
  • Рёбра return или throw (f)->(v) показывают связи функций и значений. Они помечены как return или throw.
  • Рёбра механизмов синхронизации указывают на связи, идущие от множества промисов к одному механизму синхронизации промисов. Они помечаются как resolved, rejected или pending на основании того, как ведёт себя новый промис.

Вот аннотированная версия вышеприведённого графа.

beo3RPTAZulNEa80tRBZ7Z 3RyDdXs44oIJ6plwoJZlZEMxdgKRG3mJ s9lzrEtaZQ4bt92m3N6PdEHdQrEwYKJJ1EjWOR8PM33KAYySMFIN5lOQ ypmYbFsvUXF7YRFJBegvGky

Аннотированный граф промисов

▍Использование PromiseKeeper для поиска анти-паттернов

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

Вот на какие анти-паттерны обращает внимание программа:

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

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

Реализация PromiseKeeper основана на фреймворке для динамического анализа JavaScript-кода Jalangi. В нём имеются коллбэки, которые реагируют на события жизненного цикла промисов.

Мне не удалось заставить работать PromiseKeeper на моём компьютере, но Алимадади с соавторами сообщают о том, что не вполне благополучные промисы встречаются почти во всех кодовых базах, в которых используется JavaScript.

77c7fb53726b48d333344bdd386b9fcb

Отчёт по выявленным анти-паттернам

Интересно то, что 1012 экземпляров незавершённых промисов встречаются всего в 17 местах кода Node Fetch.

Авторы сообщили, что в ходе их эксперимента 43% промисов оказались неразрешёнными. Это, вероятнее всего, указывает на неполноту тестов, а не на то, что популярные программные продукты, которые они исследовали, безнадёжно «поломаны».

Что делать?

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

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

// Выводит полезное сообщение об ошибке когда
// отклонение промиса оказывается необработанным
process.on("unhandledRejection", (err, promise) => {
  const stack = err instanceof Error ? err.stack : ""
  const message = err instanceof Error ? err.message : err

  Logger.error("Unhandled promise rejection", {
    message,
    stack,
    promise,
  })
})

Ещё можно попробовать Node.js-модуль async_hooks, который позволяет наблюдать за жизненным циклом промисов, и попытаться выявлять промисы, которые выполняются слишком долго. Можно, например, сравнивать время работы промиса с заданным тайм-аутом и выводить в консоль предупреждение.

У меня была интересная попытка использования async_hooks для выявления промисов, выполняющихся слишком долго, но особого толку из этого не вышло. Нельзя получить ссылку на контекст выполнения (только — C-указатель). То есть — можно увидеть, что что-то работает медленно, но о том, что это такое, узнать нельзя.

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

Сталкивались ли вы с проблемами, вызванными неправильной работой промисов?

oug5kh6sjydt9llengsiebnp40w

А вот еще несколько наших интересных статей:

  • Так нечестно как пишется слитно или раздельно
  • Так сказать как пишется
  • Так ли пессимистичен рассказ бунина существует ли путь к спасению
  • Так или дак как правильно пишется
  • Так значит так как пишется
  • Поделиться этой статьей с друзьями:


    0 0 голоса
    Рейтинг статьи
    Подписаться
    Уведомить о
    guest

    0 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии