データ ポイント

LINQ クエリのプリコンパイル

Julie Lerman

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

アプリケーションで LINQ to SQL や LINQ to Entities を使用するときは、繰り返し実行するクエリを作成する際に、クエリのプリコンパイルを検討することが重要です。開発工程では、特定の仕事に忙殺され、工程がかなり進むまで、プリコンパイル済みのクエリを活用することをつい忘れがちです。これは、事が起きてからアプリケーションに例外処理を押し込もうとする、"例外処理病" と非常によく似ています。

ただし、この重要なパフォーマンス強化技法を実装したとしても、そのメリットが失われる可能性があります。パフォーマンスの向上が想定どおりに実現されていないことに気付いても、その理由 (および解決策) がわからない場合があります。

今回のコラムでは、まず、クエリをプリコンパイルする方法について説明してから、Web アプリケーションや Web サービスなどのシナリオでプリコンパイルのメリットが失われる問題を重点的に取り上げます。ここでは、ポストバックとポストバックの間、有効期間が短いサービス操作、および重要なインスタンスがスコープ外になるような状況で、パフォーマンス上のメリットを確実に得る方法を理解します。

クエリのプリコンパイル

LINQ クエリを関連するストア クエリ (データベースによって実行される T-SQL など) に変換する処理は、クエリの実行という大きな括りの中でも比較的負荷の高いプロセスです。図 1 に、LINQ to Entities クエリからストア クエリへの変換に関わるプロセスを示します。


図 1 LINQ クエリから関連ストア クエリへの変換

Entity Framework チームがブログに投稿した「Exploring the Performance of the ADO.NET Entity Framework - Part 1」 (ADO.NET Entity Framework のパフォーマンスを調べる、第 1 部)(blogs.msdn.com/adonet/archive/2008/02/04/exploring-the-performance-of-the-ado-net-entity-framework-part-1.aspx、英語) では、この変換プロセスが細かい手順に分けられ、各手順にかかる相対時間が示されています。この投稿は、Microsoft .NET Framework 3.5 SP1 バージョンの Entity Framework に基づいているため、新しいバージョンでは手順ごとの時間がおそらく変わります。いずれにせよ、プリコンパイルはクエリ実行プロセスの中で負荷の高い部分であることは変わりません。

クエリをプリコンパイルすることによって、Entity Framework と LINQ to SQL はそのストア クエリを再利用でき、毎回クエリを解釈する余分なプロセスを省略できます。たとえば、アプリケーションでデータ ストアからさまざまな顧客を頻繁に取得しているとすると、おそらく次のようなクエリを使用します。

Context.Customers.Where(c=>c.CustomerID==_custID)

毎回の実行で _custID パラメーターが変化するだけであれば、毎回このパラメーターを入れ替えて SQL コマンドを作成する作業は無駄だとは思いませんか。

LINQ to SQL と Entity Framework ではどちらもクエリのプリコンパイルを実行できます。ただし、双方のフレームワークのプロセスが一部異なることから、それぞれ独自の CompiledQuery クラスを用意しています。LINQ to SQL が System.Data.LINQ.CompiledQuery を使用するのに対して、Entity Framework は System.Data. Objects.CompiledQuery を使用します。どちらの形式の CompiledQuery もパラメーターを渡すことができ、どちらも現在使用している DataContext または ObjectContext を渡す必要があります。コーディングの観点から見れば、基本的にはどちらも同じです。

CompiledQuery.Compile メソッドは、その後必要に応じて呼び出せる関数の形式でデリゲートを返します。

以下に、Entity Framework の CompiledQuery クラスによってコンパイルされる簡単なクエリを示します。このクラスは静的クラスのため、インスタンスを作成する必要はありません。

C#

var _custByID = CompiledQuery.Compile<SalesEntities, int, Customer>
   ((ctx, id) =>ctx.Customers.Where(c=> c.ContactID == id).Single());

VB

Dim _custByID= CompiledQuery.Compile(Of SalesEntities, Integer, Customer) 
   (Function(ctx As ObjectContext, id As Integer) 
    ctx.Customers.Where(Function(c) c.CustomerID = custID).Single)

クエリ式には、LINQ メソッドか LINQ 演算子のいずれかを使用できます。上記のクエリは、LINQ メソッドとラムダから構築されています。

構文は一般的なジェネリック メソッドに比べるとやや混乱を招くかもしれません。そこで、このメソッドを細かく分けて説明します。繰り返しになりますが、Compile メソッドの目的は、以下に示すように、後から呼び出せる関数 (デリゲート) を作成することです。

