※この記事は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の全ドキュメント:
- https://portal.liferay.dev/docs/7-2/frameworks/-/knowledge_base/f/pagination
- https://portal.liferay.dev/docs/7-2/frameworks/-/knowledge_base/f/filter-sort-and-search
- https://portal.liferay.dev/docs/7-2/frameworks/-/knowledge_base/f/restrict-properties
上記ドキュメントで触れられていないポイントとして、エンティティの検索インデックスを使用する必要があるフィルタ、ソート、検索が挙げられます。
例えば検索は、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
フィールドが含まれる場合がありますが、creatorId
はEntityModel
の一部であるため、フィルタリングやソートの両方で使用できます。
そのため、フィルタリングとソートの両方をサポートするフィールドを定義する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
クラスで使用した名前に由来します。
検索インデックスから、chemicalNames
、Field.USER_ID
、Field.NAME
、vitaminGroup
、vType 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
生成がありますが、これは、関連するエンティティの複雑さによるものです(例えばStructuredContent
はJournalArticle
、DDMストラクチャ
、テンプレート
の寄せ集めです)。
したがって、ただ単にメソッドをコピーして実行しないでください。あなたの場合はうまくいくかもしれませんが、他のケースではうまくいかないかもしれません。より複雑なシナリオについては、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