トップページへ PASSJ ブログへ
トップページへ
分科会
特集!
コミュニケーション
資格
セミナー・コンファレンス
インフォメーション

テクニカルライター 大澤 文孝

第5回 データベースの更新〜レコードの更新と削除、そして競合問題を考える〜

前回までで、データベースの表示部分を実装できたことになります。
連載の最後となる今回は、レコードの更新と削除の方法を説明します。


レコードの更新と削除へのリンク
レコードの更新
編集の競合を考える
レコードの削除
最後に

レコードの更新と削除へのリンク
レコードを更新する場合でも、レコードを削除する場合でも、既存のレコードをユーザーに表示し、どのレコードを編集または削除するのかを尋ねる必要があります。そのためには、対象となるレコードをクリックするためのリンクが必要となるでしょう。
リンクをどこに設けるかは、いくつかの考え方があるかと思いますが、今回は話を簡単にするため、第3回第4回で作成したDataGridコントロールによる一覧表示のWebページ(productview.aspx)に、[編集]と[削除]のリンクを設け、そこから編集用のページや削除用のページに移動できるようにすることにします(図5-1)。


図5-1 編集や削除のためのリンク

図5-1 編集や削除のためのリンク

[TIPS]
登録データの編集や削除のためのユーザーインタフェースは、一般のユーザーには見せず、管理ページからのみ見せるのが一般的です。
管理用のページを設けたければ、仮想ディレクトリとして「一般用」と「管理用」の2つを用意し、一般用の仮想ディレクトリには、[編集]や[削除]のリンクを設けない商品一覧表示ページを、管理用の仮想ディレクトリには、[編集]や[削除]のリンクを設けた商品一覧表示ページを、それぞれ用意すればよいでしょう。

●編集用のリンク
まずは、編集用のリンクを用意します。編集ページは、以降、productedit.aspxファイルに実装していくことにします。
productedit.aspxファイルでは、どの商品が編集対象となっているかを知る必要があります。そこで、第2回に作成した商品詳細のページであるproductdetail.aspxファイルと同様、URLクエリーを使って、編集の対象となっている商品番号を伝えることにしましょう。
ここでは、“productedit.aspx?id=商品ID”というURLを使うことにします。
そこで、第3回第4回で作成した商品一覧ページであるproductview.aspxファイル上のDataGridコントロールのプロパティの設定を変更し、図5-2のように、[編集]のハイパーコントロール列を加えます。


図5-2 [編集]のハイパーコントロール列の追加

図5-2 [編集]のハイパーコントロール列の追加

●削除用のリンク
同様にして、削除用のリンクも用意します。以降、削除ページは、productdelete.aspxファイルに実装していくことにします。
productdetail.aspxファイルでも、同様に、削除対象となる商品IDをURLクエリーとして得ることにしましょう。つまり、“productdelete.aspx?id=商品ID”というURLを使うことにします。
そこで、productview.aspxファイル上のDataGridコントロールに、図5-3のように、[削除]のハイパーコントロール列を加えます。


図5-3 [削除]のハイパーコントロール列の追加

図5-3 [削除]のハイパーコントロール列の追加

以上の操作によって、productview.aspxファイルは、図5-1に示した、[編集]と[削除]のリンクをもつようになったはずです。

■レコードの更新
では、レコードの更新処理をするproductedit.apsxファイルを実装します。

●編集用のWebフォーム
まずは、ユーザーが既存の商品情報を編集できるようにするためのWebフォームを用意します。  Webフォームでは、現在の商品情報をテキストボックスなどで表示し、ユーザーにその情報を編集してもらうことになります。よって編集用のWebフォームは、第1回目で作成した商品登録ページとほぼ同じ構成となります。 ここでは、リスト5-1図5-4のように作成するものとします。

[TIPS]
productedit.aspxファイルは、ファイルフィールド(<INPUT type="FILE">)を含んでいます。そのため、<FORM>要素のenctype属性に“multipart/form-data”を指定する必要があります(詳細については、連載第1回目を参照)。

リスト5-1 productedit.aspxファイル

図5-4 productedit.aspxファイルで構成されるWebフォーム

図5-4 productedit.aspxファイルで構成されるWebフォーム

図5-4を見るとわかるように、第1回目で作成したproductadd.aspxファイルと、構成はほぼ同じですが、若干違う部分があります。それは、[画像削除]というチェックボックスです。
商品の画像は、ファイルフィールド(<INPUT type="FILE">)を使ってアップロードできるようにするのですが、このとき、ユーザーがファイルフィールドから何もファイルをアップロードしてこない場合、「すでに登録されている画像を消すのか」「すでに登録されている画像はそのままにしておくのか」のどちらの仕様にするかを決める必要があります。
もし前者の仕様にするならば、テキストだけを修正したい場合にも、画像を合わせてアップロードしないと、元の画像が消えてしまうので、使いにくいユーザーインタフェースとなり、避けたいところです。
一方で後者の仕様にすると、いちどアップロードした画像を別の画像に差し替えることはできても、消すことができなくなってしまいます。 そこで図5-4に示したように、[画像削除]というチェックボックスを設けて、ユーザーが画像をアップロードしてこない場合には、
  • ユーザーが[画像削除]にチェックを付けていない場合
        →そのまま画像を残す
  • ユーザーが[画像削除]にチェックを付けている場合
        →画像を削除する
という処理をするようにします。そうすれば、ユーザーは、[画像削除]のチェックボックスにチェックを付ければ、あとから画像を削除することが可能となります。

