6
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C#向けResultパターンライブラリ「FluentResult」Readme抄訳

Last updated at Posted at 2024-10-09

この記事について

C#でResultパターンを実現するためのライブラリ「FluentResults」のREADMEを抄訳したものです。

パフォーマンス上の問題がある例外を避けつつ、表現力の貧弱なリターンコードに代わる手法を模索する中でResultパターンとこのライブラリを見つけ、導入を検討するにあたり公式ドキュメントを訳してみました。

原文確認日:2024/10/9


はじめに

FluentResultsは、例外をスロー・使用する代わりに、処理の成功・失敗を示すオブジェクトを返す機能を提供する軽量なNETライブラリです。

FluentResultsはNuGetでインストールできます。

Install-Package FluentReuslts

最も必要とされているコミュニティ機能は FluentResults.Extensions.AspNetCore パッケージとして公開されています。ドキュメントはこちら。試してフィードバックを送ってください。

主な機能 (Key Features)

  • 一般化されたコンテナになっており、ASP.NET MVC/WebApi、WPF、DDD ドメインモデルなどすべてのコンテキストで使用可能
  • 1つのResultオブジェクトに複数のエラー情報を格納可能
  • 単純なエラーメッセージ文字列の代わりに、強力で精巧なError・Successオブジェクトを格納可能
  • Error・Successオブジェクトをオブジェクト指向に基づいて設計
  • 根本的なエラー原因とそれに連なるエラーを階層的な構造で格納可能
  • .NET Standard、.NET Core、.NET 5以上、.NET Frameworkをサポート(詳細
  • SourceLinkをサポート
  • 有名・一般的なフレームワーク・ライブラリとの統合方法を示すコードサンプルを提供
  • NEW FluentAssertions 拡張により、FluentResultのオブジェクトに対するAssertを簡潔に実現可能
  • プレビュー中 ASP.NETコントローラーからResultオブジェクトを返す

例外ではなくResultオブジェクトを使う理由(Why Results insted of exceptions)

実のところ、成功・失敗を示すオブジェクトを返すというパターンは新しいアイデアというわけではなく、関数型プログラミング言語に由来しています。FluentResultによりこのパターンを.NET/C#でも使用できます。
Vladimir Khorikov氏による記事「Exceptions for Flow Control」では、Resultパターンが適しているシナリオ・適していないシナリオが非常にわかりやすく説明されています。ベストプラクティスとResultパターンに関する資料の一覧もご覧ください。

Resultの生成方法 (Creating a Result)

1つのResultオブジェクトは複数のエラー・成功メッセージを格納できます。

// 成功を示すResultを生成
Result successResult1 = Result.Ok();

// 失敗を示すResultを生成
Result errorResult1 = Result.Fail("My error message");
Result errorResult2 = Result.Fail(new Error("My error message"));
Result errorResult3 = Result.Fail(new StartDateIsAfterEndDateError(startDate, endDate));
Result errorResult4 = Result.Fail(new List<string> { "Error 1", "Error 2" });
Result errorResult5 = Result.Fail(new List<IError> { new Error("Error 1"), new Error("Error 2") });

Resultクラスは、通常戻り値を持たないvoidメソッドで使用されます。

public Result DoTask()
{
    if (this.State == TaskState.Done)
        return Result.Fail("Task is in the wrong state.");

    // 処理

    return Result.Ok();
}

さらに、特定の型の値を持たせることもできます。

// 成功を示すResultを生成
Result<int> successResult1 = Result.Ok(42);
Result<MyCustomObject> successResult2 = Result.Ok(new MyCustomObject());

// 失敗を示すResultを生成
Result<int> errorResult = Result.Fail<int>("My error message");

Result<T>クラスは、戻り値を持つメソッドで使用されます。

public Result<Task> GetTask()
{
    if (this.State == TaskState.Deleted)
        return Result.Fail<Task>("Deleted Tasks can not be displayed.");

    // 処理

    return Result.Ok(task);
}

Resultを処理する (Processing a Result)

メソッドからResultオブジェクトが返されたら、処理が正常に終了したかそうでないかを確認する必要があります。Resultオブジェクトには、成功を示す IsSuccess プロパティ、失敗を示す IsFailed プロパティがあります。Result<T>の値には、ValueValueOrDefaultプロパティを使用してアクセスできます。

Result<int> result = DoSomething();
     
// 成功・失敗理由のオブジェクトをすべて取得
IEnumerable<IReason> reasons = result.Reasons;

// エラーメッセージをすべて取得
IEnumerable<IError> errors = result.Errors;

// 成功メッセージをすべて取得
IEnumerable<ISuccess> successes = result.Successes;

if (result.IsFailed)
{
    // エラーケースを処理
    var value1 = result.Value; // resultはエラー状態なので例外をスロー
    var value2 = result.ValueOrDefault; // resultはエラー状態なのでデフォルト値(=0)を返す
    return;
}

// 成功ケースを処理
var value3 = result.Value; // 結果値を返す。例外はスローされない
var value4 = result.ValueOrDefault; // デフォルト値ではなく結果値を返す。

エラー・成功メッセージを設計する (Designing errors and success messages)

単純なメッセージ文字列だけを格納するResultパターンのライブラリは多くありますが、FluentResultはその代わりに強力でオブジェクト指向に基づいたError・Successオブジェクトを格納します。これにより、エラーや成功に関係する情報を1つのクラスにまとめて格納することができます。

このライブラリのパブリックAPIでは、IReasonインターフェースと、そこから派生するIErrorISuccessインターフェースが使用されています。Reasonsプロパティに1つでもIErrorオブジェクトが含まれている場合、そのResultオブジェクトは失敗を示し、IsSuccessプロパティはfalseとなります。

IErrorISuccessを実装するか、SuccessErrorクラスから派生させることで、独自のエラー・成功クラスを作ることができます。

public class StartDateIsAfterEndDateError : Error
{
    public StartDateIsAfterEndDateError(DateTime startDate, DateTime endDate)
        : base($"The start date {startDate} is after the end date {endDate}")
    { 
        Metadata.Add("ErrorCode", "12");
    }
}

この仕組みを使って、Warningクラスを作ることもできます。これをSuccessErrorのどちらのクラスから派生させるかは、警告がある状態をエラーとして扱うか成功として扱うかは、システムの要件により決めることになります。

その他の機能 (Further features)

エラー・成功メッセージをメソッドチェーンでつなげる (Chaining error and success messages)

var result = Result.Fail("error message 1")
                   .WithError("error message 2")
                   .WithError("error message 3")
                   .WithSuccess("success message 1");

条件式の結果によりResultオブジェクトを生成 (Create a result depending on success/failure condition)

var result = string.IsNullOrEmpty(firstName) ? Result.Fail("First Name is empty") : Result.Ok();

FailIf()OkIf()メソッドでより読みやすい書き方もできます。

var result = Result.FailIf(string.IsNullOrEmpty(firstName), "First Name is empty");

エラーインスタンスの初期化を遅延させたい場合は、Func<string>またはFunc<Error>を取るオーバーロードを使うことで実現できます。

var list = Enumerable.Range(1, 9).ToList();

var result = Result.FailIf(
    list.Any(IsDivisibleByTen),
    () => new Error($"Item {list.First(IsDivisibleByTen)} should not be on the list"));

bool IsDivisibleByTen(int i) => i % 10 == 0;

// rest of the code

Try

ある処理を実行したときに例外が発生したら、キャッチしてResultオブジェクトに変換することができます。

var result = Result.Try(() => DoSomethingCritical());

独自のResultオブジェクトを返すこともできます。

var result = Result.Try(() => {
    if(IsInvalid()) 
    {
        return Result.Fail("Some error");
    }

    int id = DoSomethingCritical();

    return Result.Ok(id);
});

上述の例では、デフォルトのcatchHandlerが使用されます。この動作をResultクラスのグローバル設定で変更し、Errorオブジェクトの内容をカスタマイズできます。

Result.Setup(cfg =>
{
    cfg.DefaultTryCatchHandler = exception =>
    {
        if (exception is SqlTypeException sqlException)
            return new ExceptionalError("Sql Fehler", sqlException);

        if (exception is DomainException domainException)
            return new Error("Domain Fehler")
                .CausedBy(new ExceptionError(domainException.Message, domainException));

        return new Error(exception.Message);
    };
});

var result = Result.Try(() => DoSomethingCritical());

It is also possible to pass a custom catchHandler via the Try(..) method.
カスタムのcatchHandlerをTry()メソッドで渡すこともできます。

var result = Result.Try(() => DoSomethingCritical(), ex => new MyCustomExceptionError(ex));

エラーの根本原因 (Root cause of the error)

CausedBy()には、エラーの根本原因として、Errorオブジェクト、Errorのリスト、文字列、文字列のリストまたは例外オブジェクトを渡すことでき、Reasonsプロパティを介して参照できます。

例1:例外を渡す場合

try
{
    //export csv file
}
catch(CsvExportException ex)
{
    return Result.Fail(new Error("CSV Export not executed successfully").CausedBy(ex));
}

例2:Errorオブジェクトを渡す場合

Error rootCauseError = new Error("This is the root cause of the error");
Result result = Result.Fail(new Error("Do something failed", rootCauseError));

例3:Errorのリストから原因を読み取る

Result result = ....;
if (result.IsSuccess)
   return;

foreach(IError error in result.Errors)
{
    foreach(ExceptionalError causedByExceptionalError in error.Reasons.OfType<ExceptionalError>())
    {
        Console.WriteLine(causedByExceptionalError.Exception);
    }
}

メタデータ (Metadata)

Error/Successオブジェクトにメタデータを追加することができます。

方法1:Resultオブジェクトを生成する際にWithMetadata()メソッドを呼ぶ

var result1 = Result.Fail(new Error("Error 1").WithMetadata("metadata name", "metadata value"));

var result2 = Result.Ok()
                    .WithSuccess(new Success("Success 1")
                                 .WithMetadata("metadata name", "metadata value"));

方法2:Error/SuccessクラスのコンストラクタでWithMetadata()メソッドを呼ぶ

public class DomainError : Error
{
    public DomainError(string message)
        : base(message)
    { 
        WithMetadata("ErrorCode", "12");
    }
}

結果のマージ (Merging)

複数のResultオブジェクトをMerge()静的メソッドでマージすることができます。

var result1 = Result.Ok();
var result2 = Result.Fail("first error");
var result3 = Result.Ok<int>();

var mergedResult = Result.Merge(result1, result2, result3);

Merge()拡張メソッドを使ってResultのリストを1つのResultオブジェクトにマージすることもできます。

var result1 = Result.Ok();
var result2 = Result.Fail("first error");
var result3 = Result.Ok<int>();

var results = new List<Result> { result1, result2, result3 };

var mergedResult = results.Merge();

変換 (Converting and Transformation)

ToResult()ToResult<TValue>()メソッドを使って、Resultオブジェクトを別のResultオブジェクトに変換することができます。

// 1. 結果値を持たないResultオブジェクトをResult<int>型(値はint型のデフォルト)に変換
Result.Ok().ToResult<int>();

// 2. 結果値を持たないResultオブジェクトをResult<int>型(値は5を指定)に変換
Result.Ok().ToResult<int>(5);

// 3. 失敗を示すResultオブジェクトをResult<int>型に変換
// 失敗しているため、値は不要
Result.Fail("Failed").ToResult<int>();

// 4. Result<int>をResult<float>に変換(引数に変換ロジックを渡す)
Result.Ok<int>(5).ToResult<float>(v => v);

// 5. 失敗を示すResult<int>オブジェクトをResult<float>型に変換
// 失敗しているため、変換ロジックは不要
Result.Fail<int>("Failed").ToResult<float>();

// 6. Result<int>オブジェクトを結果値なしのResultに変換
Result.Ok<int>().ToResult();

Map()メソッドを使ってResultオブジェクトの結果値を別の形に変換できます。

// 7. Result<int>をResult<Dto>に変換
Result.Ok<int>(5).Map(v => new Dto(5));

訳注

ToResult(変換ロジック)Map(変換ロジック) は同じ動作です(ToResult<TNewValue>(Func<TValue, TNewValue>)Map<TNewValue>(Func<TValue, TNewValue>)処理を委譲しています。)。つまり、例5と例7は変換先の結果値型が異なるだけで、同じことをしています。

T型から成功を示すResult<T>への暗黙的な変換 (Implicit conversion from T to success result Result)

string myString = "hello world";
Result<T> result = myString;

Errorから失敗を示すResult/Result<T>への暗黙的な変換 (Implicit conversion from Error to fail result Result or Result)

単一のErrorから:

Error myError = new Error("error msg");
Result result = myError;

Errorのリストから:

List<Error> myErrors = new List<Error>() 
    { 
        new Error("error 1"), 
        new Error("error 2") 
    };
    
Result result = myErrors;

ResultをほかのResultにバインド (Bind the result to another result)

バインドはResultまたはResult<T>を返す変換です。元のResultが成功している場合のみ変換が実行されます。2つのResultのReasonsは1つのフラットなResultにマージされます。

// あるResultを、失敗になる可能性のあるResult<T>に変換
Result<string> r = Result.Ok(8)
    .Bind(v => v == 5 ? "five" : Result.Fail<string>("It is not five"));

// 失敗したResultを、同じく失敗になる可能性のあるResult<T>に変換
// 結果 r は最初のResultのエラーに含まれるエラーメッセージのみを含んでおり、
// Bind()に指定した変換は評価されない(最初のResultの値が利用できないため)
Result<string> r = Result.Fail<int>("Not available")
    .Bind(v => v == 5 ? "five" : Result.Fail<string>("It is not five"));

// [訳注] 上記例の実行結果
// r.IsSuccess は false(よってr.Valueは利用できない)
// r.Errors は "Not available" のみ

// converting a result with value to a Result via a transformation which may fail
// 結果値を持つResultを、結果値のないResultに変換(変換が失敗する可能性がある)
Result.Ok(5).Bind(x => Result.OkIf(x == 6, "Number is not 6"));

// [訳注] 上記例の実行結果
// r.IsSuccess は false
// r.Errors は "Number is not 6" のみ
// 最初が Result.Ok(6) であれば、r.IsSuccess は true となる

// 結果値のないResultを、結果値のあるResult<T>に変換
Result.Ok().Bind(() => Result.Ok(5));

// 元のResultが成功している場合にアクションを実行
Result r = Result.Ok().Bind(() => Result.Ok());

Bind()には非同期版オーバーロードもあります。

var result = await Result.Ok(5)
    .Bind(int n => Task.FromResult(Result.Ok(n + 1).WithSuccess("Added one")))
    .Bind(int n => /* next continuation */);

ISuccess/IError/IExceptionalErrorのグローバルなファクトリを設定 (Set global factories for ISuccess/IError/IExceptionalError)

FluentResultsのライブラリ内で、ISuccessIErrorIExceptionalErrorのインスタンスが生成されることがあります。例えば Result.Fail("My Error") が呼ばれると、内部で IError オブジェクトが生成されます。この動作を上書きして独自のエラークラスを生成させたい場合は、エラーのファクトリを設定することができます。ISuccessIExceptionalErrorについても同様です。

Result.Setup(cfg =>
{
    cfg.SuccessFactory = successMessage => new Success(successMessage).WithMetadata("Timestamp", DateTime.Now);
    
    cfg.ErrorFactory = errorMessage => new Error(errorMessage).WithMetadata("Timestamp", DateTime.Now);
    
    cfg.ExceptionalErrorFactory = (errorMessage, exception) => new ExceptionalError(errorMessage ?? exception.Message, exception)
    .WithMetadata("Timestamp", DateTime.Now);
});

ErrorとSuccessのマッピング (Mapping errors and successes)

1つのResultオブジェクトに含まれるSuccessデータのすべてに対して情報を付加したい場合は、MapSuccesses()メソッドを使います。

var result = Result.Ok().WithSuccess("Success 1");
var result2 = result.MapSuccesses(e => new Success("Prefix: " + e.Message));

Errorに対しても同様に、MapErrors()メソッドを使います。ただし、対象となるのは第1階層にあるエラーのみで、たとえばerror.Reasonsの中にあるようなエラーまでは変更されません。

var result = Result.Fail("Error 1");
var result2 = result.MapErrors(e => new Error("Prefix: " + e.Message));

Errorオブジェクトのキャッチとハンドリング (Handling/catching errors)

例外のcatchブロックおように、Resultオブジェクトに含まれるErrorのチェックとハンドリングを行うことができます。

// Resultオブジェクトが特定の型のErrorオブジェクトを含んでいるかどうかをチェック
result.HasError<MyCustomError>();

// Resultオブジェクトが特定の型 かつ 指定された条件を満たすErrorオブジェクトを含んでいるかどうかをチェック
result.HasError<MyCustomError>(myCustomError => myCustomError.MyField == 2);

// Resultオブジェクトが特定のメタデータキーを持つErrorオブジェクトを含んでいるかどうかをチェック
result.HasError(error => error.HasMetadataKey("MyKey"));

// Resultオブジェクトが特定のメタデータキーと値を持つErrorオブジェクトを含んでいるかどうかをチェック
result.HasError(error => error.HasMetadata("MyKey", metadataValue => (string)metadataValue == "MyValue")); 

すべてのHasError()メソッドは、見つかったErrorオブジェクトにアクセスするためのoptionalなout引数を備えています。

Successオブジェクトのハンドリング (Handling successes)

// check if the Result object contains a success from a specific type
// Resultオブジェクトが特定の型のSuccessオブジェクトを含んでいるかどうかをチェック
result.HasSuccess<MyCustomSuccess>();

// check if the Result object contains a success from a specific type and with a specific condition
// Resultオブジェクトが特定の型 かつ 指定された条件を満たすSuccessオブジェクトを含んでいるかどうかをチェック
result.HasSuccess<MyCustomSuccess>(success => success.MyField == 3);

すべてのHasSuccess()メソッドは、見つかったSuccessオブジェクトにアクセスするためのoptionalなout引数を備えています。

例外オブジェクトのキャッチとハンドリング (Handling/catching exceptions)

// check if the Result object contains an exception from a specific type
// Resultオブジェクトが特定の型の例外オブジェクトを含んでいるかどうかをチェック
result.HasException<MyCustomException>();

// check if the Result object contains an exception from a specific type and with a specific condition
// Resultオブジェクトが特定の型 かつ 指定された条件を満たす例外オブジェクトを含んでいるかどうかをチェック
result.HasException<MyCustomException>(MyCustomException => MyCustomException.MyField == 1);

すべてのHasException()メソッドは、見つかった例外オブジェクトにアクセスするためのoptionalなout引数を備えています。

パターン マッチング (Pattern matching)

var result = Result.Fail<int>("Error 1");

var outcome = result switch
{
     { IsFailed: true } => $"Errored because {result.Errors}",
     { IsSuccess: true } => $"Value is {result.Value}",
     _ => null
};

Resultをタプルに分解 (Deconstruct Operators)

var (isSuccess, isFailed, value, errors) = Result.Fail<bool>("Failure 1");

var (isSuccess, isFailed, errors) = Result.Fail("Failure 1");

ロギング (Logging)

まず、loggerを作成します。

public class MyConsoleLogger : IResultLogger
{
    public void Log(string context, string content, ResultBase result, LogLevel logLevel)
    {
        Console.WriteLine("Result: {0} {1} <{2}>", result.Reasons.Select(reason => reason.Message), content, context);
    }

    public void Log<TContext>(string content, ResultBase result, LogLevel logLevel)
    {
        Console.WriteLine("Result: {0} {1} <{2}>", result.Reasons.Select(reason => reason.Message), content, typeof(TContext).FullName);
    }
}

次に、このロガーをResultクラスのグローバル設定に登録します。

var myLogger = new MyConsoleLogger();
Result.Setup(cfg => {
    cfg.Logger = myLogger;
});

そして、以下のように使用します。

var result = Result.Fail("Operation failed")
    .Log();

これに加え、ログのコンテキストを文字列またはジェネリック型で指定することができます。より詳細な情報を提供するメッセージを渡すこともできます。

var result = Result.Fail("Operation failed")
    .Log("logger context", "More info about the result");

var result2 = Result.Fail("Operation failed")
    .Log<MyLoggerContext>("More info about the result");

ログレベルを指定することも可能です。

var result = Result.Ok().Log(LogLevel.Debug);
var result = Result.Fail().Log<MyContext>("Additional context", LogLevel.Error);

成功時または失敗時にのみログ記録することもできます。

Result<int> result = DoSomething();

// 成功時のみログ記録(ログレベルはデフォルトの 'Information')
result.LogIfSuccess();

// 失敗時のみログ記録(ログレベルはデフォルトの 'Error')
result.LogIfFailed();

FluentResultのオブジェクトに対するAssert (Asserting FluentResult objects)

FluentAssertionsとFluentResults.Extensions.FluentAssertionsパッケージを試してみてください。このパッケージはv2.0から実験フェーズを抜け正式リリースされ、Resultオブジェクトに対するAssertをfluentに行うための強力な機能拡張となっています。

対象.NETバージョン (.NET Targeting)

FluentResults 3.x 以降では、.NET Standard 2.0, 2.1をサポートしています。
.NET Standard 1.1, .NET Framework 4.6.1, 4.5で使用したい場合は、FluentResults 2.xを使用してください。

サンプルとベストプラクティス (Samples/Best Practices)

FluentResultやResultパターンを有名な・よく使われているフレームワーク・ライブラリと一緒に使う際に参考にすべきサンプルやベストプラクティスを示します。

ドメイン駆動設計に基づいたパワフルなドメインモデル

  • コマンドハンドラーを備えたドメインモデル
  • Resultオブジェクトを返すファクトリメソッドなどを使用して、ドメインの不変性を守る
  • IErrorインターフェースまたはErrorクラスから派生する独自のErrorクラスを作成することで、各エラーを一意にする
  • メソッドに失敗ケースがない場合は、Resultクラスを戻り値として使用しない
  • 「複数の失敗Resultをマージして返す」「最初の失敗Resultをすぐに返す」という2つの方法があることを覚えておく

Resultオブジェクトのシリアライズ (Serializing Result objects (ASP.NET WebApi, Hangfire))

  • ASP.NET WebController
  • Hangfire Job
  • FluentResultのResultオブジェクトをシリアライズしないようにする
  • 独自のResultDtoクラスを作成して公開する
    • どのデータが送信・シリアライズされるかを制御できるようにするため
    • 自身が作成するパブリックAPIがFluentResultのようなサードパーティーAPIに依存しないようにするため
    • 自身が作成するパブリックAPIを安定させるため

Resultオブジェクトを返す MediatR リクエストハンドラー (MediatR request handlers returning Result objects)

Result Patternに関する有用なリソース(Interesting Resources about Result Pattern)

※原文をそのまま転載

オリジナル文書のライセンス MIT License

Copyright (c) 2021 Michael Altmann

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.

6
8
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
6
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?