技術者向けテクニカルtips
 / 

Liferay 7の通知設定(ノーティフィケーション)

Liferay DXP 7.0での通知設定について、通知の作成と送信をサポートする基本コードのご紹介です。

基礎


※本記事はLiferay Community Blogに投稿されている”Liferay 7 Notifications”を翻訳したものです。配信元または著者の許可を得て配信しています。
※オリジナルの英記事著者:David H Nebinger サウスカロライナ州のサマーヴィルに住むLiferay, Inc.のSoftware Architect/リードコンサルタント。Liferay Communityサイトにて、多くの技術情報を投稿するなど精力的に活動しています。

こんにちは。
日本ライフレイ、サポータビリティエンジニアの竹生(たけお)です。

本記事では、Liferay 7の通知設定(ノーティフィケーション)について解説している記事を紹介します。

先日構築したプロジェクトにて、ユーザー通知を発信できることの利点を感じるところがありました。

Liferayには、購読と通知のための組み込みシステムがあります。これらのAPIを使用することで、プロジェクトに通知をすばやく追加することができます。

実装に入る前に、サブスクリプションAPIと通知APIの基礎についてご説明します。

まず特定する必要があるのは、イベントです。イベントとは、ユーザーが購読するもので、イベントが発生したときに通知を出します。通知の送信が必要になるときにわかるよう、予定リストを作成しておくことで、作業が簡単になります。

本ブログ記事では、次のようなサンプル実装を紹介します。管理者の役割を持つユーザーがログインしたときに、通知を発行するシステムを構築します。サイトにおいて有効なセキュリティを確保するには、無制限のアクセス権限がある管理者アカウントを誰も使用しないようにすることが賢明でしょう。なので、管理者がログインした際に通知を受ける設定をしておくのは、良いセキュリティテストの一つと言えます。

この"管理者がログインしました"というイベントをもとに、以下ブログ記事では購読と通知のサポートを構築していきます。

まずはじめに必要なものは、ポートレット・モジュールです(インターフェース要件があるため)。新しいポートレット・モジュールを構築することから始めます。 IDEを使用することもできますが、今回はIDEに関係なく参考にできるよう、Bladeコマンドを使用します。


  blade create -t mvcportlet -p com.dnebinger.admin.notification admin-notification

これにより、希望するパッケージと単純なプロジェクトディレクトリを使用した新しいLiferay MVCポートレットが提供されます。

イベントを購読する


ユーザーが最初に行うことができるようにすべきことは、イベントに登録することです。ポータル内の例でいうと、ブログ(ユーザーが、すべてのブログあるいは個人のブログに更新情報の購読申し込むことができる)などがあります。そこで、ユーザーが該当イベントに登録する場所を特定することが重要です。場合によっては、ページ(ブログやフォーラムスレッド)の右側に表示される場合もあれば、設定パネルに移動したい場合もあります。

本ポートレットでは、実際のUI設計の作業はありません。購読/購読解除のチェックボックスが付いた、JSPを持つだけです。重要な部分は、LiferayサブスクリプションAPIを使用する点です。

サブスクリプションはcom.liferay.portal.kernel.service.SubscriptionLocalServiceサービスによって処理されます。購読するときはaddSubscription()メソッドを使用し、購読解除するときはdeleteSubscription()メソッドを使用します。

呼び出しの引数は次のとおり:

  • userId:購読/購読を解除しているユーザー
  • groupId:ユーザーが購読しているグループ(追加の場合)(doc libフォルダやフォーラムカテゴリのような 'containers'の場合​​)
  • className:更新を監視するユーザーのクラス名です
  • pkId:購読/購読解除するオブジェクトのプライマリキー

通常はSBエンティティに購読を構築するので、購読機能とデータアクセスを結合するために、サービスインタフェースに購読および購読解除のメソッドを配置するのが一般的な方法です。

このプロジェクトでは、実際にエンティティやデータアクセスレイヤーがないため、アクションコマンドハンドラで購読/購読解除を直接処理します。また、実際にはPK idを持たないので、ID 0とポートレットクラスをクラス名として使用します。

