この記事について
ターミナルアプリからコマンドひとつで呼び出して実行できる「コマンドラインツール」をDartで作成します。
ソースコードはこちら。
作成するコマンドラインツールの機能について
- コマンドとして
mycli
など任意の文字列を設定できます - テンプレートから
.md
ファイルを作成してPCに保存します
今回は会議の議事録を想定した.md
ファイルを作成しますが、.txt
や.puml
など、テキスト系ファイルであれば微調整で実装可能です。 - オプションを利用して引数を渡すことができます
(下記例では-t
がオプションでtitle.md
が引数です)mycli -t title.md
開発環境
OS: macOS 12.6
dart: 2.18.6
Dartのインストールはこちらを参照してください。
今回はmacOSで解説していますので、WindowsやLinuxの場合はコードの一部を変更してください。
1. プロジェクト作成と準備
1.1 プロジェクト作成
- 任意のディレクトリでプロジェクト作成コマンド
dart create
を実行dart create -t console-simple dart_simple_cli
-
-t
オプションにconsole-simple
を指定し、コマンドラインツール用テンプレートを利用しています。
(その他のオプションについてはこちらを参照)
1.2 パッケージのインストール
- インストールするパッケージ
package | 用途 |
---|---|
args | コマンドラインツールのオプションやフラグの設定などができる便利メソッドを利用 |
path | ファイル名まわりの文字列操作をするメソッドを利用 |
intl | 日時の取得やフォーマットをするメソッドを利用 |
- 1.1で作成したプロジェクトのディレクトリで1行ずつ実行
dart pub add args dart pub add path dart pub add intl
-
pubspec.yaml
のdependencies
にパッケージが追記されているのを確認pubspec.yamlname: dart_simple_cli description: A simple command-line application. version: 1.0.0 ...中略... dependencies: args: ^2.3.2 path: ^1.8.3 intl: ^0.18.0
- 今回は実装しませんが、APIなどを実行する場合はhttpなどのパッケージが利用可能です。
2. メソッド作成
- Dartプロジェクトの実行部にあたる
bin/dart_simple_cli.dart
のmain()
の前に、main()
内で利用するメソッドを先に作成します。 - 作成するメソッドは以下の2つです
メソッド 説明 getHomeDir()
macOSの Users/username/
にあたるディレクトリのパスを返す
macOS, Windows, Linuxそれぞれに対応したパスを取得するのにdart:io
を利用しています。getMinutesTemplate()
出力する議事録 .md
ファイルのテンプレートをString
で返す
今回は固定値を返すので関数化のメリットは小さいですが、動的に戻り値を生成するなど適宜実装してください。
import 'dart:io';
void main() {}
// ---------- ホームディレクトリのパス取得 ----------
String getHomeDir() {
// 出力用文字列
late String home;
// 実行環境によるの変数の取得
Map<String, String> envVars = Platform.environment;
// OSで分岐してホームディレクトリを取得
if (Platform.isMacOS) {
home = envVars['HOME']!;
} else if (Platform.isLinux) {
home = envVars['HOME']!;
} else if (Platform.isWindows) {
home = envVars['UserProfile']!;
}
// 出力
return '$home/';
}
// ---------- テンプレート文字列出力 ----------
String getMinutesTemplate() {
return '''## 場所
-
## 参加者
-
## アジェンダ
-
## 議事録
-
''';
}
3. main()
の実装
3.1 オプションの設定
- プロジェクトをコマンドラインツールとして利用する際に、オプションやフラグ、引数は全て
main()
にList<String>
型として渡されます。
例えば、下のように実行すると、['--option', 'value']
という配列がmain()
に渡されます。dart bin/dart_simple_cli.dart --option value
- このままではコーディング時もコマンド利用時にも勝手が良くないため、argsの
ArgPerser
クラスを使い、フラグやオプションとその引数を簡単に、そして正しく抽出できるようにします。 -
ArgPerser
クラスのaddOption()
メソッドでオプションを設定を追加します。
下記コード内1つ目のperser.addOption()
の設定内容は以下の通りです引数と値 説明 'output'
コマンドラインツールに --output
オプションを追加abbr: 'o'
--output
の省略形として-o
を設定help: '..略..'
--help
実行時のヘルプメッセージの設定valueHelp: '..略..'
--help
実行時の引数の入力例を設定 - 引数を持たない
--help
などはaddFlag()
を利用して、フラグとして追加します。
一番下のparser.addFlag()
の設定内容は以下の通り。引数と値 説明 'help'
--help
フラグを追加abbr: 'h'
--help
の省略形として-h
を設定hide: true
ヘルプメッセージに表示されないよう設定
import 'dart:io';
import 'package:path/path.dart'; // <- 追加
import 'package:args/args.dart'; // <- 追加
void main(List<String> args) {
// ---------- 引数パース ----------
final ArgParser parser = ArgParser();
// オプション:出力ファイルのパス指定
parser.addOption(
'output',
abbr: 'o',
help: 'Define output filename and path',
valueHelp: '/path/to/file.md',
);
// オプション:出力ファイル名指定
parser.addOption('title',
abbr: "t",
help: 'Define output filename (won\'t work with \'--output\' option)',
valueHelp: 'file.md',
);
// オプション:出力ディレクトリ指定
parser.addOption(
'directory',
abbr: "d",
help: 'Difile output directory (won\'t work with \'--output\' option)',
valueHelp: '/path/to/directory/',
);
// フラグ:ヘルプ表示
parser.addFlag('help', abbr: 'h', hide: true);
// 因数取得
final ArgResults results = parser.parse(args);
}
- 今回は下記の3つの想定でオプションを実装しています。
オプション 説明 --output
出力するパスをファイル名込みで渡すオプション --title
出力するファイル名を渡すオプション ( --output
と併用不可)--directory
出力するディレクトリパスを渡すオプション ( --output
と併用不可)
ここまでで、受け取った引数をコード内で利用する準備ができました。
3.2 ヘルプメッセージの実装 - フラグを使用
-
--help
のオプションで実行時に、ヘルプメッセージを表示する実装をします。
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart';
void main(List<String> args) {
// ...中略...
// ---------- ヘルプの表示 ----------
if (results['help']) {
print(parser.usage);
return; // 早期リターン
}
}
3.3 ファイル生成の準備 - ファイル名と出力パスの初期化
この項の下で解説の通り、出力するファイル名にはyyyy-MM-dd_01.md
のように動的に連番をつける場合がある都合上、ファイル名は柔軟に生成や変更ができる方が便利です。そのためにパスはファイル名とディレクトリに、そしてファイル名は拡張子とそれ以外の部分にそれぞれ分割して格納しています。
定数名・変数名 | 説明 |
---|---|
String timestamp |
ファイル名に使用する日付 |
String desktopPath |
デフォルトのファイル出力先 |
String fileName |
出力ファイル名 |
String fileExtention |
出力ファイルの拡張子 |
String fileNameRaw |
出力ファイルの拡張子以前の部分 |
String fileDir |
出力するディレクトリのパス |
String filePath |
出力するディレクトリとファイル名を合わせたパス |
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart';
import 'package:intl/intl.dart'; // <- 追加
void main(List<String> args) {
// ...中略...
// ---------- 変数、定数 ----------
// 年月日文字列
final DateTime now = DateTime.now();
final DateFormat formatter = DateFormat('yyyy-MM-dd');
final String timestamp = formatter.format(now);
// ホームとデスクトップのディレクトリパス
final String homePath = getHomeDir();
final String desktopPath = "$homePath/Desktop/";
// 出力ファイル名 (値は下行で決定するため、ここではlateで遅延初期化まで)
late String fileName;
// ファイル拡張子
late String fileExtention = extension(fileName) == '' ? '.md' : extension(fileName); // ファイル拡張子
late String fileNameRaw = basenameWithoutExtension(fileName); // ファイル名の拡張子以前の部分
late String fileDir; // 出力ディレクトリパス
late String filePath; // 出力ファイルパス
int fileIndex = 1; // ファイル名の連番用
}
- 出力ファイルのパスのデフォルトは
Users/username/Desktop/yyyy-MM-dd.md
- デスクトップのパスはOSにより異なるため、他OS対応する場合は、
desktopPath
を適宜変更。 - 最終的な出力ファイル名やパスはオプションによって決定するため、ここでは
late
で遅延初期化をします。
遅延初期化について詳しくはこちら (英語) - 意図せず既存のファイルを上書きしないように、ファイル名の重複時には
yyyy-MM-dd_01.md
のように末尾に連番を足す処理を実装しています。最下部のint fileIndex
はその際に利用します。
3.4 出力パスの決定 - オプションの利用
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart';
import 'package:intl/intl.dart';
void main(List<String> args) {
// ...中略...
// ---------- 出力パスの決定 ----------
// オプションで分岐して、ファイル名と出力ディレクトリパスの決定
// outputオプションで出力パスを指定
if (results['output'] != null) {
// 引数の出力パスをディレクトリとファイル名に分解
final filePathArray = results['output'].toString().split('/');
fileName = filePathArray.removeLast();
fileDir = '${filePathArray.join('/')}/';
} else {
// titleオプションでファイル名指定
if (results['title'] != null) {
fileName = '$timestamp' '_' '${results['title']}.md';
} else {
fileName = '$timestamp.md';
}
// directoryオプションでディレクトリ指定
if (results['directory'] != null) {
fileDir = results['directory'];
// スラッシュの補完
if (fileDir[fileDir.length - 1] != '/') fileDir += '/';
} else {
fileDir = desktopPath;
}
}
// ファイルパスの決定
filePath = fileDir + fileNameRaw + fileExtention;
}
3.5 ファイルの生成
テンプレートとファイルのパスが決定したので、File
クラスのcreate()
でファイルを生成することが出来ますが、その際に注意が必要です。出力するファイルと同名のファイルがすでに存在する場合、create()
メソッドはファイルを上書きしてしまいます。
そのためここでは、既存のファイルに同名がないかを確認して、あった場合はファイルに連番を付けて上書きを回避する処理をしています。
- ファイルの生成や編集は非同期で行われるため、
main()
もasync
キーワードを付けて、処理の待機ができるようにします。 - 出力するファイル名が決定し、実際にファイル生成するまで
while
ループを回します。 - ループの最初に
File(filePath).exists()
で同名のファイルがないかを取得します。
exists()
の戻り値によって以下のように分岐します。exits()
処理 true
fileIndex
をインクリメントして、連番付filePath
を再定義false
filePath
にファイルを生成し、writeAsString()
メソッドでテンプレートの文字列をファイルに書き込む
完了したらcreated = true
でループを終了
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart';
import 'package:intl/intl.dart';
Future<void> main(List<String> args) async { // <- Futureに変更し、asyncを追加
// ...中略...
// ---------- ファイル名に連番をつけて重複を防ぐ処理 ----------
bool created = false; // ファイル生成が完了したかチェック
while (!created) {
// すでに同名のファイルが存在する場合はindexをインクリメント
if (await File(filePath).exists()) {
// インクリメント
fileIndex++;
// indexを2桁のStringにする
final String fileIndexStr = fileIndex.toString().padLeft(2, '0');
// ファイル名とパスの再決定
filePath = '$fileDir${fileNameRaw}_$fileIndexStr$fileExtention';
} else {
// 同名のファイルがなければ生成して、ループ終了
await File(filePath).create().then((file) async {
await file.writeAsString(getMinutesTemplate()).then((_) => created = true);
});
}
}
}
これで.dart
ファイルの実装は完成しました。
4. コマンドラインツールとしてアクティベート
作成したプロジェクトは、下記のようにdart
コマンドで実行することができますが、毎回ファイルのパスを指定する必要があり、このままでは不便です。
$ dart path/to/dart_simple_cli/bin/dart_simple_cli.dart // <- 毎回は面倒
コマンドラインツールとしてアクティベートして、mycli
のように任意の文字列をコマンドとして設定することができます。
$ mycli // <- これだけ!
4.1 コマンドとスクリプトの割り当て
今回はmycli
というコマンドでdart_simple_cli.dart
を実行できるようにします。
-
pubspec.yaml
にexecutables
を追加pubspec.yamlexecutables: mycli: dart_simple_cli.dart
- コマンドのアクティベート
pubspec.yaml
に追記したコマンドをアクティベートします。dart pub global activate --source path .
4.2 動作確認
これでコマンドラインツールの作成とアクティベートが完了です!
実際にご利用のターミナルアプリからコマンドを実行して、ファイルが生成されることを確認してください。
ファイルが上書きされないこと、それぞれのオプションが期待通りに動作していること、テンプレートが正しく適用されていることもあわせて確認しましょう。
mycli // <- デスクトップにyyyy-MM-dd.mdを生成
mycli // <- デスクトップにyyyy-MM-dd_02.mdを生成
mycli -o /Users/username/Download/test.md // <- Downloadにtest.mdを生成
mycli -t test02.md -d /Users/username/Documents // <- Documentsにtest02.mdを生成
.dart
ファイルを変更するたびに再アクティベートが必要なので、注意してください。
解説は以上です!
おわりに
Dart以外の言語でもコマンドツールを作成する方法はたくさんあると思いますが、Flutterに慣れてる開発者にとっては文法やパッケージの知識がそのまま使えるので、便利そうだと感じました。
Dartのインストールが必要な分、実行環境を選ぶデメリットはありますが、自分や仲間用にちょっとした処理の自動化をする用途では色々と試してみる価値がありそうです!
ご覧いただきありがとうございました。
質問やコメントなどあれば、いつでもお待ちしております^ ^
参考記事