先日、ソースコードのメンテナビリティについてのエントリを書きましたが、dankogaiさんから「で、具体的にどんなコード書いてるの?」という指摘がありました。

返信エントリでは、「DataSpiderはオープンソースではないのでソースコードをそのまま出すことはできない」と書いたのですが、よく考えたら、一部エッセンスを抜き出してサンプルコードとして紹介することはできるので、最近私が書いたコードの中で、メンテナビリティに関係するコードを紹介したいと思います。

サンプルはC# / Silverlight で書いていますが、オブジェクト指向のプログラミングに親しんでいる人であれば誰にとってもわかりやすくなるよう、心がけて説明してみたいと思います。特に統合IDEやフォトレタッチソフト等、クライアントのコードだけでも数十万行規模になるような複雑度の高いクライアントを開発する可能性のある方、それ以外にも、複数のオブジェクトの間に複雑なインタラクションが発生するようなソフトウェアの開発を考えている方には参考になる部分もあるかと思います。
※ ソースコードの行数が正しく表示されない場合にはブラウザの幅を広げると正しく表示されます。なお、ソースコードの構成をシンプルにするため今回のサンプルではViewModelは使用していません。

目次
コンポーネント間のインタラクションの管理
最も原始的な実装方法: コンポーネントの相互参照
Mediatorパターン
Role Objectパターンの応用
ビューのテストを巡る課題
UIサービスの抽出とビューのテストケース

■ コンポーネント間のインタラクションの管理
複雑なGUIクライアントを開発する際、まず課題になるのは、「コンポーネント間のインタラクションをどのように管理するか」ということです。

例えば次の画面は、DataSpiderの開発クライアントの画面です(かっこいいですね!:))。

StudioForWeb

DataSpiderはデータ連携処理をビジュアルに定義・実装できるミドルウェアなので、プロジェクトの内容を管理する[プロジェクトエクスプローラ]、データ連携の処理をヴィジュアルに定義する画面中央部の[ドキュメントペイン](キャプチャでは処理の[スクリプト]という名前のフローを定義するが[スクリプトエディタ]がひとつ開かれている)、選択されたアイコンのプロパティを表示する[プロパティインスペクタ]、定義したスクリプトの実行結果を表示する[実行履歴]と[実行ログ]、スクリプトに配置する部品が並んだ[ツールパレット]などのコンポーネントが表示されています。

この画面の中だけでも、コンポーネント同士の間にはいくつものインタラクションがあります。ここではまず、次の3つのインタラクションについて考えていきます。

  1. 画面左側のプロジェクトエクスプローラでアイコンがダブルクリックされたら
    1. 中央のドキュメントペインにスクリプト編集用のスクリプトエディタを開く
  2. 画面中央のスクリプトエディタ内のアイコンをクリックしたら
    1. 左下のプロパティインスペクタにアイコンの設定情報を表示
    2. 画面中央下部左側の実行履歴で該当するアイコンを選択

■ 最も原始的な実装方法: コンポーネントの相互参照
コンポーネント間のインタラクションの最も原始的な実装方法は、コンポーネント同士が互いを参照する相互参照パターンです。今回の例で登場する4つのコンポーネントは、次のようにそれぞれの参照を保持し、コンポーネントで発生したイベントの結果、他のコンポーネントにどのような作用があるのかはイベント発生元のコンポーネントに記述されています。

コンポーネント同士の相互参照の紐付けは、アプリケーション全体を表すクラスであるScriptDesignerによって行われてます。

public partial class ScriptDesigner : UserControl
{
  public ScriptDesigner()
  {
    InitializeComponent();
    SetColleagueComponents();
  }

  private void SetColleagueComponents()
  {
    PropertyInspector.ProjectExplorer = ProjectExplorer;
    DocumentPane.ProjectExplorer = ProjectExplorer;
    ExecutionHistory.ProjectExplorer = ProjectExplorer;

    ProjectExplorer.PropertyInspector = PropertyInspector;
    ...
  }
}

