Что у всего этого общего?

  • RSS-каналы
  • Геймпады и MIDI-устройства
  • Почтовый клиент
  • Наблюдение за файловой системой (FSWatch, kqueue, ionotify и т. Д.)
  • Веб-панели мониторинга
  • Использование ЦП на вашем локальном компьютере
  • Кафка
  • RethinkDB Changefeeds
  • Документ Google Docs
  • Протокол синхронизации Contentful
  • Подсветка синтаксиса при вводе текста в редакторе, красные волнистые линии ошибок подчеркивают ошибки

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

Но посмотрите на этот список - все эти системы имеют совершенно разные API. Наблюдение за файловой системой работает на каждой ОС по-разному. RSS-каналы опрос. Почтовые клиенты… ну, электронная почта - это отдельный беспорядок (хотя JMAP выглядит многообещающе). API Google используют обратный вызов зарегистрированного URL для уведомлений об изменениях. Запросы API Kafka по заданному нумерованному смещению с возвращением событий по мере их доступности. Для получения информации о работающей системе Linux обычно требуется анализ псевдофайлов в /proc. Вы можете fs посмотреть эти файлы? Кто знает. Даже внутри ядра Linux есть несколько различных API-интерфейсов для наблюдения за изменениями в зависимости от системы, с которой вы взаимодействуете (epoll / inotify / aio / procfs / sysfs / и т. Д.). То же самое и в веб-браузерах - у нас есть события DOM (onfocus / onblur и т. Д.). Но в DOM также есть MutationEvents и MutationObserver. Вместо getUserMedia и fetch используются обещания. MIDI дает вам поток 3-байтовых сообщений для анализа. И API Gamepad опрашивается.

Тот факт, что все эти системы работают по-разному, действительно глуп. Это напоминает мне время до того, как мы стандартизировали JSON вместо REST. У каждого приложения был свой протокол для получения данных. FTP и SMTP используют текстовый протокол с отслеживанием состояния. В то время все системы Google использовали RPC поверх protobuf. А потом родился REST, и теперь через REST можно получить доступ ко всему, от прогнозов погоды до календаря пользователя и списков экзопланет от НАСА.

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

Думаю, нам нужны 2 вещи:

  • Программный API на каждом языке для доступа к данным, которые меняются с течением времени.
  • Сетевой протокол, эквивалентный REST, для потоковой передачи изменений данных (или расширение REST)

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

  1. Получите исходные данные
  2. Получите поток изменений из этого снимка. Эти изменения должны быть активными (не опрашиваемыми), инкрементными и семантическими. (Например, в Google Документах должно быть указано 'a' was inserted at document position 200, а не отправлять новую копию документа при каждом нажатии клавиши).
  3. Повторно подключитесь к этому потоку, не пропуская никаких изменений.

Stream API обычно затрудняет выполнение 1 и 3. Pub-sub обычно делает невозможным выполнение 3 (если вы пропустите сообщение, что вы делаете?). Наблюдаемые объекты не минимальны - обычно они отправляют вам весь объект с каждым обновлением. Насколько я могу судить, подписки GraphQL - это просто типизированные потоки - что очень жаль, потому что у них была прекрасная возможность сделать это правильно.

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

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

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

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

Благодаря этому я могу:

  • Повторно отображать интерфейс моего приложения при изменении данных без необходимости опрашивать или повторно отправлять все по сети, или делать различия или что-то в этом роде.
  • Поддерживайте вычисленное представление, которое пересчитывается только при изменении самих данных. (Например, артефакты компиляции или HTML-код сообщения в блоге - HTML следует повторно отображать только при изменении содержимого сообщения!)
  • Пишет местные спекулятивные статьи. Это позволяет совместное редактирование в реальном времени (например, в Google Docs).
  • Выполняйте мониторинг и аналитику изменений.
  • Сделать недействительным (и при необходимости повторно заполнить) кеш
  • Создайте вторичный индекс, который всегда будет актуальным

Одним из больших преимуществ превращения REST в стандарт является то, что мы смогли создать общие библиотеки и инфраструктуру, которые работают с любыми типами данных. У нас есть кеширование, балансировка нагрузки и инструменты CDN, такие как nginx / cloudflare. У нас есть инструменты отладки, такие как cURL и Paw. Библиотеки HTTP существуют на всех языках и прекрасно взаимодействуют друг с другом. Мы должны иметь возможность делать то же самое с изменением данных - если бы существовал стандартный протокол для обновлений, у нас могли бы быть стандартные инструменты для всего, что указано в приведенном выше списке! Потоковые API, такие как ZMQ / RabbitMQ / Redis Streams, слишком низкоуровневые, чтобы писать такие общие инструменты.

Время (версии) должно быть явным