[TIPS]
今回は、話の流れの関係上、商品を追加するproductadd.aspxファイルと、商品を編集するproductedit.aspxファイルを作成していますが、このように2つに分けず、ひとつのWebフォームで「追加」と「編集」を担ってしまうこともできます。具体的には、productedit.aspxファイルにおいて、URLクエリーとして“id=商品ID”の部分が送信されてこなかったならば新規追加処理、“id=商品ID”の部分が送られてきた場合には編集処理をするといった実装をします。 そもそも、追加と編集との違いは、1.INSERT文を使うかUPDATE文を使うか、2.非ポストバック時に既存のデータを表示するか否か、という違いでしかなく、ユーザーインタフェースはほぼ同じものとなりますから、ひとつのWebフォームにしたほうが、似た処理を複数作る必要がなく、効率的です。 もしくは、似たユーザーインタフェースをもつ部分は、それらをWebコントロールとして実装するという方法もあります。

●商品IDの取得処理
では、リスト5-1に示したWebフォームのコードを記述していきます。Loadイベントの処理は、リスト5-2のようになります。

リスト5-2 productedit.aspx.csファイルのLoadイベントの処理

Loadイベントでは、IsPostBackプロパティを調べてポストバック状態であるかを判断し、

 (1)非ポストバック状態(ユーザーがSUBMITボタンを押してこない状態)のとき
        現在の商品情報をWebフォーム上のテキストボックスなどに設定する。

 (2)ポストバック状態(ユーザーがSUBMITボタンを押してきた状態)のとき
        テキストボックスなどからユーザーが入力した変更後の情報を読み取り、データベースを更新する。

という処理をすることになります。
上記、(1)(2)のどちらの場合でも、編集対象となる商品IDが必要です。
商品IDは、URLクエリーとして、“productedit.aspx?id=商品ID”のように指定することにしています。そこでリスト5-2では、まず、次のようにして、商品IDを取得する処理をしています。



// SubmitPanelを表示する
InputPanel.Visible = false;
SubmitPanel.Visible = true;

// 商品IDを取得する
int productid;
try
{
   productid = int.Parse(Request.QueryString["ID"]);
}
catch (ArgumentNullException)
{
      // 商品IDが指定されていない
      SubmitMessage.Text = "商品番号が指定されていません";
      return;
}
//
リスト5-2では、その他の例外に対する処理もあるが
// ここでは略

// InputPanelを表示する
InputPanel.Visible = true;
SubmitPanel.Visible = false;


そしてその後、IsPostBackプロパティを調べ、非ポストバック状態の場合にはSetInitialDataメソッドを、ポストバック状態の場合にはUpdateDBメソッドを、それぞれ呼び出すようにしています。
SetInitialDataメソッドはテキストボックスなどに現在のレコードの値を設定するメソッド、UpdateDBメソッドは、ユーザーがテキストボックスなどに入力した値を読み取り、レコードを更新するメソッドとして、以降、それぞれ実装していきます。



// ポストバック状態かどうかを調べる
if (this.IsPostBack)
{
      // ポストバック状態である
      // データベースへの更新処理(後掲のリスト5-4
      if (UpdateDB(productid))
      {
          // 更新に成功した
          // SubmitPannelのほうを表示する
          InputPanel.Visible = false;
          SubmitPanel.Visible = true;
          // 成功メッセージを設定
          SubmitMessage.Text = "商品を更新しました";
      }
}
else
{
      // 非ポストバック状態である
      // 現在のデータベースの情報をWebフォームの
      // 各テキストボックスに表示する(後掲の
リスト5-3
      SetInitialData(productid);
}

●非ポストバック時のコード
非ポストバック状態では、レコードの情報を取得し、それをテキストボックスなどに設定するという処理をします。その処理をするSetInitialDataメソッドは、リスト5-3のようになります。

リスト5-3 SetInitialDataメソッド

リスト5-3では、まず、次のようなSELECT文を設定したSqlCommandオブジェクトを用意しています。


// データベースに接続して該当のレコードを得る
SqlConnection sqlconn = new SqlConnection(
      ConfigurationSettings.AppSettings["DSNSTRING"]);

// SELECT文を送信するSqlCommandオブジェクトを用意する
SqlCommand sqlcmd = new SqlCommand(
      "SELECT id, productname, price, stock, " +
      " comment, detail, DATALENGTH(imgdata) As imglength, " + "
      " createdate, lastupdate FROM Products" +
      " WHERE id=@id", sqlconn);

// パラメータを設定する
sqlcmd.Parameters.Add("@id", SqlDbType.Int).Value = productid;


そして次に、SqlCommandオブジェクトのExecuteReaderメソッドを呼び出して該当レコードを取得し、Webフォーム上のテキストボックスなどにプロパティを設定する処理をします。


// 実行する
SqlDataReader sqlreader = null;
try
{
      // 接続を開く
      sqlconn.Open();
      // SELECT文を実行する
      sqlreader = sqlcmd.ExecuteReader();
      // レコードの値を取得する
      if (sqlreader.Read())
      {
          // レコードが存在する
          // テキストボックスなどに、実際の値を設定する
          ProductNameText.Text = sqlreader.GetString(1);
          CreatedateLabel.Text = Server.HtmlEncode(string.Format("登録日 {0:yyyy/MM/dd}",
            sqlreader.GetDateTime(7)));
          LastupdateLabel.Text =Server.HtmlEncode(string.Format("最終更新日 {0:yyyy/MM/dd}",
            sqlreader.GetDateTime(8)));
          StockList.SelectedIndex = sqlreader.GetInt32(3);
          PriceText.Text = string.Format("{0:0}", sqlreader.GetDecimal(2));
          CommentText.Text = sqlreader.GetString(4);
          DetailText.Text = sqlreader.GetString(5);

          // 画像の処理
          if (sqlreader.IsDBNull(6) || sqlreader.GetInt32(6) == 0)
          {
            // 画像なし
            ProductImage.Visible = false;
            NoImageLabel.Visible = true;
            ImageDeleteChk.Visible = false;
          }
          else
          {
            // 画像あり
            ProductImage.ImageUrl =
              string.Format("productimage.aspx/{0}.jpg", productid);
            NoImageLabel.Visible = false;
            ImageDeleteChk.Visible = true;
          }
      }
      else
      {
            // レコードが存在しない
            ErrorMessage.Text = "該当の商品がありません";
          }
      }
      catch (SqlException ex)
      {
          // エラーが発生した
          Response.AppendToLog(ex.Message);
          ErrorMessage.Text =
            "申し訳ございません。データベースエラーのため処理を完了できませんでした";
      }
      finally
      {
          // 接続を閉じる
          if (sqlreader != null)
          {
            sqlreader.Close();
      }
      sqlconn.Close();
}


