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

Блог компании яндекс

Как и зачем проходить сертификацию AICPA SOC 2 и 3. Опыт Яндекс.Паспорта

13.01.2021 12:17:24 | Автор: admin
Привет! На связи Аня Зинчук. Я работаю в Службе информационной безопасности. Мы сопровождаем ключевые сервисы Яндекса на всех этапах жизненного цикла от дизайна и проектирования до реализации в коде: анализируем архитектуру новых решений, ищем потенциальные риски, проводим анализ кода на уязвимости и расследуем инциденты, если они возникают.

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

Что такое AICPA SOC 2 и 3?


Стандарт Service and Organization Controls 2 разработан Американским институтом дипломированных общественных бухгалтеров (American Institute of Certified Public Accountants) с использованием критериев надежности Trust Services Criteria. SOC 2 даёт независимую оценку контрольных процедур по управлению рисками кибербезопасности в ИТ-компаниях, предоставляющих сервисы пользователям.

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

Международные стандарты безопасности можно разделить на две группы. Первая обязательные, например, PCI DSS стандарт в области платёжных карт. У компании, имеющей сервисы, через которые проходят карты, нет опции не соответствовать. И есть стандарты, которые никто не навязывает здесь сертификация добровольна. AICPA SOC 2 относится к этой категории и при этом является одним из самых уважаемых стандартов в мире. Он предъявляет строгие требования к процессам, отражённые в критериях надежности Trust Services Criteria, и включает публичную форму отчёта SOC 3, из которого пользователи и клиенты могут узнать, как мы заботимся об их данных.

Сертификация ISO vs. AICPA SOC


Если сравнивать AICPA SOC с другими добровольными сертификациями, на ум приходит популярный стандарт ISO, который выбирают многие крупные компании. У нас тоже есть такой опыт: Яндекс.Метрика и Облако получали сертификаты ISO-27001 (системы управления и менеджмент информационной безопасности). Существует целый рынок консультантов, которые готовят к сертификации по ISO и помогают с проработкой требований.

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

Мы готовились к сертификации сами, не имея предыдущего опыта и без права на ошибку. Если на аудите выясняется, что что-то не работает по стандарту, отчёт SOC 3 не выдаётся. А мелкие шероховатости, например, один незашифрованный ноутбук из 100, тут же попадают в SOC 2 этого мы не могли себе позволить. Перепройти аудит на следующий день после устранения проблемы тоже не получится процессы должны работать безупречно в течение срока, который определяется заранее. В нашем случае полгода.

Когда аудитор ISO приходит в компанию в первый раз, он смотрит, как процессы работают в моменте. Есть ретроспектива, но она не очень подробная и её продолжительность не прописана в документах. Сертификат выдаётся на три года, каждый год приходит надзорный аудит и проверяет, что всё работает по стандарту. SOC 2 более тщательно смотрит в прошлое. Компания выбирает период: обычно это полгода в первый раз и год для повторных проверок. Аудиторы анализируют, что процессы работали в соответствии с заявленным описанием и требованиями Trust Services Criteria на протяжении всего этого времени.

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

Для чего аудит нужен Яндекс.Паспорту?


Сервисы Яндекса используют единую аутентификацию через Паспорт. Это наш ключевой сервис с точки зрения безопасности данных пользователей. Поэтому первый аудит по критериям AICPA решили провести именно для него. В дальнейшем, когда мы позовём аудиторов оценить другие сервисы, большинство вопросов, связанных с безопасностью, будет закрываться результатами AICPA SOC 2 Паспорта. Очень удобно пройти проверку один раз и потом отдавать результаты по запросу других команд. Аудит проходили все компоненты Паспорта: сайт для пользователей, API для подключения сервисов Яндекса, база данных учётных записей MySQL, интерфейс техподдержки веб-сервис для сотрудников, помогающих пользователям, и Blackbox внутренняя служба с эксклюзивным доступом к чтению БД с пользовательскими данными и журнала изменений. Каждый процесс, требующий чтения из этих баз (включая запросы от внутреннего API Паспорта) проходит через Blackbox.

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

Тем не менее, получить оценку усилий команд СБ и сервиса извне, от независимой третьей стороны, было важно. Отчёты AICPA SOC 2 и SOC 3 это не только документальное подтверждение правильных практик независимой стороной, но и способ сопоставить собственные подходы к безопасности, которые развиваются внутри компании, с лучшими мировыми практиками. Поэтому основной задачей для нас стала подготовка внутренних метрик и процессов к внешней оценке. В дальнейших планах распространить этот опыт на другие сервисы.

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

Что изучают аудиторы?


Отчёты SOC 2 и SOC 3 строятся на критериях надежности сервисов Trust Services Criteria, принятых AICPA: безопасность, конфиденциальность, доступность, приватность и целостность обрабатываемых данных. Мы изучили, на какие критерии сертифицируются большие корпорации, такие как Google, Amazon, Microsoft, и сделали упор на конфиденциальность и доступность, как и другие лидеры ИТ-рынка. Безопасность базовый критерий, без него не получится. Доступность (информация и системы доступны для эксплуатации и использования в соответствии с целями организации. Доступность означает доступность информации, используемой системами организации, а также продуктов или услуг, предоставляемых ее клиентам) потому что Паспорт является ядром для остальных сервисов.

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

Мы начали готовиться к сертификации летом 2018 года. Выбрали компанию из большой четвёрки, с которой уже сотрудничали, когда сертифицировались по ISO PwC. Аудиту предшествовал gap-анализ (классика подхода к аудитам) предварительная оценка готовности к аттестации SOC 2, занявшая полтора месяца. Затем был период проработки требований, когда мы внедряли изменения и привыкали с ними жить, а ещё через полгода сам аудит, который продолжался два месяца. Мы шли по стандартному пути, который хорошо себя зарекомендовал.

Gap-аудит


Для начала нам предстояло оценить процессы вокруг Паспорта as is и понять, ложатся ли они на требования AICPA SOC. Первый шаг был сложным. Существовала ненулевая вероятность, что аудиторы скажут: Ребята, у вас много несоответствий, так делать нельзя, нужно по-другому. А по-другому сделать не получится, потому что глобальные изменения будут негативно влиять на разработку сервисов. Например, требования стандарта могут быть настолько жёсткими, что Службе ИБ придётся проводить ревью для каждого коммита. Для нас это было бы нереально работа растянется на часы и дни и часы.

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

Уникальные решения Яндекса vs. рамки стандарта


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

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

Ещё в датацентрах Яндекса нет кондиционеров, вместо них фрикулинг. В своё время мы решили доработать стойки так, чтобы они могли штатно работать при температуре охлаждающего воздуха сначала 35, а в следующих поколениях 40 градусов Цельсия. Это позволило отказаться ото всех компонентов охлаждения. Система вентиляции в ЦОД в Яндексе состоит из приточных и вытяжных вентиляторов, фильтров, клапанов и шкафа управления. И всё. Результат система охлаждения с максимально возможной эффективностью и максимальной надёжностью работы за минимальные деньги.

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

Что касается точки росы, влага выпадает на поверхности холодного предмета, когда он помещается в тёплый влажный воздух. Если проследить путь воздуха с улицы через конструкции воздуховодов, к серверам и снова наружу таких мест в ЦОД нет. Мы делаем гидроизоляцию пространств с возможностью выделения влаги, но на самом деле в датацентрах и так всегда сухо. Работа нашей системы, несмотря на всю её оригинальность, не вызвала больших вопросов у аудиторов: мы рассказали, как всё устроено, показали фрикулинг вживую, собрали тикеты на годовое обслуживание вентиляционных установок (47 элементов) и продемонстрировали работу мониторинговой системы.

Документы и отчёты (и на бумаге тоже!)


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

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

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

Результаты


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

  • SOC 3 отчёт краткая версия, которую мы можем показывать людям за пределами компании.
  • SOC 2 отчёт полный, раскрывает детали процессов, может передаваться вовне только под NDA в случае необходимости.

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

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

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

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

Шпаргалка по функциональному программированию

19.03.2021 10:13:30 | Автор: admin

Привет, меня зовут Григорий Бизюкин, я преподаватель Школы разработки интерфейсов и фронтенд-разработчик в Яндексе. Давайте поговорим о функциональном программировании в мире JavaScript. Мы все про ФП что-то слышали, нам всем оно интересно, но у меня, когда я искал полезные материалы для подготовки к лекциям, сложилось такое впечатление: есть куча статей, каждая из которых либо говорит об ФП общими словами, либо раскрывает отдельный маленький кусочек темы, чего, конечно, недостаточно.



Добавим функционального света


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


Оглавление

Функциональное программирование


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


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


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


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


За и против


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


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


Императивный vs декларативный


Императивный подход говорит о том, как решать задачу, декларативный что хотим получить в результате.


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


В разработке та же история. Когда мы пишем декларативно, код выглядит гораздо проще:


const array = [4, 8, 15, null, 23, undefined]// императивный подходconst imperative = []for (let i = 0, len = array.length; i < len; ++i) {    if (array[i]) {        imperative.push(array[i])    }}// декларативный подходconst declarative = array.filter(Boolean)

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


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


/* css */.button {    color: azure;}

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


Такая же история и с SQL:


-- SQLSELECT titleFROM filmsWHERE rating > 9GROUP BY director

Запрос говорит о результате, а не о том, как именно его выполнить.


Функции и процедуры


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


const f = (x) => x * Math.sin(1 / x)

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


const print = (...args) => {    const style = 'color: orange;'    console.log('%c' + args.join('\n'), style)}

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


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


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


Параметры и аргументы


Параметры это переменные, созданные в объявлении функции. Аргументы конкретные значения, переданные при вызове.


// x  параметр (почти любое число)const f = (x) => x * Math.sin(1 / x)// 0.17  аргумент (конкретное число)f(0.17)

Сигнатура


Количество, тип и порядок параметров. Объявление функции в JS не содержит информации о типе параметров из-за динамической типизации. Если не используется TypeScript, эту информацию можно указать через JSDoc.


/** * @param {*} value * @param {Function|Array<string>|null} [replacer] * @param {number|string|null} [space] * @returns {string} */function toJSON (value, replacer, space) {    return JSON.stringify(value, replacer, space)}

Арность


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


const awesome = (good, better, theBest) => {}awesome.length // 3

У свойства length есть особенности, которые следует учитывать:


// аргументы по умолчаниюconst defaultParams = (answer = 42) => {}defaultParams.length // 0// остаточные параметрыconst restParams = (...args) => {}restParams.length // 0// деструктуризацияconst destructuring = ({target}) => {}destructuring.length // 1

Рекурсия


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


function factorial (n) {    if (n <= 1) {        return 1    }    return n * factorial(n - 1)}

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


function factorial (n, total = 1) {    if (n <= 1) {        return total    }    return factorial(n - 1, n * total)}

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


Функция первого класса


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


// присваиватьconst assign = () => {}// передаватьconst passFn = (fn) => fn()// возвращатьconst returnFn = () => () => {}

Функция высшего порядка


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


// map, filter, reduce и т.д.[0, NaN, Infinity].filter(Boolean)// обещанияnew Promise((res) => setTimeout(res, 300))// обработчики событийdocument.addEventListener('keydown', ({code, key}) => {    console.log(code, key)})

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


Предикат


Это функция, которая возвращает логическое значение. Самый распространённый пример использование предиката внутри функций filter, some, every.


const array = [4, 8, 15, 16, 23, 42]// isEven  это предикатconst isEven = (x) => x % 2 === 0const even = array.filter(isEven)

Замыкание


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


const createCounter = tag => count => ({    inc () { ++count },    dec () { --count },    val () {        console.log(`${tag}: ${count}`)    }})const pomoCounter = createCounter('pomo')const work = pomoCounter(0)work.inc()work.val() // pomo: 1const rest = pomoCounter(4)rest.dec()rest.val() // pomo: 3

В примере внутри замыкания хранятся две переменные: tag и count. Каждый раз, когда мы создаём новую переменную внутри другой функции и возвращаем её наружу, функция находит переменную, объявленную во внешней функции, через замыкание. Если тема замыканий кажется чем-то загадочным, почитайте о них подробнее в блоге HTML Academy.


Мемоизация


Полезный приём функция кеширует результаты своего вызова:


const memo = (fn, cache = new Map) => param => {    if (!cache.has(param)) {        cache.set(param, fn(param))    }    return cache.get(param)}const f = memo((x) => x * Math.sin(1 / x))f(0.314) // вычислитьf(0.314) // взять из кеша

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


Конвейер и композиция


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


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


Конвейер


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


# вывести идентификаторы процессов с подстрокой kernelps aux | grep 'kernel' | awk '{ print $2 }'

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


const double = (n) => n * 2const increment = (n) => n + 1// без конвейерного оператораdouble(increment(double(double(5)))) // 42// с конвейерным оператором5 |> double |> double |> increment |> double // 42

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


pipe(double, double, increment, double)(5) // 42

Аргумент, переданный в конвейер, последовательно проходит слева направо:


// 5 -> 10 -> 20 -> 21 -> 42

Хм, а что если запустить конвейер в другую сторону?


Композиция


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


// композиция функций в чистом видеdouble(increment(double(double(5)))) // 42

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


compose(double, increment, double, double)(5)

Внешне всё осталось почти так же, но место вызова функции increment изменилось, потому что теперь цепочка вычислений стала работать справа налево:


// 42 <- 21 <- 20 <- 10 <- 5

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


// оригинальная цепочка вызововone(two(three(x)))// более естественно с точки зрения чтенияpipe(three, two, one)(x)// более естественно с точки зрения записиcompose(one, two, three)(x)

Таким образом, конвейер и композиция это два направления одного потока данных.


Преимущества


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


Создание новых абстракций


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


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


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


// готовые кубикиconst words = str => str    .toLowerCase().match(/[а-яё]+/g)const unique = iter => [...new Set(iter)]const text = `Съешь ещё этих мягкихфранцузских булок, да выпей же чаю`const foundWords = words(text)const uniqueWords = unique(wordsFound)

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


function getUniqueWords (text) {    return unique(words(text))}const uniqueWords = getUniqueWords(text)

Сила композиции в том, что с её помощью можно создавать новые абстракции гораздо проще и удобнее:


// создаём новую деталь через композициюconst getUniqueWords = compose(unique, words)const uniqueWords = getUniqueWords(text)

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


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


const sort = iter => [...iter].sort()// новая деталь, которая пригодится для новых построекconst getSortedUniqueWords = compose(sort, getUniqueWords)const sortedUniqueWords = getSortedUniqueWords(text)

Если речь идёт о конструировании сложных деталей, вложенную композицию можно заменить на линейную:


// вложенная композицияcompose(sort, compose(unique, words))// линейная композицияcompose(sort, unique, words)

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


Бесточечный стиль


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


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


// стиль с параметрамиfunction getUniqueWords (text) {    return unique(words(text))}// стиль без параметров (бесточечный стиль)const getUniqueWords = compose(unique, words)

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


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


Ограничения


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


const translate => (lang, text) => magicSyncApi(lang, text)const getTranslatedWords = compose(translate, unique, words)getTranslatedWords(text) // упс... что-то сломалось

Здесь на помощь приходит частичное примирение и каррирование, о которых мы поговорим позже.


Пишем сами


Реализовать конвейер можно было бы так:


const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x)

Чтобы реализовать композицию, достаточно заменить reduce на reduceRight:


const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x)

Как на практике?


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


На проекте с Redux композиция наверняка будет использоваться для middleware, потому что createStore принимает только один усилитель (enhancer), а их, как правило, требуется хотя бы несколько.


// композиция в reduxconst store = createStore(    reducer,    compose(        applyMiddleware(...middleware),        DevTools.instrument(),    ))

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


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


const notifications = [    { text: 'Warning!', lang: 'en', closed: true },    { text: 'Внимание!', lang: 'ru', closed: false },    { text: 'Attention!', lang: 'en', closed: false }]// goodnotifications.filter((notification) => {    // ...проверить все условия})// betternotifications    .filter(isOpen)    .filter(isLang)// the bestcompose(    isLang,    isOpen)(notifications)

Частичное применение и каррирование


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


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


const sum = (x, y, z) =>    console.log(x + y + z)

Частичное применение


Преобразует функцию в одну функцию с меньшим числом параметров.


const partialSum = partial(sum, 8)partialSum(13, 21) // 42

Каррирование


Преобразует функцию в набор функций с единственным параметром.


const curriedSum = curry(sum)curriedSum(8)(13)(21) // 42

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


curriedSum(8, 13)(21) // 42curriedSum(8, 13, 21) // 42

В чём разница?


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


const partialSum = partial(sum, 42)partialSum() // NaN, потому что 42 + undefined + undefined

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


const curriedSum = curry(sum)curriedSum(8) // новая функция  sum(8)curriedSum(8)(13) // ещё одна новая функция  sum(8, 13)curriedSum(8)(13)(21) // 42, потому что набралось нужное число аргументов

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


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


Решение задачи с композицией


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


const translate => (lang, text) => magicSyncApi(lang, text)// через частичное применениеconst english = partial(translate, 'en')// через каррированиеconst english = curry(translate)('en')// создать новую деталь с возможностью переводаconst getTranslatedWords = compose(english, unique, words)getTranslatedWords(text) // теперь всё работает

Порядок данных


Частичное применение и каррирование чувствительны к порядку данных. Существует два подхода к порядку объявления параметров.


// сперва итерация, затем данные (iterate-first data-last)const translate => (lang, text) => /* */// сперва данные, затем итерация (data-first, iterate-last)const translate => (text, lang) => /* */

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


function flip (fn) {    return (...args) => fn(...args.reverse())}const curryRight = compose(curry, flip)const partialRight = compose(partial, flip)

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


Специализация


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


const fetchApi = (baseUrl, path) =>    fetch(`${baseUrl}${path}`)        .then(res => res.json())

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


// каррированиеconst fetchCurry = curry(fetchApi)const fetchUnsplash = fetchCurry('https://api.unsplash.com')const fetchRandomPhoto = fetchUnsplash(fetchApi, '/photos/random')// частичное применениеconst fetchUnsplash = partial(fetchApi, 'https://api.unsplash.com')const fetchRandomPhoto = partial(fetchUnsplash, '/photos/random')

Пишем сами


Свою версию частичного применения можно написать примерно так:


function partial (fn, ...apply) {    return (...args) => fn(...apply, ...args)}

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


function curry (fn) {    return (...args) => args.length >= fn.length ?        fn(...args) : curry(fn.bind(null, ...args))}

Как на практике?


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


А ещё в JavaScript у функций есть метод .bind, который реализует частичное применение из коробки, поэтому, если порядок параметров позволяет, то вуаля:


const fetchApi = (baseUrl, endpoint) =>    fetch(`${baseUrl}${endpoint}`)        .then(res => res.json())const fetchUnsplash = fetchApi.bind(null, 'https://api.unsplash.com')const fetchRandomPhoto = fetchUnsplash.bind(null, '/photos/random')

Неизменяемые данные


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


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


// mutable glassconst takeGlass = (volume) => ({    look () { console.log(volume) },    drink (amount) {        volume = Math.max(volume - amount, 0)        return this    }})const mutable = takeGlass(100)mutable.drink(20).drink(30).look() // 50mutable.look() // 50

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


// immutable glassconst takeGlass = (volume) => ({    look () { console.log(volume) },    drink (amount) {        return takeGlass(Math.max(volume - amount, 0))    }})const immutable = takeGlass(100)immutable.drink(20).drink(30).look() // 50immutable.look() // 100

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


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


Нечаянное мутирование данных


В JavaScript запросто можно нечаянно мутировать массив или любой другой объект:


function sortArray (array) {    return array.sort()}const fruits = ['orange', 'pineapple', 'apple']const sorted = sortArray(fruits)// упс... исходный массив тоже изменилсяconsole.log(fruits) // ['apple', 'orange', 'pineapple']console.log(sorted) // ['apple', 'orange', 'pineapple']

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


const object = {}// const означает константную ссылкуobject = {} // TypeError: Assignment to constant variable// но сам объект можно беспрепятственно изменятьobject.value = 42 // мутация объекта

Все ссылочные типы: объекты, массивы и другие всегда передаются по ссылке. Во время присваивания или передачи параметра происходит копирование ссылки, но не самих данных.


const array = []// копия ссылкиconst ref = arrayref.push('apple')// ещё одна копия ссылкиconst append = (ref) => {   ref.push('orange')}append(array)// массив дважды мутирован через ссылкуconsole.log(array) // [ 'apple', 'orange' ]

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


const object = { val: 42, ref: {} }const frozen = Object.freeze(object)// игнорирование ошибки без 'use strict'// или же TypeError: Cannot assign to read only property...frozen.val = 23// мутирование вложенных данных по ссылкеfrozen.ref.boom = 'woops'console.log(frozen) // { val: 42, ref: { boom: 'woops' }

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


const object = { val: 42, ref: {} }const proxy = new Proxy(object, {    set () { return true },    deleteProperty () { return true }})// изменение или удаление свойства не сработаетproxy.val = 19delete proxy.val// точно так же, как и добавление новогоproxy.newProp = 23// но вложенные объекты всё ещё мутабельныproxy.object.boom = 'woops'console.log(proxy) // { value: 42, ref: { boom: 'woops' } }

В общем, в JavaScript нельзя просто так взять и защитить данные от непреднамеренного изменения.


Затраты на копирование


С копированием данных тоже не всё так просто. В большинстве случаев работает копирование массивов и объектов встроенными средствами JavaScript:


const array = [4, 8, 15, 16, 23]const object = { val: 42 }// создать новый объект или массив[].concat(array)Object.assign({}, oject)// но через деструктуризацию удобнее[...array]{...object}

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


const object = { val: 42, ref: {} }const copy = { ...object }copy.val = 23copy.ref.boom = 'woops'console.log(object) // { val: 42, ref: { boom: 'woops' }

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


const array = [null, 42, {}]const copy = array.filter(Boolean)copy[0] = 23copy[1].boom = 'woops'console.log(array) // [ null, 42, { boom: 'woops' } ]console.log(copy) // [ 23, { boom: 'woops' }

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


The problems of shared mutable state and how to avoid them
What is the most efficient way to deep clone an object in JavaScript?


Неизменяемые структуры данных (persistent data structures)


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


Пожалуй, две самые популярные библиотеки в мире фронтенд разработки это Immutable и Immer. При помощи Immer мы можем сделать вот что:


import produce from 'immer';const object = { ref: { data: {} } };const immutable = produce(object, (draft) => {  draft.ref.boom = 'woops';});console.log(object) // { ref: { data: {} }console.log(immutable) // { ref: { data: {}, boom: 'woops' }console.log(object.ref.data === immutable.ref.data) // true

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


Как на практике?


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


const addTodo = (state = initState, action) => {    switch (action.type) {        case ADD_TODO: {            return {                ...state,                todos: [...state.todos, action.todo]            }        }        default: {            return state;        }    }}

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


import produce from 'immer'const addTodo = (state = initState, action) =>    produce(state, draft => {        switch (action.type) {            case ADD_TODO: {                draft.todos.push(action.todo)                break            }        }    })

Чистые функции (pure functions)


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


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


Побочные эффекты (side effects)


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


