LoginSignup
0
0

More than 3 years have passed since last update.

LiferayのREST Builderを使用した独自ヘッドレスAPIの作成方法(パート4)

Posted at

※この記事はLiferayコミュニティブログに投稿された、Creating Headless APIs (Part 4)を翻訳したものです。

はじめに

LiferayのREST Builderツールを使用して独自のヘッドレスAPIを生成することを目的とした当ブログシリーズも、いよいよ大詰めです!

パート1では、新規プロジェクトとモジュールを作成し、再利用可能なコンポーネントの説明を経てヘッドレスサービスを定義するOpenAPI Yamlファイルの作成を開始しました。
パート2では、パスの追加とREST Builderで生成されたコードにまつわる一般的な問題とそれらへの対処方法を経て、OpenAPI Yamlファイルを完成させました。
パート3では、生成されたすべてのコードをレビューし、構築を理解し、実装コードをどこへ追加するのかを学びました。

シリーズラストとなる本章では、値の永続化に必要なServiceBuilder(SB)レイヤーを作成し、ヘッドレスAPIのサポートにあたり、特に実装の必要がある部分に細心の注意を払います。

注:Service Builderの使用は必須ではありません。永続性担保については任意の手法を採ることができます(どの方法であれ、永続性の担保は必要です)。また、独自実装の負荷が高いもの(ページリストのreturn、検索/フィルタ/ソートの適用など)もあります。ServiceBuilderを利用する場合、それらの面倒事をスキップし、独自サービスの設計に時間を割くことができます。

Service Builderレイヤーの作成

永続化レイヤーにService Builderを使用します。この工程の詳細すべてには触れませんが、ヘッドレスAPIの利便性を向上させるにあたり追加する箇所に、重点的に触れていきます。

サービス部分の最も複雑な側面は、すべての「ビタミン成分」を取得する/vitaminsパスで、一見すると最も容易に感じられる箇所です。

なぜとても難しいのか?私たちはLiferayモデルに則り、下記の点を考慮する必要があるためです:

  • 検索のサポート。これはインデックスを介して行われるため、SBエンティティのインデックスを生成する必要があります。
  • パーミッションのサポート。新しい検索の実装はデフォルトでパーミッションに対応しているため、これをサポートする必要があります。
  • 呼び出し元で決定された結果のソートのサポート
  • 特殊文字を使用した検索結果のフィルタリング
  • 検索結果のページネーションのサポート。ページ数は呼び出し元によって決定されます。
  • Remote Services。適切なタイミングで権限チェッカーを呼び出します。

これらすべてを実現するには、エンティティにインデックスが付与されていることを確認する必要があります。確認方法はこちらをご覧ください。

新しいインデックスはデフォルトでパーミッションに対応しているため、エンティティにパーミッションを追加する必要があります。参考記事:https://portal.liferay.dev/docs/7-2/frameworks/-/knowledge_base/f/defining-application-permissions

私はコンポーネント名を「Vitamin」と名付けたため、Service BuilderでVitaminを使用しないようにしました。そうしないと、どこにでもパッケージを含める必要があるためです。代わりにエンティティPersistedVitaminを呼び出すことにしました。これにより、Headlessが使用しているDTOクラスと、Service Builderによって管理される、実際の永続化されたエンティティを区別できます。

リストフィルタ、検索、ソートのサポート

このセクションの残りの部分では、Liferayがサポートするメカニズムを使用したリストフィルタリング、検索、ソートのサポートの追加について説明します。リストのフィルタリング、検索、ソートをサポートしない場合、またはいずれかのみのサポートが必要で、Liferayの手法を採らない場合、このセクションは当てはまらない場合があります。

/v1.0/message-board-threads/{messageBoardThreadId}/message-board-messagesなどのLiferayのリストメソッドの多くには、検索、フィルタ、ソート、ページングおよびフィールドの制限をサポートするための、クエリで提供可能な追加の属性があります。

