Dapr query state APIをRedisで使ってみる

# TL;DR

便利だが**はまりどころがある**ので準備必須！

- RedisにRedisearch, RedisJSONインストール必須
- Componentにインデックス追加必須
  - 運用上stateのスキーマを固定し、アプリとcomponentのupdateタイミングを合わせる必要あり
- `contentType:application/json` の指定があるstateのみクエリで検索可能
  - **既存のstateにはクエリが使えない！**

https://docs.dapr.io/reference/components-reference/supported-state-stores/setup-redis/#querying-json-objects

# はじめに

先日のDapr v1.7.0リリースで、**RedisのState ComponentでもクエリAPIが使えるようになりました** :tada:
stateの一覧取得ができるようになるので、活用の幅がぐっと広がりますね。

https://blog.dapr.io/posts/2022/04/08/dapr-v1.7-is-now-available/#new-state-store-components

ただし、使うまでにいくつかハマりどころがあったので、今回は導入までの手順を紹介したいと思います。

ちなみに、query stateの概要については以前記事にしておりますので、よろしければこちらもご覧ください。

https://qiita.com/Syuparn/items/fdb72b4ecae408cfaec0

# バージョン

- Dapr: `v1.7.0`

:warning: query APIはalpha版なので、**今後のアップデートで仕様が変わる可能性があります**！

# 環境

検証には以下の環境を使用しました。

