web-dev-qa-db-ja.com

設計パターン:依存関係をコマンドパターンに挿入する方法

私はプログラミング言語にかなり慣れていないため、デザインパターンについての知識が限られているため、次の問題の解決にご協力いただければ幸いです。

さまざまなサービスのグループで動作するアプリケーションがあります。アプリケーションの機能の1つは、ユーザーに利用可能なサービスのすべてのメソッドを呼び出すためのインターフェースを提供することです。したがって、既存のコードを変更するのではなく、新しいクラスを追加するだけで新しいコマンドを追加できるので、コマンドパターンを使用したいと思います。各サービスコマンドのパラメーターは、コンストラクターに渡されます。

コマンド:

public interface ICommand {
    void Execute();
}

public abstract class Command<T> : ICommand {
    public T Service { get; set; }

    public abstract void Execute() { /* use service */ }
}

public class Command1 : Command<Service1> {
    T1 param1;
    ...

   public Command1(T1 param1, ...) { /* set parameters */ }

   public override void Execute() { /* call first service1 method */ }
}

...

public class Command2 : Command<Service2> {
    T2 param1;

    ...

   public override void Execute() { /* call first service2 method */ }
}

...

利点は、ユーザーがアプリケーションのインターフェースを知らなくてもコマンドのグループをインスタンス化し、後でサービスが設定されたときにそれらを実行できることです。問題は、サービスをエレガントに注入する方法がわからないことです。
アプリケーションは主に、サービスの開始と停止、および各サービスのインスタンスの中央管理を担当します。

応用:

public class Application {
    S1 Service1;
    S2 Service2,
    ...

    public void StartService(/* params */) { /* ... */ }
    public void StopService(/* params */) { /* ... */ }
    ...
}

質問:

だから私の質問は、コマンド内で正しいサービスをどのように取得するのですか?
ある種の依存関係注入、サービスロケータ、またはビルダーパターンの使用を考えましたが、これらのパターンを使用したことがなく、この場合の最良の解決策とそれを正しく実装する方法がわかりません。

更新:

@Andyと@Andersのコメントのおかげで、パラメーターにはCommandクラスを使用し、ロジックにはCommandHandlerクラスを使用するのがおそらく最善の解決策です。利点は、Applicationクラス内でコマンドハンドラーをインスタンス化し、ハンドラーのコンストラクターで正しいサービスを渡すことができることです。また、サービスを知らなくても、アプリケーションの外部でコマンドを作成し、このコマンドをアプリケーションに渡して実行することができます。
コマンドを正しいコマンドハンドラーにマッピングするには、@ Andyの提案に従ってCommmandBusを使用しますが、Map<Class<? extends CommandHandler<?>>, CommandHandler<? extends Command>>のようなテンプレートマップがないため、C#でのJavaの例の実装には問題があります。 C#で。

それでは、コマンドをC#のハンドラーにマップするためのクリーンなソリューションは何ですか?コマンドをアップキャストする必要があるため、以下の解決策はあまり好きではありません。

私の更新されたコード:

public interface ICommand { }

public class ConcreteCommand : ICommand {
    public Type1 Param1 { get; set; }
    public Type2 Param2 { get; set; }
    /* some more parameters */
}

public interface ICommandHandler<in TCommand> {
    Task Handle(TCommand command);
}

public class ConcreteCommandHandler : ICommandHandler<ConcreteCommand> {
    private readonly S1 service;

    public ConcreteCommandHandler(S1 service) {
        this.service = service;
    }

    public Task Handle(ConcreteCommand command) {
        return service.DoSomething(command.Param1, ...);
    }
}


public class CommandBus {
    /* BAD: Return type of command handler hardcoded */
    private readonly IDictionary<Type, Func<ICommand, Task>> handlerMap = new Dictionary<Type, Func<ICommand, Task>>();

        public void RegisterCommandHandler<TCommand>(ICommandHandler<TCommand> handler) where TCommand: ICommand
        {
            Type commandType = typeof(TCommand);

            if (handlerMap.ContainsKey(commandType))
                throw new HandlerAlreadyRegisteredException(commandType);

            /* BAD: Narrowing cast */
            handlerMap.Add(commandType, command => handler.Handle((TCommand) command));
        }

