dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

DynamoDBでサポートされたPartiQLをRubySDKで利用する

f:id:jity:20201209121952p:plain

こんにちは。開発部の高橋です。

本記事はdely #1 Advent Calendarの11日目の記事です。

adventar.org

dely #2もあるのでこちらもどうぞ。

adventar.org

昨日はうっくんさんの「UIデザイナーがSwiftを学んでUIを実装したら生産性が爆上がりした」でした。

note.com

先月末、DynamoDBがSQL互換言語であるPartiQLに対応しました。

aws.amazon.com

PartiQLとはSQL互換のクエリ言語で、PartiQLから出力される中間表現を各サービスが対応することによって様々なサービスがSQLライクに操作できるようになります。

aws.amazon.com

今回の対応で、DynamoDBのGetItemやPutItemといった操作をSQLライクに実行できるようになりました。

また、それに合わせてRubySDKの方でも早速APIが追加されています。(2020/12/07時点でリリースはまだされてなさそうです)

github.com

今回はRubySDKを利用しながら、DynamoDBのPartiQL対応を眺めていこうと思います。

準備

ただただサンプルを実行するだけだとつまらないので、(無理矢理ではありますが)今回はそれっぽい機能を実現するための手段としてDynamoDBをPartiQLで操作する形にしました。

今回はレシピサービスを題材にして、ユーザー、レシピの取得、お気に入りといった実装をPartiQLを通して行おうと思います。

必要な機能一覧

今回は以下のような機能をPartiQLで実装してみようと思います。

  • 特定ユーザーの情報を取得
  • 特定レシピの情報を取得
  • 複数レシピの情報を取得
  • ユーザーがレシピをお気に入りに追加する
  • ユーザーがレシピのお気に入りを解除する
  • 複数レシピを一度にお気に入り追加・解除する
  • ユーザーのお気に入りレシピ一覧を取得
  • レシピのお気に入り数の取得

スキーマ

今回は単一のDymamoDBテーブルで全てのデータを入れる形にします。

設計するにあたっては以下のドキュメントを参考にし、1テーブルで多対多になるよう設計してみました。

多対多の関係を管理するためのベストプラクティス - Amazon DynamoDB

テーブル

今回は以下のようなPartition Key, Sort Keyの構成にします。

名前 説明
pk String(Partition Key)
sk String(Sort Key)

今回は1つの属性が様々な役割を持ちうるため、名前もあえて役割を特定しないような名前にしてます。

また、逆引きも行いたいためGSIも設定しておきます。

名前 説明
sk String(Partition Key)
pk String(Sort Key)

Partition Key, Sort Keyに関して

今回は一つのDynamoDBテーブルで多対多構造を実現するため、項目毎に接頭辞を定義して付与します。

項目 接頭辞
ユーザー users#
レシピ recipes#
ユーザーのレシピに対するお気に入り favorites#recipes#

ここまでの設計を元にデータとして落とし込むと、例えば以下のようになります。

pk sk created_at user_name recipe_title
users#1 users#1 1600000000 No.1 null
users#2 users#2 1600000000 No.2 null
recipes#1 recipes#1 1600000000 null 小松菜と豚肉の卵炒め
recipes#2 recipes#2 1600000000 null 照り焼きチキン
users#1 favorites#recipes#1 1600000000 null null
users#2 favorites#recipes#2 1600000000 null null

使ってみる

DynamoDBでPartiQLが使えるようになる修正は2020/12/07時点ではリリースされてため、パス指定で直接使うことにします。

Rubyのバージョンは2.7.1を使ってます。

# Gemfile
source "https://rubygems.org"

gem "aws-sdk-dynamodb", path: "aws-sdk-ruby/gems/aws-sdk-dynamodb

以下を実行して依存解決し準備完了です。

$ git clone https://github.com/aws/aws-sdk-ruby.git
$ bundle install

これから実行するコードは以下を読み込んだ上での実行結果になります。

require 'aws-sdk-dynamodb'

CLIENT = Aws::DynamoDB::Client.new
TABLE_NAME = "partiql_sample".freeze

クエリを書くにあたっては以下の公式ドキュメントを参考にしました。