これらの詳細に関するLiferayの全ドキュメント:

上記ドキュメントで触れられていないポイントとして、エンティティの検索インデックスを使用する必要があるフィルタ、ソート、検索が挙げられます。

例えば検索は、1つ以上のキーワードをクエリに追加することによって実行されます。これらはインデックスクエリに入力され、エンティティの一致を検索します。

フィルタリングは、インデックス検索クエリを調整することによっても管理されます。コンポーネント内の1つ以上のフィールドにフィルタを適用するには、それらのフィールドが検索インデックス内にある必要があります。さらに、以降の別のセクションで説明するフィールドにはOData EntityModelが必要です。

ソートは、インデックス検索クエリを調整することによっても管理されます。コンポーネント内の1つまたは複数のフィールドを基準にソートするには、それらフィールドが検索インデックス内にある必要があります。さらに、com.liferay.portal.kernel.search.DocumentインタフェースのaddKeywordSortable()メソッドを使用してインデックスを作成する必要があり、ソート可能なフィールドも、後に触れるOData EntityModel実装に追加する必要があります。

上記を念頭に置き、カスタムエンティティの検索定義に特に注意を払う必要があります:

  • ModelDocumentContributorを使用して、重要なテキストやキーワードを追加し、適切な検索ヒットを取得します。
  • ModelDocumentContributorを使用して、フィルタリングをサポートするフィールドを追加します。
  • ModelDocumentContributorを使用して、ソート可能なキーワードフィールドを追加します。

VitaminResourceImplメソッドの実装

Service Builderレイヤーを作成し、headless-vitamins-implの依存関係を修正したら、次のステップでは実際にメソッドの実装を開始します。

deleteVitamin()の実装

簡単なdeleteVitamin()メソッドから始めましょう。 VitaminResourceImplでは、基本クラス(すべてのアノテーションを持つもの)からメソッドを拡張し、サービスレイヤーを呼び出します:

@Override
public void deleteVitamin(@NotNull String vitaminId) throws Exception {
  // super easy case, just pass through to the service layer.
  _persistedVitaminService.deletePersistedVitamin(vitaminId);
}

ローカルサービスではなくリモートサービスのみを使用してエンティティの永続化を処理することをおすすめします。なぜか?ユーザーが「ビタミン」の記録を削除するなどの許可を得ているかどうかを確認するのは、まさにあなたの最後の防衛線です。

OAuth2スコープを使用して制御を実行し、アクティビティをブロックできますが、管理者がOAuth2スコープの構成を正しく行うことは難しく、私自身が管理者であっても、毎回スコープを正しく取得できるとは思えません。

アクセス権限チェック付きのリモートサービスを使用することにより、スコープの整合性を心配する必要がなくなります。管理者(私)がOAuth2スコープを無効にしても、ユーザーに適切な権限がない限り、リモートサービスは操作をブロックします。

変換処理

実装メソッドのいくつかをさらに詳しく説明する前に、バックエンドのServiceBuilderエンティティから返されるヘッドレスコンポーネントへの変換について説明しておく必要があります。

現時点では、Liferayはエンティティからコンポーネントへの変換を処理するための標準を確立していません。 Liferayソースのheadless-delivery-implモジュールは一方向に変換を行いますが、headless-admin-user-implモジュールは変換を別の方法で処理します。

便宜上、ここではheadless-admin-user-implテクニックに基づく手法を紹介します。これとは異なる、より効果的な手法がある場合や、headless-delivery-impl方式を好む場合もあります。また、Liferayは次のリリースで変換をサポートするための標準的な方法を考え出すかもしれません。

「変換処理の必要がある」と書いてはいますが、特定の方法に縛られているわけではありません。 Liferayはより善いものを出すかもしれませんが、新しい方法に適応するか、自分流の方法を採るかはあなた次第です。

したがって、ヘッドレスAPI定義の一部として返すには、PersistedVitaminからVitaminコンポーネントへ変換できる必要があります。クラスVitaminResourceImplに、メソッド_toVitamin()を作成します:

protected Vitamin _toVitamin(PersistedVitamin pv) throws Exception {
  return new Vitamin() {{
    creator = CreatorUtil.toCreator(_portal, _userLocalService.getUser(pv.getUserId()));
    articleId = pv.getArticleId();
    group = pv.getGroupName();
    description = pv.getDescription();
    id = pv.getSurrogateId();
    name = pv.getName();
    type = _toVitaminType(pv.getType());
    attributes = ListUtil.toArray(pv.getAttributes(), VALUE_ACCESSOR);
    chemicalNames = ListUtil.toArray(pv.getChemicalNames(), VALUE_ACCESSOR);
    properties = ListUtil.toArray(pv.getProperties(), VALUE_ACCESSOR);
    risks = ListUtil.toArray(pv.getRisks(), VALUE_ACCESSOR);
    symptoms = ListUtil.toArray(pv.getSymptoms(), VALUE_ACCESSOR);
  }};
}

はじめに、ダブルブレースのインスタンス化を使用したことを謝らなければなりません...。私もアンチパターンだと認識しています。しかし私の目標は、headless-admin-user-implモジュールでレイアウトされている「Liferayの手法」に従うことであり、それがLiferayの使用するパターンでした。LiferayはBuilderパターンを頻繁に使用していないことから、ダブルブレースのインスタンス化が代わりに使用されていると思います。

私自身の好みを考えると、オブジェクトの生成を単純化するためにBuilderパターンやFluentパターンにも従います。結局のところ、Intellijは私のためにBuilderクラスを簡単に作成してくれます。

このメソッドは、外部のCreatorUtilクラス(Liferayのコードからコピー)、内部の整数コードをコンポーネントの列挙型に変換する_toVitaminType()メソッド、ListUtilのtoArray()メソッドによって実装の詳細の一部である内部オブジェクトをString配列に処理するVALUE_ACCESSORを使用します。

要するに、このメソッドは実際のメソッド実装で実行する必要がある変換を処理できます。

getVitamin()の実装

別の簡単なgetVitamin()メソッドを見てみましょう。このメソッドは、vitaminIdを指定すると単一のエンティティを返します。

@Override
public Vitamin getVitamin(@NotNull String vitaminId) throws Exception {
  // fetch the entity class...
  PersistedVitamin pv = _persistedVitaminService.getPersistedVitamin(vitaminId);

  return _toVitamin(pv);
}

ここでは、サービスレイヤーからPersistedVitaminインスタンスを取得しますが、取得したオブジェクトを_toVitamin()メソッドに渡して変換します。

postVitamin()patchVitamin()、およびputVitamin()の実装

パターンはすでに見飽きていると思いますので、ひとまとめに見てみましょう。

postVitamin()/vitaminsに対するPOSTメソッドであり、エンティティの新規作成を表します。

patchVitamin()は、/vitamins/{vitaminId}のPATCHメソッドであり、既存のエンティティへのパッチ適用を表します(他の既存のプロパティはそのままにして、入力オブジェクトに指定された値のみを変更する)。

putVitamin()/vitamins/{vitaminId}のPUTメソッドであり、既存エンティティの置換を表し、フィールドがnullや空の場合でも、すべての永続値を渡された値で置換します。

ServiceBuilderレイヤーを作成し、これらのエントリポイント用にカスタマイズしたため、VitaminResourceImplクラスでの実装は非常に軽量に見えます。

@Override
public Vitamin postVitamin(Vitamin v) throws Exception {
  PersistedVitamin pv = _persistedVitaminService.addPersistedVitamin(
      v.getId(), v.getName(), v.getGroup(), v.getDescription(), _toTypeCode(v.getType()), v.getArticleId(), v.getChemicalNames(),
      v.getProperties(), v.getAttributes(), v.getSymptoms(), v.getRisks(), _getServiceContext());

  return _toVitamin(pv);
}