@Component(
  immediate = true,
  property = {
    "javax.portlet.name=" + AdminNotificationPortletKeys.ADMIN_NOTIFICATION_PORTLET_KEY,
    "mvc.command.name=/update_subscription"
  },
  service = MVCActionCommand.class
)
public class SubscribeMVCActionCommand extends BaseMVCActionCommand {
  @Override
  protected void doProcessAction(ActionRequest actionRequest, ActionResponse actionResponse) throws Exception {
    String cmd = ParamUtil.getString(actionRequest, Constants.CMD);

    if (Validator.isNull(cmd)) {
      // an error
    }

    long userId = PortalUtil.getUserId(actionRequest);

    if (Constants.SUBSCRIBE.equals(cmd)) {
      _subscriptionLocalService.addSubscription(userId, 0, AdminNotificationPortlet.class.getName(), 0);
    } else if (Constants.UNSUBSCRIBE.equals(cmd)) {
      _subscriptionLocalService.deleteSubscription(userId, AdminNotificationPortlet.class.getName(), 0);
    }
  }

  @Reference(unbind = "-")
  protected void setSubscriptionLocalService(final SubscriptionLocalService subscriptionLocalService) {
    _subscriptionLocalService = subscriptionLocalService;
  }

  private SubscriptionLocalService _subscriptionLocalService;
}

ユーザー通知の設定


ユーザーは通知を発行するポートレットとは別に通知設定を管理できます。サイドバーの、[マイアカウント]に移動すると、[通知]オプションが表示されます。このリンクをクリックすると、通常、通知リストが表示されます。しかし、右上隅のドットメニューをクリックすると、「設定」を選択して、以下すべてを確認することができます。

このページには、登録されている通知ポートレットごとに折りたたみ可能な領域があり、各領域には、通知の種類と、EメールまたはWebサイトによる通知を受け取るスライダの項目があります(ポートレットが両方の通知タイプをサポートしていると仮定した場合)。今後、Liferayまたはチームが通知方法(SMSやその他)を追加する可能性があり、その場合、ポートレットは通知のタイプもサポートしている必要があります。

折りたたみパネルに登録し、このページに表示させるにはどうすればよいか?OSGi DSサービスの注釈を通してそれが可能です。

実装する必要があるクラスには、2種類あります。最初に、com.liferay.portal.kernel.notifications.UserNotificationDefinitionクラスが拡張されます。クラス名が示唆するように、このクラスは、ポートレットが送信できる通知のタイプと、それがサポートする通知タイプの定義を提供します。

@Component(
  immediate = true,
  property = {"javax.portlet.name=" + AdminNotificationPortletKeys.ADMIN_NOTIFICATION},
  service = UserNotificationDefinition.class
)
public class AdminLoginUserNotificationDefinition extends UserNotificationDefinition {
  public AdminLoginUserNotificationDefinition() {
    // pass in our portlet key, 0 for a class name id (don't care about it), the notification type (not really), and
    // finally the resource bundle key for the message the user sees.
    super(AdminNotificationPortletKeys.ADMIN_NOTIFICATION, 0,
      AdminNotificationType.NOTIFICATION_TYPE_ADMINISTRATOR_LOGIN,
      "receive-a-notification-when-an-admin-logs-in");

    // add a notification type for each sort of notification that we want to support.
    addUserNotificationDeliveryType(
      new UserNotificationDeliveryType(
        "email", UserNotificationDeliveryConstants.TYPE_EMAIL, true, true));
    addUserNotificationDeliveryType(
      new UserNotificationDeliveryType(
        "website", UserNotificationDeliveryConstants.TYPE_WEBSITE, true, true));
  }
}

この定義は「通知定義」を登録し、通知します。コンストラクタは通知をカスタム・ポートレットにバインドし、通知パネルのメッセージ・キーを提供します。また、Eメールの送信用と、通知ポートレット用の、2つのユーザー通知配信タイプも追加します。

[マイアカウント]の[通知]パネルに移動し、右上隅のドロップダウンメニューの[設定]オプションを選択すると、通知設定が表示されます。

通知の処理


通知のもう1つの側面は、UserNotificationHandler実装です。 UserNotificationHandlerの仕事は、通知イベントを解釈し、通知を配信するかどうかを判断し、UserNotificationFeedEntry(基本的に通知メッセージ自体)を構築することです。

Liferayには、独自のUserNotificationHandlerインスタンスを構築するために使用できるいくつかの基本実装クラスが用意されています:

  • com.liferay.portal.kernel.notifications.BaseUserNotificationHandler - 通知の本体、およびその他の重要な点をオーバーライドするためのポイントを持つ簡単なユーザ通知ハンドラを実装します。ほとんどの場合、すべての基本的な通知の詳細を構築できます。
  • com.liferay.portal.kernel.notifications.BaseModelUserNotificationHandler - 通知のアセット対応エンティティに適した別の基本クラスです。エンティティクラスのAssetRendererを使用して、アセットをレンダリングし、これを通知のメッセージとして使用します。

