Перейти к основному содержимому

Как написать сервис-генератор

Генератор http отдаёт решение о значениях сервису, который написали вы. Эта страница — вторая половина: как написать такой сервис на любом из пяти языков, чтобы он был быстрым, корректным и — вот это упускают все — воспроизводимым.

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

Что сервис обязан делать

Полный контракт — на странице генератора. Чтобы написать сервис, хватает четырёх строк из него:

  • приходит POST с заголовками X-TDC-Count: N и X-TDC-Seed: <hex>;
  • тело — это N значений, по одному на строку, либо пустое, что значит «придумай их»;
  • X-TDC-Input: N присутствует ровно тогда, когда вызывающий прислал входные данные, и говорит, сколько строк в теле. Читать его необязательно, но стоит — см. ниже;
  • ответить нужно ровно N строками, в том же порядке;
  • обычный текст, никакого JSON нигде не требуется.

Сравнивайте имена заголовков без учёта регистра, как того требует HTTP. Пять реализаций пишут их на проводе по-разному: HTTP-библиотека Python приводит X-TDC-Count к каноническому X-Tdc-Count — так же делает и Go, — а остальные четыре отправляют написание как есть. Все помощники, которые используются на этой странице (self.headers.get в Python, getRequestHeaders().getFirst в Java, req.headers в Node), регистр уже игнорируют. Сервис, который сравнивает имя как обычную строку, будет работать с четырьмя рантаймами и не работать с пятым.

Читайте X-TDC-Input, если можете

«Пустое тело значит придумай значения» верно почти всегда и неверно в одном случае: конфигурация, у которой колонка в in= держит единственное пустое значение, тоже шлёт пустое тело. Без заголовка эти два случая неразличимы, и сервис, угадавший «источник», придумывал значение там, где его просили обработать.

const declared = req.headers['x-tdc-input']; // undefined → входных данных не прислали
const inputs =
declared === undefined ? [] :
Number(declared) === 0 ? [] :
body.length === 0 ? [''] : body.split('\n');

Сервис, который заголовок игнорирует, работает ровно как раньше — ради этого он и сделан заголовком, а не новым форматом тела.

Сервис на пяти языках

import { createServer } from 'node:http';

/** FNV-1a (32-bit). The same three lines in every language — that is the point. */
function fnv1a(text) {
let h = 0x811c9dc5;
for (const ch of Buffer.from(text, 'utf8')) {
h ^= ch;
h = Math.imul(h, 0x01000193) >>> 0; // Math.imul keeps it 32-bit
}
return h >>> 0;
}

/** Source mode: an 8-digit account for row `i`, decided only by (seed, i). */
function accountFor(seed, i) {
return String(fnv1a(`${seed}#${i}`) % 100000000).padStart(8, '0');
}

/** Handler mode: the Luhn check digit of what was sent. */
function luhn(number) {
let sum = 0;
let dbl = true;
for (let i = number.length - 1; i >= 0; i--) {
let d = number.charCodeAt(i) - 48;
if (d < 0 || d > 9) continue;
if (dbl) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
dbl = !dbl;
}
return String((10 - (sum % 10)) % 10);
}

createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const count = Number(req.headers['x-tdc-count'] ?? '0');
const seed = String(req.headers['x-tdc-seed'] ?? '');
const body = Buffer.concat(chunks).toString('utf8');

const out =
body.length === 0
? Array.from({ length: count }, (_, i) => accountFor(seed, i)) // source
: body.split('\n').map((line) => line + luhn(line)); // handler

const payload = out.join('\n');
res.writeHead(200, {
'Content-Type': 'text/plain',
'Content-Length': Buffer.byteLength(payload),
});
res.end(payload);
});
}).listen(5701, '127.0.0.1');

Запуск: node service.mjs, затем направьте src на http://127.0.0.1:5701/.

Любой из пяти, на одном и том же конфиге:

<tdc>
<env count="3" seed="demo">
<sequence name="Payload"><gen type="number" value="10000000..99999999"/></sequence>
<sequence name="Card"><gen type="http" src="http://127.0.0.1:5701/" in="Payload"/></sequence>
<sequence name="Acct"><gen type="http" src="http://127.0.0.1:5701/"/></sequence>
</env>
<block>
<line><data>${{Payload}} -> ${{Card}} | счёт: ${{Acct}}</data></line>
</block>
</tdc>
./run demo.tdc
10047634 -> 100476340   |  счёт: 71102997
48577070 -> 485770705   |  счёт: 54325378
44149883 -> 441498839   |  счёт: 37547759

Card прошла через обработчик — полезная часть вернулась с контрольной цифрой. Acct придумана из одного лишь сида. Поменяйте порт на 5702, 5703, 5704 или 5705 — вывод будет символ в символ тем же.

Воспроизводимость: зачем нужен сид

Генератор http — единственное место, где TDC жертвует своей гарантией: значения решает сервис, поэтому движок не может обещать, что повторный прогон воспроизведётся. Ваш сервис — может, и X-TDC-Seed это и делает возможным.

Правило в одну строку: выводите каждое значение из сида, никогда — из часов или генератора случайных чисел.

accountFor(seed, i); // reproducible — same seed, same row, same answer
Math.random(); // not
new Date(); // not

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

