Construire des applications CLI en Dart
Contenu
- Configuration du projet et architecture
- Analyse des arguments et routage des commandes
- Exécution et gestion des erreurs
- Tests des applications CLI
- Compilation et distribution
- Workflows
- Exemples
Configuration du projet et architecture
Initialisez les nouveaux projets CLI en utilisant le template officiel Dart pour garantir des structures de répertoires standard.
- Exécutez
dart create -t cli <project_name>pour générer une application console avec analyse basique des arguments. - Placez les points d'entrée exécutables (fichiers contenant
main()) exclusivement dans le répertoirebin/. - Placez la logique d'implémentation interne dans
lib/src/et exposez les APIs publiques vialib/<project_name>.dart. - Imposez le formatage dans les environnements CI en exécutant
dart format . --set-exit-if-changed. Cela retourne le code de sortie 1 s'il existe des violations de formatage.
Analyse des arguments et routage des commandes
Importez le package args pour gérer les arguments de ligne de commande, les flags et les sous-commandes.
- Si vous construisez un script simple : Utilisez
ArgParserdirectement pour définir les flags (addFlag) et les options (addOption). - Si vous construisez une CLI multi-commandes complexe (comme
git) : ImplémentezCommandRunneret étendezCommandpour chaque sous-commande. - Définissez les arguments globaux sur
CommandRunner.argParseret les arguments spécifiques à une commande sur leCommand.argParserindividuel. - Capturez
UsageExceptionpour gérer gracieusement les arguments invalides et afficher le texte d'aide généré automatiquement. - Validez l'exactitude du texte d'aide : Assurez-vous que le texte d'aide fournit toutes les informations nécessaires pour exécuter l'outil. Si le texte d'aide référence un nom d'exécutable compilé et que l'utilisateur doit l'ajouter à son PATH pour l'exécuter de cette façon, fournissez des instructions claires sur comment le faire dans le texte d'aide ou la description.
Exécution et gestion des erreurs
Exploitez les packages io et stack_trace pour construire des outils CLI robustes et prêts pour la production.
- Utilisez l'enum
ExitCodedu packageiopour retourner les codes de sortie POSIX standard (par exemple,ExitCode.success.code,ExitCode.usage.code). - Utilisez
sharedStdIndu packageiosi plusieurs écouteurs asynchrones ont besoin d'un accès séquentiel à l'entrée standard. - Encapsulez l'exécution de l'application dans
Chain.capture()du packagestack_tracepour tracer les chaînes de pile asynchrones. - Formatez les traces de pile de sortie en utilisant
Trace.terseouChain.tersepour supprimer les frames de la bibliothèque centrale bruyants et présenter des erreurs lisibles à l'utilisateur. - Ne supprimez pas les exceptions dans la logique de bas niveau ou les classes de stockage à moins qu'une récupération soit possible. Laissez-les remonter ou relancez-les pour que les commandes de haut niveau sachent que les opérations ont échoué.
- Échouez rapidement avec des codes de sortie non nuls : Assurez-vous que les défaillances d'opération entraînent des messages d'erreur descriptifs vers
stderret les codes de sortie non nuls appropriés (par exemple, en utilisantexit(1)ou en déclenchant un code de sortie 64 après uneUsageExceptioncapturée).
Tests des applications CLI
[!IMPORTANT] Toutes les nouvelles commandes et fonctionnalités significatives doivent être couvertes par des tests automatisés. La vérification manuelle ne suffit pas pour tester la logique. Cependant, la vérification manuelle du texte d'aide et de l'expérience utilisateur (UX) est toujours requise pour garantir que l'interface est intuitive et correcte.
Utilisez test_process et test_descriptor pour écrire des tests d'intégration haute-fidélité pour votre CLI.
- Définissez les états du système de fichiers attendus en utilisant
test_descriptor(d.dir,d.file). - Créez le système de fichiers simulé avant l'exécution en utilisant
await d.Descriptor.create(). - Lancez le processus CLI en utilisant
TestProcess.start('dart', ['run', 'bin/cli.dart', ...args]). - Validez les flux de sortie standard et d'erreur en utilisant les matchers
StreamQueue(par exemple,emitsThrough,emits). - Affirmez le code de sortie final en utilisant
await process.shouldExit(0). - Validez les mutations du système de fichiers résultantes en utilisant
await d.Descriptor.validate().
Compilation et distribution
Sélectionnez la cible de compilation appropriée en fonction de vos exigences de distribution.
- Si vous testez localement pendant le développement : Utilisez
dart run bin/cli.dart. Cela utilise le compilateur JIT pour une itération rapide. - Si vous regroupez des actifs de code et des bibliothèques dynamiques : Utilisez
dart build cli. Cela exécute les hooks de build et produit une sortie dansbuild/cli/_/bundle/. - Si vous distribuez un exécutable natif autonome : Utilisez
dart compile exe bin/cli.dart -o <output_path>. Cela regroupe le runtime Dart et le code machine dans un seul fichier. - Si vous distribuez plusieurs applications avec des limites strictes d'espace disque : Utilisez
dart compile aot-snapshot bin/cli.dart. Exécutez le fichier.aotrésultant en utilisantdartaotruntime.
<details> <summary>Cibles de compilation croisée (Linux uniquement)</summary>
Dart supporte la compilation croisée vers Linux à partir d'hôtes macOS, Windows ou Linux.
Utilisez les flags --target-os et --target-arch avec dart compile exe ou dart compile aot-snapshot.
--target-os=linux(Seul Linux est actuellement supporté comme cible de compilation croisée)--target-arch=arm64(ARM 64 bits)--target-arch=x64(x86-64)--target-arch=arm(ARM 32 bits)--target-arch=riscv64(RISC-V 64 bits)
Exemple : dart compile exe --target-os=linux --target-arch=arm64 bin/cli.dart
</details>
Workflows
Progression de la tâche : Implémenter une nouvelle commande CLI
- [ ] Créez une nouvelle classe étendant
Commanddanslib/src/commands/. - [ ] Définissez les propriétés
nameetdescription. - [ ] Enregistrez les flags spécifiques à la commande dans le constructeur en utilisant
argParser.addFlag()ouargParser.addOption(). - [ ] Implémentez la méthode
run()avec la logique principale. - [ ] Enregistrez la nouvelle commande dans l'instance
CommandRunnerdansbin/cli.darten utilisantaddCommand(). - [ ] Créez les tests pour la nouvelle commande dans le répertoire
test/en utilisanttest_processou les tests standard. - [ ] Exécutez le validateur -> Exécutez
dart run bin/cli.dart help <command_name>pour vérifier la génération du texte d'aide. - [ ] Vérifiez l'UX final : Compilez l'application en utilisant
dart compile exeet exécutez l'exécutable résultant pour vérifier l'expérience utilisateur cible (par exemple,./bin/cli <command>).
Progression de la tâche : Compiler et publier un exécutable natif
- [ ] Exécutez le validateur -> Exécutez
dart format . --set-exit-if-changedpour assurer le formatage du code. - [ ] Exécutez le validateur -> Exécutez
dart analyzepour assurer l'absence d'erreurs d'analyse statique. - [ ] Exécutez le validateur -> Exécutez
dart testpour réussir tous les tests d'intégration. - [ ] Compilez pour l'OS hôte :
dart compile exe bin/cli.dart -o build/cli-host - [ ] Compilez pour Linux (si l'hôte est macOS/Windows) :
dart compile exe --target-os=linux --target-arch=x64 bin/cli.dart -o build/cli-linux-x64
Exemples
Exemple : Implémentation de CommandRunner
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:stack_trace/stack_trace.dart';
class CommitCommand extends Command {
@override
final String name = 'commit';
@override
final String description = 'Record changes to the repository.';
CommitCommand() {
argParser.addFlag('all', abbr: 'a', help: 'Commit all changed files.');
}
@override
Future<void> run() async {
final commitAll = argResults?['all'] as bool? ?? false;
print('Committing... (All: $commitAll)');
}
}
void main(List<String> args) {
Chain.capture(() async {
final runner = CommandRunner('dgit', 'Distributed version control.')
..addCommand(CommitCommand());
await runner.run(args);
}, onError: (error, chain) {
if (error is UsageException) {
stderr.writeln(error.message);
stderr.writeln(error.usage);
exit(64); // ExitCode.usage.code
} else {
stderr.writeln('Fatal error: $error');
stderr.writeln(chain.terse);
exit(1);
}
});
}
Exemple : Tests d'intégration avec des sous-processus
import 'package:test/test.dart';
import 'package:test_process/test_process.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
void main() {
test('CLI formats output correctly and modifies filesystem', () async {
// 1. Setup mock filesystem
await d.dir('project', [
d.file('config.json', '{"key": "value"}')
]).create();
// 2. Spawn the CLI process
final process = await TestProcess.start(
'dart',
['run', 'bin/cli.dart', 'process', '--path', '${d.sandbox}/project']
);
// 3. Validate stdout stream
await expectLater(process.stdout, emitsThrough('Processing complete.'));
// 4. Validate exit code
await process.shouldExit(0);
// 5. Validate filesystem mutations
await d.dir('project', [
d.file('config.json', '{"key": "value"}'),
d.file('output.log', 'Success')
]).validate();
});
}