明らかにアセット対応のエンティティが通知されている場合は、BaseModelUserNotificationHandlerを使用することをおすすめめします。
実装ではBaseUserNotificationHandlerを基本クラスとして使用します:

@Component(
  immediate = true,
  property = {"javax.portlet.name=" + AdminNotificationPortletKeys.ADMIN_NOTIFICATION},
  service = UserNotificationHandler.class
)
public class AdminLoginUserNotificationHandler extends BaseUserNotificationHandler {

  /**
   * AdminLoginUserNotificationHandler: Constructor class.
   */
  public AdminLoginUserNotificationHandler() {
    setPortletId(AdminNotificationPortletKeys.ADMIN_NOTIFICATION);
  }

  @Override
  protected String getBody(UserNotificationEvent userNotificationEvent, ServiceContext serviceContext) throws Exception {
    String username = LanguageUtil.get(serviceContext.getLocale(), _UKNOWN_USER_KEY);

    // okay, we need to get the user for the event
    User user = _userLocalService.fetchUser(userNotificationEvent.getUserId());

    if (Validator.isNotNull(user)) {
      // get the company the user belongs to.
      Company company = _companyLocalService.fetchCompany(user.getCompanyId());

      // based on the company auth type, find the user name to display.
      // so we'll get screen name or email address or whatever they're using to log in.

      if (Validator.isNotNull(company)) {
        if (company.getAuthType().equals(CompanyConstants.AUTH_TYPE_EA)) {
          username = user.getEmailAddress();
        } else if (company.getAuthType().equals(CompanyConstants.AUTH_TYPE_SN)) {
          username = user.getScreenName();
        } else if (company.getAuthType().equals(CompanyConstants.AUTH_TYPE_ID)) {
          username = String.valueOf(user.getUserId());
        }
      }
    }

    // we'll be stashing the client address in the payload of the event, so let's extract it here.
    JSONObject jsonObject = JSONFactoryUtil.createJSONObject(
      userNotificationEvent.getPayload());

    String fromHost = jsonObject.getString(Constants.FROM_HOST);

    // fetch our strings via the language bundle.
    String title = LanguageUtil.get(serviceContext.getLocale(), _TITLE_KEY);

    String body = LanguageUtil.format(serviceContext.getLocale(), _BODY_KEY, new Object[] {username, fromHost});

    // build the html using our template.
    String html = StringUtil.replace(_BODY_TEMPLATE, _BODY_REPLACEMENTS, new String[] {title, body});

    return html;
  }

  @Reference(unbind = "-")
  protected void setUserLocalService(final UserLocalService userLocalService) {
    _userLocalService = userLocalService;
  }
  @Reference(unbind = "-")
  protected void setCompanyLocalService(final CompanyLocalService companyLocalService) {
    _companyLocalService = companyLocalService;
  }

  private UserLocalService _userLocalService;
  private CompanyLocalService _companyLocalService;

  private static final String _TITLE_KEY = "title.admin.login";
  private static final String _BODY_KEY = "body.admin.login";
  private static final String _UKNOWN_USER_KEY = "unknown.user";

  private static final String _BODY_TEMPLATE = "[$TITLE$][$BODY$]"; private static final String[] _BODY_REPLACEMENTS = new String[] {"[$TITLE$]", "[$BODY$]"};

 private static final Log _log = LogFactoryUtil.getLog(AdminLoginUserNotificationHandler.class); }

このハンドラは基本的に管理者ログインの詳細(ユーザとログイン先)に基づいて通知本体を構築します。独自の通知を作成する場合、通知イベントペイロードを使用して詳細を渡すことがよくあります。管理者が訪問したホスト情報だけを渡すため、今回のペイロードは単純なものになりますが、必要な通知の詳細を渡すXMLまたはJSONなどの構造化文字列を、簡単に渡すことができます。

このハンドラは、Eメールと通知ポートレットの両方の表示に同内容の通知を作成しますが、この2つに違いはありません。メソッドにはUserNotificationEventが渡されるため、Eメール通知を作成するか、通知ポートレット表示メッセージを作成するかによって、getDeliveryType()メソッドを使用して異なるボディを構築できます。

通知イベントの発行