public partial class ProjectExplorer : UserControl
{
  internal PropertyInspector PropertyInspector { private get; set; }
  ...

  internal ProjectExplorer()
  {
    InitializeComponent();

    new MouseClickManager(WorkspaceTree).DoubleClicked += (sender, args) =>
    {
      if (WorkspaceTree.SelectedItem is ScriptModel)
      {
        // 1-a: ドキュメントペインにスクリプト編集用のエディタを開く
        DocumentPane.OpenScript((ScriptModel)WorkspaceTree.SelectedItem);
      }
    };
  }
}

public partial class DocumentPane : UserControl
{
  internal ProjectExplorer ProjectExplorer { private get; set; }
  ...

  internal DocumentPane()
  {
    InitializeComponent();
  }

  public void OpenScript(ScriptModel script)
  {
    ScriptEditor editor = new ScriptEditor(script);
    editor.SelectedItemChanged += (sender, args) =>
    {
      EditorItemModel selected = args.NewValue.Model;

      // 2-a: プロパティインスペクタにアイコンの設定情報を表示
      PropertyInspector.ShowProperty(selected.GetAdapter<PropertyTable>());

      // 2-b: 実行履歴で該当するアイコンを選択
      ExecutionHistory.SetSelectedItem(selected);
    };

    OpenEditor(editor);
  }
}

この実装方式は、ごくシンプルなインタラクションしか発生せず、将来的に拡張される可能性の少ないアプリケーションの場合には有効に機能することがありますが、コンポーネント同士の結合度が高く、インタラクションに関するコードがレイアウトその他のビューのコードと混在するため、アプリケーションが複雑化するにつれ、コードがスパゲティ化する恐れがあります。

■ Mediatorパターン
相互参照パターンの課題を解決する方法の一つに、Mediatorパターンを使う方法があります。Mediatorクラスにコンポーネント間のインタラクションをカプセル化することで、コンポーネント間の結合度を低め、インタラクションに関するコードを一元管理することができるようになります。

Mediatorパターンを使うことにより、ある程度複雑なインタラクションの発生するアプリケーションも美しく記述することができます。DataSpiderのクライアントでも、2000年から開発していた初期のバージョンではMediatorパターンが使われていました。

internal class Mediator
{
  internal ProjectExplorer ProjectExplorer { private get; set; }
  internal PropertyInspector PropertyInspector { private get; set; }
  internal DocumentPane DocumentPane { private get; set; }
  internal ExecutionHistory ExecutionHistory { private get; set; }

  internal void OpenScript(ScriptModel script)
  {
    // 1-a: ドキュメントペインにスクリプト編集用のエディタを開く
    DocumentPane.OpenScript(script);
  }

  internal void SelectEditorIcon(EditorItemModel item)
  {
    // 2-a: プロパティインスペクタにアイコンの設定情報を表示
    PropertyInspector.ShowProperty(item.GetAdapter<PropertyTable>());

    // 2-b: 実行履歴で該当するアイコンを選択
    ExecutionHistory.SetSelectedItem(item);
  }
}

public partial class ScriptDesigner : UserControl
{
  private readonly Mediator mediator;

  private readonly ProjectExplorer projectExplorer;
  ...

  public ScriptDesigner()
  {
    InitializeComponent();

    this.mediator = new Mediator();
    this.projectExplorer = new ProjectExplorer(mediator);
    ...
  }
}

public partial class ProjectExplorer : UserControl
{
  private readonly Mediator mediator;

  internal ProjectExplorer(Mediator mediator)
  {
    this.mediator = mediator;
    this.mediator.ProjectExplorer = this;

    InitializeComponent();

    new MouseClickManager(WorkspaceTree).DoubleClicked += (sender, args) =>
    {
      if (WorkspaceTree.SelectedItem is ScriptModel)
      {
        mediator.OpenScript((ScriptModel)WorkspaceTree.SelectedItem);
      }
    };
  }
}

public partial class DocumentPane : UserControl
{
  private readonly Mediator mediator;

