.NETの例外処理 Part.2


さて今度は ASP.NET ランタイムの例外処理について解説……しようかと思ったのですが、ここで例外処理について、もうちょっとツッコミを入れておきたいポイントがあります。それは、以下の 2 つです。

  • リソース解放のための try-finally 処理
  • Java の例外と .NET の例外の違い

この 2 つは実は例外処理コードを正しく書く上では欠かせない概念なのですが、世の中に出回っているサンプルコードでもこれらの点が正しく理解できていないために、誤ったコードが多数見られます。特に、データアクセス処理を記述する場合には正しい try ブロックの書き方が極めて重要になるのですが、これらが正しく書けているケースは非常に稀なのが悲しい現実。例外処理を正しく書くことは、障害解析・デバッグしやすいアプリケーションを作るための第一歩であり、プロフェッショナルデベロッパーになるための登竜門でもありますので、これらの内容についてもぜひ正しく理解していただきたいと思います。

# 以下はかなり難しいですが、一流になるための一つの壁だと思って、じっくり取りかかってください。

まずは、リソース解放における try-finally 処理から見ていきましょう。(コードサンプルが見づらいという方のために一応 zip ファイルも作っておきました。見づらい方はこちらからダウンロードして、手元で開いて確認してください。)

[リソース解放のための try-finally 処理]

前回のエントリでは、try-catch ブロックの記述ルールとして、以下の 3 つの原則を守らなければならない、という解説をしました。

  • try-catch ブロックは、例外が発生しうる『1 行』のみを囲む。
  • 一般例外(Exception クラス)ではなく、特定の例外(SqlException など)のみを捕捉する。
  • catch した後には、必ず後処理(業務エラーへの変換など)を記述する。

これらのルールは、try-catch ブロックを、フローチャートの調整機能として利用する場合のもの(=フローチャートからはみ出てしまったものを強制的に引き戻す際のルール)でした。しかし、try ブロックにはもう一つ、リソース解放の目的で利用する try-finally 処理というものがあり、この場合には、上記と全く違う記述を行うことになります。

  • try-finally ブロックは、アプリケーション全体を囲む。
  • 一般例外(Exception クラス)すべてに対して有効になるように記述する。
  • catch ブロックは書かない。

これは、実は try ブロックを記述する理由が全く違うから、なのですが、これを理解していただくために、次のような例を考えてみたいと思います。

[リソースリークとは何か]

.NET アプリケーションのランタイムである CLR (Common Language Runtime)には、ガベージコレクタ(GC)と呼ばれる仕組みが備わっている、ということをどこかで学ばれたかと思います。 このガベージコレクタの仕組みによって、従来の C++ アプリケーションの典型的なプログラムバグの一つであるメモリ解放漏れが防がれることになる……のですが、ここで注意すべき点は、GC の仕組みで防がれるリークはメモリリークである、という点です。実は、アプリケーションにおけるリークには大別してメモリリークとリソースリークがあり、リソースリークは GC のみで防止することができません

リソースリークの典型例として、SqlConnection オブジェクト(データベース接続)を例にとって考えてみましょう。SqlConnection オブジェクトは、以下のような特徴を持っています。

  • 内部でコネクションプールを使っている。
  • .Open() / .Close() メソッドを呼び出しても、物理的なデータベース接続がそのつどオープンされたりクローズされたりすることはない。
  • .Open() メソッドは、コネクションプールからのコネクションの貸し出し処理、.Close() メソッドは、プールへの返却処理になっている。

image

一般的に、データベースへの物理的な接続は非常に貴重なリソース(たくさん張り過ぎると各種のリソースを圧迫してしまうため)であり、大量に作るわけにはいきません。このようなオブジェクトを利用する際に正しいコーディングを行わないと、うまくプールに差し戻されずに、宙に浮いてしまって誰からも再利用できない状態のリソースが大量発生してしまい、問題となることがあります。これをリソースリークと呼びます。

[ADO.NET のデータアクセス処理におけるコネクションリークとその防止コード]

例えば以下のようなアプリケーションコードを例にとってみましょう。ここでは、SqlConnection, SqlCommand, SqlDataReader などを使って、接続型データアクセスにより一気にデータを取得する、というコードを記述しています。(※ アプリケーションコード上、SqlConnection や SqlCommand、ならびにパラメータ設定などの下準備がすべて済んでから、接続を開くコードになっていることにも注意してください。)

   1: class Program
   2: {
   3:     static void Main(string[] args)
   4:     {
   5:         AuthorDataAccess objDAC = new AuthorDataAccess();
   6:         List<AuthorDataAccess.AuthorList> ret = objDAC.GetDataByState("CA");
   7:         foreach (AuthorDataAccess.AuthorList a in ret)
   8:         {
   9:             Console.WriteLine("{0} {1}", a.au_id, a.au_name);
  10:         }
  11:     }
  12: }
  13:  
  14: public class AuthorDataAccess
  15: {
  16:     public List<AuthorList> GetDataByState(string state)
  17:     {
  18:         SqlConnection sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;Trusted_Connection=yes");
  19:         SqlCommand sqlcmd = new SqlCommand("SELECT au_id, au_fname, au_lname FROM authors WHERE state=@state", sqlcon);
  20:         sqlcmd.Parameters.AddWithValue("@state", state);
  21:         List<AuthorList> ret = new List<AuthorList>();
  22:  
  23:         sqlcon.Open();
  24:         SqlDataReader sqldr = sqlcmd.ExecuteReader();
  25:         while (sqldr.Read())
  26:         {
  27:             ret.Add(new AuthorList()
  28:             {
  29:                 au_id = sqldr["au_id"] as string,
  30:                 au_name = sqldr["au_fname"] as string + " " + sqldr["au_lname"] as string
  31:             });
  32:         }
  33:         sqlcon.Close();
  34:         return ret;
  35:     }
  36:  
  37:     public class AuthorList
  38:     {
  39:         public string au_id { get; set; }
  40:         public string au_name { get; set; }
  41:     }
  42: }