ここまでで、1.ユーザーが管理者ログインのイベントを購読できるようにする、2.通知の受信方法を選択できるようにする、そして、3.通知イベントを通知メッセージに変換できるようにするコードを紹介しました。残っているのは、通知イベント自体を、実際に発行することです。

これはイベントソースに非常に依存しています。ほとんどのLiferayイベントは、あるエンティティの追加または変更に基づいているため、エンティティが追加または更新されたときに、イベント実装のコードを、サービス実装クラスで見つけるのが一般的です。自分の通知イベントは、イベントが発生したどこからでも、サービスレイヤーの外からでも発行することができます。

今回の通知イベントは、管理者のログインに基づいています。これらの種類のイベントを発行する最善の方法は、ログイン後のコンポーネントを使用することです。新しいコンポーネントは次のように定義します。

@Component(
  immediate = true, property = {"key=login.events.post"},
  service = LifecycleAction.class
)
public class AdminLoginNotificationEventSender implements LifecycleAction {

  @Override
  public void processLifecycleEvent(LifecycleEvent lifecycleEvent)
      throws ActionException {

    // get the request associated with the event
    HttpServletRequest request = lifecycleEvent.getRequest();

    // get the user associated with the event
    User user = null;

    try {
      user = PortalUtil.getUser(request);
    } catch (PortalException e) {
      // failed to get the user, just ignore this
    }

    if (user == null) {
      // failed to get a valid user, just return.
      return;
    }

    // We have the user, but are they an admin?
    PermissionChecker permissionChecker = null;

    try {
      permissionChecker = PermissionCheckerFactoryUtil.create(user);
    } catch (Exception e) {
      // ignore the exception
    }

    if (permissionChecker == null) {
      // failed to get a permission checker
      return;
    }

    // If the permission checker indicates the user is not omniadmin, nothing to report.
    if (! permissionChecker.isOmniadmin()) {
      return;
    }

    // this user is an administrator, need to issue the event
    ServiceContext serviceContext = null;

    try {
      // create a service context for the call
      serviceContext = ServiceContextFactory.getInstance(request);

      // note that when you're behind an LB, the remote host may be the address
      // for the LB instead of the remote client.  In these cases the LB will often
      // add a request header with a special key that holds the remote client host
      // so you'd want to use that if it is available.
      String fromHost = request.getRemoteHost();

      // notify subscribers
      notifySubscribers(user.getUserId(), fromHost, user.getCompanyId(), serviceContext);
    } catch (PortalException e) {
      // ignored
    }
  }

  protected void notifySubscribers(long userId, String fromHost, long companyId, ServiceContext serviceContext)
      throws PortalException {

    // so all of this stuff should normally come from some kind of configuration.
    // As this is just an example, we're using a lot of hard coded values and portal-ext.properties values.

    String entryTitle = "Admin User Login";

    String fromName = PropsUtil.get(Constants.EMAIL_FROM_NAME);
    String fromAddress = GetterUtil.getString(PropsUtil.get(Constants.EMAIL_FROM_ADDRESS), PropsUtil.get(PropsKeys.ADMIN_EMAIL_FROM_ADDRESS));

    LocalizedValuesMap subjectLocalizedValuesMap = new LocalizedValuesMap();
    LocalizedValuesMap bodyLocalizedValuesMap = new LocalizedValuesMap();

    subjectLocalizedValuesMap.put(Locale.ENGLISH, "Administrator Login");
    bodyLocalizedValuesMap.put(Locale.ENGLISH, "Adminstrator has logged in.");

    AdminLoginSubscriptionSender subscriptionSender =
        new AdminLoginSubscriptionSender();

    subscriptionSender.setFromHost(fromHost);

    subscriptionSender.setClassPK(0);
    subscriptionSender.setClassName(AdminNotificationPortlet.class.getName());
    subscriptionSender.setCompanyId(companyId);

    subscriptionSender.setCurrentUserId(userId);
    subscriptionSender.setEntryTitle(entryTitle);
    subscriptionSender.setFrom(fromAddress, fromName);
    subscriptionSender.setHtmlFormat(true);

    int notificationType = AdminNotificationType.NOTIFICATION_TYPE_ADMINISTRATOR_LOGIN;

    subscriptionSender.setNotificationType(notificationType);

    String portletId = PortletProviderUtil.getPortletId(AdminNotificationPortletKeys.ADMIN_NOTIFICATION, PortletProvider.Action.VIEW);

    subscriptionSender.setPortletId(portletId);

    subscriptionSender.setReplyToAddress(fromAddress);
    subscriptionSender.setServiceContext(serviceContext);

    subscriptionSender.addPersistedSubscribers(
        AdminNotificationPortlet.class.getName(), 0);

    subscriptionSender.flushNotificationsAsync();
  }
}