この処理は、第2回目で説明した詳細ページ(productdetail.aspx)と、ほとんど同じです。
ただしTextBoxコントロールのTextプロパティに設定する値は、HTMLエンコードの処理をしないという点に注意してください。
たとえば、ProductNameTextという名前のTextBoxコントロールに製品名を設定する処理は、次のようになっています。



ProductNameText.Text = sqlreader.GetString(1);

これを次のように、Server.HtmlEncodeメソッドを使ってHTMLエンコードすると、HTMLエンコードされたあと、さらにTextBoxコントロールによってHTMLエンコードされるため、“<”や“>”などの文字が含まれている場合に、画面に“&gt;”や“&lt;”と表示されてしまうことになります。


// 誤り。TextBoxコントロールのTextプロパティはHTMLエンコードしてはいけない
ProductNameText.Text = Server.HtmlEncode(sqlreader.GetString(1));


同様に、改行コードを“<BR>”に置換する処理も必要ありません。
さてリスト5-3の処理によって、非ポストバック時――図5-1に示したように一覧表示において[編集]のリンクをクリックしたとき――には、図5-5のように表示され、ユーザーは、この画面で商品を編集することができるようになります。


図5-5 非ポストバック時のproductedit.aspxファイルの表示例

図5-5 非ポストバック時のproductedit.aspxファイルの表示例
●ポストバック時のコード
では次に、ポストバックの処理――図5-5において、ユーザーが商品情報を編集し、[変更する]ボタンを押したときの処理――を実装していきましょう。
ポストバック処理では、Webフォームに入力された情報をもとに、レコードを更新する処理をします。レコードを更新するには、データベースに対してUPDATE文を発行します。その処理は、リスト5-4のようになります。


リスト5-4 UpdateDBメソッド

○ユーザーが入力した情報の取得
リスト5-4に示したUpdateDBメソッドでは、まず、検証コントロールを使って、ユーザーがWebフォームに入力した情報に入力エラーがないかどうかを調べています。この処理は、第1回目で説明しました。


/// 検証コントロールを使った入力エラーの検証
this.Validate();
if (!this.IsValid)
{
      // エラーが発見された
      return false;
}


次に、ユーザーがWebフォームのテキストボックスに入力した文字列やドロップダウンリストボックスで選択した値などを取得します。


// コントロールにユーザーが入力した値を取得
string productname = ProductNameText.Text; // 商品名
string comment = CommentText.Text; // 概要
string detail = DetailText.Text; // 解説
// 以下は、Parseメソッドで例外が発生する可能性があるが
// 検証コントロールで書式チェックをしているので
// 例外が発生する可能性はなく、ここではtry〜catchでの例外チェックは省略
Decimal price = Decimal.Parse(PriceText.Text);
int stocknum = int.Parse(StockList.SelectedValue);


そしてさらに、画像がアップロードされてきたかどうかと、[画像削除]のチェックボックスにチェックが付いているかどうかを調べます。


// 画像のアップロードがされているかどうか
bool imgUpload = false, imgDelete = false;

HttpPostedFile postedfile = Request.Files["ImgFile"];
if ((postedfile == null) || (postedfile.ContentLength == 0))
{
      // 画像のアップロードはされていない
      // 画像を削除するチェックボックスがtrueかどうか
      if (ImageDeleteChk.Checked )
      {
          // 画像を削除する
          imgDelete = true;
      }
}
else
{
      // 画像のアップロードがされている
      imgUpload = true;
}


この処理により、画像がアップロードされているときには変数imgUploadがtrueに、[画像削除]のチェックボックスにチェックが付いているときには変数imgDeleteがtrueになります。変数imgUploadおよび変数imgDeleteは、次に説明するUPDATE文を生成するときに、画像を示すフィールドとなるimgdata列を更新するか否かを判別するのに使います。

○データベースに接続して更新する  
以上で、一通りの準備が整ったので、データベースに接続するためのSqlConnectionオブジェクトを得ます。



// コネクションを準備する
SqlConnection sqlconn =
      new SqlConnection(ConfigurationSettings.AppSettings["DSNSTRING"]);


そして、更新のためのUPDATE文を用意します。リスト5-4では、次のようにしています。


// UPDATE文を送信するSqlCommandオブジェクトを用意する
string updatequery;
updatequery =
      "UPDATE Products" +
      " SET productname = @productname, price = @price, stock = @stock, " +
      " comment = @comment, detail = @detail, lastupdate = CURRENT_TIMESTAMP";
if (imgUpload || imgDelete)
{
      // 画像の削除やアップロードの場合には、imgdata列の更新も加える
      updatequery += ",imgdata=@imgdata";
}
// WHERE句
updatequery += " WHERE id=@id";


この結果、送信されるUPDATE文は、変数upatequeryに、次のように格納されます。
  • 画像のアップロードや画像の削除をしないとき
    UPDATE Products
    SET productname = @productname, price=@price, stock=@stock,
          comment = @comment, detail = @detail, lastupdate = CURRENT_TIMESTAMP
    WHERE id=@id
  • 画像のアップロードや画像の削除をするとき
    UPDATE Products
    SET productname = @productname, price=@price, stock=@stock,
          comment = @comment, detail = @detail, lastupdate = CURRENT_TIMESTAMP,
          imgdata = @imgdata
    WHERE id=@id
