SourceChord

C#とXAML好きなプログラマの備忘録。最近はWPF系の話題が中心です。

MVVMな設計のTips~サービスを作ってVMの依存性を排除~

最近、色々とMVVMな設計のサンプル類を見てると、○○Serviceみたいなクラスを作って、VMが他のモジュールへの依存を極力持たないように設計している例をよく目にするようになった気がします。

ここでは、MVVMでの定番の躓きポイント「VMからのダイアログ表示どうするの?」というネタで、実際にサービスを用いたパターンでコード書いて試してみたいと思います。

また、この手のサービスを使ったパターンでは、DIコンテナ、サービスロケータなどを提供する各種ライブラリを使ったサンプルが多いです。
ですが、DependencyInjectionなどは、MVVMな設計の本質ではないと思うので、ここではそういったライブラリ類は使わず、素のWPFでの最低限なコードでサンプルを書いてみます。

色々参考にしたリンク

http://wp.qmatteoq.com/the-mvvm-pattern-dependency-injection/
https://blog.rsuter.com/recommendations-best-practices-implementing-mvvm-xaml-net-applications/
http://stackoverflow.com/questions/25366291/how-to-handle-dependency-injection-in-a-wpf-mvvm-application

あと、UWPのテンプレートを作っているTemplate10というプロジェクトでも、似たような感じでパターンを用いた設計になってます。 (Template10のサービスでは、IoCコンテナのような仕組みは使わず、シングルトンでサービスを取得するようにしているようです。)
https://github.com/Windows-XAML/Template10/tree/master/Templates%20%28Project%29/Minimal/Services/SettingsServices

作ってみる内容

とりあえず、サンプルとして以下のようなものを作ってみます。

  • ボタンを押すと、テキストボックスに入力した文字列を使ってメッセージボックスを表示

f:id:minami_SC:20160123164108p:plain

データバインディングを使わず、コードビハインドだけで書くのであれば、なんてことは無い内容です。
しかし、MVVMを意識した設計をしてると、このVMからダイアログボックスの表示だとかで躓くと思います。

  • VMから、MessageBox.Showとかやるの気持ち悪い
    • VMに思いっきりView依存なコードが出現
  • こういう処理がVMに入ると、VM単体テストなども実質不可能に
    • ⇒MSTestやらNUnitやらでテストコード走らせてると、途中でメッセージボックスが出現して、テスト中断・・・・orzとか。

最初の一歩

まずはふつうにVMにそのままメッセージボックス表示処理まで含めて書いてみます。
サンプルの中では、BindableBase/RelayCommandというクラスが出てきますが、これはそれぞれINotifyPropertyChanged/ICommandを実装した汎用的なベースクラスです。

最初のサンプルでは、MainWindowViewModel中にそのままMessageBox.Showと書いてメッセージボックスの表示を行っています。
問題なく動作はしますが、前述のとおり色々とイケてません。

ここから順を追って、VM中のViewへの依存を取り除いてきます。
MainWindow.xaml

<Window x:Class="MVVMServiceSample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:local="clr-namespace:MVVMServiceSample"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        Title="MainWindow"
        Width="400"
        Height="300"
        mc:Ignorable="d">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <StackPanel>
        <TextBox x:Name="textBox"
                 Width="120"
                 Height="23"
                 Margin="10"
                 HorizontalAlignment="Left"
                 Text="{Binding Name}"
                 TextWrapping="Wrap" />
        <Button x:Name="button"
                Width="75"
                Margin="10,5"
                HorizontalAlignment="Left"
                Content="Show"
                Command="{Binding ShowNameCommand}"/>

    </StackPanel>
</Window>

MainWindowViewModel.cs

    public class MainWindowViewModel : BindableBase
    {
        private string name;
        public string Name
        {
            get { return name; }
            set { this.SetProperty(ref this.name, value); }
        }

        private RelayCommand showNameCommand;
        public RelayCommand ShowNameCommand
        {
            get { return showNameCommand = showNameCommand ?? new RelayCommand(ShowName); }
        }

        private void ShowName()
        {
            System.Windows.MessageBox.Show($"こんにちは、{this.Name}さん");
        }
    }

サービス作成

メッセージボックス表示用のサービスを作り、View依存なコードをサービス側に移動します。

DialogServiceというクラスを作り、メッセージボックスの表示に関する処理をサービス側に移動します。

DialogService.cs

    public class DialogService
    {
        public void ShowMessage(string message)
        {
            MessageBox.Show(message);
        }
    }

MainWindowViewModel.cs

    public class MainWindowViewModel : BindableBase
    {
        private DialogService _dialogService;

        public MainWindowViewModel()
        {
            this._dialogService = new DialogService();
        }
        // 省略
        private void ShowName()
        {
            // サービス経由で、ダイアログの表示を行う。
            this._dialogService.ShowMessage($"こんにちは、{this.Name}さん");
        }
    }

サービスからIFを切り出す

DialogServiceというクラスにMessageBox表示のためのコードを分離しました。