- k8sクラスタ (kind使用)
- Redis ([bitnami/redis のHelm chart](https://github.com/bitnami/charts/tree/master/bitnami/redis))
  - イメージは `redislabs/rejson` (後述)
- Dapr ([公式 Helm chart](https://github.com/dapr/dapr/tree/master/charts/dapr))
- ダミーPod 
  - サイドカーへport-forwardしてState APIにリクエストするために使用

https://github.com/Syuparn/dapr-redis-state-query

当記事ではAPIを直接curlで叩いて検証していますが、実際にアプリケーションで使う場合には[SDK](https://docs.dapr.io/developing-applications/sdks/)を利用すると便利です。

# 準備

Redis componentでquery state APIを使うためには以下の3つの準備が必要です。

1. state保存時に、metadataに `contentType:application/json` を指定
1. RedisにRediSearch, RedisJSONをインストール
1. componentのマニフェストにインデックスを定義

手順は公式リファレンスを参考にしました。

https://docs.dapr.io/reference/components-reference/supported-state-stores/setup-redis/#querying-json-objects

## なぜ準備が必要？

Redisには本来主キー以外での検索機能が無く、単体ではクエリ機能が作れません。そこで、RedisJSON, RediSearchのプラグインを使い(JSON形式の値限定で)クエリ機能を実装しています。

このあたりの制約により、他のcomponentに比べ導入手順が多いようです。

https://github.com/dapr/components-contrib/issues/1211

## 1. state保存時に、metadataに `contentType:application/json` を指定

Dapr `v1.7.0` から、stateの値に `contentType` を指定することができるようになりました。

https://github.com/dapr/dapr/issues/2892

**クエリの検索範囲は `contentType:application/json` を指定したstateだけです！** そのため、保存時にmetadataにcontentTypeを明示する必要があります。

```bash
# contentType 無しで save
$ curl -X POST http://localhost:3500/v1.0/state/statestore \
    -d '[{"key": "hello1", "value": {"msg": "Hello, world!"}}]'

# contentType を指定して save
$ curl -X POST 'http://localhost:3500/v1.0/state/statestore?metadata.contentType=application/json' \
    -d '[{"key": "hello2", "value": {"msg": "Hello, world!"}}]'

# query state で全検索しても、 contentType が json のものしか取れない！
$ curl -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=helloIndx' \
    -d '{}'
{"results":[{"key":"hello2","data":{"msg":"Hello, world!"},"etag":"1"}]}

# もちろん state 自体は存在する
$ curl 'http://localhost:3500/v1.0/state/statestore/hello1'
{"msg":"Hello, world!"}
```

言い換えると、**既存のstateはクエリの検索対象にできません**！
別のcontentTypeを指定してsaveし直すこともできないので、どうしてもクエリの対象にしたい場合は「キー削除→contentTypeを指定して同じ内容で再作成」という運用が必要です。

```bash
# contentTypeを上書き...できなかった
$ curl -X POST 'http://localhost:3500/v1.0/state/statestore?metadata.contentType=application/json' \
    -d '[{"key": "hello1", "value": {"msg": "Hello, world!"}}]'
{"errorCode":"ERR_STATE_SAVE","message":"failed saving state in state store statestore: failed to set key nodeapp||hello1: ERR Error running script (call to f_c5d8cabb8162d7f0980fe627a55d83f54c800e66): @user_script:4: Existing key has wrong Redis type"}
```

## 2. RedisにRediSearch, RedisJSONをインストール

  続いて、Redisにプラグイン「[RediSearch](https://redis.io/docs/stack/search/)」、「[RedisJSON](https://redis.io/docs/stack/json/)」をインストールします。

bitnamiのHelm chartを使っている場合は、公式の案内を参考に `values.yaml` を作成します。

https://docs.dapr.io/reference/components-reference/supported-state-stores/setup-redis/#querying-json-objects

```yaml:values.yaml
# プラグイン入りのイメージに差し替え
image:
  repository: redislabs/rejson
  tag: 2.0.6

# プラグイン読み込み
master:
  extraFlags:
   - --loadmodule
   - /usr/lib/redis/modules/rejson.so
   - --loadmodule
   - /usr/lib/redis/modules/redisearch.so

# 注: Daprのリファレンスには書いていないが、replica側にもプラグインが必要！
replica:
  extraFlags:
   - --loadmodule
   - /usr/lib/redis/modules/rejson.so
   - --loadmodule
   - /usr/lib/redis/modules/redisearch.so

auth:
  password: redis
```

### インストールしないでquery APIを叩くとクラッシュ

ちなみに、プラグインをインストールをしない状態で `3. componentのマニフェストにインデックスを定義` を実行すると、**デプロイ時にサイドカーがクラッシュ**します。

```bash
      > [nodeapp-858b9cc6db-8ml68 daprd] time="2022-04-16T05:19:23.204465987Z" level=fatal msg="process component statestore error: redis store: error registering query schemas: ERR unknown command `FT.CREATE`, with args beginning with: `helloIndx`, `ON`, `JSON`, `SCHEMA`, `$.data.msg`, `AS`, `var0`, `TEXT`, `SORTABLE`, " app_id=nodeapp instance=nodeapp-858b9cc6db-8ml68 scope=dapr.runtime type=log ver=1.7.0
 - deployment/nodeapp failed. Error: container daprd terminated with exit code 1.
1/1 deployment(s) failed
```

エラーメッセージ的に、インデックス登録時に使用したRediSearch専用のコマンドをRedisが認識できずにクラッシュしたようです。

## 3. componentのマニフェストにインデックスを定義

最後に、componentのマニフェストにインデックスを追加します。クエリでは**事前ににインデックス中に定義したキーしか使えません**。

**アプリケーションの機能追加等でstateのスキーマを更新した場合は、こちらも忘れずに更新する必要があります**。~~忘れそう~~

インデックスのキーはクエリ同様 [JSONPath](https://goessner.net/articles/JsonPath/) で記述します。インデックスにはキーを複数指定でき、インデックス自体も複数設定可能です。

```yaml:state.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
    # jsonを埋め込んで記述
  - name: queryIndexes
    value: |
      [
        {
          "name": "helloIndx",
          "indexes": [
            {
              "key": "msg",
              "type": "TEXT"
            }
          ]
        },
        {
          "name": "personIndx",
          "indexes": [
            {
              "key": "age",
              "type": "NUMERIC"
            },
            # ネストした要素も指定可能
            {
              "key": "name.first",
              "type": "TEXT"
            },
            {
              "key": "name.last",
              "type": "TEXT"
            },
            {
              "key": "friends[0]",
              "type": "TEXT"
            }
          ]
        }
      ]
```

型は現状 数値 (`NUMERIC`) と文字列 (`TEXT`) に対応しています。

インデックスは、リクエスト時に以下のように指定します。

```bash
# queryIndexNameに使用するインデックスを指定
# filterでキーとして使えるのは、インデックスに登録されているものだけ
$ curl -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=helloIndx' \
    -d '{"filter": {"EQ": {"msg": "Hello, world!"}}}'
{"results":[{"key":"hello2","data":{"msg":"Hello, world!"},"etag":"1"}]}
```

### インデックスが無いとリクエストできない

クエリのリクエスト時にはインデックス名を指定するので、インデックスが存在しない場合エラーになります。

```bash
# インデックス無指定
$ curl -X POST http://localhost:3500/v1.0-alpha1/state/statestore/query -d '{}'
{"errorCode":"ERR_STATE_QUERY","message":"failed query in state store statestore: query index not found"}

# 未登録のインデックスを指定
$ curl -X POST http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=unknownIndx -d '{}'
{"errorCode":"ERR_STATE_QUERY","message":"failed query in state store statestore: query index schema \"unknownIndx\" not found"}
```

### インデックスに無いキーは取得できない

インデックスに無いキーがfilterに含まれる場合もエラーになります。

```bash
# personIndxに無いキーを使用
$ curl -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=personIndx' \
    -d '{"filter": {"EQ": {"msg": "Hello, world!"}}}'
{"errorCode":"ERR_STATE_QUERY","message":"failed query in state store statestore: JSON path \"msg\" is not indexed"}
```

### インデックスの形式が不正だとクラッシュ

componentの `queryIndexes` がJSONとして不正な場合、**Daprがデプロイ時にクラッシュします**！マニフェストにバリデーションチェックをかけておくのが安全そうです。

```
      > [nodeapp-98794b9fd-wszf6 daprd] time="2022-04-16T07:28:04.805367391Z" level=fatal msg="process component statestore error: redis store: error parsing query index schema: invalid character '{' after array element" app_id=nodeapp instance=nodeapp-98794b9fd-wszf6 scope=dapr.runtime type=log ver=1.7.0
 - deployment/nodeapp failed. Error: container daprd terminated with exit code 1.
```

# 使ってみる！

以上で準備は完了です！いよいよクエリを使ってみましょう。

まずはstateを作成します。`contentType` の指定をお忘れなく。

```bash
$ curl -X POST http://localhost:3500/v1.0/state/statestore?metadata.contentType=application/json -H "Content-Type: application/json" \
    -d '[{"key": "taro", "value": {"name": {"first": "Taro", "last": "Yamada"}, "age": 20}, "friends": ["hanako", "jiro"]}]'
$ curl -X POST http://localhost:3500/v1.0/state/statestore?metadata.contentType=application/json -H "Content-Type: application/json" \
    -d '[{"key": "jiro", "value": {"name": {"first": "Jiro", "last": "Yamada"}, "age": 18}, "friends": ["taro"]}]'
$ curl -X POST http://localhost:3500/v1.0/state/statestore?metadata.contentType=application/json -H "Content-Type: application/json" \
    -d '[{"key": "hanako", "value": {"name": {"first": "Hanako", "last": "Suzuki"}, "age": 21}, "friends": ["taro", "saburo"]}]'
```

続いて、クエリで取得します。`queryIndexName` には、フィルタで使用するキーを定義しているものを指定します。

```bash
# パスは `v1` ではなく `v1.0-alpha1` なので注意！
$ curl -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=personIndx' \
    -d '{"filter": {"EQ": {"name.last": "Yamada"}}}' | jq .
{
  "results": [
    {
      "key": "taro",
      "data": {
        "name": {
          "first": "Taro",
          "last": "Yamada"
        },
        "age": 20
      },
      "etag": "1"
    },
    {
      "key": "jiro",
      "data": {
        "age": 18,
        "name": {
          "first": "Jiro",
          "last": "Yamada"
        }
      },
      "etag": "1"
    }
  ]
}
```

想定通り名字が `Yamada` の人がすべて取得できました。

0件の場合は、**空配列ではなく `204` が返ります**。

```bash
$ curl -i -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metad
ata.queryIndexName=personIndx' \
    -d '{"filter": {"IN": {"age": [15, 16, 17]}}}'
HTTP/1.1 204 No Content
Server: fasthttp
Date: Sun, 17 Apr 2022 06:47:11 GMT
Traceparent: 00-5d848edbc15efc772a2498957e7712ec-2fa8d485f24172b0-00
```

 フィルタは `AND`, `OR` を使って組み合わせることも可能です。

https://docs.dapr.io/developing-applications/building-blocks/state-management/howto-state-query-api/#querying-the-state

また、リクエストに何もフィルタを指定しないと、全件取得ができます。

```bash
# ※インデックスは使わないが指定必須
$ curl -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=helloIndx' \
    -d '{}'
{"results":[{"key":"taro","data":{"name":{"first":"Taro","last":"Yamada"},"age":20},"etag":"1"},{"key":"jiro","data":{"age":18,"name":{"first":"Jiro","last":"Yamada"}},"etag":"1"},{"key":"hanako","data":{"name":{"last":"Suzuki","first":"Hanako"},"age":21},"etag":"1"},{"key":"hello2","data":{"msg":"Hello, world!"},"etag":"1"}]}
```

## ちょっと意地悪してみる

### 空のフィルタ

エラーが返ります。全件取得したい場合は、`filter` フィールド自体を未指定にする必要があります。

```bash
$ curl -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=helloIndx' \
    -d '{"filter": {}}'
{"errorCode":"ERR_MALFORMED_REQUEST","message":"failed deserializing HTTP body: unmarshalerDecoder: filter unit must have a single element, error found in #10 byte of ...|lter\": {}}|..., bigger context ...|{\"filter\": {}}|..."}
```

### ボディなし

エラーが返ります。空のボディ `{}` は必須です。

```bash
$ curl -X POST http://localhost:3500/v1.0-alpha1/state/statestore/query
{"errorCode":"ERR_MALFORMED_REQUEST","message":"failed deserializing HTTP body: Skip: do not know how to skip: 0, error found in #0 byte of ...||..., bigger context ...||..."}
```

### INに1要素しか指定しない

query state APIの構文エラーが発生します。要素数が動的な場合、1要素の場合 `EQ` を使うようにアプリケーション側で呼び分ける必要がありそうです。

```bash
$ curl -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=personIndx' \
    -d '{"filter": {"IN": {"name.first": ["Suzuki"]}}}'
{"errorCode":"ERR_STATE_QUERY","message":"failed query in state store statestore: too few values in IN operator for key \"name.first\"
```

### インデックスの型と違う型をfilterに指定

エラーはおこらず、単純に何も見つかりません。

```bash
$ curl -i -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=personIndx' \
    -d '{"filter": {"EQ": {"age": "hello"}}}'
HTTP/1.1 204 No Content
Server: fasthttp
Date: Sun, 17 Apr 2022 06:48:50 GMT
Traceparent: 00-7afb2fda9814a4f48a66203c8f8d1ea0-e1d0282d4d5ae321-00
```

### インジェクション

:innocent: 

```bash
# 名前のIN検索...と見せかけて年齢検索！
$ curl -X POST 'http://localhost:3500/v1.0-alpha1/state/statestore/query?metadata.queryIndexName=personIndx' \
    -d '{"filter": {"IN": {"name.first": ["dummy) ", " (@var0:[18, 18]"]}}}'
{"results":[{"key":"jiro","data":{"age":18,"friends":["taro"],"name":{"first":"Jiro","last":"Yamada"}},"etag":"4"}]}
```

（参考： [Daprが発行するクエリ文](https://github.com/dapr/components-contrib/blob/8ab1a862c16202b2d8be08ab5f7921d8b02cfba2/state/redis/redis_query.go#L107)）

**Daprの方針としてクエリのインジェクションは考慮しない**([参考](https://github.com/dapr/dapr/issues/3662#issuecomment-927984327))[^1]ので、[RediSearchのクエリで使われる特殊文字](https://redis.io/docs/stack/search/reference/query_syntax/) は、アプリ側でサニタイジングしてあげましょう。

# おわりに

以上、Redis stateでquery state APIを使ってみた紹介記事でした。以前MongoDB stateで使ってみたときに比べ、正直準備が大変でした... :dizzy_face: 
（Redisで本来できないことをできるようにしているので仕方がないですね...）

とはいえ、**stateの一覧取得は準備に見合う強力な機能**なので、これからガンガン使い倒していきたいと思います。
ここまでお読みいただきありがとうございました！

[^1]: 「クエリAPIを叩けるならそもそも任意のstateを参照可能なので、インジェクションだけ防いでも仕方がない」という考えのようです。それもそうですね...