画像のアップロードや画像の削除をする場合には、imgdata列に対する更新を含めていますが、そうではない場合にはimgdata列に対する更新を含めていません。これによりユーザーが画像のアップロードや画像の削除指示を出さない場合には、既存の画像は、そのまま残ります。
なおここでは、lastupdate列に最終更新日時として現在の日時(CURRENT_TIMESTAMP)を格納しているという点に注目してください。これは、のちに説明する更新の競合を防ぐときに役立ちます。
さて、UPDATE文を用意したならば、そのUPDATE文を設定したSqlCommandオブジェクトを用意します。



SqlCommand sqlcmd = new SqlCommand(updatequery, sqlconn);

そしてパラメータクエリーのパラメータを設定します。パラメータとしては、ユーザーがWebフォームに入力した値を埋め込むことになります。


// 更新となるレコードのID番号
sqlcmd.Parameters.Add("@id", SqlDbType.Int).Value = productid;

// 以下、更新データ
sqlcmd.Parameters.Add("@productname", SqlDbType.NVarChar, 64).Value = productname;
sqlcmd.Parameters.Add("@price", SqlDbType.Money).Value = price;
sqlcmd.Parameters.Add("@stock", SqlDbType.Int).Value = stocknum;
sqlcmd.Parameters.Add("@comment", SqlDbType.NVarChar, 256).Value = comment;
sqlcmd.Parameters.Add("@detail", SqlDbType.NVarChar, 2048).Value = detail;


そして画像の削除指示や画像のアップロードがされている場合には、さらにimgdata列へのパラメータも設定します。


// 画像に対するパラメータ
if (imgDelete)
{
      // 画像の削除の場合にはDBNullを設定
      sqlcmd.Parameters.Add("@imgdata", SqlDbType.Image).Value = DBNull.Value;
}

if (imgUpload)
{
      // 画像がアップロードされたときには、その画像を設定
      // アップロードされた画像がJPEG形式かどうかを調べる
      string contenttype = postedfile.ContentType.ToLower();
      if (contenttype != "image/jpeg" && contenttype != "image/pjpeg")
      {
          // JPEG形式ファイルではない
          ErrorMessage.Text = "アップロードされた画像は、JPEG形式ではありません";
          return false;
      }
      // アップロードされたファイルをバイト配列として読み込む
      try
      {
          byte []img = new byte [postedfile.ContentLength];
          postedfile.InputStream.Read(img, 0, postedfile.ContentLength);
          // パラメータとして設定
          sqlcmd.Parameters.Add("@imgdata", SqlDbType.Image).Value = img;
      }
      catch (Exception ex)
      {
          // 何らかのエラーが発生した
          // ログに書き込む
          Response.AppendToLog(ex.Message);
          ErrorMessage.Text = "画像のアップロード処理に失敗しました";
          return false;
      }
}

以上でパラメータの設定が済んだので、ExecuteNonQueryメソッドを呼び出して、実際にデータベースに対する更新をかけます。

// データベースに対して更新をかける
int recnum = -1;
try
{
      // 接続を開く
      sqlconn.Open();
      // UPDATE文を実行する
      recnum = sqlcmd.ExecuteNonQuery();
}
catch (SqlException ex)
{
      // 何らかのエラーが発生した
      // エラーをIISのログに記録しておくことにする
      Response.AppendToLog(ex.Message);
}
finally
{
      // 接続を閉じる
      sqlconn.Close();
}

ExecuteNonQueryメソッドは、データベース上で変化したレコードの数を戻り値として返します。ここでは、UPDATE文を送信して1レコードを更新していますから、戻り値は1となるはずです。
そこで戻り値が1であるかどうかを判定すれば、正しくレコードが更新できたのかを確認できます。


if (recnum == 1)
{
      // 更新に成功した
      return true;
}
else
{
      // 更新に失敗した
      ErrorMessage.Text = "データベースへの書き込みに失敗しました";
      return false;
}
return true;


■編集の競合を考える

さて以上で、既存レコードの編集機能を実装したわけですが、この実装だと、少々問題があります。というのは、Webアプリケーションは、ひとりのユーザーが使っているとは限らず、複数人が同時に利用することも十分に考えられるからです。
複数のユーザーが商品情報を変更する場合、次の2つの問題が生じます。これらの問題を「競合」と呼びます。


(1)誰かがすでに商品情報を削除している場合
あるユーザーが編集画面で編集中に、誰かがその商品を削除すると(削除するためのWebフォームは、すぐあとに作成します)、SUBMITボタンを押してポストバックしたときに、その商品に該当するレコードがなく、UPDATE文の呼び出しに失敗します(図5-6)。

図5-6 誰かがすでに商品情報を削除した場合の問題点

図5-6 誰かがすでに商品情報を削除した場合の問題点


(2)あるユーザーの編集を上書きする
ユーザーAとユーザーBが同時に編集をしはじめたとします。このときユーザーAもユーザーBも同じ商品の情報を見ています。そしてユーザーAが先に編集を終えてSUBMITボタンを押したとします。するとデータベース上の情報は、ユーザーAの編集後の内容に置き換わります。
しかしユーザーBは、ユーザーAが編集する前の情報を編集しています。ここでユーザーBがSUBMITボタンを押して更新すると、ユーザーAが編集したデータを上書きして書き換えてしまうことになります(図5-7)。


図5-7 同時に二人が編集すると上書きする可能性がある

図5-7 同時に二人が編集すると上書きする可能性がある


上記(1)は、リスト5-4の実装では、大きな問題となりません。なぜなら、UPDATE文の実行はできるものの、該当レコードがないため、実際には更新されず、ExecuteNonQueryメソッドが値として0を返すからです。リスト5-4では、ExecuteNonQueryメソッドの戻り値が1であることを確認していますから、図5-6の現象が起きた場合、ユーザーには、「データベースへの書き込みに失敗しました」と表示され、ユーザーは書き込みができなかったことを知ることができます。