ですが、以下のような目的で、DialogServiceの実装を別のものに差し替えたくなることもよくあるのではないか、と思います。

  • サービス側で実装する処理を、とりあえずモックで作って動かしたい
    • 例えば、インターネット経由でWebAPIから何らかのJSONデータを取得し、それをプログラム中で利用する形式のクラスに変換して返す、みたいなサービスを作る場合には、 いきなりWebAPIを叩いて、、、ってのは手間がかかるので、まずは毎回同じダミーデータを返す、単純なダミーサービスを作っておく、などというケースが考えられます。
  • VM単体テストしやすくする
    • VMでダイアログ表示などユーザー入力を必要とする処理を行っていると、そこで処理が止まるので単体テストを一気に走らせられない
    • ファイル入出力/DBアクセスなどを、ダミーの入出力に差し替えておき、テストの度にあちこちアクセスせずに実行できるようにする

などなど。。。
こういう場面は多々あるかと思います。

そこで、IDialogServiceというIFをつくり、VMはこのIFのメンバを持つように修正してみます。
また、単体テストなどで扱いやすくするために、DummyDialogServiceというクラスも作ります。
このクラスのShowMessageメソッドでは、ダイアログ表示ではなく、コンソールに文字を出力するだけの実装としておきます。

↓こんな感じ。

// IDialogService.cs
    public interface IDialogService
    {
        void ShowMessage(string message);
    }
    
// DialogService.cs
    class DialogService : IDialogService
    {
        public void ShowMessage(string message)
        {
            MessageBox.Show(message);
        }
    }
    
// DummyDialogService.cs
    public class DummyDialogService : IDialogService
    {
        public void ShowMessage(string message)
        {
            Console.WriteLine(message);
        }
    }

しかし、MainWindowViewModelのコンストラクタ内で、DialogServiceのインスタンスを生成してるので、これではVM内に直接MessageBox表示処理を書いているのと何も変わりませんよね。

        public MainWindowViewModel()
        {
            this._dialogService = new DialogService();
        }

IFを実装したクラスを、VMの外部から注入する

ということで、MainWindowViewModelは、インターフェースを通してサービスにアクセスをするだけ。サービス実体の生成は外部で行い、生成したインスタンスVMに渡してメンバに設定します。
※コンストラクタに引数を追加したので、XAMLファイルのDataContextは削除します。

DependencyInjection(DI)、依存性の注入、なんて言われるパターンの、シンプルな実例ですね。 ここでは、コンストラクタ注入の方法を使ってます。

// MainWindowViewModel.cs
    public class MainWindowViewModel : BindableBase
    {
        private IDialogService _dialogService;

        public MainWindowViewModel(IDialogService dialogService)
        {
            this._dialogService = dialogService;
        }
        // 以下略

// MainWindow.xaml.cs
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            // MainWindowViewModelに、コンストラクタ経由でDialogServiceへの依存性を注入する。
            // ※コンストラクタに引数が必要なので、XAML上ではなくコードビハインドからVMを生成する。
            this.DataContext = new MainWindowViewModel(new DialogService());
        }
    }

こうすることで、VMの依存性をサービスを通して外部に取り除くことができました。

単体テストからの利用

外部からIDialogServiceの実態を注入できるようになったので、単体テストを行う際は、DummyDialogServiceに差し替えてテストを行うことができるようになります。

ソリューションに単体テストプロジェクトを追加し、こんなテストクラスを書いてみます。
※とりあえずサンプルなので、Assertは書いてません。

    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
            var vm = new MainWindowViewModel(new DummyDialogService());
            vm.Name = "hoge";
            vm.ShowNameCommand.Execute(null);
        }
    }

こんな風に、ShowNameCommandを実行しても、アプリ実行時とは異なりメッセージボックスなどは表示せずに単体テストを続けることができます。
※補足
単体テスト中にコンソールに出力した内容は、テストエクスプローラの「出力」というリンクを押すと見ることができます。 f:id:minami_SC:20160123164124p:plain

全体のクラス構成

今回使ったクラスはこんな関係です。
f:id:minami_SC:20160123164130p:plain:w350

ウィンドウを持たせる

MessageBox.Showのメソッドもそうですが、たまにWindowのインスタンスが必要になる場面などもあるかと思います。
そんな時は、これらのサービスをView側で作成するときに、サービスに対してWindowのインスタンスを設定しておけばよいかと。

例えば、新規にサブウィンドウを開く際に、ウィンドウのオーナー設定のために親ウィンドウのインスタンスを指定したい、っってときとかですかね。

↓では、owner指定でメッセージボックスの表示をできるようにしています。

// DialogService.cs
    class DialogService : IDialogService
    {
        private Window _owner;

        public DialogService(Window owner = null)
        {
            this._owner = owner;
        }

        public void ShowMessage(string message)
        {
            if (this._owner != null) { MessageBox.Show(this._owner, message); }
            else { MessageBox.Show(message); }
        }
    }

// MainWindow.xaml.cs
    public MainWindow()
    {
        InitializeComponent();
        // MainWindowViewModelに、コンストラクタ経由でDialogServiceへの依存性を注入する。
        this.DataContext = new MainWindowViewModel(new DialogService(this));
    }

サンプルコード

今回のサンプル一式は以下の場所に置いておきました。

まとめ

とりあえず、VMがいろんなクラスに依存し始めてヤバイ!!となったら、こんな風に依存をサービスとして外部に切り分けてみるのもよいかと思います。

また、こんな風にWindowのインスタンスを持たせたサービスを作り、それをVMに渡すことで、VMからViewへの通知など、いろんな用途に応用が利くかと思います。

まずは、こうやってVMから依存性を取り除いていって、こういったパターンに慣れたらDIコンテナやらサービスロケータを使ったパターンに移っていけばいいのかな、と思います。