C#

CompiledQuery.Compile<SalesEntities, int, Customer>

VB

CompiledQuery.Compile(Of SalesEntities, Integer, Customer)

これはジェネリック メソッドなので、引数としてどの型を渡すか、およびデリゲートが呼び出されるときにどの型を返すかを指示する必要があります。最低でも、なんらかの種類の ObjectContext (LINQ to SQL の場合は DataContext) を渡す必要があります。System.Data.Objects.ObjectContext またはこれから派生したクラスを指定することができます。ここでは、Entity Data Model に関連付けられる派生クラスの SalesEntities を明示的に使用しています。

引数を複数定義できますが、必ず、コンテキストを最初に指定する必要があります。ここでは、結果としてプリコンパイルされるクエリが int/Integer 型のパラメーターを受け取ることを、Compile メソッドに指示しています。最後の型は、クエリが返す型を指定します。この例では Customer オブジェクトです。

C#

((ctx, id) =>ctx.Customers.Where(c => c.ContactID == id).Single())

VB

Function(ctx As ObjectContext, id As Integer) 
   ctx.Customers.Where(Function(c) c.CustomerID = custID).Single

上記の Compile メソッドの結果は、以下のデリゲートになります。

C#

private System.Func<SalesEntities, int, Customer> _custByID

VB

Private _custByID As System.Func(Of SalesEntities, Integer, Customer)

クエリをコンパイルしたら、必要に応じてそのクエリを、ObjectContext または DataContext のインスタンスと他に必要な任意のパラメーターを渡すだけで実行できます。ここで、_commonContext というインスタンスと _custID という変数があるとします。

Customer cust  = _custByID.Invoke(_commonContext, _custID);

デリゲートが最初に呼び出されるときに、クエリがストア クエリに変換され、その後の呼び出しで再利用するためにキャッシュされます。LINQ は、クエリをコンパイルするタスクがスキップされ、すぐに実行されます。

必ずプリコンパイル済みクエリが使用されるようにする

プリコンパイル済みクエリには、それほど明らかにされておらず、またあまり広く知られていない問題があります。多くの開発者は、アプリケーション プロセスでクエリをキャッシュすれば、クエリがキャッシュ内にあると想定します。多少パフォーマンスの数値が変化することを除けば、クエリがキャッシュ内に存在しないという兆候は見受けられないため、私でも確実にこのような想定を行います。しかし、プリコンパイル済みクエリのインスタンスを作成したオブジェクトがスコープ外になると、プリコンパイル済みクエリも失われます。これでは、使用するたびにクエリをプリコンパイルする必要があるため、プリコンパイルするメリットはまったくなくなってしまいます。実際には、CLR がデリゲートに関して余分な作業を行うことになるため、単純に LINQ クエリを実行するよりも負荷が高くなります。

Rico Mariani がデリゲートを使用する際に生じるコストについて、彼のブログ「Performance Quiz #13—Linq to SQL compiled query cost—solution」(パフォーマンス クイズ #13—Linq to SQL コンパイル済みクエリのコスト — ソリューション) (blogs.msdn.com/ricom/archive/2008/01/14/performance-quiz-13-linq-to-sql-compiled-query-cost-solution.aspx、英語) で詳しく調べています。このブログのComments (コメント) で繰り広げられている議論も非常に役に立ちます。

このブログでは、LINQ to Entities について、「プリコンパイル済みクエリを使用しても」、Web アプリケーションでは「パフォーマンスが著しく低下する」と報告しています。それは、ページがポストバックされるたびに新たにコンテキストのインスタンスが作成され、クエリのプリコンパイルが "再度" 行われるためです。つまり、プリコンパイル済みクエリが再利用されることはありません。短期間しか有効にならないコンテキストでも同じ問題が発生します。この状況が明らかに発生するのは、Web サービスや Windows Communication Foundation (WCF) サービスなどです。また、あまり明らかではありませんが、インスタンスが指定されていない場合に新しいコンテキストのインスタンスを実行時に作成するリポジトリなどでも発生します。

デリゲートが失われるのを防ぐには、プロセス間でクエリを保持するために静的変数 (VB では共有変数) を使用して、その時点で利用できるコンテキストを使って保持しているデリゲートを呼び出します。

