{ "index": 57, "slug": "editorial-2026-06-practice-multi-runtime", "title": "PHP, JavaScript и D: как не потерять смысл на общей границе", "excerpt": "Три runtime могут передавать один payload и всё равно принимать разные решения. Разбираем узкий контракт результата, ошибки, времени и диагностики с воспроизводимой fail-closed проверкой.", "contentHtml": "

На границе одного сервиса с другим ответ выглядит одинаково: PHP сформировал заказ, JavaScript показал его в интерфейсе, а обработчик на D получил те же поля для следующего шага. Проблема появляется не в формате, а в решении. Один потребитель считает отсутствие error успехом, второй ждёт исключение, третий подставляет ноль вместо пропущенной суммы.

\n

Цена расхождения быстро становится практической. Интерфейс может показать готовый заказ, worker — повторить уже выполненную операцию, а расследование не свяжет запись D с исходным запросом PHP. Команда видит один JSON и спорит о языках, хотя сначала надо ответить на более узкий вопрос: какие значения, состояния и правила этот JSON обязан сохранять?

\n

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

\n

JSON переносит форму, но не решение

\n

JSON удобен именно своей малой грамматикой: объект, массив, строка, число, логическое значение и null. RFC 8259 описывает его как текстовый формат обмена структурированными данными. Но в этих типах нет ответа на вопросы «можно ли повторить операцию», «что означает пустое поле» и «кто владеет отказом».

\n

Даже поле amount: 4200 не сообщает единицу. Это могут быть копейки, рубли, лимит или внутренний счётчик. Число также имеет границу переносимости: RFC 8259 отдельно отмечает точное согласование целых чисел в диапазоне от -(2**53)+1 до (2**53)-1 для реализаций с IEEE 754 binary64. Значит, «число в JSON» — ещё не денежный тип. Единицу и допустимый диапазон задаёт контракт приложения.

\n

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

\n
Что добавляет контракт поверх JSON
СлойПримерВопрос для проверки
Формаresult.tag и набор полейОтвет можно разобрать без догадок?
ЕдиницаamountMinor в RUBОдинакова ли шкала значения?
Состояниеoutcome.kind: acceptedЭто успех, отказ или незавершённая операция?
Времяbasis: logical-ticksМожно ли сравнить начало и конец?
СвязьcorrelationIdПо какому ключу искать одну операцию?
Преобразованиеmapping: exactЗначение сохранено или незаметно изменено?
\n
\"PHP,
Схема показывает состав boundary contract. Стрелки обозначают проверку формы записи, а не сетевое соединение и не запуск трёх runtime.
\n

Сначала зафиксировать одну операцию

\n

Широкий объект «данные заказа» плохо проверяется. В нём смешиваются бизнес-решение, транспортные детали, диагностические поля и состояние побочного эффекта. Для первой версии лучше выбрать одну операцию, например fixed-order-decision, и описать только результат этой операции.

\n

У каждого поля должен быть владелец смысла. Producer отвечает за то, что order-ready означает именно готовый результат, а не текст для интерфейса. Consumer не должен угадывать смысл по имени поля или по тому, что значение похоже на знакомый тип. Он принимает только известную версию и явно отказывается от незнакомой.

\n

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

\n

Три оси, которые нельзя смешивать

\n

Значение. Поле result.tag отделяет вид результата от его хранения. Для суммы нужны amountMinor, currency и правило диапазона. Не следует превращать пропущенную сумму в ноль: это два разных состояния, и у них должны быть разные tag или явное состояние отсутствия.

\n

Завершение. Поле outcome.kind отвечает на вопрос, чем закончилась операция. В примере допустимы accepted и rejected, а error содержит стабильный code и правило retry. Текст сообщения можно показывать человеку, но нельзя делать его единственным ключом для автоматики.

\n

Механизмы языков здесь различаются. PHP Manual описывает throw, catch и подъём исключения по стеку до обработчика. Спецификация ECMAScript использует Completion Record с типами normal и throw для описания значения и передачи управления. Документация D также строит обработку вокруг исключений и размотки стека. Эти источники объясняют механизмы внутри языков, но не создают общего протокола. На границе исключение надо явно сопоставить с полями envelope либо вернуть отказ.

\n

Время. logical-ticks в примере нужны только для проверки порядка: closed: 108 больше opened: 100. Это не миллисекунды и не измерение задержки. Если продукту нужна длительность, контракт должен назвать источник часов, единицы, точку старта и точку окончания. Календарная дата, monotonic clock и порядковая отметка решают разные задачи.

\n

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

\n
Минимальная семантическая матрица
ОсьЯвное полеНеверная подменаОстановка
Результатtag: order-readyВыводить тип по наличию amountMinorstop-unknown-result-tag
Ошибкаkind, code, retryСчитать отсутствие исключения успехомstop-mixed-outcome
Времяbasis, opened, closedНазывать ticks миллисекундамиstop-unknown-time-basis
СвязьcorrelationIdИскать событие по timestampstop-missing-correlation
\n

Воспроизводимая проверка в памяти

\n

Ниже обычный JavaScript без внешних зависимостей. Он проверяет заранее заданные literals, поэтому его можно сохранить в boundary-check.mjs и выполнить командой node boundary-check.mjs. Запись с меткой php не запускает PHP, а запись d не запускает D: это участники проверочной матрицы.

\n
const contract = {\n  schemaVersion: 'boundary-1',\n  operation: 'fixed-order-decision',\n  result: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n  outcome: { kind: 'accepted', error: null, retry: 'never' },\n  time: { basis: 'logical-ticks', opened: 100, closed: 108 },\n  correlationId: 'demo-order-42'\n};\n\nconst adapters = [\n  { runtime: 'php', version: 'boundary-1', resultTag: 'order-ready',\n    outcomeKind: 'accepted', timeBasis: 'logical-ticks', mapping: 'exact' },\n  { runtime: 'javascript', version: 'boundary-1', resultTag: 'order-ready',\n    outcomeKind: 'accepted', timeBasis: 'logical-ticks', mapping: 'exact' },\n  { runtime: 'd', version: 'boundary-1', resultTag: 'order-ready',\n    outcomeKind: 'accepted', timeBasis: 'logical-ticks', mapping: 'exact' }\n];\n\nconst expectedRuntimes = new Set(['php', 'javascript', 'd']);\n\nfunction review(input, peers) {\n  if (input?.schemaVersion !== 'boundary-1' ||\n      input.operation !== 'fixed-order-decision') {\n    return { status: 'stop-incomplete-contract' };\n  }\n  if (input.result?.tag !== 'order-ready' ||\n      !Number.isInteger(input.result.amountMinor) ||\n      !input.result.currency) {\n    return { status: 'stop-unknown-result-tag' };\n  }\n  if (input.outcome?.kind !== 'accepted' || input.outcome.error !== null ||\n      input.outcome.retry !== 'never') {\n    return { status: 'stop-mixed-outcome' };\n  }\n  if (input.time?.basis !== 'logical-ticks' ||\n      !Number.isInteger(input.time.opened) ||\n      !Number.isInteger(input.time.closed) ||\n      input.time.closed < input.time.opened) {\n    return { status: 'stop-unknown-time-basis' };\n  }\n  const runtimes = new Set(peers.map((peer) => peer.runtime));\n  if (runtimes.size !== expectedRuntimes.size ||\n      [...expectedRuntimes].some((runtime) => !runtimes.has(runtime))) {\n    return { status: 'stop-missing-adapter' };\n  }\n  const invalid = peers.find((peer) =>\n    peer.version !== input.schemaVersion ||\n    peer.resultTag !== input.result.tag ||\n    peer.outcomeKind !== input.outcome.kind ||\n    peer.timeBasis !== input.time.basis ||\n    peer.mapping !== 'exact'\n  );\n  if (invalid) {\n    return { status: 'stop-incomparable-adapter', runtime: invalid.runtime };\n  }\n  return {\n    status: 'contract-consistent',\n    elapsedTicks: input.time.closed - input.time.opened,\n    externalSystems: 'not observed'\n  };\n}\n\nconsole.log(review(contract, adapters));\nconst coerced = adapters.map((peer) =>\n  peer.runtime === 'd' ? { ...peer, mapping: 'coerce' } : peer\n);\nconsole.log(review(contract, coerced));
\n

Положительный вывод означает только три вещи: поля одной записи прошли названные проверки, три model label присутствуют, а интервал упорядочен. Вторая строка должна вернуть stop-incomparable-adapter, потому что D-представление изменяет значение через неописанное преобразование. Если запустить код на Node.js, получится проверяемый результат, но не тест трёх языков и не тест сетевого обмена.

\n

Совместимость версий и правило изменения

\n

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

\n

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

\n

Полезно держать отдельные fixtures для нормального результата, отказа, неполного объекта и неизвестной версии. В каждом fixture фиксируются вход, ожидаемый status и причина. Тогда изменение адаптера вызывает понятный diff теста, а не спор по логам после выката.

\n

Симптом → проверка → действие

\n
Рабочая диагностика boundary contract
СимптомПроверкаДействие
Пропущенная сумма стала нулёмСверить tag, наличие поля и единицуВернуть отказ или ввести отдельный вариант отсутствия
Интерфейс видит успех, worker повторяет операциюСравнить outcome.kind, error.code и retryСделать решение явным и проверить идемпотентность отдельно
Лог D не находится по запросу PHPПроверить общий correlationId на каждом переходеДобавить идентификатор в новую версию и прокинуть его без замены
Одинаковое время даёт разные выводыСверить time.basis и обе точки интервалаНе вычислять latency из логических ticks
Один адаптер «почти» совпалПроверить mapping на exactОписать преобразование отдельным правилом либо остановить обмен
\n

Порядок внедрения

\n
  1. Назвать одну операцию и владельца её смысла.
  2. Выбрать версию и выписать обязательные поля: tag, единицы, outcome, time и correlationId.
  3. Зафиксировать допустимые значения и точные stop reasons для неполного и неизвестного входа.
  4. Собрать по одному fixture на accepted, rejected, missing field, wrong unit и incompatible mapping.
  5. Реализовать адаптеры так, чтобы каждое преобразование было видно в коде и тесте.
  6. Проверить сериализацию: уникальные имена, допустимые JSON-значения, диапазоны чисел и кодировку.
  7. Проверить цепочку на реальном стенде отдельным тестом: транспорт, права, повтор, корреляцию и наблюдаемость.
  8. Только после этого обсуждать нагрузку, задержку и готовность выпуска. Boundary-validator сам по себе этих свойств не измеряет.
\n

Ограничения применимости

\n

Пример намеренно мал. Он не проверяет ABI, сериализацию конкретной библиотеки, кодировку транспорта, версии PHP/Node/D, схему базы, права, таймауты, повтор после частичного побочного эффекта, безопасность, нагрузку или SLA. Он также не показывает, что три компонента действительно обменялись данными. Для этого нужны реальные точки входа, тестовый стенд, логи или трассировка и известный владелец результата.

\n

Фиксированные logical ticks нельзя превращать в latency, а correlationId — в доказательство доставки. amountMinor с валютой не заменяет правила округления, возврата и финансового учёта. Envelope с retry: never не доказывает идемпотентность операции. Эти свойства должны пройти свои проверки на уровне продукта.

\n

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

\n

Проверяемый критерий готовности

\n

Граница подготовлена к следующему инженерному тесту, если независимый разработчик может взять fixture и получить тот же status без доступа к истории переписки. У записи есть одна операция и версия; результат имеет tag и единицы; outcome отделён от result; time имеет basis и две упорядоченные точки; correlationId сохраняется; каждый адаптер проходит exact mapping; отрицательные варианты возвращают конкретные stop reasons.

\n

После этого ещё нельзя писать, что PHP, JavaScript и D совместимы вообще. Можно сказать только: фиксированная запись соответствует выбранным правилам и готова перейти к отдельной проверке транспорта и среды. Такое утверждение уже достаточно полезно: оно показывает, что проверено, где заканчивается доказательство и какой следующий эксперимент нужен.

\n

Проверяемые источники

" }