自作CLIツールでテンプレート作成をしてみる
この記事は、Flutter 大学アドベントカレンダー 2023 6日目の記事です。
最近仕事先で初めてDart製のCLIツールを作ったのですが、その過程がとても有益で勉強になったため、この機会に記事にまとめておくことにしました。
(追記)
こちらの現場で一緒のmonoさんにはたくさんアドバイスいただき大変お世話になりました🙏
monoさんの方ではFlutter環境でのCLIアプリについて網羅的に書かれているので、併せてぜひお読みください。
読んでみてほしい人
こんな方に読んでもらえたら嬉しいです🙌
- Dartのコンソールアプリってなにという人
- 自作のコンポーネントを複数のリポジトリから使って便利に個人開発したい人
- 日頃からFlutterパッケージ作成を行っている人
今回作るもの
コマンドをターミナル上に入力してアプリを起動することで、pubspec.yamlファイルの更新や作業用ファイルの作成など、パッケージ開発を行う上で必要な処理を一括して行えるようになっています。
このリポジトリの用途
今回CLIツールを導入した現場では、デザインを統一したUIコンポーネントを複数リポジトリから利用したい、という目的で社内用のパッケージ化を進めていました。
詳細ご興味ある方はこちらに過去勉強会のアーカイブがあるのでご覧ください。
この現場のモノレポ構成のリポジトリ内では、毎回Flutterパッケージを新規作成後にいくつか決まって行う必要のある処理があるのですが、この処理が段々と増えてきて煩雑になってきたためこの際自動化しよう、となったのがCLIツールを作ることになった経緯でした。
ただ、この話は上記のようなチーム開発に限ったものではなく、個人の開発者にとっても有用なのかなと思います。
自分がよく使う汎用性の高いソースコードをこのリポジトリ内に置いておき、今後の個人開発の際に別リポジトリから利用するということが出来たらとても便利そうです。
プロジェクト作成
ここからは実際にステップを追って作成していきます。
今回作るサンプルリポジトリを以下に置いておくので、適宜参考にしてください。
まずはいつも通り、
flutter create <プロジェクト名>
でプロジェクトを作成します。
今回のプロジェクトは個人開発の際に使えそうなflutter関連の色んな汎用的なものを詰め込む用なので、flutter_toolkit
としました。
このプロジェクトではfvmでバージョン管理を行うので、以下コマンドでstableバージョンを用いるようにします。
fvm use stable
その後、melosを用いたマルチパッケージ構成にしていきます。
melosについての詳細はこちらの記事が参考になります。
melos導入が初めての場合は以下でインストールし、
dart pub global activate melos
pubspec.yamlにも追加します。
fvm flutter pub add melos
不要なプラットフォーム関連のディレクトリを消して、以下のような構成にします。
.
├── README.md
├── analysis_options.yaml
├── melos.yaml
├── packages // ここにパッケージを追加していく
├── pubspec.lock
└── pubspec.yaml
melos.yaml
には以下を記述します。
# プロジェクト名
name: flutter_toolkit
# 対象を指定
packages:
- packages/**
# flutterバージョンはfvmを参照
sdkPath: '.fvm/flutter_sdk'
# `melos bs`実行時にpubspec.yaml上のdartバージョン表記を一括で設定
command:
bootstrap:
environment:
sdk: ^3.2.0
また、このプロジェクト内では統一してpedantic_mono
をlintとして使いたいため、flutter_lints
から置き換えておきます。
dev_dependencies:
flutter_test:
sdk: flutter
pedantic_mono: any
# https://pub.dev/packages/pedantic_mono
include: package:pedantic_mono/analysis_options.yaml
# 今後ここに独自のルールを追加していくため、このリポジトリ内は全てこのルールで統一したい
linter:
rules:
avoid_classes_with_only_static_members: false
パッケージテンプレートの作成
ここから本題のテンプレートの作成に入ります。
一般にパッケージを作成する際には、
fvm flutter create -t package <パッケージ名>
を実行しますが、作成後にいくつか毎度修正したい箇所が出てきます。
そういった箇所を自動で修正するために、テンプレートを作成していきます。
テンプレート生成用Dartアプリの作成
ターミナル上で起動するDartのコンソールアプリを作成します。
今回はscripts
というディレクトリ配下にbootstrap_package
という名前で作成します。
fvm dart create bootstrap_package
melos.yamlにscripts
ディレクトリを追加しておきます。
# 対象を指定
packages:
- packages/**
- scripts/**
pubspec.yaml内に以下のようにパスを設定することでこのアプリのコマンド実行を端的に行えるようになります。
dev_dependencies:
bootstrap_package:
path: scripts/bootstrap_package
処理の実装
Dartアプリではlibとは別にbinというフォルダがあり、main関数はこちらに置かれるのですが、今回は以下のようにして主要な処理をlibフォルダ内のruCommand
関数で行うようにします。
import 'package:bootstrap_package/run_command.dart';
/// 新規パッケージ作成用の関数
///
/// プロジェクトのルートディレクトリから以下のコマンドにより実行する。
/// `fvm dart run bootstrap_package <パッケージ名>`
/// 実際の処理内容は[runCommand]に記述する。
void main(List<String> args) => runCommand(args);
runCommand
ちょっと長くなってしまいますが、runCommand関数は以下のようになっています。
import 'dart:io';
import 'package:args/args.dart';
import 'package:bootstrap_package/overwrite_licence_file.dart';
import 'package:bootstrap_package/show_usage.dart';
import 'package:path/path.dart' as path;
import 'create_working_file.dart';
import 'overwrite_pubspec_yaml_file.dart';
import 'overwrite_test_file.dart';
/// コマンド実行用関数
void runCommand(List<String> args) {
try {
// 引数を定義
final parser = ArgParser()
..addOption(
'description',
abbr: 'd',
)
..addFlag(
'help',
abbr: 'h',
negatable: false,
);
final parsedArgs = parser.parse(args);
// helpオプションが指定された場合、使い方を表示して処理を終了
final shouldHelp = parsedArgs['help'] as bool;
if (shouldHelp) {
showUsage();
return;
}
// パッケージ名が入力されていない場合、エラー文を表示して処理を終了
final name = parsedArgs.rest.firstOrNull;
if (name == null) {
showUsage(errorMessage: 'パッケージ名を指定してください。');
return;
}
// packagesディレクトリへ移動
Directory.current = Directory('packages');
// パッケージ用のプロジェクトを作成
Process.runSync(
'fvm',
['flutter', 'create', '-t', 'package', name],
);
// 作成されたパッケージへ移動
Directory.current = Directory(name);
// 作業用ファイルを作成
createWorkingFile(packageName: name);
// analysis_options.yamlを削除し、プロジェクトルートのものをsymbolic linkで追加
final analysisOptionsFile = File('analysis_options.yaml')..deleteSync();
Link(analysisOptionsFile.path)
.createSync(path.join('../..', analysisOptionsFile.path));
// testファイルを上書き
overwriteTestFile(packageName: name);
// パッケージ説明が引数として指定されていない場合、パッケージ名から作成
var description = parsedArgs['description'] as String?;
description ??= '$name用Flutterパッケージ';
// pubspec.yamlファイルを上書き
overwritePubspecYamlFile(packageName: name, description: description);
// LICENSEファイル削除し、プロジェクトルートのものをsymbolic linkで追加
final licenseFile = File('LICENSE')..deleteSync();
Link(licenseFile.path).createSync(path.join('../..', licenseFile.path));
// READMEファイルをパッケージ名のみに上書き
final packageTitle = '# $name';
File('README.md').writeAsStringSync(packageTitle);
} on FormatException catch (_) {
// '-d'のようなoptionコマンドに続く引数が入力されていない場合
showUsage(errorMessage: 'オプションコマンドの使い方が間違っています。');
}
}
ここから要所要所を解説していきます。もし不明点等ありましたら遠慮なくコメントいただけばと思います🙏
引数の設定
こちらでargsパッケージを用いてコマンド実行時の引数を設定しています。
// 引数を定義
final parser = ArgParser()
..addOption(
'description',
abbr: 'd',
)
..addFlag(
'help',
abbr: 'h',
negatable: false,
);
final parsedArgs = parser.parse(args);
// helpオプションが指定された場合、使い方を表示して処理を終了
final shouldHelp = parsedArgs['help'] as bool;
if (shouldHelp) {
showUsage();
return;
}
fvm dart run bootstrap_package --help
の実行時には、showUsage
により使い方を表示するようにしています。
import 'dart:io';
/// 使い方をターミナル上に表示するための関数
///
/// helpオプションが指定された時や誤った使い方がされた時に用いる。
/// 誤った使い方がされた場合、[exitCode]を1にして[errorMessage]を表示する。
void showUsage({String? errorMessage}) {
if (errorMessage != null) {
exitCode = 1;
// ターミナル上にエラーを出力する関数
stderr.writeln('[ERROR] $errorMessage');
}
const usage = '''
Usage: fvm dart run bootstrap_package <パッケージ名> [options]
Options:
-d, --description <パッケージ説明> パッケージの説明を指定
-h, --help 使い方を表示
Example:
fvm dart run bootstrap_package login_form -d "ログインフォーム用Flutterパッケージ"
''';
// ターミナル上に出力
stdout.writeln(usage);
}
これにより、いつも見るようなhelpっぽいものを表示出来るようになります🎉
また、
fvm dart run bootstrap_package <パッケージ名> -d <パッケージ説明>
のように実行することで、後ほど紹介するoverwritePubspecYamlFile
関数にパッケージ説明を渡すことが出来ます。
// パッケージ説明が引数として指定されていない場合、パッケージ名から作成
var description = parsedArgs['description'] as String?;
description ??= '$name用Flutterパッケージ';
overwritePubspecYamlFile(packageName: name, description: description);
余談ですが、さらに必要な情報が増えてきて複数引数を渡したくなってきた場合にはcli_dialogやinteractのようなパッケージを用いて対話形式のインターフェースにするとよりCLIっぽくなって体験が良さそうです。
作業用ファイルの作成
こちらの例のように、libフォルダ直下にはexport文のみを記載したファイルを置き実装自体はsrcフォルダ内で行う、というのが一般的なようなので、その場所を用意します。
import 'dart:io';
import 'package:path/path.dart' as path;
/// 作業用ファイルを作成するための関数
///
/// srcディレクトリへファイルを作成し、lib直下のファイルにそのpathへのexport文を記載する。
void createWorkingFile({required String packageName}) {
// srcディレクトリへファイルを作成
final baseFileName = '$packageName.dart';
File(path.join('lib/src', baseFileName)).createSync(recursive: true);
// lib直下のファイルへexport文を記載
final exportStatement = '''
export 'src/$baseFileName';
''';
File(path.join('lib', baseFileName)).writeAsStringSync(exportStatement);
}
複数パスを組み合わせている箇所ではpathパッケージを用いています。
Fileクラスにパスを指定して、writeAsStringSync
にてファイルを作成 or (既に存在するlib直下ファイルは)上書きします。
尚、非同期的に扱うwriteAsString
メソッドもありますが、ここではその必要もなく同期的な方が扱いやすいのでwriteAsStringSync
を用いています。
lint設定
// analysis_options.yamlを削除し、プロジェクトルートのものをsymbolic linkで追加
final analysisOptionsFile = File('analysis_options.yaml')..deleteSync();
Link(analysisOptionsFile.path)
.createSync(path.join('../..', analysisOptionsFile.path));
先ほど作ったプロジェクト全体のanalysis_options.yaml
を用いるようにしたいので、そちらへのシンボリックを作成します。
尚、単に自身のanalysis_options.yaml
を削除するだけでも基本的にはトップディレクトリのものを参照してくれますが、時々参照されないバグ的な挙動が発生するようなのでシンボリックリンクを作っておいた方がベターかなと思います。
testファイルの上書き
import 'dart:io';
import 'package:path/path.dart' as path;
/// テスト用ファイルを上書き作成するための関数
///
/// プロジェクト作成段階のテストファイルに含まれているサンプル用のクラスが
/// その後の処理により削除されているため、テスト内容を空にした状態に修正する。
void overwriteTestFile({required String packageName}) {
final content = '''
import 'package:flutter_test/flutter_test.dart';
void main() {
test('$packageName test', () {});
}
''';
File(path.join('test', '${packageName}_test.dart'))
.writeAsStringSync(content);
}
テストファイル内には、
// パッケージ用のプロジェクトを作成
Process.runSync(
'fvm',
['flutter', 'create', '-t', 'package', name],
);
の直後にはlib直下のファイルに作られているサンプル用のクラス(Caliculator
クラス)を用いたテストが書いてあるのですが、createWorkingFile
によりこのクラスを削除してしまっているため、処理の中身を空にしておきます。
pubspec.yamlの上書き
コメントにあるようにバージョン表記やフィールドの追加などを行っています。
import 'dart:io';
import 'package:pub_semver/pub_semver.dart';
/// pubspec.yamlファイルを上書き作成するための関数
///
/// プロジェクト作成段階から以下の箇所を修正:
/// - sdkバージョンはmelos.yaml同様に最新stableをキャレット記号にて記述
/// - flutterバージョンは削除
/// - flutter_lintsをpedantic_monoへ置き換え
/// - 不要なhomepageフィールドの削除
/// - 不要なコメントの削除
/// - 意図しない配信を避けるためpublish_toフィールドを追加
void overwritePubspecYamlFile({
required String packageName,
required String description,
}) {
final dartVersion = _getDartCaretVersion();
final content = '''
name: $packageName
description: $description
publish_to: 'none'
version: 0.0.1
environment:
sdk: $dartVersion
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
pedantic_mono: any
flutter:
''';
File('pubspec.yaml').writeAsStringSync(content);
}
/// pubspec.yamlに記載するdart sdkのバージョンを取得する関数
///
/// FVMのdart versionを取得し、そのバージョンをキャレット記号にて記述する。
/// 尚、pubspec.yamlファイル作成後に`melos bs`コマンドを実行しても同様の結果になるが、
/// コマンドの実行時間削減のためにこのように実装している。
String _getDartCaretVersion() {
final ProcessResult versionResult;
versionResult = Process.runSync('fvm', ['dart', '--version']);
final versionOutput = versionResult.stdout as String;
final versionMatch =
RegExp(r'Dart SDK version: (\d+\.\d+\.\d+)').firstMatch(versionOutput);
final versionString = versionMatch?.group(1);
final version = Version.parse(versionString!);
// melos.yamlの記述内容と揃えるために、patchバージョンは0としておく
return '^${version.major}.${version.minor}.0';
}
ライセンスファイルを作成
// LICENSEファイル削除し、プロジェクトルートのものをsymbolic linkで追加
final licenseFile = File('LICENSE')..deleteSync();
Link(licenseFile.path).createSync(path.join('../..', licenseFile.path));
プライベートなリポジトリであればこのファイルは削除すれば良いですが、公にするものであればライセンスも書いておいた方が良さそうです。
プロジェクトのルートに以下のようなライセンスファイルを作成し、それを全てのパッケージで参照するようにしておきます。
MIT License
Copyright (c) 2023 Masaki Sato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
作成したパッケージの使い方
作成したパッケージはリポジトリ内とリポジトリ外から参照することが出来ます。
リポジトリ内からの参照
pubspec.yamlにパッケージ名を追記し、melos bs
を実行します。
dependencies:
{パッケージ名}:
すると、参照先を示すpubspec_overrides.yaml
というファイルが自動生成されます。
# melos_managed_dependency_overrides: {パッケージ名}
dependency_overrides:
{パッケージ名}:
path: ../../packages/{パッケージ名}
実装例では、examples
というディレクトリ内にサンプルアプリを用意しており、その中で実際に作成したパッケージを利用しています。
リポジトリ外からの参照
flutter_toolkit
リポジトリ外からパッケージを参照したい場合は、以下のようにpubspec.yaml
に追加します。
dependencies:
{パッケージ名}:
git:
url: git://github.com/masa-tokyo/flutter_toolkit.git
path: packages/{パッケージ名}
# 特定コミットに対するタグを指定
ref: {パッケージ名}/v1.0.0
ref
の記述は無くても参照可能ですが、元のパッケージの変更(破壊的変更など想定外の事態が起こりうる)が常時反映されてしまわないようにタグ管理した方がベターかなと思います。
実装例は、こちらに作った別リポジトリをご覧ください。
外部リポジトリから参照するタグの作成には、リリースしたい時点のコミット上で以下のコマンドを実行します。
git tag -a {パッケージ名}/v1.0.0 -m 'release {パッケージ名}/v1.0.0'
git push origin {パッケージ名}/v1.0.0
Gitタグやリリースの詳細についてはこちらの記事が参考になります。
ちなみに、flutterfireやriverpodなどの主要なパッケージを見てみると、あまりリリースドキュメントまでは書いていないようでした。マルチパッケージ構成のリポジトリにおいては、各パッケージ内のCHANGELOGの内容のみでリリース情報は管理する、という方針が良いのかもしれません。
最後に
ここまでお読みいただきありがとうございました。
今回ご紹介した話はチームでも個人でも使えるものだと思うので、少しでも参考になりましたら幸いです。
最後に少しだけ告知をさせてください...!
FlutterGakkaiという勉強会(途中で貼っていたリンクは過去回のものです)を2024年1月末にオンライン/オフライン同時開催します。
今回もとても素敵な方々に発表いただくため、ぜひ来ていただけると嬉しいです🙌
Discussion