このアプリケーションコードにおいて、24 行目~32 行目の処理中に何らかの例外が発生したとします。すると、以下のような事象が発生します。

  • GetDataByState() メソッドは、sqlcon.Close() 処理が行われることなく終了し、上位モジュールに制御が戻ってしまう。
  • 変数 sqlcon が指し示しているコネクションオブジェクト(SqlConnection のインスタンス)は、プールに差し戻されることなく、かといって誰かから使われることもなく、宙に浮いたような形になる。(この状態をリソースリーク(またはコネクションリーク)と呼びます。)
  • しばらく時間がたって、ガベージコレクタがコネクションオブジェクトのメモリ解放を行おうとすると、SqlConnection クラスに実装された機能(ファイナライザ)が働き、物理的なコネクションをプールに差し戻してくれます。

上記の挙動からわかるように、リソースリーク(コネクションリーク)は一時的には発生するものの、ガベージコレクタの機能とファイナライザの機能によっていずれ解消はされます。しかし一時的とはいえ、しばらく誰からも再利用することができない宙に浮いたリソースが発生してしまうため、特にリクエストが集中する Web サイトなどでは、アプリケーションが過負荷状況に陥ると、不可解な挙動をする危険性が生じてきます。

これを避けるためには、仮に処理途中で例外が発生したとしても、確実にリソース(SqlConnection オブジェクト)が解放されるようにコーディングする必要があります。この目的のために、try-finally ブロックを以下のように記述します。

   1: public List<AuthorList> GetDataByState(string state)
   2: {
   3:     SqlConnection sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;Trusted_Connection=yes");
   4:     SqlCommand sqlcmd = new SqlCommand("SELECT au_id, au_fname, au_lname FROM authors WHERE state=@state", sqlcon);
   5:     sqlcmd.Parameters.AddWithValue("@state", state);
   6:     List<AuthorList> ret = new List<AuthorList>();
   7:  
   8:     try
   9:     {
  10:         sqlcon.Open();
  11:         SqlDataReader sqldr = sqlcmd.ExecuteReader();
  12:         while (sqldr.Read())
  13:         {
  14:             ret.Add(new AuthorList()
  15:             {
  16:                 au_id = sqldr["au_id"] as string,
  17:                 au_name = sqldr["au_fname"] as string + " " + sqldr["au_lname"] as string
  18:             });
  19:         }
  20:     }
  21:     finally
  22:     {
  23:         sqlcon.Close();
  24:     }
  25:     return ret;
  26: }

追加した行は、8, 9, 20, 21, 22, 24 行目です。このようにすると、コネクションオープン~処理終了までの間に異常事態(=例外)が発生したとしても、確実に接続を閉じる(プールに差し戻す)ことができ、リソースリークを防ぐことができます

※ sqlcon.Open() 処理に失敗した場合には .Close() できないのでは?と思われるかもしれませんが、.Close() メソッド内部では、うまく .Open() できていない場合には何もしないように作られています(=オープンしていないコネクションをクローズしても、トラブルが起きないように作られています)。

[try-catch と try-finally の比較]

さて、ここで try-catch と try-finally の比較をしておきましょう。

image

try-catch と try-finally は、記述する目的が全く違います。このため、try ブロックの書き方などが全くといっていいほど変わってきますし、この二つを混同するとアプリケーションコードもぐちゃぐちゃになります。まず目的意識を持って、どちらの try ブロックを書くのかをきちんと考えるようにしましょう。

[try-catch と try-finally を併用する場合のコード例]

