web-dev-qa-db-ja.com

C#でswitchステートメントに対処するための効果的なパターンを探す

場合によっては、いくつかの可能な出力が存在するある種のビジネスプロセスを実行する責任をカプセル化したサービスで終わることがあります。通常、これらの出力の1つは成功で、他の出力はプロセス自体の考えられる失敗を表します。

このアイデアを修正するには、次のインターフェースとクラスを検討してください。

  interface IOperationResult 
  {
  }

  class Success : IOperationResult 
  {
    public int Result { get; }
    public Success(int result) => Result = result;
  }

  class ApiFailure : IOperationResult 
  {
    public HttpStatusCode StatusCode { get; }
    public ApiFailure(HttpStatusCode statusCode) => StatusCode = statusCode;
  }

  class ValidationFailure : IOperationResult 
  {
    public ReadOnlyCollection<string> Errors { get; }

    public ValidationFailure(IEnumerable<string> errors)
    {
      if (errors == null)
        throw new ArgumentNullException(nameof(errors));

      this.Errors = new List<string>(errors).AsReadOnly();
    }
  }

  interface IService 
  {
    IOperationResult DoWork(string someFancyParam);
  }

返されたIServiceインスタンスを処理するには、IOperationResult抽象化を使用するクラスが必要です。これを行う簡単な方法は、単純な古いswitchステートメントを記述して、それぞれのケースで何を行うかを決定することです。

      switch (result) 
      {
        case Success success:
          Console.WriteLine($"Success with result {success.Result}");
          break;

        case ApiFailure apiFailure:
          Console.WriteLine($"Api failure with status code {apiFailure.StatusCode}");
          break;

        case ValidationFailure validationFailure:
          Console.WriteLine(
            $"Validation failure with the following errors: {string.Join(", ", validationFailure.Errors)}"
          );
          break;

        default:
          throw new NotSupportedException($"Unknown type of operation result {result.GetType().Name}");
      }

このタイプのコードをコードベースのさまざまなポイントで記述すると、基本的にはオープンクローズの原則に違反するため、混乱が生じます。

IServiceの実装がIOperationResultの新しい実装を導入することによって変更されるたびに、変更が必要ないくつかのswitchステートメントがあります。新しい機能を実装する開発者mustコードがIOperationResultインスタンスを切り替えるポイントで欠落している変更を自動的に検出できる適切に記述されたテストがない限り、その存在に注意してください。

おそらく、switchステートメントはまったく回避できます。

これは、IServiceが特定の目的で使用されている場合に簡単に実行できます。例として、アクションメソッドをシンプルで無駄のない状態にするためにASP.NETコアMVCコントローラーを作成する場合、コントローラーにサービスを挿入し、すべての処理ロジックに委任します。このように、アクションメソッドは、HTTP要求の処理、パラメーターの検証、および呼び出し元へのHTTP応答の返却のみを考慮します。このシナリオでは、ポリモーフィズムを使用することで、switchステートメントを最初から回避できます。トリックはIOperationResultを次のように変更することです:

  interface IOperationResult 
  {
    IActionResult ToActionResult();
  }

アクションメソッドは、ToActionResultインスタンスでIOperationResultを呼び出し、結果を返すだけです。

場合によっては、IService抽象化をさまざまな呼び出し元で使用する必要があり、操作の結果をどう処理するかを自由に決定できるようにする必要があります。

考えられる解決策の1つは、1つの高次関数を定義することです。これをprocessorと呼んで、IOperationResultの特定のインスタンスを処理する必要があります。 。それは次のようなものです:

  static class Processors 
  {
    static T Process<T>(
      IOperationResult operationResult,
      Func<Success, T> successProcessor,
      Func<ApiFailure, T> apiFailureProcessor,
      Func<ValidationFailure, T> validationFailureProcessor) => 
        operationResult switch
        {
          Success success => successProcessor(success),
          ApiFailure apiFailure => apiFailureProcessor(apiFailure),
          ValidationFailure validationFailure => validationFailureProcessor(validationFailure),
          _ => throw new ArgumentException($"Unknown type of operation result: {operationResult.GetType().Name}")
        };
  }

ここでの利点は次のとおりです。

  • switchステートメントが実行されるポイントは1つだけです
  • IOperationResultの新しい実装が定義されるたびに、変更する必要があるポイントは1つだけです。そうすることで、Process関数のシグネチャも変更されます。
  • 前の時点で行われた変更により、Process関数が呼び出されるいくつかのコンパイル時エラーが発生します。このエラーは修正する必要がありますが、変更するすべてのポイントをコンパイラが見つけることができると信頼できます

よりオブジェクト指向の代替策は、演算結果の使用目的ごとに1つのメソッドを追加してIOperationResultの定義を変更することです。これにより、switchステートメントをもう一度回避することができます。実際にインターフェースの新しい実装を書いています。

これは、IServiceの2つの異なるコンシューマーがあるという仮説の例です。

  interface IOperationResult 
  {
    string ToEmailMessage(); // used by the email sender service
    ICommand ToCommand(); // used by the command sender service
  }

