次の方法で共有


データ ポイント

10 年前の ASP.NET Web フォーム アプリケーションに新しい命を吹き込む

Julie Lerman

コード サンプルのダウンロード

Julie Lermanレガシ コードとは、あれば邪魔であり、なければ不便なものです。また、アプリケーションの機能が優れているほど、レガシ コードが存在し続ける期間も長くなります。私が初めて開発した ASP.NET Web フォーム アプリケーションは、10 年余りもの間現役を続けています。ようやくこのアプリケーションは、だれかが開発中のタブレット アプリケーションに置き換わろうとしています。しかしその前に、顧客から、新バージョンで収集する予定のデータを顧客企業が今すぐ収集できるようにする新機能の追加を依頼されました。

これは、1 つか 2 つの単純なフィールドで済む問題ではありません。既存のアプリケーション (勤務時間を追跡する複雑なタイム シート) では、チェック ボックスの動的なセットを作業のリストで定義しています。このリストは顧客が別のアプリケーションで管理しています。Web アプリケーションでは、ユーザーがこのリストから任意の数の項目を選択して、実行した作業を指定できます。リストの項目数は 100 個強あり、少しずつ増加しています。

現在、顧客は選択した作業ごとにかかった時間を追跡するつもりです。このアプリケーションはあと数か月しか使用しないので大量の労力を注ぎ込んでも意味がありませんでしたが、この変更については、次のような 2 つの重要な目標がありました。

  1. ユーザーがごく簡単に時間を入力できるようにする。つまり、余分なボタンをクリックしたりポストバックを引き起したりせずに済むようにする。
  2. できる限り控えめな方法で機能をコードに追加する。10 年前のアプリケーションを新しいツールで徹底的にリニューアルすることは魅力的ですが、ここでの目標は、データ アクセスやデータベースを含めた既存の (機能している) コードに影響を及ぼさずに、新しいロジックを追加することでした。

しばらくの間、私は手法を検討しました。目標 2 を達成するには、CheckBoxList は変更できませんでした。このため時間を別のグリッドに含めることにしましたが、目標 1 を達成するには、(ありがたいことに) ASP.NET の GridView コントロールを使用できませんでした。そこで、テーブルと JavaScript を使用して作業と時間に関するデータを取得および保存することにし、そのための手段をいくつか調査しました。分離コードを呼び出す AJAX PageMethods は、使用できませんでした。Server.Transfer を使用して、別のページから目的のページを取得していたためです。<%MyCodeBehindMethod()%> などのインライン呼び出しが機能していたのは、クライアント側オブジェクトとサーバー側オブジェクトの併用が必要な、複雑な (JavaScript では実行できないほど難しい) データ検証を追加する必要に迫られるまででした。インライン呼び出しのあらゆる参照先を静的にする必要が生じると、状況はいよいよ悪化しました。そのため、"できる限り控えめ" の要件を達成できませんでした。

ついに私は本当に必要な手段に気付きました。新しいロジックを完全に分離したまま、JavaScript からアクセスしやすい WebAPI に配置すればよいのです。このようにすれば、新しいロジックと以前のロジックを明確に分離しやすくなります。

それでも課題は残っていました。それまでに私が経験した Web API 開発は、新しい MVC の作成でした。この手順から開発を始めましたが、Web API のメソッドを既存のアプリケーションから呼び出すと Cross Origin Resource Sharing (CORS) の問題が発生し、私が見つけた CORS の回避パターンはいずれも役に立ちませんでした。とうとう、私は Mike Wasson が執筆した、Web API を Web フォーム プロジェクトに直接追加する方法に関する記事 (bit.ly/1jNZKzI、英語) を発見し、開発を進めました。とは言え、まだ多数の課題を乗り越える必要がありましたが。この記事では、私が成功を目指してつまづき、もがき、手探りした際の苦しみを皆さんに追体験していただくつもりはありません。ここでは、私が最終的に到達した解決策について説明します。

