Drupalで通知機能を実装!Viewsでサブクエリを組み込む方法

花岡 重宏
アイキャッチ

Views はとても機能が豊富なので、データの持ち方を工夫すればかなり、大抵の要件はかなえられると思ってます。しかし、たまーに、Views そのままでは無理な場合もあります。

今回久しぶりにサブクエリを使ったので、なぜそれが必要だったのか、どのように実装したのか、など書いておきます。

実装の背景

今回実装したのは「通知」機能です。
ノードにコメントが投稿されたらノードの作成者に「あなたのコンテンツにコメントが投稿されました」と通知するものです。
画面のヘッダーエリア右上など、ベルのアイコンに赤いバッジで知らせてくれる、あれです。

これを通知対象の各ユーザーに一覧を表示するのですが、そのユーザーに通知をすべて表示するのではなく、コメントが投稿されたコンテンツごとに最新の通知を表示させたい、というものでした。
 

通知機能を実装

Messageモジュールというのがあるので、これを使用しました。フィールド構成は次の通りです。
※本記事に関係あるものだけ記載しています

フィールド名 システム名称 フィールドタイプ
イベント発生日時 field_event_date タイムスタンプ
コメント field_comment エンティティ参照(comment)
受信者 field_target_user エンティティ参照(user)
集約キー field_aggregate_key テキスト(プレーン)

※集約キーは「『コンテンツごとに』最新の通知を表示させたい」という要件のために使用します。今回「コンテンツ」はノードだけでなく、グループ(Group)も含みますので、テキストで「node:(ノードID)」、「group:(グループID)」という風に入れておき集約します。

なんか、これだけ見ると、別にサブクエリなんか使わなくても出来そうな気もするのですよね^^;

一覧を作成

Views でメッセージの一覧を作り、フィールドに「集約キー」と、「メッセージ:ID」、コンテキストフィルターに(受信者 = カレントユーザー)で、並べ替えに「イベント発生日時」(降順)を設定。 ビューのアグリゲーションをオンにして、フィールドの「メッセージ: ID」のアグリゲーション設定と、並べ替えの「イベント発生日時」のアグリゲーション設定を MAX に。

メッセージエンティティの描画にはこの MAX(メッセージID) を使えば、それでいけそうな気がします。 イベント発生日時の最大 = メッセージID の最大、で良いのか?ということだけ許容できれば、これでおしまいですね。

パフォーマンス上の問題

しかし、厳密には Views の挙動として、クエリ上では MIN(mid) が(フィールドで設定した MAX(mid) とは別に)セレクト文に追加され、ビューの処理の中でもこの MIN(mid) をもとにメッセージエンティティのロードがされてしまいます。

つまり、各行で MIN(mid)とMAX(mid)からそれぞれエンティティがロードされる。(ページ毎の表示件数は 50件です)

ビューの処理の中でもこの MIN(mid) をもとにメッセージエンティティのロードがされる処理の箇所をハイライト

今回の案件は、他社の制作したものを改修しながら新機能を追加するという、なかなかシビアな状況であったため、現状は様々な理由でパフォーマンスに問題を抱えていました。
そのため、新しい機能の追加は「少しでも負担を軽く」ということを強く意識する必要がありました。
サブクエリも使い方によっては重くなってしまいますので、MySQLの仕様書も読み直しながらパフォーマンスには十分配慮し、今回はサブクエリで実装することにしました。

Views にサブクエリを組み込む方法

Views でサブクエリを実現する方法は、Views にプラグインを追加する方法と、クエリーオルターを使う方法があります。

このサブクエリついては、プロジェクト内では今回の機能でしか使用しないため、プラグインを作りViews Data を重くするのもちょっと・・、ということで、クエリーオルターで実装することにしました。

※クエリーオルターはあまり好きではないのですが、Views ビューの編集画面からクエリの確認もできるのと、カスタムモジュールにはクエリーオルターを実装する理由なども記載しておきます。

実装コードはこちらです。
※記事用に簡素化するために、カレントユーザーでのフィルタリングなどは省略しています

use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\query\QueryPluginBase;

/**
 * Implements hook_views_query_alter().
 */