[TIPS]
ユーザーから見たときのメッセージの内容的には、「データベースへの書き込みに失敗しました」だとわかりにくいので、変数recnumが0であるかどうかを調べ、変数recnumが0である場合には、「該当の商品は、誰かによって削除された可能性があります」などとしたほうがよいでしょう。


[TIPS]
しかしリスト5-4の実装では、図5-6に示したように、ユーザーAが編集中にもかかわらず、ユーザーBは削除操作ができます。誰かが編集中である場合の削除を禁止したいならば、誰かが編集画面を取り出したときに、レコードにロックをかけるなどの処理が必要となります。しかし後述のコラムで説明するように、ロックをかけると、「いつロックをはずすのか」のタイミングがかなり難しい問題となるので、本稿では実装しません。

しかし上記(2)の問題は、リスト5-4の実装では解決されておらず、実際に、図5-7のように上書きが起こります。そのため、何らかの対策が必要となります。
上書きを防止するには、ポストバック状態になったときに、誰かがすでにレコードを書き換えているかどうかが分かればよいということになります。今回のリスト5-4の実装では、各レコードの最終更新日時をlastupdate列に保存しています。よって、lastupdate列が、「レコードの編集前」と「レコードの編集後」とで異なっているかどうかを調べることで、誰かに編集されたかどうかを判定できます。
そこで、非ポストバック時にレコードの編集前のlastupdate列の値を保存しておき、ポストバック時には、保存しておいたlastupdate列の値と現在のレコードのlastupdate列の値が合致するかどうかを調べるという方法をとることにします。



コラム Webアプリケーションにおけるレコードのロック
本稿では、レコードのロックやトランザクションについては言及していません。複数のレコードを更新する場合には、ロック機構を設けたり、トランザクションとして処理したりするなどの処理が必要となるのが一般的です。
本稿における説明は、Webアプリケーション(というよりもクライアント/サーバーシステム)での、ユーザーインタフェースにおける競合の可能性を説明しているのに過ぎません。
なお競合を避けるためといって、ユーザーが「編集画面を呼び出したら、そのレコードにロックをかけて、他のユーザーが編集できないようにする」という仕組みは、よほど特別な理由がない限り、避けるのが無難です。
これは編集画面を表示したまま、その人が席をはずしてしまい、ロックがかかりっぱなしになってしまうという問題もありますが、それ以上に、Webアプリケーションでは、ユーザーが正しく終了操作するとは限らず、終了操作せず、Webブラウザを閉じるだけでWebアプリケーションを終了させてしまう可能性もあるためです。
Webアプリケーションの仕組み上、ユーザーがWebブラウザを閉じたかどうかをサーバー側で知ることはできないので、その場合、ロックがかかりっぱなしになることがあり得ます。
なお、サーバー側でWebブラウザが閉じられたかどうかを知ることはできませんが、「ユーザーが何分間アクセスしてこないかどうか」を調べることはできます。そのためには、Sessionオブジェクトを使い、Sessionオブジェクトのタイムアウトで判断します。
どうしてもレコードをロックしたい場合には、Sessionオブジェクトを使ってタイムアウトを判断し、タイムアウトが発生したならば、ロックを解除するという仕組みを備えることになるでしょう。


[TIPS]
Session機能は、ユーザーが二重ログインを防止する場合にも利用できます。たとえば会員制のサイトなどでは、同時に同一IDを使ってログインできないようにしたい場面もあるでしょう。そのような場合には、SessionオブジェクトにログインしたユーザーのIDなどを格納しておき、ログイン時にSessionオブジェクトに、ユーザーのIDが格納されているか否かを調べることで、二重ログインを調べることができます。Sessionオブジェクトに格納した値は、タイムアウト(デフォルトでは20分)が過ぎると消えるので、もしユーザーがログアウト操作しない場合でも、タイムアウト後は、再びログインできるようになります。


○非ポストバック時に現在の最終更新日時を埋め込む

まずは、リスト5-3の非ポストバック時の処理において、lastupdate列の値をVIEWSTATEに保存しておくという処理を追加します。

【編集前】