@Override
public Vitamin patchVitamin(@NotNull String vitaminId, Vitamin v) throws Exception {
  PersistedVitamin pv = _persistedVitaminService.patchPersistedVitamin(vitaminId,
      v.getId(), v.getName(), v.getGroup(), v.getDescription(), _toTypeCode(v.getType()), v.getArticleId(), v.getChemicalNames(),
      v.getProperties(), v.getAttributes(), v.getSymptoms(), v.getRisks(), _getServiceContext());

  return _toVitamin(pv);
}

@Override
public Vitamin putVitamin(@NotNull String vitaminId, Vitamin v) throws Exception {
  PersistedVitamin pv = _persistedVitaminService.updatePersistedVitamin(vitaminId,
      v.getId(), v.getName(), v.getGroup(), v.getDescription(), _toTypeCode(v.getType()), v.getArticleId(), v.getChemicalNames(),
      v.getProperties(), v.getAttributes(), v.getSymptoms(), v.getRisks(), _getServiceContext());

  return _toVitamin(pv);
}

このとおり、非常に軽量です。

サービスレイヤーに移動するため、ServiceContextが必要です。 Liferayはcom.liferay.headless.common.spi.service.context.ServiceContextUtilを提供します。これには、ServiceContextを作成するのに必要なメソッドだけがあります。これはコンテキストを開始するもので、企業IDや現在のユーザーIDなどの追加情報を加えるだけです。そこで、このすべてを_getServiceContext()メソッドにラップしました。REST Builderの将来のバージョンでは、有効なServiceContextをより簡単に取得できるように、新しいコンテキスト変数を取得する予定です。

私のServiceBuilderメソッドはすべて、ServiceBuilderに関する誰もが知る、展開されたパラメーターを渡して使用します。メソッド呼び出しから返されたPersistedValueインスタンスは、変換のために_toVitamin()に渡され、それが戻されます。

以上が簡単な対処方法です。getVitaminsPage()メソッドについても説明する必要がありますが、その前にEntityModelsについて説明する必要があります。

EntityModels

先ほど、Liferayが検索インデックスを使用してリストのフィルタリング、検索、ソートをサポートする方法について説明しました。また、フィルタリングやソートに使用できるフィールドがコンポーネントのEntityModel定義の一部である必要があることについても説明しました。 EntityModelの一部ではないコンポーネントのフィールドは、フィルタリングもソートもできません。

追加の副作用として、EntityModelはフィルタリングとソートのために検索インデックスからこれらのフィールドを公開するため、これらのフィールドをコンポーネントフィールドに接続する必要はありません。

例えばEntityModel定義では、検索インデックスのユーザーIDへのフィルタとなるcreatorIdのエントリを追加できます。コンポーネント定義には、creatorIdフィールドではなくCreatorフィールドが含まれる場合がありますが、creatorIdEntityModelの一部であるため、フィルタリングやソートの両方で使用できます。

そのため、フィルタリングとソートの両方をサポートするフィールドを定義するEntityModelを構築する必要があります。既存のLiferayユーティリティを使用して、EntityModelクラスをまとめます:

public class VitaminEntityModel implements EntityModel {
  public VitaminEntityModel() {
    _entityFieldsMap = Stream.of(
        // chemicalNames is a string array of the chemical names of the vitamins/minerals
        new CollectionEntityField(
            new StringEntityField(
                "chemicalNames", locale -> Field.getSortableFieldName("chemicalNames"))),

        // we'll support filtering based upon user creator id.
        new IntegerEntityField("creatorId", locale -> Field.USER_ID),

        // sorting/filtering on name is okay too
        new StringEntityField(
            "name", locale -> Field.getSortableFieldName(Field.NAME)),

        // as is sorting/filtering on the vitamin group
        new StringEntityField(
            "group", locale -> Field.getSortableFieldName("vitaminGroup")),

        // and the type (vitamin, mineral, other).
        new StringEntityField(
            "type", locale -> Field.getSortableFieldName("vType"))
    ).collect(
        Collectors.toMap(EntityField::getName, Function.identity())
    );
  }