以前のアプリケーションに新機能を追加する方法を説明するために、ここでは顧客の実際のアプリケーションを示すのではなく、ユーザーの好みの活動 (好きなこと: fun stuff) についてのコメントを通じてユーザーの好みを追跡するサンプルを使用します。100 個以上の項目を含んだ項目は使用しません。フォームには、簡単な CheckBoxList と、データ検証用の追加機能だけを表示します。また、時間を追跡する代わりに、ユーザーのコメントを追跡します。

Web API に本格的に取り組むと、検証メソッドの追加はまったく難しくありませんでした。今回は新しいサンプルを作成していたので、.NET Framework 2.0 とそのままの ADO.NET の代わりに、Microsoft .NET Framework 4.5 と Entity Framework 6 (EF) を使用しました。図 1 に、サンプル アプリケーションの出発点を示します。これは、ユーザー名を表示し、編集可能な CheckBoxList に活動の候補を表示する ASP.NET Web フォームです。このページに、図のグリッドが示すとおり、チェック ボックスをオンにした項目に関するコメントの追跡機能を追加します。

Starting Point: A Simple ASP.NET Web Form with the Planned Addition
図 1 出発点: 機能追加を予定している単純な ASP.NET Web フォーム

手順 1: 新しいクラスを追加する

サンプル アプリケーションには、新しいコメントを格納するクラスが必要でした。アプリケーションで使用するデータから、UserId と FunStuffId で構成されたキーを使用すれば、コメントの追加先となるユーザーや好きな活動を最も効率的に特定できるのではないかと考えました。

 

namespace DomainTypes{
  public class FunStuffComment{
    [Key, Column(Order = 0)]
    public int UserId { get; set; }
    [Key, Column(Order = 1)]
    public int FunStuffId { get; set; }
    public string FunStuffName { get; set; }
    public string Comment { get; set; }
  }
}

このアプリケーションではデータの保存に EF を使用する予定だったので、複合キーになるプロパティを指定する必要がありました。EF では、複合キーをマップするには Column Order 属性と Key 属性を追加します。また、FunStuffName プロパティについても説明しておきましょう。FunStuff テーブルを相互参照すれば特定のエントリの名前を取得できましたが、このクラスで FunStuffName プロパティを公開する方が簡単なことがわかりました。冗長に思えるかもしれませんが、ここでは既存のロジックを壊さないことを目標としています。

手順 2: Web フォーム ベースのプロジェクトに Web API を追加する

Wasson の記事のおかげで、Web API コントローラーを既存のプロジェクトに直接追加できることがわかりました。ソリューション エクスプローラーでプロジェクトを右クリックすると、[追加] コンテキスト メニューのオプションとして [Web API コントローラー クラス (v2)] が表示されます。作成されるコントローラーは MVC と連携するよう設計されているので、最初にすべてのメソッドを削除して、独自の Comments メソッドを追加します。Comments メソッドは、特定のユーザーに関する既存のコメントを取得します。今回は Breeze JavaScript ライブラリを使用していて、プロジェクトに NuGet を使用してこのライブラリをインストールしてあるので、Web API コントローラーには Breeze の名前付け規則を使用します (図 2 参照)。まだ Comments メソッドをデータ アクセスにフックしていないので、まずはメモリ内データを返します。

図 2 BreezeController Web API

namespace April2014SampleWebForms{
[BreezeController] 
public class BreezeController: ApiController  {
  [HttpGet]
  public IQueryable<FunStuffComment> Comments(int userId = 0)
    if (userId == 0){ // New user
      return new List<FunStuffComment>().AsQueryable();
    }
      return new List<FunStuffComment>{
        new FunStuffComment{FunStuffName = "Bike Ride",
          Comment = "Can't wait for spring!",FunStuffId = 1,UserId = 1},
        new FunStuffComment{FunStuffName = "Play in Snow",
          Comment = "Will we ever get snow?",FunStuffId = 2,UserId = 1},
        new FunStuffComment{FunStuffName = "Ski",
          Comment = "Also depends on that snow",FunStuffId = 3,UserId = 1}
      }.AsQueryable();    }
  }
}