  internal DocumentPane(Mediator mediator)
  {
    this.mediator = mediator;
    this.mediator.DocumentPane = this;

    InitializeComponent();
  }

  public void OpenScript(ScriptModel script)
  {
    ScriptEditor editor = new ScriptEditor(script);
    editor.SelectedItemChanged += (sender, args) =>
    {
      mediator.SelectEditorIcon(args.NewValue.Model);
    };

    OpenEditor(editor);
  }
}

しかし、Mediatorパターンにも欠点 - というよりも限界、があります。Mediatorパターンの欠点は、アプリケーションを構成するコンポーネントの数が増えたり、インタラクションが複雑化するにつれ、Mediatorクラスが肥大化していくことです。また、インタラクションがMediatorクラス内に記述されるため、外部からの拡張が制限されます。

■ Role Objectパターンの応用
そこで登場するのが、Role Objectパターンを応用した次のような実装方法です。アプレッソでは2004年から開発開始したDataSpider Servistaにおいて、Skypeの公式Java APIの開発などで知られる久納孝治さん(現在はイーフローに勤務)の提案により、この方式が採用されました。

この方式の核となるクラスは次のようになります。ロールごとのサービスを表すIService、サービス取得の窓口となるIServices、サービス登録の窓口となるIServiceRegister、IServicesとIServiceRegisterの実装クラスとなるServiceContainerが定義されています。

※ Role Objectパターンの詳細についてはオリジナルのテキストを参照ください。テキストで示されている内容とDataSpiderでの実装方式の差異については末尾のyojik_さんとのTwitterでのやり取りを参照ください。

public interface IService
{
}

public interface IServices
{
  TType GetService<TType>() where TType : IService;
}

public interface IServiceRegister
{
  void RegisterService<TType service>() where TType : IService;
}

public class ServiceContainer : IServices, IServiceRegister
{
  private readonly Dictionary<Type, IService> services
    = new Dictionary<Type, IService>();

  public ServiceContainer()
  {
  }

  public TType GetService<TType>() where TType : IService
  {
    if (services.ContainsKey(typeof(TType)))
    {
      return (TType)services[typeof(TType)];
    }

    return default(TType);
  }

  public void RegisterService<TType>(TType service)
      where TType : IService
  {
    Type serviceType = typeof(TType);
    if (services.ContainsKey(serviceType))
    {
      services.Remove(serviceType);
    }

    services.Add(serviceType, service);
  }
}

次に、アプリケーションで提供されるサービスのインターフェースとそのコンクリートクラスを実装します。今回の例で取り上げている3つのインタラクションはいずれも選択に関するものなので、ISelectionServiceとSelectionServiceを次のように定義します。

public interface ISelectionService : IService
{
  event ValueChangedEventHandler<Model> Selected;
  event ValueChangedEventHandler<Model> Activated;

  void Select(Model model);
  void Activate(Model model);
}

public class SelectionService : ISelectionService
{
  public event ValueChangedEventHandler<Model> Selected = delegate { };
  public event ValueChangedEventHandler<Model> Activated = delegate { };

  private Model selectedModel;
  private Model activatedModel;

  public void Select(Model model)
  {
    Model prevModel = selectedModel;
    selectedModel = model;
    var args = new ValueChangedEventArgs<Model>(prevModel, selectedModel);
    Selected(this, args);
  }

  public void Activate(Model model)
  {
    Model prevModel = selectedModel;
    activatedModel = model;
    var args = new ValueChangedEventArgs<Model>(prevModel, activatedModel);
    Selected(this, args);
  }
}

ここから先はこれまでの実装方法と同様、Role Objectパターンに対応した形でコンポーネントを実装していきます。

public partial class ScriptDesigner : UserControl
{
  private readonly ServiceContainer serviceContainer;

  private readonly ProjectExplorer projectExplorer;
  ...

  public ScriptDesigner()
  {
    InitializeComponent();

    this.serviceContainer = new ServiceContainer();
    this.projectExplorer = new ProjectExplorer(serviceContainer);
    ...
  }
}