  @Override
  public Map<String, EntityField> getEntityFieldsMap() {
    return _entityFieldsMap;
  }

  private final Map<String, EntityField> _entityFieldsMap;
}

フィールド名は、フィールド値を追加するためにサービスレイヤーのPersistedVitaminModelDocumentContributorクラスで使用した名前に由来します。

検索インデックスから、chemicalNamesField.USER_IDField.NAMEvitaminGroupvType Fieldsの定義を含めました。定義のうち、フィルタが使用するcreatorIdフィールドは、ビタミンコンポーネント定義のフィールドとしては存在しません。

Vitaminコンポーネントの一部である他のフィールドは、残りのソートまたはフィルタリングを許可する必要がないように感じます。この種の決定は、通常要件によって決定されます。

Liferayはこれらのクラスを内部パッケージであるodata.entity.v1_0パッケージに保存するため、私のケースで配置するファイルとして、com.dnebinger.headless.delivery.internal.odata.entity.v1_0を持っています。

クラスの準備が整ったので、EntityModelを提供できることを正しく確認するために、VitaminResourceImplクラスも装飾する必要があります。

必要な変更は次のとおりです:

  • <Component>ResourceImplクラスは、com.liferay.portal.vulcan.resource.EntityModelResourceインターフェースの実装。
  • クラスでEntityModelインスタンスを返すgetEntityModel()メソッドの実装。

私のVitaminEntityModelは非常に単純であまり動的ではないため、実装は次のようになります:

public class VitaminResourceImpl extends BaseVitaminResourceImpl 
    implements EntityModelResource {

  private VitaminEntityModel _vitaminEntityModel = new VitaminEntityModel();

  @Override
  public EntityModel getEntityModel(MultivaluedMap multivaluedMap) throws Exception {
    return _vitaminEntityModel;
  }

これは一般的な実装ではないことに注意してください。 Liferayのコンポーネントリソース実装クラスには、はるかに複雑で動的なEntityModel生成がありますが、これは、関連するエンティティの複雑さによるものです(例えばStructuredContentJournalArticleDDMストラクチャテンプレートの寄せ集めです)。

したがって、ただ単にメソッドをコピーして実行しないでください。あなたの場合はうまくいくかもしれませんが、他のケースではうまくいかないかもしれません。より複雑なシナリオについては、EntityModelクラスのLiferay実装と、コンポーネントリソース実装のgetEntityModel()メソッドを確認してください。

getVitaminsPage()の実装

これはおそらく最も複雑な実装方法です。それ自体が困難なのではなく、他の多くのものに依存しているという点でです。

ここでのLiferayリスト処理機能は、データベースではなく検索インデックスから取得されます。したがって、エンティティにはインデックスが付けられている必要があります。

これは、フィルタ、検索、ソートのパラメーターをサポートするメソッドでもあり、エンティティにインデックスを付ける必要があります。そして先ほど見たように、フィルタとソートもEntityModelクラスに依存しています。

最後に、Liferayメソッドを呼び出しているため、実装自体はかなり不透明で、制御できません。
最終的には次のようになります:

public Page<Vitamin> getVitaminsPage(String search, Filter filter, Pagination pagination, Sort[] sorts) throws Exception {
  return SearchUtil.search(
    booleanQuery -> {
      // does nothing, we just need the UnsafeConsumer<BooleanQuery, Exception> method
    },
    filter, PersistedVitamin.class, search, pagination,
    queryConfig -> queryConfig.setSelectedFieldNames(
      Field.ENTRY_CLASS_PK),
    searchContext -> searchContext.setCompanyId(contextCompany.getCompanyId()),
    document -> _toVitamin(
      _persistedVitaminService.getPersistedVitamin(
        GetterUtil.getLong(document.get(Field.ENTRY_CLASS_PK)))),
    sorts);
}

私たちは、すべての処理方法を知っているSearchUtil.search()メソッドを使用しています。

最初の引数はUnsafeConsumerクラスで、基本的にはエンティティの必要に応じてbooleanQueryを微調整することを担います。ここでは必要ありませんでしたが、Liferayのヘッドレスデリバリモジュールに例があります。サイトIDで記事を検索するStructuredContentのバージョンは、クエリ引数としてサイトIDを追加します。 flattenパラメータは、特定のフィルタ、これらの種類のものを検索するためにクエリを微調整します。

ヘッドレスレイヤーから取得したフィルタ、検索、およびページネーションの引数はそのまま渡されます。結果はブーリアンクエリに適用され、結果のフィルタリングと検索が行われ、ページネーションによりページに相当する結果が得られます。

queryConfigは主キー値のみを返し、他のフィールドデータは要求しません。検索インデックスDocumentから変換するわけではないので、ServiceBuilderエンティティが必要になります。

最後から2番目の引数は、ドキュメントからコンポーネントタイプへの変換の適用をする別のUnsafeFunctionです。この実装では、Documentから抽出されたプライマリ・キー値を使用してPersistedVitaminインスタンスをフェッチし、PersistedVitamin_toVitamin()に渡されて最終的な変換を処理します。

残作業

これで、すべてのコーディングを終えましたが、完了はしていません。

buildRESTコマンドを再実行します。メソッドをVitaminResourceImplメソッドへ追加したので、それらに適用できるテストケースを用意しておきたいと思います。

次にモジュールをビルドおよびデプロイし、未解決の参照やデプロイメントの問題などをクリーンアップする必要があります。ServiceBuilder層にはvitamins-apiおよびvitamins-serviceを、Headless層にはvitamins-headless-apiおよびvitamins-headless-implモジュールをデプロイします。

それらの準備ができたら、headless-vitamins-testモジュールにドロップして、すべてのテストケースを実行する必要があります(不足がある場合は、それらも再作成できます)。

すべて準備できたら、Headless APIをSwaggerHubに公開して他の人が使用できるようにしたいと思うかもしれません。

REST Builder用に作成したYamlファイルは使用しません。代わりに、ブラウザでhttp://localhost:8080/o/headless-vitamins/v1.0/openapi.yamlを指定し、そのファイルを送信に使用します。必要なすべてのパーツが配置され、PageVitaminタイプなどの追加コンポーネントが追加されます。

まとめ

パート1で新しいヘッドレス検証用のワークスペースとモジュールを作成し、REST Builderが最終的にコードを生成するのに使用するOpenAPI Yamlファイルに着手しました。

パート2ではパス定義を追加し、REST BuilderのOpenAPI Yamlファイルを完成させました。 REST Builderのビルドエラーに直面しつつも、ビルドエラーを引き起こす可能性のある一般的なフォーマットエラーの一部を理解し、それらを修正し、REST Builderを使用してコードを正常に生成しました。

パート3では、全モジュールで生成すべてのコードをレビューして、どこで変更が行われるのかを示しました。

パート4(本章)では、Service Builderレイヤーを作成し、リソースのアクセス許可(リモートサービスでのアクセス許可チェック用)とエンティティのインデックス作成(Liferayのヘッドレスインフラストラクチャのリストフィルタ/検索/ソート機能をサポートするため)を含めました。次に、VitaminResourceImplメソッドをフラッシュし、エンティティからコンポーネントへの変換の処理方法、およびフィルタとソートを容易にするために必要なEntityModelクラスについて説明しました。

私たちはすべてをテストし、おそらくはAPIをSwaggerHubに公開し、みんなが楽しめるようにしました。長い道のりでしたが、私にとっては実に興味深いものでした。楽しんでいただけたら嬉しいです。

今一度、本ブログシリーズのリポジトリを示します:https://github.com/dnebing/vitamins

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0