Wasson の記事では、global.asax ファイルへのルーティングを追加するよう説明しています。しかし、NuGet を使用して Breeze を追加すると、適切なルーティングが既に定義された .config ファイルが作成されます。そのため、図 2 のコントローラーでは Breeze 推奨の名前付け規則を使用しています。

これで、FunStuffForm のクライアント側から簡単に Comments メソッドを呼び出せるようになりました。個人的には、Web API をブラウザーでテストして、適切に機能することを確認する手法が好みです。そのためには、アプリケーションを実行して、http://localhost:1378/breeze/Breeze/Comments?UserId=1 を参照します。アプリケーションで使用している正しいホストとポートを host:port 形式で必ず使用してください。

手順 3: クライアント側のデータ バインディングを追加する

しかし、まだ完成ではありません。データを操作する必要があるので、以前に執筆したコラムを読み返して、JavaScript のデータ バインディングを行う Knockout.js (msdn.microsoft.com/magazine/jj133816) と、データ バインディングを簡略化する Breeze (msdn.microsoft.com/magazine/jj863129、英語) について復習しました。Breeze を使用すると、Web API の結果がバインド可能なオブジェクトに自動的に変換され、Knockout (および他の API) で直接使用できるようになるので、追加のビュー モデルやマッピング ロジックを作成する必要がありません。データ バインディングの追加は変換処理で最も厄介な部分ですが、私の JavaScript と jQuery に関するスキルがきわめて貧弱なためにいっそう作業が難しくなります。しかし、私は根気良く努力し、バインディングの追加を完了するころには、ほとんど Chrome での JavaScript デバッグの専門家になっていました。新しいコードの大部分は、元の Web フォーム ページ (FunStuffForm.aspx) に結び付けられたJavaScript ファイルに記述しています。

この記事をほとんど書き終えていたときに、Knockout が今では少し時代遅れ (指摘者の言葉を借りれば "非常に 2012 らしい方式") であり、多くの JavaScript 開発者が AngularJS や DurandalJS などのもっと簡単で機能が充実したフレームワークを使用しているという指摘を受けました。この手法については、後日検討したいと思います。今回の 10 年前のアプリケーションには、2 年前のツールを使用してもまったく問題ないはずです。しかし、こうしたツールについては、今後のコラムで必ず取り上げる予定です。

Web フォームには、comments という名前のテーブルを定義しました (図 3 参照)。このテーブルの列には、Knockout でバインドするデータのフィールドを表示します。また、後で必要になる UserId フィールドと FunStuffId フィールドもバインドしていますが、非表示にしています。

図 3 Knockout でバインドするよう設定した HTML テーブル

<table id="comments">
  <thead>
    <tr>
      <th></th>
      <th></th>
      <th>Fun Stuff</th>
      <th>Comment</th>
    </tr>
  </thead>
  <tbody data-bind="foreach: comments">
    <tr>
      <td style="visibility: hidden" data-bind="text: UserId"></td>
      <td style="visibility: hidden" data-bind="text: FunStuffId"></td>
      <td data-bind="text: FunStuffName"></td>
      <td><input data-bind="value: Comment" /></td>
    </tr>
  </tbody>
</table>

FunStuff.js という名前を付けた JavaScript ファイルの先頭にあるロジックは、ready 関数と呼ばれ、表示するドキュメントの準備ができしだい実行されます。この関数では、viewModel 型を定義しています (図 4 参照)。viewModel の comments プロパティは、Web フォームの comments テーブルにバインドするために使用します。

図 4 FunStuff.js の先頭

var viewModel;
$(function() {
  viewModel = {
    comments: ko.observableArray(),
    addRange: addRange,
    add: add,
    remove: remove,
    exists: exists,
    errorMessage: ko.observable(""),
  };
  var serviceName = 'breeze/Comments';
  var vm = viewModel;
  var manager = new breeze.EntityManager(serviceName);
  getComments();
  ko.applyBindings(viewModel, 
    document.getElementById('comments'));
 // Other functions follow
});