以下に、ObjectContext が頻繁にスコープ外になる場合でも、アプリケーション プロセス全体でデリゲートを利用できるパターンを示します。このパーターンは、Web アプリケーションでも、WCF サービスでも、リポジトリでも正しく機能します。この場合、クエリを呼び出す予定のクラスのコンストラクターで静的デリゲートを宣言する必要があります。以下に、上記で作成したコンパイル済みクエリに相当するデリゲートを宣言する方法を示します。

C#

static Func<ObjectContext, int, Customer> _custByID;

VB

Shared _custByID As Func(Of ObjectContext, Integer, Customer)

クエリをコンパイルできる場所はいくつかあります。クラスのコンストラクターやクエリを呼び出す直前などです。以下のメソッドは、クエリを実行し、Customer オブジェクトを返すように設計しています。

public static Customer GetCustomer( int ID)
    {
      //test for an existing instance of the compiled query
      if (_custByID == null)
      {
        _custByID = CompiledQuery.Compile<SalesEntities, int, Customer>
         ((ctx, id) => ctx.Customers.Where(c => c.CustomerID == id).Single());
      }
      return _custByID.Invoke(_context, ID);
    }

このメソッドはコンパイル済みクエリを使用します。まず、このメソッドでは必要な場合のみ、実行時にクエリをコンパイルします。ここではクエリのインスタンスが作成済みかどうかテストすることで、コンパイルが必要かどうかを判断しています。クラスのコンストラクターでコンパイルを行う場合は、同じテストを行い、必要なときみコンパイルにリソースが使用されるようにします。

デリゲート _custByID は静的変数なので、このデリゲートを含むクラスがスコープ外になっても、メモリ内に保持されます。そのため、アプリケーション プロセス自体がスコープ内にある限り、デリゲートを利用することができ、null になるこもとなく、コンパイル手順がスキップされます。

プリコンパイル済みクエリとプロジェクション

他にも、もっと明らかになることが多く、認識しておくべき、速度に関する問題があります。まず考えておくべきことはプロジェクションに関する問題です。この問題は、知らないうちにプリコンパイル済みクエリが再コンパイルされるという問題にとどまりません。クエリで列のプロジェクションを行うと、特定の型が返されるのではなく、常に、結果として匿名型が取得されます。

クエリを定義するときに、"匿名型の型" を指定する方法がないため、匿名型の戻り値を指定することはできません。結果を返すメソッド内部にクエリを含める場合も、メソッドが返す型を指定できないため、同じ問題が発生します。LINQ を使用している開発者は、後者の制限事項に直面することが多くなります。

匿名型が再利用を意図したものではない実行時の型である点に目を向ければ、こうした制限事項は、多少不満は残りますが、意味はあります。匿名型は、メソッドからメソッドへと受け渡すことを意図したものではありません。

プリコンパイル済みのクエリに対して行う必要があるのは、プロジェクションに合った型を定義することです。LINQ to Entities では、コンストラクターに含まれない型にプロジェクションを行うことが許可されないため、Entity Framework では構造体ではなく、クラスを使用する必要があります。LINQ to SQL では、プロジェクションの対象を構造体にすることができます。したがって、匿名型に関する制限事項を回避するには、Entity Framework ではクラスのみを使用でき、LINQ to SQL ではクラスと構造体を使用できます。

プリコンパイル済みクエリと LINQ to SQL のプリフェッチ

プリコンパイル済みクエリに関してもう 1 つ問題が発生する可能性があるのはプリフェッチ ("一括読み込み") です。ただし、この問題は LINQ to SQL を使用している場合のみ発生します。Entity Framework では Include メソッドを使用して一括読み込みを行います。その結果、データベース内でクエリが 1 つだけ実行されます。Include は context.Customer.Include("Orders") のようにクエリの一部にできるため、ここでは問題になりません。しかし、LINQ to SQL では、クエリ自体ではなく、DataContext 内で一括読み込みが定義されます。

DataContext.LoadOptions には、特定のエンティティと共にどのような関連データを一括読み込みするかを指定できる LoadWith メソッドがあります。

たとえば、クエリする Customer と共に Orders を読み込む LoadWith は次のように定義できます。

Context.LoadOptions.LoadWith<Customer>(c => c.Orders)

次に、読み込まれる Orders の詳細を読み込むことを指示する規則を追加できます。

Context.LoadOptions.LoadWith<Customer>(c => c.Orders)
Context.LoadOptions.LoadWith<Order>(o =>o.Details)

DataContext インスタンスに対して直接 LoadOptions を定義するか、または DataLoadOptions クラスを作成し、このオブジェクトで LoadWith の規則を定義して、コンテキストにアタッチすることができます。