SqlDataReader sqlreader = null;
try
{
      // 接続を開く
      sqlconn.Open();
      // SELECT文を実行する
      sqlreader = sqlcmd.ExecuteReader();
      // レコードの値を取得する
      if (sqlreader.Read())
      {
          // レコードが存在する
          // テキストボックスなどに、実際の値を設定する
          ProductNameText.Text = sqlreader.GetString(1);
          CreatedateLabel.Text = Server.HtmlEncode(string.Format("登録日 {0:yyyy/MM/dd}",
            sqlreader.GetDateTime(7)));
          LastupdateLabel.Text =Server.HtmlEncode(string.Format("最終更新日 {0:yyyy/MM/dd}",
            sqlreader.GetDateTime(8)));
          // 追加(GetDateTimeメソッドは不可)
          System.Data.SqlTypes.SqlDateTime lastupdate = sqlreader.GetSqlDateTime(8);
          this.ViewState["lastupdate_day"] = lastupdate.DayTicks;
          this.ViewState["lastupdate_time"] = lastupdate.TimeTicks;
          // 以下略


【編集後(赤字部分を追加)】

SqlDataReader sqlreader = null;
try
{
      // 接続を開く
      sqlconn.Open();
      // SELECT文を実行する
      sqlreader = sqlcmd.ExecuteReader();
      // レコードの値を取得する
      if (sqlreader.Read())
      {
          // レコードが存在する
          // テキストボックスなどに、実際の値を設定する
          ProductNameText.Text = sqlreader.GetString(1);
          CreatedateLabel.Text = Server.HtmlEncode(string.Format("登録日 {0:yyyy/MM/dd}",
            sqlreader.GetDateTime(7)));
          LastupdateLabel.Text =Server.HtmlEncode(string.Format("最終更新日 {0:yyyy/MM/dd}",
            sqlreader.GetDateTime(8)));
      // 追加(GetDateTimeメソッドは不可)
      System.Data.SqlTypes.SqlDateTime lastupdate = sqlreader.GetSqlDateTime(8);
      this.ViewState["lastupdate_day"] = lastupdate.DayTicks;
      this.ViewState["lastupdate_time"] = lastupdate.TimeTicks;
      // 以下略


ここでは、日付時刻型の値を取得するのに、GetDateTimeメソッドではなく、GetSqlDateTimeメソッドを使っています。
通常、日付時刻型の値を取得するには、GetDateTimeメソッドを呼び出せば十分です。GetDateTimeメソッドを呼び出すと、(.NET Frameworkの)DateTimeオブジェクトとして値を取得できます。
しかしSQL Server上の(T-SQLの)datetime型は、DateTimeクラスとは、精度が違います。
  • NET FrameworkのDateTimeクラスの精度
    西暦1年1月1日から西暦9999年12月31日。精度は100ナノ秒。
  • T-SQLのdatetime型の精度
    1753年1月1日から9999年12月31日まで。精度は3.33ミリ秒。
そのためGetDateTimeメソッドを使って.NET FrameworkのDateTimeオブジェクトとして値を得ると、変換誤差が生じます。得た日付時刻の値をユーザーに文字列として表示する場合には、変換誤差は問題とはなりません。しかし今回のように日時の合致でレコードを更新する場合には、誤差が影響して、日時の比較が正しくできなくなります(具体的には、後述のWHERE句で、“=”演算子を使って比較したときに、誤差が影響し、同じ値ではないとみなされます)。
そこでここでは、GetSqlDateTimeメソッドを使っています。GetSqlDateTimeメソッドは、(汎用的なデータベースの読み取りをするDataReaderオブジェクトではなくSQL Server固有の)SqlDataReaderオブジェクトに固有のメソッドで、SQL Serverのdatetime型を誤差なく正確に表現するSqlDateTimeオブジェクトとして値を返します。これによって誤差なくデータを取得できます。
次にVIEWSTATEに値を保存する処理ですが、次のようであってもかまわないと思われるかも知れません。



System.Data.SqlTypes.SqlDateTime lastupdate = sqlreader.GetSqlDateTime(8);
this.ViewState["lastupdate"] = lastupdate;


しかし実際には、上記のようにすると、実行時エラーが発生します。これは、VIEWSTATEには、シリアル化可能なオブジェクトしか格納することができないためです。SqlDateTimeクラスは、ISerializableインタフェースを備えておらず、シリアル化可能ではありません。
そこでここでは、DayTickプロパティとTimeTickプロパティに分けて、VIEWSTATEに保存しています。DayTickプロパティは日付部分のカウンタを、TimeTickプロパティは時刻部分のカウンタを、それぞれint型で示す値です。int型は、シリアル化可能ですから、値をVIEWSTATEに保存できます。



this.ViewState["lastupdate_day"] = lastupdate.DayTicks;
this.ViewState["lastupdate_time"] = lastupdate.TimeTicks;



コラム VIEWSTATEに保存したオブジェクトはポストバックのたびに再生成される
VIEWSTATEには、シリアル化可能なオブジェクトしか格納できません(ただし、int型などの基本型は、すべてシリアル化可能です)。
シリアル化とは、ストリームを渡されたときに、そこにオブジェクトが保持しているデータをすべて書き出すことを言います。シリアル化するためには、オブジェクトに実装されたISerializableインタフェースのGetObjectDataメソッドが使われます。
シリアル化したデータは、あとで復元することができます。復元には、クラスがもつ特別なコンストラクタが使われます。特別なコンストラクタには、ストリームが渡され、インスタンスが作られるときに、ストリームから復元すべきデータを読み込み、そのデータで初期化したオブジェクトを作り出します。この処理を「逆シリアル化」と呼びます。
ごく簡単にいえば、シリアル化とは、ファイルや文字列などに現在の状態を保存しておくこと、逆シリアル化とは、保存したデータから復元して元に戻すことです。
VIEWSTATEは、__VIEWSTATEという隠しフィールドで構成されます。隠しフィールドには文字列しか保存できません。そこでASP.NETは、VIEWSTATEに保存したオブジェクトを文字列化するためにISerializableインタフェースのGetObjectDataメソッドを呼び出して、オブジェクトの状態をストリームに書き出すように指示します。
そして復元時には、各クラスに用意されたストリームを引数として受け取る特別なコンストラクタが呼び出され、復元されます。
この行程からわかるように、VIEWSTATEに保存されたデータは、クライアントに送信されるときに文字列化され、ポストバックされたときには、文字列からオブジェクトを生成するという処理が行なわれます。
オブジェクトがずっとどこかに残っているわけではなく、ポストバックのたびにオブジェクトを作り直すという点に注意してください。あまりないとは思いますが、VIEWSTATEから取り出したオブジェクトは、ポストバッグごとに別の異なるオブジェクトを指しますから、ReferenceEqualsメソッドで同一インスタンスであるかを比較するときには、合致しないということが起こり得ます。



○ポストバック時に埋め込んだ最終更新日時と比較する
次にポストバック時の処理において、データベースを更新するときに、データベース上の最終更新日時と、いま非ポストバック時に埋め込んだ最終更新日時とが合致しているかどうかを調べる処理を追加します。
そのためには、まず、リスト5-4のWHERE句部分を次のように書き換えます。


【編集前】

// WHERE句
updatequery += " WHERE id=@id";


【編集後】

// WHERE句
updatequery += " WHERE id=@id and lastupdate=@oldlastupdate";


ここでは、WHERE句にlastupdate列の比較を入れて、最終更新日が合致するかを調べようとしています。比較のためには、@oldlastupdateというパラメータを使っています。このパラメータへの値の埋め込み処理は、次のように実装します。



// 追加
int daytick, timetick;
daytick = (int)this.ViewState["lastupdate_day"];
timetick = (int)this.ViewState["lastupdate_time"];
System.Data.SqlTypes.SqlDateTime oldlastupdate =
      new System.Data.SqlTypes.SqlDateTime (daytick, timetick);
sqlcmd.Parameters.Add("@oldlastupdate", SqlDbType.DateTime).Value = oldlastupdate;


ここでは、先の非ポストバッグ時に埋め込んでおいた最終更新日時のDayTickプロパティの値とTimeTickプロパティの値を読み取り、そこからSqlDateTimeオブジェクトを作り、それをパラメータとして埋め込んでいます。これによって、非ポストバック時の最終更新日時がWHERE句の条件として指定されます。
以上の処理によって、もし、ユーザーがポストバックしたときに、他の誰かが同じレコードをすでに書き換えていたならば、WHERE句におけるlastupdate列の値が合致しませんから、UPDATE文の対象となるレコードが存在しなくなり、図5-7に示した上書きを防止することができるようになります。


[TIPS]
ちなみに上書きを防止する方法は、ユーザーがWebブラウザの[戻る]ボタンを押して、さらに更新するという、「SUBMITボタンの二重押し」を抑制する効果もあります。ユーザーがいちどSUBMITボタンを押してポストバックすると、lastupdate列が書き換わります。その後、ユーザーがWebブラウザの[戻る]ボタンを押して、また何か書き換えて、SUBMITボタンを押したときには、更新前のlastupdate列の値がVIEWSTATEに格納されていますから、WHERE句の条件を満たさないので、戻ったページから書き換えることができなくなります。

○レコードの比較方法はさまざま
ここでは、誰かが更新したかどうかを調べる方法として、最終更新日時という概念を使いましたが、それ以外にも方法はあります。
もっとも簡単なのは、更新対象となるすべての列に対する「現在の値」をVIEWSTATEに保存しておき、WHERE句で、それらすべての列を条件として指定することです。
つまり、非ポストバック時の処理では、



this.ViewState["productname"] = sqlreader.GetString(1);
this.ViewState["price"] = sqlreader.GetDecimal(2);
this.ViewState["stock"] = sqlreader.GetInt32(3);
// 以下略…


などのように、全部の列の値をVIEWSTATEに保存しておき、ポストバック時の処理では、WHERE句を



updatequery += " WHERE id=@id and productname=@oldproductname, price=@oldprice, stock=@oldstock…以下略…";

のようにしておいて、VIEWSTATEに埋め込んだ値を条件として指定するのです。



sqlcmd.Parameters.Add("@oldproductname", SqlDbType.NVarChar, 64).Value =
      this.ViewState["productname"];
sqlcmd.Parameters.Add("@oldprice", SqlDbType.Money).Value =
      this.ViewState["price"];
sqlcmd.Parameters.Add("@oldstock", SqlDbType.Int).Value =
      this.ViewState["stocknum"];
// 以下略…

このような方法でも、上書きを禁止することができます。本連載では紹介しませんが、DataSetオブジェクトを使ってデータベースを更新する場合には、このようなWHERE句が構成されます。

[TIPS]
DataSetオブジェクトを使って更新するときのWHERE句は、開発者がカスタマイズすることもできます。

なお、今回実装した最終更新日時を使って比較する方法は、datetime型の精度に依存します。つまり3.33ミリ秒以内に誰かが上書きしたら、それを抑えることはできないので、ミッションクリティカルなケースには使えません。よって、より厳密に上書き禁止したいならば、ここで示したように、全部の列に対してWHERE句を指定するという方法をとったほうが安全です。
ただし、WHERE句に指定する項目が多ければ多いほど、パフォーマンスは悪くなります。よって全列をWHERE句で指定するのではなく、たとえば、レコードを書き換えたならばインクリメントするint型の列などを用意し、その列を比較するという方法をとったほうがよいでしょう。


レコードの削除
では次に、レコードの削除機能を実装していきます。レコードの削除は、productdelete.aspxファイルとして実装することにします。
productdelete.aspxファイルでは、いきなりDELETE文を実行してレコードを削除してもかまいませんが、そうするとユーザーが誤操作したときに取り返しが付かなくなるので、本当に削除して良いか、確認してから削除するほうがよいでしょう。
そこでproductdelete.aspxファイルを、リスト5-5図5-8のように構成することにします。


リスト5-5 productdelete.aspxファイル

図5-8 productdelete.aspxファイルで構成されるWebフォーム

図5-8 productdelete.aspxファイルで構成されるWebフォーム

レコードの削除処理は、レコードの更新処理と同様の処理で、UPDATE文の代わりにDELETE文を使うだけです。その処理は、リスト5-6のようになります。

リスト5-6 productdelete.aspx.csファイルの処理

リスト5-6では、次のDELETE文を送信することで、レコードを削除しています。


DELETE FROM Products WHERE id=@id and lastupdate=@oldlastupdate

ここで@idパラメータには、削除対象となる商品IDを、@oldlastupdateパラメータには、先に説明した更新時の処理と同様に、非ポストバック時に埋め込んでおいた古いlastupdate列の値を埋め込むようにしています。


// 更新となるレコードのID番号
sqlcmd.Parameters.Add("@id", SqlDbType.Int).Value = productid;

// 更新日時
int daytick, timetick;
daytick = (int)this.ViewState["lastupdate_day"];
timetick = (int)this.ViewState["lastupdate_time"];
System.Data.SqlTypes.SqlDateTime oldlastupdate = new System.Data.SqlTypes.SqlDateTime (daytick, timetick);
sqlcmd.Parameters.Add("@oldlastupdate", SqlDbType.DateTime).Value = oldlastupdate;


WHERE句に古いlastupdate列の値を指定することで、「誰かが更新したかも知れないレコードを削除してしまう」という事態を防いでいます。

■最後に
この連載では、商品管理というWebアプリケーションを作りながら、ADO.NETによる、レコードの「追加」「参照(検索)」「更新」「削除」と、データベース操作に必要なほとんどの処理を説明してきました。
とはいえ、この連載で説明したのは、基本的なデータベース操作にすぎず、実際にWebアプリケーションを構築する場合には、さらに、いくつかのことを考慮する必要があります。
最後に、考慮すべき項目について、いくつか挙げておきましょう。


(1)データベースとのやりとりをコンポーネント化する
まず第一に考えたいのは、データベースとのやりとりをコンポーネント化するということです。
この連載で構築した各々のWebフォームでは、直接ADO.NETを使ってデータベースへの接続をしているので、重複した処理が、Webフォームごとにたくさんあります。たとえば、「レコードの参照」について見ると、「詳細を参照するproductdetail.aspx」「更新するproductedit.aspx」「削除するproductdelete.aspx」で、同じように、「商品IDを指定して、そのレコードの値を取得する」という処理が実装されています。
そこで、図5-9のように、データベースへのアクセス部分は、別のコンポーネントに実装しておき、各Webフォームでは、データベースに直接アクセスせず、コンポーネントを通じてアクセスするようにします。
図5-9のように、データベースにアクセスする処理と、ユーザーインタフェースを担う処理を分けて構成することを「3階層モデル」と呼びます。


図5-9 3階層モデル

図4-16 DataGridコントロールのDataSourceプロパティを空にする

3階層モデルは、次の3つの層に分かれます。
  • プレゼンテーション層
    ユーザーインタフェースを担当するプログラムです。Webアプリケーションの場合には、Webフォームに相当します。
  • ビジネスロジック層
    データベース層のデータを操作するためのプログラムです。データストア層からのデータを取り出してプレゼンテーション層に渡したり、逆に、プレゼンテーション層から受け取ったデータをデータストア層に格納したりします。コンポーネントとして構成されます。
  • データストア層
    データを格納するものです。SQL Serverなどのデータベースが相当します。
大規模なWebアプリケーションを構築するならば、3階層モデルで構築すべきです。3階層モデルとして構築しておくと、次のようなメリットがあります。
  1. ユーザーインタフェースの入れ替えが容易
    ユーザーインタフェース部分の入れ替えが容易です。たとえば、Webアプリケーションとして構築したものを、さらに、Webサービスとして提供したいとか、Windows上での.NET Frameworkアプリケーションとして提供したいといった場合にも、ビジネスロジック層を変更することなく、プレゼンテーション層を新たに作れば対応できます。
  2. データ構造の変更が容易
    データベースの構成変更が容易です。たとえば、あとからデータベースの構成を変更した場合、ビジネスロジック層を変更するだけで対応できます。
    具体的には、最初は、1つのデータベースで運営していたけれども、負荷が高いので、複数のデータベースに分けたいという場合の対処がしやすくなります。
(2)キャッシュを使ったパフォーマンスの向上
今回作成してきたWebアプリケーションでは、クライアントが接続してくるたびに、データベース上のレコードを取得して、そこから動的に商品情報のWebページを構成しています。
しかしよく考えると、商品情報に変化がないならば、商品情報のWebページを作り直す必要はありません。そこで、いちど作成したWebページをキャッシュし、データベース上のレコードに変化がないならば、Webページを作り直さず、キャッシュされたWebページを返すようにすれば、パフォーマンスが高まるでしょう。
ASP.NETでキャッシュ機能を使うには、CacheオブジェクトやOutputCacheディレクティブを使います。


(3)買い物カゴを実現し、ショッピングサイトを構築する
今回作成してきたWebアプリケーションをさらに利用して、買い物カゴを実現し、ショッピングサイトを構築できるようにするという応用もありうるでしょう。
つまり商品の詳細ページに[カゴに入れる]などのボタンを用意して、買い物カゴに入れられるようにし、あとで精算ページに飛んで、配達先の住所や支払い方法などを選べば買い物できるという仕組みです。
買い物カゴは、Sessionオブジェクトを使うと容易に実現できます。しかし実際には、ユーザーが「二度同じ注文をしないような仕組み」や「SSLを使って暗号化する」など、安全面での配慮も、いくつか必要となります。


これらの項目については、また、機会があれば、本サイトにてご紹介できればと思います。
最後になりますが、本連載の執筆にあたっては、PASSJ理事の河端さんをはじめ、PASSJボードリーダーの皆様の多大なるご助言を頂きました。この場を借りて、感謝いたします。
なお本連載のご質問、ご要望に関しましては、「PASSJ掲示板『開発の現場を語ろう』」「Webテクノロジー分科会メーリングリスト」にてお受け付けいたしますので、宜しくお願いいたします。

今回のリストをすべてダウンロード(LZH形式)

「ステップアップ!ADO.NET」 目次
第1回 ASP.NETの基礎とデータベースへの書き込み
第2回 データベースからの読み込み 〜その1:単一レコードの表示〜
第3回 データベースからの読み込み 〜その2:複数レコードの表示〜
第4回 データベースからの読み込み 〜その3:ページ操作、並べ替え、そして検索〜

★この連載に関する意見交換は、下記の掲示板・MLをご利用ください★
・PASSJ掲示板 『開発の現場を語ろう』
・Webテクノロジー分科会メーリングリスト pml-web@sqlpassj.org

PASSJメールニュース 著作権ついて プライバシーポリシー リンクポリシー お問い合わせ
(C) 2005 Professional Association for SQL Server Japan. All rights reserved.