public partial class ProjectExplorer : UserControl
{
  private readonly ISelectionService selectionService;

  internal ProjectExplorer(IServices services)
  {
    this.selectionService = services.GetService<ISelectionService>();

    InitializeComponent();

    new MouseClickManager(WorkspaceTree).DoubleClicked += (sender, args) =>
    {
      if (WorkspaceTree.SelectedItem is ScriptModel)
      {
        selectionService.Activate((ScriptModel)WorkspaceTree.SelectedItem);
      }
    };
  }
}

public partial class DocumentPane : UserControl
{
  private readonly ISelectionService selectionService;

  internal DocumentPane(IServices services)
  {
    this.selectionService = services.GetService<ISelectionService>();

    InitializeComponent();

    selectionService.Activated += (sender, args) =>
    {
      if (args.NewValue is ScriptModel)
      {
        // 1-a: ドキュメントペインにスクリプト編集用のエディタを開く
        OpenScript((ScriptModel)args.NewValue);
      }
    };
  }

  public void OpenScript(ScriptModel script)
  {
    ScriptEditor editor = new ScriptEditor(script);
    editor.SelectedItemChanged += (sender, args) =>
    {
      selectionService.Select(args.NewValue.Model);
    };

    OpenEditor(editor);
  }
}

public partial class PropertyInspector : UserControl
{
  private readonly ISelectionService selectionService;

  internal PropertyInspector(IServices services)
  {
    this.selectionService = services.GetService<ISelectionService>();
    this.selectionService.Selected += (sender, args) =>
    {
      // 2-a: プロパティインスペクタにアイコンの設定情報を表示
      ShowProperty(args.NewValue.GetAdapter<PropertyTable>());
    };

    InitializeComponent();
  }
}

public partial class ExecutionHistory : UserControl
{
  private readonly ISelectionService selectionService;

  internal ExecutionHistory(IServices services)
  {
    this.selectionService = services.GetService<ISelectionService>();
    this.selectionService.Selected += (sender, args) =>
    {
      if (args.NewValue is EditorItemModel)
      {
        // 2-b: 実行履歴で該当するアイコンを選択
        SetSelectedItem((EditorItemModel)args.NewValue);
      }
    };

    InitializeComponent();
  }
}

Role Objectパターンを使った実装の特徴は、ロール(サービス)ごとに実装が独立するため、Mediatorパターンと比べてソースコードの見通しが良くなることと、既存のコードに手を加えることなく、外部から機能の拡張が可能なことです。各コンポーネントとも、サービスのインターフェースとしか結合していないため、新規のコンポーネントを追加する場合にも、そのコンポーネントでどのようなイベントが発生したとき、どのサービスにどのようなメッセージを送信すれば良いのか、また、コンポーネントはどのサービスのイベントを監視し、どのイベントが発生した時にどのように動作すればよいのかをすべて新規に追加するコンポーネントの中に記述することができます。

こうした特長により、サードパーティやエンドユーザーによるプラグイン開発が行いやすくなる他、新しいバージョンでコンポーネントを追加する際にも相互参照 / Mediatorパターンと比べて修正の範囲を狭くすることができ、結果として新機能追加によるデグレードのリスクや、パッチリリースの際のテスト範囲を小さくすることができます。

また、Mediatorパターンが「すべてのコンポーネントはMediatorクラスに依存する」という祖粒度な依存関係を持つのに対し、Role Objectパターンでは「どのコンポーネントがどのサービスに依存しているか」が定義されるため、より細粒度な依存関係の記述が可能になります。

■ ビューのテストを巡る課題
今日におけるメンテナビリティの議論はテストと切り離して語ることはできないものとなってきています。GUIアプリケーションにおいては、モデルやコントローラーについては通常のプログラムと同様にテストケースを作成することができますが、ビューのテストをどのように自動化するか、ということがしばしば課題になります。

ビューのレイヤの自動テストについては、マクロレコーダー/プレイヤーを用いたテストツールなども市販されていますが、プログラムで単体テストが書けるに越したことはありません。

