C#
ASP.NET
LINQ
ASP.NET_MVC
70
どのような問題がありますか?

この記事は最終更新日から5年以上が経過しています。

投稿日

更新日

LINQ to Entities の遅延評価と AutoMapper が便利という話

Advent Calendar 初参加です。よろしくお願いします。

本記事の概要

  1. LINQ to Entities の遅延評価は便利
  2. N+1問題を回避するために ViewModel を用意すると便利
  3. AutoMapper の IQueryable拡張メソッド ProjectTo<T>() を使うと便利

LINQ to Entities の遅延評価

個人的に、ASP.NET MVC & EntityFramework で一番便利なのは LINQ to Entities による遅延評価だと思っています。
LINQによるいつものコレクション操作(LINQ to Objects)とほぼ同じようにデータベースからデータを引っ張ってこれます(LINQ to Entities)。

IQueryable<Book> books = dbSet
    .Where(b => b.Price < 3000)
    .OrderBy(b => b.Title);

LINQで書くと「価格が3000未満のbookをタイトル昇順で取得している」のが一目で分かるだけでなく、遅延評価されるのでこの時点では books はまだ取得されていません。しかもIQueryable型の中身はSQL文なので、実際の絞り込みとソートはデータベース側がやってくれます。

foreach(var book in books) {
    Console.WriteLine("タイトル:" + book.Title);
}

上記のように呼び出すと、foreach を実行するときに初めて books がデータベースから読み込まれるため、必要になったタイミングでの最新の状態が取得できます。便利。

SQLのN+1問題

しかし遅延評価を過信して間違った使い方をすると、データベースのアクセス回数が大変なことになります。

Class Book
{
    public int Id;
    public string Title;
    public int Price;
    public int AuthorId;
    public virtual Author Author;
}
Class Author
{
    public int Id;
    public string Name;
    public DateTime Birthday;
    public virtual ICollection<Book> Books;
}

このとき、先ほどのforeachループの中で

foreach(var book in books) {
    Console.WriteLine("タイトル:" + book.Title);
    Console.WriteLine("著者:" + book.Author.Name); // AuthorのNameも表示したい
}

などとやってしまうと、最初に books が評価された時点では Author の中身までは取得されていないため、ループ内の book.Author.Name を呼び出すタイミングで Auther の遅延評価が毎回発生してしまいます。
books が 10000 件あったら 1+10000 回のSELECTが発行されるので「N+1問題」と呼ばれていたりします。

解決策 1. Include() を使う

LINQ to Entities には Include() というメソッドがあり、指定したプロパティを先読みすることができます。

IQueryable<Book> books = dbSet
    .Include(b => b.Author) // Include()追加
    .Where(b => b.Price < 3000)
    .OrderBy(b => b.Title);

foreach(var book in books) {
    Console.WriteLine("タイトル:" + book.Title);
    Console.WriteLine("著者:" + book.Author.Name);
}

とすると、SQL文にJOIN句が追加されて books と一緒に Author の中身も取得されるため、book.Author.Name 呼び出し時にデータベースまで読みに行く必要がなくなります。

解決策 2. Select() と ViewModel を使う

Select() メソッドを使って、必要なプロパティを指定する方法です。

Class BookViewModel
{
    public string Title;
    public int Price;
    public string AuthorName;
}

上記のような表示専用の ViewModel を用意して、

IQueryable<BookViewModel> bookViewModels = dbSet
    .Where(b => b.Price < 3000)
    .OrderBy(b => b.Title)
    .Select(b => new BookViewModel { // Select()追加
        Title = b.Title,
        Price = b.Price,
        AuthorName = b.Author.Name
    });

foreach(var viewModel in bookViewModels) {
    Console.WriteLine("タイトル:" + viewModel.Title);
    Console.WriteLine("著者:" + viewModel.AuthorName);
}

とすると、LINQ to Entities は BookViewModel の各プロパティが1回のSELECTで取得できるようにSQL文を組み立ててくれます。
Include() を使う方法に比べて、ViewModelに必要なプロパティだけをデータベースから引っ張ってくるため省メモリで高速化も期待できます。

2番目の方法で気になるのが、Select()メソッドの中で ViewModel を作成するときのプロパティ指定の面倒臭さ。
この例のように3~4くらいならいいですが、もし必要なプロパティが10も20もあったら全部を指定するコードは大変なのでできることなら書きたくないです。

AutoMapperとは

