Это частично руководство, частично статья о том, что вам следует учитывать перед созданием нового CLI-приложения в Rust.
Прежде чем переходить к cargo new
и приступать к ее реализации, ознакомьтесь с ней и узнайте, как повысить удобство разработки, эргономичность интерфейса и ремонтопригодность проекта.
Структура
Когда у вас есть приложение CLI, у вас есть куча флагов и команд, и для них подходящий логический модуль.
Например, для подкоманды git clone
у вас есть некоторая функциональность клонирования, но для подкоманды git commit
у вас может быть совершенно другая функциональность, которая может находиться в совершенно другом модуле (и должна быть). Таким образом, это может быть другой модуль, а также другой файл.
Или у вас может быть простая плоская структура CLI-приложения, которое делает только одну вещь, но использует различные флаги в качестве настроек для этой единственной вещи:
$ demo --help
A simple to use, efficient, and full-featured Command Line Argument Parser
Usage: demo[EXE] [OPTIONS] --name <NAME>
Options:
-n, --name <NAME> Name of the person to greet
-c, --count <COUNT> Number of times to greet [default: 1]
-h, --help Print help
-V, --version Print version
$ demo --name Me
Hello Me!
Итак, когда мы говорим о макете команды, это должно означать разделение на файлы, и затем мы также хотим поговорить о файловой структуре и макете проекта.
Просмотрев большое количество популярных CLI-приложений в Rust, я обнаружил, что обычно существует три типа структур приложений:
- Ad-hoc, см. xh в качестве примера, с любой структурой папок
- Плоский, со структурой папок, такой как:
src/
main.rs
clone.rs
..
- Вложенные команды, где структура вложена, вот в качестве примера, со структурой папок, такой как
src/
cmd/
clone.rs
main.rs
Вы можете использовать плоскую или вложенную структуру, используя стартовый проект: rust-starter
, и использовать то, что вам нужно, а то, что вам не нужно, удалить.
Пока вы этим занимаетесь, всегда полезно разделить ядро вашего приложения и его интерфейс. Я нахожу, что хорошее эмпирическое правило заключается в том, чтобы думать о создании:
- Библиотеки
- Интерфейса командной строки, использующий эту библиотеку
- И кое-что, что помогает обобщить и укрепить API этой библиотеки: какой-то другой графический интерфейс (который никогда не будет существовать), который мог бы использовать эту библиотеку
Чаще всего, особенно в Rust, я нахожу, что это разделение было чрезвычайно полезным, и мне представляются варианты использования библиотеки как самостоятельной.
Обычно оно делится на три части:
- Библиотека
- Основной рабочий процесс/запуск, (например
workflow.rs
), который управляет приложением CLI - Части CLI (запрос, обработка выхода, флаги синтаксического анализа и команды), которые отображаются в рабочий процесс
Флаги, параметры, аргументы и команды
Даже если мы исключим приложения CLI, которые очень графичны, такие как Vim (используют TUI и т.д.), нам все равно придется решать проблемы пользовательского интерфейса/UX в нашем CLI в форме флагов, команд и аргументов, которые получает программа.
Существует несколько различных способов указания и анализа параметров командной строки, каждый со своим собственным набором соглашений и рекомендаций.
Согласно стандарту POSIX, параметры задаются с помощью одного тире, за которым следует одна буква, и могут быть объединены в один аргумент (например, -abc). Длинные параметры, которые обычно более элегантны и их легче читать, указываются с помощью двух тире, за которыми следует слово (например, —option). Параметры также могут принимать значения, которые задаются с помощью знака равенства (например, -o=значение или —option=значение).
Позиционные аргументы - это аргументы, которые приложение командной строки ожидает указать в определенном порядке. Они не имеют перед собой тире и часто используются для указания требуемых данных, необходимых приложению для правильной работы.
Стандарт POSIX также определяет ряд специальных опций, таких как -h или —help для отображения справочного сообщения и -v или —verbose для вывода подробностей. Эти параметры широко известны и используются многими приложениями командной строки, что облегчает пользователям поиск и использование различных функций.
В целом, стандарт POSIX предоставляет набор соглашений для указания и синтаксического анализа параметров командной строки, которые широко признаны и которым следуют многие приложения командной строки, облегчая пользователям понимание и использование различных инструментов командной строки.
Или, другими словами, при разработке интерфейса для приложения командной строки нам нужно подумать о:
- Подкоманды: Некоторые приложения командной строки позволяют пользователям указывать подкоманды, которые по сути являются дополнительными вложенными приложениями, которые могут быть запущены внутри основного приложения. Например, команда git позволяет пользователям указывать такие подкоманды, как
commit
,push
иpull
. Подкоманды часто используются для группировки связанных функций в рамках одного приложения и упрощения поиска пользователями различных функций и их использования. - Позиционные аргументы: Позиционные аргументы - это аргументы, которые приложение командной строки ожидает указать в определенном порядке. Например, команда cp ожидает два позиционных аргумента: исходный файл и файл назначения. Позиционные аргументы часто используются для указания требуемых данных, которые необходимы приложению для правильного функционирования.
- Флаги/параметры: Флаги - это параметры командной строки, которые не ожидают указания значения. Они часто используются для переключения определенного поведения или настройки в приложении. Флаги обычно задаются с помощью одинарного тире, за которым следует одна буква (например, -v для подробной информации), или двойного тире, за которым следует слово (например, —verbose для подробной информации). Флаги также могут принимать необязательные значения, которые задаются с помощью знака равенства (например, —output=file.txt ).
Чтобы сделать отличную библиотеку, на которую можно положиться, которая поможет вам перейти от простого приложения к сложному без необходимости перехода в другую библиотеку, вы можете использовать clap crate
. Clap
поможет вам пройти долгий путь, рассмотрите возможность использования альтернативы только в том случае, если у вас есть особые требования, такие как более быстрое время компиляции, меньший размер двоичного файла или что-то подобное.
Command::new("git")
.about("A fictional versioning CLI")
.subcommand_required(true)
.arg_required_else_help(true)
.allow_external_subcommands(true)
.subcommand(
Command::new("clone")
.about("Clones repos")
.arg(arg!(<REMOTE> "The remote to clone"))
.arg_required_else_help(true),
)
.subcommand(
Command::new("diff")
.about("Compare two commits")
.arg(arg!(base: [COMMIT]))
.arg(arg!(head: [COMMIT]))
.arg(arg!(path: [PATH]).last(true))
.arg(
arg!(--color <WHEN>)
.value_parser(["always", "auto", "never"])
.num_args(0..=1)
.require_equals(true)
.default_value("auto")
.default_missing_value("always"),
),
)
.subcommand(
Command::new("push")
.about("pushes things")
.arg(arg!(<REMOTE> "The remote to target"))
.arg_required_else_help(true),
)
.subcommand(
Command::new("add")
.about("adds things")
.arg_required_else_help(true)
.arg(arg!(<PATH> ... "Stuff to add").value_parser(clap::value_parser!(PathBuf))),
)
.subcommand(
Command::new("stash")
.args_conflicts_with_subcommands(true)
.args(push_args())
.subcommand(Command::new("push").args(push_args()))
.subcommand(Command::new("pop").arg(arg!([STASH])))
.subcommand(Command::new("apply").arg(arg!([STASH]))),
)
Минималистичная альтернатива clap
- argh
. Зачем выбирать что-то другое?
- размер
clap
может быть слишком большим для вашего двоичного файла, и вас действительно волнует размер двоичного файла (здесь мы говорим о таких числах, как 300 кб против 50 кб). - компиляция
clap
может занять больше времени (но для большинства людей она достаточно быстрая) - Возможно, у вас очень простой интерфейс CLI и вы цените код, который занимает половину экрана для всего, что вам нужно сделать.
Вот пример использования argh:
#[derive(Debug, FromArgs)]
struct AppArgs {
/// task to run
#[argh(positional)]
task: Option<String>,
/// list tasks
#[argh(switch, short = 'l')]
list: bool,
/// root path (default ".")
#[argh(option, short = 'p')]
path: Option<String>,
/// init local config
#[argh(switch, short = 'i')]
init: bool,
}
А потом просто:
let args: AppArgs = argh::from_env();
Конфигурация
В большинстве операционных систем существуют стандартные места для хранения файлов конфигурации и других пользовательских данных. Эти местоположения часто называются “домашними папками” или “каталогами профилей” и используются для хранения файлов конфигурации, данных приложений и других пользовательских данных.
Unix-подобные системы (Linux / macOS)
Домашняя папка обычно находится по адресу /home/username
или /Users/username
и используется для хранения файлов конфигурации и других данных, специфичных для пользователя. Домашнюю папку часто называют каталогом $HOME
, и к ней можно получить доступ с помощью символа ~
(например, ~/.bashrc
).
В домашнем каталоге часто находится папка .config
(также известная как “каталог конфигурации”), которая используется для хранения файлов конфигурации и других данных, специфичных для пользователя. Папка .config
- это каталог с “точкой”, поэтому, если вы ее не видите, используйте терминал. Приложение может хранить свои конфигурационные файлы в подкаталоге папки .config
, например ~/.config/myapp
.
В Windows домашняя папка обычно находится по адресу C:\Users\username
, и используется для хранения файлов конфигурации и других данных, специфичных для пользователя. Домашнюю папку часто называют каталогом “профиль пользователя”, и к ней можно получить доступ с помощью переменной окружения %USERPROFILE%
(например, %USERPROFILE%\AppData\Roaming
).
dirs::home_dir();
// Lin: Some(/home/alice)
// Win: Some(C:\\Users\\Alice)
// Mac: Some(/Users/Alice)
dirs::config_dir();
// Lin: Some(/home/alice/.config)
// Win: Some(C:\\Users\\Alice\\AppData\\Roaming)
// Mac: Some(/Users/Alice/Library/Application Support)
Конфигурационные файлы
С serde легко читать содержимое конфигурации, потому что это трехэтапный процесс:
- “Сформируйте” свою конфигурацию в виде структуры
- Выберите формат и включите необходимые функции
serde
(например,yaml
) - Десериализуйте свою конфигурацию (
serde::from_str
)
В большинстве случаев этого более чем достаточно, и получается простой, поддерживаемый код, который также можно доработать, изменив форму вашей структуры.
impl Config {
pub fn load(path: &Path)->Result<Self>{
Ok(serde::from_str(&fs::read_to_string(path)?)?)
}
..
}
Некоторые варианты использования требуют “обновления” загрузки конфигурации двумя возможными способами:
- Локальная и глобальная конфигурации и их взаимосвязи. То есть, прочитайте файл конфигурации текущей папки и “подымитесь” по иерархии папок, заполняя остальную недостающую конфигурацию с каждым новым найденным файлом конфигурации, пока не доберетесь до файла глобальной конфигурации пользователя, расположенного где-то вроде
~/.your-app/config.yaml
. - Входные данные многоуровневой конфигурации. То есть чтение из локального
config.yaml
, но, если определенное значение было предоставлено с помощью флага среды или флага CLI, переопределите то, что было найдено вconfig.yaml
, этим значением. Для этого требуется библиотека, которая может обеспечить выравнивание ключей конфигурации для различных форматов:YAML
, флагов CLI, переменных окружения и так далее.
Хотя я настоятельно призываю вас сохранять простоту и развиваться (просто используйте загрузку serde
), я обнаружил, что обе эти библиотеки действительно надежны при чтении многоуровневой конфигурации и делают практически одно и то же.:
Цвета, стиль и терминал
В современном мире стало совершенно нормально выражать себя с помощью стиля в терминале. Это означает иногда цвета RGB, эмодзи, анимацию и многое другое. Использование цветов, unicode, может привести к потере совместимости с “традиционным” терминалом Unix, большинство библиотек могут понижать качество работы по мере необходимости.
Цвета
Если вы не используете какую-либо другую библиотеку пользовательского интерфейса терминала, owo-colors
великолепен, минимален, удобен в использовании ресурсов и интересен:
use owo_colors::OwoColorize;
fn main() {
// основные цвета
println!("My number is {:#x}!", 10.green());
// цвета фона
println!("My number is not {}!", 4.on_red());
}
Если вы используете что-то вроде dialoguer
для подсказок, стоит изучить, что он использует для цветов. В этом случае он использует консоль для манипулирования терминалом. С помощью консоли вы можете оформить его таким образом:
use console::Style;
let cyan = Style::new().cyan();
println!("This is {} neat", cyan.apply_to("quite"))
Немного отличается, но не слишком.
Эмодзи
Размышляя об эмодзи: это форма самовыражения. Итак, 🙂, :-) и [smiling]
- это одно и то же выражение, но разные средства. Вам нужны эмодзи с хорошей поддержкой юникода, текстовый смайлик на текстовых терминалах без юникода и многословная улыбка, когда вам нужен текст с возможностью поиска или для более доступного и читабельного вывода для слабовидящих.
Еще один совет, который следует помнить, заключается в том, что эмодзи могут выглядеть по-разному в разных терминалах. В Windows у вас есть старый терминал cmd.exe и Powershell, и они радикально отличаются тем, как они отображают эмодзи в терминалах Linux и macOS (в то время как рендеринг эмодзи в Linux и macOS довольно близок).
Если уж на то пошло, лучше всего абстрагировать ваши буквальные эмодзи от переменных. Это может быть просто набор литералов с вашей собственной логикой переключения или что-то более причудливое с реализацией fmt::Display
.
Возможно, вы захотите переключиться на основе матрицы требований:
- Операционная система
- Поддержка функций терминала (unicode, istty)
- Запрошенное пользователем значение (они специально не просили никаких смайликов?)
В консоли есть отличная реализация этой идеи (хотя и не такая обширная)
use console::Emoji;
println!("[3/4] {}Downloading ...", Emoji("🚚 ", ""));
println!("[4/4] {} Done!", Emoji("✨", ":-)"));
Таблицы
Одной из наиболее гибких библиотек для печати и форматирования таблиц является tabled
. Что делает библиотеку табличной печати гибкой?
- Поддержка “данных свободной формы” — просто набор имен строк и столбцов
- Поддержка типизированных записей через
serde
, поэтому вы просто предоставляете ему набор типизированных элементов вVec
- Форматирование и придание формы: выравнивание, интервалы, охват и многое другое
- Поддержка цветов — это не так просто, поскольку при расчете макета таблицы вам нужно учитывать коды ANSI, которые делают строку побайтно длиннее и ее трудно предсказать
- И многое другое
tabled
делает все это, и это здорово. Если вы ищете результаты печати таблиц или просто результаты компоновки таблиц (например, результаты форматирования страницы), не ищите ничего другого, это все.
Prompts
dialoguer
- наиболее широко используемая библиотека подсказок на данный момент, и она надежна как скала. В нем есть почти все различные подсказки, которые можно было бы ожидать от универсального приложения CLI, такие как checkbox
, option selects
и fuzzy selects
.
let items = vec!["Item 1", "item 2"];
let selection = FuzzySelect::with_theme(&ColorfulTheme::default())
.items(&items)
.default(0)
.interact_on_opt(&Term::stderr())?;
match selection {
Some(index) => println!("User selected item : {}", items[index]),
None => println!("User did not select anything")
}
У неё есть одна серьезная проблема — отсутствие тестируемости. То есть, если вы хотите протестировать свой код и он зависит от неё, у вас жесткая зависимость от терминала (ваши тесты будут зависать).
Чего вы могли бы ожидать, так это наличия какого-то средства абстракции ввода-вывода, которое вы сможете внедрять в тесты, программно передавать ему нажатия клавиш и проверять, что они были прочитаны и что были предприняты соответствующие действия.
Другая библиотека - inquire
, но она тоже страдает от отсутствия тестирования, и вы можете увидеть, насколько сложной может быть такая вещь при проблеме, которую я отслеживаю.
Хорошей новостью является то, что у вас есть довольно хорошая работающая с тестированием с гораздо менее популярной библиотекой requestty
, хотя, в любом случае, при внедрении этого уровня абстракции ввода-вывода вы также должны думать о изменяемости и владении:
pub struct Prompt<'a> {
config: &'a Config,
events: Option<TestEvents<IntoIter<KeyEvent>>>,
show_progress: bool,
}
impl<'a> Prompt<'a> {
pub fn build(config: &'a Config, show_progress: bool, events: Option<&RunnerEvents>) -> Self {
events
.and_then(|evs| evs.prompt_events.as_ref())
.map_or_else(
|| Prompt::new(config, show_progress),
|evs| Prompt::with_events(config, evs.clone()),
)
}
...
Зрелище не из приятных, но оно обеспечивает хорошо протестированный поток взаимодействия с CLI.
Какие еще варианты у вас есть для проведения тестирования?
- Доверяйте стабильному состоянию
dialoguer
и просто не тестируйте части взаимодействия вашего приложения - Протестируйте взаимодействие с помощью тестирования “черного ящика” (подробнее о тестировании позже). При таком подходе вы можете довольно далеко продвинуться с точки зрения рентабельности инвестиций в тестирование
- Создайте свою собственную тестовую установку с переключаемым уровнем взаимодействия с пользовательским интерфейсом, где вы полностью заменяете его во время тестирования чем-то, что воспроизводит действие (опять же, ваш собственный пользовательский код). Это означает, что реальный код, работающий с подсказками и выборками, никогда не будет протестирован, каким бы маленьким он ни был
Статус и прогресс
indicatif
- это библиотека золотого стандарта Rust для индикаторов состояния и выполнения. Есть только одна отличная библиотека, и это хорошо, потому что мы не застряли в парадоксе вариантов, так что используйте эту!
Работоспособность
В Rust в основном существует два варианта ведения лога для обеспечения работоспособности, и вы можете использовать оба или один из них:
- Логирование — что стало стандартом: https://lib.rs/crates/log где люди в основном комбинируют его с
env_logger
, который прост и действительно удобен в использовании.
env_logger::init();
info!("starting up");
$ RUST_LOG=INFO ./main[2018-11-03T06:09:06Z INFO default] starting up
- Трассировка — для этого вам нужно использовать один крейт для трассировки и экосистему, которая есть в Rust (к счастью, есть только одна!). Вы можете начать с tracing-tree, но позже вы также можете решить подключить телеметрию и сторонние SDK, а также печатать графики пламени, как и следовало ожидать от инфраструктуры трассировки
server{host="localhost", port=8080}
0ms INFO starting
300ms INFO listening
conn{peer_addr="82.9.9.9", port=42381}
0ms DEBUG connected
300ms DEBUG message received, length=2
conn{peer_addr="8.8.8.8", port=18230}
300ms DEBUG connected
conn{peer_addr="82.9.9.9", port=42381}
600ms WARN weak encryption requested, algo="xor"
901ms DEBUG response sent, length=8
901ms DEBUG disconnected
conn{peer_addr="8.8.8.8", port=18230}
600ms DEBUG message received, length=5
901ms DEBUG response sent, length=8
901ms DEBUG disconnected
1502ms WARN internal error
1502ms INFO exit
Трассировка позволяет вам очень легко настраивать ваш код, украшая функции:
#[tracing::instrument(level = "trace", skip_all, err)]
pub fn is_archive(file: &File, fval: &[String]) -> Result<Option<bool>>
И у вас может быть опция автоматического захвата аргументов, возвращаемых значений и ошибок из функции.
Обработка ошибок
Это большая проблема в Rust. Просто потому, что ошибки прошли большой этап эволюции. Было несколько библиотек, которые появились, а затем вымерли, а затем еще несколько, которые появились и вымерли.
В целом, это был фантастический процесс. В промежутках между каждым циклом экосистема Rust получала реальные уроки и вносились улучшения, и сегодня у нас есть несколько действительно замечательных библиотек.
Недостатком является то, что в зависимости от кода, который вы будете читать, примеры здесь и там и проекты с открытым исходным кодом — вам нужно будет запомнить, какие библиотеки он использует и к какой эпохе библиотек ошибок он принадлежит.
Итак, на момент написания этой статьи, это библиотеки, которые, как я обнаружил, идеально подходят для CLI-приложения. Здесь я также разделяю свое мышление на “ошибки приложений” и “ошибки библиотеки”, где для ошибок библиотеки вы хотите использовать типы ошибок, которые полагаются на стандартную библиотеку, а не заставлять ваших пользователей использовать специализированную библиотеку ошибок.
- Ошибки приложения:
eyre
, который является близким родственникомanyhow
, но имеет действительно отличную историю сообщений об ошибках с такими библиотеками, какcolor-eyre
- Ошибки библиотеки: Раньше я использовал
thiserror
, но затем перешел кsnafu
, который я использую для всего.snafu
дает вам все преимуществаthis-error
, но с эргономикойanyhow
илиeyre
И затем, я использую библиотеки, которые улучшают их отчеты об ошибках. В основном я использую fs_err
вместо std::fs
, который имеет тот же API, но более сложные и понятные для человека ошибки, например:
failed to open file `does not exist.txt`
caused by: The system cannot find the file specified. (os error 2)
Вместо
The system cannot find the file specified. (os error 2)
Тестирование
Я нахожу, что балансировка типов тестов и стратегий в Rust может быть очень деликатной, но полезной задачей. То есть ржавчина безопасна. Это не означает, что ваш код хорошо работает с самого начала, но это означает, что помимо того, что это статически типизированный язык с типами, которые защищают от многих ошибок программирования, он также безопасен в том смысле, что устраняет широкий спектр ошибок программистов, связанных с совместным использованием данных и владением ими.
Я бы осторожно сказал, что мой код Rust содержит меньше тестов по сравнению с моим кодом Ruby или JavaScript и является более надежным.
Я нахожу, что это свойство Rust также в значительной степени возвращает к тестированию чёрного ящика. Потому что после того, как вы протестировали некоторые внутренние компоненты, объединение модулей и их интеграция довольно безопасны благодаря компилятору.
Итак, в целом моя стратегия тестирования приложений Rust CLI такова:
- Модульные тесты — тестирование логики в функциях и модулях
- Интеграционные тесты — по мере необходимости, между модулями и тестирование сложных рабочих процессов взаимодействия
- Тесты черного ящика — использование таких инструментов, как
try_cmd
, для запуска сеанса CLI, предоставления входных данных, получения выходных данных и моментального снимка результирующего состояния для утверждения и сохранения.
Я использую тестирование моментальных снимков там, где могу, потому что в тестах нет смысла кодировать слева направо:
insta
- https://docs.rs/insta/latest/insta, заботится о рабочем процессе разработки, моментальных снимках, просмотре и дополнительных функциях, таких как редактирование и различные форматы сериализации моментальных снимков
#[test]
fn test_simple() {
insta::assert_yaml_snapshot!(calculate_value());
trycmd
- https://lib.rs/crates/trycmd, действительно надежен, хорошо работает и фантастически прост. Вы записываете свои тесты в виде файла markdown, и он выполняет синтаксический анализ, запускает встроенные команды и отслеживает, какие результаты должны быть сопоставлены с результатом в том же файле markdown, так что ваши тесты также являются живой документацией
$ my-cmd
Hello world
Компиляция в двоичный файл
Со временем я сформировал свой рабочий процесс реализации в виде начальных проектов и инструментов, поэтому вместо того, чтобы описывать и демонстрировать, как создать рабочий процесс с нуля, просто используйте эти инструменты и проекты. И если вам интересно — прочтите их код.
Если вы хотите провести здесь время как можно проще, вы можете ознакомиться с этим:
rustwrap
— для компиляции встроенных двоичных файлов из Github в homebrew или npmxtaskops
— для переноса некоторой логики из вашего CI в Rust в виде шаблонаxtask
rust-starter
— для использования готовых рабочих процессов CI для упрощения тестирования, компоновки и компиляции
Если у вас есть правильный двоичный файл Rust, вам также следует рассмотреть возможность компиляции в cargo контейнеры, подробнее об этом смотрите в cargo-binstall
.