function impure () {    // логирование    console.log('side effects')    // запись в файл    fs.writeFileSync('log.txt', `${new Date}\n`, 'utf8')    // запрос на сервер и т. д.    fetch('/analytics/pixel')}

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


Работа с глобальными переменными тоже побочный эффект.


function impure () {    // глобальная переменная    app.state.hasError = true}

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


function impure () {    // модификация DOM    document.getElementById('menu').hidden = true    // установка обработчика    window.addEventListener('scroll', () => {})    // запись в локальное хранилище    localStorage.setItem('status', 'ok')}

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


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


function impure (o) {    return Object.defineProperty(o, 'mark', {        value: true,        enumerable: true,    })}const object = {}const marked = impure(object)// defineProperty мутировала исходный объектconsole.log(object) // { mark: true }

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


Зависимость от параметров


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


function impure () {    // глобальная переменная    if (NODE_ENV === 'development') { /* */ }    // чтение данных из DOM    const { value } = document.querySelector('.email')    // обращение к локальному хранилищу    const id = localStorage.getItem('sessionId')    // чтение из файла и т. д.    const text = fs.readFileSync('file.txt', 'utf8')}

Внешние зависимости можно заменить на зависимость от параметров.


Непредсказуемый результат


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


function impure (min, max) {    return Math.floor(Math.random() * (max - min + 1) + min)}impure(1, 10) // 4impure(1, 10) // 2

Чтобы сделать функцию чистой, достаточно вынести неопределённость за пределы функции и передать её в качестве параметра. Например, вот так:


function pure (min, max, random = Math.random()) {    return Math.floor(random * (max - min + 1) + min)}pure(1, 10, 0.42) // 5pure(1, 10, 0.42) // 5

Теперь функция всегда возвращает один и тот же результат для одних и тех же параметров.


Преимущества чистых функций


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


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


const refTransparency = () =>    Math.pow(2, 5) + Math.sqrt(100)// вызов функцииrefTransparency()// можно раскрытьMath.pow(2, 5) + Math.sqrt(100)// и без особых трудностей понять результат32 + 10 // 42

Так почему бы всё не написать на чистых функциях?


Абсолютная и относительная чистота


Если взять и написать программу только из чистых функций, то получится:


(() => {})() // абсолютная чистота

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


// побочные эффекты выносятся за пределыconst text = fs.readFileSync('file.txt', 'utf8')// функция получает нужные данные только через параметрыfunction pure (text) {    // ... чистота}

Кроме того, чистота относительна. Функция ниже чистая или нет?


// pure или impure?function circleArea (radius) {    return Math.PI * (radius ** 2)}

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


Заключение


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


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


Жаргон функционального программирования
Functional-Light JavaScript
Mostly adequate guide to Functional Programming


Кроме того, загляните в репозиторий Awesome FP JS, вдруг найдёте что-то интересное для себя. Если же захочется целиком погрузиться в функциональную парадигму, но при этом продолжать разрабатывать фронтенд, можно посмотреть в сторону Ramda или Elm.


Спасибо за внимание!

Подробнее..

Автоматизируем сервис-воркер с Workbox 6. Доклад в Яндексе

17.04.2021 12:09:16 | Автор: admin
Задеплоил сервис-воркер нужно покупать новый домен, известная шутка о том, как сложно писать собственную логику кеширования. С приходом шестой версии библиотеки Workbox для прогрессивных веб-приложений (PWA) больше не нужен компромисс между гибкостью и удобством автоматизации сетевых задач. Максим Сальников рассказал, как начать работу с Workbox 6, реализовать типовую функциональность для офлайнового веб-приложения и пойти дальше, добавив собственную логику кеширования.

От докладчика:
Среди отзывов, полученных мной после этого доклада, занявшего второе место по полезности по результатам опроса после конференции, был такой: Краткий пересказ документации Workbox. Что ж, в какой-то степени это действительно так: я рассказал пошаговую историю превращения обычного веб-приложения в прогрессивное с помощью этой замечательной библиотеки. Буду очень рад, если доклад и конспект вдохновят вас и помогут начать ваше собственное путешествие в мир сервис-воркеров и PWA.

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

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



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

Что сегодня происходит с прогрессивными веб-приложениями? Американские горки какие-то новости радуют, какие-то не очень.

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

Компания Google радует теперь прогрессивные веб-приложения можно монетизировать, используя API для продажи цифровых товаров на их площадке Google Play. Для этого, правда, придется PWA обернуть в нативную оболочку мобильного приложения. К счастью, сделать это можно легко, используя инструмент от той же компании Google Trusted Web Activity, TWA.

Компания Apple на своей конференции для разработчиков анонсировала, что 16 API, связанных с доступом к аппаратным возможностям устройств, в ближайшее время не будут реализованы в WebKit (и соответственно, в Safari), потому что по наличию или отсутствию этих API и их возможностей можно составить цифровой профиль пользователя и тем самым нарушить его приватность.

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

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


Ссылка со слайда

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

Технически прогрессивные улучшения или, как механизм для этого, feature detection то, что нам известно в мире фронтенда с незапамятных времен.


Ссылки со слайда: первая, вторая

Как понять все эти термины? PWA что это, как это связано с сервис-воркером, что включает в себя сервис-воркер? Я сделал простую диаграмму, чтобы задать контекст нашей сегодняшней дискуссии. Под идеей PWA понимаются два концепта, связанных с веб-приложениями: возможность их установки на устройства пользователей и интересные возможности через набор новых web-API.

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

Из многообразия этого подуровня API что-то связано с кешированием, но не все.


Ссылка со слайда

Если вам нужна мотивация в плане количественных показателей популярности сервис-воркеров 1% из выборки сайтов, которые находятся в HTTP Archive, используют сервис-воркеры. Рост заметен, не взрывной на текущий момент времени, но и останавливаться он не планирует.


Ссылка со слайда

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

Почему наблюдается такая популярность сервис-воркеров именно со стороны известных веб-ресурсов?



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

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

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

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


Ссылка со слайда

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


Ссылка со слайда

Дело в том, что в теории работа с сервис-воркером выглядит достаточно просто. Что такое сервис-воркер? Во-первых, это JavaScript-код. Во-вторых, это код, который исполняется в отдельном контексте относительно основного кода нашего приложения. В-третьих, это код, который исполняется только как ответ на какие-то события (events).

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

Событие install кладем нужные ресурсы в кеш: index.html, главный бандл JavaScript, главный бандл CSS, может быть, что-то еще.


Ссылка со слайда

Событие activate управляем версиями, если приложение обновилось.


Ссылка со слайда

Событие fetch тут мы немного обманываем основное приложение тем, что в ответ на все явные и неявные HTTP, a точнее HTTPS-запросы мы можем выдавать данные не из интернета, а те, что мы до этого закешировали. Это в теории.


Ссылка со слайда

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

Действительно, если мы говорим не о приложении Hello, World!, а о том, которое растет, меняет список ресурсов ведь даже те ресурсы, что у нас есть, могут добавлять в свои названия хеш-суммы, чтобы как-то работать с http-кешированием. Все это нужно предусмотреть.

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

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


Ссылка со слайда

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


Ссылка со слайда

Именно поэтому, если мы посмотрим на статистику HTTP Archive, примерно треть всех текущих сервис-воркеров в выборке адресов сделаны с помощью Workbox или его предков. Вот мой личный топ особенностей этой библиотеки, из-за которых мне она так нравится:

  • Уровень абстракций настолько комфортен для разработчика, насколько это возможно. У нас все еще есть полный контроль, полное понимание того, что именно мы делаем то есть не ждите большой кнопки сделать круто. При этом мы избавлены от копания в глубоких технических деталях HTTP-запросов, алгоритмов кеширования, если нам это не нужно. Если нужно, то можем опуститься и на этот уровень.
  • Где уместно, возвращается декларативность, применимая к идее кеширования, что тоже очень значительно упрощает и делает комфортной саму разработку.
  • Модульность помогает нам и системам сборки оптимизировать размер итогового файла сервис-воркера, неиспользуемый код не окажется в production.
  • Если нужно, мы всегда можем расширить текущие модули и текущие методы.
  • Функциональность из коробки настолько широка, что покроет, я думаю, процентов 90 всех возможных сценариев, которые потребуются при создании, в частности, офлайн-приложения.
  • Мощный инструментарий: инструменты командной строки, модули для Node, плагины для систем сборки.
  • Очень важный момент бесплатность, открытый исходный код, активная разработка и поддержка со стороны Google и сообщества разработчиков.

Я надеюсь, что убедил вас попробовать. Давайте сделаем это незамедлительно.



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



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

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

Нативные импорты JavaScript в сервис-воркерах пока еще не работают, поэтому сервис-воркер будет необходимо собрать.

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



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



Это модуль для Node, соответственно добавим немного JavaScript-кода.



В конфигурации мы укажем тот самый исходный сервис-воркер и адрес итогового сервис-воркера.



Самое главное мы должны объяснить Workbox, какие именно файлы составляют ту самую программную оболочку, тот самый application shell. К счастью, это делается очень удобным методом glob-шаблонов. Я гарантирую, что вы сможете настроить необходимую выборку ресурсов для вашего даже сложного по конфигурации веб-приложения. В этом конфиге есть много других свойств, которые позволяют вам совершенно точно затюнинговать этот процесс.



Вызов метода injectManifest с говорящим названием.



Это позволит нам внедрить массив, который Workbox для нас собрал на основе шаблонов, вместо переменной, которая исполняет роль плейсхолдера. В итоге у нас получится примерно такого вида сервис-воркер с одним-единственным методом precacheAndRoute.



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



В общем случае нам понадобятся три плагина.


Ссылка со слайда

Как их настроить, можно посмотреть на этом слайде. Базовая настройка требуется только в плагине rollup-replace, который позволяет выбирать режим Workbox, development или production, заменой строки в исходном коде Workbox.

Чем отличаются режимы? В режиме production вся отладочная информация будет полностью исключена, в режиме development вы увидите детальный лог в консоли браузера, который позволяет точно прослеживать, что именно делает Workbox. Очень удобно.

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



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

Осталось интегрировать сборку сервис-воркера в общий билд приложения.


Ссылка со слайда

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

С использованием модуля workbox-window у нас есть удобный метод для регистрации на самом деле это синтаксический сахар для нативного метода, плюс там сразу внедрена лучшая практика по моменту регистрации сервис-воркера, а именно в событии window.onload. Чтобы не конкурировать за сетевые и процессорные ресурсы с основным потоком JavaScript, мы откладываем регистрацию сервис-воркера настолько, насколько это возможно.

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



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

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

Исходный код именно для этой части сервис-воркера есть в репозитории по адресу aka.ms/workbox6. Там находится код сегодняшнего демонстрационного приложения и ссылка на его онлайн-версию, чтобы вы могли с ним поиграть и посмотреть, какие процессы там происходят. Открывайте DevTools, вкладку Application, что я и сделаю прямо сейчас.

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



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

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

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

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



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



Если мы в блоге используем аватары из сервиса Gravatar, для этого мы можем настроить самую консервативную стратегию всегда брать их из кеша, если они там доступны с предыдущей загрузки. Как вы видите, этот метод очень декларативен, то есть это выглядит как настройка роутинга. Для адресов, который попадают под этот шаблон, в данном случае я его задаю через RegExp, мы просим Workbox использовать стратегию Cache First.



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



Самая интересная стратегия предлагается для конкретной статьи или конкретного поста. Называется Stale While Revalidate, и говорит она следующее: Workbox, попробуй взять данные для этого адреса из кеша, если они там есть, и отправь их в приложение, чтобы оно их мгновенно показало. В это же самое время сходи и посмотри, есть ли по этому адресу в сети обновление. Если есть загрузи и положи его в кеш для следующего использования.

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



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


Ссылка со слайда

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

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


Ссылка со слайда

Аналогично с картинками. Есть хорошие практики для динамического кеширования картинок, и Workbox позволяет эти практики применять буквально одной строчкой. В данном случае я задаю, что хочу кешировать не более чем 10 элементов.



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



Кеширование самих страниц.



Кеширование статических ресурсов, а именно JavaScript- и CSS-файлов.



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


Ссылка со слайда

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

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

Итак, что нам нужно сделать? Нам нужно расширить базовый класс в стратегии, назовем его CacheNetworkRace.


Ссылка со слайда

В этом классе нужно реализовать метод с названием _handle, куда мы передаем сам HTTP-запрос и очень важно экземпляр класса StrategyHandler


Ссылка со слайда


Ссылка со слайда

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


Ссылка со слайда

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

Что нам остается? Реализовать ту самую гонку в виде: Какой промис первым выполнится успешно, тот мы и отдаем. Тут же можно сделать обработку ошибок.



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

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

Продолжить общение на тему прогрессивных веб-приложений и сервис-воркеров я вам предлагаю в русскоязычном сообществе pwa_ru в телеграме. Еще одна интересная ссылка ведет на другой GitHub-репозиторий, где мы собрали максимально широкий набор ресурсов о прогрессивных веб-приложениях, сервис-воркерах, кешировании на русском языке. Там же вы найдете целую кучу примеров PWA в production и из глобального интернета, и из рунета. Я вам обещаю, многие посещаемые вами сайты, которые вы используете достаточно часто, это прогрессивные веб-приложения или, по крайней мере, они используют части сервис-воркеров для оптимизации вашего пользовательского опыта.

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

Продвинутые дженерики в TypeScript. Доклад Яндекса

03.05.2021 12:05:25 | Автор: admin
Дженерики, или параметризованные типы, позволяют писать более гибкие функции и интерфейсы. Чтобы зайти дальше, чем параметризация одним типом, нужно понять лишь несколько общих принципов составления дженериков и TypeScript раскроется перед вами, как шкатулка с секретом. AlexandrNikolaichev объяснил, как не бояться вкладывать дженерики друг в друга и использовать автоматический вывод типов в ваших проектах.

Всем привет, меня зовут Александр Николаичев. Я работаю в Yandex.Cloud фронтенд-разработчиком, занимаюсь внутренней инфраструктурой Яндекса. Сегодня расскажу об очень полезной вещи, без которой сложно представить современное приложение, особенно большого масштаба. Это TypeScript, типизация, более узкая тема дженерики, и то, почему они нужны.

Сначала ответим на вопрос, почему TypeScript и при чем тут инфраструктура. У нас главное свойство инфраструктуры ее надежность. Как это можно обеспечить? В первую очередь можно тестировать.


У нас есть юнит- и интеграционные тесты. Тестирование нужная стандартная практика.

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

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

Синтаксис


Чтобы провести базовый ликбез, сначала рассмотрим основы синтаксиса.

Дженерик в TypeScript это тип, который зависит от другого типа.

У нас есть простой тип, Page. Мы его параметризуем неким параметром <T>, записывается через угловые скобки. И мы видим, что есть какие-то строки, числа, а вот <T> у нас вариативный.

Кроме интерфейсов и типов мы можем тот же синтаксис применять и для функций. То есть тот же параметр <T> пробрасывается в аргумент функции, и в ответе мы переиспользуем тот же самый интерфейс, туда его тоже пробросим.

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

Для классов существует похожий синтаксис. Прокидываем параметр в приватные поля, и у нас есть некий геттер. Но там мы тип не записываем. Почему? Потому что TypeScript умеет выводить тип. Это очень полезная его фишка, и мы ее применим.

Посмотрим, что происходит при использовании этого класса. Мы создаем инстанс, и вместо нашего параметра <T> передаем один из элементов перечисления. Создаем перечисление русский, английский язык. TypeScript понимает, что мы передали элемент из перечисления, и выводит тип lang.

Но посмотрим, как работает вывод типа. Если мы вместо элементов перечисления передадим константу из этого перечисления, то TypeScript понимает, что это не всё перечисление, не все его элементы. И будет уже конкретное значение типа, то есть lang en, английский язык.

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

Теперь посмотрим, как можно это расширить.

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

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

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

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

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

Предположим, мы передаем аргумент, строковый литерал, и он должен соответствовать типу A, а второй аргумент, единичка, типу B. Минимально возможные для строкового литерала и единички литерал A и та же самая единичка. Нам TypeScript это и выведет. Получается такое сужение типов.

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

Отношение типов


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

Мы видим, что типы в нем связаны некими отношениями. Но какими? Это отношения частичного порядка значит, для типа всегда указан его супертип, то есть тип выше его, который охватывает все возможные значения.

Если идти в обратную сторону, то у каждого типа может быть подтип, меньше его.

Какие супертипы у строки? Любые объединения, которые включают строку. Строка с числом, строка с массивом чисел, с чем угодно. Подтипы это все строковые литералы: a, b, c, или ac, или ab.

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

И в этом порядке есть тип, как бы самый верхний, unknown. И самый нижний, аналог пустого множества, never. Never подтип любого типа. А unknown супертип любого типа.

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

Посмотрим, что нам даст знание этого порядка.

Мы можем ограничивать параметры их супертипами. Ключевое слово extends. Мы определим тип, дженерик, у которого будет всего один параметр. Но мы скажем, что он может быть только подтипом строки либо самой строкой. Числа мы передавать не сможем, это вызовет ошибку типа. Если мы явно типизируем функцию, то в параметрах можем указать только подтипы строки или строку apple и orange. Обе строки это объединение строковых литералов. Проверка прошла.


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

Посмотрим, как расширить эти ограничения.

Мы ограничились просто строкой. Но строка слишком простой тип. Хотелось бы работать с ключами объектов. Чтобы с ними работать, мы сначала поймем, как устроены сами ключи объектов и их типы.

У нас есть некий объектик. У него какие-то поля: строки, числа, булевы значения и ключи по именам. Чтобы получить ключи, используем ключевое слово keyof. Получаем объединение всех имен ключей.

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

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

Посмотрим, как использовать ключи объекта.

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

Посмотрим, как это работает с keyof. Мы определили тип CustomPick. На самом деле это почти полная копия библиотечного типа Pick из TypeScript. Что он делает?

У него есть два параметра. Второй это не просто какой-то параметр. Он должен быть ключами первого. Мы видим, что у нас он расширяет keyof от <T>. Значит, это должно быть какое-то подмножество ключей.

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

Смотрим на применение. У нас есть объект, в нем имена полей. Мы можем брать только их подмножество a, b или c, либо все сразу. Мы взяли a или c. Выводятся только соответствующие значения, но мы видим, что поле a стало обязательным, потому что мы, условно говоря, убрали знак вопроса. Мы определили такой тип, использовали его. Никто нам не мешает взять этот дженерик и засунуть его в еще один дженерик.

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

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

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

В связи с этим можно вывести такой забавный дженерик, у которого параметры расширяют ключи друг друга: a это ключи b, b ключи a. Казалось бы, как такое может быть, ключи ключей? Но мы знаем, что строки TypeScript это на самом деле строки JavaScript, а у JavaScript-строк есть свои методы. Соответственно, подойдет любое имя метода строки. Потому что имя у метода строки это тоже строка. И у нее оттуда есть свое имя.

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

Посмотрим, как это можно использовать в реальности. Используем для API. Есть сайт, на котором деплоятся приложения Яндекса. Мы хотим вывести проект и сервис, который ему соответствует.

В примере я взял проект для запуска виртуальных машин qyp для разработчиков. Мы знаем, что у нас в бэкенде есть структура этого объекта, берем его из базы. Но помимо проекта есть и другие объекты: черновики, ресурсы. И у всех есть свои структуры.

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

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

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

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

Посмотрим, как получить функцию.


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

Допустим, для проекта мы где-то описываем его тип. В нашем проекте мы генерируем тайпинги из protobuf-файлов, которые доступны в общем репозитории. Далее мы смотрим, что у нас есть все используемые типы: Project, Draft, Resource.

Посмотрим на реализацию. Разберем по порядку.

Есть функция. Сначала смотрим, чем она параметризуется. Как раз этими уже ранее описанными именами. Посмотрим, что она возвращает. Она возвращает значения. Почему это так? Мы использовали синтаксис квадратных скобок. Но так как мы передаем в тип одну строку, объединение строковых литералов при использовании это всегда одна строка. Невозможно составить строку, которая одновременно была бы и проектом, и ресурсом. Она всегда одна, и значение тоже одно.

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

Тип, которым параметризуется дженерик параметров, также совпадает с ограничением на функцию. Он может принимать только тип имени Project, Resource, Draft. ID это, конечно, строка, она нам не интересна. Вот тип, который мы указали, один из трех. Интересно, как устроена функция для путей. Это еще один дженерик почему бы нам его не переиспользовать. На самом деле все, что он делает, просто создает функцию, которая возвращает массив из any, потому что в нашем объекте могут быть поля любых типов, мы не знаем каких. В такой реализации мы получаем контроль над типами.

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

Управляющие конструкции


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

Что такое условные типы? Они очень напоминают тернарки в JavaScript, только для типов. У нас есть условие, что тип a это подтип b. Если это так, то возврати c. Если это не так возврати d. То есть это обычный if, только для типов.

Смотрим, как это работает. Мы определим тип CustomExclude, который по сути копирует библиотечный Exclude. Он просто выкидывает нужные нам элементы из объединения типов. Если a это подтип b, то возврати пустоту, иначе возврати a. Это странно, если посмотреть, почему это работает с объединениями.

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

Когда мы применяем CustomExclude, то смотрим поочередно на каждый элемент наблюдения. a расширяет a, a это подтип, но верни пустоту; b это подтип a? Нет верни b. c это тоже не подтип a, верни c. Потом мы объединяем то, что осталось, все плюсики, получаем b и c. Мы выкинули a и добились того, что хотели.

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

Как нам определить наш ранее упомянутый тип DeepPartial? Тут впервые используется рекурсия. Мы пробегаемся по всем ключам объекта и смотрим. Значение это объект? Если да, применяем рекурсивно. Если нет и это строка или число оставляем и все поля делаем опциональными. Это все-таки Partial-тип.

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

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

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

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

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

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

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

Посмотрим, как это реализовывается в TypeScript.

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

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

Вновь разберем по порядку, как это происходит.

Мы пробегаемся по всем ключам переданного объекта, потом делаем вот такую процедуру. Смотрим, что поле объекта это подтип нужного, то есть числовое поле. Если да, то важно, что мы записываем не значение поля, а имя поля, а иначе вообще, пустоту, never.

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

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

В примере показано: есть простой объект, поле единичка. Это число? Да. Поле число, это число? Да. Последняя строка не число. Получаем только нужные, числовые поля.

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


Он неотделим от предыдущей темы, потому что работает только с условной конструкцией.

Как это выглядит? Допустим, мы хотим знать элементы массива. Пришел некий тип массива, нам бы хотелось узнать конкретный элемент. Мы смотрим: нам пришел какой-то массив. Это подтип массива из переменной x. Если да верни этот x, элемент массива. Если нет верни пустоту.

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

Если мы передаем массив строк, то нам ожидаемо возвратится строка. И важно понимать, что у нас определяется не просто тип. Из массива строк визуально понятно: там строки. А вот с кортежем все не так просто. Нам важно знать, что определяется минимально возможный супертип. Понятно, что все массивы как бы являются подтипами массива с any или с unknown. Нам это знание ничего не дает. Нам важно знать минимально возможное.

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

TypeScript это и выведет, и мы получим для такого примера именно объединение строки и числа.

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

Но на самом деле не рекомендуется слишком с этим заигрываться. Обычно для 90% задач хватает захвата всего лишь одного типа.


Посмотрим пример. Задача: нужно показать в зависимости от состояния запроса либо хороший вариант, либо плохой. Тут представлены скриншоты из нашего сервиса для деплоймента приложений. Некая сущность, ReplicaSet. Если запрос с бэкенда вернул ошибку, надо ее отрисовать. При этом есть API для бэкенда. Посмотрим, при чем тут Infer.

Мы знаем, что используем, во-первых, redux, а, во-вторых, redux thunk. И нам надо преобразовать библиотечный thunk, чтобы получить такую возможность. У нас есть плохой путь и хороший.

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

Про JavaScript я не привожу сравнение, потому что оно не имеет смысла. В JavaScript в принципе нельзя никак контролировать типы и полагаться только на память. Здесь нет плохого варианта, потому что его просто нет.

Мы знаем, что хотим получить этот тип. Но у нас же не просто так появляется action. Нам нужно вызвать dispatch с этим action. И нам нужен вот такой вид, где по ключу запроса нужно отображать ошибку. То есть нужно поверх redux thunk примешивать такую дополнительную функциональность с помощью метода withRequestKey.

У нас, конечно, есть этот метод, но у нас есть и исходный метод API getReplicaSet. Он где-то записан и нам надо оверрайдить redux thunk с помощью некоего адаптера. Посмотрим, как это сделать.

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

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

Первое это просто наш API, объектик с методами. Мы можем делать getReplicaSet, получать проекты, ресурсы, неважно. Мы в текущем методе используем конкретный метод, а второй параметр это просто имя метода. Далее мы используем параметры функции, которую запрашиваем, используем библиотечный тип Parameters, это TypeScript-тип. И аналогично для ответа с бэкенда мы используем библиотечный тип ReturnType. Это для того, что вернула функция.

Дальше мы просто прокидываем свой кастомный вывод в AsyncThunk-тип, который нам предоставила библиотека. Но что это за вывод? Это еще один дженерик. На самом деле он выглядит просто. Мы сохраняем не только ответ с сервера, но и наши параметры, то, что мы передали. Просто чтобы в Reducer за ними следить. Дальше мы смотрим withRequestKey. Наш метод просто добавляет ключ. Что он возвращает? Тот же адаптер, потому что мы можем его переиспользовать. Мы вообще не обязаны писать withRequestKey. Это просто дополнительная функциональность. Она оборачивает и рекурсивно нам возвращает тот же самый адаптер, и мы прокидываем туда то же самое.

Наконец, посмотрим, как выводить в Reducer то, что нам этот thunk вернул.


У нас есть этот адаптер. Главное помнить, что там четыре параметра: API, метод API, параметры (вход) и выход. Нам надо получить выход. Но мы помним, что выход у нас кастомный: и ответ сервера, и параметр запроса.

Как это сделать с помощью Infer? Мы смотрим, что на вход подается этот адаптер, но он вообще любой: any, any, any, any. Мы должны вернуть этот тип, выглядит он вот так, ответ сервера и параметры запроса. И мы смотрим, на каком месте должен быть вход. На третьем. На это место мы и помещаем наш захват типа. Получаем вход. Аналогично, на четвертом месте стоит выход.

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

Так мы добились вывода типов, у нас есть доступ к ним уже в самом Reducer. В JavaScript сделать такое в принципе невозможно.
Подробнее..

Микрофронтенды и виджеты в 2021-м. Доклад Яндекса

07.05.2021 12:20:51 | Автор: admin
Давайте поговорим о микрофронтендах и о встраиваемых виджетах, которые, по сути, были предшественниками концепции микрофронтендов. В докладе я рассказал о способах встраивать виджеты на страницу, об их плюсах и минусах с точки зрения изоляции и производительности кода, а также о способах применять виджеты в микрофронтендной архитектуре.

Всем привет! Меня зовут Леша. Я хочу с вами сегодня обсудить немного перехайпленную тему микрофронтенды.

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

Микрофронтенды дальнейшее развитие этой идеи. Мы наше монолитное фронтенд-приложение также разделяем на независимые куски, независимые сервисы. Каждый сервис делается выделенной командой. Это может быть команда, у которой собственный фронтенд и бэкенд.

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

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

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

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

Евангелистом идеи микрофронтендов я совершенно не являюсь. У этой идеи, как и у всех, есть свои плюсы и минусы. Давайте мы с вами о них поговорим.

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

Какие у этой концепции проблемы? Она сильно усложняет код, дает дополнительные накладные расходы на интеграцию и взаимодействие виджетов в микрофронтендах между собой, накладывает требования по обратной совместимости чтобы API, через который виджеты общаются, при релизах не ломался и ваше приложение не развалилось. Свобода в технологическом стеке наверное, тоже своего рода минус. Думаю, вам не очень хотелось бы пользоваться сайтом, который для отрисовки одного кусочка грузит Angular, а для другого React, это будет работать не слишком быстро. Так что свобода это одновременно и плюс, и минус.

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

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

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

Как технически реализовать микрофронтенды/виджеты?


declare const DoggyWidget: {    init: ({        container: HTMLElement,    }) => DoggyWidgetInstance;}declare interface DoggyWidgetInstance {    destroy(): void;    updateDoggy(): void;}

В качестве примера возьмем вот такой простенький виджет DoggyWidget, он лежит по ссылке на GitHub. Виджет рисует картинку и кнопочку. Картинка принимает размеры контейнера, куда вы виджет вставили, и показывает рандомную фотографию собаки. Кнопка при нажатии меняет эту фотографию на другую рандомную. У нашего виджета будет API, с помощью которого с ним можно будет как-то взаимодействовать.

Из чего он будет состоять? В первую очередь он будет декларировать глобальный namespace DoggyWidget, в котором будет фабрика и с помощью которого можно создать инстанс этого виджета. У инстанса будет два метода. Первый метод destroy, который при вызове удалит виджет со страницы и почистит всё, что он успел сделать с DOM-ом. Второй метод updateDoggy, который делает то же самое, что нажатие на кнопку, а именно меняет картинку.

Давайте подумаем, как такой виджет реализовать.

<script>


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

class Widget {    constructor({ container }) {        this.container = container;        container.classList.add('doggy-widget');        this._renderImg();        this._renderBtn();        this.updateDoggy();    } }

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

    _renderImg() {        this.img = document.createElement('img');        this.img.classList.add('doggy-widget__img');        this.img.alt = 'doggy';        this.container.appendChild(this.img);    }

Что будет делать renderImg? Он будет создавать тег img, навешивать на него className и аппендить его в контейнер.

    _renderBtn() {        this.btn = document.createElement('button');        this.btn.classList.add('doggy-widget__btn');        this.btn.addEventListener('click', () => this.updateDoggy());        this.container.appendChild(this.btn);        this.btn.innerText = 'New doggy!';    } 

renderBtn будет делать примерно то же самое, только он будет создавать не img, а кнопочку.

    updateDoggy() {        const { width, height } = this.container.getBoundingClientRect();        const src = `https://placedog.net/${width - 10}/${height - 10}?random=${Math.random()}`;        this.img.src = src;    }

И у нас еще есть публичный API. updateDoggy определяет параметры контейнера, куда мы вставили виджет, конструирует ссылку на изображение. Я здесь буду использовать сервис placedog.net, который подставляет рандомные плейсхолдеры с фотками собак. Метод src ставит тег img.

    destroy() {        this.container.innerHTML = '';        this.container.classList.remove('doggy-widget');    }

destroy будет очень простой он будет подчищать innerHTML у контейнера и снимать с него className, который мы поставили в конструкторе.

(() => {    class Widget {        ...    }    window.DoggyWidget = {        init(config) {            return new Widget(config);        }    }})();

Напишем код, с помощью которого виджет будет вставляться. Мы его содержимое обернем в IIFE, чтобы спрятать класс виджета в замыкание, и определим в нем глобальный namespace DoggyWidget, в namespace будет функция init фабрика, которая вернет нам инстанс виджета.

<script src="doggy-widget.js"></script><link rel="stylesheet" href="doggy-widget.css"><div id="widget-1"></div><div id="widget-2"></div><script>    const widget1 = DoggyWidget.init({         container: document.getElementById('widget-1'),    });    const widget2 = DoggyWidget.init({         container: document.getElementById('widget-2'),    });</script>

Как это все будет ставиться на страничку? Вот два файла: doggy-widget.js с JS-кодом, который мы разобрали, и doggy-wodget.css со стилями для виджета.

Мы заведем два div, и в каждый из них вставим виджет через DoggyWidget.init(), который мы тоже в doggy-widget.js описали.

Ссылка со слайда

Это все будет выглядеть так. У первого виджета будет updateDoggy.

Ссылка со слайда

Мы его вызовем. Он изменит нам фотографию.

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

Ссылка со слайда

        * {            font-family:         Arial, Helvetica, sans-serif !important;            font-size: 10px !important;        }

Представим, что мы наш виджет встроили на страничку, где находится вот такой CSS-код.

Ссылка со слайда

Что произойдет, когда мы отрисуем виджет? Очевидно, у него поедет верстка, потому что у нас есть глобальный CSS selector, который для всех элементов переопределяет font-family и font-size. Так что виджет не очень хорошо изолирован от окружающего его CSS-кода.

Вы скажете, что это вредительство и такого CSS никто не пишет.


Ссылка со слайда

<link rel="stylesheet"       href="bootstrap.min.css">*, ::after, ::before {    box-sizing: border-box;}

Окей, рассмотрим чуть более реальный пример. Мы встраиваемся на страничку, на которой используется Bootstrap, например. В Bootstrap есть такой код, который всем элементам задает box-sizing.

Предположим, мы наш виджет отрисуем на такой страничке:

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

Как этого можно избежать? Первый вариант: есть достаточно старый проект cleanslate.css.

<body>  <div class="blah">      <!-- general content is not affected -->      <div class="myContainer cleanslate">          <!-- this content will be reset -->      </div>  </div></body>

Это специальный CSS reset, который перезагружает стили не на всей страничке, а только на том div, где стоит класс cleanslate. Всё, что находится внутри cleanslate, будет переопределено, у него будут дефолтные зарезеченные стили.

Либо есть более современное решение, которое использует часть спецификаций веб-компонентов, а именно Shadow DOM.

Shadow DOM это такой способ отрисовать часть DOM-дерева изолированно и скрыто от других элементов на страничке. С помощью Shadow DOM рисуются встроенные в браузер контролы, например, input range. Если вы посмотрите на него в dev tools, там внутри в shadow root находится верстка, стилизованная с помощью CSS, который зашит в движок браузера.

    constructor({ container }) {        this.shadowRoot = container.attachShadow(            { mode: 'open' }        );        this.innerContainer = document.createElement('div');        this.innerContainer.classList.add('doggy-widget');        this.shadowRoot.appendChild(this.innerContainer);            }

Окей, попробуем заюзать Shadow DOM для нашего виджета. Что нам для этого нужно? В конструкторе мы приаттачим в контейнер shadowRoot, создадим еще один div, назовем его innerContainer и зааппендим его внутрь нашего shadowRoot.

    _renderImg() {                this.innerContainer.appendChild(this.img);    }    _renderBtn() {                this.innerContainer.appendChild(this.btn);    }

И нам потребуется немного переделать методы renderImg(), renderBtn(). Теперь мы будем картинку и кнопку складывать не в контейнер, который нам пришел, а в innerContainer, который мы уже положили внутрь shadowRoot.

    destroy() {                this.shadowRoot.innerHTML = '';    } 

Осталось еще немного поправить destroy. В destroy будем shadowRoot просто подчищать за собой.

Класс! Кажется, мы использовали Shadow DOM и смогли нашу верстку изолировать от другого кода.


Ссылка со слайда

В этом случае мы получим что-то такое у нас пропали все стили.


Что именно произошло? Изоляция, которую обеспечивает Shadow DOM, работает в обе стороны: она блокирует как вредоносные стили, которые нам не нужны, так и наши собственные стили, которые мы хотим добавить. Смотрите, link с doggy widget CSS остался снаружи shadowRoot, а верстка виджета находится внутри. Соответственно, правила, которые описаны снаружи, не влияют на то, что находится внутри shadowRoot.

     constructor() {                const link = document.createElement('link');        link.rel = 'stylesheet';        link.href = 'doggy-widget.css';        this.shadowRoot.appendChild(link);            }

<script src="doggy-widget.js"></script>

<link rel="stylesheet" href="doggy-widget.css">

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

Ссылка со слайда

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

Единственная проблема, которую вы можете заметить, если откроете dev tools: на каждую инициализацию виджета появился отдельный запрос за doggy-widget.css. Здесь вам нужно будет убедиться, что у вас корректно настроено кеширование, чтобы повторно не грузить этот файл вашим клиентам.

Вроде изоляцию мы полечили. Или не совсем? Давайте немножко поиграем в шарады.

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

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

var str = JSON.stringify(['haha'])> '["haha"]'JSON.parse(str)> ["haha"]

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

Очевидно, если мы такую строку распарсим, то получим массив. Все хорошо.

var str = JSON.stringify(['haha'])> '"[\"haha\"]"'JSON.parse(str)> '["haha"]'

А вот на сайте одного из партнеров, куда мы этот виджет встраивали, мы видели такую картину.

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

Array.prototype.toJSON: () => Object

Стали разбираться, что происходит. JSON.stringify по спецификации, если у объекта определен метод toJSON, вызывает его. Метод должен вернуть сериализуемый объект, который дальше будет через JSON.stringify преобразован, чтобы получить строку.

Array.prototype.toJSON = function () {    var c = [];    this.each(function (a) {        var b = Object.toJSON(a);        if (!Object.isUndefined(b))            c.push(b)    });    return '[' + c.join(', ') + ']'}

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

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

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

var _json_stringify = JSON.stringify;JSON.stringify = function(value) {    var _array_tojson = Array.prototype.toJSON;    delete Array.prototype.toJSON;    var r=_json_stringify(value);    Array.prototype.toJSON = _array_tojson;    return r;};

Строго говоря, предлагается полечить monkey-patching еще одним monkey-patching, что не кажется очень хорошим решением.

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

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

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

    _renderImg() {        const img = document.createElement(img');        this.img = img;        img.classList.add('doggy-widget__img');        img.alt = 'doggy';        this.container.appendChild(this.img);         this.updateDoggy(img);    }

Если помните, у нас был метод renderImg, который отрисовывал картинку. Давайте мы его сломаем, а именно удалим третью строчку, которая img кладет в поле нашего класса.

Что произойдет? Начальная отрисовка у нас отработает.

Ссылка со слайда

А вот если мы нажмем на кнопочку, то увидим exception.

window.addEventListener('error', (e) => {    console.log('got error:', e.error);    e.preventDefault();});


Как этот exception можно поймать, обработать и залогировать? Что делают те сервисы, которые я показывал несколько слайдов назад? Есть глобальный ивент 'error', который срабатывает на объекте window. На него можно подписаться и получить из этого ивента объект ошибки, которая произошла и которую вы не отловили через try-catch. У ивента можно вызвать preventDefault, чтобы также скрыть красную ошибку в консольке и не пугать ваших пользователей, которые внезапно решили открыть devtools.

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

window.addEventListener('unhandledrejection', (e) => {    console.log('got promise reject:', e.reason);    e.preventDefault();});

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

Promise.reject(new Error('bla'))

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


window.addEventListener('error', (e) => {    console.log('got error:', e.error);    e.preventDefault();});

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

Так что независимые мониторинги при таком подходе мы не получаем.

Давайте подведем промежуточные итоги. Что нам дает использование независимых скриптов?

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

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

Тем не менее, эта идея активно используется. Например, один из популярных фреймворков для построения микрофронтендов single-spa как раз на ней, в общем-то, и построен.

Что делать, если нам это все не подходит и хочется больше изоляции? Здесь поможет старая технология iframe.

<iframe>


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

(() => {    window.DoggyWidget = {        init({ container }) {            const iframe = document.createElement('iframe');        }    }})();

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

(() => {    window.DoggyWidget = {        init({ container }) {            const iframe = document.createElement('iframe');            iframe.style.width = '100%';            iframe.style.height = '100%';            iframe.style.borderWidth = 0;            iframe.style.display = 'block';            iframe.src = 'https://some-url/doggy-widget.html';                    }    }})();

В фабрике init нашего виджета нам нужно будет создать iframe и повесить на него стили. Мы поставим width и height 100%, чтобы он полностью растягивался до размеров контейнера, куда его вставили. Мы переопределим ему display и поставим границу 0, потому что по дефолту браузеры рисуют border.

Внутри iframe загрузим документ, в котором будет рендериться наш виджет.

(() => {    window.DoggyWidget = {        init({ container }) {            const iframe = document.createElement(iframe');            iframe.style.width = '100%';            iframe.style.height = '100%';            iframe.style.borderWidth = 0;            iframe.style.display = 'block';            iframe.src = 'https://some-url/doggy-widget.html';            container.appendChild(iframe);                        ...        }    }})();

Осталось зааппендить этот iframe внутрь контейнера.


Ссылка со слайда

Все будет работать, виджет будет отрисовываться.

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

declare const DoggyWidget: {    init: ({        container: HTMLElement,    }) => DoggyWidgetInstance;}declare interface DoggyWidgetInstance {    destroy(): void;    updateDoggy(): void;}

Но мы кое о чем забыли. У нашего виджета есть API. У инстанса есть destroy и updateDoggy. Давайте попробуем их реализовать.

destroy() {    this.container.innerHTML = '';}

destroy будет суперпростой. Нам нужно будет просто почистить контейнер, если вы не используете этого парня. В IE 11 и legacy Edge есть неприятный баг, связанный с тем, что контекст JS, который работает внутри фрейма, продолжает частично жить после удаления iframe из DOM. Что значит частично? В нем ломается стандартная библиотека, перестают, например, быть доступны объекты Date, Object, Array и прочее. Но асинхронный код, сет таймауты, сет интервалы, реакция на ивенты, которая там была, продолжают работать, и вы можете в ваших мониторингах в таком случае увидеть очень странные эксепшены из IE и legacy Edge о том, что у вас вдруг пропал Date, он стал undefined.

Чтобы это обойти, нам наш iframe предварительно перед удалением его из DOM нужно будет вот таким образом почистить. Тогда IE 11 и старый Edge корректно его задестроят и остановят весь JS-код, который внутри него выполнялся.

destroy() {    // чистим iframe для ie11 и legacy edge     this.iframe.src = '';    this.container.innerHTML = '';}


Ссылка со слайдов

Proof of concept destroy работает.

Что еще? У нас остался updateDoggy, для него нам нужно обновить картинку, которая рисуется внутри фрейма. Соответственно, сделать какое-то действие между нашим основным документом, отправить команду внутрь iframe. Здесь есть проблема. Если iframe загружается с другого хоста, браузер заблокирует любое взаимодействие с window внутри фрейма и вы получите примерно такую ошибку.

Как же все-таки можно взаимодействовать? Для взаимодействия нужно использовать postMessage. Это API, который позволяет отправить сериализуемую команду внутрь другого window, и внутри этого window подписаться на объект message, прочитать то, что было в команде. И отреагировать на нее.

updateDoggy() {    this.iframe.contentWindow        .postMessage({ command: 'updateDoggy' });}

Давайте реализуем updateDoggy через postMessage. В родительском документе у нас будет отправляться сообщение с командой updateDoggy внутрь iframe.

window.addEventListener('message', (e) => {    if (e.data.command === 'updateDoggy') {        widget.updateDoggy();    }})

И внутри iframe нам нужно будет написать вот такой код, который подписывается на события message, а если там updateDoggy, то дергает updateDoggy у виджета, который перерисует нам картинку.


Ссылка со слайдов

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

События, которые происходят внутри iframe, наружу не всплывают. Если вы хотите реагировать, например, снаружи на то, что пользователь кликнул внутри виджета, то вам нужно отправлять postMessage наверх. Использовать addEventListener напрямую у вас не получится событие через iframe не всплывет.

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

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

Что мы в итоге получаем? У нас сильно усложняется код. И еще появляются накладные расходы.

Если мы вспомним, как рисовался наш виджет, вставляемый через скрипт, это это выглядело бы так. У нас бы загружалась страничка, загружался CSS, JS. Дальше, когда виджет рисовался бы, каждый виджет запрашивал бы для себя картинку.

Если мы рассмотрим наш новый вариант с iframes, мы увидим такое. Внутри каждого виджета загрузится документ, у нас загрузится CSS, который там нужен, и JS, который внутри этого документа исполняется.

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

Ссылка со слайда

Здесь могло бы помочь кеширование, но недавно браузеры сделали так, чтобы изолировать кеши друг от друга между различными сайтами. Это нужно, чтобы предотвратить трекинг посещения пользователем одного сайта с другого. То есть если на сайте номер 1 используется какая-то библиотечка, сайт номер 2 тоже может ее подключить и посмотреть через Performance API, была они ла загружена из кеша. Если да, то пользователь, скорее всего, до этого посещал сайт 1 и это можно как-то использовать. Браузеры сейчас от такого поведения стараются пользователей защищать.

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

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

https://website.ru/    https://yastatic.net/react/16.8.4/react-with-dom.min.js    Widget #1        <iframe> https://widget-1.ru/            https://yastatic.net/react/16.8.4/react-with-dom.min.js    Widget #2        <iframe> https://widget-2.ru/            https://yastatic.net/react/16.8.4/react-with-dom.min.js

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

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

Итак, что мы получаем с iframe? У нас есть полная изоляция виджетов в CSS. Есть полная изоляция JS, потому что документы не зависят друг от друга. Есть независимые мониторинги, потому что внутри каждого iframe свой собственный window, на котором мы можем ловить ошибки.

Но при этом сильно усложнился код, поскольку появилась асинхронность.

Появились накладные расходы за дополнительными запросами, а именно за документом, который грузится внутри iframe, за HTML. И появились сложности с кешированием. iframes также требуют дополнительные браузерные ресурсы на работу, дополнительную память и дополнительное процессорное время.

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

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

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

Здесь поможет так называемый friendly <iframe>. Вы еще можете встретить название same-origin <iframe>, или anonymous <iframe>.

const globalOne = window;let iframe = document.createElement('iframe');document.body.appendChild(iframe);const globalTwo = iframe.contentWindow;

В чем идея? Есть глобальная область наш текущий window. Можно создать через createElement новый iframe и зааппендить его на страничку. При этом заметьте, что я внутри этого фрейма никакой документ не загружаю, дополнительного запроса за HTML здесь не будет и внутри документа окажется пустая страничка, которую туда автоматически подложит браузер.

Теперь contentWindow этого iframe можно рассматривать как еще один независимый контекст, который мы можем использовать.

foobar.js:
window.someMethod = () => {...}

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

Вот наш скрипт foobar.js, который в глобальную область добавляет метод. Как подключить его внутрь нашего нового контекста? Создаем скрипт, ставим ему src и аппендим внутрь head нашего iframe.

const script = document.createElement(script);script.src = 'foobar.js';globalTwo.head.appendChild(script);

Теперь, чтобы взаимодействовать с кодом внутри этого скрипта, нам больше не нужно использовать postMessage, потому что контекст у нас same-origin:

globalTwo.postMessage();

globalTwo.someMethod();

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

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

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

А верстку мы будем рисовать в контексте родительской страницы и будем использовать Shadow DOM. Попробуем взять лучшее из двух подходов, которые мы до это рассматривали.

Как теперь будет выглядеть фабрика нашего виджета?

const iframe = document.createElement('iframe');document.head.appendChild(iframe);const script = document.createElement('script');script.src = 'doggy-widget-inner.js';const loaded = new Promise((resolve) => {    script.onload = resolve;});loaded.then(() => {    iframe.contentWindow.init(config);})iframe.contentDocument.head.appendChild(script);

Создаем в нем iframe, загружаем внутрь этого iframe скрипт и сохраняем promise, который зарезолвится, когда этот скрипт загрузится.

После того, как он прогрузился, мы вызовем внутри нашего виджета init и передадим его config, который отрисует виджет внутри. Нам осталось зааппендить скрипт в head нашего iframe.

Как теперь преобразуется doggy-widget-inner.js, код, который работает внутри фрейма?

window.init = (config) => {    const widget = new Widget(config);    window.widget = widget;};

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

Как в итоге все будет работать? Если мы отрисуем таким способом два виджета на страничке, то получим примерно такое DOM-дерево.



Ссылка со слайдов

Для каждого виджета у нас будет в хэде скрытый friendly iframe, который пользователь не видит, но при этом код внутри него исполняется и с ним можно работать. Для каждого виджета в контейнере, который мы передали, будет использоваться shadow root, внутри которого будет находиться верстка этого конкретного виджета. Вот для первого виджета, а вот для второго.

Код целиком:

<head>    <iframe>        #document            <html>                <head>                    <script src="doggy-widget-inner.js"></script>                </head>                <body></body>            </html>    </iframe>    <iframe>        #document            <html>                <head>                    <script src="doggy-widget-inner.js"></script>                </head>                <body></body>            </html>    </iframe></head><body>    <div id="widget-1">        #shadow-root            <link rel="stylesheet" href="doggy-widget.css">            <div class="doggy-widget">                <img class="doggy-widget__img"/>                <button class="doggy-widget__btn"/>            </div>    </div>    <div id="widget-2">        #shadow-root            <link rel="stylesheet" href="doggy-widget.css">            <div class="doggy-widget">                <img class="doggy-widget__img"/>                <button class="doggy-widget__btn"/>            </div>    </div>    <script src="doggy-widget.js"></script></body>

Что этот подход нам дает? Мы получаем:

  • Полную изоляцию наших виджетов в CSS, потому что используем Shadow DOM.
  • Полную изоляцию в JS, потому что код работает внутри выделенного iframe, и какой-либо monkey-patching в родительском документе на него никак не влияет.
  • Независимые мониторинги, потому что код виджета работает, опять-таки, в независимом window, где мы можем слушать эксепшены.
  • Работающее кеширование, так как контекст same-origin в браузере больше не изолирует кеши между виджетами.

При этом все еще есть:

  • Некоторое усложнение кода. Загрузка становится асинхронной, но гораздо лучше использовать асинхронное взаимодействие, в отличие от секьюрного фрейма, где мы использовали постмесседжи.
  • Небольшие накладные расходы запрос за дополнительным js-файлом, который загружается внутрь iframe.
  • Необходимость в дополнительных ресурсах. Требуются дополнительные ресурсы браузера и устройства пользователя на работу отдельного контекста iframe, но они сильно меньше, чем у секьюрного фрейма.

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

Немного поговорим о том, что ждет нас в светлом будущем. Там нас ждет спецификация Realms API. Она сейчас находится в TC39 на Stage 2, это draft. Активно идет написание стандарта. Спецификация развивается. Надеемся, что скоро она перейдет на stage 3.

Что она позволяет делать? Вспомним, как мы создавали friendly frame. У нас был глобальный контекст globalOne. Мы создавали элемент iframe, аппендили его в документ и получали globalTwo еще один независимый контекст внутри этого фрейма.

const globalOne = window;let iframe = document.createElement('iframe');document.body.appendChild(iframe);const globalTwo = iframe.contentWindow;

const globalOne = window;const globalTwo = new Realm().globalThis;

Realms позволяет это заменить на такую конструкцию. Появляется новый глобальный объект Realm. Создав инстанс Realm, вы получаете внутри него globalThis, который является как раз тем самым независимым контекстом, который при этом работает оптимальнее, чем отдельный iframe.

Как внутри Realm можно будет исполнить код? Через вызов импорта.

const realm = new Realm();const { doSomething } = await realm.import(    ./file.js');doSomething();

Заимпортируем какой-нибудь JS-файл, который экспортирует метод doSomething. Его сразу можно будет вызвать, он будет работать в контексте Realm независимо от основной странички.

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

Итоги


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

Пайка, C, светодиоды часовой стрим Геннадия Крэйла Круглова

12.03.2021 10:04:49 | Автор: admin

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

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

О чём я рассказываю в видео

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

А как управлять яркостью? Менять напряжение/ток обычно неудобно, поэтому все используют ШИМ. Воспользуемся ей и мы с помощью STM32 и C++. Но и тут не всё просто. Линейное изменение мощности нелинейно воспринимается глазом нужна коррекция.

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

Полная запись пайки

Подробнее..

Как и зачем мы создаём собственную курьерскую платформу. Три истории Яндекс.Маркета

28.04.2021 14:06:12 | Автор: admin
Всем привет, меня зовут Алексей Остриков, я руковожу разработкой в Яндекс.Маркете. Когда-то я много-много писал код, затем полтора года руководил группой бэкенда одного из сервисов Маркета, а сейчас отвечаю за разработку курьерской платформы Маркета.

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


На фото команда курьерской платформы десять месяцев назад. В те времена она помещалась в одной комнате. Сейчас нас стало в 5 раз больше.

Зачем мы всё это делали


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

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

Второе прозрачность. Когда что-то идёт не так (происходят переносы, срывы сроков), то мы не знаем, почему они произошли. Мы не можем пойти и подсказать: Ребят, давайте делать вот так. Мы и сами не видим, и клиенту не можем показать какие-то дополнительные вещи. Например, что заказ приедет не к восьми, а в интервале 15 минут. А всё потому, что в процессе нет такого уровня прозрачности.

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

Это и были те три цели, которые мы ставили во главу всего.

Как выглядит платформа


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



На изображении показана схема процесса. У нас есть большие склады, на которых хранятся сотни тысяч заказов. С каждого склада вечером уезжают фуры, доверху набитые заказами. Там может быть 56 тысяч заказов. Эти фуры едут в здания поменьше, которые называются сортировочные центры. В них за несколько часов большая куча заказов превращается в маленькие кучки для курьеров. И когда с утра приезжают курьеры на машинах, то каждый курьер знает, что ему нужно забрать кучку вот с этим QR-кодом, загрузить себе в машину и поехать развозить.

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


Как это видит курьер

У курьеров есть приложение для Android, написанное на React Native. И в этом приложении они видят весь свой день. Они чётко понимают последовательность: на какой адрес ехать сначала, на какой потом. Когда позвонить клиенту, когда отвезти возвраты в сортировочный центр, как начать день, как закончить. Они всё видят в приложении и практически не задаются лишними вопросами. Мы им очень помогаем. По сути, они просто выполняют задания.



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

Кстати, про бэкенд. Мы в Маркете очень любим Java, в основном версию 11. И все бэкенд-сервисы, про которые пойдёт речь, написаны на Java.

Архитектура




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

Второй узел это сервис, который коммуницирует с внутренними сервисами Яндекса. Все сервисы это классические RESTful-сервисы со стандартной коммуникацией. Когда вы сделаете заказ на Маркете, через какое-то время к вам прилетит документ в JSON-формате, где будет всё написано: когда доставляем, кому доставляем, в какой интервал. И у нас это состояние сохранится в базу данных. Всё просто.

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

Этот узел также является входной точкой, у него есть API, в которую стучится наша админка. У неё есть свой endpoint, который называется, скажем, /partner. И наша админка, всё состояние системы, конфигурируется через коммуникацию с этим сервисом.

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

И в центре всего находится база данных, в которой, собственно, и хранится всё состояние. Все сервисы входят в одну базу данных.



Отказоустойчивость


У Яндекса есть несколько дата-центров, и наш сервис регионально распределен по трём дата-центрам. Как это выглядит.

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

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

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

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

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

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

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

Итак, это была архитектура. А теперь начинаются истории.

История первая про Яндекс.Ровер


Недавно у нас была Yet another Conference, там Роверу уделили много внимания. Я продолжу тему.

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

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

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



Мы придумали максимально простой флоу, когда человеку приходит SMS с предложением нестандартного способа доставки не курьер привезёт, а Ровер. Весь эксперимент проходил во внутреннем дворе Яндекса. Человек выбирал подъезд, к которому Ровер подъедет. Когда робот приезжал открывалась крышка, клиент брал заказ, закрывал крышку, и Ровер уезжал за новым заказом. Всё просто.

Затем мы пришли к команде Ровера, чтобы договориться про API.

В API Ровера есть простые методы: открыть крышку, закрыть крышку, поехать в такую-то точку, получить состояние. Классика. Тоже JSON. Очень просто.

Что ещё очень важно: и такие маленькие истории, и любые большие истории лучше делать через featureflags. Фактически у вас есть рубильник, по которому вы можете в production включить эту историю. Когда она вам больше не нужна, эксперимент завершен успешно или не успешно, либо заметили какие-то баги, вы просто вырубаете её. И вам не нужно делать ещё один деплой новой версии кода на прод. Эта штука здорово облегчает жизнь.

Казалось бы, всё просто и всё должно работать. Там даже две недели нечего разрабатывать, можно за несколько дней сделать. Но посмотрите, где собака зарыта.

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

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

Есть много разных подходов. Мы сделали максимально простой вариант.

Мы решили, что можно запустить самый обычный фоновый Java-тред либо задачу в Executer. Этот фоновый тред тут же запускается отслеживать процесс. И как только процесс выполнен, мы отправляем уведомление.



Выглядит это, например, так. Это практически копия кода с production, за исключением вырезанных комментариев. Но есть подвох. Нельзя делать таким образом серьёзные системы. Допустим, мы выкатываем новую версию на бэкенд. Хост перезагружается, состояние теряется, и всё, Ровер уезжает в бесконечность, его больше никто не видит.

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

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

История вторая про базы данных


Но сначала несколько слов о том, как устроены основные сущности. Есть сервис Яндекс.Маршрутизация, который в конце дня строит маршруты курьерам.

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

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

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



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

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

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

Затем добавили трекинг и Ровер. Всего лишь по две таблички. В трекинге курьер отправляет свои координаты, мы их фиксируем в отдельной табличке. И есть трекинг заказа со своей моделью состояний, есть дополнительные штуки, типа SMS ушла / не ушла. Не стоит это добавлять прямо в задание. Лучше вынести в отдельную табличку, ведь этот трекинг нужен не для всех типов заданий.



В Ровере его координаты и доставка. У нас доставка Ровером это трекинг как бы для Ровера. Можно его добавить в трекинг заказа, но зачем? Ведь когда мы избавимся от этого эксперимента, когда он будет выключен, эти опции навсегда останутся в сущности трекинга. Там будут null-поля.

Может возникнуть вопрос: а зачем делать табличку с координатами? Один Ровер доставляет пять заказов в день. Не нужно хранить координаты в базе данных, можно просто ходить в API Ровера и получать их на runtime.

Суть в том, что так и было сделано изначально. Этой таблички не было, мы сразу ходили на сервис и всё это брали. Но во время тестов мы увидели, что много людей открывают карту с катящимся Ровером, и нагрузка на этот сервис кратно возрастает. Допустим, семь человек открыли. А там на страничке каждые две секунды Java Script запрашивает координаты. И коллеги нам писали в чат: Откуда такая нагрузка? У вас же там один человек должен кататься.

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

Эту историю можно было сделать с помощью 20 таблиц. Можно было использовать две таблицы: курьер и заказ. Но в первом случае это был бы over-engineering, а во втором случае это было бы слишком сложно поддерживать. Сложная логика, сложно тестировать.

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

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

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

  1. дойти до всех клиентов, обеспечить обратную совместимость;
  2. выкатить новый API, всех клиентов переключить на новый API;
  3. выпилить старый код о клиентах, выпилить старый код на бэкенде.


Это очень затратно.

Ошибки в коде по сравнению с этим вообще ерунда. Код вы просто переписали, прогнали тесты. Тесты зелёные вы запушили в мастер. А вот API базы данных уделяйте особое внимание.

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



Есть один совет. Когда вы очень быстро разрабатываете что-то, в базе данных возникают неточности, иногда не хватает внешнего ключа или появляется дубль поля. Поэтому иногда, раз в два-три месяца, просто смотрите только на базу. Та же Intellij IDEA умеет генерировать классные схемы. И там это всё видно.

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

История третья про качество


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

Например, у нас есть один процесс, который крайне важен для платформы. Весь день мы набираем заказы на завтрашний день, но вечером срабатывает пометка, что после 22:00 мы не набираем заказы, а до 01:00 готовимся к завтрашнему дню. Потом начинается распределение заказов по сортировочным центрам. Мы идём в Яндекс.Маршрутизацию, она строит маршруты.

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

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

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

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

Расскажу про один из вариантов, как можно настроить такой процесс.

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



Например, день закончился, мы хотим рассчитать маршруты на завтра. Мы отправляем запрос в первую очередь: сформируй задание и запусти расчёт. Consumer забирает первое сообщение, идёт в сервис маршрутизации. Это асинхронный API. Consumer получает ответ, что задача взята в работу.

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

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

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

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

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

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

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

Что мы сделали в офлайне


Я описал большую часть того, что происходит на нашей платформе. Но кое-что осталось за кадром.

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

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

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

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

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

Итоги


В начале я говорил, зачем мы начинали создавать собственную курьерскую платформу. Теперь расскажу, чего мы достигли. Это невероятно, но при использовании нашей платформы мы смогли приблизиться почти к 100% попадания в интервал. Например, за последнюю неделю качество доставляемости в Москве было порядка 9598%. Это значит, что в 9598% случаев мы не опаздываем. Мы укладываемся в интервал, который выбрал клиент. И мы даже не могли мечтать о такой точности, когда полагались исключительно на внешние службы доставки. Поэтому сейчас мы постепенно распространяем нашу платформу на все регионы. И будем улучшать доставляемость.

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

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

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

И если обобщать все идеи, о которых шла речь, можно выделить следующее.

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

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

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

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

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

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

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

И последнее. Я рассказывал про Ровер, что хорошо подобные процессы делать с помощью featureflags (фиче-флагов). Советую послушать доклад Марии Кузнецовой с митапа по Java. Она рассказывала, как устроены фиче-флаги в нашей системе и мониторинге.
Подробнее..

Супераппы мертвы. Да здравствуют супераппы! Доклад Яндекса

28.12.2020 10:22:36 | Автор: admin
Всем привет, меня зовут Илья Богин, я руковожу отделом разработки мобильного портального приложения Яндекса и Яндекс.Браузера для Android/iOS. В докладе на конференции YaTalks я решил поговорить о том, что сейчас понимается под супераппами, какие задачи они решают, чем отличаются азиатские и российские подходы к созданию супераппов. Еще я поделился опытом своей команды: какие технические вызовы бросает суперапп, может ли в этой роли выступать веб-платформа, что мы поняли в процессе разработки и почему в итоге решили, что мы не суперапп.

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

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

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



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



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

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



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



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

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

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



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

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

Технические вызовы при создании супераппа


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

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



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



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

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

Следующий момент, который я бы обязательно включил в понятие качественного user experience это широкий доступ сервисов к нативным возможностям девайса. Современный мобильный телефон или планшет очень мощное устройство с большим количеством дополнительных нативных возможностей. Нативная оплата: Apple Pay, Google Pay, Huawei Pay; куча дополнительных инструментов, гироскопы, AR. И конечно, чем больше возможностей мы даем сервисам внутри нашего супераппа, тем более качественные и инновационные сервисы мы сможем давать нашим пользователям.

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

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



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

Вроде бы понятный подход, но он имеет свои минусы. Да, у вас получается максимально идеальный look and feel. Но представьте, что таких сервисов у вас сто или двести. Или не дай бог, тысяча. Если мы говорим, например, о WeChat, Dianping или о приложении VK, то сервисов может быть очень много, число может исчисляться тысячами. Вы встаете перед дилеммой: либо вы пытаетесь интегрировать большое количество внешнего UI что, наверное, рано или поздно просто станет невозможным; либо вы пытаетесь предоставить им некий общий UI, который они будут пытаться кастомизировать под себя. Это тоже может быть не так просто.

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

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

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

Мы долго думали. Вначале долго обсуждали, холиварили при выборе подхода, но в какой-то момент нам пришлось сказать самим себе: alea iacta est, жребий брошен, Рубикон перейден. Мы выбрали технологию. Думаю, вы уже догадались: мы выбрали интеграцию сервисов в виде веба.

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



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

Второй важный момент. Сервис, который к нам въезжает, не дублирует свой код между платформами: веб, iOS, Android. У него есть единое представление. Это единое представление он использует как в вебе, в любых браузерах, так и внутри нашего супераппа на платформе iOS или на Android. Но как мы уже упоминали, выбор похода интеграции через веб-сервис ставит очень тяжелые задачи, связанные с улучшением технологических характеристик, потому что скорость, плавность, нативность UI все это очень сильно проседает.

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



Кто-то скажет, что современные веб-движки прекрасно справляются с веб-контентом и такой проблемы вроде бы уже не должно существовать. Веб отображается быстро и работает замечательно. Да, я бы тоже хотел сказать, что ситуация выглядит так, но, к сожалению, бесстрастная статистика говорит нам в том числе и об обратном. Например, если посмотреть не время получения первого байта. Это время установки соединения, прогрузки веб-движка. Оно может в среднем превышать 2,5 с. А если у вас еще нет ни одного байта, вам нечего отобразить, кроме какого-нибудь красивого спиннера или splash screen.

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

Что мы предлагаем? Что мы имплементировали в рамках нашего супераппа? Мы предлагаем максимально кешировать все, что можно, на уровне супераппа. Мы предоставляем технологию, которую внутри себя и вовне назвали Native Cache. Это возможность кеширования ресурсов, прописанных в списке в манифесте. Это похоже по идеологии на сервис-воркеры, которые тоже кешируют контент. Но в отличие от сервис-воркеров, он не несет дополнительных сложностей, дополнительного ухудшения производительности, связанной с инициализацией сервис-воркера. Сервис-воркер пропускает через себя все запросы и за счет этого немного замедляет взаимодействие клиента с ресурсами.

Систему Native Cache мы построили на самом нижнем возможном уровне. Он просто подменяет контент, который идет из запроса.



Таким образом мы не тратим дополнительное время на установку и перехват соединения, как это было бы в сервис-воркере. Чем сайт более SPA-oriented, тем больше контента мы можем предзакешировать. Чем он более SSR-oriented тем меньше. Но наши измерения показали, что даже в случае тяжелых SSR-сайтов кеширование в Native Cache подресурсов, статических картинок, стилей позволяет сильно сократить время загрузки. А главное сделать его максимально независимым от скорости сетевого соединения. Для остальных этапов мы тоже предложили решения, дополнительные оптимизации, связанные с нашим движком. На данный момент мы считаем, что проблему скорости загрузки, скорости показа контента мы очень сильно улучшили внутри нашего супераппа. Выбор веб-подхода был оправдан. Мы смогли решить те технологические проблемы, которые он за собой повлек.

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



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

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

Перейдем к следующей, достаточно интересной теме UX.

UX супераппа. Айсберги на горизонте


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

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



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

Но есть маленький подвох. Если мы выбираем этот путь, то начинаем отрисовывать этот элемент поверх веб-контента. Значит, мы можем конфликтовать с тем, что находится под этим элементом. Там тоже могут быть элементы управления. Это вроде бы очень небольшая область, сколько там, 50 на 100 пикселей.

Но как оказалось на практике, это очень используемая область в тех сервисах, которые хотели к нам въехать. Для них это была просто гигантская головная боль. Мы к ним приходили и говорили: интеграция практически элементарна, вам нужно написать манифест, в котором вы описываете ресурсы для хранения в Native Cache, использовать JS API, который предоставит вам возможности использования нативных элементов авторизации или платежей, и все будет замечательно. Мы забыли упомянуть, что нужно еще оставить небольшое место под наш нативный элемент. Сервисы начинали хвататься за голову: у нас там на тысячах страниц есть наши элементы управления, и мы будем переделывать это целый год.

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

Тут, казалось бы, мы решили предыдущую проблему. Мы никогда не перекрываем контент сервиса, он может въезжать к нам без изменений внешнего вида. Но к сожалению, механика взаимодействия со шторкой рождает дополнительные проблемы. В первую очередь она конфликтует с pull-to-refresh. Стандартное движение, которое привычно пользователю для обновления контента: немного оттянуть контент и магия, он начинает перезагружаться.



Нам приходится отключать pull-to-refresh. Также бывают сложные скроллы внутри контента: вертикальные или горизонтальные галереи, а в самом тяжелом случае карта. Это бесконечно скроллируемый контент. Вам нужно каким-то образом уметь взаимодействовать с этими картами, с такими бесконечными областями для скролла.

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

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

К чему мы пришли? Мы пришли к full screen-контенту с навигационной панелью, которая возникает снизу. Это очень похоже на веб-табы, которые и так уже существуют в браузерах, в том числе в Яндекс.Браузере и в приложении Яндекс. Тут мы предоставляем сервису максимум области для контента. Мы не перекрываем его, но, к сожалению, у нас теряется та самая некая выделенность сервиса по сравнению с обычными веб-сайтами. То есть мы хотели предоставить ему дополнительный внешний вид, дополнительную изюминку. Но к сожалению, в этом решении мы от этого отказываемся. И за счет навигационной панели снизу начинаем немного подрезать видимую область сервиса. Он может предоставить своим пользователям чуть меньше контента, доступного при первом старте.



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

Веб в целом как суперапп


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

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



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

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

Последний и самый важный момент: мы хотим, чтобы наша экосистема, наш суперапп не был завязан на конкретное приложение, а был доступен из любого веб-браузера. Если сервис загружается внутри нашего супераппа, внутри поискового приложения Яндекс или Яндекс.Браузера, мы используем как раз все те оптимизации, о которых я рассказывал, чтобы сервис работал быстро и качественно. Мы используем Native Cache и дополнительное JS API, которое пробрасывает взаимодействие пользователя в нативные интерфейсы. Пример нативная авторизация.





Мы используем дополнительные оптимизации на уровне движка веб-рендеринга. Но если пользователь заходит к нам через обычный внешний браузер, он по-прежнему может использовать те же самые возможности, которые реализованы другими средствами. Эти средства, может быть, менее эффективны, но при этом все равно реализовывают те возможности, ради которых пользователь приходил и ради которых сервис интегрировался в нашу экосистему. Мы можем использовать сервис-воркеры вместо Native Cache, использовать веб-реализацию JS API, который будет проводить это все через JS-средства и через веб-взаимодействие. И у пользователя будет возможность использовать нашу экосистему и входящие в нее сервисы через любой мобильный браузер, поддерживающий современные технологии.

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

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

Код на React и TypeScript, который работает быстро. Доклад Яндекса

12.01.2021 12:17:05 | Автор: admin
Евангелисты Svelte и других библиотек любят показывать примеры тормозящих компонентов на React. React и TypeScript дают много возможностей создавать медленный код. После доклада Виктора victor-homyakov вы сможете писать более производительные компоненты без усложнения кода.

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

О преждевременной оптимизации




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

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



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



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



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

Видеть эти варианты заранее и выбирать нужные, а не выбирать заведомо плохой, это не преждевременная оптимизация. Те тривиальные приемы, про которые я расскажу, дают при консистентном использовании до 5% производительности кода. Это по данным реальных проектов, которые переходили со старых стеков на использование React и TypeScript. Первая часть про React.

React


Лишние ререндеры


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


Источник

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



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

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



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

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


Источник

Многие хуки, такие как useState и useReducer, возвращают из себя какие-то функции. В данном случае setCount. И очень просто на лету сгенерировать стрелочную функцию, использующую setCount, чтобы передать ее во вложенный компонент.

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

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

const Foo = () => (    <Consumer>{({foo, update}) => (...)}</Consumer>);const Bar = () => (    <Consumer>{({bar, update}) => (...)}</Consumer>);const App = () => (    <Provider value={...}>        <Foo />        <Bar />    </Provider>);

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

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


Ссылка со слайда

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

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


Источник

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


Ссылка со слайда

Разработчиками React и контекста предусмотрен способ, как это предотвратить.

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


Ссылка со слайда

Пакет, который называется Why Did You Render, это однозначный must have для всех, кто борется с лишними ререндерами. Он лежит в NPM, ставится довольно легко и в режиме разработчика позволяет в консоли Developer Tools браузера отследить все компоненты, которые перерендериваются, хотя фактически содержимое props и state у них не изменилось.

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

Если подвести итог, что у нас есть для борьбы с лишними ререндерами:



  • Пакет Why Did You Render. Это must have в любом проекте, у любого разработчика на React.
  • В Developer Tools браузера Chrome можно включить опцию Paint flashing. Тогда он будет подсвечивать те области экрана, которые перерисовались. Вы визуально заметите, что и как часто у вас ререндерится.
  • Самое убойное средство это в каждый рендер вставить console.log. Это позволяет оценить, сколько вообще у вас ререндеров: и нужных, и ненужных.
  • И еще одна вещь: часто забываемый второй параметр в React.memo. Это функция, которая позволит вручную написать код сравнения props с предыдущими и самому возвращать true/false, то есть дополнительно к сравнению по ссылке сравнивать какое-то содержимое. Функция аналогична методу shouldComponentUpdate для классовых компонентов.

HTML-комментарии


Следующий интересный момент комментарии в HTML-коде, который сгенерирован на сервере.

ReactDOMServer.renderToString(    <div>{someVar}bar</div>);<div data-reactroot="">foo<!-- -->bar</div>

В местах склейки статического текста и текста из JavaScriptовых переменных React вставляет HTML-комментарий. Это сделано, чтобы безболезненно гидрировать такие места на клиенте.

ReactDOMServer.renderToString(    <div>{`${someVar}bar`}</div>);<div data-reactroot="">foobar</div>

Если вам нужно удалить такой комментарий, то вы склеиваете строки в JS-коде и вставляете в JSX всю склеенную строку, как в этом примере. Почему это важно?


Источник

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

То есть при server-side rendering мы вынуждены потратить лишние ресурсы процессора, лишнюю память и лишнее время на то, чтобы их отрендерить. Мы должны передать на клиент эту лишнюю разметку, а браузер должен эти три килобайта распарсить. И пока страница будет открыта, браузер будет держать их в памяти, потому что они присутствуют в дереве DOM документа.

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

HOC


function withEmptyFc(WrappedComponent) {    return props => <WrappedComponent {...props} />;}function withEmptyCc(WrappedComponent) {    class EmptyHoc extends React.Component {        render() {            return <WrappedComponent {...this.props} />;        }    }    return EmptyHoc;}

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



Если замерить производительность server-side rendering, то пустая кнопка, классическая кнопка HTML, рендерится 0,9 микросекунды. Если мы ее обернем в пустой HOC, который не делает ничего, то увидим, что это уже добавляет замедление в рендеринг.

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



При server side rendering и при первом рендеринге на клиенте HOC всегда делает вызов React.createElement. Это довольно сложная функция, которая выполняет довольно много работы внутри самой библиотеки React. Она не может не занимать дополнительного времени.

Также происходит копирование props. Мы снаружи HOC получили какие-то props и должны сформировать новые props для вложенного в HOC компонента. Это тоже занимает время.



При ререндере у нас никуда не делся React.createElement. Также HOC добавляет обертку в дереве. Сравнение с предыдущим деревом и обход дерева замедляет работу с ним.



В итоге на продакшене это может выглядеть как результат угара по HOC. Только половина разметки в дереве это полезная нагрузка, а оставшаяся половина это context consumer, context provider и разнообразные HOC.

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



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

switch (workInProgress.tag) {  case IndeterminateComponent: {    //     return mountIndeterminateComponent();  }  case FunctionComponent: {    //     return updateFunctionComponent();  }  case ClassComponent: {    //     return updateClassComponent();  }

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

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



Если у вас есть выбор, где реализовать вашу функциональность, в HOC или в хуке, то однозначно рекомендую хук. Он по размеру кода меньше, он всего лишь вызов функции, которая не несет смысла внутри библиотеки React, в то время как в HOC, я уже говорил, React.createElement сложная вещь. И в HOC добавляются уровни вложенности и прочая ненужная работа. Если можно ее избежать избегайте.

Изоморфный код



Про изоморфный код. Евангелисты изоморфизма не очень любят углубляться в детали того, как же их изоморфный код работает на наших серверах и наших клиентах. Проблема в том, что мы контролируем наш бэкенд, можем на нем доставить свежую Node.js, которая понимает последний диалект ECMAScript. В то же время на клиенте до сих пор значительная доля древних браузеров, например Internet Explorer 11 или старые Android: четвертый и немножко новее. Поэтому клиентам до сих пор все равно очень часто нужно отдавать ES5.

Поэтому никакими полифилами вы не сможете добавить на клиент понимание нового синтаксиса: стрелочных функций, классов, async await и прочих вещей.

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

Мы бы хотели, когда пишем изоморфный код на TypeScript, так настроить сборку, чтобы наш TypeScript компилировался в максимально свежий диалект для Node.js. Чтобы именно этот скомпилированный код исполнялся на Node.js при server side rendering. И чтобы для браузеров TypeScript компилировался в подходящий диалект, ES5 или чуть более новый, если вы собираете разные версии кода для старых и новых браузеров.



Если же мы пишем сразу на ECMAScript, то можем нативно писать для Node.js, и в этом случае бонусом будет то, что нам не нужны никакие системы сборки и бандлинга. Мы сразу пишем код, который нативно понимается Node.js. Node.js умеет использовать модульные системы: CommonJS через require, ESM через import. И нам надо только скомпилировать в ES5 для браузеров и собрать в бандлы.

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

TypeScript


Дизайн языка


Мы плавно перешли к TypeScript. Сначала очень важно упомянуть про дизайн языка. Агрессивная оптимизация производительности скомпилированных программ и система типов, которая позволяет на этапе компиляции доказать, что ваша программа корректна, это все не является целями дизайна TypeScript. Не является приоритетом при его дальнейшем развитии.



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

Spread operator


О чем я хотел бы сказать в первую очередь, это оператор Spread.


Он очень часто используется в коде на React. Но то, что его легко написать, не означает, что его так же легко выполнять.



Потому что при компиляции такого кода TypeScript запишет в модуль на ES5, во-первых, реализацию метода __assign, а во-вторых, его вызов. То есть фактически воткнет полифил для Object.assign.

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

И еще одна проблема: Object.assign, если вы знаете, означает клонирование объекта. Клонирование объекта выполняется не за константное время. Чем сложнее объект, чем больше в нем полей, тем больше времени будет занимать клонирование. И с этим связан такой пример фейла.

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



Проблема в том, что на каждой итерации мы выполняем клонирование предыдущего объекта. И соответственно, на N+1 итерации мы вынуждены будем склонировать объект, в котором уже N полей.

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

Бывает еще вот такой фейл при использовании spread с массивами.



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

А если массив начинает занимать гигабайт? Представьте: во-первых, постоянно занято 3 ГБ одновременно (1 ГБ исходный массив, 1 ГБ предыдущая копия и 1 ГБ следующая). Во-вторых, на каждой итерации мы копируем из предыдущего расположения массива в следующее 1 ГБ плюс 1 элемент, 1 ГБ плюс 2 элемента и т. д. Ваша задача заметить такое на код-ревью и не пустить в продакшен.



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

// TS:res = {...obj, a: 1};// компилируется в ES5:res = __assign(__assign({}, obj), {a: 1});// хотелось бы:res = __assign({}, obj, {a: 1});// илиres = __assign({}, obj);res.a = 1;

Если же порядок поменяется, это будет означать уже два вложенных вызова assign. Хотя мы хотели бы один вызов или вообще запись поля a в объект результата. Почему так происходит? Напоминаю, что генерация оптимального кода не цель написания и развития языка TypeScript. Он просто обязан учитывать гипотетические крайние случаи: например, когда в объекте есть getter и поэтому он строит универсальный код, который в любых случаях работает правильно, но медленно.



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

Rest operator


Двоюродный родственник Spread-оператора это Rest. Те же три точечки, но по-другому.


У нас в коде это чаще всего используется в деструктурировании. Вот один из примеров. Здесь под капотом, чтобы получить объект otherProps, надо выполнить следующую нетривиальную работу: из объекта props скопировать в новый объект otherProps все поля, название которых не равно prop1, prop2 или prop3.

Чувствуете, к чему я клоню? При компиляции в ES5 получается примерно такой код:

var blackList = ['prop1', 'prop2', 'prop3'];var otherProps = {};// Цикл по всем полямfor (var p in props)    if (        hasOwnProperty(props, p) &&        // Вложенный цикл  поиск в массиве indexOf(p)        blackList.indexOf(p) < 0    )        otherProps[p] = props[p];

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

Нативная поддержка Rest в новых Node.js и новых браузерах не спасает. Вот пример бенчмарка (к сожалению, сейчас сайт jsperf.com лежит), который показывает, что даже примитивная реализация Rest с помощью вспомогательных функций чаще всего работает не медленнее, а даже быстрее нативного кода, который сейчас реализован в Node.js и браузерах.



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

// хотелось бы ES5:Component.prototype.fn1 = function(path) {    utils.fn2.apply(utils, arguments);};

Мы бы хотели, чтобы TypeScript понимал такие кейсы и генерировал вызов apply, передавая в него arguments.

// получаем замедление в ES5:Component.prototype.fn1 = function(path) {    var vars = [];    for (var _i = 1; _i < arguments.length; _i++) {        vars[_i - 1] = arguments[_i];    }    utils.fn2.apply(utils, __spreadArrays([path], vars));};

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

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

=> вместо bind


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



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


Источник

Под капотом такая конструкция означает вот что: в конструкторе объекта создается поле onClick, где записывается стрелочная функция, привязанная к контексту. То есть в прототипе метод onClick не существует!



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

Это только минусы в производительности. Но я еще не закончил.



С наследованием такого кода появляются проблемы:

  • Если в классе-потомке мы создадим метод onClick, он будет затерт в конструкторе предка.
  • Если мы все-таки как-то создадим метод, то все равно не сможем вызвать super.onClick, потому что на прототипе метода не существует.
  • Хоть как-то переопределить onClick в классе-потомке, опять же, можно только через стрелочную функцию.

Это еще не все минусы.


Источник

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

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

@boundMethod вместо bind


Хорошо, тогда разработчики говорят: у нас есть декораторы. В частности, такой интересный декоратор @boundMethod, который вместо нас магически привязывает контекст к нашему методу.

import {boundMethod} from 'autobind-decorator';class Component {    @boundMethod    method(): number {        return this.value;    }}

Выглядит красиво, но под капотом этот декоратор делает следующие вещи:

const boundFn = fn.bind(this);Object.defineProperty(this, key, {    get() {        return boundFn;    },    set(value) {        fn = value;        delete this[key];    }});

Он все равно вызывает bind. И в придачу определяет getter и setter с именем вашего метода. Можно сразу сказать, что getter и setter никогда не работали быстрее, чем обычное чтение и запись поля.

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

class Base extends Component {    @boundMethod    method() {}}class Child extends Base {    method = debounce(super.method, 100);}

Кроме того, очень легко выстрелить себе в ногу и организовать утечку памяти, всего лишь вызвав в классе-потомке debounce для нашего метода.



В DevTools это выглядит примерно так. Мы видим, что в памяти накапливаются старые экземпляры компонента Child. И если посмотреть в одном экземпляре, как у него выглядит этот метод, то мы увидим целую цепочку из bind-function-debounced-bind-function-debounced- и так далее. И в каждом из этих debounced в замыканиях содержатся предыдущие экземпляры Child. Вот вам утечка памяти на ровном месте, когда можно было ее избежать.


Ссылка со слайда

Задним числом хотелось бы сказать: перед тем, как вы решили использовать эту библиотеку в продакшене, хотелось бы посмотреть на то, как работает ее код. Одного знания, что ее код вместо одного вызова bind делает такие вещи, как getter и setter, было бы достаточно, чтобы не хотеть ее использовать.

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

Не используйте этот декоратор как минимум до тех пор, пока баг не будет исправлен.

TL;DR


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

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

У меня все. Вот ссылка на документ со всеми упомянутыми материалами.
Подробнее..

Как не держать лишнее железо и справляться с ростом нагрузки внедрение graceful degradation в Яндекс.Маркете

14.01.2021 12:05:51 | Автор: admin

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

Проблема

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

Обычное состояниеОбычное состояние

Но когда все ДЦ работают, каждый из них оказывается задействован лишь на 60%. Ещё 10% резервируются для экспериментов и непредвиденного роста, а оставшиеся 30% используются только в случае, если один из ДЦ отключается, и нужно перераспределить запросы.

ДЦ 2 отключёнДЦ 2 отключён

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

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

ДЦ 1 и ДЦ 3 не справляются с нагрузкойДЦ 1 и ДЦ 3 не справляются с нагрузкой

Применяем graceful degradation

Graceful degradation или изящная деградация это подход к построению систем, при котором система продолжает выполнять свою основную функцию, даже если отдельные её элементы вышли из строя.

Мой коллега из Яндекс.Такси уже рассказывал про graceful degradation в случае, когда ломается часть сервиса. Мы же применили этот подход на бэкендах для ситуации, когда у нас отключается значительное количество серверов.

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

Чтобы запустить graceful degradation, нам надо было решить две задачи:

  1. Разработать механизм уменьшения нагрузки.

  2. Сделать автоматизацию включения механизма.

Механизм уменьшения нагрузки

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

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

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

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

Бэкенд получает запрос и раcпределяет его на 8 серверов с шардами. В шардах хранятся предложения от магазинов.

Общая схема обработки поискового запросаОбщая схема обработки поискового запроса

На каждом шарде поиск проходит несколько стадий. На стадии фильтрации ищется примерно 50000 предложений, это число зависит от категории. На этапе ранжирования для каждого предложения вычисляется релевантность, учитывается цена, рейтинг товара, рейтинг магазина и ещё более 2000 факторов. ML по факторам вычисляет вес каждого предложения. Затем берётся только 48 лучших. Meta Search получает эти 48 предложений с каждого шарда, то есть всего 48*8=384 предложения. Предложения снова ранжируются, опять берётся 48 лучших. Последние 48 уже показываются пользователю. То есть чтобы показать нашим пользователям 48 телефонов, мы обрабатываем 400 000 предложений.

Количество обрабатываемых документов без graceful degradationКоличество обрабатываемых документов без graceful degradation

В случае с graceful degradation, когда надо уменьшить нагрузку, мы можем скомандовать: теперь обрабатывай 95% документов, а теперь 90% или 80%. Если обрабатывать 95%, то есть 400000*0.95=380 000 документов, то из них всё равно выбираются 48 лучших предложений для выдачи. И в среднем только 2 предложения будут отличаться от изначальной выдачи без снижения качества. При таком маленьком изменении большинство пользователей даже не заметят разницы.

Количество обрабатываемых документов при включении механизма уменьшения нагрузкиКоличество обрабатываемых документов при включении механизма уменьшения нагрузки

Автоматизация включения механизма

Автоматизация работает за счёт постоянного мониторинга загрузки CPU. Если нагрузка становится выше 90%, автоматика начинает снижать качество. На 90% снижение небольшое, но если нагрузка продолжает расти, процент деградации повышается линейно и доходит до максимума при 100% загрузки CPU. Такой подход позволяет снижать качество минимально.

Общий алгоритм выглядит так:

При выключении ДЦ: балансеры перераспределяют запросы в оставшиеся ДЦ => нагрузка на CPU повышается => при превышении порогового значения происходит снижение качества по заданной формуле.

При включении ДЦ: балансеры перераспределяют запросы на все ДЦ => нагрузка на CPU снижается => понижение качества прекращается.

Повышение нагрузки при выключении ДЦ. Линии на верхнем графике показывают загрузку CPU в отдельных ДЦ. Нагрузка выросла с 82% до 98%. Нижний график показывает процент срезанных документов. Повышение нагрузки при выключении ДЦ. Линии на верхнем графике показывают загрузку CPU в отдельных ДЦ. Нагрузка выросла с 82% до 98%. Нижний график показывает процент срезанных документов.

Внедрение

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

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

Выводы

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

Подробнее..

Как мы интегрировали Яндекс.Музыку с Siri. Доклад Яндекса

19.01.2021 12:23:47 | Автор: admin
Siri мощный инструмент с публичным API для сторонних приложений. Например, музыкальных. В докладе я рассказал, как начать разработку обработки голосовых медиазапросов от Siri, используя Intents.framework. Поделился нашим опытом с чем пришлось столкнуться, чего нет в документации и что не работает.

Всем привет! Меня зовут Ваня, я из команды Яндекс.Музыки. Сегодня я вам расскажу, как Siri попала в Яндекс.Музыку. Музыку можно включать с помощью Siri.

Чтобы вам было понятно, что это и как работает, пример первый. Говорим: Включи Сектор Газа в Яндекс.Музыке и бум, музыка пошла. Второй пример: можно сказать Мне нравится этот трек в Яндекс.Музыке. Вы идете, слушаете, не хотите доставать телефон, whatever. Все полайкано, все хорошо.



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



Пасхалка от Apple. На страничке документации класса INPlayMediaIntent есть много примеров того, как это работает на разных языках. На русском написано: Играй Qeen на Яндекс Музыке. Это было сделано еще до того, как мы реализовали поддержку Siri, так что Apple, спасибо вам большое. Это очень лестно.

Зачем мы это делали? Во-первых, почему бы и нет, крутая фича. Во-вторых, это была часть большой задачи по реализации Яндекс.Музыки под Apple CarPlay, но мы сейчас не об этом.

Давайте теперь про Siri. Siri появилась в iPhone 4S, начиная с iOS 5, если я все правильно гуглил. Она выглядела вот так, была совсем неуклюжей. Только к iOS13, на WWDC 2019 показали, что теперь вы можете реализовывать в своих музыкальных приложениях поддержку Siri. Здорово.



Как это работает? Я не придумал ничего лучше, чем просто взять этот слайд из презентации WWDC. Пользователь говорит что-то Siri. Siri это обрабатывает и отдает вам данные в какой-то extension. Вы с этими данными идете в ваши сервисы, бэкенды, app-группы, общие контейнеры. Это работает с вашим приложением, но не всегда. Дальше объясню, почему, и расскажу всю обратную сторону: чтобы вам на экране показалось то, что надо, Siri сказала то, что надо, и так далее.

Типы интентов. Первый INPlayMediaIntent, интент из серии включи что-нибудь. INAddMediaIntent это добавь что-нибудь. Добавь этот трек в плейлист, когда грустно. INUpdateMediaAffinityIntent это интент лайк/дизлайк. Последний INSearchMediaIntent, найди. То есть вы говорите: Найди Сектор Газа в Яндекс.Музыке. Открывается приложение Яндекс.Музыка, в котором сразу открыт Сектор Газа.



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



Как я говорил, это extension. Называется IntentsExtension. Его нужно создать. Вы его создаете, у вас появляется таргет, в котором вы должны написать строками названия классов, этих интентов, которые вы поддерживаете. Как видите, у нас их два: INPlayMediaIntent и INUpdateMediaAffinityIntent.

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



Если это не медиазапрос, а что-нибудь другое например, у Siri есть еще поддержка заметок, то там будут другие типы.

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



Про реализацию протокола INMediaIntentHandling. Это протокол, как очевидно из названия, обрабатывает INPlayMediaIntent. У него больше двух методов, чуть ли не семь. Но я расскажу про эти два, потому что они нам как раз понадобились. Мы их реализовали. Это resolveMediaItems, такой метод нужен для того, чтобы вы собрали данные, с которыми Siri за вас что-то сделала. Вы пошли в ваш поиск, помапили нужные данные для Apple и вернули их в коллбэк. handle это первая часть обработки этих данных. Дальше объясню, почему первая.



У этих двух методов есть общий параметр: INPlayMediaIntent. Давайте посмотрим, что это такое. Здесь много букв. Запомните MediaItems мы потом о нем еще поговорим. Здесь есть куча всего. Например, playback speed для подкастов. Играть с шафлом, без шафла. Repeat mode. Но сейчас нам нужен mediaSearch.



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



Пример 1: Включи трек Skyfall от Adele в Яндекс.Музыке. Вы можете это сказать Siri прямо сейчас, если у вас есть подписка Яндекс.Музыки. Слово включи определяет тип интента. INPlayMediaIntent. Соответственно, будут вызываться те методы, которые я показывал ранее. Слово трек определяет поле mediaType, значение song. Его говорить необязательно, дальше объясню, почему. Когда вы произносите такие дополнительные штуки для Siri, вы улучшаете качество вашего поиска. Вы все еще можете сказать Включи Skyfall в Яндекс.Музыке. Если наш поиск посчитает, что Skyfall это трек, который вам нужен, Siri именно его и включит.

Слово Skyfall определяет mediaName. От Adele определяет artistName. Как вы можете заметить, предлог от просто игнорируется, потому что Siri сама за вас поняла: от значит, что следующим будет название артиста. И последняя часть: в Яндекс.Музыке, в каком приложении это должно работать. К сожалению, мы не можем назначить музыкальное приложение по умолчанию. Поэтому нужно всегда в конце добавлять: в Яндекс.Музыке.

Пример 2: Включи грустную акустическую музыку в Яндекс.Музыке. Включи понятно. Слово грустную определяет moodNames == [sad]. Обратите внимание, что тут написано на английском, а не на русском. Есть список констант, который матчит ваши слова в moodNames, вот этот массив, но в документации его нет. Готовьтесь.

Слово акустическую определяет genreNames, которое тоже написано на английском. Но эти константы уже есть в документации. Зайдя в документацию по INMediaSearch.genreNames, вы увидите, что она там есть. Огромная таблица, в которой написано, какие жанры понимает Siri. Главное, если вы будете реализовывать это у себя, приготовьтесь к тому, что ваш поиск должен понимать английский язык. Наш, к счастью, понимает.

Слово музыка определяет mediaType==.music. Это считается типом сущности, который можно воспроизводить.



Пример 3: Скажи Яндекс.Музыке включить рок. То есть мы полностью поменяли слова во фразе местами. И это все равно работает. Еще есть вот такая штука: Включи музыку, чтобы уснуть, в Яндекс.Музыке. Казалось бы, что здесь такого? То ли в genreNames, то ли в moodNames будет слово meditation. Почему здесь слово meditation, решает только Siri. Ваше дело реализовать то, что сказала Siri, а дальше надо разбираться самим. И еще куча всего, чего мы не знаем. Возможно, есть и другие фразы, но не в документации. Надо готовиться к тому, что Siri сделает кучу всего за вас. Это прикольно, но одновременно очень странно.





Дальше расскажу прикольную штуку. Siri в прошлом году обучили на библиотеке Apple Music. Когда вы начинаете разговаривать с Siri в музыкальном контексте, например, Включи Сектор Газа в Яндекс.Музыке, она поймет, что Сектор Газа это исполнитель, и сама подставит значение, сделает все за вас.

Вы можете даже сказать на английском: Play Sector Gaza in Yandex.Music. Сектор Газа нормально распарсится. Это очень здорово. До этого был вообще кошмар. Во-первых, как видите, имя Децл она не смогла спокойно распознать. А вот тут она еще почему-то взяла название проекта Xcode, которого ни в каких константах нет. Очень странно. Если ваш проект называется в Xcode Суперпуперприкольное приложение, то здесь будет написано то же самое, хотя название самого приложения другое. Очень странно. Видите, внизу написано Я.Музыка и все окей.

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



Далее вы должны взять этот mediaSearch и склеить эти данные. Мы берем практически все, что есть, помещаем в один массив, делаем из него строку, где каждый элемент просто разбит через пробел, и отправляем в наш поиск, потому что наш поиск такое может съесть. Это здорово. Дальше вы мапите эти данные и отдаете в коллбэки с результатом success. Но важно, как мапить эти данные и во что.




Помните, я вам говорил запомнить INMediaItem? Это они и есть. Вы должны помапить ваши сущности в INMediaItem. Это пример того, как у нас мапятся треки. Для всех остальных сущностей типа плейлиста, артиста, альбома, whatever, все идет таким же образом. Поле mediaItems в интенте будет заполнено данными, которые вы запомнили. Давайте разберем, что куда летит. Оно иногда может показываться на экране дальше покажу, как. identifier вы заполняете, скорее, для себя. Это id сущности, который хранится у вас на стораджах и на бэкенде. Title, тип, обложка, артист вот они. Все здорово.



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



Так вот, это оно и есть. В AppDelegate, где же еще, вы должны реализовать еще один метод, который называется application handle with completionHandler, в котором появляются базовые классы интента. Поскольку у нас музыкальное приложение, то мы проверяем только на музыкальные интенты на то, что это INPlayMediaIntent. Дальше отдаем это в класс, который умеет ходить на бэкенды и качать треки, помещаем все это в плеер и получается вот так. Все, что нужно. Самое прикольное: если вы вернете больше одного успешного результата, то Siri это видно на виджете плеера на первом скриншоте покажет кнопку Maybe you wanted. При тапе на эту кнопку открывается второй экран, который находится справа. Там как раз будут сущности, которые вы еще не искали. Максимум четыре. Вы можете сложить туда хоть миллион, но система покажет только четыре. В целом здорово, ничего страшного.



Дальше давайте поговорим про INUpdateMediaAffinityIntentHandling. Из названия протокола очевидно, что он умеет обрабатывать интент INUpdateMediaAffinity. Это как раз лайки и дизлайки. Тут намного интереснее. У самого протокола, по-моему, четыре-пять методов. Я расскажу про три из них, которыми мы воспользовались.



Они вызываются в таком порядке: resolveMediaItems, resolveAffinityType, IntentHandler.

resolveMediaItems работает так же, как и с предыдущим интентом. Вы берете эти данные, идете в ваш поиск, мапите в INMediaItem и возвращаете в коллбэки.

Все то же самое. resolveAffinityType. Нужно проверить, что вы можете с этой конкретной сущностью, которую вы нашли в поиске, совершить это конкретное действие. Например, лайк или дизлайк. Дальше покажу подробнее, зачем это нужно. Handle уже одинарный, не двойной, в котором мы должны совершить это действие лайк/дизлайк. У них есть общий параметр. Это INUpdateMediaAffinityIntent. Давайте разберем, что это такое. Он гораздо меньше.



У него есть три поля. С mediaItems и mediaSearch мы уже знакомы. Что такое affinityType? Это enum, у которого есть три значения: unknown, like и dislike. В целом понятно, это как раз тип действия, которое вы должны совершить.



С mediaSearch вы уже знакомы, но у него есть одно поле, которым мы не пользовались: reference.



Что это такое? Это значение INMediaReference, тоже enum. У него есть два значения: unknown и currentlyPlaying.



Если еще кто-то не догадался, что это такое, то давайте я вам покажу на примерах.

Пример 1. Мне нравится трек Skyfall от Adele в Яндекс.Музыке. Фраза мне нравится определяет тип интента, INUpdateMediaAffinityIntent. То есть по этому протоколу будет вызываться именно ваш код, INUpdateMediaAffinityIntentHandling. Также это определяет поле affinityType как like, потому что Мне нравится. Cлово трек определяет mediaType==.song так же, как раньше.

Skyfall точно так же определяет поле mediaName. Adele artistName. В целом понятно.

Пример 2. Мне не нравится этот трек в Яндекс.Музыке. Тут по-другому. Мне не нравится определяет тип интента и affinityType==dislike, так как Мне не нравится.

Слово этот определяет слово reference как currentlyPlaying. То есть как раз то самое значение, то, что сейчас играет.

Слово трек определяет mediaType==.song, которое также необязательно, потому что можно сказать: Мне нравится это в Яндекс.Музыке. Этого будет достаточно. Но трек улучшит поиск.




Реализация resolveMediaItems. В начале вы точно так же проверяете логин, подписку. Дальше идет небольшой паттерн-матчинг, примерно похожий на тот, который есть у нас в коде. Пример resolveNotCurrent я рассматривать не буду, потому что он точно такой же. Вы берете все данные, которые есть у вас в интенте, в mediaSearch, идете в ваш поиск, мапите и возвращаете в коллбэке. Все здорово. Но я расскажу про вот эту штуку, потому что она интереснее. resolveCurrent. Во-первых, как вы можете заметить, этот enum работает не совсем правильно. CurrentlyPlaying это хорошо, если сказать, что мне нравится этот трек. Но если сказать, мне нравится это, значение будет unknown, а query будет пустым. Почему так? Понятия не имею. Но это так. Мы это поняли в момент испытания Siri. Это очень странно, но работает именно так. Давайте теперь подумаем. currentlyPlaying, что сейчас сыграет. Extension это другая часть приложения. У нас нет доступа.

Что делать? Для начала расскажу, кто не знает, что такое NowPlayingInfo. Это большой словарь с кучей стандартных ключей, которые есть в Media Player framework, если я ничего не путаю. Вы его заполняете данными. На виджете плеера, на локскрине и в Control Center появляются как раз те данные, которыми вы заполнили этот словарь.

Apple нам обещала, что если положить в NowPlayingInfo по тому ключу, который вы видите на экране, любое строковое значение, то в intent.mediaSearch?.mediaIdentifier будет как раз то значение, которое лежит в NowPlayingInfo. Но это вообще не работает. Я пытался, не сработало. К счастью, на помощь пришли божественные App Groups, которые работают уже тысячу лет, и никаких проблем с ними нет.



Как они помогли? Вы создаете appGroupUserDefaults, указываете в suitName id вашей app.group. В основном приложении вы вставляете значение по ключу. Из extension достаете по этому ключу. Все работает классно. Я на всякий случай решил воспользоваться ключом, который как раз не работает, чтобы как минимум оставить напоминание самому себе, что это не работает.



Есть еще вот такая штука. Один из результатов, которые нужно запомнить, это disambiguation. Например, пользователь сказал, что ему нравится этот артист в Яндекс.Музыке.

Но играет трек, у которого несколько исполнителей. Что делать? Этот результат как раз для этого и нужен.



Siri отобразит вот такое меню.





Вы можете голосом или тапом выбрать то, что нужно. INMediaItem, один из них уйдет дальше в метод handle. Точнее, сначала в resolve AffinityType. Зачем он нужен? Например, в Музыке так повелось, что мы дизлайкать можем только треки. Артиста или альбом вы дизлайкать не можете. Этот метод нужен как раз для таких случаев. Вы проверяете тип значения, и если это трек, то его можно лайкать и дизлайкать. Если это что-то другое, вы можете только лайкать. Дальше проверяете: если они совпадают, возвращаете константу unsupported. Тут забавно. Siri мне говорит, что это работает для какого-то определенного типа. Поэтому она скажет, что просто не поддерживаются дизлайки. Хотя они поддерживаются, но только для треков. Спасибо!




Метод handle. Вы точно так же проверяете MediaItems, который у нас есть, берете его id и дальше должны сходить в API и полайкать. То есть в целом все просто.



По ответу от сервера вы можете вернуть два значения: success или fail. Если пришла ошибка, то все плохо. Siri обязательно об скажет, что произошла какая-то ошибка, либо как я показывал в примере: Я сказала Яндекс.Музыке, что вам это нравится.

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



Как они нам помогли и что это такое, сейчас объясню. Дарвиновские нотификации это core-штука системы. Ими можно обмениваться между таргетами, между приложениями. Отправка выглядит так, обработка так. В целом понятно. Мы из extension отправляем нотификацию, что мы дизлайкнули текущий трек. Это ловит основное приложение, делает скип, все довольны.

Нюанс номер два русский язык. Сейчас объясню, почему. Я тестировал на английском, потому что система у меня стоит на английском. Наше приложение называется Yandex Music. Никаких проблем нет, для Siri тем более. Но на русском языке наше приложение называется Я.Музыка. Когда я попробовал что-то типа Включи Сектор Газа в Я.Музыке, Siri посчитала, что Я сказано случайно и надо включить исполнителя в Apple Music. Вот так это и работало. К счастью, есть решение.



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

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



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

Включи плейлист дня в Яндекс.Музыке. Казалось бы, мы хотим, чтобы слово Включи определило тип интента, а слова плейлист дня определили mediaName. Но это работает по-другому. Слово плейлист определяется как mediaType==.playlist, потому что Siri поняла: нужно включить какой-то плейлист. А слово дня распознается как mediaName.

Есть workaround, но он для пользователя. Включи плейлист плейлист дня в Яндекс.Музыке, где слово плейлист определится как mediaType. Второе слово плейлист определится как mediaName, и все счастливы.

Кажется, можно это закостылить сразу объясню, почему мы не стали этого делать. На разных версиях iOS и на разных языках это работает по-разному. Например, если я скажу на английском: Play playlist playlist of the day in Yandex Music, Siri решит, что вы случайно сказали слово playlist два раза подряд, а of the ничего не значит, она его выкинет. У вас будет mediaName== day. Как вы можете догадаться, включится Green Day (00:26:05). Это аботает не так хорошо, как хотелось бы.

Включи плейлист с Алисой в Яндекс.Музыке. Тут еще интереснее. Включи по-прежнему определяет интент. Слово плейлист определяет playlist. А с Алисой определяется как artistName.

Знаете, почему? Потому что есть такая рок-группа Алиса. Русская Siri посчитала, что Алиса это та самая рок-группа. Причем если сказать ту же самую фразу на английском, то включится исполнитель, которого зовут A-List.

К этому можно было бы найти решение. Есть класс INVocabulary, который может задавать для Siri кастомный вокабуляр ваших сущностей в приложении. Слишком умно сказано, в чем соль? Вы можете передать туда название ваших сущностей, как у нас плейлист дня и плейлист с Алисой. Передаете по специальному типу mediaPlaylistTitle, чтобы Siri поняла, что это такие плейлисты. И все должно заработать. Это первая фишка из моего опыта, которая кидает exception при обращении к ней, если не выставлен entitlement для этой API. Я проверял, оно не помогает. Они это как-то асинхронно делают.

Вторая проблема. Все это, к сожалению, не сработало. Слово плейлист все-таки важнее для Siri как тип сущности, а не как название этой сущности.



Нюанс номер пять. Когда мы закинули сборку в App Store Connect, нам пришло письмо счастья с перечислением проблем приложения. К счастью, это был просто warning, не автоматический reject о том, что Siri реализована неправильно. В письме было сказано, что мы не представили примеры фраз по каждому из языков, со ссылкой на документацию.

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

В этих строчках нужно писать для каждого интента примеры того, как пользователю пользоваться Siri. Например: Включи рок в Яндекс.Музыке, Мне не нравится этот трек. Я это сделал. Потом у меня возник вопрос: где это показывается? В документации этого нет. Никто ничего не пишет.



В какой-то момент до меня дошло. Помните бородатые времена, когда была iOS 13 и Siri была полноэкранной? У нее, если совершить определенное количество действий, появлялись подсказки. Там есть сторонние приложения. Вы видите Яндекс.Музыку и Telegram. Почему это здесь написано, мне неизвестно, но Apple, очевидно, это чинить не будут, потому что Siri в iOS 14 уже неполноэкранная. Там просто маленький красивый кругляшок снизу, и все.



Итого:

  1. Siri это круто. Можно идти в плохую погоду, например по ужасному морозу, и говорить, что нужно включить, что лайкнуть, что дизлайкнуть.
  2. Siri неплохо задокументирована, почти без багов. Я никаких серьезных багов сегодня не приводил.
  3. Если у вас тип сущности содержится в названии этой сущности, то вы страдаете вместе с нами.

А помимо того, что всем это нравится и все довольны, вы получаете заветную маленькую иконку в App Store для вашего приложения, на которой написано, что Siri его поддерживает. Очень здорово и мило. На этом у меня всё, всем спасибо!
Подробнее..

Перевод ARM и программирование без блокировок

22.01.2021 10:22:11 | Автор: admin


Выпуск ARM-процессора Apple M1 вдохновил меня на то, чтобы написать в Твиттер про опасности программирования без блокировок (lock-free). Этот твит вызвал бурную дискуссию. Обсуждение прошло довольно неплохо, учитывая то, что попытки втиснуть в рамки Твиттера обсуждениие такой сложной темы, как модели памяти центрального процессора, в принципе бессмысленны. Но у меня осталось желание немного раскрыть тему.

Этот пост задуман не только как обычная вводная статья про опасности программирования без блокировок (о которых я в последний раз писал около 15 лет назад), но и как объяснение, почему слабая модель памяти ARM ломает некоторый код, и почему этот код, вероятно, не работал изначально. Я также хочу объяснить, почему стандарт C++11 значительно улучшил ситуацию в программировании без блокировок (несмотря на возражения против противоположной точки зрения).

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

Основные проблемы программирования без блокировок лучше всего объяснять на примере паттерна производитель/потребитель, в котором не используются блокировки и поток-производитель выглядит следующим образом (псевдокод C ++ без деления на функции):

// Поток-производительData_t g_data1, g_data2, g_data3;bool g_flagg_data1 = calc1();g_data2 = calc2();g_data3 = calc3();g_flag = true; // Сигнализируем, что данные можно использовать.

А вот поток-потребитель, который извлекает и использует данные:

// Поток-потребительif (g_flag) {  DoSomething(g_data1, g_data1, g_data2, g_data3);

Здесь опущено множество деталей: Когда сбрасывается g_flag? Как потоки избегают простоя (spinning)? Но этого примера достаточно для наших целей. Вопрос в том, что не так с этим кодом, особенно с потоком-производителем?

Основная проблема в том, что этот код предполагает, что данные будут записываться в три переменные g_data до флага, но этот код не может этого гарантировать. Если компилятор переставит местами инструкции записи, то флаг g_flag может получить значение true до того, как будут записаны все данные, и поток-потребитель увидит неверные значения.

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

Компиляторам разрешено переставлять местами инструкции записи, потому что есть правило as-if, которое гласит, что компилятор выполнил свою работу, если программа, которую он генерирует, ведет себя так, как если бы её не оптимизировали. Поскольку абстрактная машина C/C ++ долгое время допускала только однопоточное выполнение без внешних наблюдателей вся эта перестановка инструкций записи была правильной и разумной и использовалась десятилетиями.

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

  1. Объявить g_flag как volatile. Это не позволит компилятору пропустить чтение/запись g_flag, но, к удивлению многих, не избавит от основной проблемы перестановки. Компиляторам запрещено переставлять инструкции чтения/записи volatile-переменных относительно друг друга, но разрешено переставлять их относительно обычных инструкций чтения/записи. То, что мы добавили volatile, никак не решает нашу проблему перестановок (/volatile:ms на VC ++ решает, но это нестандартное расширение языка, из-за которого код будет выполняться медленнее).
  2. Если недостаточно объявить g_flag как volatile, тогда давайте попробуем объявить все четыре переменные как volatile! Тогда компилятор не сможет изменить порядок записи и наш код будет работать на некоторых компьютерах.

Оказывается, не только компиляторы любят переставлять инструкции чтения и записи. Процессоры тоже любят это делать. Не нужно путать это с out-of-order исполнением, всегда незаметным для вашего кода, к тому же на самом деле есть in-order процессоры, которые меняют порядок чтения/записи (Xbox 360), и есть out-of-order процессоры, которые в большинстве случаев не меняют местами чтение и запись (x86/x64).

Таким образом, если вы объявите все четыре переменные как volatile, вы получите код, который будет правильно исполняться только на x86/x64. И этот код потенциально неэффективен, потому что при оптимизации нельзя будет удалить никакие операции чтения/записи этих переменных, а это может привести к лишней работе (например, когда g_data1 дважды передается в DoSomething).

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

Чтобы предотвратить перестановку в x86/x64, нужно использовать компиляторный барьер памяти. Вот как это делается:

g_data1 = calc1();g_data2 = calc2();g_data3 = calc3();_ReadWriteBarrier(); // Только для VC++ и уже deprecated, но для 2005 это окей.g_flag = true; // Сигнализируем, что данные можно использовать.

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

Проблема в том, что этот код не будет работать на процессорах со слабой моделью памяти. Слабая модель памяти означает, что процессоры могут переставлять инструкции чтения и записи (для большей эффективности или простоты реализации). К таким процессорам относятся, например, процессоры с архитектурами: ARM, PowerPC, MIPS и практически все используемые сейчас процессоры, кроме x86/x64. Эту проблему решает тот же барьер памяти, но на этот раз это должна быть инструкция процессора, которая сообщает ему, что менять порядок не нужно. Что-то вроде этого:

g_data1 = calc1();g_data2 = calc2();g_data3 = calc3();MemoryBarrier(); // Дорогостоящий полный барьер памяти (full memory barrier). Только для Windows.g_flag = true; // Сигнализируем, что данные можно использовать.

Конкретная реализация MemoryBarrier зависит от процессора. На самом деле, как сказано в комментарии в коде, MemoryBarrier здесь не идеальный выбор, потому что нам просто нужен барьер между записями в память (write-write) вместо гораздо более дорогого полного барьера памяти (full memory barrier), который заставляет операции чтения ждать окончательного завершения операции записи. Но сейчас этого достаточно для наших целей.

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

#ifdef X86_OR_X64#define GenericBarrier _ReadWriteBarrier#else#define GenericBarrier MemoryBarrier#endifg_data1 = calc1();g_data2 = calc2();g_data3 = calc3();GenericBarrier(); // И почему мне пришлось самому это писать?g_flag = true; // Сигнализируем, что данные можно использовать.

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

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

Код, который я привёл выше, может содержать ошибки (как реализованы эти барьеры?), к тому же он избыточен и неэффективен. К счастью, когда появился C++11, у нас появились варианты получше. На самом деле до C ++ 11 в языке не было модели памяти, было лишь встроено неявное предположение, что весь код является однопоточным, и если вы меняете общие данные не под блокировкой, то пусть бог простит вашу грешную душу. В C ++ 11 появилась модель памяти, которая признаёт существование потоков. Стало очевидным, что приведенный выше код без барьеров не работал, но одновременно у нас появились возможности, чтобы его исправить, например:

// Поток-производительData_t g_data1, g_data2, g_data3;std::atomic<bool> g_flag // Обратите внимание!g_data1 = calc1();g_data2 = calc2();g_data3 = calc3();g_flag = true; // Сигнализируем, что данные можно использовать.

Небольшое изменение, которое легко не заметить. Я только изменил тип данных g_flag с bool на std :: atomic . Для компилятора это означает не игнорировать инструкции чтения и записи этой переменной (ну, в основном), не менять порядок чтения и записи между чтением и записью в эту переменную и, при необходимости, добавлять соответствующие процессорные барьеры памяти. Мы даже можем немного оптимизировать этот код:

// Поток-производительData_t g_data1, g_data2, g_data3;std::atomic<bool> g_flagg_data1 = calc1();g_data2 = calc2();g_data3 = calc3();g_flag.store(true, std::memory_order_release);

С помощью memory_order_release мы сообщаем компилятору, что именно мы делаем, чтобы он мог использовать соответствующий (менее затратный) тип инструкции барьера памяти или, наоборот, вообще не использовать барьер памяти (в случае x86/x64). Наш код теперь относительно чист и наиболее эффективен.

Теперь написать поток-потребитель очень просто. Фактически, с новым описанием g_flag исходная версия потока-потребителя теперь верна! Но мы можем ещё немного улучшить её:

// Поток-потребительif (g_flag.load(std::memory_order_acquire)) {  DoSomething(g_data1, g_data1, g_data2, g_data3);

Флаг std :: memory_order_acquire сообщает компилятору, что нам не нужен полный барьер памяти. Барьер чтения-захвата (read-acquire) гарантирует, что до g_flag нет чтения общих данных, не блокируя при этом другие перестановки.

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

Если вы хотите научиться использовать этот подход, советую вам начать с внимательного изучения статей: Jeff Preshings introduction to lock-free programming или This is Why They Call It a Weakly-Ordered CPU. После этого подумайте, не лучше ли вам уйти в монастырь (мужской или женский). Lock-free programming это самое опасное оружие в арсенале C++ (а это о многом говорит) и применять его стоит лишь в редких случаях.

Примечание: Чтобы написать x86-эмулятор на ARM, придётся помучаться с этим подходом, потому что никогда не знаешь, в какой момент перестановка инструкций станет проблемой. Из-за этого приходится вставлять много барьеров памяти. Или можно следовать стратегии Apple, то есть добавить в CPU режим, который обеспечивает порядок доступов к памяти как в x86|x64 и включать его при эмуляции.
Подробнее..

Любовь. Python. C. Доклад Яндекса

29.01.2021 10:18:54 | Автор: admin
Что связывает языки Python и C++? Как извлечь из этого выгоду лично для себя? На большой конференции Pytup Александр Букин показал способы, благодаря которым можно оптимизировать свой код, а также выбирать и эффективно использовать сторонние библиотеки.

Всем привет, меня зовут Александр Букин, я разрабатываю Яндекс.Погоду. Вы еще можете знать меня как сооснователя Pytup. Также я состою в программных комитетах таких классных конференций, как PyCon.ru и YaTalks.

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

Дисклеймер: название доклада это отсылка к прекрасному сериалу Любовь. Смерть. Роботы на Netflix. Всем очень советую.

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



Этот герой был силен, он не по годам хорошо осваивал силу. У него было много мидихлориан. А еще все знали, что этот парень очень быстрый.



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



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



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



Я считаю, это прекрасно. Давайте поймем, почему для нас, для разработчиков, это может быть полезно, а не только интересно. Как я и упомянул, C++ всегда был очень быстр, а Python не всегда. Скорее он даже прославился тем, что его производительность в важных задачах иногда страдает. Это как раз то, чем C++ может помочь ускорить ваш код, некоторые части, которые вы в своих сервисах вызываете действительно часто. Давайте выясним, как это сделать.

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



Так как Python написан на C, у него есть довольно большое количество способов интеграции с ним. Сегодня мы рассмотрим два из них Cython и Native Extension на C/C++. Подробнее остановимся именно на нативных расширениях. Да, там есть еще подмножество. Но, во-первых, эти два мои любимые. Во-вторых, целого доклада не хватит рассказывать про каждый из них.

Небольшое введение про Cython. Это довольно популярная технология: например, gevent написан с помощью него. Он легко интегрируется в сборку проекта, именно поэтому он мне нравится. У Cython достаточно хорошая документация. Еще для разработчика есть полезная вещь cythonize. Это оборачивание вашей функции вызов этой функции, который в среднем на синтетических тестах даже дает ускорение в выполнении кода до двух раз.


Ссылка со слайда

Понятно, что есть и минусы. Во-первых, хотя Cython, в отличие от других способов, предлагает вам писать на близком к CPython синтаксисе, все равно в мелочах они отличаются. И иногда это мешает. Во-вторых, когда вам всё-таки приходится переключаться на C, его необходимо знать. Но здесь, кстати, необязательно знать его всегда. Конечно же, присутствует наш любимый Segmentation Fault, который можно словить, если плохо поработать с памятью уже в сыром C. Из этого же растут ноги сложностей в дебаггинге. Но если вам не хочется очень глубоко погружаться в C, а хочется попробовать ускорить свою технологию прямо сейчас, Cython хороший выбор.

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


Ссылка со слайда

Полезть внутрь это как раз использовать нативный Extension. Несмотря на то, что это не самая простая технология, она родная. Ее документация есть прямо в доке Python. Она тоже легко интегрируется в сборку проекта и позволяет создавать свои типы данных. Здесь вам уже никак не обойти необходимость знания C и написания на нем кода. Здесь придется, если вы будете делать что-то сложное, поработать с памятью и поотлаживаться.



Маленький пример. Вот минимальный файл spammodule.c, в котором описывается, как ни странно, модуль спама. Как мы видим, мы подключаем заголовочный файл include Python.h, который понадобится нам для любого модуля. И описываем наш модуль. Говорим, какое у него будет имя, и описываем функцию его инициализации PyInit_spam. Дальше вызываем PyModule_Create, который либо возвращает null, либо возвращает модуль.



Чтобы все это заработало, необходимо всё-таки сбилдить наш модуль. Для этого можно воспользоваться setuptools. Мы пишем небольшой setup.py, в котором указываем, что нам нужен Extension. Говорим, как его называть и откуда брать исходники. Запускаем setup.py build, setup.py install. Можно импортить, можно использовать.

Это пример для C. Пример для С++ выглядит очень похоже.



Просто пишем исходник на плюсах, добавляем sources spammodule.cpp, и указываем, что язык у нас тоже С++. Поздравляю, вы прекрасны. Всего лишь нужно в разделе вашего файла .c или .cpp написать валидный, хорошо работающий, правильно интерпретирующий работу с памятью код на C и на плюсах. Возможно, вы к этому не очень готовы. Может быть, вам просто лень это делать, или вы думаете: я же разработчик, а разработчик не делает своих велосипедов. Наверное, раз это такой родной и давно используемый механизм, уже есть кто-то, кто это сделал. И да, уже есть.

Давайте посмотрим парочку примеров. Например, есть ujson.



Что мы делаем часто, как все разработчики? Перекладываем джейсончики.

Эта библиотека используется довольно легко. У нее есть стандартные функции dumps и loads. И внутри она реализована как раз с помощью Extension. Там парочка C-файлов и оберточка сверху.

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

Для начала возьмем не очень большой файл, который возвращает API Twitter, JSON, размером 600 килобайт.


Ссылка со слайда

Я взял за основу две очень популярные Python-библиотеки: json и simplejson. Мы получаем, что сериализация и десериализация в районе 3,5 миллисекунд у json и 3,15 у simplejson. Выглядит довольно быстро. Как вы думаете, за сколько это сделает ujson? Допустим, он сделает это за 3 секунды ровно. Может быть, за 2,9. Но на самом деле прирост будет больше. Я добавил ссылку на бенчмарк, и сериализация заняла практически 2 миллисекунды. Как мы видим, прирост в полтора раза, довольно неплохо. Но конечно, хотелось бы большего.


Ссылка со слайда

Возьмем файлик побольше canada.json. Это файл с геоточками на 2 мегабайта. Видим, что тот же simplejson уже работает не так однозначно, ему потребовалось целых 80 миллисекунд на сериализацию. json немножко получше. Но ujson здесь вырывается вперед гораздо сильнее на большом количестве данных, и мы уже получаем прирост в четыре с половиной раза относительно simplejson с сериализацией и в три раза относительно json. Отличный результат.

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

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



Посмотрим на задачу, которая часто встречается и которая в Python исторически не очень быстро работает, парсинг дат. Есть такая библиотека ciso8601. Она тоже написана с помощью C Extension. Вот так выглядит ее использование. Довольно просто. Есть функция parse_datetime, в которой вы передаете строчку в одном из поддерживаемых форматов. Это парсится в стандартный datetime object. Даже поддерживает тайм-зоны.

Давайте тоже побенчмаркаем. Парсить мы будем вот такую строчку: 2014-01-09T21:48:00. Все измерения, которые получим, будут в микросекундах.



Здесь добавилась еще версия Python. Будет интересно посмотреть на разных версиях, как оно работает. Я взял за основу python-dateutil, который фактически является расширенной стандартной библиотекой datetime, и популярный, но написанный на Python str2date.

python-dateutil на версии Python 3.8 делает это за 122 микросекунды видите, необычно, что он чуть замедлился относительно 3.7. Гораздо быстрее, на порядок, делает это str2date. Что же может нам предложить ciso? Наверное, будет одна микросекунда.


Ссылка со слайда

На самом деле будет меньше одной микросекунды, очень быстро. И даже в одном из худших случаев с версией 3.8 это опережает оригинальный datetime парсер в 600 раз. Если мы возьмем версию 2.7, это будет практически в 1000 раз быстрее.

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

Но давайте взглянем на нее чуть подробнее как на хороший пример C Extension. Вот небольшой проектик. Тут много файликов, но самый важный module.c. В этом файлике находится весь код этой библиотеки. Это всего один файлик, и он всего на 586 строчек. Второй важный файлик setup.py. Помните, мы вначале рассматривали spammodule.c. Это точно такая же схема. Есть один C-файлик и один setup.py. Как мы видим, внутри он, конечно, поразвернутее, но вот эта строчка Дай мне, пожалуйста, Extension, обзови его вот так, возьми у него исходники присутствует и здесь.



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

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

Немножко о том, где еще можно узнать, что такое расширение для Python, почему они хороши и как его ускорить. В 2018 году у нас на Pytup выступал Костя Гуков, который рассказывал про расширение на Rust. Там он показывал расширения, которые тоже парсят даты. Забегая вперед, скажу, что они медленнее, чем написанные на C, но тоже очень быстрые. Антон Патрушев на PyCon тоже рассказывал про расширение на Rust:

Смотреть видео

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

Давайте подведем небольшие итоги:

  • Не забывайте оптимизировать свой код на Python. Это первое, что нужно делать всегда, когда кажется, что что-то может происходить быстрее.
  • Если вы провели оптимизацию, попробуйте сторонние библиотеки, которые содержат написанное с помощью C и C++ Extension или с помощью Rust. Возвращаясь немного назад, ujson не самая быстрая библиотека, есть быстрее: orjson, njson. Попробуйте их.
  • И если ваша задача довольно узкая или вам хочется сделать что-то свое, пишите свои расширения, изучайте новые языки для этих расширений. Развивайтесь.

May the Force be with you, друзья.

Подробнее..

Энтерпрайз, который выжил. Доклад в Яндексе

04.02.2021 12:07:48 | Автор: admin
Мы часто задумываемся о том, что нужно изменить, чтобы наша жизнь стала лучше. Но меняться должны не только мы, но и компании, в которых мы работаем. И мы сами можем принимать непосредственное участие в этих положительных изменениях. Вас ждёт маленькая сказка про одну компанию, которая смогла стать лучше. И, конечно же, большие выводы.

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



Присказка


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



Итак, приходит он в новую компанию и видит, что на стене логотип компании, на котором новогодние украшения. На дворе 2 августа. Рядом кто-то заваривает Доширак. И в целом атмосфера запустения и нежелания что-то делать дальше.

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

Сказка


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

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



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

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

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

А внешний монитор ему дали просто волшебный. Под каким углом на него посмотришь, такие цвета и видишь. И такой монитор дали верстальщику. Грустно. Чтобы шея не уставала, подложил Вася два томика Страуструпа под монитор. Стало получше. Ладно, справимся, подумал Вася, и, наконец, загрузилась операционная система. Открыл он почту



И увидел, что в этой компании принято писать очень много писем. А еще в этой компании принято проходит код-ревью в почте. Как это? А вот так. Не было у них ни GitLab, ни Bitbucket, ни GitHub. А просто брали они diff и отправляли его по почте. А потом люди начинали кусочки этого diff комментировать. И ладно, если бы комментировали про код. Очень много обсуждений было про то, что поставил пробела два, скобочки не поставил, где надо, точку с запятой забыл. И вот этим была забита почта.

Ну что, страшно? Уже очень страшно, но еще можно жить. И тогда Вася посмотрел в код. А код этот был, как водится у компании, которая не первый год работает, монолитом. А монолит это не только то, что все команды в одном коде работали, но и то, что все было жестко сцеплено. Не понятно, кто за что отвечает, циклические зависимости, все ужасно. Но не так ужасно, как то, что в этом коде еще и Node-модули лежали закоммиченные. А почему?

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



И ладно, собирается релиз минут 40, так ведь к нему еще надо заполнить очень много бюрократии. Зайти в Confluence, завести страничку. В страничке расписать по строчкам, что у нас в этом релизе катится, какие задачи в него попали. Создать задачи на QA. Как только QA задачу проверят, создать задачу на админов. Как только админы выкатят написать, что все хорошо. На это убьешь еще час. Два релиза за день, вот и вся работа тимлида. Некогда ему еще чем-то заниматься релизы катить надо.



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

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



Код в Bitbucket стал лежать, автоматизацию стал Jenkins делать, все зависимости уехали в Nexus, релизы тоже, появился docker. Тимлид делом занялся. Наконец-то у него появилось время, чтобы взглянуть на своих людей. Он начал проводить встречи one-to-one, понял, где, у кого и что болит. Команды начали переставлять местами. Зарплаты изменились. Все стало гораздо лучше.



Конец ли это сказки? В нашей сказке это конец. Но на самом деле здесь есть еще много чего менять. И, наверное, компания, в которой Вася остался работать, еще многое изменит, станет лучше.

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

Жизнь


Я составил несколько советов, которые мы и рассмотрим по порядку.

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

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

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



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

Ладно. Что еще можно добавить? Правила ветвления кода. Очень часто мы сталкиваемся с тем, что в компании нет никаких правил. Люди просто создают какие-то ветки, как-то это назад вливают. Главное, что есть одна ветка, с которой они как-то собирают релизы, но никаких правил нет. Поэтому рассмотрим два популярных варианта.



Более старый, но известный GitFlow. Посмотрим, что это.

GitFlow говорит нам, что нужно завести две ветки master и dev и работать с ними. Все разработчики работают с веткой dev. Они не имеют права туда коммитить напрямую. У нас есть только механизм ветвления и пул-реквест.

Итак, мы делаем задачу, ответвляемся от ветки dev, делаем коммиты по нашей задаче. Эта ветка называется feature-ветка, или feature branch. Как только мы решили, что все доделано, тестировщик проверил, мы создаем пул-реквест и вливаемся обратно в ветку dev. Как только мы захотим релизиться, мы с ветки dev срезаемся и получаем релизную ветку.

В этой релизной ветке снова проходит тестирование, и если нашли какие-то проблемы, вносят исправления. Появляется еще коммит в релизной ветке.

Дальше, если все хорошо, мы вливаем релизную ветку в master. Это наша версия 2.0, которую мы и отправляем в продакшен. И одновременно доставляем этот код назад в dev. Теперь наш dev равен мастеру и все могут работать дальше.

Чем хороша эта система? В ней нам всегда понятно, что делать.

Вот очень сложный кейс: нам нужно быстро зарелизить hotfix. Все очень просто.



Мы с ветки master, предыдущей версии, срезаем веточку, которая теперь у нас называется hotfix. Делаем в ней исправления. Вливаем назад в master и релизимся. Это наша версия 1.1, которую мы, конечно же, отправляем в dev. Чтобы наш код доехал в dev, наши релизы тоже должны пересобраться, переподтянуть наш dev. Но в целом все будет хорошо.

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

Здесь вы можете увидеть, что ветка master становится несколько редуцированной. Мы можем от нее избавиться и превратить наш dev в master. Такая схема и называется GitHub Flow, давайте на нее посмотрим.



Когда уже нет отдельной ветки master, это значит, что наш dev переименовался в master. Получается, что наш релиз идет из dev, но опирается на теги. Мы у каждого релиза проставляем теги и всегда можем срезать hotfix с тега. В остальном это тот же самый GitFlow.

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



Что мы еще можем сделать? Добавить обсуждение наших решений. Почему это важно?

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

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



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

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

И мы не должны разрешать вливать код, который не прошел QA. Почему это важно? Потому что dev должен оставаться рабочим. Если человек вольет туда что-то сломанное, мы можем этого не заметить. Мы можем об этом спорить, но QA нам скажет, что он, как минимум, все кейсы уже проверил и оно работает. Мы должны это понимать, это должно быть не на словах, а запрещено автоматически.

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

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

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

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

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



Как только в dev пришел свежий код, можно срезать релиз. Пусть он будет максимально атомарным. Влили одну фичу отправляем её в релиз, чтобы, если что-то сломалось, мы сразу поняли: эта маленькая фича, влитая час назад, сломала прод. Так бывает, недосмотрели. Но мы знаем, куда нам откатываться и что чинить.

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

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

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

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



В этом нам поможет CI/CD. Для реализации есть много разных тулзов, тот же самый Jenkins, TeamCity. TeamCity платный. Но его любят за более человечный интерфейс, чем у Jenkins.

У GitLab есть GitLab CI. Да и, на самом деле, есть множество новых решений, которые тоже можно использовать. Пожалуйста, используйте, никто не запрещает. Здесь, наверное, самые заметные на рынке, но я уже слышу, как кто-то говорит: А как же вот это решение? Оно же самое лучшее! Пожалуйста. Главное, чтобы оно было. Но мы должны и подстелить себе соломки, максимально уменьшить стоимость нашей ошибки, потому что ошибки будут всегда.



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

Релиз всегда должен быть воспроизводимым то есть когда в момент релиза что-то выкачивается из хранилища Node-модулей. Итак, мы решили откатиться, у нас сейчас версия 2.0.0. Мы откатываемся на версию 1.9.0. Мы снова пошли в хранилище и выкачали ту же самую версию Node-модулей, которые уже стояли в версии 2.0.0. И у нас снова все сломалось. Поэтому наш релиз собирается со всеми зависимостями, упаковывается и только так едет на прод.

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

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

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

Еще одна маленькая штучка, которая поможет сделать наши релизы стабильнее: релиз можно раскатить не на всех пользователей. Можно применить то самое A/B-тестирование, раскатить на маленький процент пользователей, так называемый канареечный хост. Canary release, знаете, да? Канарейка в шахте. Ее использовали те же самые шахтеры и проверяли, нет ли углекислого газа в шахте. Если канарейке плохо, надо уходить. Отсюда и пошло понятие канареечного релиза.

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

Смотрим дальше. Что еще нужно автоматизировать?

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



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

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


Что еще можно автоматизировать? Много чего. Наверное, тут уже не будем разбирать каждое. Например, обновления зависимостей. Можно всегда сигнализировать, что зависимости устарели, и автоматически создавать для команд задачи о том, что нужно обновить зависимости.

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

То же самое с любой другой рутину. Если видите, что что-то можно автоматизировать, автоматизируйте это.

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

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

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

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



Хотя мы говорим о бизнес-коде, а не о том, что должно быть максимально эффективно на уровне инфраструктуры или рендера, Для себя я уже выбрал только статическая типизация. А мы пишем то, что должно приносить деньги. Лучше типизировать.

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

Когда у нас все это есть, мы можем построить поверх этого архитектуру. И эта архитектура даст нам надежность. Архитектура это ограничение.



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

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

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

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

Это, наверное, самый тяжелый, самый опасный и самый интересный совет.

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

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

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

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

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

А если все хорошо, то зависимости всегда вечнозеленые. Изменили в одном месте, доехало до всех.

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

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

Какой у нас выход? Маленькие монорепозитории. Мы можем взять код одного вида и собрать его в небольшой монорепозиторий, который решает маленькую задачу. Вот посмотрите на Babel, для которого придумали lerna. Отличное решение. И очень часто оно решает многое.

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

И это был мой самый страшный и самый последний совет. Поэтому перейдем к выводам.



Да, сказка ложь, да в ней намек. Думаем.

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

Вы видите, что какой-то кусочек не автоматизирован вы его автоматизируете. Экономите себе пять минут, а если распространить на 50 разработчиков? Уйма времени. И это каждый день. Если вы видите, что что-то сломано, почините это, всем станет лучше и вам это зачтется.

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

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

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

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

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

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

Как катать релизы несколько раз в день и спать спокойно. Доклад Яндекса

26.02.2021 12:14:08 | Автор: admin
Высокие темпы разработки сопряжены с рисками, влияющими на отказоустойчивость и стабильность особенно если хочется экспериментировать и пробовать разное. Разработчик Маркета Мария Кузнецова рассказала о релизном цикле своей команды от и до, а также о мониторингах и других вещах, позволяющих обновлять сервис со скоростью три релиза в день.



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

(...) Сейчас у нас в среднем выкатывается по три релиза в день.



Стек технологий, которые мы используем типичен для Java-приложений Маркета. Мы используем 11-ю Java, Spring, PostgreSQL для хранения данных, Liquibase для накатывания миграций и Quartz для регулярных Cron-задач. Конечно, у нас реализовано много интеграций с внутренними сервисами.

Начать я хочу с того, как у нас устроен процесс релиза.

1. Релизы


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

Среды у нас сейчас только две продакшен и тестинг. Когда код-ревью пройдено и код попал в trunk, запускается вот такой релизный пайплайн.



Дальше я покажу все шаги подробнее.



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



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

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

Конечно, у нас есть ограничения при выкатке релиза. Парадигма trunk-based development диктует то, что не должно быть долго живущих feature branches, и получается так, что в trunk может оказаться незаконченная функциональность.

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

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

Но такой подход звучит дорого.

2. Feature toggles


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

if (configurationService.isBooleanEnabled(NEW_FEATURE_ENABLED)) {    //new feature here} else {        //old logic}

Можно выкатить код и пока не использовать его в продашкене, например, ждать поддержки фронтенда или же дальше его реализовывать.

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

public class User {    private Map<String, UserProperty> properties = new HashMap<>();    String getPropertyValue(String key) {        UserPropertyEntity userProperty = properties.get(key);        return userProperty == null ? null : userProperty.getValue();    }}

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

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



Конечно, у такого подхода есть минусы. Toggles скапливаются, и здесь ничего не остается, кроме как их убирать. Также увеличивается сложность тестов, потому что проверять свой код нужно во всех режимах работы toggles. Кроме того, toggles лежат в базе, поэтому получается лишний поход в БД. Здесь ничего не остается, кроме как поставить кэш. Какие плюсы мы за это получаем?



Мы получаем возможность спокойно жить в парадигме trunk based development. Также можем проводить точечные эксперименты на пользователях.

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

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

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

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

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

3. Метрики и мониторинги


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

Какую информацию мы собираем? На слайд я выписала формальное определение метрик и мониторинга.



Но предлагаю рассмотреть, что это такое, на примере.



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

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



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

На слайде пример, как нам в базе перестало хватать CPU.

Окей, у нас Java-приложение, и, конечно, стоит собирать информацию о состоянии JVM.



Мы используем Spring, поэтому для решения такой задачи хорошо подходит библиотека Spring Boot Actuator. Она добавляет endpoint в ваше приложение, и к этому endpoint можно обратиться по http и получить необходимую информацию о памяти или что-то еще. Окей, приложение запущено. Дальше оно вообще работает или нет? Что с ним происходит? Можно отправлять запросы в это приложение или нет?

@RestController@RequiredArgsConstructorpublic class MonitoringController {    private final ComplexMonitoring generalMonitoring;    private final ComplexMonitoring pingMonitoring;    @RequestMapping(value = "/ping")    public String ping() {        return pingMonitoring.getResult();    }    @RequestMapping(value = "/monitoring")    public String monitoring() {        return generalMonitoring.getResult();    }}

Такие вещи нужно понимать не только нам, но и балансеру. Для этого мы добавляем в приложение контроллер с двумя методами Ping и Monitoring. Рассмотрим вначале Ping. Он отвечает на вопросы, живо ли приложение, можно ли отправлять на него запросы. И отвечает он это в первую очередь не нам, но балансеру. Но мы же используем этот метод для мониторинга того, живо приложение или нет.

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

public enum MonitoringStatus { OK, WARNING, CRITICAL;}@RequiredArgsConstructorpublic class MonitoringEvent { private final String name; private volatile long validTill; private volatile MonitoringStatus status;}

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

public interface ComplexMonitoring { void addTemporary(String name, MonitoringStatus status, long validTillMilis); Result getResult(); //тут можно делать проверку для статусов в MonitoringEvent} 

Получается такой простейший интерфейс мониторинга. Добавить или обновить событие и вернуть текущее состояние мониторинга в оговоренном формате.

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



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

Чтобы рассказать, что это такое, на слайде есть нижний график, на нем выделена точка в 400 мс. Это график 99 перцентиля какого-то метода из нашего API. Что значат эти 400 мс? Что в этот момент 99% запросов отрабатывали не хуже 400 мс.

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

Как мы собираем RPS, тайминги и пятисотки? Когда запрос оказался у нас в инфраструктуру, он попадает на L7 balancer. А дальше он не сразу попадает в приложение, перед этим есть nginx.



А вот уже из nginx он попадает в приложение. У nginx есть access.log, в который можно собирать всю необходимую информацию. Это коды ответа, время ответа и сам запрос.



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

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



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

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

public class MetricQueryRealJobExecutor { private static final RowMapper<Metric> METRIC_ROW_MAPPER = BeanPropertyRowMapper.newInstance(Metric.class); private final JdbcTemplate jdbcTemplate; public void doJob(MetricQuery task) { List<Metric> metrics = jdbcTemplate.query(task.getQuery(), METRIC_ROW_MAPPER); metrics.forEach(metric -> KEY_VALUE_LOG.info(KeyValueLogFormat.format(metric)) ); } @Data private static class Metric { private String key; private double value; }} 

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

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

Итого, что мы получаем?

  • Плохой код не должен попадать в продакшен, для этого мы ставим всевозможные преграды: тесты, стрельбы, мониторинги. Задача без тестов не считается сделанной. Лучше заниматься оптимизацией работы тестов, чем получить проблему в продакшене.
  • Feature toggles помогают нам управлять логикой работы бэкенда, а мы, в свою очередь, можем легко управлять toggles, и их плюсы все-таки перевешивают минусы.
  • Мы должны уметь быстро обнаруживать проблему, в идеале раньше, чем ее заметят наши пользователи. Но ее мало обнаружить, нужно еще ее интерпретировать. Поэтому собирайте много метрик о состоянии системы. Это помогает в поиске проблемы.

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

Прочти меня код, который не выбесит соседа

16.03.2021 12:09:05 | Автор: admin


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

Я расскажу о подходах, которые мы используем в Яндекс.Такси для написания читаемого кода на C++, Python, JavaScript и других языках.

Обычный рабочий процесс


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

Выглядит функция sample как-то так:

std::string sample(int d, std::string (*cb)(int, std::string)) {  // Getting new descriptors from d with a timeout  auto result = get(d, 1000);  if (result != -13) {    // Descriptor is fine, processing with non bulk options    auto data = process(result, true, false, true);    // Passing processing result to the callback    return cb(data.second.first, data.second.second);  }  // Notifying callback on error  return cb(result, {});}

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

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

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

enum class Descriptor : int {};std::string sample(Descriptor d, std::string (*cb)(Descriptor, std::string)) {  // Getting new descriptors from d with a timeout  auto result = get(d, 1000);  if (result != Descriptor(-13)) {    // Descriptor is fine, processing with non bulk options    auto data = process(result, true, false, true);  // <== ERROR    // Passing processing result to the callback    return cb(data.second.first, data.second.second);  // <== ERROR  }  // Notifying callback on error  return cb(result, {});}

И тут понеслось:

  • Компилятор нашёл сразу две ошибки. Это очень подозрительно, может, мы не так типы расставили?
  • А что вообще такое data.second.first и data.second.second? В комментариях не написано.

Делать нечего, придётся прочитать весь код и комментарии, чтобы понять, как исправить ошибки.

Боль


Поначалу казалось, что много комментариев это хорошо. Однако при отладке всё выглядит иначе. Код написан на двух языках: на английском и C++. Когда мы пытаемся понять, что происходит в коде, и отладить его, нужно прочитать английский, перевести его в голове на русский. Затем прочитать код на C++, его тоже перевести в голове на русский и сравнить эти два перевода. Убедиться, что смысл комментария совпадает с тем, что написано в коде, а если код делает что-то другое, то, возможно, там и кроется ошибка.

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

Попробуйте угадать, что значат булевые флажки true, false, true в функции process? В комментариях о них ни слова. Чтобы разобраться, нужно пойти в header file, где объявлена функция process:

std::pair<Descriptor, std::pair<int, std::string>> process(bool, Descriptor, bool, bool);

И там мы увидим, что у булевых переменных нет осмысленных имён.

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

Наконец, data.second.first и data.second.second. Чтобы выяснить их назначение, нужно отмотать назад туда, где мы получаем переменную data. Пойти в место, где объявлена функция process, увидеть, что комментариев нет, а process возвращает пару от пары. Пойти в исходники, узнать, что обозначают переменные int и string, и на всё это снова уходит очень много нашего времени.

Ещё одна маленькая боль код обработки ошибок перемешан с основной логикой. Это мешает ориентироваться. Обработка ошибок функции sample находится внизу, а в середине, внутри цикла if, с большими отступами находится happy path.

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

Выжимка проблем


  • Код написан на двух языках:
    Его в два раза больше.
    При отладке возникают проблемы со сверкой двух языков.

  • Комментариев всё ещё недостаточно:
    Приходится читать код смежных функций.
    Есть магические константы.

  • Код обработки ошибок и основной логики перемешаны:
    Большие блоки кода с большими отступами.

Читаемый код


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

Поехали:

std::string Sample(Descriptor listener,                              std::string (*cb)(Descriptor, std::string)){  UASSERT_MSG(cb, "Callback must be a non-zero function pointer");  const auto new_descriptor = Accept(listener, kAcceptTimeout);  if (new_descriptor == kBadDescriptor) {    return cb(kBadDescriptor, {});  }  auto data = Process(Options::kSync, new_descriptor);  return cb(data.descriptor, data.payload);}

В первой же строчке проверяем входные параметры. Это мини-подсказка/документация по тому, какие данные ожидаются на входе функции.

Следующая правка: вместо функции с непонятным именем Get появляется Accept, широко известная в кругах сетевых программистов. Затем страшную константу 1000 превращаем в именованную константу с осмысленным читаемым именем.

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

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

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

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

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

В итоге мы незначительно (а то и вовсе незаметно) ускорили приложение. Пустячок, но приятно.

Дальше. Функция process преобразовалась. Вместо true, false, true теперь есть перечисление возможных опций для process. Код можно прочитать глазами. Сразу видно, что из дескриптора мы процессим какие-то данные в синхронном режиме и получаем их в переменную data.

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

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

Приёмы


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

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

1) Compute(payload, 1023) нечитаемо. Что такое 1023?
Используйте именованные константы.
Compute(payload, kComputeTimeout)

Альтернативным решением может быть явное использование имён параметров. Например, Python позволяет писать:

Compute(payload=payload, timeout=1023);

Ну и C++20 не отстаёт:

Compute({.payload=payload, .timeout=1023});

Идеальный результат получается, если пользоваться сразу двумя приёмами:

Compute(payload=payload, timeout=MAX_TEST_RUN_TIME);

2) Compute(payload, false) нечитаемо. Что такое false?
Используйте перечисления или именованные константы вместо bool.
У bool не всегда понятна семантика. Введение перечисления даже из двух значений явно описывает смысл конструкции.

Compute(payload, Device::kGPU)

Именованные аргументы в этом месте не всегда спасают:

Compute(payload=payload, is_cpu=False);

Всё ещё непонятно, что False заставляет считать на GPU.

3) Compute(data.second.first, data.second.second) или Compute(data[1][0], data[1][1]) что вообще тут происходит?
Используйте типы с информативными именами полей, избегайте кортежей.
Compute(data.node_id, data.chunk_id)

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

Попробуйте угадать, какой смысл у возвращаемых int и std::string в коде.

std::tuple<int, std::string> Receive();

int это дескриптор устройства? Код возврата?

А вот так всё становится кристально ясно:

struct Response {    int pending_bytes;    std::string payload;};Response Receive();

4) void Accept(int , int); что это за два числа?
Заводите отдельные типы данных для разных по смыслу вещей.
void Accept(Descriptor, std::chrono::milliseconds)

Или для Python:

def Accept(listener: Descriptor, timeout: datetime.timedelta) -> None:

На самом деле это совет не столько про читаемость кода, сколько про отлов ошибок. Многие (но далеко не все) современные языки программирования позволяют статически проверять типы и узнавать об ошибках ещё до запуска приложения или тестов. В C++ эта функциональность доступна из коробки, в Python нужно пользоваться линтерами и typing.

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

5) void Compute(Data data) функция есть в модуле или заголовке, но должны ли мы ей пользоваться?
Используйте особый namespace или именование для служебных вещей.
namespace detail { void Compute(Data data); }

Или для Python:

def _compute(data: Data) -> None:

С namespace detail/impl или с особым наименованием пользователь поймёт, что функцию использовать не нужно.

6) d, cb, mut, Get что это?
Придумывайте информативные имена переменных, классов и функций.
descriptor, callback, mutator, GetDestination

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

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

Так вот, через неделю или месяц будет сложно вспомнить, что такое d или cd, что делает метод Get (или это вообще класс Get?). Что он возвращает?

Информативные имена вам очень помогут. При чтении будет сразу видно, где descriptor, callback и mutator, а где функция под именем GetDestination() возвращает какой-то Destination.

7) connection = address.Connect(timeout) отладчик завёл нас в эту строчку кода. Что там за переменные, откуда они и куда мы их присваиваем?
Закрепите разные стили за переменными класса, аргументами функций и константами.
Если закрепить отдельные стили за разными типами переменных, код читается лучше. В большинстве распространённых code style именно так и делают:

connection_ = address.Connect(kTimeout);

Мы сразу видим, что переменная address локальная, что мы пытаемся соединиться с kTimeout, который является константой. Результат соединения присваиваем переменной класса connection_. Поменяли буквально пару символов, а код стал понятнее.

Для Python стоит дополнительно придерживаться правила, что приватные поля начинаются с нижнего подчёркивания:

self._connection = address.Connect(TIMEOUT);

8) connection_ = address.Connect(timeout / attempts) есть ли тут ошибка?
Используйте assert, чтобы проверить, правильно ли используют ваш код.
Если количество attempts будет равно нулю, то нативное приложение, скорее всего, рухнет. Где-то возникнет stack trace или корка. С помощью дополнительных телодвижений можно добраться до stack trace и понять, что падение произошло именно в этой строчке.

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

Однако если внутри Connect не будет проверки, всё станет сильно-сильно сложнее. Приложение не упадёт, но будет работать неправильно, не так, как мы ожидаем.

Коду явно не хватает проверок:

ASSERT(attempts > 0);

assert timeout > 0

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

Assert не только позволяет быстро находить ошибки, но и добавляет читаемости. Выражение assert timeout > 0 прямо говорит, что код ниже будет работать неправильно с отрицательными числами и 0.

8.1) connection_ = address.Connect(timeout / attempts) есть ли тут ошибка?
НЕ используйте assert для проверки пользовательского ввода.
Будет невежливо (и опасно!), если ваша библиотека уронит весь сервер потому, что кто-то на сайте ввёл неправильное число. Здесь стоит использовать другие механизмы для сообщения об ошибках:

if (attempts <= 0) throw NegativeAttempts();

if (attempts <= 0) raise NegativeAttempts();

Как отличить неправильное использование функции программистом от неправильного ввода?

Вот несколько примеров:

  • функция init() должна быть вызвана перед первым использованием функции foo() assert,
  • мьютекс не должен дважды захватываться из одного и того же потока assert,
  • баланс на карте должен быть положительным НЕ assert,
  • стоимость поездки не должна превышать миллиона доллларов НЕ assert.

Если не уверены не используйте assert.

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

ASSERT(attempts > 0);if (attempts <= 0) throw NegativeAttempts();

9) v = foo(); bar(v); baz(v); assert(v); а функциям bar() и baz() точно хорошо?
Не тяните с обработкой ошибок, не несите их в блок else.
Как только получили значение сразу проверяйте и обрабатывайте ошибки, а дальше работайте как ни в чём не бывало.

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

Пример:

if (value != BAD) {    auto a = foo(value);    auto b = bar(value);    if (a + b < 0) {         return a * b;    } else {         return a / b + baz();    }}return 0;

Сравните:

if (value == BAD) {    return 0;}auto a = foo(value);auto b = bar(value);if (a + b < 0) {     return a * b;}return a / b + baz();

10) [(x, y) for x in [1,2,3] for y in [3,1,4] if x != y]
Придерживайтесь вменяемой вложенности конструкций.
Если в вашем коде есть if, внутри которого for, внутри которого if, внутри которого while, это сложно читать. Стоит разнести какие-то внутренности на отдельные функции и дать функциям осмысленные имена. Так код станет красивее и приятнее для чтения.

11) Самая важная часть, самая большая хитрость, о которой мало где написано!
Если вам хочется поставить комментарий...
Попробуйте переделать код так, чтобы не хотелось.

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

Вместо заключения


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

Даже в рамках одной компании практики могут расходиться (например, в userver у нас пожёстче с наименованями и bool).

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

Где я и где конечный автомат? Доклад Вадима Пацева оматематике во фронтенде

10.04.2021 12:12:31 | Автор: admin
Некоторые фронтенд-разработчики полушутливо называют себя форма-клепатель. Это не так. Руководитель фронтенда Яндекс.Маршрутизации Вадим Пацев поставил себе задачу на примере развития и уточнения одной простой задачи взаимодействия с пользователем показать: не стоит бояться лезть в такие вещи, как конечный автомат, цепи Маркова и так далее. Во фронтенде тоже есть место взрослым архитектурным паттернам и алгоритмам. Ссылка на видео в конце текста.



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

Intro


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

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

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

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

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

Контекст


Что это за задача, в каком контексте все происходило?

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



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

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

Раз мы тут на фронтенд-конференции, то допустим, что решение было написано на React Native, но это не так важно.

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

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

Линейные сценарии


Итак, нам надо сделать навигацию, пошаговую инструкцию.


Окей, давайте сделаем простой сценарный движок: возьмем Redux и сделаем такую декларативную структуру, массив, в котором декларативно опишем каждый шаг.

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


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

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

Наверное, для вас не секрет, что философия работы Redux связана с конечным автоматом. В машине состояний системы store определяет состояние, action вызывает работу reducers. Reducers изменяют состояние системы, система переводит в новое состояние store, то есть возникает новое состояние автомата.


Такое решение работает, и оно довольно простое. Мы берем обычный state management, предустановленный набор сценариев. Запускаем машинку. Она сама переключает шаги.

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

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


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

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

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

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


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

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

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

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

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


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

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

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

Кроме того, сразу стало понятно, что физические ресурсы это бутылочное горлышко. На таком масштабе темных комнат не хватает, актеров не хватает, ничего не хватает.

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

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


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

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

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

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

Но если вы разрабатывали чат-ботов или пользовались ими, то вам сразу видны эти рельсы, сразу видно: если происходит шаг вправо, шаг влево от сценария, то бот перестает тебя понимать. Теряется магия того, что ты общаешься с умным существом.

У нас примерно та же история: есть рельсы и любой шаг вправо-влево ошибка. Это сразу ломает весь user experience. Требовалось другое решение.

У нас был опыт работы с чат-ботами, мы работали с Dialogflow. Это стартап, который впоследствии купил Google. Они сделали систему, которая позволяет строить нелинейные сценарии, как раз то, что нам было нужно.

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

Конечный автомат


Из чего состоит более сложный конечный автомат?


У нас были декларативные сценарии и сигналы. Мы делаем три новых сущности, в которые конвертируем уже существующие сценарии. Это можно сделать автоматически. Новые сущности это Activity (активность), Trigger и Interaction (взаимодействие).



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

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


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

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


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

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


Еще раз: Activity однозначно определяет состояние нового конечного автомата. У пользователя сначала нет никаких Activities, пустое состояние. Потом они начинают накапливаться.

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

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

И есть Interaction, который переводит систему в новое состояние.

Схематично это выглядит так.

У нас имеется унекоторое множество состояний, в которых может быть система. Это возможный набор Activities, которые пользователь может собрать за свой путь.



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



Набор сущностей Trigger и Interaction, то есть два набора возможных переходов и нулевое пустое состояние определяют работу системы. Мы закидываем набор Trigger/Interaction, при нулевом состоянии срабатывает первый Trigger.

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

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

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

Цепи Маркова


Дальше речь пойдет про цепи Маркова. К конечному автомату мы добавляем вероятности.

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

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


Схематично выглядит так. У нас есть некоторое количество возможных состояний. Для каждого состояния есть вычисленная вероятность перехода из состояния например, из S1 в S3.

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


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

Дальше срабатывают какие-то Triggers, их может быть несколько. Тогда мы рандомно выбираем. Если Trigger один, тогда вероятность перехода в следующее состояние равняется единице.

Марковский процесс, которым мы усложнили наш конечный автомат, позволяет посчитать вероятность срабатывания Trigger через n шагов. Здесь написаны формулы, я могу их озвучить. Вероятность перехода из состояния j в состояние i за m шагов равна сумме вероятности перехода из i в k, умноженной на вероятность перехода из j в k при m минус первом шаге. Это простая формула, описывающая довольно сложные вычисления.

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

Итак, еще раз. Есть конечный автомат множество состояний, в котором может находиться система. Есть Triggers, возможности перехода из одного состояния в другое.



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


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

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

Также этот математический аппарат позволяет нам тестировать наборы Trigger/Interaction. Допустим, мы сделали набор Triggers, запускаем эту машинку. Проверяем ее на тысяче разных пользователей, которые рандомно нажимают на кнопки, рандомно делают выбор. Мы вычисляем вероятности, с которыми пользователи были в определенных участках, то есть в определенных состояниях нашей системы. И дальше понимаем, как вообще распределяются потоки, нагрузка и так далее.

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


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

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

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

Если сети нет, то система работает так же, как и работала, по старым правилам. Когда придут Activities, мы обновим модели на сервере, начнем заново обучать нашу систему и так далее.

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

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

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

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

Outro


Цепи Маркова, конечный автомат всё, о чем я сейчас рассказал, это всё в контексте фронтенда. У нас был React Native плюс Redux. Технически все это было сделано на фронтовых технологиях и находилось в поле ответственности фронтенд-разработчика.


Но нельзя сказать, что есть какой-то ряд задач, которые надо решать так. Основная мысль в другом. Мы делаем решение, потом сталкиваемся с новой проблемой. И тут мы можем сказать: окей, есть привычные для нас фронтовые решения, Redux и так далее. Но также мы можем оглянуться вокруг, посмотреть, какие, например, есть другие абстракции, заглянуть в теорию массового обслуживания, в построение чат-ботов. И вообще в те абстракции, которые уже есть в теории Computer Science и математики, статистики. И найти там какое-то вдохновение для более интересного и гибкого решения.

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

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

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

Здесь я собрал несколько ссылок на материалы:

  • Хорошая статья на Smashing Magazine про state-машины, там хорошо описано, как они работают поверх Redux.
  • Цепи Маркова, краткое объяснение довольно простым языком на Brilliant.
  • И очень хорошая видеолекция от профессора из MIT, в которой очень хорошо рассказывается математический аппарат цепей Маркова.

Спасибо за внимание.



Смотреть видео доклада
Подробнее..

Уже сегодня это на нас влияет, а завтра повлияет очень сильно. Разговор о квантовых технологиях с Алексеем Фёдоровым

15.04.2021 14:13:15 | Автор: admin

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


И всё же прогресс в квантовых технологиях заметен хотя бы по тому, какое внимание им уделяют крупнейшие корпорации. IBM ещё в 2018 году рапортовали о сотне тысяч пользователей платформы Quantum Experience, Microsoft создаёт quantum development kit, и даже J.P. Morgan пытается развить в компании quantum culture. Любопытно, что сейчас всё больше говорят о связи квантовых вычислений и искусственного интеллекта.


В конце ноября 2020 года я встретился с Алексеем Фёдоровым, одним из ведущих российских специалистов в области квантовых технологий, автором десятков научных публикаций, руководителем научной группы Российского квантового центра, профессором МФТИ и обладателем бесчисленного множества других регалий. Он многое рассказал о состоянии современной квантовой науки, о грядущих технологических внедрениях и об интересных задачах, которые можно решать прямо сейчас. Видеозапись интервью смотрите на YouTube, там же доступна и запись последующего доклада на конференции YaTalks.


image


Про Алексея Фёдорова


Алексей Шаграев: Расскажи, пожалуйста, кем ты работаешь, чем занимаешься?


Алексей Фёдоров: Я руководитель научной группы Российского квантового центра (rqc.ru), и это означает, что я занимаюсь научными исследованиями в области квантовых информационных технологий. Мы активно развиваем два направления: квантовые коммуникации и квантовые вычисления. Вторая большая часть моей жизни преподавание: я преподаю на Физтехе курс Введение в современные квантовые технологии.


Ты попадал в какое-то бесчисленное количество рейтингов, например, в рейтинг Forbes 30 до 30. Как туда попадают и что это за рейтинги?


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


image


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


Что ты делал для того, чтобы популяризовать свою область?


Тут нужно сказать о Российском квантовом центре организации, где я работаю. Это очень необычная форма организации науки в России частный научный институт. Мы привыкли, что в России науку развивают университеты, институты академии наук, оборонная промышленность, какие-то отраслевые институты. А квантовый центр частный институт на площадке Сколково. Поэтому Центру, чтобы существовать, чтобы быть узнаваемым, важно заниматься популяризацией науки, объяснять обществу важность достигаемых результатов. Я работаю в Российском квантовом центре уже около 8 лет. Популяризация науки часть нашего ДНК, часть нашей культуры не просто достигать результатов, но и рассказывать обществу, в чём состоит их важность.


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


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


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


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


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


Качественный скачок произошёл за последние 3040 лет. Мы научились приготавливать состояния отдельных квантовых объектов, а затем и управлять ими. Можно, например, изолировать один атом (точнее, создать условия, при которых он будет изолирован), контролировать степени свободы этого атома, кодировать в них информацию, использовать для каких-то интересных приложений. То же самое произошло с другими квантовыми объектами: частицами света фотонами. Из квантово-оптических экспериментов, в которых создавались отдельные состояния света и проводилось манипулирование ими, кстати, возникла вся область квантовой криптографии.


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


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


Не так давно наступил переломный момент. В течение последних десятилетий большинство результатов было получено в лабораториях и научных центрах; первые эксперименты научные публикации европейских, американских, российских, японских, китайских лабораторий. А в последние 5-10 лет мы видим очень активное вовлечение в квантовую гонку компаний, в основном из области IT, как раз во многом тех же, что развивают искусственный интеллект.


Если смотреть сейчас на главные технологические достижения, связанные с квантовыми компьютерами, то их обеспечивают Google, IBM, Intel, Microsoft, Amazon и другие компании. Сейчас действительно переломный момент: будет возникать конкуренция или коллаборация? Пока это до конца не ясно. Крупные компании (тот же Google) сотрудничают с университетами и научными центрами, работают над совместными научными публикациями. Но в какой мере корпорация сможет продолжать коллаборации, когда станет более заинтересована в экономическом эффекте это вопрос. Сейчас наука развивается и в университетах, и в частных компаниях, так что между ними наблюдается конкуренция за получение результатов.


Что касается национальных особенностей, то в США наиболее сильна частная экспертиза и крупнейшие компании финансируют собственные исследования; в Европе фокус на развитии технологий в университетах; в России же достаточно масштабную работу ведут госкорпорации. Квантовыми вычислениями занимается Росатом и не так давно созданный консорциум Национальная квантовая лаборатория, коммуникациями РЖД, сенсорами Ростех.


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


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


Что такое квантовое превосходство? Довольно долго это была абстрактная концепция, введённая физиками. Суть квантового превосходства в обозначении момента, когда компьютеры, построенные на квантовых принципах (квантовые компьютеры), смогут решить некоторую задачу за разумное время, а классическими технологиями, компьютерами или суперкомпьютерами, за разумное время её решить будет невозможно.


Эта фраза про разумное время предмет дискуссии, который изначально был заложен в определение, но интуитивно всем более-менее понятно, что имеется в виду. Условно, это ситуация, когда квантовые компьютеры решают задачу за минуту, а классическому компьютеру требуется тысяча лет. Или: квантовый компьютер решает за 10 секунд, а классическому суперкомпьютеру требуется год. Масштаб может быть разный, но идея интуитивно понятна. Есть какая-то задача, которая классически решается сложно (долго), а на квантовом компьютере решается просто (быстро).


В Google построили процессор из 53 кубит, который называется Sycamore. Он показал, что достаточно абстрактную и неочевидно полезную с прикладной точки зрения задачу процессор Google решает за 200 секунд, а классический суперкомпьютер IBM Summit, по их оценкам, решал бы десятьтысяч лет. Задача действительно специфическая, для её понимания нужно немного погрузиться в детали работы квантовых процессоров. Одно из возможных практических применений такой задачи генерация случайных чисел.


Что это за задача?


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


На самом деле, это объяснение является некоторым упрощением

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


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


Задача, которую решал Google, состоит в следующем. Мы приготавливаем регистр из 53 кубитов, выполняем достаточно большое количество случайно выбранных одно- и двухкубитных операций и производим измерение получившегося квантового состояния. В результате измерения мы получаем образец (сэмпл) случайной классической 53-битной строки. Основной интерес представляет собой распределение вероятности, из которого получаются эти битовые строки (его определяют выбранные нами случайные одно- и двухкубитные операции). Эта задача называется сэмплированием из случайных квантовых цепочек (Random Quantum Circuits). Имея квантовый компьютер, выполнить подобное сэмплирование достаточно просто: достаточно его включить, выполнить все необходимые квантовые операции и провести измерения. Для классического компьютера данная задача оказывается вычислительно сложной: чтобы сымитировать работу квантового компьютера, в общем случае нужно хранить в памяти компьютера промежуточное состояние квантового регистра объёмом 253.


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

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


В эксперименте Google было проведено около 1,5тысяч операций за время когерентности кубитов. Команда Google взяла то время, которое было необходимо им на квантовом процессоре, и оценила время, необходимое для реализации части эксперимента на суперкомпьютере. Затем, как я понял, они провели несколько симуляций и аппроксимировали, что для финального эксперимента потребовалось бы десятьтысяч лет.


В 2017 году Джон Мартинис, который до последнего времени был лидером проекта Google по квантовому компьютеру, приезжал в Москву на конференцию Российского квантового центра ICQT-2017. Ещё в 2017 году он говорил, что готов к демонстрации квантового превосходства и вполне конкретно объяснял суть того, что будет делать (https://youtu.be/Q3zNpwgaAuY). Всё выглядело очень понятно, и все ждали, что завтра-послезавтра у них выйдет статья. Но она не выходила.


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


В 2019 году Хартмут Невен из Google был в России на конференции Квантового центра (ICQT-2019), и уже он более явно говорил о том, что они находятся на пороге квантового превосходства, и для его демонстрации может быть использована определённая задача. Сейчас понятно, что к тому моменту, скорее всего, статья Google была готова или находилась в высокой степени готовности, уже была готова для отправки в научный журнал.


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


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


Тут каким-то образом в сети появляется сообщение Financial Times: на сайте NASA опубликован пресс-релиз, где сказано, что Google вместе с NASA достигли квантового превосходства (потом пресс-релиз был удалён). Это было мгновенно растиражировано. Все начали спрашивать, что же произошло на самом деле. Кто-то нашёл препринт научной статьи, непонятно как. Конечно, это всем дало некоторое время для того, чтобы детально разобраться в эксперименте, попытаться раскритиковать его.


Ряд команд, в том числе команда компании IBM, утверждали в своей научной публикации, что, если бы моделирование на классическом суперкомпьютере квантового суперпроцессора происходило иначе, то время, которое затратил бы классический суперкомпьютер, можно было бы значительно сократить скажем, до нескольких дней (работа компании Alibaba утверждала о моделировании поведения процессора Sycamore за 20 дней: https://arxiv.org/abs/2005.06787).


При этом команда Google ничего не могла на это ответить, поскольку они не имели права комментировать свои результаты!


Наконец, статья была опубликована. Джон Мартинис сделал замечательный доклад, рассказал детально обо всех технических результатах, которые были достигнуты: https://youtu.be/FklMpRiTeTA. Конечно, один из вопросов, который он поднимает это критика Google со стороны IBM. Он сказал очень разумную вещь (цитирую примерно): Мы провели эксперименты, мы опубликовали свои данные. Можете проверить, что мы сделали правильное заключение на основе тех данных, что получили. Конечно, можно моделировать квантовые процессы по-другому, можно что-то придумать. Но если я добавлю ещё один кубит в свой процессор, то снова значительно вырвусь вперёд.


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


Есть выдающийся учёный Скотт Ааронсон, у него есть замечательный научный блог. У него в блоге охарактеризовали ситуацию так: квантовое превосходство 2019 года это победа квантового Давида над классическим Голиафом. Маленький процессор из 53 кубитов это очень маленький квантовый процессор И он уже конкурентен по сравнению с самым мощным суперкомпьютером из когда-либо созданных. Поэтому можно считать, что порог квантового превосходства пройден. Дальше мы можем набирать всё больше и больше экспериментов, всё больше и больше результатов, которые будут это подтверждать.


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

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


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


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


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


У криптографии, построенной на принципах открытых ключей, асимметричной криптографии, есть огромное количество сильных сторон. Но есть и один серьёзный недостаток. Если появится достаточно мощный квантовый компьютер, он сможет взламывать криптографические алгоритмы с открытым ключом, который мы сейчас используем. Это такие примитивы как RSA, Диффи-Хеллман, эллиптические кривые определённых типов и так далее. Соответственно, появляется угроза: коммуникации, которые нам необходимо сделать защищёнными, не будут защищены в эпоху квантового компьютера. Что же мы можем с этим сделать?


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


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


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


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


Второй способ жить в эпоху квантового компьютера разработка новых алгоритмов криптографии с открытым ключом, которые основаны на предположении наличия у злоумышленника квантового компьютера. Такая область называется постквантовой криптографией. Это новая математика, вдохновлённая исследованиями в области квантовых компьютеров. И, опять же, в Российском квантовом центре в сотрудничестве с Центром НТИ МИСиС мы занимаемся развитием таких алгоритмов (см. qapp.tech).


Это новые типы криптографических библиотек, которые легко использовать в мобильных устройствах, защищённых коммуникациях в интернете и так далее. Поэтому здесь такой take-home message: мы начнём пользоваться этими технологиями уже в самое-самое ближайшее время, и, возможно, этот переход произойдёт для нас совсем незаметно. Просто в какой-то момент вы увидите, что ваше соединение уже не HTTPS, а HTTPS-PQ (HTTPS Post-Quantum) или что-то подобное. Оно будет защищено не только от текущих угроз, но и от атак обозримого будущего.


Как квантовые вычисления связаны с искусственным интеллектом?


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


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


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


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


В этом смысле их соединение с квантовыми процессорами технологический perfect match. И поэтому, например, лаборатория Google по развитию квантовых компьютеров называется Google Quantum Artificial Intelligence Lab. В Российском квантовом центре у нас есть совместная Индустриальная лаборатория квантового искусственного интеллекта (при поддержке Газпромбанка) и была целая программа по квантовому машинному обучению для решения индустриальных задач (например, совместно с Росатомом).


Сложность квантовых алгоритмов машинного обучения.

Действительно, работы в этой области публикуются весьма активно. Алексей Фёдоров собрал таблицу сравнения сложности классических и квантовых алгоритмов для различных методов машинного обучения. В столбце QRAM указывается "yes", если алгоритм требует наличия Quantum Random Access Memory.


Таблица создана на основе обзоров J. Biamonte, P. Wittek, N. Pancotti, P. Rebentrost, N. Wiebe, and S. Lloyd, Quantum machine learning, Nature (London) 549, 195 (2017) и C. Outeiral, M. Strahm, J. Shi, G.M. Morris, S.C. Benjamin, and C.M. Deane, The prospects of quantum computing in computational molecular biology, WIREs Comput. Mol Sci. 11, e1481 (2021).


Algorithm Classical Quantum QRAM References
Linear Regression $O(N)$ $O(\log{N})$ Yes Phys Rev A. 94, 022342 (2016); Phys Rev A. 96, 012335 (2017); IET Quantum Commun. 2, 55 (2020); arXiv:1907.06949.
Gaussian process regression $O(N)$ $O(\log{N})$ Yes Phys. Rev. A 99, 052331 (2019); Phys. Rev. A 100, 012304 (2019).
Decision trees $O(N \log{N})$ Unclear No Quantum Information Processing 13, 757 (2013).
Ensemble methods $O(N)$ $O(\sqrt{N})$ No Sci Rep. 8, 2772 (2018); arXiv:1902.00869; arXiv:2002.05056.
Support vector machines $O(N^{2-3})$ $O(\log{N})$ Yes Phys. Rev. Lett. 113, 130503 (2014); Quantum Information and Communication 17, 1292 (2017); Phys. Rev. Lett. 122, 040504 (2019).
Hidden Markov models $O(N)$ Unclear No Applied Mathematical and Computational Sciences 3, 93 (2011); arXiv:1710.09016.
Bayesian networks $O(N)$ $O(\sqrt{N})$ No arXiv:1512.03145; Phys. Rev. A 89, 062315 (2014).
Graphical models $O(N)$ Unclear No Phys. Rev. X 7, 041052 (2017).
kMeans clustering $O(kN)$ $O(\log{kN})$ Yes arXiv:1307.0411; Quant Inform Comput. 15, 318 (2018); Advances in Neural Information Processing Systems. New York: Curran Associates, 2019; p. 4136-4146.
Principal component analysis $O(N)$ $O(\log{N})$ No Nat. Phys. 10, 631 (2014).
Persistent homology $O(\exp{N})$ $O(N^5)$ No Nat Commun. 7, 10138 (2016).
Gaussian mixture models $O(\log{N})$ $O(\textrm{polylog}{N})$ Yes arXiv:1908.06657; Phys. Rev. A 101, 012326 (2020).
Variational autoencoder $O(\exp{N})$ Unclear No Quantum Sci. Technol. 4 014001 (2019)
Multilayer perceptrons $O(N)$ Unclear No Advances in imaging and electron physics. Volume 94. Amsterdam: Elsevier, 1995; p. 259-313; Int. J. Theor. Phys. 37, 651 (1998); arXiv:1711.11240; npj Quant. Inform. 3, 36 (2017); Phys. Rev. Res. 1, 033063 (2019).
Convolutional neural networks $O(N)$ $O(\log{N})$ No Nat Phys. 15, 1273 (2019).
Bayesian deep learning $O(N)$ $O(\sqrt{N})$ No Quant Mach Intell. 1, 41 (2019).
Generative adversarial networks $O(N)$ $O(\textrm{polylog}{N})$ No Phys Rev Lett. 121, 040502 (2018); Phys Rev A. 98, 012324 (2018); arXiv:1711.02038.
Boltzmann machines $O(N)$ $O(\sqrt{N})$ No NIPS 2011 Deep Learning and Unsupervised Feature Learning Workshop. Toward the implementation of a quantum RBM. New York: Curran Associates, 2011; On the challenges of physical implementations of RBMs. Twenty-eighth AAAI conference on artificial intelligence. Palo Alto, California: Association for the Advancement of Artificial Intelligence, 2014; arXiv:1412.3489; Phys Rev A. 94, 022308 (2016); arXiv:1903.01359; Phys. Rev. X 8, 021050 (2018).
Reinforcement learning $O(N)$ $O(\sqrt{N})$ No Phys Rev Lett. 117, 130501 (2016); 2017 IEEE International Conference on Systems, Man, and Cybernetics (SMC). New York: IEEE, 2017; p. 282287.

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


Можно ли прямо сейчас пойти и что-нибудь попробовать сделать, потрогать технологию, написать программу?


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


Я уже рассказывал, что преподаю. На первом занятии я всегда спрашиваю студентов-магистрантов, программировали ли они когда-либо на квантовых компьютерах, и год от года количество положительных ответов увеличивается. Кто-то использует их для решения научных задач, кто-то просто ради интереса что-то пробует делать. Почему? Потому что, например, платформа IBM очень user-friendly. Можно прийти, прочитать, понять, как это работает, найти удобный инструмент и начать программировать; можно писать код, собирать операции над кубитами в визуальном интерфейсе и пытаться решать задачи на реальном квантовом компьютере.


Сейчас немало разрабатывается и более серьёзных инструментов: например, проекты Microsoft и Google по созданию инструментов квантового программирования, недавно анонсированный квантовый вариант Tensor Flow.


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


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


Это зависит от того, что тебе было бы интересно делать.


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


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


Люди с энтузиазмом и знаниями Python очень нужны в сообществе по разработке квантовых технологий. Python был нашим основным инструментом прототипирования всех алгоритмов обработки ключей для систем квантовой криптографии. Моя команда до этого занималась (и сейчас продолжает в каком-то смысле заниматься) разработкой программной платформы для обработки ключей в системах квантовых коммуникаций. Идея возникла так: учёные в основном используют Python для прототипирования, а потом уже индустриальное программное обеспечение пишется на C++ профессиональными инженерами.


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


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


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


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


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


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


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


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


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


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


Какую роль в твоей жизни играют стартапы?


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


В первый раз я прошёл этот путь с компанией QRate. Она появилась около 5 лет назад и планомерно развивается как технологический стартап с очень серьёзной составляющей в части инжиниринга, R&D, адаптации новых технологий. Уже появилось несколько поколений продуктов для разных пользователей и разных рынков. Конечно, я в процессе очень многому научился, а ещё стартап стимулирует действовать так, чтобы научные результаты, которые ты делаешь в рамках и в интересах спин-оффа, давали конкурентное преимущество, а не просто превращались в научную публикацию. Мне это понравилось. Поэтому позже появились ещё два стартапа, которыми я занимаюсь: QApp (пост-квантовые алгоритмы, qapp.tech) и QBoard (квантовые вычисления, создание платформы для квантовых компьютеров, qml.rqc.ru).


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


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


Про путь в науке


Ты в 15 лет поступил в Бауманку?Как это вообще возможно?


Я в 15 лет закончил школу, так получилось, и потом поступил в Бауманку. Мои родители решили, что начальные классы школы это скучно, и сказали: А почему бы тебе не пойти сразу в пятый класс?. Я такой: А почему бы и нет?. Так и получилось.


image


Сложно учиться, когда ты моложе одногруппников?


Да. Причём, когда я пришёл, то сразу сказал на кафедре: Мне, наверное, будет сложно, потому что я моложе всех на два года. Они говорят: Да? А давай тогда ты будешь старостой. Тебе так будет легче. Поэтому я ещё и был старостой в своей группе. Это помогло мне немножко собраться и двигаться вперёд. Но для Бауманки, на самом деле, это нормальная история: многие ребята поступают достаточно рано.15 лет далеко не рекорд.


Почему квантовые компьютеры? Как ты попал в эту область?


Это был длинный путь. Я учился на факультете информатики и систем управления. Занимался классическим IT, если можно так сказать: информатикой, криптографией, и в какой-то момент понял, что мне очень не хватает физики. Начал читать всякие научно-популярные статьи, и узнал, что прямо сейчас происходит какая-то интересная история про кванты. Квантовые компьютеры, квантовая криптография. А почему нам про это не рассказывают? Начал сам про это читать, ходил на занятия на кафедру физики в Бауманке. И как-то так получилось, что в момент выбора научной стези я уже понимал, что хочу заниматься квантовыми вычислениями и квантовой криптографией.


Причём здесь есть личная история. Я увлёкся квантовой криптографией. Это направление меня очень заинтересовало, так что я долго им занимался, но никогда не думал, что из этого получится что-то практическое. Я думал, что это будет теоретическая работа: буду статьи читать, статьи писать и так далее. Но в какой-то момент познакомился с Юрием Курочкиным он сейчас CTO QRate, спин-оффа Российского квантового центра, который как раз занимается разработкой систем квантовой криптографии. Даже уже не просто разработкой, а внедрением их в разные приложения. И я увидел, что у меня появился человек, благодаря которому, скорее всего, все теоретические идеи в какой-то момент воплотятся в железо. И это действительно получилось. В дипломе, который я писал под руководством Юры, уже содержались элементы экспериментов по квантовой криптографии. А буквально через несколько лет появилась промышленная установка квантовой криптографии, которую производит QRate.




Алексей Фёдоров выступил на конференции YaTalks 5 декабря 2020 года. Посмотреть запись можно на сайте конференции, а также в YouTube. Запись интервью доступна по ссылке.

Подробнее..

Как мы внедрили свою модель хранения данных highly Normalized hybrid Model. Доклад Яндекса

26.05.2021 12:10:08 | Автор: admin
Общепринятый и проверенный временем подход к построению Data Warehouse (DWH) это схема Звезда или Снежинка. Такой подход каноничен, фундаментален, вотрфоллен и совсем не отвечает той гибкости, к которой призывает Agile. Чтобы сделать структуру DWH гибкой, существуют современные подходы к проектированию: Data Vault и Anchor modeling похожие и разные одновременно. Задавшись вопросом, какую из двух методологий выбрать, мы в Яндекс Go пришли к неожиданному ответу: выбирать надо не между подходами, а лучшее из двух подходов.

Темы доклада, который вместе со мной прочитал Николай Гребенщиков:
DV и AM: в чем разница и где точки соприкосновения
Гибридный подход к построению хранилища
Сильные и слабые стороны этого подхода
Примеры кода
Дальнейший вектор развития hNhM

Меня зовут Евгений Ермаков, я руководитель Data Warehouse в Яндекс Go.

Я расскажу историю о том, как два руководителя объединились и сделали нечто крутое как минимум, по мнению этих двух руководителей. Расскажу про наш подход к хранению данных в детальном слое. Мы его называем highly Normalized hybrid Model. Надеюсь, что корректно произнес по-английски, я тренировался.

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



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

Также я расскажу про архитектуру хранилища Яндекс Go в целом, вместе с детальным слоем, где как раз эта модель и применяется. Потом сравню Data Vault и якорное моделирование так, как мы у себя их сравнивали, и объясню, почему мы из этого сравнения сделали вывод, что нужно создавать нечто свое. И расскажу базисные основы про hNhM.



А во второй главе я передам слово Коле.

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

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



Итак, архитектура Data Warehouse (DWH) в Яндекс Go. Расскажу об архитектуре слоев данных, какая она у нас, какие инструменты хранения и обработки информации есть, и покажу место детального слоя во всей этой архитектуре.



По моему мнению, архитектура слоев данных в нашем хранилище максимально классическая. Мы шли от глаголов действий над данными, которые происходят с хранилищами. Что мы делаем с данными?

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

Потом мы предоставляем эти данные для анализа, непосредственно анализируем и все. Классика.

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



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

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

Дальше детальный слой. Ядро хранилища. Здесь мы храним детальную историю изменений и консолидируем данные между всеми источниками.



На базе этого детального слоя есть слой витрин Common Data Marts.



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



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



В итоге классика, все по слоям, от RAW до REP. Данные протекают в хранилище, все как завещали Кимбалл и Инмон в своих подходах.

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



RAW, ODS это такой Data Lake. Здесь у нас полуструктурированные данные, каркас MapReduce и всевозможные внутренние аналоги экосистемы Hadoop.

Центр хранилища это непосредственно Data Warehouse. Здесь у нас слои ODS, DDS, CDM.



Основные цели давать ответ на всевозможные ad-hoc-запросы наших аналитиков, выдерживать большое количество Join и достаточно малое время отклика на всевозможные вопросы.

И витрины. Помимо того, что они служат Data Warehouse, мы еще отгружаем их содержимое в системы анализа и визуализации данных. Это кубы данных, отчеты, дашборды, Tableau.



Если мы эти слои разложим по системам, то получится приблизительно такая картина.

То есть RAW, ODS и части CDM-слоя на супербольших данных это у нас Data Lake.



Маленькая ремарка: в Яндексе много внутренних инструментов, которые мы делаем сами. У нас есть своя собственная платформа, есть такие внутренние инструменты, как YT. Можно проводить аналоги: YT это как Hadoop. И в принципе, есть всевозможные аналоги Hadoop-стека.

На Greenplum у нас находятся часть слоя ODS, детальный слой и витрины. Построенные в Greenplum витрины мы затем отгружаем в MS SSAS или ClickHouse для ряда пользователей. Некоторым удобно пользоваться кубами данных, некоторым широкимb плоскими таблицами, и ClickHouse здесь прямо идеален.

Часть витрин доступно для биосистем, или мы делаем из них агрегаты, доступные для нашего BI. BI у нас это Tableau.

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

Смотреть доклад Владимира

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

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



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

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

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

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

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

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

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



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

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

При этом минимальное дублирование информации, если мы используем SCD. И какое-то приемлемое количество join. Если аналитики работают с SQL, они должны уметь работать и с этим.

Следующие походы Data Vault и Anchor modeling. Почему я их вывел одновременно? Потому что они предлагают, на самом деле, нечто очень похожее. Это достаточно строгая нормализация, их сложно использовать без подготовки, без понимания, какие таблицы и какие правила они накладывают.



Обе методологии обещают, что их не надо перестраивать. Для Data Vault это работает с ограничениями, я дальше проговорю, какими. Здесь ультрабольшое количество join. При этом обе методологии относительно современны и обещают гибкость.

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

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

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



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

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

Сперва стоит спрогнозировать требования, а потом подумать, согласовать, подумать еще. Я уверен, что все, кто работал с классическими DWH, которые построены не по Data Vault или по якорю, понимают, что DWH это не быстро.

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

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

Логичные вопросы: откуда родились методологии Data Vault и якорь; может ли DWH быть agile; можно ли подходить к разработке хранилища гибко?

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



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



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

Data Vault вводит и регламентирует несколько основных тип таблиц. Ключевые это Hub, Link и Satellite, дальше я буду о них говорить. Hub хранит сущности, Link обеспечивает связь между хабами, Satellite предоставляет атрибуты и описания Data Vault.

Data Vault 2.0 я не буду касаться. Есть еще специальные таблицы типа bridge и point-in-time. Они упрощают или соединение данных через несколько связей, или получение информации из сателлитов с разной частотой обновления. Это скорее расширяющие, упрощающие модель сущности. Ключевые таблицы это все-таки Hub, Link и Satellite.

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



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

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

Если на это посмотреть с точки зрения третьей нормальной формы, то есть вот такая неплохая картинка из презентации самого автора Data Vault:


Ссылка со слайда

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



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

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

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



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

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

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

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



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



Внешние ключи находятся в наших связях.


Ссылка со слайдов

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

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

Посмотрим на стандартный тест TPC-H. Я уверен, что многие из вас знают, что это такое, но кратко напомню: это стандартный тест для проверки аналитических хранилищ, внизу на слайде есть ссылка на одну из его версий.


Ссылка со слайда

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

Если мы эту схему преобразуем в Data Vault, то видно, насколько больше таблиц становится, появляются отдельные хабы, отдельные Link. Причем Link сделаны в виде таблиц многие-ко-многим.


Ссылка со слайда

У нас вешаются сателлиты как на Link, так и на отдельные хабы. И в целом, таблица становится больше.

Если мы это конвертируем в якорную модель, получится нечто подобное.



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

В чем же схожести и различия, если их в лоб сравнивать?



И в Data Vault, и в якоре создается специальная таблица на сущность. В Data Vault это Hub, в якоре это якорь. Разница в том, что в хабе есть бизнес-ключ, а в якоре бизнес-ключ это атрибут.

В Data Vault атрибуты группируются в таблицы-сателлиты. В якоре все строже: один атрибут одна таблица, шестая нормальная форма, все раскладываем на отдельные кубики-элементы.

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

И есть специальные таблицы, в Data Vault point in time и bridge, в якоре knot.

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


В общем, как-то так. Яндекс славится тем, что создает свои инструменты. Почему бы нам не создать свою модель?

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



Из этой идеи и родилась наша гибридная модель highly Normalized hybrid Model, hNhM. Здесь я кратко расскажу ключевые идеи модели. (...)



Ключевая идея выбирать оптимальный формат хранения. Мы не ограничиваем себя ни Data Vault, ни строгостью якоря. Но при этом позволяем выбрать либо одно, либо другое. А если мы позволяем это выбрать, то с точки зрения методологии классно было бы разделить логическое и физическое моделирование.

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

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

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



У нас есть слои, про которые я рассказывал, RAW. ODS это наш Data Lake. В RAW мы захватили данные, они лежат как есть. В ODS мы их чуть почистили, но это операционные данные, без истории. В детальном слое мы фактически разложили это все на маленькие кубики-сущности. С точки зрения логического проектирования это сущности-связи между ними. С точки зрения физического хранения на нашем Greenplum это скрыто с точки зрения использования.

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

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

Про разделение логического и физического уровня. Мы хотели на старте их разделить явным образом.

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

Логический уровень когнитивно сложная часть проектирования. Он не зависит от СУБД в общем виде и достаточно сложен. Трудно выделить сущности и домены в бизнесе, особенно если ты его не знаешь так, как основатели этого бизнеса.



Физический уровень это скрипты DDL. Здесь выполняется партицирование сущностей, объединение атрибутов в группы, дистрибьюция в системах MPP. И индексы для ускорения запросов. Все перечисленное мы хотели скрыть. Во-первых, оно зависит от СУБД и технических ограничений, которые у нас есть. Во-вторых, нам хотелось сделать так, чтобы этот физический уровень был невидим, чтобы мы могли переключаться между Data Vault и якорем, если захотим.



Разделяя так логический и физический уровень, мы фактически делим задачи между нашими ролями. У нас есть две роли:

  • Партнер по данным, Data Partner. Мы так переименовали Data steward. Да, слово steward имеет здравое значение в переводе: распорядитель чего-то чужого, а данные в этом смысле чужие. Но все равно партнер по данным звучит гораздо лучше.

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

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



В итоге, базируясь на ER-диаграммах, мы используем сущность и связь. Сущность базовый кубик любого описания домена. Здесь, на логическом уровне, мы хотели сделать так, чтобы сущности описывались наименованием и комментарием; именем сущности и его описанием; бизнес-ключом, который обязателен для любой сущности, чтобы понимать, что его определяет; и каким-то набором атрибутов.

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

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

Атрибут это таблица. Она содержит информацию об одном атрибуте, может содержать историю, а может не содержать. Group это группа атрибутов, как сателлит в Data Vault. Она может содержать информацию о нескольких атрибутах. Важное ограничение на уровне модели: все атрибуты должны приходить в эту группу сателлита из нового источника и иметь один тип историзма.



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

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

Итого мы получаем примерно такую картинку: На логическом уровне рисуем ER-диаграмму или описываем нашу сущность.



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



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

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

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

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

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

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

Из якоря мы взяли к ебе сам якорь. То есть мы не храним бизнес-ключи в ключевой таблице, а смотрим на них как на еще одни атрибуты. Связи идут через отдельную таблицу, как в Data Vault или якоре, но мы запретили вешать на них атрибуты. В перспективе хотим разрешить.



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

Из Data Vault мы взяли специальные таблицы point in time и bridge для упрощения своей собственной внутренней работы с hNhM.

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



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

Да, я расскажу о нашем фреймворке что сделали, как храним и как с этим работаем.

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

Смотреть доклад Владимира

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



В центральной части находится наш Data Lake, который хранится на YT. Это аналог экосистемы Hadoop, в нем мы храним слои RAW и ODS. Сейчас у нас объем данных около двух петабайт.

И само хранилище на Greenplum, в котором находится частично ODS за очень небольшое количество времени и слой DDS. Сейчас Greenplum у нас весит, по-моему, около пол-петабайта. На каждом слое мы умеем вычислять инкремент, что позволяет ускорить загрузку, избавиться от лишней работы по чтению и обработке данных.

Детальный слой основной элемент хранилища на Greenplum. На его основе мы строим все наши витрины.



Отличительная особенность платформы: все сущности на всех слоях описываются в питонячем коде, но это видно и на слайде. Особенности описания сущностей в DDS мы рассмотрим на основе сущностей Person сотрудник.



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

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



Далее необходимо описать технические параметры layout. С помощью трех полей Layer, Group и Name мы определяем путь до места хранения объекта в нашем хранилище. Неважно, будет ли это YT, Greenplum или что-то еще в будущем.



Следующим шагом мы начинаем описывать все атрибуты, которые есть в сущности. Для этого мы используем предопределенные типы, которые также едины во всей платформе. Для каждого атрибута нужно обязательно указать документацию. Я уже говорил, что это сделано для удобства пользователей.

Для каждого атрибута указываем тип историчности.



Сейчас у нас есть три типа. Ignore это когда пришедшее значение записывается, а мнение с большей бизнес-датой игнорируется. Update когда при изменении значение перезаписывается. New исторический атрибут.

Атрибуты с Ignore и Update не хранят историю изменений и отличаются тем, что в Ignore предпочтение отдается значению с меньшей бизнес-датой, а в Update с большей.



Также для каждой сущности мы указываем логический ключ.

На слайде видно, во что физически превращается каждая сущность.





Тут, наверное, сложно сходу что-то понять, поэтому я детально опишу основные особенности каждой таблицы. В каждой таблице есть поле SK, это суррогатный ключ, который мы получаем из натурального ключа. Используем хэш, это удобно, потому что при загрузке любого атрибута, любой сущности, любого хаба, мы можем на основе данных из системы источника сгенерить хэш и иметь внутренний ключ в хранилище.

Также в каждой таблице есть поля, в которых хранятся даты действия атрибута.





Если у данного атрибута историчность New, то это два столбца utc_valid_from_dttm и utc_valid_to_dttm. То есть это метка во времени, с которой определяется действие конкретной записи.

Для атрибутов типа историчности Update и Ignore действует только один столбец: utc_valid_from. Это бизнес-дата, с которой мы узнали, что атрибут имеет текущее значение.





Также в каждой из таблиц находится два системных столбца: дата последнего изменения записи и система источника, из которой пришел этот атрибут.

В якорной модели каждый атрибут должен храниться в отдельной таблице, но в реальной жизни это оказалось не очень удобно для таблиц фактов.



Например, для финансовых транзакций, где все атрибуты приходят из одного источника. Самих транзакций крайне много, и соединять между собой таблицы атрибутов достаточно дорого. А в отчетности очень редко бывает, что атрибуты и транзакции должны быть по отдельности.

Поэтому мы решили, что круто уметь атрибуты объединять в группы, чтобы их хранить в одном месте. Это позволит и ускорить загрузку и упростить чтение данных. Для этого мы добавили свойства групп их надо задавать у атрибутов, которые должны быть объединены в группу.

В группы можно объединять только атрибуты с одинаковой историчностью. И важно понимать минус этого решения: атрибуты должны приходить все вместе, иначе мы будем терять информацию. И желательно, с одинаковой частотой изменения. Потому что если один атрибут имеет гораздо большую частоту изменений, то при создании исторических данных все атрибуты будут лишний раз дублироваться.

Структура после объединения части атрибутов в группы выглядит уже гораздо более компактно.





Например, все флаги мы объединили в группу flg, общую информацию в группу info, ключевые атрибуты в группу key.

Теперь поговорим про объявление связей. Каждая связь тоже описывается в отдельном классе.



Атрибутами связи являются сами сущности, поэтому атрибуты Link должны быть объединены с объектами специального класса. Также обязательно для Link указывать ключ. Ключ определяет набор сущностей, которые однозначно идентифицируют строку в Link.

Каждый Link по умолчанию является историческим. Таким образом, в нем всегда есть два поля: utc_valid_from и utc_valid_to.

В этом примере мы делаем Link между департаментом и сотрудником. В данном случае ключом Link является сотрудник.



На слайде мы видим, как это физически реализовано.



Видно, что у Link нет суррогатного ключа, потому что его суррогатным ключом являются все те сущности, которые входят в ключ Link.

Теперь мы обсудим, как загружаем сущности.

Для загрузки был разработан ETL-кубик, который является частью нашего ETL-процесса. Он также описывается на Python. Мы описываем, что подается на вход, как данные распределяются по атрибутам и что является полем с бизнес-датой.



В самом простом случае этот кубик выглядит так.



В самом начале мы описываем источник. Это stage-таблица, для которой есть точно такой же класс, написанный отдельно в платформе.

Также мы указываем поле, в котором хранится бизнес-дата и система-источник.

Далее мы делаем маппинг между атрибутами. Описание таргета состоит из двух частей указание сущности, куда мы загружаем атрибуты, и маппинг самих атрибутов.



Как мы видим, в качестве таргета указана таблица Person. Дальше следует маппинг атрибутов. Это самый простой вариант описания. В реальной жизни все выглядит гораздо сложнее. Например, так.



Мы видим, что из одной stage-таблицы данные грузятся в несколько сущностей. Одна сущность может грузиться несколько раз. Это в данном случае e-mail, он может быть в stage-таблице и персональным, и рабочим.

В большинстве случаев при загрузке Link не обязательно указывать маппинг, так как на основе этой информации мы можем понять, какие сущности указаны в Link, и найти маппинг этих сущностей в таргетах.

А если нет, то, скорее всего, это ошибка и мы об этом сообщаем пользователю.



Но в ряде случаев мы не можем автоматически этого сделать. Например, в stage-таблицу приходит и персональный почтовый ящик, и рабочий. Но и в обоих Link используется одна и та же сущность e-mail. Мы их специально не разделяем, потому что в реальном мире e-mail единая сущность. В то же время нам надо понять, какое здесь поле, какой Link грузить. Мы добавили специальный параметр, который позволяет определить, какое поле куда идет.

Дальше есть вот такой граф загрузки. Для каждой такой загрузки, для каждого такого лоудера генерится свой граф загрузки, который состоит из атомарных загрузок атрибутов Hub Link. Суррогатный ключ генерится как хэш, поэтому он может создаваться на каждом этапе самостоятельно.



Все задачи внутри графа выполняются параллельно, в зависимости от типа изменений. Это либо Insert\Update по SCD2, либо Insert. Данные из разных источников также могут загружаться параллельно.



Дальше я расскажу, как мы используем наш hNhM.

На основе DDS мы строим витрины. Пользователи напрямую могут обращаться к нашим сущностям и просматривать данные.



У нас есть два основных типа потребления данных DDS: ad-hoc-запросы от бизнес-пользователей и построение витрин. ad-hoc-запрос реализован двумя способами либо view, либо через функции. И то и то скрывает реализацию от пользователей.

Витрины мы строим с помощью нашего фреймворка и тоже полностью скрываем реализацию.

Почему нельзя писать чистый SQL для детального слоя? В первую очередь потому, что дата-инженер должен знать о всех сущностях и атрибутах, должен знать, какой тип историчности у конкретного атрибута, и правильно это использовать в запросе.

После изменения описания сущности все витрины, которые использует тот или иной атрибут, теоретически могут развалиться, потому что атрибут был исторический, стал не исторический, и это изменит структуру таблицы.

В конце концов, это просто очень сложно написать что-то с select, где будет 20-30 join.

Как у нас происходит доступ к сущностям из Python?



Было разработано несколько классов, которые позволяют сформировать SQL-запрос к определенной сущности и затем использовать его в построении витрины. Как это выглядит?



Мы описываем CTE. Оно может быть либо историческое, либо актуальное.



В первую очередь мы указываем сущность, которую хотим использовать.



После этого указываем необходимые атрибуты, которые мы будем использовать для витрины.



Прямо здесь мы можем сделать переименования, чтобы лишний не делать их в коде.

А так выглядит SQL-запрос для построения витрины. Есть переменные постановки, которые полностью скрывают реализацию. Потом идет сам запрос, который их использует. При этом можно использовать переменные как в визе, так и во временных таблицах. Это уже зависит от объема данных.



Как происходит доступ к сущностям из СУБД?



У нас есть вьюхи и есть две специальные процедуры. Первая get_entity. Она получает в качестве параметров сущность, столбцы и создает временную таблицу, в которой будут, собственно, все необходимые данные процедуры.

И вторая процедура это когда мы от entity к существующей таблице через Link добавляем дополнительную сущность и расширяем нашу табличку. Теоретически ключ у нее даже может поменяться.

А сейчас чуть более подробно покажу, как это работает.



Мы вызываем специальную функцию.



Указываем сущность.



Указываем столбцы, которые необходимо получить. Получить все столбцы сейчас нельзя, потому что для больших сущностей достаточно дорого считать всё вместе. Поэтому мы заставляем пользователя указать конкретные атрибуты, которые он хочет получить.



Дальше указываем временную таблицу, в которую надо положить данные.

Затем выполняется эта процедура. В ней создается таблица, куда вставляются данные. После этого можно просто селектить таблицу и смотреть подготовленные данные.



Примерно так же выглядит добавление сущности к уже созданной таблице.



Мы указываем таблицу, в которой уже есть сущность.



Указываем Link, через который мы хотим подключить дополнительную сущность.



Указываем саму сущность, которую мы хотим добавить.



И столбцы этой новой сущности. При этом мы можем как положить данные в уже существующую таблицу, так и создать новую.



Дальше мы расскажем, какие задачи оптимизации мы решали. Тут я передам слово Жене.

Спасибо. Расскажу про оптимизацию и атрибуты против групп. Задача, на мой взгляд, очень интересная.

Посмотрим на объявление класса. Например, на объявление сущности Person.



В физическом мире мы можем представить ее в виде один атрибут одна табличка.





Это будет вполне нормально с точки зрения якорной модели. А можем ли мы сгруппировать атрибут? Здесь часть сгруппирована: флаги отдельно, информация о персоне отдельно, ключи отдельно, атрибуты отдельно.





Какая схема лучше? С точки зрения загрузки данных все то же самое. С точки зрения чтения данных все это скрыто, Коля про это рассказал. И с точки зрения использования будет одинаково.Не можем ли мы сделать так, чтобы эта физическая модель выбиралась наилучшим, оптимальным образом?



Мы постарались это свести к оптимизационной задаче. У нас буквально стояла проблема с объемом данных на Greenplum. Сейчас она не стоит. У нас действительно полупетабайтовый кластер на Greenplum, это достаточно много. Но еще буквально год-полтора назад даже такого не было.

Перед нами стоял вопрос: как оптимально объединить атрибуты по группам так, чтобы минимизировать объем хранения информации. У нас были метаданные объектов, это все наше описание того, что за объекты у нас есть. Маппинги полей и загрузчиков, количество строк в объекте и инкремент, а также накопленное знание о частоте изменений. У нас свой metaDWH, в который мы складываем всю эту информацию, и могли бы ее переиспользовать.

Существует набор ограничений, набор полей в метаданных объектов и эти маппинги, загрузчики. Как вы помните, группа должна загружаться из одного источника и иметь общий тип историзма.

Будем минимизировать занимаемое место на диске. Вот такую задачу мы себе поставили. Как мы ее решали?



Прежде всего мы формализовали и стандартизовали операции, меняющие схему, но не меняющие логику. Они совсем простые и очевидные. Можем объединять группы атрибутов в какую-нибудь еще группу, еще более крупную. Можем разъединять набор групп или вообще разъединить до атрибутов.

Фактически эта миграция, которая на слайде, никак не поменяет логическое устройство данных. Но при этом каждую из них мы можем хотя бы приблизительно оценить узнать, сколько места они будут занимать в нашем случае.

Дальше мы взяли наше исходное состояние и из каждого состояния с помощью этих атомарных операций могли получить другое состояние физической системы, не меняя логическую архитектуру.



Из них следующие, из них следующие так мы получаем пространство всех возможных состояний, в которые мы можем этими атомарными операциями попасть.

Дальше все просто. Мы из нашего состояния генерим новое мутациями атомарными преобразованиями. Чтобы не обходить все, мы ограничиваем выборку и оцениваем каждое состояние.

Берем лучшие, скрещиваем их между собой, проводим новые мутации и так далее, классический генетический алгоритм.

В итоге при признаках какой-то сходимости мы останавливаемся, получаем итоговое состояние, которое лучше текущего по нашей оптимизационной функции. Сравниваем метаданные между собой и генерируем скрипт миграции. Как проходит миграция, как аккуратненько смигрировать так, чтобы пользователь не заметил, что у него физически что-то поменялось, это отдельный большой вопрос на целый доклад, мы специально его не освещаем.

Я делаю акцент на том, что оптимизация была по месту, потому что применимость этого подхода гораздо шире. Гораздо интереснее оптимизировать не место, которое мы фактически сейчас закупили, это не для нас не проблема, а скорость чтения и скорость вставки информации. Усложнить нашу матмодель так, чтобы мы оценивали не только по месту, но и как-нибудь более сложно.

У нас есть наработки в этом направлении, но, честно говоря, мы не готовы про них рассказывать, потому что еще ничего не применяли на практике. Наверное, мы это сделаем и тогда поделимся, насколько мы смогли оптимизировать построение витрины и загрузку данных с помощью такой оптимизации.



К чему мы пришли, зачем мы сюда пришли и стоит ли вообще повторять наш путь?

Первый, на наш взгляд, инсайт от доклада сравнение Data Vault и якоря. Мы поняли, что нам нужен детальный слой, что есть современные методологии Data Vault и якорь, и провели между ними сравнение. Они очень похожи. Я воспринимаю якорь как более жесткую форму Data Vault: в последнем есть правила, а в якоре эти правила еще более сложные.



Мы провели сравнение и сделали вывод: можно взять лучшее из каждой методологии. Не стоит слепо идти по одной из них и разрезать, например, clickstream на отдельные атрибуты. Стоит взять лучшее из Data Vault и из якоря.


Наш вариант это гибридная модель, мы ее назвали hNhM. Требования я не буду перечислять, они на слайде. Основных идей три. Логическое и физическое проектирование, физический мир полностью скрыть как с точки зрения загрузки информации, так и с точки зрения ее получения.

Раз мы все это скрыли, то можем для физического мира выбирать нечто наиболее оптимальное. Оптимизировали именно хранение информации, но можно сделать нечто более интересное, например чтение.

На слайде показано, что мы взяли из Data Vault, а что из якоря.

Стоит ли за этим повторять? Пожалуйста, можете взять и полностью повторить, использовать точно так же. Кажется, что это вполне рабочая вещь, потому что мы ее используем уже года полтора.



Всем этим невозможно управлять без фреймворка. На явный вопрос, сколько у нас это заняло времени, Коля ответил квартал. Но именно потому, что мы базировались на существующем внутреннем решении. На схеме было видно, что мы лишь маленький кубик в нашей общей платформе. Однако квартал это не совсем честно. У нас был еще квартал на проектирование, размышление, не то чтобы это абсолютно чистая разработка.

Этот путь мы не советуем повторять. Если у вас нет свободной горы разработчиков, которые настолько круто прокачаны в Python, что готовы все это повторить и аккуратно сделать так же, как у нас, лучше не вступать на этот скользкий путь. Вы просто убьете очень много времени и не получите того бизнес-результата, который можно получить, используя Data Vault, якорь или гибридную модель, как мы.



Что же делать? У нас есть большой roadmap развития. Можно добавить больше гибкости например, я говорил, что на связь мы не вешаем ни атрибуты, ни группы. Но здесь можно это реализовать и воссоздать все типы таблиц Data Vault и якоря.

В нашем коде мы сейчас сильно завязаны на Greenplum. Хотелось бы от этого оторваться, сделать так, чтобы можно было применять модель где угодно именно с точки зрения фреймворка.

DSL у нас хоть и есть, но хотелось бы полноценный язык, который позволит строить витрины с учетом инкремента так, чтобы была полная кодогенерация. Пока она частичная.

Можно оптимизировать не только хранение, но и скорость выполнения запросов, делать автоматизированные миграции, потому что сейчас они еще полуручные, мы все еще боимся делать это аккуратно. И совсем фантастика это визуальное редактирование метаданных.

Что делать, если у вас нет разработчиков? Дождаться, когда мы это заопенсорсим. У нас есть такие мечты. Вполне возможно, что мы свои фантазии осуществим и заопенсорсим этот продукт.

Следите за обновлениями или, если не хотите следить, приходите к нам и создавайте гибридную модель вместе с нами. На этом все. Спасибо за внимание.
Подробнее..

Пишем переиспользуемые компоненты, соблюдая SOLID

01.06.2021 12:20:22 | Автор: admin
Всем привет! Меня зовут Рома, я фронтендер в Я.Учебнике. Сегодня расскажу, как избежать дублирования кода и писать качественные переиспользуемые компоненты. Статья написана по мотивам (но только по мотивам!) доклада с Я.Субботника видео есть в конце поста. Если вам интересно разобраться в этой теме, добро пожаловать под кат.

Статья содержит более детальный разбор принципов и подробные примеры из практики, которые не поместились в доклад. Рекомендую прочитать, если вы хотите глубоко погрузиться в тему и узнать, как мы пишем переиспользуемые компоненты. Если же вы хотите познакомиться с миром переиспользуемых компонентов в общих чертах, то, по моему мнению, вам больше подойдёт запись доклада.



Все знают, что дублирование кода это плохо, потому что об этом часто говорят: в книге по вашему первому языку программирования, на курсах по программированию, в книгах, посвящённых написанию качественного кода, таких как Совершенный код и Чистый код.

Давайте разберёмся, почему так сложно избегать дублирования во фронтенде и как правильно писать переиспользуемые компоненты. И помогут нам принципы SOLID.

Почему сложно перестать дублировать код?


Казалось бы, принцип звучит просто. И в то же время легко проверить, соблюдается ли он: если в кодовой базе нет дублирования, значит, всё хорошо. Почему же на практике получается так сложно?

Разберём пример с библиотекой компонентов Я.Учебника. Когда-то давно проект был монолитом. Позже для удобства разработчики решили вынести переиспользуемые компоненты в отдельную библиотеку. Одним из первых туда попал компонент кнопки. Компонент развивался, со временем появились новые умения и настройки для кнопки, увеличилось количество визуальных кастомизаций. Спустя некоторое время компонент стал настолько навороченным, что использовать его для новых задач и расширять дальше стало неудобно.

И вот, в очередной итерации появилась копия компонента Button2. Это произошло очень давно, точных причин появления уже никто и не помнит. Тем не менее, компонент был создан.



Казалось бы, ничего страшного пусть будет две кнопки. В конце концов, это всего лишь кнопка. Однако на самом деле наличие двух кнопок в проекте имело очень неприятные долговременные последствия.

Каждый раз, когда нужно было обновить стили, было непонятно, в каком компоненте это делать. Приходилось проверять, где какой компонент используется, чтобы случайно не сломать стили в других местах. Когда появлялся новый вариант отображения кнопки, мы решали, какой из компонентов расширять. Каждый раз, когда пилили новую фичу, задумывались, какую из кнопок использовать. А иногда в одном месте нужно было несколько разных кнопок, и тогда в один компонент проекта мы импортировали сразу два компонента кнопки.

Несмотря на то, что в долгосрочной перспективе существование двух компонентов кнопки оказалось болезненным, мы не сразу поняли серьёзность проблемы и успели сделать нечто похожее с иконками. Создали компонент, а когда поняли, что он нам не очень удобен, сделали Icon2, а когда и он оказался неподходящим для новых задач, написали Icon3.

Почти весь набор негативных последствий дублирования кнопки повторился в компонентах иконки. Было немного легче потому, что иконки используются в проекте реже. Хотя, если быть честным, тут всё зависит от фичи. Причём и для кнопки, и для иконки старый компонент не удалялся при создании нового, потому что удаление требовало большого рефакторинга с возможным появлением багов по всему проекту. Что же объединяет случаи с кнопкой и иконкой? Одинаковая схема появления дубликатов в проекте. Нам было сложно переиспользовать текущий компонент, адаптировать его к новым условиям, поэтому мы создавали новый.

Создавая дубликат компонента, мы усложняем себе дальнейшую жизнь. Нам хотелось собирать интерфейс из готовых блоков, как конструктор. Чтобы делать это удобно, нужны качественные компоненты, которые можно просто взять и использовать. Корень проблемы в том, что компонент, который мы планировали переиспользовать, был написан неправильно. Его было сложно расширять и применять в других местах.

Компонент для переиспользования должен быть достаточно универсальным и в то же время простым. Работа с ним не должна вызывать боль и напоминать стрельбу из пушки по воробьям. С другой стороны, компонент должен быть достаточно кастомизируемым, чтобы при небольшом изменении сценария не выяснилось, что проще написать Компонент2.

SOLID на пути к переиспользуемым компонентам


Чтобы написать качественные компоненты, нам пригодится набор правил, скрывающихся за аббревиатурой SOLID. Эти правила объясняют, как объединять функции и структуры данных в классы и как классы должны сочетаться друг с другом.

Почему же именно SOLID, а не любой другой набор принципов? Правила SOLID говорят о том, как правильно выстроить архитектуру приложения. Так, чтобы можно было спокойно развивать проект, добавлять новые функции, изменять существующие и при этом не ломать всё вокруг. Когда я попытался описать, каким, по моему мнению, должен быть хороший компонент, то понял, что мои критерии близки к принципам SOLID.

  • S принцип единственной ответственности.
  • O принцип открытости/закрытости.
  • L принцип подстановки Лисков.
  • I принцип разделения интерфейсов.
  • D принцип инверсии зависимостей.

Какие-то из этих принципов хорошо подходят для описания компонентов. Другие же выглядят более притянутыми за уши в контексте фронтенда. Но все вместе они хорошо описывают моё видение качественного компонента.

Мы пойдём по принципам не по порядку, а от простого к сложному. Вначале рассмотрим базовые вещи, которые могут пригодиться в большом количестве ситуаций, а затем более мощные и специфичные.

В статье приведены примеры кода на React + TypeScript. Я выбрал React как фреймворк, с которым больше всего работаю. На его месте может быть любой другой фреймворк, который вам нравится или подходит. Вместо TS может быть и чистый JS, но TypeScript позволяет явно описывать контракты в коде, что упрощает разработку и использование сложных компонентов.

Базовое


Принцип open/close


Программные сущности должны быть открыты для расширения и закрыты для изменения. Другими словами, мы должны иметь возможность расширять функционал с помощью нового кода без изменения существующего. Почему это важно? Если каждый раз для добавления нового функционала придётся редактировать кучу существующих модулей, весь проект станет нестабильным. Появится много мест, которые могут сломаться из-за того, что в них постоянно вносятся изменения.

Рассмотрим применение принципа на примере кнопки. Мы создали компонент-кнопку, и у него есть стили. Пока всё работает хорошо. Но тут приходит новая задача, и выясняется, что в одном конкретном месте для этой кнопки нужно применить другие стили.


Кнопка написана так, что её нельзя изменить без редактирования кода

Чтобы применить другие стили в текущей версии, придётся отредактировать компонент кнопки. Проблема заключается в том, что в компонент не заложена кастомизируемость. Вариант написать глобальные стили рассматривать не будем, так как он ненадёжен. Всё может сломаться при любой правке. Последствия легко представить, если на место кнопки поставить что-то более сложное, например, компонент выбора даты.

Согласно принципу открытости/закрытости мы должны написать код так, чтобы при добавлении нового стиля не пришлось переписывать код кнопки. Всё получится, если часть стилей компонента можно прокинуть снаружи. Для этого заведём проп, в который пробросим нужный класс для описания новых стилей компонента.

// утилита для формирования класса, можно использовать любой аналогimport cx from 'classnames';// добавили новый проп  mixconst Button = ({ children, mix }) => {  return (    <button      className={cx("my-button", mix)}    >      {children}    </button>}

Готово, теперь для кастомизации компонента не нужно править его код.



Этот довольно популярный способ позволяет кастомизировать внешний вид компонента. Его называют миксом, потому что дополнительный класс подмешивается к собственным классам компонента. Отмечу, что проброс класса не единственная возможность стилизовать компонент извне. Можно передавать в компонент объект с CSS-свойствами. Можно использовать CSS-in-JS решения, суть не изменится. Миксы используют многие библиотеки компонентов, например: MaterialUI, Vuetify, PrimeNG и другие.

Какой вывод можно сделать о миксах? Они просты в реализации, универсальны и позволяют гибко настраивать внешний вид компонентов с минимальными усилиями.

Но у такого подхода есть и минусы. Он даёт очень много свободы, из-за чего могут возникнуть проблемы со специфичностью селекторов. А ещё такой подход нарушает инкапсуляцию. Для того, чтобы сформировать правильный css-селектор, нужно знать внутреннее устройство компонента. А значит, такой код может сломаться при рефакторинге компонента.

Изменчивость компонентов


У компонента есть части, являющиеся его ядром. Если их изменить, мы получим другой компонент. Для кнопки это набор состояний и поведение. Пользователи отличают кнопку от, например, чекбокса благодаря эффекту при наведении и нажатии. Есть общая логика работы: когда пользователь кликает, срабатывает обработчик события. Это ядро компонента, то, что делает кнопку кнопкой. Да, бывают исключения, но в большинстве сценариев использования всё работает именно так.

А ещё в компоненте есть части, которые могут меняться в зависимости от места применения. Стили как раз относятся к этой группе. Может, нам понадобится кнопка другого размера или цвета. С другой обводкой и скруглением или с другим ховер эффектом. Все стили изменяемая часть компонента. Мы не хотим переписывать или создавать новый компонент каждый раз, когда кнопка выглядит по-другому.

То, что часто меняется, должно настраиваться без изменения кода. Иначе мы окажемся в ситуации, когда проще создать новый компонент, чем настраивать и дописывать старый, который оказался недостаточно гибким.

Темизация


Вернёмся к кастомизации визуала компонента на примере кнопки. Следующий способ применение тем. Под темизацией я подразумеваю способность компонента отображаться в нескольких режимах, по-разному в разных местах. Эта интерпретация шире, чем темизация в контексте светлой и тёмной темы.

Использование тем не исключает предыдущий способ с миксами, а дополняет его. Мы явно говорим, что компонент имеет несколько способов отображения и при использовании требует указать желаемый.

import cx from 'classnames';import b from 'b_';const Button = ({ children, mix, theme }) => (  <button    className={cx(     b("my-button", { theme }), mix)}  >    {children}  </button>)

Темизация позволяет избежать зоопарка стилей, когда, например, у вас в проекте 20 кнопок и все выглядят немного по-разному из-за того, что стили каждой кнопки задаются в месте применения. Применять подход можно для всех новых компонентов, не опасаясь оверинжиниринга. Если вы понимаете, что компонент может выглядеть по-разному, лучше с самого начала явно завести темы. Это упростит дальнейшую разработку компонента.

Но есть и минус способ подходит только для кастомизации визуала и не позволяет влиять на поведение компонента.

Вложенность компонентов


Я перечислил не все способы избежать изменения кода компонента при добавлении новых функций. Другие будут продемонстрированы при разборе остальных принципов. Здесь же мне хотелось бы упомянуть про дочерние компоненты и слоты.

Веб-страница представляет собой древовидную иерархию компонентов. Каждый компонент сам решает, что и как отрисовать. Но это не всегда так. Например, кнопка позволяет указать, какой контент будет отрисован внутри. В React основной инструмент проп children и render-пропы. Во Vue есть более мощная концепция слотов. При написании простых компонентов с использованием этих возможностей не возникает никаких проблем. Но важно не забывать, что даже в сложных компонентах можно использовать пробрасывание части элементов, которые должен отобразить компонент сверху.

Продвинутое


Описанные ниже принципы подойдут для более крупных проектов. Соответствующие им приёмы дают большую гибкость, но увеличивают сложность проектирования и разработки.

Single Responsibility Principle


Принцип единственной ответственности означает, что модуль должен иметь одну и только одну причину для изменения.

Почему это важно? Последствия нарушения принципа включают:
  • Риск при редактировании одной части системы сломать другую.
  • Плохие абстракции. Получаются компоненты, которые умеют выполнять несколько функций, из-за чего сложно понять, что именно должен делать компонент, а что нет.
  • Неудобная работа с компонентами. Очень сложно делать доработки или исправлять баги в компоненте, который делает всё сразу.

Вернёмся к примеру с темизацией и посмотрим, соблюдается ли там принцип единственной ответственности. Уже в текущем виде темизация справляется со своими задачами, но это не значит, что у решения нет проблем и его нельзя сделать лучше.


Один модуль редактируется разными людьми по разным причинам

Допустим, мы положили все стили в один css-файл. Он может редактироваться разными людьми по разным причинам. Получается, что принцип единственной ответственности нарушен. Кто-то может отрефакторить стили, а другой разработчик внесёт правки для новой фичи. Так можно легко что-то сломать.

Давайте подумаем, как может выглядеть темизация с соблюдением SRP. Идеальная картина: у нас есть кнопка и отдельно набор тем для неё. Мы можем применить тему к кнопке и получить темизированную кнопку. Бонусом хотелось бы иметь возможность собрать кнопку с несколькими доступными темами, например, для помещения в библиотеку компонентов.


Желаемая картина. Тема отдельная сущность и может примениться к кнопке

Тема оборачивает кнопку. Такой подход используется в Лего, нашей внутренней библиотеке компонентов. Мы используем HOC (High Order Components), чтобы обернуть базовый компонент и добавить ему новые возможности. Например, возможность отображаться с темой.

HOC функция, которая принимает компонент и возвращает другой компонент. HOC с темой может прокидывать объект со стилями внутрь кнопки. Ниже представлен скорее учебный вариант, в реальной жизни можно использовать более элегантные решения, например, прокидывать в компонент класс, стили которого импортируются в HOC, или использовать CSS-in-JS решения.

Пример простого HOC для темизации кнопки:

const withTheme1 = (Button) =>(props) => {    return (        <Button            {...props}            styles={theme1Styles}        />    )}const Theme1Button = withTheme1(Button);

HOC может применять стили, только если указана определённая тема, а в остальных случаях не делает ничего. Так мы можем собрать кнопку с комплектом тем и активировать нужную, указав проп theme.

Использование нескольких HOCов для сбора кнопки с нужными темами:

import "./styles.css"; // Базовый компонент кнопки. Принимает содержимое кнопки и стилиconst ButtonBase = ({ style, children }) => { console.log("styl123e", style); return <button style={style}>{children}</button>;}; const withTheme1 = (Button) => (props) => { // HOC применяет стили, только если выбрана тема "theme1" if (props.theme === "theme1") {   return <Button {...props} style={{ color: "red" }} />; }  return <Button {...props} />;}; const withTheme2 = (Button) => (props) => { // HOC применяет стили, только если выбрана тема "theme2" if (props.theme === "theme2") {   return <Button {...props} style={{ color: "green" }} />; }  return <Button {...props} />;}; // ф-я для оборачивания компонента в несколько HOCconst compose = (...hocs) => (BaseComponent) => hocs.reduce((Component, nextHOC) => nextHOC(Component), BaseComponent); // собираем кнопку, передав нужный набор темconst Button = compose(withTheme1, withTheme2)(ButtonBase); export default function App() { return (   <div className="App">     <Button theme="theme1">"Red"</Button>     <Button theme="theme2">"Green"</Button>   </div> );}

И тут мы приходим к выводу, что нужно разделить области ответственности. Даже если кажется, что у вас один компонент, подумайте так ли это на самом деле? Возможно, его стоит разделить на несколько слоёв, каждый из которых будет отвечать за конкретную функцию. Почти во всех случаях визуальный слой можно отделить от логики компонента.

Выделение темы в отдельную сущность даёт плюсы к удобству использования компонента: можно поместить кнопку в библиотеку с базовым набором тем и разрешить пользователям писать свои при необходимости; темы можно удобно шарить между проектами. Это позволяет сохранить консистентность интерфейса и не перегружать исходную библиотеку.

Существуют разные варианты реализации разделения на слои. Выше был пример с HOC, но композиция также возможна. Однако я считаю, что в случае с темизацией HOC более уместны, так как тема не является самостоятельным компонентом.

Выносить в отдельный слой можно не только визуал. Но я не планирую подробно рассматривать вынесение бизнес-логики в HOC, потому что вопрос весьма холиварный. Моё мнение вы можете так поступить, если понимаете, что делаете и зачем вам это нужно.

Композитные компоненты


Перейдём к более сложным компонентам. Возьмём в качестве примера Select и разберёмся, в чём польза принципа единственной ответственности. Select можно представить как композицию более мелких компонентов.



  • Container связь между остальными компонентами.
  • Field текст для обычного селекта и инпут для компонента CobmoBox, где пользователь что-то вводит.
  • Icon традиционный для селекта значок в поле.
  • Menu компонент, который отображает список элементов для выбора.
  • Item отдельный элемент в меню.

Для соблюдения принципа единственной ответственности нужно вынести все сущности в отдельные компоненты, оставив каждому только одну причину для редактирования. Когда мы распилим файл, возникнет вопрос: как теперь кастомизировать получившийся набор компонентов? Например, если нужно задать тёмную тему для поля, увеличить иконку и изменить цвет меню. Есть два способа решить эту задачу.

Overrides


Первый способ прямолинейный. Все настройки вложенных компонентов выносим в пропы исходного. Правда, если применить решение в лоб, окажется, что у селекта огромное количество пропов, в которых сложно разобраться. Нужно как-то удобно их организовать. И тут нам поможет override. Это конфиг, который пробрасывается в компонент и позволяет настроить каждый его элемент.

<Select  ...  overrides={{    Field: {      props: {theme: 'dark'}    },    Icon: {      props: {size: 'big'},    },    Menu: {      style: {backgroundColor: '#CCCCCC'}    },  }}/>

Я привёл простой пример, где мы переопределяем пропы. Но override можно рассматривать как глобальный конфиг он настраивает всё, что поддерживает компоненты. Увидеть, как это работает на практике, можно в библиотеке BaseWeb.

Итого, с помощью override можно гибко настраивать композитные компоненты, а ещё такой подход отлично масштабируется. Из минусов: конфиги для сложных компонентов получаются очень большими, а мощь override имеет и обратную сторону. Мы получаем полный контроль над внутренними компонентами, что позволяет делать странные вещи и выставлять невалидные настройки. Также, если вы не используете библиотеки, а хотите реализовать подход самостоятельно, придётся научить компоненты понимать конфиг или написать обёртки, которые будут читать его и настраивать компоненты правильно.

Dependency Inversion Principle


Чтобы разобраться в альтернативе override-конфигам, обратимся к букве D в SOLID. Это принцип инверсии зависимостей. Он утверждает, что код, реализующий верхнеуровневую политику, не должен зависеть от кода, реализующего низкоуровневые детали.

Вернёмся к нашему селекту. Container отвечает за взаимодействие между другими частями компонента. Фактически он представляет собой корень, управляющий рендером остальных блоков. Для этого он должен их импортировать.

Так будет выглядеть корень любого сложного компонента, если не использовать инверсию зависимостей:

import InputField from './InputField';import Icon from './Icon';import Menu from './Menu';import Option from './Option';

Разберём зависимости между компонентами, чтобы понять, что может пойти не так. Сейчас более высокоуровневый Select зависит от низкоуровневого Menu, потому что импортит его в себя. Принцип инверсии зависимостей нарушен. Это создаёт проблемы.
  • Во-первых, при изменении Menu придётся править Select.
  • Во-вторых, если мы захотим использовать другой компонент меню, нам тоже придётся вносить правки в компонент селекта.


Непонятно, что делать, когда понадобится Select с другим меню

Нужно развернуть зависимость. Сделать так, чтобы компонент меню зависел от селекта. Инверсия зависимостей делается через инъекцию зависимостей Select должен принимать компонент меню как один из параметров, пропов. Здесь нам поможет типизация. Мы укажем, какой компонент ожидает Select.

// теперь вместо прямого импорта Select принимает одним из параметров компонент менюconst Select = ({  Menu: React.ComponentType<IMenu>}) => {  return (    ...    <Menu>      ...    </Menu>    ...  )...}

Так мы декларируем, что селекту нужен компонент меню, пропы которого удовлетворяют определённому интерфейсу. Тогда стрелки будут направлены в обратную сторону, как и предписывает принцип DI.


Стрелка развёрнута, так работает инверсия зависимостей

Мы решили проблему с зависимостями, но немного синтаксического сахара и вспомогательные инструменты здесь не помешают.

Каждый раз прокидывать все зависимости в компонент в месте рендера утомительно, но в библиотеке bem-react есть реестр зависимостей и процесс композиции. С их помощью можно упаковать зависимости и настройки один раз, а дальше просто использовать готовый компонент.

import { compose } from '@bem-react/core'import { withRegistry, Registry } from '@bem-react/di'const selectRegistry = new Registry({ id: cnSelect() })...selectRegistry.fill({    'Trigger': Button,    'Popup': Popup,    'Menu': Menu,    'Icon': Icon,})const Select = compose(    ...    withRegistry(selectRegistry),)(SelectDesktop)

В примере выше показана часть сборки компонента на примере bem-react. Полный код примера и песочницу можно посмотреть в сторибуке yandex UI.

Что мы получаем от использования Dependency Inversion?

  • Полный контроль свободу в настройке всех составляющих компонента.
  • Гибкую инкапсуляцию возможность сделать компоненты очень гибкими и полностью кастомизируемыми. При необходимости разработчик переопределит все блоки, из которых состоит компонент, и получит то, что хочет. При этом всегда есть вариант создать уже настроенные, готовые компоненты.
  • Масштабируемость способ хорошо подходит для библиотек любых размеров.

Мы в Яндекс.Учебнике пишем собственные компоненты, используя DI. Внутренняя библиотека компонентов Лего тоже использует этот подход. Но один существенный минус у него есть гораздо более сложная разработка.

Сложности разработки переиспользуемых компонентов


В чём же сложность разработки переиспользуемых компонентов?

Во-первых, долгое и тщательное проектирование. Нужно понять, из каких частей состоят компоненты и какие части могут меняться. Если сделать все части изменяемыми, мы получим огромное количество абстракций, в которых сложно разобраться. Если же изменяемых частей будет слишком мало, компонент получится недостаточно гибким. Его нужно будет дорабатывать во избежание будущих проблем с переиспользованием.

Во-вторых, высокие требования к компонентам. Вы поняли, из каких частей будут состоять компоненты. Теперь нужно написать их так, чтобы они ничего не знали друг о друге, но могли использоваться вместе. Это сложнее, чем разработка без оглядки на переиспользуемость.

В-третьих, сложная структура как следствие предыдущих пунктов. Если требуется серьёзная кастомизация, придётся пересобрать все зависимости компонента. Для этого нужно глубоко понимать, из каких частей он состоит. Важную роль в процессе играет наличие хорошей документации.

В Учебнике есть внутренняя библиотека компонентов, где находятся образовательные механики часть интерфейса, с которой взаимодействуют дети во время решения заданий. И ещё есть общая библиотека образовательных сервисов. Туда мы выносим компоненты, которые хотим переиспользовать между разными сервисами.

Перенос одной механики занимает несколько недель при условии, что у нас уже есть работающий компонент и мы не добавляем новый функционал. Большая часть этой работы распилить компонент на независимые куски и сделать возможным их совместное использование.

Liskov Substitution Principle


Предыдущие принципы были о том, что нужно делать, а последние два будут о том, что нужно не сломать.

Начнём с принципа подстановки Барбары Лисков. Он говорит, что объекты в программе должны быть заменяемыми на экземпляры их подтипов без нарушения правильности выполнения программы.

Мы обычно не пишем компоненты как классы и не используем наследование. Все компоненты взаимозаменяемы из коробки. Это основа современного фронтенда. Не совершать ошибок и поддерживать совместимость помогает строгая типизация.

Как же заменяемость из коробки может сломаться? У компонента есть API. Под API я понимаю совокупность пропов компонента и встроенных во фреймворк механизмов, таких, как механизм обработчика событий. Строгая типизация и линтинг в IDE способны подсветить несовместимость в API, но компонент может взаимодействовать с внешним миром и в обход API:

  • читать и писать что-то в глобальный стор,
  • взаимодействовать с window,
  • взаимодействовать с cookie,
  • читать/писать local storage,
  • делать запросы в сеть.



Всё это небезопасно, потому что компонент зависит от окружения и может сломаться, если перенести его в другое место или в другой проект.

Чтобы соблюдать принцип подстановки Лисков нужно:

  • использовать возможности типизации,
  • избегать взаимодействия в обход API компонента,
  • избегать побочных эффектов.

Как избежать взаимодействия не через API? Вынести всё, от чего зависит компонент, в API и написать обёртку, которая будет пробрасывать данные из внешнего мира в пропы. Например, так:
const Component = () => {   /*      К сожалению, использование хуков приводит к тому, что компонент много знает о своем окружении.      Например, тут он знает о наличии стора и его внутренней структуре.      При переносе в другой проект, где стор отсутствует, код может сломаться.   */   const {userName} = useStore();    // Тут компонент знает о куках, что не очень хорошо для переиспользования (может сломаться, если в другом проекте это не так).   const userToken = getFromCookie();    // Аналогично  доступ к window может стать проблемой при переиспользовании компонента.   const {taskList} = window.ssrData;    const handleTaskUpdate = () => {       // Компонент знает об API сервера. Это допустимо только для верхнеуровневых компонентов.       fetch(...)   }    return <div>{'...'}</div>;  }; /*   Здесь компонент принимает только необходимый ему набор данных.   Его можно легко переиспользовать, потому что только верхнеуровневые компоненты будут знать все детали.*/const Component2 = ({   userName, userToken, onTaskUpdate}) => {   return <div>{'...'}</div>;};

Interface Segregation Principle


Много интерфейсов специального назначения лучше, чем один интерфейс общего назначения. Мне не удалось так же однозначно перенести принцип на компоненты фронтенда. Поэтому я понимаю его как необходимость следить за API.

Нужно передавать в компонент как можно меньшее количество сущностей и не передавать данные, которые им не используются. Большое количество пропов в компоненте повод насторожиться. Скорее всего, он нарушает принципы SOLID.

Где и как переиспользуем?


Мы обсудили принципы, которые помогут в написании качественных компонентов. Теперь разберём, где и как мы их переиспользуем. Это поможет понять, с какими ещё проблемами можно столкнуться.

Контекст может быть разным: вам нужно использовать компонент в другом месте той же страницы или, например, вы хотите переиспользовать его в других проектах компании это совсем разные вещи. Я выделяю несколько вариантов:

Переиспользование пока не требуется. Вы написали компонент, считаете, что он специфичный и не планируете нигде больше его использовать. Можно не предпринимать дополнительных усилий. А можно сделать несколько простых действий, которые окажутся полезны, если вы всё-таки захотите к нему вернуться. Так, например, можно проверить, что компонент не слишком сильно завязан на окружение, а зависимости вынесены в обёртки. Также можно сделать запас для кастомизации на будущее: добавить темы или возможность изменять внешний вид компонента извне (как в примере с кнопкой) это не займёт много времени.

Переиспользование в том же проекте. Вы написали компонент и почти уверены, что захотите его переиспользовать в другом месте текущего проекта. Здесь актуально всё написанное выше. Только теперь обязательно нужно убрать все зависимости во внешние обёртки и крайне желательно иметь возможность кастомизации извне (темы или миксы). Если компонент содержит много логики, стоит задуматься, везде ли она нужна, или в каких-то местах её стоит модифицировать. Для второго варианта предусмотрите возможность кастомизации. Также здесь важно подумать над структурой компонента и разбить его на части при необходимости.

Переиспользование в похожем стеке. Вы понимаете, что компонент будет полезен в соседнем проекте, у которого тот же стек, что и у вас. Здесь всё сказанное выше становится обязательным. Кроме этого, советую внимательно следить за зависимостями и технологиями. Точно ли соседний проект использует те же версии библиотек, что и вы? Использует ли SASS и TypeScript той же версии?

Отдельно хочу выделить переиспользование в другой среде исполнения, например, в SSR. Решите, действительно ли ваш компонент может и должен уметь рендериться на SSR. Если да, заранее удостоверьтесь, что он рендерится, как ожидается. Помните, что существуют другие рантаймы, например, deno или GraalVM. Учитывайте их особенности, если используете.

Библиотеки компонентов


Если компоненты нужно переиспользовать между несколькими репозиториями и/или проектами, их следует вынести в библиотеку.

Стек


Чем больше технологий используется в проектах, тем сложнее будет решать проблемы совместимости. Лучше всего сократить зоопарк и свести к минимуму количество используемых технологий: фреймворков, языков, версий крупных библиотек. Если же вы понимаете, что вам действительно нужно много технологий, придётся научиться с этим жить. Например, можно использовать обёртки над веб-компонентами, собирать всё в чистый JS или использовать адаптеры для компонентов.

Размер


Если использование простого компонента из вашей библиотеки добавляет пару мегабайт к бандлу это не ок. Такие компоненты не хочется переиспользовать, потому что перспектива написать свою лёгкую версию с нуля кажется оправданной. Решить проблему можно с помощью инструментов контроля размера, например, size-limit.

Не забываем про модульность разработчик, который захочет использовать ваш компонент, должен иметь возможность взять только его, а не тащить весь код библиотеки в проект.

Важно, чтобы модульная библиотека не собиралась в один файл. Также нужно следить за версией JS, в которую собирается библиотека. Если вы собираете библиотеку в ES.NEXT, а проекты в ES5, возникнут проблемы. Ещё нужно правильно настроить сборку для старых версий браузеров и сделать так, чтобы все пользователи библиотеки знали, во что она собирается. Если это слишком сложно, есть альтернатива настроить собственные правила сборки библиотеки в каждом проекте.

Обновление


Заранее подумайте о том, как будете обновлять библиотеку. Хорошо, если вы знаете всех клиентов и их пользовательские сценарии. Это поможет лучше думать о миграциях и ломающих изменениях. Например, команде, использующей вашу библиотеку, будет крайне неприятно узнать о мажорном обновлении с ломающими изменениями накануне релиза.

Вынося компоненты в библиотеку, которую использует кто-то ещё, вы теряете лёгкость рефакторинга. Чтобы груз рефакторинга не стал неподъёмным, советую не тащить в библиотеки новые компоненты. Они с высокой вероятностью будут меняться, а значит, придётся тратить много времени на обновление и поддержку совместимости.

Кастомизация и дизайн


Дизайн не влияет на переиспользуемость, но является важной частью кастомизаци. У нас в Учебнике компоненты не живут сами по себе, их внешний вид проектируют дизайнеры. У дизайнеров есть дизайн-система. Если компонент в системе и репозитории выглядит по-разному проблем не избежать. У дизайнеров и разработчиков не совпадут представления о внешнем виде интерфейса, из-за чего могут быть приняты неверные решения.

Витрина компонентов


Упростить взаимодействие с дизайнерами поможет витрина компонентов. Одно из популярных решений для витрины Storybook. С помощью этого или другого подходящего инструмента можно показать компоненты проекта любому человеку не из разработки.

Добавьте в витрину интерактивность у дизайнеров должна быть возможность взаимодействовать с компонентами и видеть, как они отображаются и работают с разными параметрами.

Не забудьте настроить автоматическое обновление витрины при обновлении компонентов. Для этого нужно вынести процесс в CI. Теперь дизайнеры всегда смогут посмотреть, какие готовые компоненты есть в проекте, и воспользоваться ими.



Дизайн-система


Для разработчика дизайн-система набор правил, регулирующих внешний вид компонентов в проекте. Чтобы зоопарк компонентов не разрастался, можно ограничить кастомизируемость её рамками.

Другой важный момент дизайн-система и вид компонентов в проекте иногда расходятся между собой. Например, когда проходит большой редизайн и не всё получается обновить в коде, или приходится корректировать компонент, а внести изменения в дизайн-систему времени нет. В этих случаях как в ваших интересах, так и в интересах дизайнеров синхронизировать дизайн-систему и проект, как только появится такая возможность.

Последний универсальный и очевидный совет: общайтесь и договаривайтесь. Не нужно воспринимать дизайнеров как тех, кто стоит в стороне от разработки и только создаёт и правит макеты. Тесное взаимодействие с ними поможет спроектировать и реализовать качественный интерфейс. В конечном счёте это пойдёт на пользу общему делу и порадует пользователей продукта.

Выводы


Дублирование кода приводит к сложностям в разработке и снижению качества фронтенда. Чтобы избежать последствий, необходимо следить за качеством компонентов, а писать качественные компоненты помогают принципы SOLID.

Написать хороший компонент с заделом на будущее гораздо сложнее, чем тот, что быстро решит задачу здесь и сейчас. При этом хорошие кирпичи лишь часть решения. Если вы выносите компоненты в библиотеку, работу с ними нужно сделать удобной, а ещё их нужно синхронизировать с дизайн-системой.

Как видите, задача непростая. Разрабатывать качественные переиспользуемые компоненты сложно и долго. Стоит ли оно того? Я считаю, что каждый ответит на этот вопрос сам. Для небольших проектов накладные расходы могут оказаться слишком высокими. Для проектов, где не планируется длительное развитие, вкладывать усилия в повторное использование кода тоже спорное решение. Однако, сказав сейчас нам это не нужно, легко не заметить, как окажешься в ситуации, где отсутствие переиспользуемых компонентов принесёт массу проблем, которых могло бы и не быть. Поэтому не повторяйте наших ошибок и dont repeat yourself!

Смотреть доклад
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    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