Нам нужно поговорить о версиях. На мой взгляд, одна из больших проблем с большим количеством API-интерфейсов для подобных вещей сегодня заключается в том, что в них отсутствует явное понятие времени. Эта концептуальная ошибка проявляется повсюду, и как только вы ее видите, ее невозможно не заметить. Благодарю Rich Hickey и Martin Kleppmann за то, что я поделился своими мыслями по этому поводу.

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

Если мы добавим понятие явных версий, об этом станет намного легче думать. Представьте, что я делаю два запроса (или SYSCALL, или что-то еще). Я сначала узнаю, что x = 5, затем y = 6. Но только из этого я ничего не знаю о том, как эти ценности соотносятся во времени! Возможно, никогда не было времени, когда (x,y) = (5,6). Если вместо этого я узнаю, что x = 5 at time 100, то y = 6 at time 100, у меня будет два неизменных факта. Я знаю, что на момент 100 (x,y) = (5,6). Я могу задать дополнительные вопросы, например what is z at time 100?. Или, что немаловажно, notify me when x changes after version 100.

Эти версии могут быть одним увеличивающимся числом (например, SVN или Kafka), вектором версии, непрозрачной строкой или хешем, например git.

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

Например, если я сделаю два SQL-запроса, у меня нет возможности узнать, согласованы ли два результата запроса во времени. Данные, которые я получил, могли измениться между запросами. Ответ SQL - использовать транзакции. Транзакции заставляют отвечать на оба запроса с одного и того же момента времени. Проблема с транзакциями в том, что они не составляют:

  • Я не могу использовать результаты двух последовательно проведенных транзакций вместе, даже если данные меняются редко.
  • Я не могу выполнить транзакцию SQL в нескольких базах данных.
  • Если у меня есть данные в PostgresQL и индекс моих данных в ElasticSearch, я не могу выполнить запрос, который извлекает идентификатор из индекса, а затем извлекает / изменяет соответствующее значение в postgres. Данные могли измениться между двумя запросами. Или мой индекс ElasticSearch может отставать от времени postgres. Я не могу сказать.
  • Невозможно создать общий кеш результатов запроса, используя транзакции без версии. Разве не странно, что у нас есть общие кеши для HTTP (например, varnish или nginx), но ничего подобного для большинства баз данных? Причина в том, что если вы запрашиваете ключи A и B из базы данных, а в кэше локально хранится A, он не может вернуть кэшированное значение для A и просто выберите B. Кеш также не может хранить B вместе со старым результатом для A. Без версий эту проблему в общих чертах правильно решить в принципе невозможно. Но мы можем решить эту проблему для HTTP, потому что у нас есть ETags.

Проблема кеширования вроде решается репликами только для чтения, но я считаю, что реплики только для чтения часто нуждаются в частных API-интерфейсах для работы. Основной API большинства баз данных недостаточно мощный, чтобы поддерживать функцию, которая должна масштабироваться и функционировать самой базе данных. (Сейчас становится лучше - Mongo / Postgres.)

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

Это не должно быть так - я думаю, мы можем съесть свой торт и съесть его; нам просто нужны более качественные API.

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

Минимальная жизнеспособная спецификация

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

На мой взгляд, нам нужно 2 основных API:

  • выборка (запрос) - ›данные, версия
  • подписаться (запрос, версия) - ›поток пар (обновление, версия). (Или, может быть, ошибка, если версия слишком старая)

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

Интересно, что HTTP у нас уже есть функция выборки с этим API в GET методе. Сервер возвращает данные и обычно либо заголовок Last-Modified, либо ETag. Но в HTTP отсутствует стандартный способ подписки.

Сами объекты обновления должны быть маленькими и семантическими. Золотой стандарт для операций обычно заключается в том, что они должны выражать намерение пользователя. И я также считаю, что у нас должен быть эквивалентный MIME-тип набор стандартных функций обновления (например, JSON-patch).

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

Для Документов Google мы не можем повторно отправлять весь документ при каждом нажатии клавиши. Это было бы не только медленно и расточительно, но и сделало бы одновременное редактирование практически невозможным. Вместо этого Docs хочет отправить семантическое редактирование, например insert 'x' at position 4. Благодаря этому мы можем правильно обновлять позиции курсора и обрабатывать одновременные изменения от нескольких пользователей. Здесь недостаточно различий - если документ aaaa и у меня есть курсор посередине (aa|aa), вставка другого a в начале или в конце документа имеет тот же эффект для документа. Но эти изменения по-разному влияют на положение курсора и предполагаемые правки.