        public Task Dispatch<TCommand>(TCommand command) where TCommand : ICommand
        {
            Type commandType = typeof(TCommand);

            if (!handlerMap.TryGetValue(commandType, out Func<ICommand, Task> handler))
                throw new HandlerNotRegisteredException(commandType);

            return handler(command);
        }
}


public class Application {
    private CommandBus bus;
    private S1 service1;
    private S2 service2;
    ...

    private void InitializeBus() {
        bus.RegisterCommandHandler(new ConcreteCommandHandler(service1))
        ...
    }

    public void ExecuteCommand<TCommand>(TCommand command) where TCommand : ICommand {
        bus.Dispatch(command);
    }

    ...
}
3
Martin

質問に答える前に、最初に開発者がコマンドパターンで達成しようとしている目標を最初に知っておく必要があります。多くの場合、パターンの目的は、モジュールを相互に分離し、システムへのインテント(コマンド)の実行に適した抽象化を提供することです。

コマンドがアプリケーションにどのように適合するかを理解するために、ユーザーを登録できる架空のアプリケーションをリファクタリングして、コマンドパターンを統合しましょう。 Java言語、私は長い間C#でプログラミングしていませんでした。この非常に単純なアプリケーションには、サービスとコントローラーがあります。

class UserService {
    private UserRepository userRepository;
    private PasswordHashService passwordHashService;

    public User registerUser(
        String firstName,
        String lastName,
        String email,
        String passwordInPlainText
    ) {
        User userToBeRegistered = new User();
        userToBeRegistered.setId(userRepository.retrieveNewId());
        userToBeRegistered.setFirstName(firstName);
        userToBeRegistered.setLastName(lastName);
        userToBeRegistered.setEmail(email);
        userToBeRegistered.setPassword(passwordHashService.hash(passwordInPlainText));

        userRepository.save(userToBeRegistered);

        return userToBeRegistered;
    }
}

class UserController {
    private UserService userService;

    public Response<User> registerUser(FormInput formInput) {
        return new Response<>(userService.registerUser(
            formInput.getString("first_name"),
            formInput.getString("last_name"),
            formInput.getString("email"),
            formInput.getString("password")
        ));
    }
}

サービス層には、設計上の問題があります。このメソッドは、非常に特定の順序で4つの文字列引数を受け取ります。これにより、メソッドの呼び出し元がサービスに結合され、新しいオプションの引数を追加してメソッドをリファクタリングすることで、複数の場所からregisterUserを呼び出した場合に困難になる場合があります。

サービスメソッドの引数の数を減らすために、2つの層の間のデータメッセンジャーとして機能する特別なDTOオブジェクトを導入しましょう。便宜上、RegisterUserCommandという名前を付けます。オブジェクトの構造は次のとおりです。

class RegisterUserCommand {
    private String firstName;
    private String lastName;
    private String email;
    private String passwordInPlainText

    // getters and setter are omitted
}

これにより、サービスメソッドの設計が変更され、次のようになります。

public User registerUser(RegisterUserCommand command) {
    User userToBeRegistered = new User();
    userToBeRegistered.setId(userRepository.retrieveNewId());
    userToBeRegistered.setFirstName(command.getFirstName());
    userToBeRegistered.setLastName(command.getLastName());
    userToBeRegistered.setEmail(command.getEmail());
    userToBeRegistered.setPassword(passwordHashService.hash(
        command.getPasswordInPlainText()
    ));

    userRepository.save(userToBeRegistered);

    return userToBeRegistered;
}

そしてコントローラのメソッドは次のように変わります:

public Response<User> registerUser(FormInput formInput) {
    RegisterUserCommand command = new RegisterUserCommand();
    command.setFirstName(formInput.getString("first_name"));
    command.setLastName(formInput.getString("last_name"));
    command.setEmail(formInput.getString("email"));
    command.setPasswordInPlainText(formInput.getString("password"));

    return new Response<>(userService.registerUser(command));
}

これにより、不要な引数の位置結合が修正され、コマンドオブジェクトに新しい属性を追加するだけで、オプションの引数をユーザー登録メソッドに導入しやすくなります。オプションの引数を使用しない場所では、変更はまったく必要ありません。他の場所では、新しく追加されたプロパティを利用できます。