function mymodule_views_query_alter(ViewExecutable $view, QueryPluginBase $query) {
  // コンテンツごとに集約した中で、最新の通知を表示するため、ここでサブクエリを使用する。(ビューのほうで Aggregation を使い、
  // フィールドに MAX(mid) を追加して表示させても、Views の仕様上、デフォルトで MIN(mid) がエンティティロードされるのは無くならないため、
  // パフォーマンスの観点でこの方法を採用)
  // ビューの「イベント発生日時」と、サブクエリの「MAX(イベント発生日時)」を ON で繋げて INNER JOIN することで、目的の mid を抽出できる。
  if ($view->id() == 'notification_list') {
    $sub_query = \Drupal::database()->select('message_field_data', 'm');
    $sub_query->addField('ak', 'field_aggregate_key_value', 'ak_field_aggregate_key_value');
    $sub_query->leftJoin('message__field_aggregate_key', 'ak', 'm.mid = ak.entity_id and ak.deleted = :deleted and ak.langcode = m.langcode', [':deleted' => 0]);
    $sub_query->leftJoin('message__field_event_date', 'd', 'm.mid = d.entity_id and d.deleted = :deleted and d.langcode = m.langcode', [':deleted' => 0]);
    $sub_query->groupBy('ak.field_aggregate_key_value');
    $sub_query->addExpression('MAX(d.field_event_date_value)', 'max_field_event_date_value');

    // message__field_event_date テーブルを確認
    $field_event_date_alias = $query->ensureTable('message__field_event_date');

    $configuration = [
      'type' => 'INNER',
      'table formula' => $sub_query,
      'field' => 'max_field_event_date_value',
      'left_table' => $field_event_date_alias,
      'left_field' => 'field_event_date_value',
      'operator' => '=',
    ];

    $join = \Drupal::service('plugin.manager.views.join')->createInstance('standard', $configuration);
    $query->addRelationship('sub', $join, 'message_field_data');
  }
}

Views ビューのほうはアグリゲーションをオフにし、サブクエリのほうで GROUP BY するようにします。
$join のところの、$configuration の扱いが少し難しいのですが、 Views モジュールの /src/Plugin/views/join/joinPluginBase.php のドキュメントコメントに詳しい説明があります。

これで以下のようなクエリになります。

SELECT message_field_data.langcode AS message_field_data_langcode, message__field_event_date.field_event_date_value AS message__field_event_date_field_event_date_value, message_field_data.mid AS mid
FROM
message_field_data message_field_data
LEFT JOIN message__field_event_date message__field_event_date ON message_field_data.mid = message__field_event_date.entity_id AND message__field_event_date.deleted = '0'
INNER JOIN (
	SELECT ak.field_aggregate_key_value AS ak_field_aggregate_key_value, MAX(d.field_event_date_value) AS max_field_event_date_value
	FROM
	message_field_data m
	LEFT OUTER JOIN message__field_aggregate_key ak ON m.mid = ak.entity_id and ak.deleted = '0' and ak.langcode = m.langcode
	LEFT OUTER JOIN message__field_event_date d ON m.mid = d.entity_id and d.deleted = '0' and d.langcode = m.langcode
	GROUP BY ak.field_aggregate_key_value
) sub ON message__field_event_date.field_event_date_value = sub.max_field_event_date_value
ORDER BY message__field_event_date_field_event_date_value DESC
LIMIT 50 OFFSET 0

これで各行で無駄なエンティティロードを避けることが出来ました。
クエリのパフォーマンスも問題ないと思います。

構築はできるだけシンプルに、を心がけていますが、たまーに、このようなワザが必要になるときがありますね。
何かの参考になれば幸いです。

花岡 重宏/ Drupalエンジニア

Drupal が恋人です。開発側から見えてくるものも沢山あるので、チームメンバーと協力しながら、最終的にお客様も満足するモノづくりを目指しています。
週末は散歩したり、Drupalと遊んだりしています。

花岡 重宏 の書いた記事一覧

最新の関連記事

Ranking

Category

Tag

Contact お問い合わせ

Drupalでの開発・運用、サーバー構築、Webサイト構築全般、制作費用などに関してお気軽にご相談ください。