例えば、「プロジェクトをデプロイ(サーバに登録)する」ための次のようなコードを考えてみます。

public partial class ProjectExplorer : UserControl
{
  ...
  internal ProjectExplorer(IServices services)
  {
    ...
  }

  public void Deploy()
  {
    ProjectModel project = SelectedProject;
    // 1. 権限がなければエラーダイアログが表示される
    if (!securityService.CanDeploy(project, userService.CurrentUser))
    {
      DialogUtil.ShowErrorDialog(
        "プロジェクトをサービスとして登録する権限がありません。");
      return;
    }

    if (!project.IsModified)
    {
      project.Deploy();
    }
    else
    {
      // 2. プロジェクトが更新されていれば保存確認ダイアログが表示される
      ConfirmDialogResult confirmResult = DialogUtil.ShowConfirmDialog(
        "プロジェクトは更新されています。プロジェクトを保存しますか?");
      if (confirmResult != ConfirmDialogResult.Yes)
      {
        return;
      }

      // 3. コンフリクトが発生した場合には詳細ダイアログが表示される
      ConflictStatus status = project.CheckConflict();
      switch (status)
      {
        case ConflictStatus.NoConflict:
          break;
        case ConflictStatus.ProjectConflict:
          ShowProjectConflictInfo(project);
          return;
        case ConflictStatus.ScriptConflict:
          ShowScriptConflictInfo(project);
          return;
        default:
          throw new InvalidOperationException(
              "Unsupported ConflictStatus: status=" + status);
      }

      project.Save();
      project.Deploy();
    }
  }
}

Deployメソッドの中では、現在ログインしているユーザにプロジェクトの保存権限がなければその旨エラーを表示し、プロジェクトが更新されていれば保存が必要である旨、ダイアログを表示し、プロジェクトを保存する場合には現在の作業内容とサーバにコミットされている最新版との間にコンフリクトがあればコンフリクトの詳細を表示して終了します。

プロジェクトの保存処理やデプロイの処理はモデルのレイヤーであるProjectModel#Save()、ProjectModel#Deploy()の2つのメソッドに対するテストケースを書くことができます。ですが、

1. 権限がなければエラーダイアログが表示されること
2. プロジェクトが更新されていれば保存確認ダイアログが表示されること
3. コンフリクトが発生した場合にはコンフリクトの状態に応じた適切な詳細ダイアログが表示されること

などについては、Deployメソッドの処理中にダイアログが表示されてユーザーからの応答待ちになってしまうため、テストケースによる自動テストを行うことが困難です。

■ UIサービスの抽出とビューのテストケース
ビューレイヤーのコードをよりテストしやすくするため、現在開発中のDataSpider Servista 3.0SP1では、UI関連のサービスをUIサービスとして抽出し、上述のダイアログ表示確認等のテストを自動化できるようしています。

まず、次のようにIUiService / UiServiceを定義し、ダイアログ表示などのユーザーとの対話型の処理をカプセル化します。

public interface IUiService : IService
{
  void ShowErrorDialog(string message);
  ConfirmDialogResult ShowConfirmDialog(string message);
  void ShowProjectConflictInfo(ProjectModel project);
  void ShowScriptConflictInfo(ProjectModel project);
}

public class UiService : IUiService
{
  public void ShowErrorDialog(string message)
  {
    DialogUtil.ShowErrorDialog(message);
  }

  public ConfirmDialogResult ShowConfirmDialog(string message)
  {
    return DialogUtil.ShowConfirmDialog(message);
  }

  public void ShowProjectConflictInfo(ProjectModel project)
  {
    // プロジェクトのコンフリクト情報をダイアログ表示
  }

  public void ShowScriptConflictInfo(ProjectModel project)
  {
    // プロジェクト内のスクリプトのコンフリクト情報をダイアログ表示
  }
}

次に、ProjectExplorer#Deploy()に記述されているロジックを、テストケースを含む、プロジェクトエクスプローラ以外の箇所からも呼び出せるよう、メニューやツールバーから呼び出されるコマンドなどの各種コマンドを管理するICommandServiceに移し、ユーザとの対話型処理の箇所はUIサービスに処理を委譲します。

