Log機能を持ったResult型library.logger_result
追記(20240226)
別の library に統合したため、非公開な状態になっています(統合先の library はいずれ公開する).
はじめに
Log 機能を持った Result 型 library を作った。 https://pub.dev/packages/logger_result
Result 型を説明するにあたり、まず、Error Handling について少し説明する。
Error Handling(エラーハンドリング)について
throw(スロー)
Error や Exception を throw する。throw すると プログラムの処理が一旦停止し、処理の呼び出し側に戻っていく。その途中、catch されれば、throw された Error or Exception を受け取り、任意の処理を行い recovery するか rethrow するかする。途中で catch されなければ、そのまま、呼び出し側がなくなるまで呼び出し側に戻り続け、最終的にプログラムは緊急停止する。
catch(キャッチ)
throw された Error や Exception を受け取り、呼び出し側に戻っていく処理を止める。
recovery(リカバリー)
throw された Exception を catch したあと、rethrow を行わず、任意の処理を行い、正常な処理に戻ること。つまり、呼び出し側に戻っていく処理が終わり、recovery をしたところから、一旦停止したプログラムが再び動き出す。
rethrow(リスロー)
throw された Error や Exception を catch したあと、recovery を行わず、任意の処理を行い、再び、処理を一旦停止させ、呼び出し側に戻っていく。つまり、また、catch されるか、最後まで戻り続け、プログラムが緊急停止するかする。
Error と Exception の違い
Dart におけるこの2つの概念のニュアンスについて。
深刻度
Error > Exception である。 Error の方がより深刻である。
使用頻度
Exception > Error である。 Error は使用頻度が少ない。プログラムを強制停止させ、code を書き換えなければならないケースは Exception と比べれば少ない。 基本的にエラーという単語がプログラミング界では飛び交うため、エラーはよく使うのかなと思ってしまうが(自分はそうだった)、広義の意味のErrorと、Dart における狭義の意味の Error は異なり、Error は頻繁に起きない。 file の読み込みに失敗した、アクセスできなかった、などは、Dart の文脈において Error ではなく Exception である。
Error の役割
基本的に Error はプログラムを緊急停止するために throw される。つまり、recovery はせず、catch しても、必ず rethrow を行う。 例えば、GUIアプリケーションなどを使用していて、Error が throw された場合、そのアプリはウィンドウもすべて閉じられ、完全に落ちる。 Error が throw され プログラムが緊急停止した場合、その Error が二度と throw されないように、code の修正が必要であることを意味する。 例えば、分岐が3種類のケースに対して対応しなければならなかったのに、2種類のケースにしか対応した code しか書いていなかった場合など。このような場合に、Error が throw されれば、その2種類のケースにしか対応してなかった code の不備に早く気付くことができる。 Error の役割はそのプログラムの深刻なバグや欠陥を適切に知らせることと言える。
Exception の役割
基本的に Exception は最終的に recovery されることを前提に throw される。途中で catch され、rethrow されても、最終的にどこかの時点で recovery をしなければならない。 throw された Exception が recovery されず、プログラムを緊急停止するまで、呼び出し側に戻っていってしまった場合、それは code のバグ、recovery し忘れなので、必ず recovery の処理をどこかで書かなければいけない。 Exception が throw されるのは そのプログラムにバグや欠陥があった時ではなく、プログラム外での失敗、要求した内容とは異なる結果が返ってきた時などである。 例えば、あるユーザーがそのプログラムを通してあるファイルを開こうとする。しかし、そのプログラムに対応したファイル形式ではなかったため、ファイルを開くことができず、操作に失敗した。この場合、プログラムの欠陥ではなく、開こうとしたファイル、もしくは、対応していないファイルを開こうとしたユーザー側に失敗の原因がある。このような時に Exception を throw し、プログラムの内部のどこかで catch し、ユーザーに対して、このプログラムはそのファイル形式には対応していない、などの旨を伝えるメッセージを表示するなどの処理を行い、recovery し、プログラムは元の処理に戻る。つまり、使ってるプログラムは終了せず、待機画面や操作画面などに戻る。
Dart における Error Handling とその問題点
Dart ではこのようなエラーハンドリングを行うために try catch 構文を用意している。
throw される Exception の種類が呼び出し側から機械的に確認できない
try catch 構文を前提としたエラーハンドリングは、throw される Exception の詳細を知るためにドキュメントやコードを手作業で確認しに行かなければならない。そのことがわかるページやファイルへ移動し、確認しに行かなければならない。 コメントのどの Exception が throw されるかもしれないかを書いておくこともできるが、修正や追記、それの正しさをどうやって保証するかなど、手作業でそれを逐一行うのは、変更に対するコストが跳ね上がる。
Error も Exception も try catch 構文を使う
記述ミスをしなければいいのだけだが、Error を握りつぶす code を書いてしまわないように、神経を使う。 Error を握りつぶすとは、throw された Error を catch し、rethrow せず、recovery してしまうことである。
rethrow し忘れ、recovery し忘れ
これらは、エディターによって強制されるわけではなく、自分の意識と手で、throw や recovery の記述を書かなければならない。throw される Exception の種類が機械的に確認できないことも合わさり、書き忘れしないように自分で意識をしなければならず、高い認知負荷がかかる。
Result 型の利点
try catch 構文によるエラーハンドリングの問題点を Result 型で軽減することができる。 Result 型の場合 Exception は throw されず、値として返るようになる。 また、基本的に Result 型は、Exception を扱い、Error は扱わない。
throw される Exception の機械的な確認
Result
Error と Exception を別構文で扱う
try catch 構文は Error を扱う時だけに限定でき、Exception を扱う Result 型を使うときは、エラーを握りつぶしてしまう記述をしないように気を配る必要がなくなる。
Exception が返った時の記述を機械的に強制
Result 型から値を取り出すときに case を網羅的にチェックできる switch 構文を使えば、成功したときの値、失敗したときの値(Exception)、の場合の処理の記述を強制できる。 その関数の返り値の型として確認できるため、rethrow(throw ではなく、値を返す、だが) か recovery をしなければならないことがすぐにわかる。
logger_result
以上のような Result 型に、Log の機能も加えた。 Log といっても、よくあるきれいな書式で print する機能は持たず、json または yaml 形式の文字列を返すだけ。また、構造を自分の見やすいように整形することができる。
どのような Log が返るか
下記の画像はデフォルトで存在する構造の整形を利用して yaml 形式で file に出力し、vscode で開いたもの。(file への出力、print などは自前で用意する必要がある)
注意点
内部で dart:convert の jsonEncode を利用しているため、log はすべて、json encode 可能でないと実行時エラーが発生する。
ログレベルは無い
monitor or debug(monitor と debug に関する使い方と説明は例の code で説明) があるだけ。
もしも、必要な場合、monitor or debug の中に、下記のように記述することを想定している。
{
fatal: {
'a': a
},
error: {
'b': b
},
info: {
'c': c
}
}
また、特定の値や項目だけを表示するという機能もない。その用途の場合、ソートや絞り込み機能を持った yaml や json ビューワーという形で実現することを考えている。
方針や思想
式ではなく文を使い、早期 return を行うことで、Result 型にありがちな入れ子構造が深くなることを防ぐ。なので、式での利用を前提とした、入れ子を深くしないための utility method は無い。 定型 code が多いので、 editor の snipetts 機能の利用は必須。 例の function4 での if の早期リターンと値の取り出しは頻繁に使う。早期リターン以後、型が Success と解析され入れ子を増やさずに値を取り出すことができる。
網羅的な switch 文の書き方(switch 式ではなく)
例の code の Example.function1() の中に comment で書いてある。 自分が調べた時点では 網羅的(exhaustive) な switch 文の書き方の説明がどこにもなかったが、実は書けるということに、いろいろ試して途中で書けることに気付けた。
例
だいたいの使い方。 実際には、sealed class の機能を使って、専用の Exception や Error class を定義して管理するのだが、ここでは省略している。
code
import 'package:logger_result/logger_result.dart';
void main() {
final Type? classLocation = null;
const functionLocation = 'main';
Map<String, dynamic> monitor = {};
Map<String, dynamic> debug = {};
List<Log> histories = [];
final example = Example();
final function1Result = example.function1(1);
histories.add(function1Result);
final function2Result = example.function2('a');
histories.add(function2Result);
final function3Result = example.function3('b');
histories.add(function3Result);
final function4Result = example.function4(1);
histories.add(function4Result);
// Complete 型は void 型の代わりに利用する.
// 何か任意のメッセージを log に残したい場合 loggerResultMessage に List<String> 型で 値を代入する.
final result = Safety(Complete(), classLocation, functionLocation, monitor, debug, histories);
final formatterA = Formatter.loggerResultDefault(result);
print('--- デフォルトの整形形式 ---');
print(formatterA.baseToYamlString());
final formatterB = Formatter.toYamlString(result.toJson());
print('--- 整形無しで yaml で表示 ---');
print(formatterB);
final formatterC = Formatter.toJsonString(result.toJson());
print('--- 整形無しで json で表示 ---');
print(formatterC);
}
class Example
{
Result<int, Exception> function0(int number) {
final classLocation = runtimeType;
const functionLocation = 'function0';
Map<String, dynamic> monitor = {};
Map<String, dynamic> debug = {};
List<Log> histories = [];
// Error を throw したいときは Panic に wrap して throw する.
if (number == 99.99) throw Panic(Error(), classLocation, functionLocation, monitor, debug, histories);
if (number == 0) return Failure(Exception(), classLocation, functionLocation, monitor, debug, histories);
return Success(number, classLocation, functionLocation, monitor, debug, histories);
}
Result<int, Exception> function1(int number) {
final classLocation = runtimeType;
const functionLocation = 'function1';
Map<String, dynamic> monitor = {};
Map<String, dynamic> debug = {};
List<Log> histories = [];
final function0Result = function0(number);
histories.add(function0Result);
// 下記の code の syntax sugar.
// switch (function0Result) {
// case Success(asValue: final value):
// return Success(value, classLocation, functionLocation, monitor, debug, histories);
// case Failure(asError: final error):
// return Failure(error, classLocation, functionLocation, monitor, debug, histories);
// }
return Result.fromResult(function0Result, classLocation, functionLocation, monitor, debug, histories);
}
Result<String, Exception> function2(String string) {
final classLocation = runtimeType;
const functionLocation = 'function2';
Map<String, dynamic> monitor = {};
Map<String, dynamic> debug = {};
List<Log> histories = [];
// monitor に追加された値は、debug 実行時、本番実行時ともに、log として記録される.
monitor.addAll({
'input_value': string,
});
// debug に追加された値は debug 実行時のみ、 log として記録される.
debug.addAll({
'input_value': string,
});
if (string.isEmpty) return Failure(Exception(), classLocation, functionLocation, monitor, debug, histories);
return Success(string, classLocation, functionLocation, monitor, debug, histories);
}
// Safety は Exception が発生しない場合に利用する.
Safety<String> function3(String string) {
final classLocation = runtimeType;
const functionLocation = 'function3';
Map<String, dynamic> monitor = {};
Map<String, dynamic> debug = {};
List<Log> histories = [];
return Safety(string, classLocation, functionLocation, monitor, debug, histories);
}
Result<int, Exception> function4(int number) {
final classLocation = runtimeType;
const functionLocation = 'function4';
Map<String, dynamic> monitor = {};
Map<String, dynamic> debug = {};
List<Log> histories = [];
final function0Result = function0(number);
histories.add(function0Result);
// これ以降 function4Result は Success 型として解釈される.
if (function0Result is! Success<int, Exception>) return Failure(function0Result.asError!, classLocation, functionLocation, monitor, debug, histories);
return Success(function0Result.wrapped, classLocation, functionLocation, monitor, debug, histories);
}
}
print 結果
--- デフォルトの整形形式で yaml で表示 ---
main():
result: Complete
Example.function1()(1):
result: 'Success<int, Exception>'
Example.function0()(1):
result: 'Success<int, Exception>'
Example.function2()(2):
result: 'Success<String, Exception>'
input_value(monitor): a
input_value(debug): a
Example.function3()(3):
result: String
Example.function4()(4):
result: 'Success<int, Exception>'
Example.function0()(1):
result: 'Success<int, Exception>'
--- 整形無しで yaml で表示 ---
wrapped: "Instance of 'Complete'"
classLocation:
functionLocation: main
monitor: '{}'
debug: '{}'
historyList:
values:
-
wrapped: 'Success<int, Exception>'
classLocation: Example
functionLocation: function1
monitor: '{}'
debug: '{}'
historyList:
values:
-
wrapped: 'Success<int, Exception>'
classLocation: Example
functionLocation: function0
monitor: '{}'
debug: '{}'
historyList:
values: []
-
wrapped: 'Success<String, Exception>'
classLocation: Example
functionLocation: function2
monitor: '{input_value: a}'
debug: '{input_value: a}'
historyList:
values: []
-
wrapped: String
classLocation: Example
functionLocation: function3
monitor: '{}'
debug: '{}'
historyList:
values: []
-
wrapped: 'Success<int, Exception>'
classLocation: Example
functionLocation: function4
monitor: '{}'
debug: '{}'
historyList:
values:
-
wrapped: 'Success<int, Exception>'
classLocation: Example
functionLocation: function0
monitor: '{}'
debug: '{}'
historyList:
values: []
--- 整形無しで json で表示 ---
{"wrapped":"Instance of 'Complete'","classLocation":"","functionLocation":"main","monitor":"{}","debug":"{}","historyList":{"values":[{"wrapped":"Success<int, Exception>","classLocation":"Example","functionLocation":"function1","monitor":"{}","debug":"{}","historyList":{"values":[{"wrapped":"Success<int, Exception>","classLocation":"Example","functionLocation":"function0","monitor":"{}","debug":"{}","historyList":{"values":[]}}]}},{"wrapped":"Success<String, Exception>","classLocation":"Example","functionLocation":"function2","monitor":"{input_value: a}","debug":"{input_value: a}","historyList":{"values":[]}},{"wrapped":"String","classLocation":"Example","functionLocation":"function3","monitor":"{}","debug":"{}","historyList":{"values":[]}},{"wrapped":"Success<int, Exception>","classLocation":"Example","functionLocation":"function4","monitor":"{}","debug":"{}","historyList":{"values":[{"wrapped":"Success<int, Exception>","classLocation":"Example","functionLocation":"function0","monitor":"{}","debug":"{}","historyList":{"values":[]}}]}}]}}
最後に
自分で試してみて、想像以上に良い開発体験を得ることができたので、library として公開することにしました。 この library は、ただ Result 型を実装しているということではなく、Error Handling は Log Handling の一部である、という思想に加え、私のプログラミングの設計思想に最適化したものになります。 Result 型を使いたい、というより、この思想に共感した人が使うものになります。 みんなに使ってもらうように整備して公開した、ではなく、自分用に使っているものをついでだから公開した、という感じです。デバッグも不十分で、仕様もちょくちょく変わるだろうと思います。 なので、個人、団体、問わず、開発しているプロダクトにこの library の検討もしくは、利用している人がいれば、その旨や質問をこの記事にコメントしてもらえればと思います。 誰も使わない場合、使い方のより詳細な説明などたぶん追記しないと思います。記事書くの疲れるのと、仕様がまだ固まっていないため。