何かご意見は ?他にもっと良い代替品はありますか?

11
Enrico Massone

問題

これらの結果クラスを同じインターフェースから派生させる目的は、インターフェースがコンシューマーが認識し、操作するものになるようにすることです。消費者は特定の実装クラスを気にしません。

ただし、インターフェースには何も含まれていません。マーカーインターフェースとして使用しています。コンシューマーとしてIOperationResultオブジェクトを受け取った場合、それで何ができますか? なしIOperationResultインターフェースは契約をまったく定義しないためです。

これは、結果クラスに同じインターフェースを共有させる目的に反します。あなたは基本的にそれらすべてを同じ契約に準拠するように強制しましたが、空の契約を適所に置いたので、それに準拠することは文字通り不可能です。


最初の解決策

あなたのProcessorsクラスは効果的にイベント委譲ホイールを再発明しようとしています。すべての結果を処理する特定のイベントハンドラー(Funcオブジェクト)を定義しています。

しかし、これはまったく同じ理由でOCPに違反しています。新しい結果タイプが開発されるたびに展開する必要があるスイッチがまだあります。すべてのコンシューマーは、新しい結果タイプの新しいハンドラーを追加する必要があります。

それはまだ同じ問題です。複雑さを増して難読化しました。


2番目のソリューション

OCPに違反する新しい方法を見つけました。これで、結果タイプが開発されるたびにコードを拡張する必要がなくなり、新しいコンシューマーが開発されるたびにインターフェースを拡張する必要があります。

何度も同じ問題です。

その上で、コアロジックはどうにかしてknow各コンシューマーが独自の結果を処理することを望んでおり、コアロジックはすべての特定の顧客が望む結果を正確に噛むため。
これにより、コアロジックの純粋な狂気につながります。これは、独自の結果(すべての個人に基づく)の処理を考慮する必要があります消費者のニーズ);つまり、OCPに加えてSRPに違反しています。


私の提案する解決策

全体として、OCPの主要な問題を完全に理解しているように思われます。これは、ソリューションと主張したどのソリューションでも回避できなかったためです。

インターフェイスを正しく開発する方法は、それをどのように使用するかによって異なります。スイッチのケースでの使用例に基づいて、あなたは主に2つのことに興味があるようです:それが成功したかどうかと、消費者にさらに通知する可能なメッセージです。そうすると、インターフェースは簡単になります。

public interface IOperationResult
{
    bool IsSuccess { get; }
    string Message { get; }
}

そして、あなたの実装は簡単になります:

public class Success : IOperationResult
{
    public bool IsSuccess => true;
    public string Message { get; set; }
}

public class ApiFailure : IOperationResult
{
    public bool IsSuccess => false;
    public string Message { get; set; }
}

ただし、オーバーエンジニアリングを回避するために、ここではより簡単な方法があります。現在のニーズに基づいて、実際には個別のクラスは必要ありません。異なるのは、結果オブジェクト自体の構造ではなく、結果オブジェクトに含まれるvaluesだけです。

ここでは、インターフェースを取り除き、単純なDTOクラスを使用する方がずっとクリーンです。ここで派生と呼ぶもの(成功、API失敗など)は、次のように、基本的に「名前付きコンストラクター」として使用する静的メソッドとして表すことができます。

public class OperationResult
{
    public bool IsSuccess { get; private set; }
    public string Message { get; private set; }

    //This ensures you can only instantiate an object via the static methods
    private OperationResult() {}

    public static OperationResult Success()
    {
        return new OperationResult()
        {
           IsSuccess = true,
           Message = String.Empty
        };
    }

    public static OperationResult ValidationFailure(string message)
    {
        return new OperationResult()
        {
           IsSuccess = false,
           Message = message
        };
    }
}

その後、必要な場所で使用できます。

if( a == b )
    return OperationResult.Success();
else
    return OperationResult.ValidationFailure("a does not equal b");

この例では、失敗した場合にのみメッセージが表示されるようにしました。これは、特定の結果に特定の値を強制する方法のほんの一例です。これにより、結果の値を、どの場合でも希望どおりに正確に設定できます。

質問で述べた状況については、上記のクラスで十分です。原則として、単純なものを過剰設計しないでください。現在、時間と労力がかかり、将来的には維持する必要があります。

29
Flater

IOperationResultインターフェースの代わりに例外を使用しないことで、この例ではあなたの人生は非常に複雑になっているようです。

サービスにValidationExceptionまたはAPIErrorExceptionをスローし、通常どおりtry catchで処理します。

さらに、そうしたとすれば、サービスの内部で特定の種類のエラーを処理することによって、サービスの内部を公開してはならないということになります。

つまり、サービスがAPIExceptionをスローするかAPIFailureを返し、プロセス全体が特定の方法で処理される必要があります。ただし、呼び出しコードは、サービスがその結果を達成するためにAPIを使用するかどうかを気にする必要はありません。

