3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Dartで業務自動化コマンドラインツールを作成する

Last updated at Posted at 2023-01-30

Untitled.png

この記事について

ターミナルアプリからコマンドひとつで呼び出して実行できる「コマンドラインツール」を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.yamldependenciesにパッケージが追記されているのを確認
    pubspec.yaml
    name: 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.dartmain()の前に、main()内で利用するメソッドを先に作成します。
  • 作成するメソッドは以下の2つです
    メソッド 説明
    getHomeDir() macOSのUsers/username/にあたるディレクトリのパスを返す
    macOS, Windows, Linuxそれぞれに対応したパスを取得するのにdart:ioを利用しています。
    getMinutesTemplate() 出力する議事録.mdファイルのテンプレートをStringで返す
    今回は固定値を返すので関数化のメリットは小さいですが、動的に戻り値を生成するなど適宜実装してください。
bin/dart_simple_cli.dart
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
    
  • このままではコーディング時もコマンド利用時にも勝手が良くないため、argsArgPerserクラスを使い、フラグやオプションとその引数を簡単に、そして正しく抽出できるようにします。
  • 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 ヘルプメッセージに表示されないよう設定
bin/dart_simple_cli.dart
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のオプションで実行時に、ヘルプメッセージを表示する実装をします。
bin/dart_simple_cli.dart
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 出力するディレクトリとファイル名を合わせたパス
bin/dart_simple_cli.dart
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 出力パスの決定 - オプションの利用

bin/dart_simple_cli.dart
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でループを終了
bin/dart_simple_cli.dart
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.yamlexecutablesを追加
    pubspec.yaml
    executables:
      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のインストールが必要な分、実行環境を選ぶデメリットはありますが、自分や仲間用にちょっとした処理の自動化をする用途では色々と試してみる価値がありそうです!

ご覧いただきありがとうございました。
質問やコメントなどあれば、いつでもお待ちしております^ ^

参考記事

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?