オブジェクトからオブジェクトへのプロパティのマッピングを一括で行うためのライブラリ。
Nuget からインストールできます。
http://automapper.org/

AutoMapper を使うと、

// 適当にBookオブジェクトを用意
Book book = new Book {
    Title = "すごいタイトル",
    Price = 2980,
    Auther = new Auther {
        Name = "すごい著者",
        BirthDay = new DateTime(2000, 12, 31);
    }
}

// Book から BookViewModel へのマッピング
Mapper.CreateMap<Book, BookViewModel>();
BookViewModel bookViewModel = Mapper.Map<BookViewModel>(book);

Console.WriteLine("タイトル:" + bookViewModel.Title);
Console.WriteLine("著者:" + bookViewModel.AuthorName);
// タイトル:すごいタイトル
// 著者:すごい著者

このように、Mapper.CreateMap()Mapper.Map() だけでオブジェクトの詰め替えを一括で実行してくれます。
(※CreateMap()は毎回実行する必要はないので、初期化関数の中にまとめておいたりします)

同名のプロパティでなくても独自のマッピング設定を細かく指定できるので、詳しくは本家ドキュメントや@ITの記事 (http://www.atmarkit.co.jp/ait/articles/1503/17/news115.html) などをご覧ください。

Queryable Extensions

便利なAutoMapperですが、では最初のIQueryableに対しても使えるかというと

IQueryable<Book> books = dbSet
    .Where(b => b.Price < 3000)
    .OrderBy(b => b.Title);
// IQueryable<Book> から IQueryable<BookViewModel> にマッピングしようとすると例外発生
// var bookViewModels = Mapper.Map<IQueryable<BookViewModel>>(books);
// IQueryable<Book> から IEnumerable<BookViewModel> にマッピング(こちらはOK)
var bookViewModels = Mapper.Map<IEnumerable<BookViewModel>>(books);

このような通常の使い方をするとIQueryable型では取得できないので、Map()のマッピング処理の中でbookが遅延評価されてしまい結局N+1問題が発生してしまいます。また、

IQueryable<BookViewModel> bookViewModels = dbSet
    .Where(b => b.Price < 3000)
    .OrderBy(b => b.Title)
    .Select(b => Mapper.Map<BookViewModel>(b)); // 例外発生

のようにIQueryableのラムダ式の中で使用することもできません。

もしかして LINQ to Entities のN+1問題対策には使えない…?と思ってソースを見たら、ちゃんとProjectTo<T>()という専用の拡張メソッドが用意されていました。
https://github.com/AutoMapper/AutoMapper/wiki/Queryable-Extensions

ProjectTo<T>()メソッドを使うことで、IQueryable型のままマッピングすることができます。
よって先ほどのSelect()内のマッピングはたった一行で書き換えられます。便利。

IQueryable<BookViewModel> bookViewModels = dbSet
    .Where(b => b.Price < 3000)
    .OrderBy(b => b.Title)
    .Select(b => new BookViewModel {
        Title = b.Title,
        Price = b.Price,
        AuthorName = book.Author.Name,
        ...
        // 以下BookViewModelのプロパティあるだけ全部
    });
IQueryable<BookViewModel> bookViewModels = dbSet
    .Where(b => b.Price < 3000)
    .OrderBy(b => b.Title)
    .ProjectTo<BookViewModel>(); // 一行で終わり

もちろん bookViewModels は遅延評価前の IQueryable のままなので、あとは必要になったタイミングで必要なプロパティだけをデータベースから取得できます。

最後に

AutoMapper はNugetでもトップクラスのDL数を誇るライブラリにも関わらず、Queryable Extensions を解説している日本語ページが見つからなかったので紹介いたしました。
ASP.NET 5 の RTM も公開間近ですし ASP.NET MVC もっと流行ってほしいですね。

新規登録して、もっと便利にQiitaを使ってみよう

  1. ユーザーやタグをフォローできます
  2. 便利な情報をストックできます
  3. 記事の編集提案をすることができます
ログインすると使える機能について
midori44

コメント

この記事にコメントはありません。
あなたもコメントしてみませんか :)
新規登録
すでにアカウントを持っている方はログイン
70
どのような問題がありますか?
新規登録して、Qiitaをもっと便利に使ってみませんか

この機能を利用するにはログインする必要があります。ログインするとさらに下記の機能が使えます。

  1. ユーザーやタグのフォロー機能であなたにマッチした記事をお届け
  2. ストック機能で便利な情報を後から効率的に読み返せる
新規登録ログイン
ストックするカテゴリー