Из Python, Go и shell: работающие клиенты, а не декларации
Код в этой главе записан в прежней поверхности языка — со словами
категория,объект,утилита. Сегодняшний компилятор её слова читает, но программой такой файл не считает: файл, где есть только утилиты,flang checkотклоняет. Разбор задачи в главе верен; синтаксис переносится по таблице из главы «Старые модели».
FTS не привязан к Node.js. Компилятор написан на TypeScript, но граница между
ним и остальным кодом — не библиотека, а процесс и JSON: любой язык, который
умеет запустить команду и распарсить stdout, может исполнить FTS-утилиту.
Дальше — три работающих клиента: Python, Go и shell, один и тот же контракт,
один и тот же результат. Все три лежат в examples/spec/clients/ и правда
запускаются, а не только читаются.
Минимальный пример
Модель, вокруг которой построен весь модуль, — расчёт скидки на покупку. Она
же используется HTTP-сервисом в examples/spec/discount-api и клиентами ниже:
единственный источник правды для правила, три способа его вызвать.
категория «Продажи»
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
утилита «Рассчитать скидку»
принимает Покупка
возвращает деньги
начинает с 0
правило «Большая покупка»
если сумма не меньше 10000
и сумма не больше 100000
то добавить 10 процентов от поля сумма
правило «Постоянный клиент»
если «постоянный клиент» равен да
и сумма больше 0
и сумма не больше 100000
то добавить 5 процентов от поля сумма
правило «Очень крупная покупка»
если сумма больше 100000
то добавить 15000
свойство «Скидка ограничена»
результат не больше 15000
пример «Обычная покупка»
дано сумма равна 5000
дано «постоянный клиент» равен нет
ожидается результат равен 0
пример «Постоянный клиент на пять тысяч»
дано сумма равна 5000
дано «постоянный клиент» равен да
ожидается результат равен 250
пример «Большая покупка постоянного клиента»
дано сумма равна 20000
дано «постоянный клиент» равен да
ожидается результат равен 3000
пример «Покупка на потолок скидки»
дано сумма равна 100000
дано «постоянный клиент» равен да
ожидается результат равен 15000
пример «Очень крупная покупка»
дано сумма равна 200000
дано «постоянный клиент» равен нет
ожидается результат равен 15000
Что делает компилятор
fts compile превращает текст выше в один JSON-объект: категория, структуры
с полями и типами, утилиты с правилами и примерами. fts test прогоняет
примеры и сравнивает ожидаемое с фактическим. fts run --utility ... --input purchase.json исполняет конкретную утилиту на конкретных данных и печатает
результат. Ничего из этого не требует TypeScript на стороне вызывающего —
только умение прочитать JSON.
Граница через JSON
Канонический документ — публичный контракт, а не внутренняя деталь. В апстриме
есть его JSON Schema (schema/document.schema.json): она требует поля
category, structures, functors, proposition, ts_compat и допускает
utilities. Так выглядит модель выше после fts compile (сокращено):
{
"category": "Продажи",
"structures": [
{ "name": "Покупка", "fields": [
{ "name": "сумма", "type": "Деньги" },
{ "name": "постоянный клиент", "type": "Признак" }
] }
],
"functors": [],
"proposition": null,
"ts_compat": {},
"utilities": [
{ "name": "Рассчитать скидку", "input": "Покупка", "output": "Деньги",
"initial": 0, "rules": [ /* … */ ], "properties": [ /* … */ ],
"examples": [ /* … */ ] }
]
}
Любой язык может валидировать этот документ схемой, не имея парсера FTS —
достаточно обычного JSON Schema валидатора (jsonschema в Python, ajv в
Node, любой аналог в Go). Рядом лежит schema/proof-certificate.schema.json
для сертификатов доказательств — та же идея: структура сертификата публична
и проверяется независимо от того, кто его выпустил.
Контракт CLI при этом простой и одинаковый у всех команд:
- успех — JSON-результат в stdout, exit code
0; - ошибка —
{"error": "...", "diagnostics": [...]}в stderr, exit code не равен0(1— ошибка домена или валидации,2— ошибка аргументов).
Настоящий fts раздавался пакетом, который снят; исходники открыты
(github.com/digitable-lol/flang).
Для примеров ниже он всё равно не берётся — используется
examples/spec/clients/fts-cli.mjs, тонкий мост поверх той же вендорной
сборки, на которой работает песочница, повторяющий контракт compile / check / test / run. Так клиенты этой главы не зависят ни от установки пакета, ни от
доступа в реестр. Замена мостика на настоящий fts не меняет ни один клиент —
только имя команды в одной строке.
Клиент на Python
examples/spec/clients/python/calculate_discount.py — только subprocess и
json из стандартной библиотеки:
def calculate_discount(amount, loyal, cli="bridge", model=DEFAULT_MODEL):
purchase = {"сумма": amount, "постоянный клиент": loyal}
input_path = write_temp_json(purchase)
command = build_cli_command(cli, model) + ["--utility", UTILITY, "--input", str(input_path)]
completed = subprocess.run(command, capture_output=True, text=True)
if completed.returncode != 0:
raise RuntimeError(describe_failure(completed))
return json.loads(completed.stdout)["result"]
Данные утилиты передаются файлом: контракт CLI требует --input путь.json,
а не stdin, поэтому временный файл — не лишняя деталь, а требование границы.
Ошибка не глотается: describe_failure разбирает stderr как JSON и достаёт
error и коды диагностик, а если stderr не JSON вообще (CLI не нашёлся) —
показывает то, что есть, вместо тихого падения.
Запуск и фактический вывод:
$ python3 calculate_discount.py
сумма=20000 постоянный_клиент=False -> скидка=2000
$ python3 calculate_discount.py --сумма 20000 --постоянный-клиент
сумма=20000.0 постоянный_клиент=True -> скидка=3000
Клиент на Go
examples/spec/clients/go/main.go использует только os/exec и
encoding/json:
func calculateDiscount(sum float64, loyal bool, cli, model string) (float64, error) {
purchase := map[string]any{"сумма": sum, "постоянный клиент": loyal}
inputFile, err := writeTempInput(purchase)
// ...
cmd := exec.Command(command[0], command[1:]...)
// stdout и stderr пишутся в отдельные буферы —
// diagnostics не должны потеряться, даже если процесс упал
runErr := cmd.Run()
if runErr != nil {
return 0, fmt.Errorf("%s", describeFailure(stderrBuf, stdoutBuf, runErr))
}
var result runResult
json.Unmarshal(stdout, &result)
return result.Result, nil
}
Структуры runResult и errorResult в коде типизируют оба возможных исхода
JSON-контракта — успех и ошибку — так, что encoding/json разбирает их без
interface{} и приведений типов. Собрано и проверено локально (go1.26.3):
$ go run main.go
сумма=20000 постоянный_клиент=false -> скидка=2000
$ go run main.go -sum 20000 -loyal
сумма=20000 постоянный_клиент=true -> скидка=3000
Клиент на shell
examples/spec/clients/shell/discount.sh — bash плюс jq. Написать честный
парсер JSON голым bash без jq не получится: экранирование юникода и кавычек
в сумма/«постоянный клиент» — ровно тот случай, где самодельный grep/sed
тихо ломается на первом же нестандартном значении. jq в системе есть почти
всегда, а его отсутствие клиент проверяет и явно об этом сообщает.
jq -n --argjson sum "$SUM" --argjson loyal "$LOYAL" \
'{"сумма": $sum, "постоянный клиент": $loyal}' > "$INPUT_FILE"
"${CMD[@]}" >"$STDOUT_FILE" 2>"$STDERR_FILE"
STATUS=$?
if [[ $STATUS -ne 0 ]]; then
ERROR_MESSAGE="$(jq -r '.error' "$STDERR_FILE" 2>/dev/null || true)"
echo "ошибка: $ERROR_MESSAGE" >&2
exit "$STATUS"
fi
set -e отключается на время вызова CLI намеренно: без этого код возврата
терялся бы раньше, чем скрипт успевает прочитать stderr и показать диагностику
пользователю. Фактический вывод:
$ ./discount.sh
сумма=20000 постоянный_клиент=false -> скидка=2000
$ ./discount.sh 20000 true
сумма=20000 постоянный_клиент=true -> скидка=3000
Через HTTP вместо CLI
Если запуск процесса на каждый вызов дорог (высокая частота, короткий SLA),
альтернатива — не CLI, а долгоживущий HTTP-сервис. examples/spec/discount-api
уже делает это: модель компилируется и тестируется один раз при старте, дальше
Node.js только маршрутизирует запросы, а решение принимает FTS.
node examples/spec/discount-api/server.mjs
curl -s localhost:8788/discount -d '{"сумма":20000,"постоянный клиент":true}'
# {"discount": 3000}
Ошибка входа возвращается тем же телом, что и в CLI — error и
diagnostics — только кодом 400 вместо ненулевого exit code:
{
"error": "поле «сумма» не соответствует типу «Деньги»",
"diagnostics": [{ "code": "FTS_UTILITY_INPUT_TYPE", "message": "…", "severity": "error" }]
}
Любой клиент из разделов выше можно переписать на requests/net/http/curl
вместо subprocess/os/exec/CLI-моста — разбор ответа не меняется вообще,
потому что тело то же самое, что печатает CLI. Выбор между CLI и HTTP — это
выбор между простотой (один процесс на вызов, ничего не держать живым) и
пропускной способностью (модель компилируется один раз, дальше только сеть).
Третий способ: не звать компилятор, а напечатать модель в свой язык
У обоих способов выше есть общая черта: рядом с вашим кодом живёт процесс на Node, и он считает. Иногда этого нельзя — нет Node на целевой машине, нельзя поднять сервис, нельзя платить за запуск процесса на вызов. Тогда работает третий способ, и он появился недавно.
FTS вырос в полный язык — flang, — для которого модель .fts является
валидной программой (модуль
«FTS toolchain»). У языка есть
кодогенерация, и печатает он в восемь целей:
$ node flang/bin/flang.mjs emit examples/utilities/discount.fts --target zzz
{"error":"неизвестная цель «zzz»; доступны: c, csharp, elixir, go, java, js, python, rust", …}
Вход тот же самый файл .fts, который вы весь курс проверяли через fts check.
Возьмём Java — тот случай, когда «поднять рядом Node» обычно и не обсуждается:
$ node flang/bin/flang.mjs emit examples/utilities/discount.fts --target java --out out-java
{"target":"java","module":"Продажи","files":[
{"path":"Value.java","bytes":21992}, {"path":"Field.java","bytes":1619},
{"path":"FlangError.java","bytes":4920}, {"path":"Ctx.java","bytes":5676},
{"path":"Flang.java","bytes":35537}, {"path":"Prodazhi.java","bytes":5927},
{"path":"FlangCli.java","bytes":21963}, {"path":"Makefile","bytes":1077}]}
$ make build
javac -encoding UTF-8 -Xlint:all -Werror -d . *.java
$ printf '{"fn":"Рассчитать скидку","args":[{"r":[["сумма",{"n":"20000"}],["постоянный клиент",true]]}]}\n' \
| java -cp . FlangCli Prodazhi
{"ok":true,"value":{"n":"3000"}}
Три тысячи — ровно то, что даёт fts run на тех же данных и что записано в
примере «Большая покупка постоянного клиента» в самой модели. Обратите внимание,
чего в этой цепочке нет: Node на стороне вызывающего.
Прогонщик FlangCli в примере выше — только для того, чтобы результат было
видно в консоли; сам он в вашей программе не нужен. В Prodazhi.java лежат
обычные статические методы, и утилита модели — один из них:
Ctx ctx = Prodazhi.newContext();
Value покупка = Prodazhi.rec_pokupka(Value.number(20000), Value.flag(true));
Value скидка = Prodazhi.fn_rasschitat_skidku(ctx, покупка); // 3000
Роль входит в каждое имя (fn_ у функции, rec_ у конструктора записи) не для
красоты: класс Java — одно пространство имён, а в модели FTS одно и то же имя
запросто носят и объект, и утилита. Столкнись они после транслитерации — класс
просто не собрался бы, и бэкенд считает такое столкновение ошибкой печати, а не
поводом молча кого-нибудь переименовать.
Совпадение здесь не «обычно сходится», а требование, которое проверяется. У
кодогенерации flang одно общее правило на все восемь бэкендов: напечатанный код
обязан давать то же значение и ту же ошибку — код и текст, что даёт
интерпретатор. Из этого правила растут решения, которые иначе выглядели бы
придирками: числа везде IEEE-754 (в бэкенде C# decimal отвергнут именно
поэтому — в нём 0.1 плюс 0.2 дало бы ровно 0.3), проценты печатаются как
(процент / 100) * значение в этом порядке, тексты диагностик копируются
буквально вплоть до кавычек-ёлочек.
Что это значит для выбора границы
Способов стало три, и выбирают между ними по одному вопросу — что вы готовы держать рядом со своим кодом.
| Способ | Что живёт рядом | Когда брать |
|---|---|---|
| CLI-процесс | Node и модель | редкие вызовы, скрипты, CI |
| HTTP-сервис | Node и модель, но один раз на всех | высокая частота, короткий SLA |
| печать в целевой язык | ничего — только ваш код | Node недоступен или процесс на вызов недопустим |
У третьего способа есть цена, и её стоит назвать. Напечатанный код — это
срез модели на момент печати: изменили .fts — обязаны напечатать заново,
иначе разойдётесь. Ровно та же проблема, что у сгенерированного TypeScript, и
решается она ровно так же — шагом --check в CI, который сравнивает
напечатанное с моделью (модуль
«Генерация и CI»). Первые два способа
этой цены не имеют: там модель читается при каждом запуске.
И то же самое сделали с самим компилятором
Это уже не про интеграцию, но упомянуть стоит, потому что показывает, насколько приём общий.
Ядро FTS — лексер, парсер, вычислитель утилит и печать канонического JSON — переписано с TypeScript на flang: четыре файла, 300 функций, все в тотальном классе. А раз это программа на flang, её можно напечатать в C:
$ node flang/bin/flang.mjs emit flang/core/parser.flang --target c --out core-c
{"target":"c","module":"Парсер FTS","files":[…, {"path":"parser_fts.c","bytes":914838}, …]}
$ cd core-c && make
…
cc -std=c99 -Wall -Wextra -Werror -pedantic -O2 -o flang_cli flang_cli.o flang_runtime.o parser_fts.o -lm
Получившийся бинарник — компилятор FTS без Node. Мы скормили ему исходник
модели из этого модуля и сравнили результат с тем, что даёт ядро на TypeScript:
канонический JSON совпал побайтово, 1195 байт в 1195. Тот же документ,
который вы весь курс получали командой fts compile, теперь получается ещё и
нативным бинарником.
Критерий, по которому это ядро считается верным, стоит забрать себе независимо от FTS: не «проходят собственные тесты», а «на всех моделях репозитория старое ядро и новое дают побайтово совпадающий JSON, включая коды и тексты диагностик». Своя тестовая база проверяет то, о чём подумал автор; сверка с работающим предшественником проверяет всё, что предшественник умеет, — включая поведение, о котором никто уже не помнит.
Практика в песочнице
Измените сумму или признак постоянного клиента и посмотрите, какой JSON уйдёт в CLI и какой вернётся — та же утилита, что исполняют клиенты выше.
Типичные ошибки
Ниже — реальные коды диагностики, воспроизведённые на модели из этого модуля.
FTS_UTILITY_INPUT_TYPE — поле не того типа:
{"сумма": "много", "постоянный клиент": true}
{"error": "поле «сумма» не соответствует типу «Деньги»", "diagnostics": [{"code": "FTS_UTILITY_INPUT_TYPE", "severity": "error"}]}
FTS_UTILITY_INPUT — во входных данных не хватает поля (в примере выше
пропущено «постоянный клиент»); тот же код используется, если утилита сама
не найдена во входной структуре.
FTS_UTILITY_INPUT_FIELD — во входных данных лишнее поле, которого нет
в структуре Покупка. FTS не игнорирует лишние ключи молча: это чаще всего
признак, что клиент и модель разошлись по контракту.
FTS_UNKNOWN_UTILITY — опечатка в имени утилиты (--utility "Расчитать скидку" вместо «Рассчитать скидку»).
FTS_NO_UTILITY_EXAMPLES — fts test вызван на утилите без единого
пример «…»: тестировать нечего, и компилятор говорит об этом явно, а не
молча возвращает пустой отчёт.
Общее правило для клиента на любом языке: не парсить текст error руками.
Ветвиться нужно по diagnostics[].code — он стабилен между версиями, а
формулировка сообщения — нет.
Чек-лист
- Клиент вызывает CLI и разбирает stdout как JSON только при exit code
0; при ненулевом — читаетstderrкак{error, diagnostics}. - Входные данные утилиты передаются файлом (
--input path.json), а не строкой в аргументах: так путь виден в диагностике при сбое. - Ветвление по ошибке — через
diagnostics[].code, а не через текстerror. - Для нечастых вызовов — CLI-процесс; для высокой частоты — HTTP-сервис с моделью, скомпилированной один раз при старте.
- Каноническую модель, а не только результат утилиты, можно валидировать внешней JSON Schema — это работает даже без CLI, на уже сохранённом документе.