В инди-игре Factorio используется функция детерминированного обновления игры. И сохранения игр, и сетевой протокол представляют собой потоки действий, которые четко определенным образом изменяют состояние игры (добывать уголь, строить, поставить галочку , так далее). Каждый игрок применяет поток действий к локальному снимку мира. Обратите внимание, что в этом случае семантическое содержание обновлений полностью зависит от приложения - я сомневаюсь, что какой-либо общий тип JSON-patch подойдет для такой игры.

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

Подписки GraphQL должны работать именно так. GraphQL уже позволяет мне определять схему и отправлять запрос с формой, которая отражает схему. Я хочу знать, когда изменится набор результатов запроса. Для этого я должен иметь возможность использовать тот же запрос, но подписываться на результаты, а не просто получать их. Под капотом GraphQL может отправлять обновления с помощью JSON-patch или чего-то подобного. Затем клиент может локально обновить свое представление запроса. С помощью этой модели мы могли бы также написать тесную интеграцию между этим форматом обновления и интерфейсными фреймворками, такими как Svelte. Это позволит нам обновлять только те узлы DOM, которые необходимо изменить в результате появления новых данных. Это не то, как сегодня работают подписки GraphQL. Но на мой взгляд так и должно быть!

Чтобы обеспечить взаимодействие GraphQL и Svelte (и всего остального), мы должны определить некоторые стандартные форматы обновления для структурированных данных. Такие игры, как Factorio, всегда должны будут заниматься своими делами, но остальные из нас могут и должны использовать стандартные вещи. Я бы хотел увидеть Content-Type: для форматов обновления. Я могу представить один тип для обновлений в виде простого текста, другой для JSON (возможно, несколько для JSON). Другой тип форматированного текста, который можно использовать в таких приложениях, как Google Docs. У меня почти десятилетний опыт возиться с совместным редактированием в реальном времени, и эта модель API будет отлично работать с совместными редакторами, построенными на основе OT или CRDT.

По совпадению, я написал этот тип операции JSON, который также поддерживает альтернативные встроенные типы и операционное преобразование. А Джейсон Чен написал этот шрифт в формате RTF. Также существует множество CRDT-совместимых типов.

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

  • fetchAndSubscribe (query) - ›данные, версия, поток обновлений. Это экономит круговой обход в общем случае и избавляет от повторной отправки запроса.
  • getOps (query, fromVersion, toVersion / limit) - ›список обновлений. Полезно для некоторых приложений
  • mutate (update, ifNotChangedSinceVersion) - ›новая версия или ошибка конфликта

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

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

Очевидно, что не все данные являются изменяемыми, и для данных нет смысла направлять все мутации через одну функцию. Но это отличное свойство! Также интересно отметить, что HTTP POST уже поддерживает такие вещи с заголовками If-Match / If-Unmodified-Since.

Стандарты

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

  • Локальные программные API для ядер (и тому подобное)
  • Стандартный API, который мы можем использовать по сети. Эквивалент REST или протокол, напрямую расширяющий REST.

Оба этих API должны поддерживать:

  • Версии (или метки времени, теги ET или аналогичные)
  • Стандартный набор операций обновления, например Content-Type в http, но для модификаций. Отправлять новую копию всех данных при каждом обновлении - это плохо.
  • Возможность переподключиться в определенный момент времени

И мы должны использовать эти API практически везде, от баз данных до приложений и вплоть до наших ядер. Лично я потратил слишком много своей профессиональной жизни на внедрение и переопределение кода, чтобы сделать это. И поскольку наша отрасль каждый раз создает эти вещи с нуля, наши реализации не так хороши, как могли бы быть. У некоторых есть ошибки (просмотр fs в MacOS), некоторые трудны в использовании (анализ файлов sysfs), некоторые требуют опроса (Contentful), некоторые не позволяют повторно подключаться к каналам (GraphQL, RethinkDB, большинство систем pubsub). Некоторые не позволяют отправлять небольшие инкрементальные обновления (наблюдаемые). Высококачественные инструменты, которые у нас есть для создания такого рода вещей, слишком низкоуровневые (потоки, веб-сокеты, MQ, Kafka). Результатом является полное отсутствие взаимодействия и общих инструментов для отладки, мониторинга и масштабирования.

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

Уф.

Кстати, я работаю над решением некоторых проблем в этой сфере с Statecraft. Но это еще одно сообщение в блоге. ;)

Вдохновения

Datomic и все остальное. Rich Hickey - The Value of Values ​​talk - это здорово.

Kafka и сообщества event sourcing / DDD.

Подписки GraphQL

Ленты изменений RethinkDB

RxJS / Obj-C наблюдаемые и все, что между ними

Svelte

Firebase

Google Realtime API (Снято с производства)

Все, что делает Мартин Клеппманн. Обсуждение 1 Обсуждение 2

Государственный автобус / Тесьма

Реагировать Флюкс

Эта статья написана Джозефом Джентлом и впервые опубликована 25 мая в его блоге.