Что у всего этого общего?
- 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)
Вы можете подумать, разве эта проблема не решена с помощью потоков? Или наблюдаемые? Или Кафка? Нет. Обычно я хочу, чтобы моя программа делала следующее:
- Получите исходные данные
- Получите поток изменений из этого снимка. Эти изменения должны быть активными (не опрашиваемыми), инкрементными и семантическими. (Например, в Google Документах должно быть указано
'a' was inserted at document position 200
, а не отправлять новую копию документа при каждом нажатии клавиши). - Повторно подключитесь к этому потоку, не пропуская никаких изменений.
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, потому что у нас есть
ETag
s.
Проблема кеширования вроде решается репликами только для чтения, но я считаю, что реплики только для чтения часто нуждаются в частных 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.
RxJS / Obj-C наблюдаемые и все, что между ними
Google Realtime API (Снято с производства)
Все, что делает Мартин Клеппманн. Обсуждение 1 Обсуждение 2
Государственный автобус / Тесьма
Эта статья написана Джозефом Джентлом и впервые опубликована 25 мая в его блоге.