ready 関数では、次のようなスタートアップ コードも指定しています。

  • serviceName: Web API の URI を定義します。
  • vm: viewModel を略した別名です。
  • manager: Web API 用に Breeze の EntityManager を設定します。
  • getComments: API を呼び出してデータを返すメソッドです。
  • ko.applyBinding: viewModel を comments テーブルにバインドする、Knockout のメソッドです。

viewModel を関数の外部で宣言したことに注目してください。viewModel には後で .aspx ページ内のスクリプトからアクセスする必要があるので、外部から参照できるようにスコープを設定する必要がありました。

viewModel の最も重要なプロパティは、comments という observableArray 型の配列です。Knockout は配列の内容を追跡し、配列が変更されたら、バインドされたテーブルを更新します。他のプロパティは、スタートアップ コードの後で定義している追加の関数を、viewModel 経由で公開しているだけです。

図 5 の getComments 関数を見てみましょう。

図 5 Breeze を使用した Web API によるデータのクエリ

function getComments () {
  var query = breeze.EntityQuery.from("Comments")
    .withParameters({ UserId: document.getElementById('hiddenId').value });
  return manager.executeQuery(query)
    .then(saveSucceeded).fail(failed);
}
function saveSucceeded (data) {
  var count = data.results.length;
  log("Retrieved Comments: " + count);
  if (!count) {
    log("No Comments");
    return;
  }
  vm.comments(data.results);
}
function failed(error) {
  vm.errorMessage(error);
}

getComments 関数では、Breeze を使用して Web API の Comments メソッドを実行し、Web ページの非表示フィールドからこのメソッドに現在の UserId を渡します。Breeze と Comments の URI を manager 変数で既に定義していることに注意してください。クエリが成功したら、saveSucceeded 関数を実行し、画面に情報をログとして表示して、クエリの結果を viewModel の comments プロパティに格納します。手元のノート PC では、非同期タスクが完了するまで空のテーブルが表示され、完了すると結果が突然テーブルに表示されます (図 6 参照)。また、この処理を全面的にクライアント側で行っていることにも注意してください。ポストバックが発生しないので、ユーザー エクスペリエンスが滑らかです。

Comments Retrieved from Web API and Bound with the Help of Knockout.js
図 6 Web API から取得し、Knockout.js を利用してバインドしたコメント

手順 4: チェック ボックスのオンとオフに応答する

次の課題は、[Fun Stuff List] でユーザーが選択した内容に応じてリストを変更することでした。項目の操作時には、ユーザーがチェック ボックスをオンとオフのどちらにするかに応じて、viewModel.comments 配列の項目とバインドされているテーブルの項目を追加または削除する必要があります。配列を更新するロジックは JavaScript ファイルに記述していますが、操作についてモデルに通知するロジックは .aspx のスクリプトに記述しています。checkbox の onclick などの関数を Knockout にバインドすることもできますが、今回は採用しませんでした。

.aspx フォームのマークアップでは、次のメソッドをページのヘッダー セクションに追加しました。

$("#checkBoxes").click(function(event) {
  var id = $(event.target)[0].value;
  if (event.target.nodeName == "INPUT") {
    var name = $(event.target)[0].parentElement.textContent;
    // alert('check!' + 'id:' + id + ' text:' + name);
    viewModel.updateCommentsList(id, name);  }
});

この処理が可能な理由は、動的に生成されるすべての CheckBox コントロールを checkBoxes という名前の div で囲んでいるためです。jQuery を使用して、イベントをトリガーする CheckBox の値と、関連するラベルの名前を取得します。続いて、値と名前を viewModel の updateCommentsList メソッドに渡します。この通知は、関数を正しく関連付けたことをテストしているにすぎません。