PartiQL - A SQL-Compatible Query Language for Amazon DynamoDB - Amazon DynamoDB

特定ユーザーの情報を取得

まずは単一リソースの取得です。

単一のSELECT, INSERTなどの操作はExecuteStatementで行えます。

RubyのDynamoDBクライアントからはexecute_statementメソッドからAPIを呼び出せるためこちらからSELECT文を実行します。

ただ、LIMIT句は使えないためRuby側で1つに絞ります。

pp CLIENT.execute_statement(statement: "SELECT * FROM #{TABLE_NAME} WHERE pk='users#5' AND sk='users#5'").items.first

{"sk"=>"users#5",
 "created_at"=>0.160429738e10,
 "pk"=>"users#5",
 "user_name"=>"No.4"}

特定レシピの情報を取得

こちらもユーザーと同様にSELECTで取得できることが確認できました。

pp CLIENT.execute_statement(statement: "SELECT * FROM #{TABLE_NAME} WHERE pk='recipes#10' AND sk='recipes#10'").items.first

{"sk"=>"recipes#10",
 "recipe_title"=>"小松菜と豚肉の卵炒め",
 "created_at"=>"1601273380",
 "pk"=>"recipes#10"}

複数のレシピ情報を取得

今度はPartition Keyを元に複数のレシピを取得してみましょう。BatchExecuteStatementで複数のクエリを一度に取得できるため今回はこちらを利用します。

元々DynamodBにはBatchGetItemBatchWriteItemというAPIが提供されており、ReadとWriteで使い分ける必要がありました。

一方でPartiQLから利用する場合はどちらもBatchExecuteStatementから行います。

※ただし、一回のリクエストでReadとWriteを混ぜて実行することはできません。

CLIENT.batch_execute_statement(statements: [
  {
    statement: "SELECT * FROM #{TABLE_NAME} WHERE pk = 'recipes#1' AND sk = 'recipes#1'"
  },
  {
    statement: "SELECT * FROM #{TABLE_NAME} WHERE pk = 'recipes#2' AND sk = 'recipes#2'"
  },
  {
    statement: "SELECT * FROM #{TABLE_NAME} WHERE pk = 'recipes#3' AND sk = 'recipes#3'"
  },
])

#<struct Aws::DynamoDB::Types::BatchExecuteStatementOutput
 responses=
  [#<struct Aws::DynamoDB::Types::BatchStatementResponse
    error=nil,
    table_name="partiql_sample",
    item=
     {"sk"=>"recipes#1",
      "recipe_title"=>"お酒にピッタリ しいたけと玉ねぎの中華風ポン酢和え",
      "created_at"=>0.160205098e10,
      "pk"=>"recipes#1",
      "user_name"=>nil}>,
   #<struct Aws::DynamoDB::Types::BatchStatementResponse
    error=nil,
    table_name="partiql_sample",
    item=
     {"sk"=>"recipes#2",
      "recipe_title"=>"ジェノバソースの冷製パスタ",
      "created_at"=>0.160196458e10,
      "pk"=>"recipes#2",
      "user_name"=>nil}>,
   #<struct Aws::DynamoDB::Types::BatchStatementResponse
    error=nil,
    table_name="partiql_sample",
    item=
     {"sk"=>"recipes#3",
      "recipe_title"=>"たっぷり胡麻風味の無限キャベツ",
      "created_at"=>0.160187818e10,
      "pk"=>"recipes#3",
      "user_name"=>nil}>]>

なお、BatchGetItem, BatchWriteItemでは処理が失敗すると失敗した要素がレスポンスのUnprocessedKeysUnprocessedItems属性として返却され、それを元に使う側が適宜リトライをすることができました。

ただ、BatchExecuteStatementに関しては、実装を見る限りはそのようなレスポンスは現状生えてなさそうなので、エラーがあるかどうかを元にリトライするなど工夫する必要がありそうです。

BatchGetItemのレスポンス

github.com

BatchExecuteStatementのレスポンス

github.com

ユーザーがレシピをお気に入りに追加する

今度はINSERT文を実行してレシピをお気に入りに追加してみます。

一点注意したいのが、MySQLといったRDBだと

INSERT INTO tbl (column_a, columb_b) VALUES (xxx, yyy)