Написанный так прогон воспроизводится:

./run demo.tdc — дважды
прогон 1:  71102997  54325378  37547759
прогон 2:  71102997  54325378  37547759

Считайте строку напрямую, а не перебором

Обратите внимание на форму accountFor(seed, i): она принимает номер строки и возвращает значение этой строки, не таща состояние между вызовами. Это сделано нарочно, и это стоит перенять.

Генератор, который идёт по последовательности — «вызови next() N раз», — обязан вызываться в правильном порядке, с начала и ровно один раз. Сервис не в том положении, чтобы это гарантировать: движок может повторить запрос, а запросы могут прийти одновременно. Функция без состояния от (сид, i) ко всему этому невосприимчива, а писать её не сложнее.

Ловушка: 32 бита на пяти языках

Чтобы все пять сошлись, должна сойтись арифметика. Именно здесь ломается наивный перенос, и лечится это одной строкой на язык:

ЯзыкЧто удерживает хеш в 32 битах
NodeMath.imul(h, prime) >>> 0 — обычное * прошло бы через double и потеряло младшие биты
Python& 0xFFFFFFFF — целые здесь неограниченной длины, само ничего не переполнится
Javaничего — умножение int уже заворачивается
C#unchecked { … } — вне него .NET на переполнении бросает исключение, а не заворачивает
Rustwrapping_mul — обычное * в отладочной сборке паникует на переполнении

Пропустите это в Python — числа будут расти бесконечно и тихо дадут значения, отличные от остальных. Движку TDC приходится решать ровно ту же задачу: его генератор случайных чисел написан через Math.imul и 32-битные операции именно ради того, чтобы все пять реализаций совпадали, — так что ограничение это не выдумка примера.

Все пять сервисов выше были запущены и получили один и тот же вопрос — восемь значений, один сид, — а ответы сверены:

for p in 5701 5702 5703 5704 5705; do
curl -s -X POST -H "X-TDC-Count: 8" -H "X-TDC-Seed: demo" --data-binary "" \
http://127.0.0.1:$p/ > out.$p.txt
done
shasum -a 256 out.*.txt
1d7bf9b2a4ef1ded8558cc0afd0ef18d15321a9da2398dc72c87cb8629c2ca2d  out.node.txt
1d7bf9b2a4ef1ded8558cc0afd0ef18d15321a9da2398dc72c87cb8629c2ca2d  out.py.txt
1d7bf9b2a4ef1ded8558cc0afd0ef18d15321a9da2398dc72c87cb8629c2ca2d  out.java.txt
1d7bf9b2a4ef1ded8558cc0afd0ef18d15321a9da2398dc72c87cb8629c2ca2d  out.cs.txt
1d7bf9b2a4ef1ded8558cc0afd0ef18d15321a9da2398dc72c87cb8629c2ca2d  out.rs.txt

Идентичны, а не «похожи». Если переносите это на шестой язык — сделайте ту же проверку, прежде чем доверять.

Обработчик обычно воспроизводим и так

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

Если до сервиса дотягивается кто-то ещё

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

Конфигурация добавляет secret=; с запросом тогда едут ещё два заголовка, а сам секрет не едет никуда:

X-TDC-Timestamp: 1786000000
X-TDC-Signature: hex(HMAC-SHA256(secret, timestamp \n seed \n count \n body))

Проверка — те же несколько строк на любом языке: пересчитать и сравнить.

const mine = crypto
.createHmac('sha256', process.env.TDC_HTTP_SECRET)
.update(`${ts}\n${seed}\n${count}\n${body}`)
.digest('hex');
if (mine !== req.headers['x-tdc-signature']) return res.status(401).end();

Три вещи, которые стоит знать до того, как писать эту проверку:

  • Окно выбираете вы. TDC ставит свои настоящие часы и ничего больше; насколько старым может быть запрос, прежде чем вы его отклоните, — ваша политика. Несколько минут — разумное значение по умолчанию, и оно требует, чтобы часы на двух машинах примерно совпадали.
  • Секрет не протухает, поэтому набор тестов, который гоняют раз в квартал, подпишется верно. Ради этого здесь подпись, а не токен.
  • Подпись — не шифрование. По обычному http:// тело всё ещё читается тем, кто сидит на канале. Если сами значения чувствительные, добавьте https://.

Прежде чем направлять на него TDC

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

  • Отвечайте ровно N строками. Одной больше или меньше — и прогон встанет с returned N line(s) for a batch of M. Проверка существует потому, что расхождение в длине означает: ответы больше не совпадают со строками, иначе была бы тихая порча.
  • Держите порядок. Строка i ответа должна отвечать строке i запроса. Перестановку TDC обнаружить не может — вы просто получите неверные данные.
  • В значениях не должно быть перевода строки, ни туда, ни обратно: протокол построчный, и перенос внутри значения ломает счёт. Падает громко, а не портит, — но падает.
  • Будьте безопасны при одновременных вызовах либо запускайте генерацию с --jobs 1. Своих воркеров TDC за вас не согласует.
  • Обрабатывайте всю пачку одним запросом. Не разворачивайте её внутри в вызов на каждое значение — именно пачка держит скорость.

См. также