Есть за мной такой грешок: если какая-то проблема мне долго досаждает, я в конце концов пишу библиотеку, которая её решает. На сей раз такая история возникла с кодом для валидации CLI.
Смотрите, я немало времени уделяю чтению кода, который написали другие люди. Это код опенсорсных проектов, всякий материал по работе, а также код из случайных репозиториев с GitHub, на которые, бывает, наткнёшься в два часа ночи. Причём, я то и дело замечаю одну и ту же проблему: в любом инструменте CLI найдётся одинаковый уродливый валидационный код, запрятанный поглубже. Знаете, в таком роде:
if (!opts.server && opts.port) { throw new Error("--port requires --server flag"); } if (opts.server && !opts.port) { opts.port = 3000; // порт по умолчанию } // подождите, а что делать, если они передадут --port без значения? // что, если порт находится за пределами допустимого диапазона? // что, если...
Дело даже не в том, что такой код сложно писать. А в том, что он повсюду. В любом проекте. В каждом инструменте CLI. Одни и те же паттерны с небольшими вариациями. Опции, зависящие от других опций. Несочетаемые флаги. Аргументы, которые в определённых режимах не имеют смысла.
Причём вот что меня по-настоящему поразило: ведь для других типов данных эта проблема решена уже много лет назад. Только не для CLI.
Проблема с валидацией
Была одна статья, которая совершенно изменила моё представление о парсинге. Это «Парсь, не валидируй» Алексиса Кинга. В чём её суть? Нельзя разбирать данные в рыхлый тип, а затем проверять, валидны ли они. Их нужно разбирать именно в такой тип, который будет без вариантов валиден.
Задумайтесь об этом. Когда мы получаем JSON с API, мы не парсим его как any, а потом не записываем как ворох if-конструкций. Вероятно, вы воспользуетесь инструментом вроде Zod, чтобы распарсить данные именно в такую форму, какая вас интересует. Недопустимые данные? Тогда парсер их отклонит. И всё.
Но как мы поступаем в случае с CLI? Мы парсим аргументы, складывая их в некий мешок свойств, а затем в следующих 100 строках кода проверяем, есть ли смысл в содержимом этого мешка. А нужно поступать наоборот.
Так что, знаете, я разработал Optique. Не потому, что мир отчаянно нуждается в очередном парсере для CLI (нет, не нуждается), а потому, что я устал повсюду видеть — и писать — один и тот же валидационный код.
Три паттерна, которые я утомился валидировать
Зависимые опции
Встречается повсюду. У вас будет такая опция, которая имеет смысл, лишь если активирована какая-то другая опция.
Дедовский способ? Всё распарсить, а потом проверить:
const opts = parseArgs(process.argv); if (!opts.server && opts.port) { throw new Error("--port requires --server"); } if (opts.server && !opts.port) { opts.port = 3000; } // Пожалуй, где-то ещё вас поджидает дополнительная валидация...
Работая с Optique, вы просто описываете то, что вам нужно:
const config = withDefault( object({ server: flag("--server"), port: option("--port", integer()), workers: option("--workers", integer()) }), { server: false } );
Вот что TypeScript позволяет вывести для типа config:
type Config = | { readonly server: false } | { readonly server: true; readonly port: number; readonly workers: number }
Теперь система типов понимает, что, когда server равен false, port буквально не существует. Не undefined, не null— его просто нет. Попытайтесь к нему обратиться — и TypeScript будет ругаться. Никакой валидации во время выполнения не потребуется.
Взаимоисключающие опции
Ещё один классический пример. Вот вам форматы вывода на выбор: JSON, YAML или XML. Можно выбрать один, но определённо не два.
Я привык писать такую путаницу:
if ((opts.json ? 1 : 0) + (opts.yaml ? 1 : 0) + (opts.xml ? 1 : 0) > 1) { throw new Error('Choose only one output format'); }
(Не осуждайте меня, вы тоже писали что-то подобное.)
А теперь?
const format = or( map(option("--json"), () => "json" as const), map(option("--yaml"), () => "yaml" as const), map(option("--xml"), () => "xml" as const) );
Комбинатор or() означает, что успешно выполнится лишь один вариант. В результате имеем просто "json" | "yaml" | "xml". Одна строка. Не приходится жонглировать тремя булевыми значениями.
Требования, диктуемые окружением
В продакшене нужна аутентификация. В разработке нужны отладочные флаги. При работе в Docker нужны иные опции, нежели при работе на локальной машине. Вы и сами это знаете.
Чтобы не путаться с валидацией, просто опишите каждое из окружений:
const envConfig = or( object({ env: constant("prod"), auth: option("--auth", string()), // В проде обязательно ssl: option("--ssl"), monitoring: option("--monitoring", url()) }), object({ env: constant("dev"), debug: optional(option("--debug")), // В разработке опционально verbose: option("--verbose") }) );
Нет авторизации в продакшене? Парсер сразу же откажет. Попытались обратиться к --auth в режиме разработки? TypeScript вам этого не позволит — нужное вам поле в этом типе не существует.
«Но ведь комбинаторы парсера…»
Знаю, знаю. Термин «комбинаторы парсера» звучит так, как будто без учёной степени по информатике его не понять.
Вот в чём дело: у меня нет степени по информатике. Вообще никакой степени. Но я уже много лет пользуюсь комбинаторами парсеров, поскольку… не так они и сложны? Просто название у них такое, из-за которого они кажутся страшнее, чем есть на самом деле.
С их помощью я решаю и другие задачи — разбираю конфигурационные файлы, код на предметно-ориентированных языках (DSL) и пр. Но почему-то мне никогда не приходила в голову мысль применить их для парсинга CLI, пока я не увидел optparse-applicative в Haskell. Это был подлинный момент «постойте-ка, ну конечно». Вернее, почему мы вообще делаем это как-то иначе?
Всё оказалось просто до идиотизма. Парсер — это просто функция. Комбинаторы — это просто функции, принимающие парсеры и возвращающие новые парсеры. Вот и всё.
// Это парсер const port = option("--port", integer()); // Это тоже парсер (составленный из более мелких парсеров) const server = object({ port: port, host: option("--host", string()) }); // Тоже парсер (парсер на парсере сидит и парсером погоняет) const config = or(server, client);
Никаких монад. Никакой теории категорий. Просто функции. Скучные и красивые.
TypeScript берёт на себя тяжёлую работу
Ниже рассмотрю аспект, всё-таки отдающий читерством: дело в том, что я перестал писать типы для моих конфигураций CLI. TypeScript просто… сам с этим разбирается.
const cli = or( command("deploy", object({ action: constant("deploy"), environment: argument(string()), replicas: option("--replicas", integer()) })), command("rollback", object({ action: constant("rollback"), version: argument(string()), force: option("--force") })) ); // TypeScript автоматически выводит этот тип: type Cli = | { readonly action: "deploy" readonly environment: string readonly replicas: number } | { readonly action: "rollback" readonly version: string readonly force: boolean }
TypeScript известно, что, если action имеет значение "deploy", то environment существует, а version — нет. Он знает, что replicas — это number. Знает, что force — это boolean. Я сам ничего этого не сообщал.
Речь не только о приятном автозавершении (хотя, да, с автозавершением здесь всё отлично). Речь об отлове багов до того, как они случатся. Забыли где-то обработать новую опцию? Тогда код не скомпилируется.
Что именно для меня изменилось
Я на несколько недель отдал эту библиотеку на внутреннее тестирование в нашей компании. Некоторые реальные отзывы:
Теперь я удаляю код. Не рефакторю. Удаляю. Где та логика валидации, занимавшая процентов 30 моего кода для CLI? Исчезла. К этому никак не привыкнуть.
Рефакторить не страшно. Знаете, что чего меня обычно бросает в дрожь? Если нужно менять механизм приёма аргументов в CLI. Например, менять --input file.txt на file.txt в качестве позиционного аргумента. Пользуясь при этом традиционными парсерами, приходится повсюду вылавливать логику валидации. А теперь? Просто меняешь определение парсера. TypeScript сразу же показывает тебе все места, где возникли неполадки, вы их точечно исправляете — готово. Раньше можно было битый час проверять «всё ли я выловил?», а теперь – «исправь там, где подчёркнуто красным, и двигайся далее».
Мои CLI стали причудливее. Когда при добавлении сложных опций не приходится городить валидацию, ты просто… добавляешь их. Взаимоисключающие группы? Конечно же. Опции, зависящие от контекста? Почему бы и нет. Парсер со всем справится.
Кроме того, код реально переиспользуется:
const networkOptions = object({ host: option("--host", string()), port: option("--port", integer()) }); // Переиспользуем всё, только в других сочетаниях const devServer = merge(networkOptions, debugOptions); const prodServer = merge(networkOptions, authOptions); const testServer = merge(networkOptions, mockOptions);
А если честно — что изменилось сильнее всего, так это степень доверия. Если код скомпилируется, то логика CLI будет работать. Не «наверное будет» или не «будет работать, если только кто-то не передаст странных аргументов». Работает и всё.
Нужно ли это вам?
Если вы пишете 10-строчный скрипт, принимающий один аргумент, то вам всё это не нужно. Напишите process.argv[2] — и можете этим удовлетвориться.
Но, если вам знакомы подобные ситуации:
Имеющаяся валидационная логика рассинхронизируется с фактически действующими опциями.
В продакшене было обнаружено, что определённые комбинации опций взрывоопасны.
Вы потратили остаток рабочего дня, чтобы отследить, почему
--verboseломается при использовании с--json.В пятый раз пишете одну и ту же проверку «опция A требует опции B».
То, вероятно, вам стоит обратить внимание на мой инструмент.
Честно предупреждаю: Optique ещё незрелая. В чём-то я сам пока продолжаю разбираться, API может немного гулять. Но основная идея — хороший парсинг заменяет валидацию — никуда не денется. Я уже несколько месяцев вообще не пишу валидационного кода.
Это по-прежнему ощущается странно. В хорошем смысле.
Пробовать или нет - дело ваше
Если вас заинтересовал пост, то вот вам:
Туториал: напишите что-нибудь реальное, проверьте, станет ли оно вас бесить.
Концепции: примитивы, конструкции, модификаторы, парсеры значений, вот это всё.
GitHub: код, темы, возмущения.
Не скажу, что Optique решит все ваши проблемы с CLI. Хочу лишь сказать, что я устал видеть повсюду один и тот же валидационный код, поэтому написал библиотеку, которая меня от этого избавляет.
Решать вам. Кстати, а сейчас вы пишете валидационный код? Может быть, он вам и не понадобится.
