Правило в Node.js: модель, напечатанная в JavaScript, и граница HTTP
Модель на flang считает правило. Всё остальное — сокет, маршруты, коды ответа,
лимит тела запроса — обычный код Node.js. В этой главе граница проходит по
реальному файлу курса: examples/spec/discount-api/server.mjs. Это HTTP-сервис
на встроенном node:http без фреймворка. Запускается он командой
npm run spec:api.
Минимальный пример
модуль «Продажи»
объект «Покупка»
«сумма»: неотрицательное
«постоянный клиент»: признак
тотальная функция «Большая покупка»
принимает покупка: «Покупка»
возвращает число
обеспечивает «скидка за объём не отрицательна» результат не меньше 0
пример «на десять тысяч скидка за объём уже есть»
дано покупка равно запись «Покупка» с «сумма» равным 10000 и «постоянный клиент» равным нет
ожидается 1000
пример «на девять тысяч девятьсот девяносто девять ещё нет»
дано покупка равно запись «Покупка» с «сумма» равным 9999 и «постоянный клиент» равным нет
ожидается 0
если покупка.«сумма» не меньше 10000 и притом покупка.«сумма» не больше 100000
то (покупка.«сумма» умножить на 10) делить на 100
иначе 0
тотальная функция «Постоянный клиент»
принимает покупка: «Покупка»
возвращает число
обеспечивает «скидка постоянному не отрицательна» результат не меньше 0
пример «постоянному клиенту пять процентов»
дано покупка равно запись «Покупка» с «сумма» равным 5000 и «постоянный клиент» равным да
ожидается 250
если покупка.«постоянный клиент» и притом покупка.«сумма» не больше 100000
то (покупка.«сумма» умножить на 5) делить на 100
иначе 0
тотальная функция «Очень крупная покупка»
принимает покупка: «Покупка»
возвращает число
обеспечивает «за очень крупную покупку не больше потолка» результат не больше 15000
пример «сто тысяч — ещё не очень крупная»
дано покупка равно запись «Покупка» с «сумма» равным 100000 и «постоянный клиент» равным нет
ожидается 0
если покупка.«сумма» больше 100000
то 15000
иначе 0
тотальная функция «Рассчитать скидку»
принимает покупка: «Покупка»
возвращает число
обеспечивает «скидка ограничена» результат не больше 15000
пример «обычная покупка»
дано покупка равно запись «Покупка» с «сумма» равным 5000 и «постоянный клиент» равным нет
ожидается 0
пример «постоянный клиент на пять тысяч»
дано покупка равно запись «Покупка» с «сумма» равным 5000 и «постоянный клиент» равным да
ожидается 250
пример «большая покупка постоянного клиента»
дано покупка равно запись «Покупка» с «сумма» равным 20000 и «постоянный клиент» равным да
ожидается 3000
пример «покупка на потолок скидки»
дано покупка равно запись «Покупка» с «сумма» равным 100000 и «постоянный клиент» равным да
ожидается 15000
пример «очень крупная покупка»
дано покупка равно запись «Покупка» с «сумма» равным 200000 и «постоянный клиент» равным нет
ожидается 15000
(«Большая покупка» от покупка) плюс («Постоянный клиент» от покупка) плюс («Очень крупная покупка» от покупка)
тотальная функция «Скидка по сумме»
принимает сумма: неотрицательное, постоянный: признак
возвращает число
пример «тот же расчёт без записи на входе»
дано сумма равно 20000
дано постоянный равно да
ожидается 3000
«Рассчитать скидку» от (запись «Покупка» с «сумма» равным сумма и «постоянный клиент» равным постоянный)
Этот файл лежит в static/spec/models/order-discount.flang, и его же читает
server.mjs. Модель одна: её открывает песочница на сайте, считает
HTTP-сервис и проверяют его тесты.
Правила — отдельные функции: «Большая покупка», «Постоянный клиент», «Очень крупная покупка». Итог — их сумма в «Рассчитать скидку». Последняя функция, «Скидка по сумме», принимает два скаляра вместо записи. Она нужна вызову из командной строки, о нём — в главе про клиенты на других языках.
Что делает компилятор на старте сервиса
JS-библиотеки компилятора нет. Модель в сервис попадает другим путём: flang
печатает её в обычный модуль JavaScript (flang emit --target js), и сервис
этот модуль подключает. Правило в рантайме — обычная функция, компилятор при
запросах не участвует.
На старте server.mjs делает два шага:
export async function createCalculator(modelFile = MODEL_FILE) {
const compiler = await flang();
const tests = await compiler.test(modelFile);
if (tests.code !== 0) throw new Error(`примеры модели не прошли:\n${tests.output}`);
const passed = /примеров (\d+), прошло (\d+)/u.exec(tests.output);
const { functions } = await importModel(modelFile);
return { examples: passed ? `${passed[2]}/${passed[1]}` : '?', calculate: functions.get(FUNCTION) };
}
Первый шаг — flang test: прогон всех примеров модели. Если хоть один не
сошёлся, функция бросает исключение и порт не открывается. Правило, которое
не проходит свои примеры, до продакшена не доезжает.
Второй шаг — importModel из examples/spec/lib/flang.mjs. Он печатает модель
в JavaScript, кладёт результат в node_modules/.cache/spec-course/ и
подключает модуль. Функции модели достаются по их именам во flang:
functions.get('Рассчитать скидку').
Порядок шагов не случаен. flang emit сам примеры не гоняет, он об этом
прямо пишет:
$ flang emit static/spec/models/order-discount.flang --target js --out js
проверок при работе снято 1: постусловие доказано ядром обо всех входах и
проверено примерами функции — в напечатанный код оно не едет.
напечатано файлов 3, байт 52564, в js
проверено перед печатью — разбор, типы, завершаемость и ядро доказательств.
ПРИМЕРЫ НЕ ПРОГНАНЫ: их считает вычислитель на самом языке, и на самых больших
программах он в предел шагов этого бинарника не укладывается — свяжи с ними
печать, и компилятор перестал бы печатать сам себя. Прогоните их отдельно:
flang test <файл>
Поэтому flang test идёт первым.
Компилятор lib/flang.mjs берёт двоичный, если задан FLANG_BIN=<путь к flang>. Без него — тот же компилятор в WebAssembly, на котором работает
песочница. Ответы у них одинаковые.
Что печатает flang emit
Напечатанный модуль самодостаточен: ни одной зависимости, работает в Node и в браузере. Функции модели доступны двумя способами — по транслитерированному имени и по имени во flang:
import { $PROGRAM } from './js/prodazhi.js';
const calculate = $PROGRAM.functions.get('Рассчитать скидку');
console.log(calculate({ 'сумма': 20000, 'постоянный клиент': true }));
// 3000
Обещания модели печатаются вместе с ней. Обещание «скидка ограничена»
компилятор не доказал — в отчёте flang check --proof оно стоит как «сетка 5
значений». Сетка значит: обещание проверено только на пяти значениях из
примеров, это не доказательство. Такое обещание уезжает в код проверкой:
export function rasschitatSkidku(pokupka) {
const $t1 = $add(
$add(bolshayaPokupka(pokupka), postoyannyyKlient(pokupka)),
ochenKrupnayaPokupka(pokupka),
);
// постусловие «скидка ограничена»
if (!$post($lte($t1, 15000), "скидка ограничена", "Рассчитать скидку")) {
$fail(
"FLANG_PROPERTY",
"нарушено свойство «скидка ограничена» функции «Рассчитать скидку»",
{ line: 46, column: 3 },
);
}
return $t1;
}
Доказанное обещание в код не едет: «за очень крупную покупку не больше потолка» компилятор доказал для всех входов, и строка «проверок при работе снято 1» говорит именно о нём.
HTTP-обработчик и контракт
if (request.method === 'GET' && request.url === '/contract') {
return send(response, 200, { function: FUNCTION, input: fields, output: 'number' });
}
if (request.method !== 'POST' || request.url !== '/discount') {
return send(response, 404, { error: 'используйте POST /discount' });
}
const purchase = readPurchase(await readBody(request), fields);
return send(response, 200, { discount: calculate(purchase) });
fields не описан в коде руками. Он берётся из разобранной программы —
flang ast отдаёт объекты модели с полями и типами:
export async function contractOf(modelFile = MODEL_FILE) {
const ast = await (await flang()).ast(modelFile);
const purchase = ast.types.find((type) => type.name === 'Покупка');
return purchase.fields.map((field) => ({ name: field.name, type: typeWord(field.type) }));
}
Поменялась модель — поменялся и контракт, второго места для правки нет. Проверить локально:
npm run spec:api
curl -s localhost:8788/discount -d '{"сумма":20000,"постоянный клиент":true}'
curl -s localhost:8788/contract
curl -s localhost:8788/health
{
"discount": 3000
}
{
"function": "Рассчитать скидку",
"input": [
{
"name": "сумма",
"type": "неотрицательное"
},
{
"name": "постоянный клиент",
"type": "признак"
}
],
"output": "number"
}
{
"ok": true,
"model": "Продажи",
"examples": "10/10"
}
Функция модели не читает request и не пишет response. Она принимает
запись и возвращает число или бросает ошибку. Поэтому правило проверяется без
единого HTTP-вызова — это делает flang test.
Почему ошибка входа — это 400, а не 500
Напечатанная функция сама типы входа не сверяет. Строку вместо числа она не отвергнет по типу, а упадёт где-то внутри, на первом сравнении:
FLANG_TYPE сравнения порядка допустимы только для чисел
Отрицательную сумму у обычного клиента она молча посчитает как 0. Сверять вход
с объявленными типами — работа границы. В сервисе это readPurchase:
export function readPurchase(body, fields) {
const purchase = JSON.parse(body);
const problems = [];
for (const field of fields) {
const value = purchase?.[field.name];
if (field.type === 'признак' && typeof value !== 'boolean') problems.push(`«${field.name}»: ждали да или нет`);
if (field.type === 'неотрицательное' && !(Number.isInteger(value) && value >= 0)) problems.push(`«${field.name}»: ждали неотрицательное целое`);
}
for (const name of Object.keys(purchase ?? {})) {
if (!fields.some((field) => field.name === name)) problems.push(`«${name}»: такого поля у «Покупки» нет`);
}
if (problems.length) {
const error = new Error(`вход не подходит к «Покупке»: ${problems.join('; ')}`);
error.code = 'INPUT';
throw error;
}
return purchase;
}
Проверка идёт по тем же полям из flang ast, что и контракт. Лишнее поле —
тоже ошибка, а не молча проигнорированное значение.
Ошибка входа — ошибка запроса: клиент прислал не то. Отвечать на неё 500
значило бы сказать «мы сломались». Поэтому обработчик возвращает 400 с
причиной и кодом:
} catch (error) {
return send(response, 400, {
error: error instanceof Error ? error.message : String(error),
code: error?.code ?? 'INPUT',
});
}
$ curl -s -w '%{http_code}\n' localhost:8788/discount -d '{"сумма":"много","постоянный клиент":true}'
{
"error": "вход не подходит к «Покупке»: «сумма»: ждали неотрицательное целое",
"code": "INPUT"
}
400
Если на настоящем входе нарушится обещание модели, напечатанная функция бросит
FLANG_PROPERTY, и клиент получит 400 с этим кодом. server.test.mjs
проверяет неверный вход явно:
test('неверный тип входа — 400 с причиной, а не 500', async () => {
const response = await fetch(`${origin}/discount`, { method: 'POST', body: JSON.stringify({ 'сумма': 'много', 'постоянный клиент': true }) });
assert.equal(response.status, 400);
const body = await response.json();
assert.equal(body.code, 'INPUT');
assert.match(body.error, /«сумма»: ждали неотрицательное целое/);
});
Если граница — не сервис на Node, а отдельный процесс, типы сверяет
прогонщик flang_cli.js, который печатается рядом с модулем. Запрос — строка
JSON, ответ — тоже:
$ echo '{"fn":"Рассчитать скидку","args":[{"r":[["сумма",{"s":"много"}],["постоянный клиент",true]]}]}' | node flang_cli.js ./prodazhi.js
{"ok":false,"code":"FLANG_TYPE","message":"вызов функции «Рассчитать скидку»: аргумент «покупка».сумма не соответствует типу неотрицательное"}
TypeScript вместо JavaScript
Если проект на TypeScript, цель печати другая: flang emit --target ts.
Курс показывает это в examples/spec/typescript-codegen: напечатанный модуль
лежит в репозитории, а команда с ключом --check сравнивает его с тем, что
напечатала бы модель заново, и падает, если код поправили руками мимо модели.
Подробности — в главе про генерацию и CI.
Ошибки, которых следует избежать
- Не читайте тело запроса без лимита:
server.mjsостанавливает чтение после 64 КБ до вызоваJSON.parse. - Не передавайте в функцию модели объект запроса целиком. Сверьте поля с контрактом: напечатанная функция этого не сделает.
- Не выполняйте платёж или списание прямо по ответу
calculate. Сначала получите результат правила, затем выполните эффект отдельным вызовом, который можно повторить или откатить. - Не держите вторую, ручную реализацию правила на TypeScript «на всякий случай». Без теста, который сверяет её с моделью, они разойдутся тихо.
- Не печатайте модель, не прогнав её примеры:
flang emitих не считает.
Практика в песочнице
- В песочнице прочитайте отчёт о доказательствах. Найдите обещание, которое доказано, и три, которые держатся только на примерах.
- Запустите локально
npm run spec:apiиcurl -s localhost:8788/discount -d '{"сумма":"много","постоянный клиент":true}'. Проверьте код ответа и полеcode. - Добавьте в запись «Покупка» поле
«вес»: неотрицательное, поправьте примеры и перезапустите сервис. Посмотрите, как поменялся ответGET /contract— без правки серверного кода.
Типичные ошибки
INPUT— вход не подходит к контракту: не тот тип, лишнее или отсутствующее поле. Это код самой границы сервиса, а не компилятора. Возвращается как400.FLANG_PROPERTY— обещание модели нарушено на настоящем входе. Проверку поставилflang emit, потому что обещание не доказано. Тоже400.- Сервис не стартует —
flang testнашёл несошедшийся пример.createCalculatorбросает исключение с выводомflang test, иnpm run spec:apiзавершается, не открыв порт.
Чек-лист
- Примеры модели прогоняются один раз при старте процесса, до открытия порта.
- Модель печатается в JavaScript при старте или при сборке; при запросах компилятор не участвует.
- Обработчик HTTP не содержит бизнес-условий — только маршруты, разбор тела и коды ответа.
- Контракт входа строится из
flang ast, а не дублируется вручную. - Вход сверяется с типами полей до вызова функции модели.
- Ошибки входа и нарушенные обещания возвращаются как
4xx, а не как500. - Есть лимит на размер тела запроса до
JSON.parse.