ただし、現在の設計には、コントローラーとサービス間の結合がまだ含まれています。この場合、それは大きな問題だとは言いませんが、完全なコマンドパターン統合について説明しているので、コードをもう少しリファクタリングします。

コマンドバスの紹介

コマンドバスのバリアントを使用せずにコマンドパターンを使用することは、ほとんど意味がありません。しかし、このコマンドバスとは何でしょうか。要するに、それは主に栄光のある service locator です。コマンドバスの一部の高度な実装では、プラグイン、ミドルウェアを使用できるため、内部を知らなくてもコマンドバス実行プロセスの機能を拡張できます。

コマンドバスは一般に2つの主要部分で構成されます。

  1. サービスレジストリ(サービスのインスタンスが存在する一部の内部コレクション)、
  2. コマンドからサービスへのマッピング(どのサービスでどのコマンドタイプを処理するかを指定する構成)。

これらの2つの部分は両方とも、ユーザーが構成する必要があります。 1.の部分では、コマンドバスにサービスインスタンスを提供する必要があります。2。の部分では、マッピングを定義する必要があります。

非常に基本的なコマンドバスの実装(本当に基本的な、プラグインのサポートなしなど)は次のようになります。

interface Command {
}

interface CommandHandler<T extends Command> {
    Object execute(T command);
}

class CommandBus {
    private Map<Class<? extends CommandHandler<?>>, CommandHandler<? extends Command>> commandHandlers;
    private Map<Class<? extends Command>, Class<? extends CommandHandler<?>>> commandToHandlerConfig;

    public CommandBus() {
        commandHandlers = new HashMap<>();
        commandToHandlerConfig = new HashMap<>();
    }

    public void registerCommandHandler(CommandHandler<? extends Command> handler) {
        Class<CommandHandler<?>> clazz = (Class<CommandHandler<?>>) handler.getClass();

        if (commandHandlers.containsKey(clazz)) {
            throw new RuntimeException("The command handler " + clazz + " is already registered.");
        }

        commandHandlers.put(clazz, handler);
    }

    public void registerCommandToCommandHandler(
            Class<? extends Command> command,
            Class<? extends CommandHandler<?>> handler
    ) {
        if (!commandHandlers.containsKey(handler)) {
            throw new RuntimeException("The command handler " + handler + " is not registered.");
        }

        commandToHandlerConfig.put(command, handler);
    }

    public <T extends Command, U> U dispatch(T command, Class<U> resultClass) {
        Class<?> commandClass = command.getClass();

        if (!commandToHandlerConfig.containsKey(commandClass)) {
            throw new RuntimeException(
                    "The command " + commandClass + " could not be executed, no handler is configured."
            );
        }

        Class<? extends CommandHandler<?>> handlerClass = commandToHandlerConfig.get(commandClass);
        CommandHandler<? super Command> handler = (CommandHandler<? super Command>) commandHandlers.get(handlerClass);

        return resultClass.cast(handler.execute(command));
    }
}

このコマンドバスを使用すると、コマンドハンドラーをレジストリに登録し(1.部分を満たす)、特定のコマンドを特定のハンドラーにマッピングする(2.部分を満たす)ことができます。その上、期待される結果クラスでコマンドを実行することができます。

実装の一部として、CommandCommandHandlerの2つのインターフェイスも導入したことに気づいたかもしれません。これらはコンパイルされた言語では必要ですが、PHPまたはPythonのような動的言語では理論的には省略できます(ただし、コマンドバスの実装-主にハンドラーの実行メソッドのメソッド)。

コマンドバスのシンプルな統合

まず、RegisterUserCommandCommand interfaceを実装させる必要があります。

class RegisterUserCommand implements Command {
    // [...] the rest remains the same
}

次に、UserServiceCommandHandlerインターフェースを実装させます。つまり、適切なexecuteメソッド実装を追加する必要があります。簡単にするために、現在のregisterUserメソッドをexecuteメソッドに変更してみましょう。

class UserService implements CommandHandler<RegisterUserCommand> {
    // [...] the rest remains the same

    public Object execute(RegisterUserCommand command) {
        // [...] the rest remains the same
    }
}