では、JavaScript ファイルの updateCommentsList メソッドとその関連関数について説明しましょう。ユーザーが項目のチェック ボックスをオンにする場合もオフにする場合も考えられるので、項目を追加または削除する必要があります。exists メソッドでは、チェック ボックスの状態を考慮するのではなく、Knockout の utils 関数を使用して、項目がコメントの配列に既に存在しているかどうかを確認できるようにします。存在している場合は、項目を削除する必要があります。Breeze で変更を追跡しているので、observableArray からは項目を削除しますが、Breeze の変更追跡機能には項目が消去されたと見なすよう指示します。これにより、2 つの処理が行われます。まず、データを保存すると、Breeze からデータベースに (このサンプルでは EF を使用して) DELETE コマンドが送信されます。しかし、項目のチェック ボックスをオンに戻したことで observableArray に追加し直す必要が生じると、Breeze によって変更追跡機能から項目が復元されます。このような方法を利用しない場合、コメントの ID として複合キーを使用しているので、新しい項目と消去された項目に同じ ID が設定されていれば競合が発生します。Knockout は項目を追加する push メソッドに応答するので、Knockout が項目の削除に応答できるように、配列が変更されたことを Knockout に通知する必要があります。ここでも、データ バインディングを利用して、チェック ボックスのオンとオフが切り替わったらテーブルを動的に変更します。

新しい項目を作成する際に、フォームのマークアップにある非表示フィールドからユーザーの userId を取得していることに注意してください。元のバージョンのフォームにあった Page_Load では、ユーザーを取得してからこの値を設定していました。コメントの各項目に UserId と FunStuffId を結び付ければ、必要なデータをコメントと共に保存して、適切なユーザーと項目に関連付けることができます。

oncheck の発生に応じて comments observableArray を変更すると、たとえば [Watch Doctor Who] チェック ボックスのオンとオフを切り替えれば、チェック ボックスの状態に応じて [Watch Doctor Who] 行の表示と非表示が切り替わります。

手順 5: コメントを保存する

アプリケーションのページには、true とマークされたチェック ボックスを保存する "保存" 機能が既にありますが、今回は別の Web API メソッドを使用して、コメントも同時に保存します。既存の保存メソッドを実行するタイミングは、SaveThatStuff ボタンのクリックに応じてページのポストバックが発生するときです。保存ロジックは、ページの分離コードに記述しています。クライアント側でロジックを呼び出してコメントを保存した後に、同じボタン クリックを使用してサーバー側で呼び出すことができます。私は Web フォームで昔ながらの onClientClick 属性を使用すればこのような保存を実行できることを知っていましたが、今回変更していたタイム シート アプリケーションでは、作業時間とタイム シートが保存可能な状態かどうか確認する検証も実行する必要がありました。検証に失敗した場合は、Web API による保存をあきらめる必要があっただけでなく、ポストバックとサーバー側の保存メソッドが実行されないようにする必要もありました。onClientClick を使用してこのような処理を実現するのに苦労した結果、ここでも jQuery を使用して最新の手法を導入することにしました。クライアント側で CheckBox のクリックに応答する場合と同じ方法で、btnSave のクリックにもクライアント側で応答できます。しかも、この応答はポストバックやサーバー側の応答の前に発生します。そこで次のように、ボタンを 1 回クリックすれば両方のイベントが発生するようにしました。

$("#btnSave").click(function(event) {
  validationResult = viewModel.validate();
  if (validationResult == false) {
    alert("validation failed");
    event.preventDefault();
  } else {
    viewModel.save();
  }
});

今回のサンプルには、必ず true を返すスタブ検証メソッドを含めていますが、このメソッドが false を返す場合もサンプルが正常に機能することは確認済みです。false の場合は、JavaScript の event.preventDefault を使用して、以降の処理を中止します。コメントを保存しないだけでなく、ポストバックとサーバー側の保存も実行しません。検証結果が true の場合は、viewModel.save を呼び出して、ページでボタンのサーバー側動作を続行し、ユーザーが FunStuff で選択した内容を保存します。viewModel.save から呼び出す saveComments 関数は、Breeze の entityManager に saveChanges メソッドを実行するよう指示します。

function saveComments() {
  manager.saveChanges()
    .then(saveSucceeded)
    .fail(failed);
}

