JavaScript нередко называют «самым популярным языком», но, кажется, никто не отзывается о JS-разработке как о «самой безопасной», и количество подстерегающих проблем в экосистеме велико. Как эффективно их обходить?
Илья Климов задумался об этом, когда ошибка обошлась очень дорого (в буквальном смысле) — и в итоге сделал доклад на HolyJS. А поскольку зрительские отзывы оказались отличными, мы теперь подготовили для Хабра текстовую версию этого доклада. Под катом — и текст, и видеозапись.
Всем привет. Меня зовут Илья Климов, я из Харькова, Украина. У меня собственная небольшая, до десяти человек, аутсорсинговая компания. Мы делаем всё, за что платят деньги… в смысле, программируем на JavaScript во всех отраслях. Сегодня, разговаривая о надёжном JavaScript, я хочу поделиться своими наработками где-то за последний год, с тех пор как меня эта тема начала достаточно жёстко и серьёзно беспокоить.
Те, кто работает в аутсорсе, прекрасно поймут содержимое следующего слайда:
Всё, о чем мы будем говорить, конечно же, не имеет никакого отношения к реальности. Как говорят в South Park, все персонажи спародированы, причём убого. Естественно, те места, где есть подозрение на нарушение NDA, были согласованы с представителями заказчиков.
Ничто так не повлияло на мои мысли о надёжности и тому подобном, как основание собственной компании. Когда ты заводишь собственную компанию, внезапно оказывается, что ты можешь быть очень крутым программистом, у тебя могут быть очень крутые ребята, но иногда происходят совершенно невероятные вещи, парочка невозможных и совершенно безумная.
У меня есть образовательный проект JavaScript Ninja. Иногда я даю обещания. Иногда я даже их выполняю. Я пообещал в 2017 году в рамках образовательного проекта записать видео про Kubernetes. Я осознал, что дал это обещание и хорошо бы его выполнить, 31 декабря. И я сел записывать (вот результат).
Поскольку я люблю записывать видео, максимально приближенные к реальности, я воспользовался примерами реального проекта. В итоге в демонстрационном кластере я развернул штуку, которая забирала реальные заказы с реального продакшена и клала их в отдельную Kubernetes базу в моем демонстрационном кластере.
Поскольку это было 31 декабря, часть заказов пропала вникуда. Списали на сезонность: все ушли пить чай. Когда заказчик очнулся, примерно 12-13 января, суммарная стоимость видео составила порядка $500 000. Такого дорогого продакшена у меня ещё не было.
Пример номер два: очередной кластер Kubernetes. Новомодная экосистема Infrastructure as a Сode: всё, что можно, описано кодом и конфигами, Kubernetes дёргается программно из JavaScript-оболочек и так далее. Классно, всем нравится. Немножечко изменяют процедуру развёртывания, и наступает момент, когда необходимо развёртывать новый кластер. Возникает следующая ситуация:
const config = {
// …
mysql: process.env.MYSQL_URI
|| ‘mysql://localhost:3306/foo’
// ...
}
У многих из вас наверняка тоже есть такая строчка кода в ваших конфигах. То есть забираем конфиг из mysql-переменной или берём локальную базу.
Из-за опечатки в системе деплоймента получилось так, что система опять сконфигурировалась в качестве продакшна, а вот MySQL-базу использовала тестовую — локальную, которая лежала для тестов. В этот раз денег было потрачено поменьше — всего $300 000. К счастью, это уже была не моя компания, а место, где я работал как привлечённый консультант.
Вы могли подумать, что вас как фронтендеров всё это не касается, ведь я рассказывал о DevOps (кстати, я восхищён названием конференции DevOops, отлично описывает суть). Но расскажу про ещё одну ситуацию.
Есть система, которая осуществляет контроль ветеринарных эпидемий в Эфиопии, разрабатывалась под эгидой ООН. Она содержит в себе один из элементов интерфейса, когда к человеку приходят, и он вручную вводит координаты: когда и где были вспышки определённой болезни.
Происходит вспышка какого-то очередного ящура (я не силён в коровьих болезнях), и в спешке оператор случайно два раза нажимает кнопку «добавить», оставив поля пустыми. Поскольку у нас JavaScript, а JavaScript и типы — это очень хорошо, пустые поля широты и долготы радостно приводятся к нулевым координатам.
Нет, мы не отправили врача далеко в океан, но оказалось, что для отображения на карте, построения отчётов, аналитики, анализа размещения людей на бэкенде всё это предварительно просчитывается с точки зрения кластеризации. Мы пытаемся объединить точки возникновения эпидемий.
В итоге система парализована в течение дня, потому что бэкенд пытается рассчитать кластер с включением этой точки, все данные становятся неактуальными, врачам приходят совершенно невменяемые приказы из серии «езжайте за 400 километров». 400 километров по Эфиопии — это сомнительное удовольствие.
Суммарная оценка потерь — порядка миллиона долларов. В отчёте об этой ситуации было написано «Мы стали жертвой неудачного стечения обстоятельств». Но мы-то знаем, что дело в JavaScript!
И последний пример. К сожалению, хотя эта история была достаточно давно, я до сих пор не могу называть компанию. Но это компания, у которой своя собственная авиалиния, собственные отели и так далее. Она очень интерактивно работает с бизнесами, то есть предоставляет им отдельный интерфейс для бронирования билетов и так далее.
Однажды по нелепой случайности приходит заказ на бронирование билетов из Нью-Йорка в Лос-Анджелес в количестве 999 999 штук. Система компании радостно выкупила все рейсы собственной авиакомпании, обнаружила, что мест не хватает, и отправила данные в международную систему бронирования, чтобы компенсировать нехватку. Международная система бронирования, увидев запрос в приблизительно 950 000 билетов, радостно отключила эту авиакомпанию от своей системы.
Поскольку отключение — это из ряда вон выходящее событие, после этого проблема была решена в течение семи минут. Однако за эти семь минут стоимость штрафов, которые пришлось заплатить, составила всего-навсего $100 000.
К счастью, это всё происходило не в один год. Но эти случаи заставили меня задуматься о вопросах обеспечения надёжности и задать два исконно русских вопроса: кто виноват и что с этим делать?
Почему так происходит: юность экосистемы
Если вы проанализируете много историй, вы обнаружите, что историй о подобных проблемах, связанных с JavaScript, гораздо больше, чем с другим языком программирования. Это не моё субъективное впечатление, а результаты интеллектуального анализа новостей на Hacker News. С одной стороны, это хипстерский и субъективный источник, но, с другой стороны, найти какой-нибудь вменяемый источник по факапам в области программирования достаточно сложно.
Более того, год назад я проходил соревнование, где надо было каждый день решать алгоритмические задачки. Поскольку мне было скучно, я их решал на JavaScript с помощью функционального программирования. Я написал абсолютно чистую функцию, и она в актуальном Chrome 1197 раз работала правильно, а 3 раза выдавала другой результат. Это была всего лишь небольшая ошибка в оптимизаторе TurboFan, который только-только попадал в основной Chrome.
Конечно, она была поправлена, но вы же понимаете: такое означает, например, что если ваши юнит-тесты один раз прошли, это совершенно не означает, что они будут работать в системе. То есть мы выполняли код порядка 1197 раз, потом приходил оптимизатор и говорил: «Ух ты! Горячая функция! Давайте её соптимизируем». И в процессе оптимизации приводил к неправильному результату.
Другими словами, одной из первых причин, почему так происходит, мы можем называть юность экосистемы. JavaScript — достаточно молодая отрасль именно в области серьёзного программирования, тех дел, где вращаются миллионы, где стоимость ошибки измеряется пятью-шестью знаками.
Долгое время JavaScript воспринимался как игрушка. Из-за этого (не потому что мы не воспринимаем это серьёзно) у нас всё ещё проблемы с тем, что не хватает инструментария.
Поэтому, чтобы бороться с этой причиной, которая является фундаментальной первоосновой всего, о чем буду сегодня говорить, я попытался сформулировать правила надёжности, которые мог бы навязать в своей компании или передать в качестве консалтера в другие. Как говорится, «правило номер один — не говорить о надёжности». А если серьёзнее, то…
Правило надёжности #1: всё, что может быть автоматизировано, должно быть автаматизировано
Включая, кстати, и проверку орфографии:
Всё начинается с самых простых вещей. Казалось бы, все давно пишут на Prettier. Но только в 2018 году эта штука, которую мы все используем, хорошая и здравая, научилась работать с git add -p, когда мы частично выполняем добавление файлов в git репозиторий, и нам хочется красиво отформатировать код, допустим, перед отправкой в основной репозиторий. Абсолютно той же проблемой обладает достаточно известная утилита realinstaged, которая позволяет проверять только те файлы, которые были изменены.
Продолжим играть в Капитана Очевидность: ESLint. Я не буду спрашивать, кто тут его использует, потому что нет смысла в том, чтобы весь зал поднимал руки (ну, я надеюсь на это и не хочу разочаровываться). Лучше поднимите руки, у кого в ESLint есть собственные кастомно написанные правила.
Такие правила — один из очень мощных способов автоматизации того бардака, который происходит в проектах, где пишут люди уровня junior и тому подобного.
Мы все хотим определённого уровня изоляции, однако рано или поздно возникает ситуация: «Смотри, вот этот helper Вася реализовал где-то в директории своего компонента совсем рядышком. Я не буду его выносить в common, потом сделаю». Волшебное слово «потом». Это приводит к тому, что в проекте начинают появляться не вертикальные зависимости (когда верхние элементы подключают нижние, нижние никогда не лезут за верхними), а компонент A зависит от компонента B, который находится совершенно в другой ветке. В итоге компонент A становится не так просто переносимым в другие компоненты.
Кстати, выражаю респект «Альфа-банку», у них очень хорошо и красиво написана библиотека компонентов на React, ей пользоваться одно удовольствие именно в плане оформления качества кода.
Банальное ESLint-правило, которое следит, откуда вы импортируете сущности, позволяет существенно увеличить качество кода и сохранить ментальную модель при code review.
Я уже с точки зрения мира фронтенда старый. У нас недавно в Харьковской области большая серьезная компания PricewaterhouseCoopers закончила исследование, и средний возраст фронтендера составил порядка 24–25 лет. Мне уже тяжело думать обо всём этом, я хочу при ревью пулл-реквеста сосредотачиваться на бизнес-логике. Поэтому я с удовольствием пишу ESLint-правила, чтобы не думать о таких вещах.
Казалось бы, под это можно подстроить обычные правила, но реальность обычно расстраивает куда больше, потому что, оказывается из реактовского компонента нужны какие-нибудь селекторы Redux (он, к сожалению, всё ещё жив). И эти селекторы лежат где-то в совершенно другой иерархии, поэтому «../../../..».
Или, ещё хуже, webpack alias, который ломает приблизительно 20% другого инструментария, потому что не все понимают, как с ним работать. К примеру, мой горячо любимый Flow.
Поэтому в следующий раз перед тем, как вам захочется нарычать на джуниора (а у программиста есть такое любимое занятие), задумайтесь, можете ли вы как-то автоматизировать это, чтобы не допускать ошибки в дальнейшем. В идеальном мире вы, конечно, напишете инструкцию, которую всё равно никто не прочитает. Вот спикеры HolyJS — талантливые специалисты с большим опытом, но когда на внутреннем митинге было предложено составить инструкцию для спикеров, на это сказали «да они ж её не прочитают». А это те люди, с которых надо брать пример!
Последнее из банальщины, и перейдём к жести. Это любые инструменты для запуска прекоммит-хуков. Мы используем husky, и я не мог не вставить эту красивую фотографию хаски, но вы можете использовать что-то другое.
Если вы думаете, что это всё очень просто — как говорится, hold my beer, скоро разберёмся, что всё бывает сложнее, чем вам кажется. Ещё несколько пунктов:
Типизация
Если вы не пишете на TypeScript, возможно, вам стоит об этом задуматься. Я TypeScript не люблю, я традиционно хайплю Flow, но об этом мы можем похоливарить позже, а здесь со сцены я буду с отвращением продвигать мейнстримное решение.
Почему так? У программного комитета TC39 недавно было очень большое обсуждение, куда вообще идёт язык. Очень забавный вывод, к которому они пришли: в TC39 вечно «лебедь, рак и щука», которые тащат язык в разных направлениях, но есть одна вещь, которую хотят все и всегда, — это перформанс.
TC39 неофициально, во внутреннем обсуждении, выдал такую тираду: «мы всегда будем делать JavaScript так, чтобы он оставался производительным, а те, кому не нравится, возьмут какой-нибудь язык, который компилируется в JavaScript».
TypeScript достаточно неплохая альтернатива со взрослой экосистемой. Не могу не упомянуть о своей любви к GraphQL. Он действительно хорош, к сожалению, его никто не даст внедрять на огромном количестве существующих проектов, где нам уже приходится работать.
На конференции уже были доклады по GraphQL, поэтому всего один штрих именно к вопросу о надёжности: если вы используете, допустим, Express GraphQL, то каждый раз в дополнение к определенному резолверу вы можете навешивать определённые валидаторы, которые позволяют ужесточить требования к значению по сравнению со стандартными типами GraphQL.
К примеру, хотелось бы, чтобы сумма перевода между двумя представителями каких-нибудь банков была положительной. Потому что не далее как вчера поп-ап в моем интернет-банкинге радостно возвещал, что у меня -2 непрочитанных сообщения от банка. И это вроде как лидирующий банк моей страны.
Что касается этих валидаторов, накладывающих дополнительную строгость: использовать их — хорошая и здравая идея, только не используете их так, как предлагает, допустим, GraphQL. Вы оказываетесь очень сильно привязаны к GraphQL как к платформе. В то же время валидация, которую вы делаете, нужна одновременно в двух местах: на фронтенде перед тем, как мы отправляем и получаем данные, и на бэкенде.
Мне регулярно приходится объяснять заказчику, почему мы взяли в качестве бэкенда JavaScript, а не язык X. Причем язык X — это обычно какой-нибудь PHP, а не красивые Go и тому подобные. Мне приходится объяснять, что мы способны максимально эффективно переиспользовать код, в том числе между клиентом и бэкендом, за счет того, что они написаны на одном языке программирования. К сожалению, как показывает практика, часто этот тезис остается всего лишь фразой на конференции и не находит воплощения в реальной жизни.
Контракты
Я уже говорил о юности экосистемы. Контрактное программирование существует более 25 лет как основной подход. Если вы пишете на TypeScript, возьмите io-ts, если вы пишете на Flow, как я, возьмите typed-contract, и получите очень важную вещь: возможность описывать runtime-контракты, из которых выводить статические типы.
Хуже нет для программиста, чем наличие более чем одного источника правды. Я знаю людей, которые потеряли пятизначные суммы в долларах просто из-за того, что их тип, описанный на языке со статической типизацией (они использовали TypeScript — ну, конечно, это просто совпадение), и runtime-тип (вроде бы использовали tcomb) немного различались.
Поэтому в compile-time ошибка не было поймана, просто потому что зачем её проверять? Юнит-тестов на неё не было, потому что нам же проверил это статический типизатор. Нет смысла проверять вещи, которые были проверены слоем ниже, все помнят иерархию тестирования.
Из-за того, что с течением времени нарушилась синхронизация между этими двумя контрактами, однажды выполнился неправильный перевод по адресу, который ушёл на неправильный адрес. Поскольку это была криптовалюта, откатить транзакцию невозможно чуть более, чем в принципе. Форкать эфир ради вас ещё раз никто не будет. Поэтому контракты и взаимодействия на контрактном программировании — первое, что вы должны начать делать прямо завтра.
Почему так происходит: изоляция
Следующая проблема — изоляция. Она многогранна и многолика. Когда я работал для компании, связанной с отелями и авиаперелетами, у них было приложение на Angular 1. Это было достаточно давно, так что простительно. Над этим приложением работала команда из 80 человек. Все было покрыто тестами. все было хорошо, пока я один прекрасный день не сделал свою фичу, не замерджил ее и обнаружил, что я сломал в рамках тестирования совершенно невероятные места системы, которых я даже не касался.
Оказалось, у меня проблемы с креативностью. Оказалось, что я чисто случайно назвал сервис точно так же, как другой сервис, который существовал в системе. Поскольку это был Angular 1, а система сервисов там была не strictly typed, a stringly typed — строкотипизированная, Angular совершенно спокойно начал подсовывать мой сервис в совершенно другие места и по иронии судьбы парочка методов в именовании совпала.
Это, естественно не было совпадением: вы же понимаете, что если два сервиса названы одинаково, с большой вероятностью они делают плюс-минус одинаковые вещи. Это был сервис, связанный с расчётом скидок. Только один модуль был занят обсчётом скидок для корпоративных клиентов, а второй модуль с моим названием был связан с расчётом скидок по акциям.
Очевидно, когда приложение пилят 80 человек, это значит, что оно большое. В приложении был реализован code splitting, и это означает, что последовательность подключения модулей напрямую зависело от путешествия пользователя по сайту. Чтобы было ещё интереснее, так сложилось, что ни один end-to-end тест, который тестировал поведение и проход пользователя по сайту, то есть определённый бизнес-сценарий, не поймал эту ошибку. Потому что вроде бы никому никогда не понадобится одновременно связываться с обоими модулями скидок. Правда, это полностью парализовало работу админов сайта, но с кем не бывает.
Проблема изоляции очень хорошо иллюстрируется логотипом одного из проектов, которые эту проблему частично решают. Это Lerna.
Lerna — это превосходный инструмент для управления несколькими npm-пакетами в репозитории. Когда у вас в руках молоток, все становится подозрительно похожим на гвоздь. Когда у вас есть unix-подобная система с правильной философией, всё становится подозрительно похоже на файл. Все знают, что в unix-системах всё есть файл. Есть системы, где это доведено до высшей степени (чуть не сказал «до абсурда»), вроде Plan 9.
Я знаю организации, которые, настрадавшись с обеспечением надёжности гигантского приложения, пришли к одной простой идее: всё есть пакет.
Когда вы выносите какой-то элемент функциональности, будь это компонент или ещё что-то, в отдельный пакет, вы автоматически обеспечиваете слой изоляции. Просто потому что вы не можете из одного пакета нормально дотянуться до другого. И ещё потому, что система работы с пакетами, которые собраны в монорепозитории через npm-link или Yarn Workspaces, устроена настолько ужасно и непредсказуемо с точки зрения того, как это организовано внутри, что вы даже не можете прибегнуть к хаку и подключить какой-нибудь файл через «node_modules что-то», просто потому, что у разных людей это всё собирается в разную структуру. Особенно это зависит напрямую от версии Yarn. Там в одной из версий втихаря полностью поменяли механизм, как Yarn Workspaces с организует работу с пакетами.
Вторым примером изоляции, чтобы показать, что проблема многогранна, является пакет, который я стараюсь сейчас повсеместно использовать, — это cls-hooked. Вам может быть известен другой пакет, который реализует то же самое, — это continuation-local-storage. Он решает очень важную проблему, с которой, к примеру, не сталкиваются, например, разработчики на PHP.
Речь об изоляции каждого конкретного запроса. В PHP у нас, в среднем по больнице, все запросы изолированы, взаимодействовать между ними, только если вы не используете какие-нибудь извращения типа Shared Memory, мы не можем, все хорошо, мирно, красиво. По сути, cls-hooked добавляет то же самое, позволяя создавать контексты выполнения, класть в них локальные переменные и потом, что самое важное, автоматически уничтожать эти контексты, чтобы они не продолжали поедать вашу память.
Этот cls-hooked построен на async_hooks, которые в node.js всё ещё в экспериментальном статусе, но я знаю не одну и не две компании, которые используют их в достаточно суровом продакшене и счастливы. Есть небольшое падение по производительности, но возможность автоматической сборки мусора бесценна.
Когда мы начинаем говорить о вопросах изоляции, о том, чтобы пихать разные вещи в разные node-модули.
Правило надёжности #2: плохой и «неправильный» код должен выглядеть неправильно
С первым критерием, который я для себя вывел, десять лет назад бы сам не согласился. Потому что пищал бы, что JavaScript — динамический язык, а вы лишаете меня всей красоты и выразительности языка. Это grep-тест.
Что это такое? Многие пишут в vim. Это означает, что если вы не используете какие-нибудь относительно новомодные навороты типа Language Server, единственный способ найти упоминание какого-нибудь атома — это, грубо говоря, grep. Конечно, есть вариации на тему, но общая идея понятна. Идея grep-теста в том, что вы должны найти все вызовы функций и их определение путём обычного grep. Вы скажете, все так делают, ничего странного.
Возьмем Sequelize. Это самая популярная ORM для реляционных баз данных. И возьмем совершенно простой код user.getProjects(). Как вы думаете, откуда появляется метод getProjects? Он появляется благодаря магии.
Каждый раз, когда мне надо описывать связи между таблицами в Sequelize, на меня накатывает депрессия, потому что я все время путаю hasMany, belongsToMany. Это не то что бы сложно, но я каждый раз чувствую, как мой
Я очень много говорю о code review, потому что мы можем автоматизировать что угодно, но мы никогда не можем предусмотреть всего. Последним оплотом в обеспечении надёжности всегда будет человек. Наша задача — максимально упростить работу этому человеку, чтобы ему пришлось думать о минимальном количестве вещей.
Я уже несколько раз повторял эту фразу, но она мне всё ещё нравится: «merge request на 20 строчек — 30 замечаний, merge request на 5000 строк — looks good to me». Это абсолютная правда, я сам такой.
У нас в чатике JavaScript Ninja есть человек, которого, похоже, взяли на позицию junior-разработчика, и против нашей воли делится с нами успехами своего рефакторинга. Вчера запостил скриншот «перевёл половину проекта на react и redux», там было 8000 строк добавлено, 10 000 удалено» Я с нетерпением предвкушаю, как его будут реьювить, с нетерпением жду «выхода нового сезона». При этом, по его словам, ему сказали «всё в порядке», и он искренне уверен, что этот merge request никак нельзя разбить на отдельные части.
Тем, кто считает, что такие merge request оправданы, и их действительно нельзя разбить на новые части, советую взять пример с ядра Linux. Это превосходный пример репозитория, где все работают не с коммитами, а с патч-сетами. То есть по почте вам присылают патчи, который вы должны накатить на свой git. Вы знаете, почта не лучшая среда для работы с патчами. Ревьювить большой код в почте неудобно, в частности, вам сложно оставить комментарий к конкретной строчке. В почте очень быстро начинает теряться контекст.
А в итоге пользователь вынужден разбивать свои патч-сеты на отдельные изолированные куски. И эти отдельные куски должны ревьювиться отдельно. В итоге даже какой-нибудь сложный рефакторинг начинается с того, что в начале мы вкатываем новую функциональность в самую основу, потом вкатываем какую-нибудь инфраструктуру поверх этой функциональности и только в конце начинаем подсистема за подсистемой переводить это на новую основу.
Я знаю, о чем говорю. Мой ноутбук Microsoft Surface почти идеально работает под Linux, но там не отображается статус батарейки. И я очень внимательно наблюдаю за тем, как люди реверсят протокол и как они постепенно готовят патчи для включения в основную ветку ядра, это очень сложно. И навык разбивать большой код на отдельные мелкие патч-сеты фундаментально важен для обеспечения надёжности.
Почему так происходит: магия
Магия бывает не только «запрещённой за пределами Хогвартса», но и «вредной и полезной». К примеру, большинство из вас вряд ли представляют всю магию реконсилера React. Потому что если мы говорим о шестнадцатом React, Fiber — это мегасложно.
По сути, разработчики Fiber полностью с нуля реализовали стековую машину для вызовов (которая была почти полностью содрана из OCaml) внутри JavaScript, чтобы иметь возможность асинхронно обрабатывать рендеры компонентов. Дальше они внедрили планировщик, который очень сильно напоминает код первых примитивных планировщиков операционных систем. Дальше они поняли, что планировщик — это очень сложно, поэтому они создали proposal, чтобы внедрить это прямо в JavaScript. Scheduler — это proposal stage 0.
Но тем не менее, магия React в том, как он делает что-то максимально быстро. То есть что он делает, понятно: мы отрендерили, он применяет виртуальный DOM. Но как он это делает быстро — это уже магия. Это полезная магия, потому что она управляема.
Приведу пример другого фреймворка — Vue.js. Кому из вас нравится Vue? Я рад, что вас становится меньше. Я долго восхищался Vue, теперь период большого разочарования, сейчас объясню, почему.
Абстрактный проект на Vue работает гораздо быстрее абстрактного проекта на React. Проект, написанный джуниором на Vue, абсолютно точно работает гораздо быстрее проекта, написанного джуниором на React.
Дело в магии реактивности. Vue способен отслеживать, какие элементы state, причем неважно, где этот state был размещен, зависят от каких компонентов и каждый раз перерендивает только нужные компоненты. Очень крутая идея, которая позволяет вам не думать о производительности. Vue меня в свое время этим покорила.
Вторая классная идея. Наверняка многие из вас, если не все, знакомы с философией Web Components и c философией трансклюзии, когда у нас есть слоты, и контент из дочернего объекта вставляется в дыру в другом компоненте. Простейший пример применения слота: допустим, у нас есть pop-up, который содержит оверлеи, крестик и так далее, и нам надо передавать туда какой-то контент. Просто передаём кусок — здраво.
Во Vue есть слоты на стероидах — scoped slots. Они нужны, когда нам надо в слот передать еще какие-то данные. Простейший пример применения scoped slot — это когда есть таблица, и вы хотите кастомизировать каждую строку. То есть вам нужен кастомный рендер для каждой строки. В React умные хипстеры сейчас бы использовать для этого Render Proper: красиво, декларативно. Во Vue вы просто вставляете кусок шаблона и не задумываетесь, что на самом деле это тоже компилируется в рендер-функцию.
Как только у вас в компоненте появляются рендер-слоты, ваша хвалёная система реактивности Vue превращается в тыкву. Прям в исходниках Vue написано: проверить, если у child-компонента, который мы обновляем, есть scoped slots, сделать forceUpdate. Как только родитель обновляется, child тоже перерисовывается. Это становится катастрофой для производительности в отдельных ситуациях.
В React со всей его магией мы можем этой магией управлять. У нас есть, допустим, shouldComponentUpdate(), который запретит компоненту перерисовываться. С Vue нам остаётся долгий пристальный взгляд, чтобы на это посмотреть, у нас нет механизма, например, чтобы запретить компоненту перрерисоваться. Приходится изобретать совершенно феноменальные костыли для этого. Это поправлено в третьем Vue. Это отдельная история, когда-нибудь он точно выйдет.
Последний пункт — это Jest. Превосходный фреймворк тестирования от Facebook. Я абсолютно честно считаю его на данный момент лидирующим фреймворком в мире тестирования JavaScript: красивый, выразительный, эффективный. Он прекрасно умеет мокать импорты. Это работает превосходно до тех пор, пока это работает.
Проблема в том, что импорты по определению по спецификации к ECMAScript 2015 являются статическими. Вы не можете подменять реализацию импортов, вы не можете объявить импорт в if, в зависимости от require. Require вы можете применить, а импорт нет. Импорты, в принципе, не могут быть подменяемы, они должны быть вычислены ещё до процесса вычисления компонента. Jest компилирует с помощью Babel импорты в Require и дальше их подменяет.
В один прекрасный день вы решаете побыть совсем хипстером и воспользоваться NGS-модулями в Node, которые являются proposal, но совсем скоро станут стандартом. Потому что это противоестественно иметь в мире JavaScript две системы управления модулями: одну с Require, вторую с импортами. Хочется везде писать импорты. И тут вы обнаруживаете, что Jest c NGS-модулями бессилен, потому что нодовцы решили строго следовать спецификации, и подменять импорты нельзя в принципе. То есть результат вычисления импорта является замороженным объектом, который разморозить нельзя.
В итоге, после боли и страданий вы приходите к тому, к чему пришли другие языки программирования много-много лет назад. Вы понимаете, что вам нужен паттерн Inversion of Control и желательно какой-нибудь Dependency Injection-контейнер.
IoC/DI
Причём, как вы понимаете, эта философия уже давно есть на фронтенде. Angular целиком построен вокруг философии IoC/DI. В React сам господин Абрамов… кстати, знаете, почему React круче Vue? Потому что сайт dan.church зареган, а сайта evan.church пока нет.
Так вот, Абрамов говорил, что контекст в React является по сути реализацией паттерна Dependency Injection, позволяет вам добавлять в контекст какие-то зависимости и вытягивать их на уровни ниже. В самом Vue даже слова называются inject и provide. То есть на фронте этот паттерн уже давно присутствует в неявном виде.
Что касается бэкенда, у нас тоже начинают появляться вещи, которые реализуют это. К примеру, NestJS. Я использую InversifyJS. Он построен с любовью к TypeScript, что, впрочем, не мешает ему работать с использованием обычного JavaScript. И тут мы упираемся в то, что наша экосистема все еще недостаточно взрослая.
Код на Typescript, взят из документации Inversify. Посмотрим на конструктор кода, в котором мы говорим: inject(TYPES.Weapon) katana: Weapon. Как вы думаете, на каком этапе будет проверяться, что объект типа TYPES.Weapon удовлетворяет классу Weapon? Ответ — никогда.
Грубо говоря, если вы опечатаетесь в том, что вы вставляете в объект, хвалёный TypeScript (хваленый Flow поведет себя точно так же) не сможет никак это проверить, потому что процедура inject и весь dependency injection работает в runtime, а там информации о типах почти нет.
Что значит «почти нет»? Если вы будете использовать Weapon не как интерфейс, а как абстрактный класс, TypeScript сможет это проверить, потому что у нас есть абстрактные классы. Абстрактные классы в TypeScript компилируются в классы, а классы являются first-class citizen в JavaScript, они остаются после компиляции. А вот интерфейсы являются эфемерными сущностями, которые испаряются, и в runtime у вас уже не будет никакой информации о том, что элемент katana должен удовлетворять интерфейсу Weapon. И это проблема.
В каком-нибудь C++, который существует больше лет, чем я живу, существует RTTI: run-time type information, позволяющий во время выполнения получать информацию о том, как это работает. В C# и Java есть reflection, тоже позволяющий получать необходимую информацию. Разработчики TypeScript, у которых есть отдельный раздел «что мы НЕ будем реализовывать», сказали, что не намерены предоставлять никакие инструменты для RTTI.
Доходит до смешного. Пятнадцать секунд инсайдов. Разработчики Vue переписывают Vue 3 на TypeScript, и обнаружили, что подход Vue несовместим с философией TypeScript настолько, что они попросили Microsoft: «А можно, мы напишем свой плагин к TypeScript, чтобы это работало?» Те, кто пишет на Vue, поймут: когда вы в компонент передаёте props, они магически появляются на this. Очевидно, что тип передаваемых props и тип объектов, появляющихся на this, должен быть одинаковым. Но в TypeScript вы не можете это ни проверить, ни описать. Сюрприз.
Разработчики Microsoft отказали, а то знаем мы вас: сейчас разрешим Vue это сделать, потом потянется Angular, у которых очень давно болит от этого. Им же свой компилятор пришлось дописывать, чтобы нормально обойти ограничения TypeScript. Потом подтянется React, и у нас получится не система типизации, а непонятно что.
Но тем не менее, проблема все еще остается. Как ни странно, мне в этом плане импонирует Dart, который имеет mirrors, работающие везде, кроме Flutter. Даже если не доступны mirrors, есть инструменты, с которыми можно через кодогенерацию на этапе компиляции вытащить всю необходимую информацию, сохранить её и всё ещё получить получить доступ в рантайме.
Такое же решение я сейчас пилю для Inversify, чтобы использовать Babel-плагин, который в процессе компиляции, когда ещё есть типы, вытягивает информацию, генерит определенные runtime-проверки, чтобы гарантировать, что всё хорошо работает.
Последняя ремарка о незрелости экосистемы: вообще с типами у нас всё очень плохо и странно. Вот у нас типизированный код. Он содержит информацию, с какими параметрами будет вызываться функция, с какими аргументами, какие типы возвращаемых значений. И всё это мы выкидываем для того, чтобы V8 после запуска начал делать то же самое.
V8 начинает собирать информацию о типах, потому что она фундаментально важна для работы оптимизирующего компилятора. Более того, есть определённые инструменты, которые позволяют вытащить из V8, если собрать его с определёнными патчами, информацию о типах, которую он собрал. Очень забавно сравнивать выводы в типах, которые получились в V8 и выводы, которые были у вас в TypeScript.
Но с точки зрения любого нормального языка программирования это дико. Мы выкидываем информацию, которая у нас есть, чтобы потом героически её собирать.
Правило надежности #3: должно быть проще писать «правильный» код, чем «неправильный»
Когда я говорю о словах «правильный» или «неправильный» код, в данном контексте, «правильный» — это код, который устраивает команду. В каждой команде есть определённые конвенции и соглашения, особенно это важно во фреймворках типа Vue, где одну задачу можно решить множеством путей. Кто-то использует слоты, кто-то передаёт props’ами функцию и так далее.
Здесь всеми красками сияет кодогенерация. Кто использует typed-css-modules? О чём идёт речь: вы пишете CSS, потом подключаете его в CSS Modules, а потом обращаетесь к конкретным полям полученного объекта.
Существует готовое решение typed-css-modules, которое с помощью кодогенерации позволит проверить, что вы обращаетесь классом css-файла, который там точно есть.
Я за последний год участвовал в 12 проектах в качестве консультанта, которые использовали CSS-модули и существовали по крайней мере, полгода. И в 11 из 12 я нашел на сгенерированной странице класс undefined, потому что CSS-стили развивались, изменялись и так далее, а в итоге радостно подключается из объекта стилей класс, которого не существует, и получается undefined, бывает.
Yeoman хорошо известен всем, но проблема его и всего подобного инструментария в том, что попытка построить слишком общий инструмент для кодогенерации приводят к тому, что он почти бесполезен в частных случаях. Чтобы применить его для конкретного проекта, приходится очень много думать, страдать и пытаться. Поэтому мне гораздо больше импонирует инициатива Angular CLI, которая называет Blueprints (чёрт, я два года хаял Angular, а через два дня на GDG SPB буду рассказывать, какой он классный, и как я его полюбил).
Эта инициатива позволяет вам эффективно в контексте Anguar описать для того, как должны выглядеть ваши сервисы, компоненты и прочее. Всё это целиком позволяет эффективно построить работу команды с минимальным количеством усилий со стороны тимлида на старте проекта.
Ещё один пример, когда «правильный» код проще писать, чем «неправильный» — это хорошие, большие фреймворки. Вообще эталонный пример, когда «правильный» код писать проще — функциональное программирование. Как только вы познали дао функционального программирования, поняли, что такое монада, попутно лишившись возможности объяснить это остальным, вы понимаете, что в функциональном стиле вы или пишете правильный код, или он у вас не получается вовсе.
Другое дело, что там порог входа — проще повеситься. У меня в команде есть junior’ы, и они важны мне для того, чтобы не терять отрыв от реальности, понимать, как вообще в реальном мире люди пишут код. Поэтому, к примеру, React-хуки не стали брать концепцию чистого функционального программирования.
Они долго и мучительно искали компромисс, который, с одной стороны был бы близок к функциональному программированию, и мы могли бы использовать какой-нибудь ESLInt, чтобы кое-что допроверить, а с другой стороны, которые были бы понятны большинству простых смертных.
Когда фреймворк навязывает вам, как делать задачи, это очень круто. Когда мы говорим о бэкенде, примерами таких фреймворков являются NestJS и Sails.js.
Казалось бы, и тот и другой фреймворк, решают одну и ту же задачу, они навязывают конкретную архитектуру, и описывают, как вы должны решать то-то и то-то. Я не буду сейчас критиковать Sails за совершенно убогую ORM, которая идет в комплекте. Waterline — это первая штука, которую все выбрасывают. Но проблема в том, что при всем этом Sails.js обеспечивает слишком много магии. Вы можете делать blueprint контроллеров, из которых потом генерируется куча магических свойств и вещей. Это не просто работает медленно, а архимедленно, причем это дико сложно отлаживать другим.
А вот если мы говорим о Nest, то здесь отличный компромисс — фреймворк просто дает компоненты, каждый из которых необязательно использовать. У вас есть Dependency Injection, который лежит в основе всего, и есть правило описания контроллеров middleweight и так далее.
Но при случае каждый элемент является легко заменяемым. Каждый раз, когда вы хотите понять, является ли система достаточно безопасной, для того, чтобы играться с ней вдолгую, задайте себе один простой вопрос: «Смогу ли я заменить какой-то компонент системы в случае надобности?»
Если мы говорим об Angular, то — да, у нас есть огромное количество хорошо подогнанных компонентов друг к другу, но если мы не берем механизм dependency injection, который является фундаментом, всё абсолютно взаимозаменяемо.
Если мы берём какой-нибудь Vue (извините, сегодня получается лёгкий хейт в его сторону), то обнаружим, что за пределами экосистемы мейнстрима Vue начинается выжженная земля. Никто не пилит ничего за пределами экосистемы Vue, поэтому если вы будете выбирать что-то из этих двух вещей, вывод достаточно очевиден — берите Nest. Он крутой, мне очень нравится, куда он развивается, несмотря на то, что TypeScript.
Почему так: общее
Парочка общих актуальных вещей, которые актуальны не только для JavaScript. Если вы пришли в наш мир из другого, нормального языка программирования, вам всё это очевидно, но пробежимся, потому что об этом не стоит забывать, а на фундаментальном уровне постоянно забывают.
Первое — кроме тестирования, о котором я специально почти ничего не сказал, потому что все понимают, что нужно писать тесты, и все понимают, что делать это некогда: хренак-хренак и в продакшн. Когда у вас есть хоть сколько-нибудь серьёзный продакшн, связанный с потерей денег — нужно мерить метрики.
Мы используем Grafana, мне очень нравится, но что вы выберете — абсолютно неважно. Я был свидетелем, когда платформа для создания чат-ботов не учла один интересный сценарий, который позволял зациклить бота. Поскольку каждая его фраза обращалась к speech recognition API от Microsoft, к тому моменту, когда обнаружили ошибку, счёт составлял порядка 20 тысяч долларов. Бот пытался распознать фразу, распознавал, уходил в ошибку, начинал заново пытаться сделать что-то.
Следующий важный пункт: CI/CD. Нам нравится GitLab, но это дело вкуса, у каждого свои впечатления. Но сейчас GitLab, по моим ощущениям, это наиболее JavaScript-friendly environment с точки зрения понимания, как это работает, что с этим делать и так далее.
В первых рядах начали смотреть часы. Знаете, при задержавшихся гостях взгляд будет гораздо красноречивее, если перевести его с часов на висящее рядом ружьё… Не переживайте, осталось чуть-чуть.
Blue/Screen deployment: Если у вас хоть сколько-нибудь важный сервис или микросервис, задумайтесь о том, чтобы держать в облаке два инстанса. Если эти слова вам незнакомы, загуглите, потом сможете продать себя подороже!
Что мы имеем в итоге
- Экосистема JavaScript очень юная, и это открывает очень большие возможности тем, кто хочет круто хайпануть. Вы берете любой другой язык программирования, ищете, как та или иная проблема решена в нём, перекладываете решение на JavaScript и гребёте звездочки на GitHub. Рецепт рабочий — я так сам, конечно, не делал, но тем не менее.
- Обеспечение надёжности JavaScript упирается в то, что разработчики не хотят переучиваться (особенно актуально для аутсорсинговых компаний, где мотивация разработчиков ниже продуктовых). Есть отличная фраза Андрея Листочкина «Хорошо не жили, нечего и начинать». Необходимость построения надёжного кода требует полного изменения подхода к тому, как этот код пишется. Одно внедрение DI требует кардинального изменения в философии, вида «Где же мои уютненькие импорты?»
- Язык и низлежащая экосистема не готовы полностью к написанию надёжного кода, что бы вы не выбрали. Выберите TypeScript, Flow, даже если выберете Rizen — вы столкнётесь с тем, что рано или поздно настигнет runtime exception, который вы не сумеете обработать, и так далее.
Чтобы этого не происходило, ваша задача — максимально облегчить работу ревьювера на пулл-реквестах, потому что последним звеном в обеспечении надёжности всегда будет оставаться человек.
Если вам понравился этот доклад с HolyJS, обратите внимание: в следующий раз HolyJS состоится 24-25 мая в Петербурге. Среди спикеров — например, Мишель Вестстрате (создатель MobX) и Алексей Козятинский (работает в команде Chrome DevTools). Вся актуальная информация и билеты — на сайте.
Автор: Евгений Трифонов