これらの変更は基本的に、コマンドとサービスをコマンドバスコンテキストで使用できるようにするために行われました。今、あなたは警告に気づいたかもしれません。クラスがジェネリックインターフェイスを実装できるのは1回だけです。そのため、基本的に、コマンドごとに1つのコマンドハンドラーが必要になります。最初はこれが厄介なことに気付くかもしれませんが、結局のところ、特定のコマンドインスタンスを処理するという単一の責任しか持たない、多くの小さなコマンドハンドラーになってしまうため、実際にはこれは非常に素晴らしいことです。

コントローラレベルでのコマンドバスの非常に基本的な統合は、次のようになります(現時点ではあまり意味がありません)。

public Response<User> registerUser(FormInput formInput) {
    RegisterUserCommand command = new RegisterUserCommand();
    command.setFirstName(formInput.getString("first_name"));
    command.setLastName(formInput.getString("last_name"));
    command.setEmail(formInput.getString("email"));
    command.setPasswordInPlainText(formInput.getString("password"));

    CommandBus commandBus = new CommandBus();
    commandBus.registerCommandHandler(userService);
    commandBus.registerCommandToCommandHandler(
        RegisterUserCommand.class,
        userService.getClass()
    );

    return new Response<>(commandBus.dispatch(command, User.class));
}

ここでは、コマンドバスのインスタンスを手動で作成し、コマンドハンドラーとコマンドハンドラーのマッピングを登録しました。基本的に、コマンドバスをプロキシとして機能させます。実際には、大きなアプリケーションでは、通常、いくつかの個別のコマンドバスのインスタンスが最大で数個(またはおそらく単一のインスタンスのみ)あり、事前に構成されており、すべての登録済みハンドラーとマッピング、およびそのような構成済みコマンドがすでに含まれています。バスが挿入され、コントローラーが次のようになります。

class UserController {
    private CommandBus commandBus;

    public Response<User> registerUser(FormInput formInput) {
        RegisterUserCommand command = new RegisterUserCommand();
        command.setFirstName(formInput.getString("first_name"));
        command.setLastName(formInput.getString("last_name"));
        command.setEmail(formInput.getString("email"));
        command.setPasswordInPlainText(formInput.getString("password"));

        return new Response<>(commandBus.dispatch(command, User.class));
    }
}

これにより、UserServiceに接続する必要がなくなり、マッピングはインフラストラクチャレベルで(構成により)行われます。これは、単一のサービス/コントローラー/コマンドハンドラーが複数のコマンドを呼び出す場合に、サービスの依存関係の数を減らすため、有益です。

構成は、たとえば、 YAML、例:このような:

- CommandBus:
  - Handlers:
    - RegisterUser: UserService
    - SomeOtherCommand: SomeOtherHandler

CommandBusは、それを正しく解析するメカニズムを提供する必要があることは明らかです。

コマンドバスのいくつかの利点

  • 減少したカップリング
  • プラグインのシームレスな追加(まともなコマンドバス実装を使用している場合)
  • 注入された依存関係の潜在的な削減
  • よりまとまりのある構成可能なコードにつながる(多くの小さなまとまりのあるコマンドハンドラー)
  • 理論的には、レイヤー間の異なるタイプの通信のためにプロジェクトを準備します。エンタープライズサービスバス経由

コマンドバスのいくつかの欠点

  • 複雑さのオーバーヘッド(レイヤーのマッピングは構成で行われ、見えにくくなります)
  • 各コマンドはコマンドバスプロキシを通過しました
  • コマンドバスの準備が整ったソリューションに行くとき、あなたはベンダーに固執します

独自のプロジェクトへのコマンドバス統合の議論中に、他の潜在的な(不利な)利点が浮かび上がることがあります。コマンドバスを使用するアプローチが特定のプロジェクトに適しているかどうかは、多くの変数に依存し、システムの設計者と話し合う必要があります。

2
Andy

コマンドはDTOとしてのみ扱う必要があります。

public class Command 
{
   public Foo SomeFoo { get;set; }
}

次に、そのdtoにビジターパターンを使用します。

public interface ICommandHandler<in TCommand>
{
    Task Handle(TCommand command);
}

編集:私は理解できない反対票を得ました、それは答えの最もクリーンな解決策です。また、型キャストとIsSubclassOfの使用は含まれません。これは、たとえばSOLIDのOpen/Closed原則に違反します。私のソリューションでは、IoCに反対しないで作業します。サービスが必要な場合は、