では応用例として、try-catch と try-finally を併用する場合を考えてみたいと思います。今度は、authors テーブルにデータ挿入する以下のような処理を考えてみます。

   1: class Program
   2: {
   3:     static void Main(string[] args)
   4:     {
   5:         AuthorDataAccess objDAC = new AuthorDataAccess();
   6:         bool result = objDAC.InsertAuthor("172-32-1176", "White", "Johnson", "408 496-7223", true);
   7:         if (result == true)
   8:         {
   9:             Console.WriteLine("データを挿入しました。");
  10:         }
  11:         else
  12:         {
  13:             Console.WriteLine("データが重複しており、挿入できませんでした。");
  14:         }
  15:     }
  16: }
  17:  
  18: public class AuthorDataAccess
  19: {
  20:     public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
  21:     {
  22:         SqlConnection sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;Trusted_Connection=yes");
  23:         SqlCommand sqlcmd = new SqlCommand("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (@au_id, @au_lname, @au_fname, @phone, @contract)", sqlcon);
  24:         sqlcmd.Parameters.AddWithValue("@au_id", au_id);
  25:         sqlcmd.Parameters.AddWithValue("@au_lname", au_lname);
  26:         sqlcmd.Parameters.AddWithValue("@au_fname", au_fname);
  27:         sqlcmd.Parameters.AddWithValue("@phone", phone);
  28:         sqlcmd.Parameters.AddWithValue("@contract", contract);
  29:  
  30:         sqlcon.Open();
  31:         int affectedRows = sqlcmd.ExecuteNonQuery();
  32:         sqlcon.Close();
  33:  
  34:         return true;
  35:     }
  36: }

データ挿入では PK 衝突による失敗というものが考えられますが、ここでは、ID 重複していた場合にはエラーメッセージを出す(=ID 重複が業務エラーである)ものとして、どのように例外処理を実装すればよいのかを考えてみましょう。

この InsertAuthor() メソッドでは、以下の 2 つの例外処理を行う必要があります。

  • 確実なリソース解放(コネクションリーク防止)のための try-finally 処理
  • PK 制約違反を業務エラーに変換するための try-catch 処理

一度に行うと分かりにくいので、段階を追って解説しましょう。(コード全部をコピペするとものすごい量になるので、それぞれ一部だけコピペします。最終的な完成形は最後のコードを見てください。)

Step 1. リソース解放のための try-finally 処理の追加

まず、コネクションリーク防止のための try-finally 処理を追加します。これは非常に簡単で、接続オープンクローズまで全体を try-finally で囲み、finally の中でクローズすればおしまいです。(今回の例だと囲むべきコードが 2 行なのでちょっとさびしいですが^^)

try
{
    sqlcon.Open();
    int affectedRows = sqlcmd.ExecuteNonQuery();
}
finally
{
    sqlcon.Close();
}

Step 2. PK 制約違反のための try-catch 処理の追加

次に、PK 制約違反に対する対処のための try-catch を追加します。.ExecuteNonQuery() 命令時に PK 制約違反が発生すると、SqlException 例外が発生しますので、これに対する対処コードを追加します。

try
{
    sqlcon.Open();
    try
    {
        int affectedRows = sqlcmd.ExecuteNonQuery();
    }
    catch (SqlException sqle)
    {
        return false;
    }
}
finally
{
    sqlcon.Close();
}
 
return true;

ここで、try ブロックが二重に記述される形になっていることに着目してください。外側の try ブロックと内側の try ブロックは、記述目的が違うので、まとめてはいけません

  • 外側の try ブロックは、リソース解放のためのもの。(try-finally、catch は書かず、大きく囲む)
  • 内側の try ブロックは、業務エラー制御のためのもの。(try-catch、1 行だけを小さく囲む)

Step 3. 間違って拾ってしまった SqlException への対処

さて、.ExecuteNonQuery() 命令で発生する SqlException 例外は、PK 制約違反以外でも発生するものでした(ディスク枯渇やネットワークエラーなど)。こうした、PK 制約違反以外で発生した SqlException 例外を業務エラー化してしまうとまずいため、PK 制約違反のみを業務エラーに変換するように、catch ブロックの中に振り分けロジックを記述します。(この辺の詳細は Part.1 に書いたのでそちらを参照してください。)

try
{
    sqlcon.Open();
    try
    {
        int affectedRows = sqlcmd.ExecuteNonQuery();
    }
    catch (SqlException sqle)
    {
        if (sqle.Number == 2627)
        {
            return false;
        }
        else
        {
            throw;
        }
    }
}
finally
{
    sqlcon.Close();
}

Step 4. 「あり得ない事象」が発生した場合への対処コードの追加

そして最後に、あり得ない事象が発生した場合のための自爆コードを記述します。この例の場合、INSERT 処理で影響を受ける行数(更新結果行数)は 1 行であるはずなので、それ以外だった場合には自爆するためのコードを書きます。

try
{
    sqlcon.Open();
    try
    {
        int affectedRows = sqlcmd.ExecuteNonQuery();
        if (affectedRows != 1) throw new ApplicationException("INSERT 処理で異常事態が発生しました。" + affectedRows.ToString());
    }
    catch (SqlException sqle)
    {
        if (sqle.Number == 2627)
        {
            return false;
        }
        else
        {
            throw;
        }
    }
}
finally
{
    sqlcon.Close();
}
 
return true;

以下が最終的に完成した、正しい InsertAuthor() メソッドです。的確に例外処理を書こうと思うと、意外に大変であることがおわかりいただけるかと思います。(が、同時に楽しい!と思ってしまったりするんですけどね、私の場合^^)

