この記事は ユアマイスターAdvent Calender の15日目の記事です。
はじめまして。ユアマイスターOBのzarammeこと五藤です。
ユアマイスターでは、メインのフレームワークとしてCakePHPを使用しています。
Laravel全盛期の今、なんとなく「旧世代」という雰囲気もあるCakePHPですが、紐解いていくと、他のフレームワークと差別化している要素なども実はいろいろと残っており、まだまだ使い倒す価値のあるフレームワークだと個人的に思っています。
今回は、その中でも特徴的なORMのSQLクエリの実装方法について、比較してみました。
CakePHPは常にEagerLoad
CakePHPは、主流フレームワークと比較して、基本的なSQLクエリ実行計画がEagarLoadであるという点が特徴である。
基本的なクエリ実行方針 | クエリを呼び出すクラス | クエリを保持するクラス | 読み込み結果の実態クラス | |
---|---|---|---|---|
Laravel | LazyLoad | Modelのクラスメソッド | Builderクラス | Model |
CakePHP | EagarLoad | Modelのインスタンスメソッド | QuerySet(実質Model) | ResultSet(実質Entiryの配列) |
Django | LazyLoad | Model.Object | Builder | Model |
具体的には、CakePHPはテーブルクラス/エンティティモデル分離型の設計のため、特定のタイミングでクエリセットで定義されたSQLが実行され、その後は実体を保持するResultSetとなります。
クエリ実行タイミングの代表的なものとしては
-
all()
やfirst()
などの終端メソッド実行時 -
foreach
文で テーブルクラスがイテレートされた際
です。
具体的にはこんな感じ。
function fetchArticles(){
return $articleTable
->where(['category_id' => '3'])
->contain([
'Category',
'Tags',
])
->all() // ←このタイミングでクエリが実行される
}
function main(){
$articles = fetchArticles(); // $articles = Entityクラスの配列(厳密にはResultSet)
// $articles->where(['published_year' => 2019]) # これは呼び出せない
// 全ての記事に対して処理を実行
foreach($article as $article){
print $article->category->name // # 記事のカテゴリ名
// 記事内の全てのタグについて処理を実行
foreach($tag as $article->tags){
print($tag->name)
}
// print($article->auther->name) # これは取得できない
}
return response($articles)
}
このサンプルでAll()を使っているように、多くの場合は、開発者は、任意のタイミングで、クエリを確定させる処理を呼び出します。
逆に言えば、クエリ確定後のオブジェクトであるEntityはクエリ発行の機能を持たないため、実行されたクエリに含まれていないリレーション先のテーブル(auther)は呼び出すことはできません。
#DjangoのModelは常にクエリを保持するが、実行タイミングは流動的
対して、同じ処理をDjangoで書くとこのようになります。
def fetch_articles();
return Article.objects.filter(category_id=3)
def main(){
articles = fetch_articles(); # $articles = QuerySet
articles.filter(published_year=2019); # さらに条件を追加可能
for article in $article; # このタイミングでarticle取得のクエリが実行される
print article.title
print article.category.name # このタイミングでcategory取得のクエリが実行される
foreach(tag as article->tags){ # このタイミングでtag取得のクエリが実行される
print(tag->name)
}
print(article.auther.name) # このタイミングで、Auther取得のクエリが発行される
}
return response(articles)
}
クエリの実行タイミングが、実態となる各データを呼び出すタイミングに応じて小分けに実行されていることがわかります。
また、呼び込み結果の実態もまたModelクラスであるので、呼び込み結果からさらにリレーションを読み込む、といったことも可能です。
このままではいわゆるN+1問題が山のように発生するアンチパターンのお手本のようなコードになるので、prefetchを使用してEagarLoadを導入してみます。
def fetch_articles();
return Article.objects
.filter(category_id=3)
.prefetch_related('tags')
.prefetch_related('category')
def main(){
articles = fetch_articles(); # $articles = QuerySet
articles.filter(published_year=2019); # さらに条件を追加可能
# 全ての記事に対して処理を実行
for article in $article; #このタイミングでクエリが実行される
print article.title
print article.category.name # Prefetchによってキャッシュされた結果を取得
foreach(tag as article->tags){ # Prefetchによってキャッシュされた結果が取得可能
print(tag->name)
}
print(article.auther.name) # このタイミングで、Auther取得のSQLが発行される
}
return response(articles)
}
prefetch処理の導入でEagarloadになりましたが、
子テーブル呼び出しのたびに、
「Prefetchによるキャッシュが存在する場合はキャッシュされた結果を、そうでない場合はクエリ実行」
という評価が走ります。
ここで注意したいのは、
fetch_articlesの戻り値の時点では依然としてクエリは確定されておらず、その実行タイミングは使用者側に委ねられているという事です。
なので、Prefetchしていない子レコード(auther)を取得しようとしてしまうと、容易にN+1問題を引き起こすことができます。
まとめ
- CakePHPは、EagarLoadを主軸として、実行されるクエリを確定し、その後のリレーションクエリの呼び出しを構造上不可能にすることができる。(SQLの発行処理と、データの出力処理を完全に分離できる)
- ほかのフレームワークにもEagarLoadをするための仕組みは用意されているが、あくまで「特定条件のクエリをキャッシュする」仕組みであり、呼び出し処理側で、SQL発行側で意図していないリレーションが呼び出した場合、追加のクエリが発行される(SQLの内容を特定メソッドに切り出すすることが本質的に不可能)