一方、saveChanges メソッドは、コントローラーの SaveChanges メソッドを見つけて実行します。

[HttpPost]
  public SaveResult SaveChanges(JObject saveBundle)
  {
    return _contextProvider.SaveChanges(saveBundle);
  }

この処理を有効にするために、私は Comments を EF6 データ層に追加してから、Breeze のサーバー側コンポーネントを使用してデータベースに対するクエリを実行するよう (その結果 EF6 データ層を呼び出すよう)、Comments コントローラー メソッドを切り替えました。したがって、クライアントに返すデータがデータベースのデータになるので、SaveChanges でデータベースに再保存できます。こうした処理については、ダウンロード サンプルで確認できます。このサンプルでは、EF6 と Code First を使用し、サンプル データベースを作成してシードします。

図 7 ユーザーによるチェック ボックスのクリックに応じてコメント リストを更新する JavaScript

 

function updateCommentsList(selectedValue, selectedText) {
  if (exists(selectedValue)) {
    var comment = remove(selectedValue);
    comment.entityAspect.setDeleted();
  } else {
  var deleted = manager.getChanges().filter(function (e) {
    return e.FunStuffId() == selectedValue
  })[0];  // Note: .filter won't work in IE8 or earlier
  var newSelection;
  if (deleted) {
    newSelection = deleted;
    deleted.entityAspect.rejectChanges();
  } else {
    newSelection = manager.createEntity('FunStuffComment', {
      'UserId': document.getElementById('hiddenId').value,
      'FunStuffId': selectedValue,
      'FunStuffName': selectedText,
      'Comment': ""
    });
  }
  viewModel.comments.push(newSelection);    }
  function exists(stuffId) {
    var existingItem = ko.utils.arrayFirst(vm.comments(), function (item) {
      return stuffId == item.FunStuffId();
    });
    return existingItem != null;
  };
  function remove(stuffId) {
    var selected = ko.utils.arrayFirst
    (vm.comments(), function (item) {
    return stuffId == item.FunStuffId;
    });
    ko.utils.arrayRemoveItem(vm.comments(), selected);
    vm.comments.valueHasMutated();
  };

 

友人のちょっとした協力で完成した JavaScript

このプロジェクトや、今回の記事のために構築したサンプルに取り組む中で、私はこれまでにないほど大量の JavaScript を記述しました。JavaScript は (このコラムでたびたび指摘しているように) 私の専門分野ではありませんが、自分の成果には大きな誇りを感じました。しかし、多くの読者にとって今回の技法の一部は初めて触れるのものではないかと考え、私は IdeaBlade (Breeze の考案者団体) の Ward Bell に、詳しいコード レビューについて協力を仰ぎました。また、ペア プログラミングを引き受けてもらったことで、自作の Breeze コードや JavaScript と jQuery のコードをいくらかすっきりさせることができました。今では "時代遅れ" かもしれない Knockout.js の使用を除けば、この記事からダウンロード可能なサンプルは、なかなか優れた教材として役立ちます。ただし、サンプルの目的に留意してください。今回紹介した最新の技法で以前の Web フォーム プロジェクトを強化して、エンド ユーザー エクスペリエンスを大幅に向上することが目的です。

Julie Lerman は、バーモント ヒルズ在住の Microsoft MVP、.NET の指導者、およびコンサルタントです。世界中のユーザー グループやカンファレンスで、データ アクセスなどの Microsoft .NET トピックについてプレゼンテーションを行っています。彼女のブログは thedatafarm.com/blog (英語) で、『Programming Entity Framework』(O'Reilly Media、2010 年)、および同書の Code First 版 (O'Reilly Media、2011 年) と DbContext 版 (O'Reilly Media、2012 年) の著者でもあります。Twitter (twitter.com/julielerman、英語) で彼女をフォローし、juliel.me/PS-Videos (英語) で Pluralsight のコースをご覧ください。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Damian Edwards (dedward@microsoft.com、英語のみ) と Scott Hunter (Scott.Hunter@microsoft.com、英語のみ) に心より感謝いたします。