internal class CommandService : ICommandService
{
  private readonly Dictionary<CommandKey, ICommand> commandMap =
    new Dictionary<CommandKey, ICommand>();

  private readonly IServices services;
  private readonly ISecurityService securityService;
  private readonly IUserService userService;
  private readonly IContextService contextService;

  internal CommandService(
        IServices services,
        ISecurityService securityService,
        IUserService userService,
        IContextService contextService)
  {
    this.services = services;
    this.securityService = securityService;
    this.userService = userService;
    this.contextService = contextService;
    this.commandMap[CommandKey.Deploy] = CreateDeployCommand();
  }

  public ICommand GetCommand(CommandKey key)
  {
    if (!commandMap.ContainsKey(key))
    {
      throw new ArgumentException("Command not found. key=" + key);
    }

    return commandMap[key];
  }

  private ICommand CreateDeployCommand()
  {
    return new SimpleCommand()
    {
      ExecuteAction = param =>
      {
        IUiService uiService = services.GetService<IUiService>();

        ProjectModel project = contextService.SelectedProject;
        // 1. 権限がなければエラーダイアログが表示される
        if (!securityService.CanDeploy(project, userService.CurrentUser))
        {
          uiService.ShowErrorDialog(
            "プロジェクトをサービスとして登録する権限がありません。");
          return;
        }

        if (!project.IsModified)
        {
          project.Deploy();
        }
        else
        {
          // 2. プロジェクトが更新されていれば保存確認ダイアログが表示される
          ConfirmDialogResult confirmResult = uiService.ShowConfirmDialog(
            "プロジェクトは更新されています。プロジェクトを保存しますか?");
          if (confirmResult != ConfirmDialogResult.Yes)
          {
            return;
          }

          // 3. コンフリクトが発生した場合には詳細ダイアログが表示される
          ConflictStatus status = project.CheckConflict();
          switch (status)
          {
            case ConflictStatus.NoConflict:
              break;
            case ConflictStatus.ProjectConflict:
              uiService.ShowProjectConflictInfo(project);
              return;
            case ConflictStatus.ScriptConflict:
              uiService.ShowScriptConflictInfo(project);
              return;
            default:
              throw new InvalidOperationException(
                "Unsupported ConflictStatus: status=" + status);
          }

          project.Save();
          project.Deploy();
        }
      }
    };
  }
}

UIサービスを抽出し、ProjectExplorer#Deploy()に記述されていたロジックをコマンドとしてICommandServiceに移したことにより、例えば次のような形で、これまでテストケースの作成が困難だったユーザーとの対話型の処理の箇所についてもテストケースを作成することができるようになります。

public class SampleTest
{
  [TestClass]
  public class SampleTest : BaseTest
  {
    [TestMethod]
    public void ProjectDeploy_ShowAuthenticationException()
    {
      // テスト用のUIサービス
      TestUiService uiService = new TestUiService();
      RegisterService(uiService);

      IContextService contextService = GetService<IContextService>();
      contextService.CurrentProject = <保存権限のないプロジェクト>;
      IUserService userService = GetService<IUserService>();
      userService.CurrentUser = <保存権限のないユーザ>;

      ICommandService commandService = GetService<ICommandService>();
      ICommand command = commandService.GetCommand(CommandKey.Deploy);
      command.Execute(null);

      Assert.AreEqual(1, uiService.ErrorDialogCount);
      Assert.AreEqual("プロジェクトをサービスとして登録する権限が...",
        uiService.Errors[0].Message);
    }
  }
}

以上が、コンポーネント間のインタラクションに関するDataSpiderのこれまでの取り組みです。同様の課題に取り組んでいる方の参考になれば幸いです。

※ 2012/02/02 01:46追記
todeskingさんがクラス図とシーケンス図を描いてくれました。ありがとうございます。

※ 2012/02/02 16:49追記
Role Objectパターンについてのyojik_さんとのやり取りを追記します。