のような形式ですが、PartiQLの場合は

INSERT INTO tbl VALUE { 'column_a' : xxx, 'column_b' : yyy }

のような形式になります。

ではお気に入りに追加してみます。

CLIENT.execute_statement(statement: "INSERT INTO #{TABLE_NAME} VALUE {'pk' : 'users#1', 'sk' : 'favorites#recipes#10', 'created_at' : #{Time.now.to_i}}")
=> #<struct Aws::DynamoDB::Types::ExecuteStatementOutput items=[], next_token=nil>

念の為正しく追加されてることをSELECT文を発行して確認しておきます。

CLIENT.execute_statement(statement: "SELECT * FROM #{TABLE_NAME} WHERE pk = 'users#1' AND sk = 'favorites#recipes#10'")
=> #<struct Aws::DynamoDB::Types::ExecuteStatementOutput items=[{"pk"=>"users#1", "sk"=>"favorites#recipes#10", "created_at"=>0.160739201e10}], next_token=nil>

ユーザーがレシピのお気に入りを解除する

DELETE文もサポートされているためこちらで物理削除してみます。

CLIENT.execute_statement(statement: "DELETE FROM #{TABLE_NAME} WHERE pk = 'users#1' AND sk = 'favorites#recipes#10'")
=> #<struct Aws::DynamoDB::Types::ExecuteStatementOutput items=[], next_token=nil>

今度は消えてることがSELECTで確認できました。

CLIENT.execute_statement(statement: "SELECT * FROM #{TABLE_NAME} WHERE pk = 'users#1' AND sk = 'favorites#recipes#10'")
=> #<struct Aws::DynamoDB::Types::ExecuteStatementOutput items=[], next_token=nil>

複数レシピを一度にお気に入り追加・解除する

現状INSERTは複数のレコード書き込みには対応してないので、複数レコードに対する追加・更新はBatchExecuteStatementExecuteTransactionを利用する必要があります。

今回はExecuteTransactionを利用して、複数レシピに対するお気に入り追加・お気に入り解除を行ってみます。

まずは複数レシピのお気に入り追加です。ExecuteTransactionの引数に複数のINSERTを含めます。

CLIENT.execute_transaction(transact_statements: [
  {
    statement: "INSERT INTO #{TABLE_NAME} VALUE { 'pk' : 'users#1', 'sk' : 'favorites#recipes#1' }"
  },
  {
    statement: "INSERT INTO #{TABLE_NAME} VALUE { 'pk' : 'users#1', 'sk' : 'favorites#recipes#2' }"
  }
])
=> #<struct Aws::DynamoDB::Types::ExecuteTransactionOutput responses=[]>

SELECTでIN句を指定して取得することで、両方書き込まれていることが確認できました。

CLIENT.execute_statement(statement: "SELECT * FROM #{TABLE_NAME} WHERE pk = 'users#1' AND sk IN ['favorites#recipes#1', 'favorites#recipes#2']")
=> #<struct Aws::DynamoDB::Types::ExecuteStatementOutput items=[{"pk"=>"users#1", "sk"=>"favorites#recipes#1"}, {"pk"=>"users#1", "sk"=>"favorites#recipes#2"}], next_token=nil>

今度は複数お気に入り解除です。引数に複数のDELETEを含めます。

CLIENT.execute_transaction(transact_statements: [
  {
    statement: "DELETE FROM #{TABLE_NAME} WHERE pk = 'users#1' AND sk = 'favorites#recipes#1'"
  },
  {
    statement: "DELETE FROM #{TABLE_NAME} WHERE pk = 'users#1' AND sk = 'favorites#recipes#2'"
  },
])

こちらも解除できていることが確認できました。

CLIENT.execute_statement(statement: "SELECT * FROM #{TABLE_NAME} WHERE pk = 'users#1' AND sk IN ['favorites#recipes#1', 'favorites#recipes#2']")
=> #<struct Aws::DynamoDB::Types::ExecuteStatementOutput items=[], next_token=nil>

ユーザーのお気に入りレシピ一覧を取得

組み込み関数にBEGINS_WITHがあるのでそれを元にソートキーでfavoritesが含まれるものを絞り込んで見ます。