public bool InsertAuthor(string au_id, string au_lname, string au_fname, string phone, bool contract)
{
    SqlConnection sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;Trusted_Connection=yes");
    SqlCommand sqlcmd = new SqlCommand("INSERT INTO authors (au_id, au_lname, au_fname, phone, contract) VALUES (@au_id, @au_lname, @au_fname, @phone, @contract)", sqlcon);
    sqlcmd.Parameters.AddWithValue("@au_id", au_id);
    sqlcmd.Parameters.AddWithValue("@au_lname", au_lname);
    sqlcmd.Parameters.AddWithValue("@au_fname", au_fname);
    sqlcmd.Parameters.AddWithValue("@phone", phone);
    sqlcmd.Parameters.AddWithValue("@contract", contract);
 
    try
    {
        sqlcon.Open();
        try
        {
            int affectedRows = sqlcmd.ExecuteNonQuery();
            if (affectedRows != 1) throw new ApplicationException("INSERT 処理で異常事態が発生しました。" + affectedRows.ToString());
        }
        catch (SqlException sqle)
        {
            if (sqle.Number == 2627)
            {
                return false;
            }
            else
            {
                throw;
            }
        }
    }
    finally
    {
        sqlcon.Close();
    }
 
    return true;
}

さて、データアクセスコードに関する正しい try ブロックの書き方についてここまで解説してきましたが、実際の業務アプリケーションの記述では、さらに以下のような点も問題になってきます。これらについてもせっかくなのでついでに解説してしまいましょう。

  • トランザクション処理を伴うときの try ブロックの記述(マニュアル/自動トランザクション)
  • テーブルアダプタを使うときの try ブロックの記述

※ テーブルアダプタや自動トランザクションに関する一般的な解説についてはここでは割愛しますので、必要な方は一般的な書籍を読んで学習してください。

[トランザクション処理を伴うときの try ブロックの記述]

さて try-finally ブロックは、異常事態が発生したときのリソース解放の目的で典型的に利用されますが、トランザクションの確実なロールバックの目的などにも利用されます。

① マニュアルトランザクションの場合

まずは、以下のようなマニュアルトランザクション処理に対して、例外処理を追加してみましょう。以降は少し応用的なコード(ここまでに解説してきた try-catch や try-finally とはちょっと違った書き方をするもの)になりますので、よくコードを見てみてください。

public bool RaisePriceByTitleId(string title_id, decimal priceDelta)
{
    SqlConnection sqlcon = new SqlConnection("server=.;database=pubs;Trusted_Connection=yes");
    SqlCommand sqlcmd1 = new SqlCommand("SELECT price FROM titles WITH (UPDLOCK) WHERE title_id=@title_id", sqlcon);
    SqlCommand sqlcmd2 = new SqlCommand("UPDATE titles SET price = @val WHERE title_id=@title_id", sqlcon);
    sqlcmd1.Parameters.AddWithValue("@title_id", title_id);
    sqlcmd2.Parameters.AddWithValue("@title_id", title_id);
 
    sqlcon.Open();
    SqlTransaction sqltx = sqlcon.BeginTransaction(IsolationLevel.Serializable);
    sqlcmd1.Transaction = sqltx;
    sqlcmd2.Transaction = sqltx;
 
    SqlDataReader sqldr = sqlcmd1.ExecuteReader();
    sqldr.Read();
    Decimal val = (Decimal)sqldr["price"];
    sqldr.Close();
    
    val = val + priceDelta;
    sqlcmd2.Parameters.AddWithValue("@val", val);
    sqlcmd2.ExecuteNonQuery();
    sqltx.Commit();
    sqlcon.Close();
 
    return true;
}

まず上記のコードに、確実なトランザクションロールバックのためのコードを含めたコードは以下のようになります。コネクションの確実な解放のための try-finally を追加した場合、.Close() メソッドにより未コミット状態のトランザクションがロールバックされますので、基本的にはこれで十分です。

public bool RaisePriceByTitleId(string title_id, decimal priceDelta)
{
    SqlConnection sqlcon = new SqlConnection("server=.;database=pubs;Trusted_Connection=yes");
    SqlCommand sqlcmd1 = new SqlCommand("SELECT price FROM titles WITH (UPDLOCK) WHERE title_id=@title_id", sqlcon);
    SqlCommand sqlcmd2 = new SqlCommand("UPDATE titles SET price = @val WHERE title_id=@title_id", sqlcon);
    sqlcmd1.Parameters.AddWithValue("@title_id", title_id);
    sqlcmd2.Parameters.AddWithValue("@title_id", title_id);
 
    try
    {
        sqlcon.Open();
        SqlTransaction sqltx = sqlcon.BeginTransaction(IsolationLevel.Serializable);
        sqlcmd1.Transaction = sqltx;
        sqlcmd2.Transaction = sqltx;
        SqlDataReader sqldr = sqlcmd1.ExecuteReader();
        if (sqldr.Read() == false)
        {
            sqldr.Close();
            sqltx.Rollback();
            return false;
        }
        Decimal val = (Decimal)sqldr["price"];
        sqldr.Close();
        val = val + priceDelta;
        sqlcmd2.Parameters.AddWithValue("@val", val);
        sqlcmd2.ExecuteNonQuery();
        sqltx.Commit();
    }
    finally
    {
        sqlcon.Close();
    }
    return true;
}

もしもっと明示的にロールバックコードを書きたい!ということであれば、以下のようなコードになります。