public class SendInvoiceCommandHandler : ICommandHandler<SendInvoiceCommand>
{
   private readonly IInvoiceService _invoiceService;

   public SendInvoiceCommandHandler(IInvoiceService invoiceService) 
   {
      _invoiceService = invoiceService;
   }

   public async Task Handle(SendInvoiceCommand cmd)
   {
      await _invoiceService.Send(cmd.InvoiceId);
   }
}

ICommandHandlerの実装は、必要なサービスを自由に呼び出し、コンストラクターを使用してそれを注入できます。

実行時にハンドラーを検索するために魔法を使いたいので、

await _cqsClient.ExeceuteAsync(new SendInvoiceCommand(invoiceId));

リシャーパーを使用する場合、このような訪問者パターンでシステムを構築するときに役立つプラグインを作成しました。 https://plugins.jetbrains.com/plugin/12156-resharpervisitorpatternnavigation

リシャーパーのホットキーを介して、DTOのインスタンスからハンドラーに直接移動できます。

したがって、具象型をより動的に登録できるIoCが必要です。 .NET CoreでバニラIoCを使用し、IServiceCollectionに拡張メソッドを記述しました。

.AddAllTransient(typeof(ICommandHandler<>), typeof(MyCommandThatPointsOutAssembly))

最初のパラメーターはインターフェイスを指し、2番目のパラメーターは、そのインターフェイスの具体的なタイプをスキャンするアセンブリのタイプです。そのコードは表示しません。ただし、アセンブリとレジスターをすべてスキャンしますICommandHandler<T>見つけることができます。同時に型検索用のキャッシュも登録します。これは、このようなコマンドランナークラスから使用されます

public CommandRunner(IServiceProvider serviceProvider, ILogger<CommandRunner> logger, Dictionary<Type, IEnumerable<RegistrationInfo>> typeScanners)
{
    _serviceProvider = serviceProvider;
    _logger = logger;

    _cache = typeScanners[typeof(ICommandHandler<>)]
        .Union(typeScanners[typeof(IQueryHandler<,>)])
        .ToDictionary(info => info.Interface.GetGenericArguments()[0], info => info.Interface);
}

基本的には、DTOがキーであり、値が具象型であるキャッシュを構築します。

その情報を取得したら、ハンドラーを実行するのは非常に簡単です

private Task ExecuteCommandInternalAsync(Command cmd, IServiceProvider serviceProvider)
{
    var handler = serviceProvider.GetService(_cache[cmd.GetType()]) as dynamic;
    return handler.Handle(cmd as dynamic);
}

編集:それらが要求されたときにICommandHandlerを構築してキャッシュを遅延ビルドすることもできます

var interface = typeof(ICommandHandler<>).MakeGenericType(cmd.GetType());
var handler = _serviceProvider.GetService(interface) as dynamic;
handler.Handle(cmd as dynamic);      
2
Anders

最初に、すべてのサービスに共通のタイプを持つためにIServiceを導入することをお勧めします。次に、ICommandを抽象メソッドSetService(IService)で拡張します。これは、Command<T>に実装されています。

   void SetService(IService service) {Service = (T)service;}

サービスのリストがアプリケーションで固定されていると仮定すると、タイプを1つずつチェックすることで、サービス割り当てを実装できます。

 void InitServiceForCommand(ICommand cmd)
 {
    Type cmdType = cmd.GetType();
    if(cmd.GetType().IsSubclassOf(typeof(Command<Service1>))
        cmd.SetService(S1);

    if(cmd.GetType().IsSubclassOf(typeof(Command<Service2>))
        cmd.SetService(S2);
    ///...
}

ユーザーが特定のコマンドを初期化してアプリケーションオブジェクトに渡した後、このメソッドをアプリケーション内で呼び出すことができるようになりました。

実行時にサービスのリストを変更できるようにする必要がある場合は、Dictionary<Type,IService>を準備してこのアプローチを拡張できます。これにより、各コマンドタイプCommand<ServiceXYY>から関連するサービスオブジェクトへのマッピングが保持されます。これはこのような解決策につながります

  Dictionary<Type,IService> serviceMap = ... // some initialization
  // ...
  void InitServiceForCommand(ICommand cmd)
  {
      cmd.SetService(serviceMap(typeof(cmd));
  }
0
Doc Brown