サービスは単純に例外をスローする必要があり、API障害のために例外に実装する必要のある特別なロジックは、おそらくハンドラーを注入することによって、そのサービスクラスで処理する必要があります。

このようにサービスを並べ替えると、select caseが不要になるか、呼び出しコードでキャッチスイッチを試す必要がなくなります。

public class MyService : IService<Result>
{
    private IApiErrorHandler apiErrorHandler;
    public Result Process()
    {
        try
        {
            /call api
        }
        catch(Exception ex)
        {
            apiErrorHandler(ex)
            throw;
        }
        return result;
    }
}

public class MyController
    public IActionResult  MyAction
    {
         return myService.Process(); //rely on error handler to convert errors to http/json
    }

純粋に例外のフォーマットがクライアントに返され、変更された場合、おそらく検証エラー用の別のHTTPコードですか?次に、すべてをエラーハンドラーで処理するか、サービスを呼び出す前に検証を確認します

6
Ewan

これは長くて非常に興味深い質問で、段階的な研究を公開しました。

問題を要約すると:

  • IServiceはいくつかの処理を行い、IOperationResultを返します
  • IOperationResultに応じて、異なる結果後のアクションを実行する必要があります
  • 具体的なアクションは呼び出しコンテキストに依存するため、アクションをIOperationResultに多態的に実装することはできません。
  • 回避策として、多くのswitchが使用されますが、これは [〜#〜] ocp [〜#〜] の観点からの問題であり、おそらく- LoD

残念ながら、この問題を一般的な方法で解決するための特効薬はありません。アクションがコンテキストと結果に依存するという事実は、アクションを決定するために両方について知っておく必要があることを意味します。組み合わせ爆発。

あなたはおそらくより良い場所またはより良い決定方法を見つけることができますが、新しいIOperationResultがある場合、またはサービスを呼び出す新しいコンテキストがある場合は、常に新しいアクションを追加する必要があります。

複数の場所で実行する必要のある定義済みアクションのファミリーがある場合、IOperationResultを処理するために、スイッチをある種の定義済み 責任の連鎖 に置き換えることができます。しかし、アクションがすべてのスイッチで異なる場合、これはオーバーヘッドであり、スイッチを変更する代わりにハンドラーを追加します。その場合、スイッチは非常にまともなアプローチのようです。

この種の問題については、イベント駆動型の設計を検討する価値があります。イベントはフローに関する情報(つまり、関連するサービス呼び出しのシーケンスといくつかのステータス情報)を提供し、プロセッサーはイベントをデキューしてイベントハンドラー(これも責任の連鎖、またはテーブルロジック)に渡します。このイベントは、呼び出されるサービスまたは要約される結果を示します。全体が単純になるわけではありません。しかし、新しい種類のIOperationResultを追加すると、新しいハンドラーをいくつか追加するだけで(実行するさまざまな種類のアクションごとに1つ)、イベントベースのアーキテクチャが残りの処理を実行します。

5
Christophe

Flatersの回答は、質問に対しては正しいものの、一般的な問題を実際に解決するものではありません。

同じパターンを実装したいが、文字列ではなく強く型付けされた「メッセージ」を使用する場合はどうでしょうか?例えば。 IOperationResultIOperationResult<T>になります。

この「メッセージ」をジェネリックパラメーターでラップした後で別のタイプに投影したい場合はどうなりますか?

(Ewanによって指摘された)1つの解決策は、例外プログラミングフローです。これは一般に悪い習慣と見なされており、 ASP.NETチーム には好まれていません。また、コールスタックの1つ上の「層」だけを取得します。アプリケーションの複雑さによっては、これを何回かキャッチしてスローする必要がある場合があり、キャッチされなかった場合の表面積が大きくなります。ブルー。

ビジター(アンチ)パターンは使用できますが、ソリューションのアーキテクチャに関するさまざまな前提に依存しています。たとえば、IOperationResult<T>の実装とVisitingクラスの両方は、互いを認識させるために同じアセンブリに存在する必要があります。

あなたが提案する解決策は、醜い一方で機能し、現在の形式(8.0)ではC#で必要なトレードオフになる可能性があります。 C#にはニースソリューションはありませんが、識別された共用体や閉じた型をサポートしていません。 プロポーザル があります。

新しい実装のリスクを軽減するためにできる最善の方法は、switchが発生する場所を制限し、できればアプリケーションの境界で、型を投影するための一般的な拡張を定義し、パターンを使用している場所を慎重に検討することです。

2
Durdsoft

Open/Closeプリンシパルに注目する価値があるのは、たとえばマイクロサービスの場合、不要な複雑さを追加してKISSに違反する可能性が高い、より大きなコードベースでのメリットのみです。

20年前に、switchステートメントを削除しました。最近、それらをより小さなコードベース(C++のようにcよりも多い)で使用し、パブリックな純粋な静的メソッドと一緒に使用して、結果に満足しています。

1
user1496062