public bool RaisePriceByTitleId(string title_id, decimal priceDelta)
{
    SqlConnection sqlcon = new SqlConnection("server=.;database=pubs;Trusted_Connection=yes");
    SqlCommand sqlcmd1 = new SqlCommand("SELECT price FROM titles WITH (UPDLOCK) WHERE title_id=@title_id", sqlcon);
    SqlCommand sqlcmd2 = new SqlCommand("UPDATE titles SET price = @val WHERE title_id=@title_id", sqlcon);
    sqlcmd1.Parameters.AddWithValue("@title_id", title_id);
    sqlcmd2.Parameters.AddWithValue("@title_id", title_id);
 
    try
    {
        sqlcon.Open();
        SqlTransaction sqltx = null;
        try
        {
            sqltx = sqlcon.BeginTransaction(IsolationLevel.Serializable);
            sqlcmd1.Transaction = sqltx;
            sqlcmd2.Transaction = sqltx;
            SqlDataReader sqldr = sqlcmd1.ExecuteReader();
            if (sqldr.Read() == false)
            {
                sqldr.Close();
                sqltx.Rollback();
                return false;
            }
            Decimal val = (Decimal)sqldr["price"];
            sqldr.Close();
            val = val + priceDelta;
            sqlcmd2.Parameters.AddWithValue("@val", val);
            sqlcmd2.ExecuteNonQuery();
            sqltx.Commit();
        }
        catch (Exception)
        {
            if (sqltx != null) sqltx.Rollback();
            throw;
        }
    }
    finally
    {
        sqlcon.Close();
    }
    return true;
}

このコードでは、内側の try ブロックにより、処理中に例外が発生した場合には確実にロールバックするための try-catch を記述していますが、この try-catch は、例外を業務エラーに変換するためのものではなく、後処理を確実に行うためのものです。このため、ここまでに解説してきた try-catch とも、また try-finally とも違った書き方をしています。ポイントは以下の通りです。

  • 大きく try ブロックを記述し、捕捉漏れのないようにしている。
  • 一般例外 Exception クラスを拾い、どんな例外が発生した場合でも後処理できるようにしている。
  • 例外を潰してはいけないので、後処理後に throw を行い、catch しなかったことにしている。

上記のコードを見て、「うーん、難しい;」と思った方も多いと思います。はい、難しいです><。が、自動トランザクション(TransactionScope オブジェクト)を使った場合には、もうちょいラクになります。

② 自動トランザクションの場合

上記のコードを、自動トランザクションを使って書いてみます。まず、コネクションリークについて考えずに書いたコードは以下のようになります。

public bool RaisePriceByTitleId2(string title_id, decimal priceDelta)
{
    using (TransactionScope scope = new TransactionScope(TransactionScopeOption.RequiresNew))
    {
        SqlConnection sqlcon = new SqlConnection("server=.;database=pubs;Trusted_Connection=yes");
        SqlCommand sqlcmd1 = new SqlCommand("SELECT price FROM titles WITH (UPDLOCK) WHERE title_id=@title_id", sqlcon);
        SqlCommand sqlcmd2 = new SqlCommand("UPDATE titles SET price = @val WHERE title_id=@title_id", sqlcon);
        sqlcmd1.Parameters.AddWithValue("@title_id", title_id);
        sqlcmd2.Parameters.AddWithValue("@title_id", title_id);
 
        sqlcon.Open();
        SqlDataReader sqldr = sqlcmd1.ExecuteReader();
        if (sqldr.Read() == false)
        {
            sqldr.Close();
            sqlcon.Close();
            return false;
        }
        Decimal val = (Decimal)sqldr["price"];
        sqldr.Close();
        val = val + priceDelta;
        sqlcmd2.Parameters.AddWithValue("@val", val);
        sqlcmd2.ExecuteNonQuery();
        sqlcon.Close();
 
        scope.Complete();
    }
    return true;
}

前出のコードと異なり、TransactionScope オブジェクトは、例外発生時の確実なロールバック処理について考えなくて済むようになっているという点に着目してください。では、ここにコネクションリークの対処コードを追加してみます。

public bool RaisePriceByTitleId2(string title_id, decimal priceDelta)
{
    using (TransactionScope scope = new TransactionScope(TransactionScopeOption.RequiresNew))
    {
        SqlConnection sqlcon = new SqlConnection("server=.;database=pubs;Trusted_Connection=yes");
        SqlCommand sqlcmd1 = new SqlCommand("SELECT price FROM titles WITH (UPDLOCK) WHERE title_id=@title_id", sqlcon);
        SqlCommand sqlcmd2 = new SqlCommand("UPDATE titles SET price = @val WHERE title_id=@title_id", sqlcon);
        sqlcmd1.Parameters.AddWithValue("@title_id", title_id);
        sqlcmd2.Parameters.AddWithValue("@title_id", title_id);
 
        try
        {
            sqlcon.Open();
            SqlDataReader sqldr = sqlcmd1.ExecuteReader();
            if (sqldr.Read() == false)
            {
                sqldr.Close();
                return false;
            }
            Decimal val = (Decimal)sqldr["price"];
            sqldr.Close();
            val = val + priceDelta;
            sqlcmd2.Parameters.AddWithValue("@val", val);
            sqlcmd2.ExecuteNonQuery();
        }
        finally
        {
            sqlcon.Close();
        }
 
        scope.Complete();
    }
    return true;
}

