Spring BootでDynamoDBのquery methodを使う


# 状況

```java:TweetRepository.java
package com.pontsuyo.anyiine.domain.repository;

import com.pontsuyo.anyiine.domain.model.Tweet;
import java.util.List;
import org.socialsignin.spring.data.dynamodb.repository.EnableScan;
import org.springframework.data.repository.CrudRepository;

@EnableScan
public interface TweetRepository extends CrudRepository<Tweet, Long> {
  @Override
  List<Tweet> findAll();

  List<Tweet> findAllByType(String type);  // <- これはダメという話をします
}
```
これはSpringアプリケーションのDBとしてDynamoDBを利用するために私が作成したRepositoryクラスです。 が、**これのせいでアプリケーションの起動に失敗します。**
そこで対処としてrepositoryクラスを別に自作した話をします。


### 環境情報
- OSX Catalina
- spring boot: 2.2.7 RELEASE
- spring-data-dynamodb: 5.0.3
- JAVA: 12


# まえおき
Springでは、DBへのアクセスはRepositoryの責務としますが、各種ライブラリが用意しているRepositoryのinterfaceを継承したクラスを作れば、メソッドを自分で用意しなくても事足りることが多いです。

DBとしてDynamoDBも選択する場合には[spring-data-dynamodb](https://derjust.github.io/spring-data-dynamodb/)が便利です。しかし、**query指定でのデータ取得（hash key, range key以外のフィールドでの絞り込みなど）は実装されていないようでした**。このため、
Springアプリケーションの起動時にエラーが吐かれ、起動に失敗します。

## 実際に出るエラー

アプリケーション起動時のエラーは以下でした。

```java:スタックトレースの下端
Caused by: java.lang.IllegalStateException: You have defined query method in the repository but you don't have any query lookup strategy defined. The infrastructure apparently does not support query methods!
	at org.springframework.data.repository.core.support.RepositoryFactorySupport$QueryExecutorMethodInterceptor.<init>(RepositoryFactorySupport.java:553) ~[spring-data-commons-2.2.7.RELEASE.jar:2.2.7.RELEASE]
	at org.springframework.data.repository.core.support.RepositoryFactorySupport.getRepository(RepositoryFactorySupport.java:332) ~[spring-data-commons-2.2.7.RELEASE.jar:2.2.7.RELEASE]
	at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.lambda$afterPropertiesSet$5(RepositoryFactoryBeanSupport.java:297) ~[spring-data-commons-2.2.7.RELEASE.jar:2.2.7.RELEASE]
	at org.springframework.data.util.Lazy.getNullable(Lazy.java:212) ~[spring-data-commons-2.2.7.RELEASE.jar:2.2.7.RELEASE]
	at org.springframework.data.util.Lazy.get(Lazy.java:94) ~[spring-data-commons-2.2.7.RELEASE.jar:2.2.7.RELEASE]
	at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.afterPropertiesSet(RepositoryFactoryBeanSupport.java:300) ~[spring-data-commons-2.2.7.RELEASE.jar:2.2.7.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1855) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1792) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]
	... 44 common frames omitted
```
よく見るとわかりますが、このエラーはspringが吐いていますね。
エラー文をざっくり訳すと、
「このrepositoryにqueryメソッドを定義してあるのに、query lookup strategy が何も定義されていない。infrastructureは明示的にqueryメソッドをサポートしていない！」
となります。

Spring謹製のライブラリで完結する場合はrepositoryの実装も当然Spring製であり、queryメソッドがサポートされていることを保証されているはずですが、今回利用しているspring-data-dynamodbはそのサポートが保証されていないことが原因と思われます。

保証されていないことが原因なので、解決方法としてまず思いつくのは、queryメソッド保証の設定をいじることですが、実はこのライブラリは本当にqueryメソッドをサポートしていないようです。（[本件に対応するGitHub issue](https://github.com/derjust/spring-data-dynamodb/issues/114)を確認すると、v5.0.3へのアップデートのTODOに一旦含めたあとで外された経緯が見えます。大変そうですね…。 ）

というわけで強引ではありますが、自分で実装する方針を取りました。（多分他にも対処法は存在します。）


# 対策
DynamoDBMapperを利用して自分でrepository相当のクラスを定義する。

AWSが提供しているDynamoDBMapperを利用して、DynamoDBのクエリ検索を再現するようなqueryメソッドを実装します。
AWSの公式ドキュメントで例と共に示されているので、参考にします。（このリンク先の実装ではrepository相当のクラスにmodelも実装されていますが、私はmodelを別クラスとして実装しています。）
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/DynamoDBMapper.QueryScanExample.html

最終的に出来上がったのはこちらです。（こういう場合のクラス名ってどういうのがいいのでしょう…）
DynamoDBでのデータは他所でTweetクラスとして定義しています。DBに何かしらのTweetの情報が集積していると思ってください。
今回はTweetクラスが**type**という名前のフィールドをもつことだけ把握していれば問題ありません。

```java:TweetRepositoryPlugin.java
package com.pontsuyo.anyiine.domain.repository;

import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBScanExpression;
import com.amazonaws.services.dynamodbv2.model.AttributeValue;
import com.pontsuyo.anyiine.domain.model.Tweet;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Repository;

/**
 * Dynamo DB へのアクセスに使用しているspring-data-dynamodbの中で
 * サポートされていないメソッド（query指定でのscanなど）の実装
 *
 * issue: https://github.com/derjust/spring-data-dynamodb/issues/114
 */
@Repository
public class TweetRepositoryPlugin {

  private final DynamoDBMapper mapper;

  public TweetRepositoryPlugin(DynamoDBMapper mapper) {
    this.mapper = mapper;
  }

  public List<Tweet> findAllByType(String type) {
    DynamoDBScanExpression scanExpression =
        new DynamoDBScanExpression()
            // "type"はDynamoDBの予約語のようなので、
            // クエリ文字列では一旦プレースホルダを入れておき、後で置き換える。
            .withFilterExpression("#t = :val1")
            .withExpressionAttributeNames(     
                Map.of("#t", "type")
            )
            .withExpressionAttributeValues(
                Map.of(":val1", new AttributeValue().withS(type))
            );

    return mapper.scan(Tweet.class, scanExpression);
  }
}
```

[withExpressionAttributeNamesについて](https://qiita.com/ponponpopon/items/9b723d6210a26d512a9f#%E6%B3%A8-withexpressionattributenames%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)


DynamoDBMapperクラスのインスタンスmapperをコンストラクタインジェクションしていますが、これは別に用意してあるConfigクラスに定義しBean登録しています。

```java:DynamoDBConfig.java
package com.pontsuyo.anyiine.config;

import com.amazonaws.auth.DefaultAWSCredentialsProviderChain;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMapper;
import org.socialsignin.spring.data.dynamodb.repository.config.EnableDynamoDBRepositories;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableDynamoDBRepositories(basePackages = "com.pontsuyo.anyiine.domain.repository")
public class DynamoDBConfig {

  /**
   * AWS DynamoDBの設定
   * @see com.amazonaws.auth.DefaultAWSCredentialsProviderChain
   * @return
   */
  @Bean
  public AmazonDynamoDB amazonDynamoDB() {
    return AmazonDynamoDBClientBuilder.standard()
        .withCredentials(DefaultAWSCredentialsProviderChain.getInstance())
        .withRegion(Regions.AP_NORTHEAST_1)
        .build();
  }

  @Bean
  public DynamoDBMapper dynamoDBMapper(){
    return new DynamoDBMapper(amazonDynamoDB());
  }
}

```

一応、今回定義したメソッドを呼び出すServiceクラスです。既に何かしらのrepositoryをインジェクションしていたはずなので、単純にインジェクションする要素を追加するだけです。

```java:TweetService.java
package com.pontsuyo.anyiine.domain.service;

import com.pontsuyo.anyiine.controller.model.DestroyRequestParameter;
import com.pontsuyo.anyiine.controller.model.UpdateRequestParameter;
import com.pontsuyo.anyiine.domain.model.Tweet;
import com.pontsuyo.anyiine.domain.repository.TweetRepository;
import com.pontsuyo.anyiine.domain.repository.TweetRepositoryPlugin;
import java.util.List;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import twitter4j.Status;
import twitter4j.Twitter;
import twitter4j.TwitterException;

@Slf4j
@Service
public class TweetService {

  private final TweetRepository tweetRepository;
  private final TweetRepositoryPlugin tweetRepositoryPlugin; // <- 今回追加したrepository

  private final Twitter twitter;

  public TweetService(TweetRepository tweetRepository, TweetRepositoryPlugin tweetRepositoryPlugin, Twitter twitter) {
    this.tweetRepository = tweetRepository;
    this.tweetRepositoryPlugin = tweetRepositoryPlugin;  // <- 今回追加したrepository
    this.twitter = twitter;
  }

  // 以下、メソッド定義色々。
```

# おわりに
以上になります。
[公式の解説](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/DynamoDBMapper.QueryScanExample.html)では様々なパターンのクエリでの実装方法を説明していくれています。この記事の説明で不十分だった場合は参考にしてください。

他の対処法をご存知の方がいらしたらお教え願います。



## 注: .withExpressionAttributeNames()について
コード中にコメントを含めていますが、**.withExpressionAttributeNames()**を使ってフィールド名を置き換えています。
このようにしている理由は、実は今回私が指定したかった検索クエリに含まれる**type**というフィールドがDynamoDBの予約語だったようで、当初以下のエラーが吐かれました。

```
Invalid UpdateExpression: Attribute name is a reserved keyword; reserved keyword: type
```
先のフィールド名の置換によりこのエラーを回避することができます。

参考：
https://note.kiriukun.com/entry/20190212-attribute-name-is-a-reserved-keyword-in-dynamodb