DataLoadOptions _myLoadOptions = new DataLoadOptions();
_myLoadOptions.LoadWith<Customer>(c => c.Orders)
Context.LoadOptions= myLoadOptions

LoadOptions と DataLoadOptions クラスの使用全般について注意事項がいくつかあります。たとえば、DataLoadOptions を定義してアタッチする場合、いったんその DataContext に対してクエリを実行すると、新しく DataLoadOptions のセットをアタッチすることはできません。さまざまな読み込みオプションとその注意事項について把握しておくべきことはたくさんありますが、プリコンパイル済みクエリに当てはまる LoadOptions の基本パターンについて見ていくことにしましょう。

このパターンで重要な点は、DataLoadOptions を特定のコンテキストに関連付けずに事前定義できることです。

プリコンパイル済みクエリを保持するために静的 Func 変数を宣言したクラスで、新たに DataLoadOptions 変数を宣言します。この変数もデリゲートと共に利用できるようにするため、静的変数にすることが重要です。

static DataLoadOptions Load_Customer_Orders_Details = new DataLoadOptions();

次に、クエリをコンパイルして呼び出すメソッドで、デリゲートと共に LoadOptions を定義することができます (図 2 参照)。このメソッドは、.NET Framework 3.5 および .NET Framework 4 で有効です。

図 2 デリゲートと LoadOptions の定義

public Customer GetRandomCustomerWithOrders()
    {
      if (Load_Customer_Orders_Details == null)
      {
        Load_Customer_Orders_Details = new DataLoadOptions();
        Load_Customer_Orders_Details.LoadWith<Customer>(c => c.Orders);
        Load_Customer_Orders_Details.LoadWith<Order>(o => o.Details);
      }
      if (_context.LoadOptions == null)
      {
        _context.LoadOptions = Load_Customer_Orders_Details;
      }
      if (_CustWithOrders == null)
      {
        _CustWithOrders = CompiledQuery.Compile<DataClasses1DataContext, Customer>
               (ctx => ctx.Customers.Where(c => c.Orders.Any()).FirstOrDefault());
      }
      return _CustWithOrders.Invoke(_context);
    }

DataLoadOptions は静的なので、必要なときにのみ定義します。クラスのロジックを基に、DataContext を新しく定義するかどうかを決定します。再利用する場合には、それ以前に LoadOptions が割り当てられています。そうでなければ、割り当てる必要があります。これで、クエリを繰り返し呼び出せるようになり、同時に LINQ to SQL のプリフェッチ機能のメリットも得られます。

チェックリストの冒頭にプリコンパイルを記載する

LINQ クエリ実行のスコープでは、クエリのコンパイルはプロセスの中でも負荷の高い部分になります。LINQ to SQL ベースのアプリケーションまたは Entity Framework ベースのアプリケーションに LINQ クエリのロジックを追加するときは、必ず、クエリをプリコンパイルして再利用することを検討します。ただし、クエリをプリコンパイルするだけで終わりではありません。ここまで説明したように、プリコンパイル済みのクエリからメリットが得られないシナリオがあります。SQL Profiler や、L2SProf (l2sprof.com/.com、英語) および EFProf (efprof.com、英語) に付属する Hibernating Rhinos のプロファイリング ツールの 1 つなど、なんらかの種類のプロファイラーを使用します。プリコンパイル済みクエリによって見込まれるメリットを確実に得るには、ここで示したパターンを活用できます。

Microsoft Entity Framework チームの Danny Simmons は、彼のブログ (blogs.msdn.com/dsimmons/archive/2010/01/12/ef-merge-options-and-compiled-queries.aspx、英語) の中で、クエリをプリコンパイルするときのマージ オプションの制御方法について説明し、注意すべき点をいくつか挙げています。

 

Julie Lerman は、バーモント ヒルズ在住の Microsoft MVP、.NET の指導者、およびコンサルタントです。世界中のユーザー グループやカンファレンスで、データ アクセスなどの Microsoft .NET トピックについてプレゼンテーションを行っています。彼女のブログは thedatafarm.com/blog (英語) で、彼女が執筆した『Programming Entity Framework』 (O’Reilly Media、2009 年) は絶賛を浴びました。彼女には Twitter (Twitter.com/julielermanvt、英語) から連絡できます。

この記事のレビューに協力してくれた技術スタッフの Danny Simmons に心より感謝いたします。