Dart: プロジェクト設計と開発 — DataPipeline CLI ツール
最終更新:2026-08-26
真のエンジニアはコードを書くだけではない。コードが成長できるアーキテクチャを設計するのだ。 — Bob
1. 学べること
- プロジェクトアーキテクチャ設計:レイヤード / モジュール分割 / 依存性注入
- CLI 引数解析(args パッケージ)とサブコマンド設計
- データソース抽象化(Sealed クラス)+ マルチフォーマットパーサー
- 非同期パイプライン設計(Stream + Isolate 並列処理)
- Charlie のコードレビュー:コード品質とパターンマッチングの実践
2. 開発者のリアルな物語
(1) 課題:スクリプトからツールへの成長痛
Bob は当初、EC データを処理するために複数の独立した Dart スクリプト(parse_csv.dart、calc_stats.dart、gen_report.dart)を書いていた。要件が増えるにつれて、スクリプト間でコピー&ペーストが頻発するようになった。1 つのフィールド名を変更するだけで 5 つのファイルを修正する必要があった。Alice は「JSON データソースを追加できる?」と尋ねた。Bob はすべての解析ロジックが CSV と密結合しており、修正が困難だと気づいた。
(2) 再設計の解決策
Bob は DataPipeline をゼロから設計し直すことにした。レイヤードアーキテクチャを採用し、Sealed クラスでデータソースを抽象化し、Stream パイプラインで非同期処理を行う。Charlie が品質を保証するためにコードレビューを実施した。
graph TD
subgraph アーキテクチャ層
A[CLI 層<br/>args 解析]
B[サービス層<br/>パイプラインスケジューリング]
C[データ層<br/>ソース + パーサー]
D[コア層<br/>モデル + ユーティリティ]
end
A --> B
B --> C
C --> D
B --> E[Isolate プール]
B --> F[Stream パイプライン]
C --> G["Sealed DataSource"]
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
(3) 効果
- 新しい JSON データソースを追加するにはサブクラスを 1 つ追加するだけでよく、既存コードへの変更はゼロ
- Stream パイプラインは数百万件のデータを 1 件ずつ処理し、メモリ使用量を抑える
- Isolate 並列処理は 4 コア CPU をフルに活用する
- CLI サブコマンドにより、ユーザーは必要に応じて解析・エクスポート・検証を呼び出せる
3. プロジェクトアーキテクチャ設計
(1) レイヤードアーキテクチャ
| 層 | ディレクトリ | 責任 | 依存関係 |
|---|---|---|---|
| CLI 層 | bin/ |
引数解析、コマンドルーティング | サービス層 |
| サービス層 | lib/src/services/ |
パイプラインスケジューリング、Isolate 管理 | データ層 + コア層 |
| データ層 | lib/src/data/ |
データソース抽象化、パーサー | コア層 |
| コア層 | lib/src/core/ |
モデル、ユーティリティ、定数 | 外部依存なし |
▶ サンプル
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
: プロジェクトディレクトリ構造
data_pipeline/
bin/
data_pipeline.dart # CLI エントリポイント
lib/
src/
core/
models/
order.dart
product.dart
customer.dart
analysis_result.dart
utils/
formatters.dart
validators.dart
constants.dart
data/
sources/
data_source.dart # Sealed クラス
csv_source.dart
json_source.dart
api_source.dart
parsers/
order_parser.dart
product_parser.dart
services/
pipeline.dart
analyzer.dart
isolate_pool.dart
report_generator.dart
data_pipeline.dart # バレルエクスポート
test/
core/
models_test.dart
utils_test.dart
data/
parsers_test.dart
services/
pipeline_test.dart
analyzer_test.dart
pubspec.yaml
```text
```text
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
4. CLI 引数解析とサブコマンド
(1) args パッケージのサブコマンド設計
▶ サンプル
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
: CLI エントリポイントとサブコマンド
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
// サブコマンド:analyze
class AnalyzeCommand extends Command {
@override
final name = 'analyze';
@override
final description = 'Analyze order data and generate statistics';
AnalyzeCommand() {
argParser
..addOption('source', abbr: 's', defaultsTo: 'csv', allowed: ['csv', 'json', 'api'])
..addOption('input', abbr: 'i', mandatory: true)
..addOption('output', abbr: 'o', defaultsTo: 'stdout')
..addFlag('parallel', abbr: 'p', defaultsTo: false)
..addOption('isolate-count', defaultsTo: '4');
}
@override
Future``<void>`` run() async {
final source = argResults!['source'] as String;
final input = argResults!['input'] as String;
final output = argResults!['output'] as String;
final parallel = argResults!['parallel'] as bool;
final isolateCount = int.parse(argResults!['isolate-count'] as String);
print('Source: $source | Input: $input | Parallel: $parallel');
}
}
// サブコマンド:export
class ExportCommand extends Command {
@override
final name = 'export';
@override
final description = 'Export analysis results to file';
ExportCommand() {
argParser
..addOption('format', defaultsTo: 'json', allowed: ['json', 'csv', 'markdown'])
..addOption('input', abbr: 'i', mandatory: true)
..addOption('output', abbr: 'o', mandatory: true);
}
@override
Future``<void>`` run() async {
final format = argResults!['format'] as String;
final input = argResults!['input'] as String;
final output = argResults!['output'] as String;
print('Export: $format | $input -> $output');
}
}
void main(List``<String>`` args) async {
final runner = CommandRunner('data_pipeline', 'DataPipeline - E-commerce data analytics CLI')
..addCommand(AnalyzeCommand())
..addCommand(ExportCommand());
try {
await runner.run(args);
} on UsageException catch (e) {
print(e);
}
}
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
| サブコマンド | 機能 | 主なパラメータ |
|---|---|---|
analyze |
データ解析と統計 | --source, --input, --parallel |
export |
レポートのエクスポート | --format, --input, --output |
validate |
データ検証 | --input, --strict |
5. データソース抽象化 — Sealed クラス
▶ サンプル
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
: Sealed DataSource
sealed class DataSource {
const DataSource();
String get displayName;
Stream``<String>`` readLines();
}
class CsvSource extends DataSource {
final String path;
final String delimiter;
const CsvSource({required this.path, this.delimiter = ','});
@override
String get displayName => 'CSV: $path';
@override
Stream``<String>`` readLines() => File(path).openRead().transform(utf8.decoder).transform(const LineSplitter());
}
class JsonSource extends DataSource {
final String path;
const JsonSource({required this.path});
@override
String get displayName => 'JSON: $path';
@override
Stream``<String>`` readLines() async* {
final content = await File(path).readAsString();
final jsonList = jsonDecode(content) as List;
for (final item in jsonList) {
yield jsonEncode(item);
}
}
}
class ApiSource extends DataSource {
final String endpoint;
final Map<String, String> headers;
const ApiSource({required this.endpoint, this.headers = const {}});
@override
String get displayName => 'API: $endpoint';
@override
Stream``<String>`` readLines() async* {
final client = Client();
final response = await client.get(Uri.parse(endpoint), headers: headers);
final jsonList = jsonDecode(response.body) as List;
for (final item in jsonList) {
yield jsonEncode(item);
}
client.close();
}
}
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
▶ サンプル
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
: DataSource を利用するパターンマッチング
DataSource createSource(String type, String input) => switch (type) {
'csv' => CsvSource(path: input),
'json' => JsonSource(path: input),
'api' => ApiSource(endpoint: input),
_ => throw ArgumentError('Unknown source type: $type'),
};
String sourceIcon(DataSource source) => switch (source) {
CsvSource() => '📄',
JsonSource() => '📋',
ApiSource() => '🌐',
};
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
| パターン | 目的 | 利点 |
|---|---|---|
| Sealed クラス | データソースの種類 | 網羅的な switch をコンパイル時に保証 |
| パターンマッチング | データソースの利用 | if-else チェーンが不要 |
| ファクトリ関数 | データソースの生成 | 統一されたエントリポイント |
6. マルチフォーマットパーサー
▶ サンプル
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
: Order パーサー
abstract class OrderParser {
const OrderParser();
Order parse(String raw);
List``<Order>`` parseBatch(List``<String>`` raws) => raws.map(parse).toList();
}
class CsvOrderParser extends OrderParser {
final String delimiter;
const CsvOrderParser({this.delimiter = ','});
@override
Order parse(String raw) {
final parts = raw.split(delimiter);
if (parts.length < 4) {
throw FormatException('Invalid CSV row: $raw');
}
return Order(
id: parts[0].trim(),
amount: double.parse(parts[1].trim()),
status: parts[2].trim(),
category: parts[3].trim(),
region: parts.length > 4 ? parts[4].trim() : 'US',
);
}
}
class JsonOrderParser extends OrderParser {
const JsonOrderParser();
@override
Order parse(String raw) {
final json = jsonDecode(raw) as Map<String, dynamic>;
return Order(
id: json['id'] as String,
amount: (json['amount'] as num).toDouble(),
status: json['status'] as String,
category: json['category'] as String,
region: (json['region'] as String?) ?? 'US',
);
}
}
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
7. Stream 非同期パイプライン
▶ サンプル
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
: Pipeline の Stream 処理
import 'dart:async';
import 'dart:convert';
import 'dart:io';
class Pipeline {
final DataSource _source;
final OrderParser _parser;
final int _chunkSize;
Pipeline({
required DataSource source,
required OrderParser parser,
int chunkSize = 1000,
}) : _source = source,
_parser = parser,
_chunkSize = chunkSize;
Future``<AnalysisResult>`` execute() async {
final orders = ``<Order>``[];
var processed = 0;
var skipped = 0;
await for (final line in _source.readLines()) {
try {
final order = _parser.parse(line);
orders.add(order);
processed++;
} on FormatException {
skipped++;
}
if (processed % _chunkSize == 0) {
stdout.writeln('Progress: $processed orders processed, $skipped skipped');
}
}
stdout.writeln('Total: $processed processed, $skipped skipped');
final analyzer = OrderAnalyzer();
return analyzer.analyze(orders);
}
Stream``<Order>`` streamOrders() async* {
await for (final line in _source.readLines()) {
try {
yield _parser.parse(line);
} on FormatException {
continue;
}
}
}
Stream``<AnalysisResult>`` streamByCategory() {
final controller = StreamController``<AnalysisResult>``();
streamOrders().fold<Map<String, List``<Order>``>>(
{},
(groups, order) {
groups.update(order.category, (v) => v..add(order), ifAbsent: () => [order]);
return groups;
},
).then((groups) {
final analyzer = OrderAnalyzer();
for (final entry in groups.entries) {
controller.add(analyzer.analyze(entry.value));
}
controller.close();
});
return controller.stream;
}
}
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
8. Isolate 並列処理
▶ サンプル
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
: Isolate プール
import 'dart:isolate';
class IsolatePool {
final int _poolSize;
final List``<Isolate>`` _isolates = [];
final List``<SendPort>`` _sendPorts = [];
IsolatePool({int poolSize = 4}) : _poolSize = poolSize;
Future``<void>`` initialize() async {
for (var i = 0; i < _poolSize; i++) {
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(
_isolateEntryPoint,
receivePort.sendPort,
);
final sendPort = await receivePort.first as SendPort;
_isolates.add(isolate);
_sendPorts.add(sendPort);
}
}
static void _isolateEntryPoint(SendPort mainSendPort) {
final receivePort = ReceivePort();
mainSendPort.send(receivePort.sendPort);
receivePort.listen((message) {
if (message is _IsolateTask) {
final result = _processChunk(message.orders, message.taxRate);
message.responsePort.send(result);
}
});
}
static AnalysisResult _processChunk(List<Map<String, dynamic>> rawOrders, double taxRate) {
final orders = rawOrders.map((o) => Order(
id: o['id'] as String,
amount: (o['amount'] as num).toDouble(),
status: o['status'] as String,
category: o['category'] as String,
region: (o['region'] as String?) ?? 'US',
)).toList();
final analyzer = OrderAnalyzer(taxRate: taxRate);
return analyzer.analyze(orders);
}
Future<List``<AnalysisResult>``> processInParallel(
List<Map<String, dynamic>> allOrders,
double taxRate,
) async {
final chunkSize = (allOrders.length / _poolSize).ceil();
final results = ``<AnalysisResult>``[];
final completers = <Completer``<AnalysisResult>``>[];
for (var i = 0; i < _poolSize; i++) {
final start = i * chunkSize;
final end = (start + chunkSize).clamp(0, allOrders.length);
if (start >= allOrders.length) break;
final chunk = allOrders.sublist(start, end);
final completer = Completer``<AnalysisResult>``();
completers.add(completer);
_sendPorts[i].send(_IsolateTask(
orders: chunk,
taxRate: taxRate,
responsePort: completer.future as dynamic,
));
}
for (final completer in completers) {
results.add(await completer.future);
}
return results;
}
void dispose() {
for (final isolate in _isolates) {
isolate.kill(priority: Isolate.immediate);
}
}
}
class _IsolateTask {
final List<Map<String, dynamic>> orders;
final double taxRate;
final SendPort responsePort;
const _IsolateTask({
required this.orders,
required this.taxRate,
required this.responsePort,
});
}
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
| 並列モデル | 適したシナリオ | 利点 |
|---|---|---|
| Isolate.run | 一回限りの計算 | シンプルな API |
| Isolate.spawn | 長時間動作するワーカー | 再利用可能 |
| Isolate プール | 均等なシャーディング | マルチコアをフル活用 |
| compute (Flutter) | UI のカクつき防止 | Flutter 専用 |
9. 依存性注入
▶ サンプル
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
: サービスロケーターパターン
class ServiceLocator {
static final _instances = <Type, dynamic>{};
static void register``<T>``(T instance) {
_instances[T] = instance;
}
static T get``<T>``() {
final instance = _instances[T];
if (instance == null) {
throw StateError('Service not registered: $T');
}
return instance as T;
}
static void reset() {
_instances.clear();
}
}
// 起動時に登録
void setupServices({required String sourceType, required String inputPath}) {
final source = createSource(sourceType, inputPath);
ServiceLocator.register``<DataSource>``(source);
final parser = switch (source) {
CsvSource() => const CsvOrderParser() as OrderParser,
JsonSource() => const JsonOrderParser(),
ApiSource() => const JsonOrderParser(),
};
ServiceLocator.register``<OrderParser>``(parser);
ServiceLocator.register``<OrderAnalyzer>``(const OrderAnalyzer());
ServiceLocator.register``<Pipeline>``(Pipeline(source: source, parser: parser));
}
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
10. 完全なサンプル:DataPipeline CLI ツール
// ============================================
// DataPipeline CLI ツール - 完全な実装
// Bob の EC データ分析ツール
// ============================================
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
// ---- コアモデル ----
class Order {
final String id;
final double amount;
final String status;
final String category;
final String region;
const Order({
required this.id,
required this.amount,
required this.status,
required this.category,
this.region = 'US',
});
double get tax => amount * _taxRate(region);
double get total => amount + tax;
static double _taxRate(String region) => switch (region) {
'US' => 0.08,
'EU' => 0.20,
'UK' => 0.15,
'JP' => 0.10,
_ => 0.10,
};
Map<String, dynamic> toJson() => {
'id': id,
'amount': amount,
'status': status,
'category': category,
'region': region,
};
}
class AnalysisResult {
final int totalOrders;
final int completedOrders;
final double revenue;
final double tax;
final double total;
final Map<String, double> revenueByCategory;
const AnalysisResult({
required this.totalOrders,
required this.completedOrders,
required this.revenue,
required this.tax,
required this.total,
required this.revenueByCategory,
});
double get averageOrderValue => completedOrders > 0 ? revenue / completedOrders : 0;
@override
String toString() => '''
=== DataPipeline Analytics ===
Orders: $completedOrders/$totalOrders completed
Revenue: \$${revenue.toStringAsFixed(2)} USD
Tax: \$${tax.toStringAsFixed(2)} USD
Total: \$${total.toStringAsFixed(2)} USD
Average: \$${averageOrderValue.toStringAsFixed(2)} USD
Categories: ${revenueByCategory.keys.join(', ')}
''';
}
// ---- コアサービス ----
class OrderAnalyzer {
final double taxRate;
const OrderAnalyzer({this.taxRate = 0.08});
AnalysisResult analyze(List``<Order>`` orders) {
final completed = orders.where((o) => o.status == 'completed').toList();
final revenue = completed.fold``<double>``(0, (s, o) => s + o.amount);
final tax = completed.fold``<double>``(0, (s, o) => s + o.tax);
final total = revenue + tax;
final byCategory = <String, double>{};
for (final o in completed) {
byCategory.update(o.category, (v) => v + o.amount, ifAbsent: () => o.amount);
}
return AnalysisResult(
totalOrders: orders.length,
completedOrders: completed.length,
revenue: revenue,
tax: tax,
total: total,
revenueByCategory: byCategory,
);
}
}
// ---- データソース(Sealed) ----
sealed class DataSource {
const DataSource();
String get displayName;
Stream``<String>`` readLines();
}
class CsvSource extends DataSource {
final String path;
const CsvSource({required this.path});
@override
String get displayName => 'CSV: $path';
@override
Stream``<String>`` readLines() =>
File(path).openRead().transform(utf8.decoder).transform(const LineSplitter());
}
class JsonSource extends DataSource {
final String path;
const JsonSource({required this.path});
@override
String get displayName => 'JSON: $path';
@override
Stream``<String>`` readLines() async* {
final content = await File(path).readAsString();
final list = jsonDecode(content) as List;
for (final item in list) {
yield jsonEncode(item);
}
}
}
// ---- パーサー ----
abstract class OrderParser {
const OrderParser();
Order parse(String raw);
}
class CsvOrderParser extends OrderParser {
const CsvOrderParser();
@override
Order parse(String raw) {
final parts = raw.split(',');
if (parts.length < 4) throw FormatException('Invalid row: $raw');
return Order(
id: parts[0].trim(),
amount: double.parse(parts[1].trim()),
status: parts[2].trim(),
category: parts[3].trim(),
);
}
}
class JsonOrderParser extends OrderParser {
const JsonOrderParser();
@override
Order parse(String raw) {
final json = jsonDecode(raw) as Map<String, dynamic>;
return Order(
id: json['id'] as String,
amount: (json['amount'] as num).toDouble(),
status: json['status'] as String,
category: json['category'] as String,
);
}
}
// ---- Pipeline ----
class Pipeline {
final DataSource source;
final OrderParser parser;
const Pipeline({required this.source, required this.parser});
Future``<AnalysisResult>`` execute() async {
final orders = ``<Order>``[];
var count = 0;
await for (final line in source.readLines()) {
try {
orders.add(parser.parse(line));
count++;
if (count % 100000 == 0) {
stdout.writeln('Progress: ${(count / 1000).toStringAsFixed(0)}K orders');
}
} on FormatException {
continue;
}
}
stdout.writeln('Loaded: ${(count / 1000).toStringAsFixed(0)}K orders from ${source.displayName}');
return const OrderAnalyzer().analyze(orders);
}
}
// ---- デモデータ生成 ----
List``<String>`` generateDemoOrders(int count) {
final categories = ['Electronics', 'Clothing', 'Books', 'Home', 'Sports'];
final statuses = ['completed', 'completed', 'completed', 'pending', 'cancelled'];
final regions = ['US', 'EU', 'UK', 'JP'];
return List.generate(count, (i) {
final category = categories[i % categories.length];
final status = statuses[i % statuses.length];
final region = regions[i % regions.length];
final amount = (100 + (i * 37) % 5000).toDouble();
return 'ORD-${(i + 1).toString().padLeft(5, '0')},$amount,$status,$category,$region';
});
}
// ---- Main ----
void main() async {
print('=== DataPipeline CLI v1.0 ===\n');
final demoData = generateDemoOrders(1000);
final tempFile = File('${Directory.systemTemp.path}/demo_orders.csv');
await tempFile.writeAsString(demoData.join('\n'));
final source = CsvSource(path: tempFile.path);
final parser = const CsvOrderParser();
final pipeline = Pipeline(source: source, parser: parser);
print('Source: ${source.displayName}');
print('Processing...\n');
final result = await pipeline.execute();
print(result);
final sorted = result.revenueByCategory.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
print('Revenue by Category:');
for (final entry in sorted) {
print(' ${entry.key.padRight(12)}: \$${entry.value.toStringAsFixed(2)} USD');
}
await tempFile.delete();
print('\nDone. Temporary file cleaned up.');
}
> **Output:** ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
11. Charlie のコードレビュー
(1) ▶ レビューポイント
| 観点 | レビュー基準 | DataPipeline のチェックリスト |
|---|---|---|
| 型安全性 | dynamic を使わない、網羅的な switch | Sealed DataSource + パターンマッチング |
| テスト容易性 | コアロジックが I/O に依存しない | OrderParser は純粋関数、Pipeline はインターフェースを受け取る |
| エラー処理 | 例外を握りつぶさない、フォールバックを持つ | FormatException をスキップ + カウント |
| パフォーマンス | メモリ制御、非ブロッキング非同期 | Stream の行単位処理 + Isolate シャーディング |
| 拡張性 | 新機能が既存コードを変更しない | 既存コード変更ゼロで新しい DataSource サブクラスを追加 |
Charlie: 「Sealed クラス + パターンマッチングはコンパイラが漏れをチェックしてくれるので、if-else よりはるかに安全だ。OrderParser は純粋関数だから、テストにファイルシステムのモックは不要だ。」
❓ よくある質問
Q:データソースに enum ではなく Sealed クラスを使うのはなぜですか? A: enum はデータ(path や endpoint など)を持てないからです。Sealed クラスはフィールドとメソッドを持てる一方で、網羅的な switch のコンパイル時保証も維持できます。
Q:Pipeline では Stream と Future のどちらを選ぶべきですか? A: データが 1 件ずつ到着してリアルタイム処理が必要なら Stream を使い、一度にすべて読み込んでから処理するなら Future を使います。数百万件のレコードでは、メモリオーバーフローを避けるために Stream が推奨されます。
Q:Isolate 間の通信ではプリミティブ型しか渡せないのですか? A: SendPort を通じて渡すデータはシリアライズ可能である必要があります。カスタムクラスは Map に変換するか、jsonEncode/Decode を使う必要があります。Dart 3 の Records も渡せます。
Q:args パッケージと dcli パッケージのどちらが良いですか? A: args は公式パッケージで、標準的なサブコマンドパターンに適しています。dcli はより豊富な CLI ツール(ファイル操作、プロセス管理など)を提供しますが、非公式です。このチュートリアルでは args を使用します。
Q:依存性注入にはフレームワークが必要ですか? A: 必ずしも必要ではありません。小規模プロジェクトでは Service Locator やコンストラクタ注入で十分です。flutter_bloc や get_it のようなフレームワークは大規模プロジェクトに適しています。DataPipeline はシンプルな ServiceLocator を使用します。
Q:Pipeline のエンドツーエンドフローをテストするにはどうすればよいですか? A: 一時ファイル(Directory.systemTemp)をテストデータソースとして使い、出力された AnalysisResult の各フィールドの値を検証します。レッスン L20 の統合テストの例を参照してください。
📖 まとめ
- レイヤードアーキテクチャは CLI/サービス/データ/コアの各層の責任を明確にし、依存関係を単方向にする
- Sealed クラスがデータソースを抽象化し、パターンマッチングがそれを利用し、コンパイラが漏れを保証する
- Stream パイプラインは数百万件のデータを 1 件ずつ流しながら処理し、メモリを制御する
- Isolate プールの並列シャーディングがマルチコアをフル活用する
- 依存性注入によりコアロジックがテスト可能かつ置換可能になる
📝 練習問題
- 基礎(難易度 ⭐):DataPipeline に
validateサブコマンドを追加し、--inputパラメータを受け取って CSV ファイル内の形式が不正な行数を数え、報告してください。 - 上級(難易度 ⭐⭐):
JsonSource+JsonOrderParserを実装してください。JSON 配列を入力として使い、Pipeline を実行して解析結果を出力してください。Sealed クラスの網羅的な switch がすべてのサブクラスをカバーしているか検証してください。 - 挑戦(難易度 ⭐⭐⭐):
IsolatePoolを実装してください。1000 件のデモデータを 4 つのシャードに分割し、4 つの Isolate で並列に解析し、最後に 4 つのAnalysisResultオブジェクトをマージしてください(収益と注文数をそれぞれ合計)。並列実行と逐次実行の時間差を測定してください。