pp res = CLIENT.execute_statement(statement: "SELECT * FROM #{TABLE_NAME} WHERE pk = 'users#1' AND BEGINS_WITH(sk, 'favorites')")
#<struct Aws::DynamoDB::Types::ExecuteStatementOutput
 items=
  [{"sk"=>"favorites#recipes#16",
    "recipe_title"=>nil,
    "created_at"=>0.160723498e10,
    "pk"=>"users#1",
    "user_name"=>nil},
   {"sk"=>"favorites#recipes#17",
    "recipe_title"=>nil,
    "created_at"=>0.160706218e10,
    "pk"=>"users#1",
    "user_name"=>nil},
    ...

上記で取得したレスポンスを元にレシピIDを取得し、レシピ情報を取得します。

recipe_ids = res.items.map { _1['sk'].delete_prefix("favorites#") }

pp CLIENT.execute_statement(statement: "SELECT * FROM #{TABLE_NAME} WHERE pk IN #{'[' + recipe_ids.map { "'#{_1}'" }.join(',') + ']'}").items.map { _1['recipe_title'] }
["葉にんにくと牡蠣のバターソテー",
 "しめじとスナップえんどうの簡単和風パスタ",
 "ピンク色!桜の花の塩漬けで混ぜご飯",
 "大根の中華風浅漬け",
 "めんつゆで簡単かつ煮",
 "油揚げの明太玉ねぎ包み",
 "ちくわと半熟卵の天ぷらの節約天丼",
 "ゆず香る 豚バラとカブのレンジ蒸し",
 "フレッシュトマトソースのガーリックバターチキンソテー",
 "皮から作る 豚こまおやき"]

IN句の中身に関しては[\"a\", \"b\"]のようなエスケープを含む形だと上手くいかないため、不格好ですが自前で加工してます。

レシピのお気に入り数の取得

MySQLなどのRDBであれば条件に当てはまるレコード数はCOUNTで取得できますが、現状そのような関数はないため、今回はRuby側で数えます。

CLIENT.execute_statement(statement: "SELECT * FROM #{TABLE_NAME} WHERE sk = 'favorites#recipes#25' AND BEGINS_WITH(pk, 'users')").items.size
=> 2

なお、今回GSIを貼ってますが、この場合正しくインデックスを使ってくれるかはよくわかってません。 (Query APIで同様の処理を行う場合は利用するインデックスを自分で指定する必要があるため、インデックスを指定しない今回の場合はフルスキャンになってる...?)

ちなみに今回の処理をQuery APIで行う場合は以下のようになります。

# Queryの場合

res = CLIENT.query(
  table_name: TABLE_NAME,
  index_name: 'inverse',
  select: "COUNT",
  expression_attribute_names: {
    '#pk': 'pk',
    '#sk': 'sk'
  },
  expression_attribute_values: {
    ':sk': 'favorites#recipes#25',
    ':pk': 'users'
  },
  key_condition_expression: "#sk = :sk AND begins_with(#pk, :pk)"
)

res.count
=> 2

所感

今回は無理やりではありますがそれっぽいユースケースに沿う形で試してみました。

自分で試してみた感想としては、DynamoDBのAPIのパラメータは色々あって中々覚えるのが大変なのでSQLで簡潔に書けるというのはよいと思いました。

AWS CLIでGetItemなどのクエリを組み立てるのは面倒なので、サクッとデータを確認する用途として使うには非常に便利そうです。

一方で、現段階においてはまだ機能が足りなかったりドキュメントが少なかったりするなど詰まりポイントが多い印象も受けましたが、まだまだ出たばかりなので今後どうなっていくか楽しみです。

これからDynamoDBのPartiQL周りがどう進化していくのか引き続きウォッチしていきたいと思います。

最後に

明日はbababachiさんの「コンテナイメージ対応したLambdaで湯婆婆してみる」です。ぜひ御覧ください!

またdelyではエンジニアを絶賛大募集中です!

興味があればぜひ以下からエントリーください!

join-us.dely.jp

エントリー前に開発部の様子を知りたいという方は「クラシル Tech Talk」などのイベントを定期的に行っているのでこちらを覗いてみるのがおすすめです!

bethesun.connpass.com