マニュアルトランザクションの場合に比べると非常に簡単にトランザクション制御ができていることがお分かりいただけると思うのですが、ここでテーブルアダプタを利用すると、このコードはさらに簡単になります。

[テーブルアダプタを使う場合]

そもそもテーブルアダプタは、データベース接続オープン~SQL処理~接続クローズまでの一連の処理をラッピングしたクラスを作ってくれるものです。しかも、コネクションリークに対する対処も内部的に行ってくれているので、自分で try-finally を記述する必要がありません。

例えば、上記の処理をテーブルアダプタを使って作ってみることにします。以下のような .xsd ファイルを作成し、実行するパラメタライズドクエリを定義します。

image 

このとき、.xsd ファイルから自動生成されるコードの中身を見てみると、こんなコードが書かれています。

   1: [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
   2: [global::System.ComponentModel.Design.HelpKeywordAttribute("vs.data.TableAdapter")]
   3: public virtual global::System.Nullable<decimal> GetPriceByTitleIdWithUpdlock(string title_id) {
   4:     global::System.Data.SqlClient.SqlCommand command = ((global::System.Data.SqlClient.SqlCommand)(this.CommandCollection[0]));
   5:     if ((title_id == null)) {
   6:         throw new global::System.ArgumentNullException("title_id");
   7:     }
   8:     else {
   9:         command.Parameters[0].Value = ((string)(title_id));
  10:     }
  11:     global::System.Data.ConnectionState previousConnectionState = command.Connection.State;
  12:     if (((command.Connection.State & global::System.Data.ConnectionState.Open) 
  13:                 != global::System.Data.ConnectionState.Open)) {
  14:         command.Connection.Open();
  15:     }
  16:     object returnValue;
  17:     try {
  18:         returnValue = command.ExecuteScalar();
  19:     }
  20:     finally {
  21:         if ((previousConnectionState == global::System.Data.ConnectionState.Closed)) {
  22:             command.Connection.Close();
  23:         }
  24:     }
  25:     if (((returnValue == null) 
  26:                 || (returnValue.GetType() == typeof(global::System.DBNull)))) {
  27:         return new global::System.Nullable<decimal>();
  28:     }
  29:     else {
  30:         return new global::System.Nullable<decimal>(((decimal)(returnValue)));
  31:     }
  32: }

このコードの、17行目~24行目に着目してください。テーブルアダプタでは、SQL 文を実行した際に例外が発生した際でも確実にコネクションクローズが行われるように、内部でちゃんと try-finally を利用しているわけです。このため、テーブルアダプタ(と自動トランザクション)を利用すれば、前掲のコードは以下のように非常に簡単に書けます。

   1: public bool RaisePriceByTitleId3(string title_id, decimal priceDelta)
   2: {
   3:     using (TransactionScope scope = new TransactionScope(TransactionScopeOption.RequiresNew))
   4:     {
   5:         PubsDataSetTableAdapters.QueriesTableAdapter ta = new PubsDataSetTableAdapters.QueriesTableAdapter();
   6:         decimal? val = ta.GetPriceByTitleIdWithUpdlock(title_id) as decimal?;
   7:         if (val.HasValue == false) return false;
   8:         val = val.Value + priceDelta;
   9:         ta.SetNewPriceByTitleId(val, title_id);
  10:         scope.Complete();
  11:     }
  12:     return true;
  13: }

ここで注意していただきたいのは、このコードには try ブロックが一つもありませんが、以下の点に対しては対処済みのコードになっている、という点です。

  • リソースリーク(コネクションリーク)が発生しないようにするための、確実な .Close() 処理
  • トランザクションロールバック漏れが発生しないようにするための、確実なロールバック処理

マニュアルトランザクションを使って自力で SQL 文を書く場合に比べて、恐ろしく簡単になっている(=恐ろしく生産性が高い)ことがお分かり頂けると思います。(というより、こういう機能があるにもかかわらず自力で ADO.NET を直接触ろうとするのは狂気の沙汰としか....と、つぶやいてみる)

[テーブルアダプタを利用する場合の SqlException 例外に対する対処コード]

では、もうちょっと応用的な例として、テーブルアダプタを利用する場合に発生した PK 制約違反などをどのようにハンドリングすればよいのかについて解説しましょう。

ここでは話を簡単にするために、以下のような SQL 文をテーブルアダプタを使って定義します。

  • DELETE FROM authors WHERE au_id=@au_id

image

このテーブルアダプタを使う処理を以下に示します。

   1: public bool DeleteAuthor(string au_id)
   2: {
   3:     using (TransactionScope scope = new TransactionScope(TransactionScopeOption.RequiresNew))
   4:     {
   5:         PubsDataSetTableAdapters.authorsTableAdapter ta = new PubsDataSetTableAdapters.authorsTableAdapter();
   6:         int affectedRows = ta.DeleteAuthorByAuId(au_id);
   7:         scope.Complete();
   8:     }
   9:     return true;
  10: }

authors テーブルには FK 制約(その人が執筆した書籍のデータ)があるために、au_id を指定してこの処理を呼び出しても、必ずしも削除できるとは限りません。また、そもそも指定された著者 ID が存在しない場合もあります。そこで、業務エラーを、① 著者 ID が存在しなかった、② 関連書籍があるために削除できなかった、の 2 パターンとして、上記のコードに例外処理や分岐処理を加えることにすると、以下のようになります。

   1: public DeleteAuthorResult DeleteAuthor(string au_id)
   2: {
   3:     using (TransactionScope scope = new TransactionScope(TransactionScopeOption.RequiresNew))
   4:     {
   5:         PubsDataSetTableAdapters.authorsTableAdapter ta = new PubsDataSetTableAdapters.authorsTableAdapter();
   6:         int affectedRows;
   7:         try
   8:         {
   9:             affectedRows = ta.DeleteAuthorByAuId(au_id);
  10:         }
  11:         catch (SqlException sqle)
  12:         {
  13:             if (sqle.Number == 547) return DeleteAuthorResult.FailureByExistingTitles;
  14:             throw;
  15:         }
  16:  
  17:         if (affectedRows == 0) return DeleteAuthorResult.FailureByNotFoundAuId;
  18:         if (affectedRows != 1) throw new ApplicationException("DELETE 処理で異常事態が発生しました。" + affectedRows.ToString());
  19:  
  20:         scope.Complete();
  21:     }
  22:     return DeleteAuthorResult.Success;
  23: }
  24:  
  25: public enum DeleteAuthorResult
  26: {
  27:     Success,
  28:     FailureByNotFoundAuId,
  29:     FailureByExistingTitles
  30: }

具体的な修正点は以下の通りです。

  • FK 制約違反は SqlException 例外として発生するため、これを catch する。
  • しかしすべての SqlException 例外が FK 制約違反ではないため、catch ブロック内で寄り分ける。
  • さらに更新結果行数が 1 行でない場合(=対象行がなかっていた場合など)には、自爆するようにする。

このように、テーブルアダプタや自動トランザクション処理などを利用すると、かなり例外処理が書きやすくなることがご理解いただけるかと思います。

[IDisposable インタフェースと using ブロックによる try-finally の記述]

さて、ここまでいろいろと回り道しながら正しい try-catch, try-finally ブロックの書き方について解説を進めてきましたが、最後に、IDisposable インタフェース、そして try-finally ブロックと using ブロックの関係について解説をしておくことにします。

基本的に try-finally 処理は、確実なリソース解放を行う目的で利用します。try-finally によるリソース解放を必要とする代表的なリソースとしては、以下のようなものが挙げられます。

  • SqlConnection オブジェクト(データベース接続)
  • SqlTransaction オブジェクト(マニュアルトランザクション) (※ これは前述のとおり try-catch が必要になりますが。)
  • TransactionScope オブジェクト(自動トランザクション)
  • StreamReader オブジェクト(ファイル読み取り)
  • Socket オブジェクト(ソケット通信オブジェクト)

これらは、OS などが保有する貴重な共有リソースを利用するため、当該オブジェクトを使ったらすぐさまオブジェクトを解放し、オブジェクトが内部的に抱えている共有リソースを返却する必要があります。

通常、これを行うのが各オブジェクトの持つ .Close() メソッドなどなのですが、こうした「明示的な即時解放」を必要とするクラスがどれなのかは、ぱっとクラス名だけを見ても分かりません。そこで、.NET Framework では、

  • IDisposable と呼ばれるインタフェースを用意。
  • プログラマが明示的に解放処理を行わなければならないクラスについては、このクラスを継承させて作らなければならない。

という設計・実装ルールを設けています。(実際、上記に挙げたクラスはすべて IDisposable インタフェースを継承する形で作られています。)

IDisposable インタフェースは非常に単純なインタフェースで、以下のようなものになっています。

   1: public interface IDisposable
   2: {
   3:     void Dispose();
   4: }

このインタフェースを継承したクラスは必ず .Dispose() メソッドを持ち、そしてその中で、リソース解放処理を書かなければならない、というルールになっています。実はリソース解放は、.Close() メソッドではなくこちらの .Dispose() メソッドでもよいため、例えば SqlConnection オブジェクトの確実なリソース解放のためには、.Close() ではなく .Dispose() を使っても OK になっています。

   1: SqlConnection sqlcon = new SqlConnection("...");
   2: SqlCommand sqlcmd = new SqlCommand("...", sqlcon);
   3: ...
   4:  
   5: try
   6: {
   7:     sqlcon.Open();
   8:     ...
   9: }
  10: finally
  11: {
  12:     sqlcon.Dispose(); // sqlcon.Close() のかわりに
  13: }

※ ちなみに、SqlConnection オブジェクトに対して、.Dispose() を使うべきか .Close() を使うべきか、という議論が昔ありましたが、結論からいえば、どちらでも好きな方で書いていただければ OK です。理由は単純で、中身の処理が同じだから、です。一般的に SqlConnection のようなオブジェクトは、オープン~ディスポーズ、よりも、オープン~クローズ、の方が直観的にわかりやすいので、.Dispose() ではなく .Close() を使うことが多いですが、どちらで書いても処理的には変わりません。

さて、IDisposable インタフェースを持つオブジェクトは、確実かつ明示的なリソース解放が必要である、と書きました。このため、IDisposable インタフェースを持つオブジェクト(仮に DisposableClass としました)の基本的な実装パターンは、以下のようになります。

   1: DisposableClass obj = new DisposableClass();
   2:  
   3: try
   4: {
   5:     ...
   6:     obj を利用するコード...
   7:     ...
   8: }
   9: finally
  10: {
  11:     if (obj != null) obj.Dispose();
  12: }

このようなコーディングパターンは頻出であるために、C# や VB では、このコードをもっと簡単に書ける構文を導入しました。それが、using ブロックです。上記のコードは以下のように記述することができます。

   1: using (DisposableClass obj = new DisposableClass())
   2: {
   3:     ...
   4:     obj を利用するコード...
   5:     ...
   6: }

SqlConnection オブジェクトは IDisposable インタフェースを継承して作られているので、以下のようなコードを書くこともできます。

   1: int count;
   2:  
   3: using (SqlConnection sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;Trusted_Connection=yes"))
   4: {
   5:    SqlCommand sqlcmd = new SqlCommand("SELECT COUNT(*) FROM authors", sqlcon);
   6:    sqlcon.Open();
   7:    count = (int)sqlcmd.ExecuteScalar();
   8: }
   9:  
  10: Console.WriteLine(count);

このサンプルコードで注目していただきたいのは、8 行目のコードです。ここには "}" (中閉じかっこ)しか書いていませんが、実はクローズ処理が行われます。上記のコードは下記と等価です。

   1: int count;
   2:  
   3: SqlConnection sqlcon = null;
   4: try
   5: {
   6:     sqlcon = new SqlConnection("server=.;Initial Catalog=pubs;Trusted_Connection=yes"))
   7:     SqlCommand sqlcmd = new SqlCommand("SELECT COUNT(*) FROM authors", sqlcon);
   8:     sqlcon.Open();
   9:     count = (int)sqlcmd.ExecuteScalar();
  10: }
  11: finally
  12: {
  13:     if (sqlcon != null) sqlcon.Dispose();
  14: }
  15:  
  16: Console.WriteLine(count);

この二つのコードを見比べた場合、どちらが分かりやすいかというと、おそらくは後者の方でしょう。このため、SqlConnection のようなオブジェクトの場合には using ブロックはあまり使わないのですが、TransactionScope オブジェクトなどの場合には、using ブロックを使った方がわかりやすいため、try-finally ではなく using ブロックを使った実装を行います。

[try-finally によるリソース解放に関するまとめ]

というわけで、かなり長々と try-catch および try-finally について解説してきましたが、キーポイントをまとめると以下のようになります。

① try-catch ブロックは、基本的に、例外を業務エラーなどに変換したい場合に使う。

  • try-catch ブロックは、例外が発生しうる『1 行』のみを囲む。
  • 一般例外(Exception クラス)ではなく、特定の例外(SqlException など)のみを捕捉する。
  • catch した後には、必ず後処理(業務エラーへの変換など)を記述する。

② try-finally ブロックは、基本的に、リソースを確実に解放したい場合に使う。

  • try-finally ブロックは、アプリケーション全体を囲む。
  • 一般例外(Exception クラス)すべてに対して有効になるように記述する。
  • catch ブロックは書かない。

上記の二つは絶対に混同してはいけません。場合によっては、try ブロックをネストさせ、目的意識を持って例外処理を書くようにしてください。

# というかあまりにもエントリが長すぎたので .NET と Java の例外の違いについてはまた今度に;。

Comments (2)

  1. aetos より:

    > ※ sqlcon.Open() 処理に失敗した場合には .Close() できないのでは?と思われるかもしれませんが、.Close() メソッド内部では、うまく .Open() できていない場合には何もしないように作られています(=オープンしていないコネクションをクローズしても、トラブルが起きないように作られています)。

    それは素晴らしい。

    ところで、それは Open を try の中に入れる理由にはならないと思うのですが、何故入れているのでしょうか?

    その方が分かりやすいから、でしょうか?

  2. nakama より:

    > aetosさん

    > ところで、それは Open を try の中に入れる理由にはならないと思うのですが、何故入れているのでしょうか?

    > その方が分かりやすいから、でしょうか?

    いえ、コーディングの作法上、そのようにした方がより厳格だからです。SqlConnection クラスの .Open() メソッドから例外が発生した場合、

    ・内部的にはまだ物理コネクションが開いていない。

    ・内部的には物理コネクションが開いてしまっている。(.Open() メソッド内で物理コネクションが開いた後で、何らかの例外が発生してしまった) ← もちろん滅多にはありませんが

    の 2 パターンが「論理的には」ありうるので、.Open() メソッドも try ブロックに入れるべき、ということになります。

    まあ、「気にしすぎ」なのは確かで、お作法的な意味のほうが強いとは思うのですが。

Skip to main content