これは、ログイン後のライフサイクルイベントリスナーとして登録する、LifecycleActionコンポーネントです。一連のチェックを経て、ユーザーが管理者であるかどうかを判断し、管理者であれば通知を出します。実際の処理はnotifySubscribers()メソッドで起こります。

このメソッドには、多くの初期化とsubscriptionSenderプロパティの設定があります。この変数の型はAdminLoginSubscriptionSenderで、SubscriptionSenderを継承したクラスです。これは、実際の通知送信を処理するものです。flushNotificationAsync()メソッドは、メッセージ受信者がSubscriptionSenderを取得し、そのflushNotification()メソッドを呼び出すLiferayメッセージバスにインスタンスをプッシュします(非同期通知送信が不要な場合でもこのメソッドを呼び出すことができます)。

flushNotification()メソッドは、権限チェック、ユーザー確認、フィルタリング(イベントを生成したユーザーには通知を送信しない)を行い、最終的にEメール通知を送信したり、通知ポートレットのユーザー通知を追加したりします。

AdminLoginSubscriptionSenderクラスはとてもシンプルです:

public class AdminLoginSubscriptionSender extends SubscriptionSender {

  private static final long serialVersionUID = -7152698157653361441L;

  protected void populateNotificationEventJSONObject(
      JSONObject notificationEventJSONObject) {

    super.populateNotificationEventJSONObject(notificationEventJSONObject);

    notificationEventJSONObject.put(Constants.FROM_HOST, _fromHost);
  }

  @Override
  protected boolean hasPermission(Subscription subscription, String className, long classPK, User user) throws Exception {
    return true;
  }

  @Override
  protected boolean hasPermission(Subscription subscription, User user) throws Exception {
    return true;
  }

  @Override
  protected void sendNotification(User user) throws Exception {
    // remove the super classes filtering of not notifying user who is self.
    // makes sense in most cases, but we want a notification of admin login so
    // we know when never any admin logs in from anywhere at any time.

    // will be a pain if we get notified because of our own login, but we want to
    // know if some hacker gets our admin credentials and logs in and it's not really us.

    sendEmailNotification(user);
    sendUserNotification(user);
  }

  public void setFromHost(String fromHost) {
    this._fromHost = fromHost;
  }

  private String _fromHost;
}

まとめ


これで実装に必要なすべてが揃いました:

  • ユーザーに管理者ログインイベントを購読できるようにするコード
  • ユーザーが通知を受け取る方法を選択できるようにするコード
  • 通知ポートレットに表示するために、データベース内の通知メッセージを、HTMLに変換するコード
  • 管理者がログインしたときに通知を送信するコード

モジュールのビルドと、デプロイすれば、ほぼほぼ準備完了です。テストをしてみましょう。

まずログインして、通知を受け取る設定をする必要があります。ローカルのLiferayテスト環境を使用している場合、Eメールが有効になっていない可能性がありますので、必ずWeb通知を使用してください。実際、私のDXP環境では、Eメール設定がされておらず、LiferayのEメールサブシステムから数多くの例外を受信しました。Eメールを設定をされてないので無視しました。

その後ログアウトし、管理者として再度ログインすると、左側のサイドバーに通知ポップアップが表示されるはずです。

 

おわりに

通知の作成と送信をサポートする基本コードをご紹介しました。このコードを使用して、独自のポートレットに通知サポートを追加し、必要に応じて通知を受け取ることができます。

このプロジェクトのコードは以下githubでも参照できます:https://github.com/dnebing/admin-notification

ぜひ使ってみてください!


本記事は以上です。
いかがでしたでしょうか?

記事に関するご意見、ご感想などございましたら、お気軽に[email protected]までご連絡くだい。


■ これまで発信してきた日本語による技術コンテンツを、Qiitaでお読みいただけます。よろしければそちらの記事も合わせてご役立てください。
https://qiita.com/yasuflatland-lf

■ Doorkeeperのライフレイコミュニティメンバー大歓迎!
コーポレートブログの技術コンテンツ更新情報など定期的におとどけしています。
https://liferay.doorkeeper.jp/