■レコードの更新と削除へのリンク
レコードを更新する場合でも、レコードを削除する場合でも、既存のレコードをユーザーに表示し、どのレコードを編集または削除するのかを尋ねる必要があります。そのためには、対象となるレコードをクリックするためのリンクが必要となるでしょう。
リンクをどこに設けるかは、いくつかの考え方があるかと思いますが、今回は話を簡単にするため、第3回、第4回で作成したDataGridコントロールによる一覧表示のWebページ(productview.aspx)に、[編集]と[削除]のリンクを設け、そこから編集用のページや削除用のページに移動できるようにすることにします(図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 [編集]のハイパーコントロール列の追加
●削除用のリンク
同様にして、削除用のリンクも用意します。以降、削除ページは、productdelete.aspxファイルに実装していくことにします。
productdetail.aspxファイルでも、同様に、削除対象となる商品IDをURLクエリーとして得ることにしましょう。つまり、“productdelete.aspx?id=商品ID”というURLを使うことにします。
そこで、productview.aspxファイル上のDataGridコントロールに、図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を見るとわかるように、第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において、ユーザーが商品情報を編集し、[変更する]ボタンを押したときの処理――を実装していきましょう。
ポストバック処理では、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 誰かがすでに商品情報を削除した場合の問題点

(2)あるユーザーの編集を上書きする ユーザーAとユーザーBが同時に編集をしはじめたとします。このときユーザーAもユーザーBも同じ商品の情報を見ています。そしてユーザーAが先に編集を終えてSUBMITボタンを押したとします。するとデータベース上の情報は、ユーザーAの編集後の内容に置き換わります。
しかしユーザーBは、ユーザーAが編集する前の情報を編集しています。ここでユーザーBがSUBMITボタンを押して更新すると、ユーザーAが編集したデータを上書きして書き換えてしまうことになります(図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フォーム
レコードの削除処理は、レコードの更新処理と同様の処理で、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列の値を指定することで、「誰かが更新したかも知れないレコードを削除してしまう」という事態を防いでいます。
|