本記事は AWS Amplify Advent Calendar 2020の3日目の記事です。
はじめに
amplifyをお使いの皆さん、@searchalbe使ってますか?
既存のモデルに@searchableとつけるだけで、AWS ElasticSearchと連携し、検索可能なresolverまで用意してくれる便利なものです。
ただし@searchableと@authを併用すると、構造を理解せずに使うと思いもよらぬ挙動にぶつかることがあります。そこで本記事では自分が遭遇した怪しい挙動とその解決方法について書きたいと思います。
あるはずの検索結果が返ってこない?
Seachableを使い始めてから、Amazon ESには確かにあるのに検索結果が出てこないという現象に悩まされました。そこで、シンプルな「本の管理アプリ」を例に詳しく説明したいと思います。
schemaは下記のとおりです。
type Book
@model
@searchable
@auth(
rules: [
{allow: owner, ownerField: "owner", operations: [create, update, delete, read]}
]
)
{
id: ID!
title: String!
owner: String
}
@authではownerのみがcrud操作をできるように定義してあります。searchableは単に@searchableを付け加えるだけです。
たとえば「投稿した書籍の中から、"Harry Potter"を含む本の一覧を登録した順に5件取得したい」という場合、@searchableディレクティブによって作られたsearchBooksを使ってあげると良さそうです。
searchBooks({
limit: 5,
sort: {direction: desc, field: "createdAt"},
filter: { title: { match: "Harry Potter" } }
})
このクエリの結果は、思いもよらず実際に登録されている"Harry Potter"を含む書籍の数と合わない結果になりえます。場合によってはempty array,場合によっては5件あるはずなのに1件しか返ってこなかったりします。
const response = await API.graphql(
graphqlOperation(searchBooks, {
limit: 5,
sort: { direction: "desc", field: "createdAt" },
filter: { title: { match: "Harry Potter" } },
}))
// response.data.searchBooks: []
// response.data.nextToken: null
結果が空でも、nextTokenで再度リクエストをすれば良さそうにも見えますが、nextTokenもnullになってしまいなすすべがありません。
searchableの仕組み
さて、何が起きているのでしょうか。ここから、@searchableによってどうやってelasticSearchへクエリが送られれ、レスポンスが返ってきてるかを追ってみましょう。
全体の流れ
全体の流れは図の通り、ざっくりこのようなものになります(間違っていたら指摘ください)
- ユーザーがデータをpostする
- AppSyncを通してDynamoDBへ保存
- DynamoDB StreamsでDynamoDBへのinsertをhookしAmazonESに保存する
- ユーザーが検索をかける
- AppSyncを介してAmazonESに検索クエリを投げる
- 検索結果がユーザーへ返却される
Requestのvtl
AmplifyでのsearchQueryがどのようなrequestになっているかは、該当resolverのvtlをみるとわかります。
amplify-cliによって作られたresolverは、amplify/backend/api/api/build/resolvers/配下に全てありますが、今回Bookのようなモデルがあった場合、リクエスト/レスポンスのresolverは次のような名前になるでしょう。
- Query.searchBooks.req.vtl(リクエスト)
- Query.searchBooks.res.vtl(レスポンス)
実際にQuery.searchBooks.req.vtlを見てみましょう。
#set( $indexPath = "/book/doc/_search" )
#set( $nonKeywordFields = ["createdAt"] )
#if( $util.isNullOrEmpty($context.args.sort) )
#set( $sortDirection = "desc" )
#set( $sortField = "id" )
#else
#set( $sortDirection = $util.defaultIfNull($context.args.sort.direction, "desc") )
#set( $sortField = $util.defaultIfNull($context.args.sort.field, "id") )
#end
#if( $nonKeywordFields.contains($sortField) )
#set( $sortField0 = $util.toJson($sortField) )
#else
#set( $sortField0 = $util.toJson("${sortField}.keyword") )
#end
{
"version": "2018-05-29",
"operation": "GET",
"path": "$indexPath",
"params": {
"body": {
#if( $context.args.nextToken )"search_after": [$util.toJson($context.args.nextToken)], #end
"size": #if( $context.args.limit ) $context.args.limit #else 100 #end,
"sort": [{$sortField0: { "order" : $util.toJson($sortDirection) }}],
"version": false,
"query": #if( $context.args.filter )
$util.transform.toElasticsearchQueryDSL($ctx.args.filter)
#else
{
"match_all": {}
}
#end
}
}
}
vtlです。やや分かりづらいと思いますが、これはフロントエンドエンジニアにとってはJSONのテンプレートエンジンと思えば理解できると思います。htmlのテンプレートエンジンでejs, erbがあるようにvtlはJSONのテンプレートエンジンです。
このコードのJSONの部分が実際にAmazonESに対するリクエストになります。よく見てみると、$util.transform.toElasticsearchQueryDSL($ctx.args.filter)
というコードがありますね。このtoElasticsearchQueryDSLによって、AppSync風のクエリがElasticsearchのクエリに変換されるようです(実はこのメソッドは非公開のようで、どういう処理をしているかはわかりません。)
elasticsearchへのクエリ
graphqlOperation(searchBooks, {
limit: 5,
sort: { direction: "desc", field: "createdAt" },
filter: { title: { match: "Harry Potter" } },
}))
記事の冒頭でも示したこのようなsearchBooksクエリは、Query.searchBooks.req.vtlを通して、下記のようなelasticsearchの文法でリクエストされます。
GET book/doc/_search
{
"size": 5,
"sort": [ { "createdAt": { "order": "desc" } }]
"query": {
"match": { "title": "Harry Potter" }
}
}
elasticSearchを使ったことがある人はわかると思いますが、このクエリはtitleに"Harry Potter"を含む全てのbookを検索するクエリです。
つまり、本来検索したい「ユーザーが投稿した"Harry Potter"を含む全てのbook」ではなく、「全ユーザーが投稿した"Harry Potter"を含む全てのbook」を検索しています。
Responseのvtl
それではresponseのvtlを見てみましょう。
#set( $es_items = [] )
#foreach( $entry in $context.result.hits.hits )
#if( !$foreach.hasNext )
#set( $nextToken = $entry.sort.get(0) )
#end
$util.qr($es_items.add($entry.get("_source")))
#end
#if( $util.isNullOrEmpty($authMode) && !$util.isNull($ctx.identity) && !$util.isNull($ctx.identity.sub) && !$util.isNull($ctx.identity.issuer) && !$util.isNull($ctx.identity.username) && !$util.isNull($ctx.identity.claims) && !$util.isNull($ctx.identity.sourceIp) && !$util.isNull($ctx.identity.defaultAuthStrategy) )
#set( $authMode = "userPools" )
#end
#if( $authMode == "userPools" )
#if( !$isStaticGroupAuthorized )
#set( $items = [] )
#foreach( $item in $es_items )
#set( $isLocalOwnerAuthorized = false )
#set( $allowedOwners0 = $util.defaultIfNull($item.owner, []) )
#set( $identityValue = $util.defaultIfNull($ctx.identity.claims.get("username"), $util.defaultIfNull($ctx.identity.claims.get("cognito:username"), "___xamznone____")) )
#if( $util.isList($allowedOwners0) )
#foreach( $allowedOwner in $allowedOwners0 )
#if( $allowedOwner == $identityValue )
#set( $isLocalOwnerAuthorized = true )
#end
#end
#end
#if( $util.isString($allowedOwners0) )
#if( $allowedOwners0 == $identityValue )
#set( $isLocalOwnerAuthorized = true )
#end
#end
#if( ($isLocalDynamicGroupAuthorized == true || $isLocalOwnerAuthorized == true) )
$util.qr($items.add($item))
#end
#end
#set( $es_items = $items )
#end
#end
#set( $es_response = {
"items": $es_items
} )
#if( $es_items.size() > 0 )
$util.qr($es_response.put("nextToken", $nextToken))
$util.qr($es_response.put("total", $es_items.size()))
#end
$util.toJson($es_response)
長いですが、重要なのは
- 検索結果をownerチェックしfilterしている
- filterされたものがなければnextTokenは返さない
です。@authディレクティブが使われていると、amplify-cliでコード生成する際にこのようなロジックが作られます。
さて、これで仕組みが明らかになりましたね。
あるはずのものが返ってこない原因、解決方法
これまでの流れをまとめると、
searchBooks({
limit: 5,
sort: {direction: desc, field: "createdAt"},
filter: { title: { match: "Harry Potter" } }
})
このクエリは、
- 全ユーザーの投稿した本の中から、"Harry Potter"を含むものを最新5件検索し、
- その5件の中にcurrentUserのものがあればその分だけ返す、なければ返さない
というものになります。
つまり、currentUser以外に"Harry Potter"を含む本を登録しているユーザーがいて、5件のhitの中にcurrentUserがownerのものがなかった場合、検索結果は空になってしまいます。
そこで本来の目的を果たすためには、ownerフィールドをfilter条件に追加してあげる必要があります
searchBooks({
limit: 5,
sort: {direction: desc, field: "createdAt"},
filter: {
owner: { eq: "<user id>" },
title: { match: "Harry Potter" }
}
})
こうすれば、elasticSearchへのリクエストの時点でownerでfilterがかかり、リゾルバのownerチェックで結果が変わることがなくなります。
残された課題
今回の解決策は、一つ問題があります。それはelasticsearchへのリクエストでowner fieldをフィルタリングし、responseのresolverでも同様のフィルタリングが走るところで、単純に二重の無駄な処理です。もし気になる場合はcustom resolverを書いてあげるのが良いかもしれません。
あとがき
本ケースに関連するissueがamplify-cliのリポジトリにいくつか上がっており、今後の改善に期待です。(PR出そうといじってみたものの、toElasticsearchQueryDSLが非公開のメソッドだったのでギブアップ)
Searchableには他にもいくつか構造上のリミテーションがありますが(nested objectを対象とした検索がカスタムリゾルバ書かないとできないとか)、個人的にはAPIを無理にAppSyncに寄せすぎず、ElasticSearchのクエリのAPIの形を保つべきだったのかなと思ったりしました。
amplifyはハマったりエッジケースに当たると突然AWSサービスの深い理解が必要になりますが、使っていくうちに得られるAWSのナレッジは非Amplifyなプロジェクトでも役に立つものなのでめげずに使っていきたいです。