谷本 心 in せろ部屋

はてなダイアリーから引っ越してきました

Spring Data JDBCを拡張してみる その1 - クエリを受け取れるメソッドを増やす

ここまでSpring Dataの自作を行ってきましたが、Spring DataやSpring Data JDBCの知識がついてきたおかげで、Spring Data JDBCそのものを拡張することもできそうだなと分かってきました。

元々あるものは再実装せずにそのまま使ったほうが良いに決まってるので、Spring Data JDBCに乗っかった上で好きな機能を追加してみることにします。

TL;DR

  • いままでのエントリーをきちんと読んでなくても、今回から話が変わるのできっと読めると思うよ
  • Spring Data JDBCの拡張は難しくなかったよ
  • 作ったものはGitHubに置いてあるよ(リンク
  • 作る様子を動画でもライブ配信したよ

やりたいこと

先にゴールを明確にしておきます。

  • Spring Data JDBCに機能追加して query(String query, Object... args) メソッドを追加できること
  • Spring Data JDBCを使ったうえで @SqlFile アノテーションで指定したSQLファイルを読み込んで実行できること

今回は1つ目を実現するところまで説明します。

1. Spring Data JDBCの拡張ポイント

まずはSpring Data JDBCをどうやって拡張できるかという方向性の確認です。

入口の EnableJdbcRepositories に拡張ポイントがある

Spring Data JDBC@EnableJdbcRepositories アノテーションに、次のような定義があります。

public @interface EnableJdbcRepositories {
    Class<?> repositoryFactoryBeanClass() default JdbcRepositoryFactoryBean.class;
}

(in GitHub)

RepositoryFactoryを提供するクラスを指定できるもので、デフォルトでは JdbcRepositoryFactoryBean を利用するようになっています。RepositoryFactoryはRepositoryを作成する入口となるクラスですから、これを自作のものに置き換えればいくらでもカスタマイズできることが分かります。

RepositoryBaseを置き換えるだけならもっと簡単

ちなみに @EnableJdbcRepositories アノテーションにはもう一つカスタマイズしやすいポイントがあります。

public @interface EnableJdbcRepositories {
    Class<?> repositoryBaseClass() default DefaultRepositoryBaseClass.class;
}

(in GitHub)

RepositoryBaseのクラスです。Spring Data JDBCでは、RepositoryBaseは SimpleJdbcRepository が利用されますが、このアノテーションで指定することで、それを別のものに置き換えることが可能だということです。SimpleJdbcRepository を継承してメソッドを追加したものを提供することもできるということですね。

ただこの repositoryBaseClass だけを指定してもRepository自身のコンストラクタの引数を変えることはできませんし、前回実装した QueryLookupResolver を使った独自アノテーションを読み取るような処理もできず、カスタマイズできる範疇に限界があります。なので今回はこの部分は使わず、上で説明した repositoryFactoryBeanClass を使うことにします。

2. Spring Data JDBCの独自拡張を実装する

それでは実際にライブラリを作っていきましょう。

Spring Data JDBCに依存したモジュールを作成する

これまではSpring Dataを自作するために spring-data-commons というSpring Dataの共通ライブラリと spring-data-relational というRDBMS用の共通ライブラリと spring-jdbc というJDBCアクセスライブラリの3つに依存したモジュールを作成していました。

今回はそれとは別に spring-data-jdbc だけに依存したモジュールを作成します。pom.xml に入れる依存はこれだけです。

<groupId>ninja.cero.data.sql</groupId>
<artifactId>data-jdbc-ext</artifactId>

<dependencies>
    <dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-jdbc</artifactId>
        <version>3.0.0</version>
    </dependency>
</dependencies>

(in GitHub)

共通Repositoryインターフェイスを作る

まずは今回作るSpring Data JDBC拡張のRepositoryの共通インターフェイスとして JdbcExtRepository を作成します。

public interface JdbcExtRepository <T, ID> extends CrudRepository<T,ID>, PagingAndSortingRepository<T,ID>, QueryByExampleExecutor<T> {
    List<T> query(String query, Object... args);
}

(in GitHub)

前回のエントリーでも少し振れましたが、Spring Data JPAではこのように基底リポジトリインターフェイスを集約した JpaRepository というインターフェイスを提供しています。Spring Data JDBCではこのようなインターフェイスは提供していませんが、あった方が便利だし拡張もしやすいので、まずはこれを作りました。

そして独自拡張となる query メソッドを定義して、Spring Data JDBCの基本機能に加えて query メソッドを使えるように定義しておきます。

Repositoryの実装クラスを作る

続いて、この共通インターフェイスの実装クラスを作成します。実装クラスは spring-data-jdbc が提供する SimpleJdbcRepository を継承したうえで、先ほど作成した JdbcExtRepository の実装として提供します。

public class JdbcExtDefaultRepository<T, ID> extends SimpleJdbcRepository<T, ID> implements JdbcExtRepository<T, ID> {
    public List<T> query(String query, Object... args) {
        return null;
    }
}

(in GitHub)

この query メソッドの中身をきちんと実装すれば、アプリケーション側のRepositoryを作成する際に JdbcExtRepository インターフェイスを継承するだけで、この query メソッドが使えるようになるという仕組みです。早速、実装してみましょう。

public class JdbcExtDefaultRepository<T, ID> extends SimpleJdbcRepository<T, ID> implements JdbcExtRepository<T, ID> {
    JdbcOperations jdbcOperations;

    EntityRowMapper<T> entityRowMapper;

    public JdbcExtDefaultRepository(JdbcOperations jdbcOperations, JdbcAggregateOperations entityOperations, RelationalPersistentEntity<T> entity, JdbcConverter converter) {
        super(entityOperations, entity, converter);
        this.jdbcOperations = jdbcOperations;
        this.entityRowMapper = new EntityRowMapper<>(entity, converter);
    }

    @Override
    public List<T> query(String query, Object... args) {
        return jdbcOperations.query(query, entityRowMapper, args);
    }
}

(in GitHub)

3分間クッキングばりに一気にできあがりました。中核となる部分は query メソッドの実装ですね。

return jdbcOperations.query(query, entityRowMapper, args);

この処理を実行するために必要となるインスタンスたちをコンストラクタで受け取るようにします。具体的には次のものになります。

  • JdbcOperationsquery メソッドを呼び出すための JdbcOperations
  • EntityRowMapper を作るための RelationalPersistentEntityJdbcConverter

JdbcOperations は実際には JdbcTemplate なのですが、Spring Data JDBCでは JdbcTemplateNamedParameterJdbcTemplate もいずれもインターフェイスである JdbcOprationsNamedParameterOperations のまま扱っているため、それに合わせるようにしました。

また今回の継承元にした SimpleJdbcRepository のコンストラクタ引数は JdbcAggregateOperations RelationalPersistentEntity JdbcConverter の3つでしたが、それに加えて JdbcOperations が必要となるため、コンストラクタに追加した形です。

ちなみに前回までの独自実装では RecordMapper という自作の RowMapper を利用していましたが、ここでは spring-data-jdbc が提供する EntityRowMapper を利用するようにしました。こういうクラスを利用できるのも、独自Spring Dataを作らずに、Spring Data JDBCの拡張として作るメリットの一つですね。

どうあれこれでベースとなるRepositoryクラスはできあがりました。

Repositoryを提供するRepositoryFactoryを作る

続いて、いま作成した JdbcExtDefaultRepositoryインスタンスを生成するためのFactoryクラスを作成します。これも spring-data-jdbcJdbcRepositoryFactory クラスを継承して作成します。

public class JdbcExtRepositoryFactory extends JdbcRepositoryFactory {
    @Override
    protected Object getTargetRepository(RepositoryInformation repositoryInformation) {
        return null;
    }

    @Override
    protected Class<?> getRepositoryBaseClass(RepositoryMetadata repositoryMetadata) {
        return JdbcExtDefaultRepository.class;
    }
}

(in GitHub)

まず一つ目のポイントが getRepositoryBaseClass でいま作成した JdbcExtDefaultRepository.class を返すことです。継承元である JdbcRepositoryFactory ではここで SimpleJdbcRepository.class を返しており、要するにRepositoryの実体クラスを指定するわけです。この getRepositoryBaseClass を書き換えることでその実体クラスを変更できるということです。

ちなみに冒頭の方で @EnableJdbcRepositories アノテーションrepositoryBaseClass を指定すれば基底クラスを変更できると書きましたが、そちらで指定されていた場合は、ここの getRepositoryBaseClass で返すクラスよりも優先して使われるようです。

RepositoryFactoryの getTargetRepository を実装する

続いて getTargetRepository を実装しましょう。

@Override
protected Object getTargetRepository(RepositoryInformation repositoryInformation) {
    JdbcAggregateTemplate template = new JdbcAggregateTemplate(publisher, context, converter, accessStrategy);

    if (entityCallbacks != null) {
        template.setEntityCallbacks(entityCallbacks);
    }

    RelationalPersistentEntity<?> persistentEntity = context
            .getRequiredPersistentEntity(repositoryInformation.getDomainType());

    return getTargetRepositoryViaReflection(repositoryInformation, jdbcOperations, template, persistentEntity,
            converter);
}

(in GitHub)

ここは継承元の JdbcRepositoryFactory の実装とほぼ同じなのですが、最後の getTargetRepositoryViaReflection のところで引数に jdbcOperations を増やしています。ここで指定することで JdbcExtDefaultRepository のコンストラクタに JdbcOperationsインスタンスを渡すことができるのです。

RepositoryFactoryのコンストラクタ群を実装する

ちなみにここまでで説明していませんでしたが JdbcExtRepositoryFactory のフィールドやコンストラクタは次のように実装しています。

public class JdbcExtRepositoryFactory extends JdbcRepositoryFactory {
    private final RelationalMappingContext context;
    private final JdbcConverter converter;
    private final ApplicationEventPublisher publisher;
    private final DataAccessStrategy accessStrategy;

    private final NamedParameterJdbcOperations operations;
    private EntityCallbacks entityCallbacks;

    public JdbcExtRepositoryFactory(DataAccessStrategy dataAccessStrategy, RelationalMappingContext context, JdbcConverter converter, Dialect dialect, ApplicationEventPublisher publisher, NamedParameterJdbcOperations operations) {
        super(dataAccessStrategy, context, converter, dialect, publisher, operations);

        this.publisher = publisher;
        this.context = context;
        this.converter = converter;
        this.accessStrategy = dataAccessStrategy;
        this.operations = operations;
    }
}

(in GitHub)

継承元である JdbcRepositoryFactory の各フィールドが protected になっていればそれをそのまま使いたかったんですが、残念ながらすべて private であるために、このクラスでもフィールドとして保持するようにしています。しかも JdbcRepositoryFactory はイミュータブルでも何でもなく、後からフィールドが書き換えられる可能性があるので、その辺りの対応のためにセッターを書いたりする必要もありました。

@Override
public void setEntityCallbacks(EntityCallbacks entityCallbacks) {
    this.entityCallbacks = entityCallbacks;
    super.setEntityCallbacks(entityCallbacks);
}

(in GitHub)

もうちょいSpring Data JDBC自身が拡張しやすい形になってくれていれば嬉しかったのですけどね。

どうあれこれで、Factoryも作成できました。

RepositoryFactoryを提供するRepositoryFactoryBeanを作る

続いて、いま作成した JdbcExtRepositoryFactoryインスタンスを生成するためのFactoryBeanクラスを作成します。これは spring-data-jdbcJdbcRepositoryFactoryBean クラスを参考にしながら・・・全コピペで作成しました。

コピペした理由は後で説明するとして、コピペしたうえで修正した部分は次の箇所のみです。

public class JdbcExtRepositoryFactoryBean<T extends Repository<S, ID>, S, ID extends Serializable>
        extends TransactionalRepositoryFactoryBeanSupport<T, S, ID> implements ApplicationEventPublisherAware {
    @Override
    protected RepositoryFactorySupport doCreateRepositoryFactory() {
        JdbcRepositoryFactory jdbcRepositoryFactory = new JdbcExtRepositoryFactory(dataAccessStrategy,
                mappingContext, converter, dialect, publisher, operations);
        jdbcRepositoryFactory.setQueryMappingConfiguration(queryMappingConfiguration);
        jdbcRepositoryFactory.setEntityCallbacks(entityCallbacks);
        jdbcRepositoryFactory.setBeanFactory(beanFactory);

        return jdbcRepositoryFactory;
    }
}

(in GitHub)

具体的には new JdbcRepositoryFactorynew JdbcExtRepositoryFactory にしただけです。

たったそれだけのためにクラスの全コードをコピペしなければならなかった理由は、上で説明した JdbcRepositoryFactory と同じで、すべてのフィールドが private かつミュータブルなので、いつどのように値が書き換えられるか分からないためです。@EnableJdbcRepositories アノテーションでカスタマイズ可能な部分なんだから、もうちょいカスタマイズしやすい形にしてくれよという気持ちになります。

さすがにコピペしてしまうとメンテナンス性に疑問もありますから、リフレクションでprivateフィールドにアクセスしてしまった方が良いのかな、いやでもそんなことするとGraalVMでAOTコンパイルする時にまた面倒なことになるよな、なんて過剰に将来性を考えたりしつつ、とりあえずは全コピペする方向にしました。

全フィールドをprotectedにするとか、FactoryBeanだけでなくFactoryをカスタマイズ可能にする、みたいなことをSpring Data JDBCの開発者にお願いしたら通るもんなんですかね。もうちょっと真面目に開発・メンテナンスするつもりになったら、issueでも立てて相談してみます。

どうあれほぼほぼコピペですが、JdbcExtRepositoryFactoryBean まで作成できました。これで作るものは完成です。

3. サンプルアプリケーションから動かしてみる

それでは、ここまでで作った箇所をサンプルアプリケーションから利用してみます。

pom.xmlの変更

まずはpom.xmlで今回作成した data-jdbc-ext モジュールに依存するようにします。

<dependency>
    <groupId>ninja.cero.data.sql</groupId>
    <artifactId>data-jdbc-ext</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

(in GitHub)

アプリケーション起動クラスの変更

アプリケーションの起動クラスにSpring Data JDBC標準の @EnableJdbcRepositories を付けたうえで今回作成した JdbcExtRepositoryFactoryBean を指定します。

@SpringBootApplication
@EnableJdbcRepositories(repositoryFactoryBeanClass = JdbcExtRepositoryFactoryBean.class)
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

(in GitHub)

これでSpring Data JDBCの元々のFactoryBeanである JdbcRepositoryFactoryBean の代わりに自作の JdbcExtRepositoryFactoryBean が使われるようになります。

Repositoryクラスの変更

リポジトリではインターフェイスに今回作成した JdbcExtRepository を指定します。

@Repository
public interface EmpRepository extends JdbcExtRepository<Emp, Long> {
}

(in GitHub)

これでSpring Dataが提供するメソッド群に加えて JdbcExtRepository が提供する query メソッドが使えるようになります。

Controllerクラスの変更

Controllerでは3つのエンドポイントを作りました。Spring Dataが標準で提供する findAllfindById を利用するものと、JdbcExtRepository が提供する query を利用するものです。

@RestController
public class EmpController {
    EmpRepository empRepository;

    @GetMapping("/")
    Iterable<Emp> findAll() {
        return empRepository.findAll();
    }

    @GetMapping("/{id}")
    Optional<Emp> findById(@PathVariable Long id) {
        return empRepository.findById(id);
    }

    @GetMapping("/search/{name}")
    List<Emp> search(@PathVariable String name) {
        return empRepository.query("select * from emp where name like '%' || ? || '%'", name);
    }
}

(in GitHub)

この3つのエンドポイントがすべて機能すれば、Spring Data JDBCの標準機能と、今回追加した独自拡張の両方がきちんと動いているということになります。

起動して試してみる

いざ、起動して試してみましょう!

% curl localhost:8080/
[{"id":1,"name":"Nakamoto"},{"id":2,"name":"Kikuchi"},{"id":3,"name":"Mizuno"},{"id":4,"name":"Sayashi"},{"id":5,"name":"Fujihira"},{"id":6,"name":"Okazaki"}]
% curl localhost:8080/2
{"id":2,"name":"Kikuchi"}
% curl localhost:8080/search/ka
[{"id":1,"name":"Nakamoto"},{"id":6,"name":"Okazaki"}]

全件検索、IDによる検索、そして「ka」という文字列を含んだ検索のすべてが正常に動作しました!!🎊🎊🎊🎉🎉🎉🥳🥳🥳🎉🎉🎉🎊🎊🎊

そりゃまぁ動くだろうと思って作ってはいますが、実際に動いてみると嬉しいもんですね。

まとめ

今回作ったものを振り返ると、次のようになります。

たったこれだけですので、いったん分かってしまえば独自Spring Data JDBC拡張するのはさほど難しくないと言えると思います。

今回作ったコードはすべてGitHubに置いています。

github.com

また今回は実装している様子をYoutubeライブ配信しました。

www.youtube.com

配信の動画を後から見て楽しいものだとは思わないのですが、僕がどんなことを考えながら実装しているかが分かると思いますし、一緒にコードを読みながら勉強してもらっても良いと思いますので、今後もちょくちょくライブ配信してみようかなと思います。

そんなわけで、好評価・チャンネル登録、よろしくお願いします!(お願いしません)

独自Spring Dataを作ってみる その3 - SQLファイルを読んで実行する処理を作る

前回のエントリーで、findAllは無事に実行できたので、次は独自拡張として任意のSQLを実行することを目指します。

やりたいこと

先にゴールを明確にしておきます。

具体的には次のような実装ができるようになるイメージです。

@Repository
public interface EmpRepository extends SqlRepository<Emp, Long> {
    @SqlFile("/sql/selectOdd.sql")
    List<Emp> selectOdd();

    @Query("select * from emp where id in (2, 4, 6)")
    List<Emp> selectEven();
}

いったんクエリに渡す引数については考えないことにします。アノテーションを読んで処理する仕組みさえできれば、あとはどうとでもなると思いますので。

TL;DR

例によって長文ですので、先にまとめておきますね。

  • もちろん作ろうと思ってたものは作れたし、余裕があったから、もう1機能つくったよ
  • SqlFileアノテーションと、Queryアノテーションと、さらに任意のクエリを実行できるようになった独自Spring DataとサンプルアプリケーションをGitHubに置いといたよ(リンク
  • RepositoryQueryQueryLookupStrategy を作成すれば、メソッドに対するクエリを自分で作れるようになるよ
  • 共通のRepositoryインターフェイスと、それに対する DefaultRepository にメソッドを増やせば、好みの共通メソッドをどんどん増やせるよ
  • 今回のエントリーは前回よりも短いよ

1. Spring Data JDBCの実装を読む

今回もまずは spring-data-jdbc のコードリーディングです。実はSpring Data JDBCにも @Query アノテーションで指定したクエリをそのまま実行する機能があります。なので、その周辺のコードから読んでいけばよさそうです。

Queryアノテーションを利用している所を追う

Queryアノテーションspring-data-jdbc が提供しています。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@QueryAnnotation
@Documented
public @interface Query {
    String value() default "";
    // (略)
}

(in GitHub)

IntelliJでこのクラスのFind Usageすると、JdbcQueryMethod というクラスで利用されていることが分かりました。

public boolean hasAnnotatedQueryName() {
    return lookupQueryAnnotation() //
            .map(Query::name) //
            .map(StringUtils::hasText).orElse(false);
}

(in GitHub)

さらにこの呼び出し元をたどると JdbcQueryLookupStrategy (implements QueryLookupStrategy) というクラスの resolveQuery メソッドから呼ばれていることが分かりました。

JdbcQueryMethod#hasAnnotatedQueryNameの呼び出し階層

abstract class JdbcQueryLookupStrategy implements QueryLookupStrategy {
    // (略)
    static class DeclaredQueryLookupStrategy extends JdbcQueryLookupStrategy {
        // 略
        @Override
        public RepositoryQuery resolveQuery(Method method, RepositoryMetadata repositoryMetadata,
                ProjectionFactory projectionFactory, NamedQueries namedQueries) {

            JdbcQueryMethod queryMethod = getJdbcQueryMethod(method, repositoryMetadata, projectionFactory, namedQueries);

            if (namedQueries.hasQuery(queryMethod.getNamedQueryName()) || queryMethod.hasAnnotatedQuery()) {

                if (queryMethod.hasAnnotatedQuery() && queryMethod.hasAnnotatedQueryName()) {
                    LOG.warn(String.format(
                            "Query method %s is annotated with both, a query and a query name; Using the declared query", method));
                }

                StringBasedJdbcQuery query = new StringBasedJdbcQuery(queryMethod, getOperations(), this::createMapper,
                        getConverter(), evaluationContextProvider);
                query.setBeanFactory(getBeanFactory());
                return query;
            }

            throw new IllegalStateException(
                    String.format("Did neither find a NamedQuery nor an annotated query for method %s", method));
        }

(in GitHub)

何やら見た感じ、アノテーションの値を利用して StringBasedJdbcQuery というクエリを作っている感じですね。

また resolveQuery メソッドの第一引数として Method が渡されているあたり、Repositoryに追加したメソッドが呼ばれたときにこのメソッドが呼ばれて、このメソッドに対応するクエリを返すような処理であることが何となく分かります。

さらに、この resolveQuery メソッドは spring-data-commonsQueryExecutorMethodInterceptor というInterceptorから呼ばれています。

QueryLookupStrategy#resolveQueryの呼び出し階層

   private Pair<Method, RepositoryQuery> lookupQuery(Method method, RepositoryInformation information,
            QueryLookupStrategy strategy, ProjectionFactory projectionFactory) {
        try {
            return Pair.of(method, strategy.resolveQuery(method, information, projectionFactory, namedQueries));
        } catch (QueryCreationException e) {
            throw e;
        } catch (RuntimeException e) {
            throw QueryCreationException.create(e.getMessage(), e, information.getRepositoryInterface(), method);
        }
    }

(in GitHub)

Spring Dataのように interface だけ定義して動くようなフレームワークでは、Interceptor を利用して処理を行わせることは王道中の王道ですね。

この一連の流れを見る限り、Repositoryに実装したメソッドの実行時に発行すべきクエリを決めるために QueryLookupStrategyresolveQuery メソッドが呼ばれると考えて差し支えなさそうです。

RepositoryQueryって何ぞや?

QueryLookupStrategyresolveQuery の処理をよく見ていきます。

まず戻り値の RepositoryQuery についてですが、これは「Repositoryで定義するクエリ」を扱うクラスで、まさに今回のRepositoryに定義した @Query アノテーションのついたメソッドはそれに該当します。むしろRepository以外のどこで定義するんだよと思って調べたところ、Spring Data JPAには NamedQuery という仕組みがあり、それはRepositoryではなくEntity側で定義するようです。

たとえば、こんな感じで定義するそうです。

@Entity
@NamedQuery(name = "myFindByAge", query = "select u from User u where u.age = ?1")
public class User {
  //...
}

さすがにこういう感じのEntity側で行う定義はNo thank youなので RepositoryQuery のことだけを考えることにします。

ちなみに RepositoryQuery インタフェースは次のように定義されています。

public interface RepositoryQuery {
    @Nullable
    Object execute(Object[] parameters);

    QueryMethod getQueryMethod();
}

(in GitHub)

えらくシンプルですね。execute が実際にクエリを実行する処理であろうことが推測できます。

StringBasedJdbcQueryを読、、、まず

resolveQuery メソッドでは RepositoryQuery の実装として StringBasedJdbcQueryインスタンスを生成して返していました。

StringBasedJdbcQuery query = new StringBasedJdbcQuery(queryMethod, getOperations(), this::createMapper,
        getConverter(), evaluationContextProvider);

(in GitHub)

名前からして明らかに文字列を使ったクエリという感じです。

これは間違いなく参考になるだろうと思って少し実装を眺めたところ、それなりに色々な処理をしていたので、よくわからんなと思って閉じました。そういうこともありますよ、ハハッ。

とりあえずクエリを発行したいだけであれば RepositoryQuery の実装はさほど難しくなさそうなので、後ほど自力で実装したいと思います。

QueryLookupStrategyのインスタンスはどこで作られるのか?

一方で JdbcQueryLookupStrategy (implements QueryLookupStrategy)インスタンスはどこで作られているのでしょうか。

これまでの経験で、なんとなく JdbcRepositoryFactoryJdbcRepositoryFactoryBean か、あるいは AbstractJdbcConfiguration だろうなという予想はつきますが、JdbcQueryLookupStrategy のコンストラクタの呼び出し階層を辿っていったところ JdbcRepositoryFactory で行っていることが分かりました。

JdbcQueryLookupStrategyのコンストラクタの呼び出し階層

public class JdbcRepositoryFactory extends RepositoryFactorySupport {
    // (略)
    @Override
    protected Optional<QueryLookupStrategy> getQueryLookupStrategy(@Nullable QueryLookupStrategy.Key key,
            QueryMethodEvaluationContextProvider evaluationContextProvider) {

        return Optional.of(JdbcQueryLookupStrategy.create(key, publisher, entityCallbacks, context, converter, dialect,
                queryMappingConfiguration, operations, beanFactory, evaluationContextProvider));
    }
    // (略)

(in GitHub)

このメソッドでインスタンスを作成して返せば、あとは RepositoryFactorySupport がよしなにやってくれそうな感じがしますね。

となると、作る必要がある主たるクラスは QueryLookupStrategyRepositoryQuery の2つだけで済みそうです。

2. おもむろに実装してみる

それでは、サクッと実装してみましょう。

独自アノテーションの作成

まずはSQLファイル名やクエリを指定するためのアノテーションを作成します。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface SqlFile {
    String value() default "";
}

(in GitHub)

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface Query {
    String value() default "";
}

(in GitHub)

これらをアプリケーション側で使ってもらう想定です。

RepositoryQuery の実装

続いて RepositoryQuery の実装クラスとして StringQuery を実装します。

public class StringQuery implements RepositoryQuery {
    private JdbcTemplate jdbcTemplate;
    private String query;

    private RecordMapper<?> recordMapper;

    private QueryMethod queryMethod;

    public StringQuery(JdbcTemplate jdbcTemplate, String query, RecordMapper<?> recordMapper, QueryMethod queryMethod) {
        this.jdbcTemplate = jdbcTemplate;
        this.query = query;
        this.recordMapper = recordMapper;
        this.queryMethod = queryMethod;
    }

    @Override
    public Object execute(Object[] parameters) {
        return jdbcTemplate.query(query, recordMapper);
    }

    @Override
    public QueryMethod getQueryMethod() {
        return queryMethod;
    }
}

(in GitHub)

execute メソッドで JdbcTemplate#query を実行するためだけのクラスです。

クエリを実行するために必要なものは、SQL文字列と JdbcTemplate と、結果をエンティティにマッピングするための RecordMapper (RowMapper) の3つです。これらを外からコンストラクタ経由で受け取るようにします。冒頭にも書きましたが、いったんパラメータを渡さない前提で作るので execute メソッドの引数である Object[] parameters は無視します。

また getQueryMethod の実装のために QueryMethodインスタンスを返す必要があるので、これもコンストラクタで受け取るようにしました。

QueryLookupStrategy の実装

さらに、StringQuery を生成する QueryLookupStrategy も実装します。ちなみに QueryLookupStrategy インターフェイスの定義は次のようになっています。

public interface QueryLookupStrategy {
    // (略)
    RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory,
            NamedQueries namedQueries);
}

(in GitHub)

この1メソッドを実装するだけで良いようです。実際に実装してみましょう。

public class SqlQueryLookupStrategy implements QueryLookupStrategy {
    private final JdbcTemplate jdbcTemplate;

    public SqlQueryLookupStrategy(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, NamedQueries namedQueries) {
        QueryMethod queryMethod = new QueryMethod(method, metadata, factory);
        RecordMapper<?> recordMapper = new RecordMapper<>(metadata.getDomainType());

        Query queryAnnotation = method.getAnnotation(Query.class);
        if (queryAnnotation != null) {
            return new StringQuery(jdbcTemplate, queryAnnotation.value(), recordMapper, queryMethod);
        }

        SqlFile sqlFileAnnotation = method.getAnnotation(SqlFile.class);
        if (sqlFileAnnotation != null) {
            return new StringQuery(jdbcTemplate, queryByFile(sqlFileAnnotation), recordMapper, queryMethod);
        }

        throw new RuntimeException("Query cannot be resolved: " + method.getName());
    }

    private String queryByFile(SqlFile sqlFileAnnotation) {
        String fileName = sqlFileAnnotation.value();
        URL resource = getClass().getResource(fileName);

        if (resource == null) {
            throw new RuntimeException("SQL file cannot be read: " + fileName);
        }

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.openStream()))) {
            return reader.lines().collect(Collectors.joining());
        } catch (IOException e) {
            throw new UncheckedIOException("SQL file cannot be read: " + fileName, e);
        }
    }
}

(in GitHub)

これまで書いたコードの中で一番長いのですが、大した処理はしていません。

実行しようとしたRepositoryのメソッドに Query アノテーションがついていれば、そのアノテーションの文字列をそのままクエリにして、SqlFile アノテーションがついていれば、そのファイルを読み込んだものをクエリにする、という処理です。ゆくゆくはこの辺りにテンプレートエンジンを適用する機能も盛り込みたいところですね。

ここで StringQueryインスタンスを生成するために JdbcTemplateインスタンスが必要となるため、コンストラクタで受け取るようにしています。

さて、ここまでで StringQuery (implements RepositoryQuery)SqlQueryLookupStrategy (implements QueryLookupStrategy) という主要な登場人物の実装クラスを作成したので、あとは SqlRepositoryFactorySqlQueryLookupStrategyインスタンスを生成するように実装すれば良いはずです。

SqlRepositoryFactory クラスで RepositoryFactorySupport インターフェイスgetQueryLookupStrategy メソッドをオーバーライドして実装します。

    @Override
    protected Optional<QueryLookupStrategy> getQueryLookupStrategy(QueryLookupStrategy.Key key, QueryMethodEvaluationContextProvider evaluationContextProvider) {
        return Optional.of(new SqlQueryLookupStrategy(jdbcTemplate));
    }

(in GitHub)

コンストラクタで渡したかった JdbcTemplate は前回の実装で SqlRepositoryFactory クラスのインスタンス変数として保持するようにしていたので、それをそのまま渡しています。

これで独自Spring Data側の実装はおしまいです。

3. サンプルアプリケーション側も実装する

それでは、サンプルアプリケーション側からこの追加した機能を使えるよう、処理を追加します。

EmpRepositoryにメソッドを追加

まずは EmpRepository に2つのメソッドを追加します。

@Repository
public interface EmpRepository extends CrudRepository<Emp, Long> {
    @SqlFile("/sql/selectOdd.sql")
    List<Emp> selectOdd();

    @Query("select * from emp where id in (2, 4, 6)")
    List<Emp> selectEven();
}

(in GitHub)

@SqlFile アノテーションをつけてSQLファイル名を指定した selectOdd メソッドと、@Query アノテーションをつけてクエリを指定した selectEven メソッドです。

src/resources/sql/selectOdd.sql にはこんなクエリを書いています。

select * from emp where id in (1, 3, 5)

(in GitHub)

ちなみに id % 2 みたいな書き方をしたらSQLでエラーが起きました。剰余はRDBMSごとに方言が違ってて難しいですね。

EmpControllerにメソッドを追加

EmpRepository に追加したメソッドを呼び出すよう EmpController にもメソッドを追加します。

    @GetMapping("/odd")
    List<Emp> odd() {
        return empRepository.selectOdd();
    }

    @GetMapping("/even")
    List<Emp> even() {
        return empRepository.selectEven();
    }

(in GitHub)

/odd /even というエンドポイントから、それぞれ selectOdd selectEven メソッドを呼ぶような形です。

実行してみよう

それではサンプルアプリケーションを起動した後、それぞれのエンドポイントを叩いてみます。

% curl localhost:8080/odd      
[{"id":1,"name":"Nakamoto"},{"id":3,"name":"Mizuno"},{"id":5,"name":"Fujihira"}]

きちんとidが奇数のデータのみ取れていますね!

% curl localhost:8080/even
[{"id":2,"name":"Kikuchi"},{"id":4,"name":"Sayashi"},{"id":6,"name":"Okazaki"}]

こちらはidが偶数のデータのみ取れています!

完全に期待通りの結果が返ってきました。目的を果たすためだけの手抜き実装とは言え、思った以上にあっさりと動いてしまいました。

4. 引数でクエリを指定する

思ったよりも簡単だったため、もう一つやってみます。

ここまでの実装で、SQLファイルやアノテーションでクエリを指定することができるようになりました。この部分で何かしらのテンプレートエンジンを適用するようにすれば、動的にクエリを書き換えることもできるようになるでしょう。

ただ、より複雑なクエリを構築したい場合にはRepositoryの外側でSQLの文字列を構築し、それをRepositoryに渡したいということもあるでしょう。次はそのようなパターンを考えてみます。

どうやって引数でクエリを受け取るか?

クエリを受け取るには、たとえばこんな実装が想定できます。

@Repository
public interface EmpRepository extends SqlRepository<Emp, Long> {
    List<Emp> select(String query, Object... args);
}

ただしこのままでは何が引数で、何がパラメータかをフレームワーク側では判断できないため、アノテーションをつけるのが良いかも知れません。

@Repository
public interface EmpRepository extends SqlRepository<Emp, Long> {
    List<Emp> select(@Query String query, @Parameters Object... args);
}

こうすれば、何となくできそうな感じがします。ただ、こんなアノテーションを毎回つけることが分かりやすいかというと微妙ですね。

どうしたもんかなと考えていた時に、ふと思いつきました。このようなパターンが固定されそうなメソッドは、ベースにしてるRepository側、つまり DefaultRepository にメソッドを追加してしまえば良いのではないかと。

その方向で実装を進めてみます。

典型的な処理をBaseRepository側で提供する

これまでは CrudRepository などを使っていたのですが、リポジトリの共通インターフェイスとなる SqlRepository を作りました。

public interface SqlRepository<T, ID> extends ListCrudRepository<T,ID>, ListPagingAndSortingRepository<T,ID>, QueryByExampleExecutor<T> {
}

(in GitHub)

Spring Data JDBCではこのような集約したインターフェイスは提供されていないのですが、Spring Data JPAでは JpaRepository インターフェイスとして同じような実装が提供されています。

そして、ここに query メソッドを追加します。

public interface SqlRepository<T, ID> extends ListCrudRepository<T,ID>, ListPagingAndSortingRepository<T,ID>, QueryByExampleExecutor<T> {
    List<T> query(String query, Object... args);
}

(in GitHub)

また DefaultRepository も同じインターフェイスを利用して、 query メソッドの実装を提供します。

public class DefaultRepository<T, ID> implements SqlRepository<T, ID> {
    // 略
    @Override
    public List<T> query(String query, Object... args) {
        return jdbcTemplate.query(query, recordMapper, args);
    }

(in GitHub)

ただただ JdbcTempalte に処理を流すだけの簡単なものです。

これだけで、SqlRepositoryquery メソッドが使えるようになります。

サンプルアプリケーション側にも適用する

サンプルアプリケーションの EmpRepository も同じインターフェイスを指定します。

public interface EmpRepository extends SqlRepository<Emp, Long> {

(in GitHub)

ただこれだけです。特に実装を追加する必要はありません。

そして、SqlRepository インターフェイスに追加した query メソッドを利用するためのエンドポイントを EmpController に追加します。

@GetMapping("/search/{name}")
List<Emp> search(@PathVariable String name) {
    return empRepository.query("select * from emp where name like '%' || ? || '%'", name);
}

(in GitHub)

中間一致のlike検索をすることにしました。ここでは固定の文字列としてクエリを渡していますが「Repositoryの外側で組み立てた文字列を渡すことができる」というところがポイントですね。

これでアプリケーションを起動して実行してみます。

% curl localhost:8080/search/ka
[{"id":1,"name":"Nakamoto"},{"id":6,"name":"Okazaki"}]

完全に期待通り name に「ka」という文字列を含む結果が返ってきました。

欲しいメソッドをインターフェイスに定義して、DefaultRepository で実装すればいくらでも拡張できるということです。

Bootful SQL Tempalteを作成した時は、引数やパラメータをうまく渡せるようなメソッドをたくさん用意していましたが、それを同じような、いわゆるシンタックスシュガー的なメソッドをたくさん作成していけば、Spring Dataでは物足りないなと感じていた部分を補えるということですね。

まとめ

今回の話をまとめます

  • メソッドに対応したクエリを自作したい場合
    1. RepositoryQuery インターフェイスの実装として、任意のクエリを実行するようにする
    2. QueryLookupStrategy インターフェイスを実装して、メソッドに対応した 1. の RepositoryQuery の実装を返す
    3. RepositoryFactory (extends RepositoryFactorySupport)getQueryLookupStrategy にて 2. で作成した QueryLookupStrategy の実装を返す
  • 独自の共通メソッドを作成したい場合
    1. CrudRepository などを継承した共通の Repository インターフェイスを定義する
      1. で作成したインターフェイスにメソッドを追加する
    2. デフォルトRepositoryクラスを 1. で作成したインターフェイスの実装として作成し、追加したメソッドを実装する
    3. アプリケーション側のRepositoryも 1. で作成したインターフェイスを継承して作る

この2つの方法によって、自作のSpring Dataに機能を追加することができるようになります。前者の方法は主にメソッド名やアノテーションなどを使ったクエリのルールを作成でき、後者の方法は共通のメソッドを作成できます。たとえば後者の方法を用いて、Bootiful SQL Templateの全機能を提供することだってできるでしょう。

ここまでの実装はGitHubに置いています。

github.com

さて、次はどうしようかなと考えているときに気づいたのですが、わざわざSpring Dataを自作しなくとも、Spring Data JDBCに対して上に書いた2つの方法を使って拡張することができるのではないでしょうか。ここまでSpring Data JDBCのコードを読んできて、どうすれば拡張ができるのかが大体分かるようになってきました。また逆に、自作Spring Dataの DefaultRepository を実装するために、延々とSpring Data JDBCから処理をコピーしてくるというのも大変なことです。

そこで次回はいったん自作Spring Dataという方向を止め、Spring Data JDBCを拡張するという方向に進んでみます。

独自Spring Dataを作ってみる その2 - findAllを作る

前回のエントリーで自作Spring Dataの扉を開けましたので、今回は実際に全件検索、findAllが動くところまで進めてみます。

TL;DR

このエントリーは長文すぎて誰も読まないと思うので、先にまとめておきます。

  • findAllだけ動く独自Spring DataとサンプルアプリケーションをGitHubに置いといたよ(リンク
  • spring-data-relationalSqlRenderer StatementBuilder Select Table Column クラスなどを利用してSQLの文字列を作成することができるのが、ちょっと楽しかったよ
  • この先は Spring Data JDBC のコードリーディングしたメモみたいなものだよ
  • 「ここから先の長文を読む者は一切の希望を捨てよ」

1. Repositoryクラスのインスタンス生成の流れを追う

findAllについては提供したいものがSpring Data JDBCと完全に同じなので、Spring Data JDBCのコードを読みながら再実装します。

SimpleJdbcRepositoryfindAll 実装を読む

spring-data-jdbcSimpleJdbcRepositoryfindAll の実装は次のようになっています。

@Override
public Iterable<T> findAll() {
    return entityOperations.findAll(entity.getType());
}

(in GitHub)

ここで使われている entityOperationsentitySimpleJdbcRepositoryインスタンス変数として定義されています。

private final JdbcAggregateOperations entityOperations;
private final PersistentEntity<T, ?> entity;
private final RelationalExampleMapper exampleMapper;

(in GitHub)

このうち JdbcAggregateOperations は、spring-data-jdbc が提供しているクラスで、クエリの実行を行っています。軽くソースを眺めてみましたが相当作り込んでいて目が回りました。メインとなる処理は JdbcTemplateSQL文字列を渡して実行するものですね。なのでここは目的を最短で達成するために、代わりに JdbcTemplate を使いたいと思います。

PersistentEntityRelationalExampleMapperspring-data-commons が提供しているクラスです。それぞれ保存・取得するエンティティの情報を扱うクラス(インターフェイス)と、検索条件を構築するための Exampler を扱うためのクラスです。Exampler については今は使わないのでいったん無視して、PersistentEntity についてのみ考えることにします。

SimpleJdbcRepositoryインスタンス変数は誰が作って誰が渡しているのか?

これらのインスタンス変数ですが、いずれもコンストラクタで設定しています。

public SimpleJdbcRepository(JdbcAggregateOperations entityOperations, PersistentEntity<T, ?> entity,
        JdbcConverter converter) {

    Assert.notNull(entityOperations, "EntityOperations must not be null");
    Assert.notNull(entity, "Entity must not be null");

    this.entityOperations = entityOperations;
    this.entity = entity;
    this.exampleMapper = new RelationalExampleMapper(converter.getMappingContext());
}

(in GitHub)

entityOperationsentity についてはコンストラクタの引数で渡されたものをそのまま利用し、exampleMapper はコンストラクタで渡された JdbcConverter から作成しています。

このコンストラクタ、誰が呼び出しているのでしょうか。IntelliJSimpleJdbcRepository のコンストラクタをOpen Call Hierarchyしても 呼び出し元は見つかりません。しばらくここで彷徨ってしまったのですが、ふと思い出しました。前回実装した SqlRepositoryFactory でRepositoryのインスタンスを作っているのでしたね。

こんなシンプルな実装にしていました。

@Override
protected Object getTargetRepository(RepositoryInformation metadata) {
    return getTargetRepositoryViaReflection(metadata);
}

ここで呼び出している getTargetRepositoryViaReflection メソッドは、実際にはこんな実装になっています。

protected final <R> R getTargetRepositoryViaReflection(Class<?> baseClass, Object... constructorArguments) {
    return instantiateClass(baseClass, constructorArguments);
}

(in GitHub)

見て分かる通り、可変長引数でコンストラクタに渡すオブジェクトを指定できるようです。

spring-data-jdbcJdbcRepositoryFactory クラスの getTargetRepository では次のように実装されています。

@Override
protected Object getTargetRepository(RepositoryInformation repositoryInformation) {
    JdbcAggregateTemplate template = new JdbcAggregateTemplate(publisher, context, converter, accessStrategy);

    if (entityCallbacks != null) {
        template.setEntityCallbacks(entityCallbacks);
    }

    RelationalPersistentEntity<?> persistentEntity = context
            .getRequiredPersistentEntity(repositoryInformation.getDomainType());

    return getTargetRepositoryViaReflection(repositoryInformation, template, persistentEntity,
            converter);
}

(in GitHub)

やはり JdbcAggregateTemplateRelationalPersistentEntityconverter を引数に渡しています。これは SimpleJdbcRepository のコンストラクタと一致していますので、ここでコンストラクタに渡していると考えて間違いありません。

ではこれを真似て実装し、、、たいところなのですが、ここで RelationalPersistentEntityインスタンスを生成するために context というインスタンス変数が利用されています。これは JdbcRepositoryFactoryインスタンス変数です。

private final RelationalMappingContext context;

(in GitHub)

次はこれを追う必要があります。

JdbcRepositoryFactoryインスタンス変数は誰が作って誰が渡しているのか?

この context を含むインスタンス変数は、いずれも JdbcRepositoryFactory のコンストラクタで渡されています。

public JdbcRepositoryFactory(DataAccessStrategy dataAccessStrategy, RelationalMappingContext context,
        JdbcConverter converter, Dialect dialect, ApplicationEventPublisher publisher,
        NamedParameterJdbcOperations operations) {
// (略)
    this.context = context;
    this.converter = converter;
    this.dialect = dialect;
    this.accessStrategy = dataAccessStrategy;
    this.operations = operations;
}

(in GitHub)

JdbcRepositoryFactoryインスタンスを作っているのは JdbcRepositoryFactoryBean ですから、そちらでインスタンス生成を行っている部分のコードを見直します。

@Override
protected RepositoryFactorySupport doCreateRepositoryFactory() {
    JdbcRepositoryFactory jdbcRepositoryFactory = new JdbcRepositoryFactory(dataAccessStrategy, mappingContext,
            converter, dialect, publisher, operations);
// (略)
    return jdbcRepositoryFactory;
}

(in GitHub)

見ての通り、コンストラクタで変数を渡しています。このうち context は第二引数ですから mappingContext というオブジェクトがそれにあたります。これは JdbcRepositoryFactoryBeanインスタンス変数です。

private RelationalMappingContext mappingContext;

(in GitHub)

次はこの mappingContext を追っていくことになります。

JdbcRepositoryFactoryBeanmappingContext は誰が作って誰が渡しているのか?

この mappingContext を設定している箇所ですが、次の部分しかありません。

@Autowired
public void setMappingContext(RelationalMappingContext mappingContext) {
    Assert.notNull(mappingContext, "MappingContext must not be null");

    super.setMappingContext(mappingContext);
    this.mappingContext = mappingContext;
}

(in GitHub)

は? Autowired? 誰がインスタンスを作ってるの? 誰が呼び出してるの? っていうかフレームワークの中でもそういう事しちゃうの?

という感じで少し混乱しましたが、何にせよ @Autowired しているということは、どこかでインスタンス生成をしているに違いありません。このコンストラクタを Open Call Hierarchy してみると、、、ありました。

RelationalMappingContextのコンストラクタの呼び出し階層

AbstractJdbcConfiguration で生成しているようです。このクラスを見てみましょう。

@Configuration(proxyBeanMethods = false)
public class AbstractJdbcConfiguration implements ApplicationContextAware {
(略)
    @Bean
    public JdbcMappingContext jdbcMappingContext(Optional<NamingStrategy> namingStrategy,
            JdbcCustomConversions customConversions, RelationalManagedTypes jdbcManagedTypes) {

        JdbcMappingContext mappingContext = new JdbcMappingContext(namingStrategy.orElse(DefaultNamingStrategy.INSTANCE));
        mappingContext.setSimpleTypeHolder(customConversions.getSimpleTypeHolder());
        mappingContext.setManagedTypes(jdbcManagedTypes);

        return mappingContext;
    }
(略)
}

(in GitHub)

なるほどね。@Configuration をつけたクラスを作り、その中に @Bean をつけたメソッドでインスタンスを返せばSpringコンテナの管理下に入るので、それを @Autowired させて使っているということですね。JdbcMappingContextRelationalMappingContext を継承したものなので、ここで生成した JdbcMappingContext のBeanが RelationalMappingContext としてAutowiredされるようです。

AbstractJdbcConfiguration には他にもいくつかのBean定義があり、それらを JdbcRepositoryFactoryBean でAutowiredしているところがありました。そういうお作法でやっているのだと理解すれば、簡単な話ですね。また後で必要なインスタンスがAutowiredされているのを見たら、AbstractJdbcConfiguration に戻ってくればインスタンスの生成を確認できそうです。

たどってたどって長くなりましたが mappingContext についてはこれで解決です。これでRepositoryの作成に必要となるインスタンスは準備できそうです。

2. SQL組み立ての流れを追う

Repositoryクラスのインスタンス生成についての流れは大まかに把握できたので、次は findAll の内部の実装がどうなっているかの確認です。

findAllの実行箇所を追う

再掲になりますが、SimpleDataJdbcfindAll の実装は次のようになっています。

@Override
public Iterable<T> findAll() {
    return entityOperations.findAll(entity.getType());
}

(in GitHub)

この entityOperations.findAll の実装は、JdbcAggregateTemplate クラスで提供されています。

@Override
public <T> Iterable<T> findAll(Class<T> domainType) {

    Assert.notNull(domainType, "Domain type must not be null");

    Iterable<T> all = accessStrategy.findAll(domainType);
    return triggerAfterConvert(all);
}

(in GitHub)

ここで accessStrategy.findAll というメソッドを呼び出しています。これの実装は複数のクラスで提供されていますが、最もシンプルそうな DefaultDataAccessStrategy クラスの findAll メソッドを確認します。

@Override
public <T> Iterable<T> findAll(Class<T> domainType) {
    return operations.query(sql(domainType).getFindAll(), getEntityRowMapper(domainType));
}

(in GitHub)

ようやくそれらしいコードがでてきました。

operations.query は、引数で指定されたSQLを実行するもの。JdbcTemplatequery メソッドですね。

sql(domainType).getFindAll() は、全件検索のSQLを組み立てるもの。

getEntityRowMapper(domainType) は、ResultSet をEntityにマッピングするための RowMapper を提供するもの。これも JdbcTemplate を使ったことがあれば馴染みのあるものです。

ということで最も分からないSQLの組み立て部分を追っていきます。

SQLの組み立てを追う

まずは sql メソッドを確認してみます。

private SqlGenerator sql(Class<?> domainType) {
    return sqlGeneratorSource.getSqlGenerator(domainType);
}

(in GitHub)

sql メソッドはEntityに対応した SqlGenerator クラスを返すもののようです。見るからにSQLをジェネレートしそうなクラス名です。

この呼び出し先である SqlGeneratorSource クラスの getSqlGenerator についても確認しておきます。

SqlGenerator getSqlGenerator(Class<?> domainType) {
    return CACHE.computeIfAbsent(domainType,
            t -> new SqlGenerator(context, converter, context.getRequiredPersistentEntity(t), dialect));
}

(in GitHub)

SqlGeneratorインスタンスを作ってキャッシュしておく処理のようですね。とりあえず今のところは無視しましょう。

sql(domainType).getFindAll() の処理に戻って SqlGenerator の実装を確認します。

String getFindAll() {
    return findAllSql.get();
}

(in GitHub)

生成済みである findAllSql を返しているようです。なるほどね、findAllのようなSQLは特にコンテキストで内容が変わるものではないから、使ったものを使い回しているのでしょうね。

ではこの findAllSql を確認しましょう。SqlGenrator クラスのインスタンス変数として定義されています。

private final Lazy<String> findAllSql = Lazy.of(this::createFindAllSql);

(in GitHub)

Lazy ということは遅延ローディングですね。初めて必要になって初めてSQL文を組み立てて、次回以降はそれを使い回す形だと理解できます。

ではここで呼んでいる createFindAllSql を確認しましょう。そろそろ核心に近づいてきた気がします。

private String createFindAllSql() {
    return render(selectBuilder().build());
}

(in GitHub)

private String render(Select select) {
    return this.sqlRenderer.render(select);
}

(in GitHub)

まだ核心ではありませんでした。sqlRendererselectBuilder().build() したものを渡すことで、SQL文を作成しているようです。

ここまではずっと spring-data-jdbc のクラスを追ってきましたが、ここで使われている SqlRenderer クラスや SelectBuilder クラスは spring-data-relational のクラスです。spring-data-relationalspring-data-commons と同じようにSpring Dataを作るための共通ライブラリのようなものですが、特にRDBMSに関連するクラス群を提供しているようです。

そういう立ち位置のため、自作のSpring Dataが spring-data-jdbc に依存することはできませんが、spring-data-relational なら依存して利用することができます。なのであまりコードを読み込まずに利用しても良いのですが、念のため実装を簡単に確認しておきましょう。

SqlRendererrender メソッドは次のようになっています。

@Override
public String render(Select select) {

    SelectStatementVisitor visitor = new SelectStatementVisitor(context);
    select.visit(visitor);

    return visitor.getRenderedPart().toString();
}

(in GitHub)

この内部実装はあまり真面目に追うつもりはありませんが、SelectStatementVisitor に次のような実装を見つけました。

@Override
public Delegation doLeave(Visitable segment) {

    if (segment instanceof Select) {

        Select select = (Select) segment;

        builder.append("SELECT ");

        if (select.isDistinct()) {
            builder.append("DISTINCT ");
        }

        builder.append(selectList);
        builder.append(selectRenderContext.afterSelectList().apply(select));

        if (from.length() != 0) {
            builder.append(" FROM ").append(from);
        }

        builder.append(selectRenderContext.afterFromTable().apply(select));

        if (join.length() != 0) {
            builder.append(' ').append(join);
        }

        if (where.length() != 0) {
            builder.append(" WHERE ").append(where);
        }

        CharSequence orderBy = orderByClauseVisitor.getRenderedPart();
        if (orderBy.length() != 0) {
            builder.append(" ORDER BY ").append(orderBy);
        }

        builder.append(selectRenderContext.afterOrderBy(orderBy.length() != 0).apply(select));

        return Delegation.leave();
    }

    return Delegation.retain();
}

(in GitHub)

めちゃくちゃゴリゴリにSQL文字列を組み立てていますね。どうあれ SqlRender というクラスが、引数で渡された Select を元にSQL文を文字列として組み立てていることが理解できました。この処理を追うのはこれぐらいにして、戻りましょう。

SqlRenderer は誰が作って誰が渡しているのか?

SqlRendererSqlGenerator のコンストラクタで作られています。

SqlGenerator(RelationalMappingContext mappingContext, JdbcConverter converter, RelationalPersistentEntity<?> entity,
        Dialect dialect) {
// (略)
    this.renderContext = new RenderContextFactory(dialect).createRenderContext();
    this.sqlRenderer = SqlRenderer.create(renderContext);
// (略)
}

(in GitHub)

これを見る限り dialectrenderContext さえあればインスタンス生成ができそうです。

SqlGenerator のコンストラクタは、先ほども記載した SqlGeneratorSource クラスの getSqlGenerator メソッドで呼び出しています。

SqlGenerator getSqlGenerator(Class<?> domainType) {
    return CACHE.computeIfAbsent(domainType,
            t -> new SqlGenerator(context, converter, context.getRequiredPersistentEntity(t), dialect));
}

(in GitHub)

ここでコンストラクタ引数として指定している contextdialectSqlGeneratorSource のコンストラクタで指定されています。

public SqlGeneratorSource(RelationalMappingContext context, JdbcConverter converter, Dialect dialect) {
// (略)
    this.context = context;
    this.converter = converter;
    this.dialect = dialect;
}

(in GitHub)

どんどん遡っていきましょう。

SqlGeneratorSourceインスタンスJdbcRepositoryFactoryBean で作っています。

SqlGeneratorSource sqlGeneratorSource = new SqlGeneratorSource(this.mappingContext, this.converter,
        this.dialect);

(in GitHub)

mappingContext は先ほど @Autowired されていたインスタンスですね。そうすると dialect も同様でしょうかね。そう思って JdbcRepositoryFactoryBean のコードを確認すると。

@Autowired
public void setDialect(Dialect dialect) {
    Assert.notNull(dialect, "Dialect must not be null");
    this.dialect = dialect;
}

(in GitHub)

ビンゴ! ありました。ちなみにドラマに出てくるエンジニアはすぐ「ビンゴ!」と言いますが、実際にエンジニアが「ビンゴ」と発言することはありませんね。

@Autowired されているということは、mappingContext と同様に AbstractJdbcConfiguration クラスで定義されていそうです。

@Bean
public Dialect jdbcDialect(NamedParameterJdbcOperations operations) {
    return DialectResolver.getDialect(operations.getJdbcOperations());
}

(in GitHub)

ビンゴ! ありました。またビンゴと言ってしまいました。

この DialectResolverspring-data-jdbc のクラスでした。Dialect の取得なんて共通処理なんだから spring-data-relation にあるのかと思ってましたけど、ないんですね。

この Dialect の取得処理をもう少し辿っていくと、次のようなメソッドに到達しました。

private static Dialect getDialect(Connection connection) throws SQLException {
    DatabaseMetaData metaData = connection.getMetaData();

    String name = metaData.getDatabaseProductName().toLowerCase(Locale.ENGLISH);

    if (name.contains("hsql")) {
        return HsqlDbDialect.INSTANCE;
    }
    if (name.contains("h2")) {
        return H2Dialect.INSTANCE;
    }
    if (name.contains("mysql")) {
        return new JdbcMySqlDialect(getIdentifierProcessing(metaData));
    }
    if (name.contains("mariadb")) {
        return new MariaDbDialect(getIdentifierProcessing(metaData));
    }
    if (name.contains("postgresql")) {
        return JdbcPostgresDialect.INSTANCE;
    }
    if (name.contains("microsoft")) {
        return JdbcSqlServerDialect.INSTANCE;
    }
    if (name.contains("db2")) {
        return JdbcDb2Dialect.INSTANCE;
    }
    if (name.contains("oracle")) {
        return OracleDialect.INSTANCE;
    }

    LOG.info(String.format("Couldn't determine Dialect for \"%s\"", name));
    return null;
}

(in GitHub)

いや完全にゴリゴリに実装してますね、マジかよ。RDBへのコネクションを使って、そのメタデータからどのDBかを判定しています。昔ながらの方法です。

この辺りを実装するのも面倒ですから、いったんRDBは「H2」固定にするとします。もう少し実装が進んだ時に、きちんと実装することにしましょう。

どうあれこれで、SqlRenderer を生成する流れも把握できました。

Select の組み立て

さて、残すは SqlRenderer に渡す Select の組み立てです。SqlGeneratorselectBuilder の実装を確認しましょう。

private SelectBuilder.SelectWhere selectBuilder() {
    return selectBuilder(Collections.emptyList());
}

private SelectBuilder.SelectWhere selectBuilder(Collection<SqlIdentifier> keyColumns) {
    Table table = getTable();

    List<Expression> columnExpressions = new ArrayList<>();

    List<Join> joinTables = new ArrayList<>();
    for (PersistentPropertyPath<RelationalPersistentProperty> path : mappingContext
            .findPersistentPropertyPaths(entity.getType(), p -> true)) {

        PersistentPropertyPathExtension extPath = new PersistentPropertyPathExtension(mappingContext, path);

        // add a join if necessary
        Join join = getJoin(extPath);
        if (join != null) {
            joinTables.add(join);
        }

        Column column = getColumn(extPath);
        if (column != null) {
            columnExpressions.add(column);
        }
    }

    for (SqlIdentifier keyColumn : keyColumns) {
        columnExpressions.add(table.column(keyColumn).as(keyColumn));
    }

    SelectBuilder.SelectAndFrom selectBuilder = StatementBuilder.select(columnExpressions);
    SelectBuilder.SelectJoin baseSelect = selectBuilder.from(table);

    for (Join join : joinTables) {
        baseSelect = baseSelect.leftOuterJoin(join.joinTable).on(join.joinColumn).equals(join.parentId);
    }

    return (SelectBuilder.SelectWhere) baseSelect;
}

(in GitHub)

ざっと眺めてみると、何となくやりたいことは分かりますね。戻り値である baseSelect から逆算し、また今回対応しない join も無視して考えると、次のようなコードになりそうです。

return StatementBuilder.select(columnExpressions).from(getTable());

まずは Table を作っている箇所から確認します。

private Table getTable() {
    return sqlContext.getTable();
}

(in GitHub)

さらにこの SqlContext の実装も確認すると、コンストラクタで Table クラスのインスタンスを作成していました。

SqlContext(RelationalPersistentEntity<?> entity) {
    this.entity = entity;
    this.table = Table.create(entity.getQualifiedTableName());
}

(in GitHub)

とりあえず RelationalPersistentEntity さえあれば、問題なくインスタンスを作れるようです。これは既に出自の分かっているオブジェクトです。

続いて、Column についても確認しましょう。columnExpressions は、SELECT文の SELECT 直後に羅列するカラム名の一覧であることは容易に想像がつきます。

getColumn メソッドはjoinやオブジェクトのネストなどを考慮しているため少し長い処理なのですが、シンプルなSELECT ALL処理だけで言うと、次の部分の処理になります。

return sqlContext.getColumn(path);

(in GitHub)

この SqlContext の処理を追ってみます。

Column getColumn(PersistentPropertyPathExtension path) {
    return getTable(path).column(path.getColumnName()).as(path.getColumnAlias());
}

(in GitHub)

Table から PersistentPropertyPathExtension を使って取れるみたいですね。

PersistentPropertyPathExtension は上に書いた selectBuilder の処理の中で生成しています。

   for (PersistentPropertyPath<RelationalPersistentProperty> path : mappingContext
            .findPersistentPropertyPaths(entity.getType(), p -> true)) {

        PersistentPropertyPathExtension extPath = new PersistentPropertyPathExtension(mappingContext, path);

(in GitHub)

for文が若干分かりづらいですが mappingContextentity さえあれば問題なく PersistentPropertyPathExtension が生成できそうで、特に別途必要なインスタンスはなさそうです。

どうあれ、ここまで見た流れで SelectSqlRenderer の組み合わせができそうです。

3. いったんSQL文を発行するサンプルを作ってみる

既存のコードを追ってばかりで分かりづらくなってきたので、ここまで読んだ内容を元に、いったん自作Spring DataでSQL文を生成するところまで作り込んでみます。

SqlRepositoryFactoryBeanでRelationalMappingContextの作成

まずは RelationalMappingContext の生成ですが、spring-data-jdbc では AbstractJdbcConfiguration という @Congiguration アノテーションをつけたクラスで行っていました。ただこのようなConfigurationを有効にするためにはAutoConfigurationなども必要になって面倒ですから、今回は固定で作ってしまいましょう。

どこでインスタンスを生成するか若干悩みますが spring-data-jdbc では JdbcRepositoryFactoryBean にAutowireしていましたので、それと同じように SqlRepositoryFactoryBean クラスで生成するようにします。

public class SqlRepositoryFactoryBean<T extends Repository<S, ID>, S, ID extends Serializable>
        extends TransactionalRepositoryFactoryBeanSupport<T, S, ID> {
    private final RelationalMappingContext mappingContext = new RelationalMappingContext(DefaultNamingStrategy.INSTANCE);

    protected SqlRepositoryFactoryBean(Class<? extends T> repositoryInterface) {
        super(repositoryInterface);
    }

    @Override
    protected RepositoryFactorySupport doCreateRepositoryFactory() {
        return new SqlRepositoryFactory(mappingContext);
    }
}

(in GitHub)

特に NamingStrategy をカスタマイズする必要も今のところはないので、固定にしています。

SqlRepositoryFactoryでRepositoryのコンストラクタに渡す引数を指定

続いて SqlRepositoryFactoryRelationalMappingContext をコンストラクタで受け取るようにして、その RelationalMappingContext と、そこから生成した RelationalPersistentEntityリポジトリ生成時のコンストラクタに渡すようにします。

public class SqlRepositoryFactory extends RepositoryFactorySupport {
    private final RelationalMappingContext mappingContext;

    public SqlRepositoryFactory(RelationalMappingContext mappingContext) {
        this.mappingContext = mappingContext;
    }

    @Override
    public <T, ID> EntityInformation<T, ID> getEntityInformation(Class<T> domainClass) {
        return null;
    }

    @Override
    protected Object getTargetRepository(RepositoryInformation metadata) {
        RelationalPersistentEntity<?> persistentEntity = mappingContext
                .getRequiredPersistentEntity(metadata.getDomainType());
        return getTargetRepositoryViaReflection(metadata, persistentEntity, mappingContext);
    }

    @Override
    protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
        return DefaultRepository.class;
    }
}

(in GitHub)

この辺りも spring-data-jdbc劣化コピーといった感じの実装です。

DefaultRepositoryのコンストラクタで引数を受け取る

前回のエントリーで説明した通り、生成されるリポジトリのクラスは SqlRepositoryFactory#getRepositoryBaseClass で返される DefaultRepository になります。なので、その DefaultRepository のコンストラクタで引数を受け取るようにします。

public class DefaultRepository<T, ID> implements CrudRepository<T, ID>, PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
    private final RelationalPersistentEntity<T> entity;

    private final RelationalMappingContext mappingContext;

    private final SqlRenderer sqlRenderer;

    public DefaultRepository(RelationalPersistentEntity<T> entity, RelationalMappingContext mappingContext) {
        this.entity = entity;
        this.mappingContext = mappingContext;
        this.sqlRenderer = SqlRenderer.create(new RenderContextFactory(H2Dialect.INSTANCE).createRenderContext());
    }

(in GitHub)

また SqlRenderer もこのコンストラクタで生成することにしました。

DefaultRepositoryのfindAllを実装する

これで DefaultRepository ではSQL文の組み立てに必要なものが揃った状態になるので findAll を実装します。

@Override
public List<T> findAll() {
    Table table = Table.create(entity.getQualifiedTableName());

    List<Expression> columnExpressions = new ArrayList<>();
    for (PersistentPropertyPath<RelationalPersistentProperty> path : mappingContext
            .findPersistentPropertyPaths(entity.getType(), p -> true)) {
        PersistentPropertyPathExtension extPath = new PersistentPropertyPathExtension(mappingContext, path);
        Column column = table.column(extPath.getColumnName()).as(extPath.getColumnAlias());
        columnExpressions.add(column);
    }

    Select select = StatementBuilder.select(columnExpressions).from(table).build();
    String sql = sqlRenderer.render(select);
    System.out.println(sql);

    return null;
}

(in GitHub)

だいぶ端折った実装にしていますがエンティティクラスの情報を元に Table を作成し、やはりエンティティクラスの情報を元にフィールド一覧から Column の一覧を作り、そこから Select を作成しています。

StatementBuilder.select(columnExpressions).from(table).build(); というのが、なかなか痺れるクエリビルディングなので、オレオレO/Rマッパーを作りたい人は、spring-data-relational にあるこの辺りのクラスを読んで利用してみるのも良いと思います。

ということで、ここまでで findAll の作成が済みました。

いざ、実行!

ではここまでで作った独自Spring Dataをサンプルアプリケーションから呼び出してみましょう。実装は前回と変わりませんが、念のためコードを貼っておきます。

@SpringBootApplication
@EnableSqlRepositories
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}
public record Emp (Long id, String name) {
}
@Repository
public interface EmpRepository extends CrudRepository<Emp, Long> {
}
@RestController
public class EmpController {
    EmpRepository empRepository;

    public EmpController(EmpRepository empRepository) {
        this.empRepository = empRepository;
    }

    @GetMapping("/")
    Iterable<Emp> emp() {
        return empRepository.findAll();
    }
}

これで App クラスを実行すると、問題なく起動します。

アクセスしてみましょう。

%curl localhost:8080 -i
HTTP/1.1 200 
Content-Length: 0
Date: Fri, 23 Dec 2022 21:10:02 GMT

問題なく実行できています。そして、コンソールには・・・

SELECT "EMP"."ID" AS "ID", "EMP"."NAME" AS "NAME" FROM "EMP"

SQL文が表示されています、やりました!!!🎊🎊🎊🎉🎉🎉🥳🥳🥳🎉🎉🎉🎊🎊🎊

かったよ、、、ぼく!

4. 実際にSQLを発行する

SQL文まで発行できれば、もうこちらのもんですよね、後はSQLを実際に実行できるよう改修します。みんな大好きな JdbcTemplate を使います。

SqlRepositoryFactoryBeanJdbcTemplate をAutowireする

まずは SqlRepositoryFactoryBeanJdbcTemplate@Autowired でセットできるようにします。もう少し先のクラスで指定しても良いのですが、spring-data-jdbc がすべてこの FactoryBean クラスに @Autowired を集約していたのでそれに倣いました。

変更部分は次の通りです。

public class SqlRepositoryFactoryBean<T extends Repository<S, ID>, S, ID extends Serializable>
        extends TransactionalRepositoryFactoryBeanSupport<T, S, ID> {
    private JdbcTemplate jdbcTemplate;

    @Override
    protected RepositoryFactorySupport doCreateRepositoryFactory() {
        return new SqlRepositoryFactory(mappingContext, jdbcTemplate);
    }

    @Autowired
    public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}

@Autowired で受け取って、渡すだけです。

SqlRepositoryFactory のコンストラクタで受け取った JdbcTempalte をRepsitoryのコンストラクタに渡す

続いて SqlRepositoryFactory のコンストラクタで JdbcTemplate を受け取り、それをRepository生成時のコンストラクタに渡せるようにします。

変更部分は次の通りです。

public class SqlRepositoryFactory extends RepositoryFactorySupport {
    private final JdbcTemplate jdbcTemplate;

    public SqlRepositoryFactory(RelationalMappingContext mappingContext, JdbcTemplate jdbcTemplate) {
        this.mappingContext = mappingContext;
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    protected Object getTargetRepository(RepositoryInformation metadata) {
        RelationalPersistentEntity<?> persistentEntity = mappingContext
                .getRequiredPersistentEntity(metadata.getDomainType());
        return getTargetRepositoryViaReflection(metadata, persistentEntity, mappingContext, jdbcTemplate);
    }
}

(in GitHub)

コンストラクタで受け取って、渡すだけです。

DefaultRepositoryfindAll でクエリを実行する

そして同様に、DefaultRepository のコンストラクタで JdbcTemplate を受け取るようにします。

private final JdbcTemplate jdbcTemplate;

public DefaultRepository(RelationalPersistentEntity<T> entity, RelationalMappingContext mappingContext, JdbcTemplate jdbcTemplate) {
    this.entity = entity;
    this.mappingContext = mappingContext;
    this.sqlRenderer = SqlRenderer.create(new RenderContextFactory(H2Dialect.INSTANCE).createRenderContext());
    this.jdbcTemplate = jdbcTemplate;
}

(in GitHub)

後は JdbcTemplate を使ってクエリ発行するだけ、、、なのですが、JdbcTemplate では ResultSet をエンティティに変換するための RowMapper が必要となります。その実装も必要なのですが、ここでは Bootiful SQL Template で作成した、ResultSetRecordマッピングするための RecordMapper をコピペして使います。

RecordMapper のコードはこちらになります。実際にはこれを少しだけ改修しましたが、大きな話ではないので割愛します。

public class RecordMapper<T> implements RowMapper<T> {
    // (割愛)
}

(in GitHub)

そしていよいよ findAll の実装です。最後にクエリを実行する処理を入れます。

@Override
public List<T> findAll() {
// (略)
    String sql = sqlRenderer.render(select);

    return jdbcTemplate.query(sql, new RecordMapper<>(entity.getType()));
}

(in GitHub)

これで実装が完了しました。あとは実行するだけです。

いざ、実行!!

テーブルがないと実行してもコケることが分かりきっているため、サンプルアプリケーション側でテーブルを作成します。

schema.sql を次のような内容で作成します。ぜんぜん emp っぽくないですが。

CREATE TABLE emp
(
  id BIGINT NOT NULL PRIMARY KEY,
  name VARCHAR(64)
);

(in GitHub)

そして data.sql を次のような内容で作成します。

INSERT INTO emp (id, name)
VALUES
 (1, 'Nakamoto')
,(2, 'Kikuchi')
,(3, 'Mizuno')
,(4, 'Sayashi')
,(5, 'Fujihira')
,(6, 'Okazaki')
;

(in GitHub)

羅列された名前についてはご意見無用です。

これで準備は整いました。起動して実行してみましょう!

 % curl localhost:8080     
[{"id":1,"name":"Nakamoto"},{"id":2,"name":"Kikuchi"},{"id":3,"name":"Mizuno"},{"id":4,"name":"Sayashi"},{"id":5,"name":"Fujihira"},{"id":6,"name":"Okazaki"}]

きちんと取得結果が返ってきました!!!🎊🎊🎊🎉🎉🎉🥳🥳🥳🎉🎉🎉🎊🎊🎊

長すぎて誰もここまで読んでない気がしますが、とりあえず、僕はやりました、やりきりました。

まとめ

今回の話をまとめます。

  • spring-data-jdbc では AbstractJdbcConfiguration で利用するBeanの定義をしている
    • この定義は @Configuration がついている。
    • この定義を extends したクラスがAutoConfiguration設定されている
  • spring-data-jdbc では JdbcRepositoryFactoryBean で利用するBeanを @Autowired している
  • RepositoryFactoryの基底クラスである RepositoryFactorySupportgetTargetRepository メソッドには可変長引数でRepositoryのコンストラクタに渡す引数を指定することができる
  • spring-data-relationalRelationalMappingContextRelationalPersistentEntity クラスがエンティティとテーブルをマッピングするための情報を作成する
  • spring-data-relationalSqlRenderer StatementBuilder Select Table Column クラスなどを利用してSQLの文字列を作成することができる

正直、ほとんど実装してなくて、時間の大半は spring-data-jdbc のコードリーディングに費やしたという感じですが、何にせよ無事に findAll が動作するところまで動きました。

とりあえずここまでで動いたものをGitHubに置いておいたので、ちょっと読んでみたいという人はどうぞ。実質的には、ほんの数十行しか実装していません。 github.com

この後 DefaultRepository の他のメソッドの実装を充実させていく分には、今回の延長でできそうなイメージが沸いてきました。

それよりもまだイメージの沸いていない任意のSQLの発行について、次回は挑戦したいと思います。

独自Spring Dataを作ってみる その1 - まずは入口

僕は約束を守る男なので、Spring Data SQLを作り始めたというお話です。

背景

これまでBootiful SQL TemplateというJdbcTemplateの薄いラッパーを(作って)使っていたのですが、INSERT文とかSELECT ALLくらいは自動生成して欲しいよなと思い、Spring Dataを使おうかなと思って調べ始めました。

Spring Dataの中でも最も素直に動いてくれそうな Spring Data JDBCを試したところ、簡単なクエリはSQLを書かずに生成できるし、アノテーションSQL文を渡すこともできるのですが、動的に組み立てたクエリを渡すことができないようでした。

さすがにそこまで割り切った仕様のフレームワークは使いづらく、かと言って他に好みのO/Rマッパーがあるわけでもありません。

「好みのO/Rマッパーがないなら、作れば良いのよ」とはもう数百年くらい言い古された言葉ですが、はい、試しに作ってみることにしました。

作り始めるまでの流れ

まずは作り始めるまでに至った流れや構想などを書いておきます。

構想

INSERT文の自動生成やページングなどに対応したものが欲しいので、Spring Dataのお作法に則るのが良いと思いました。具体的には次のような形になります。

これくらいなら割と簡単にできそうじゃないですか? フラグ?

入口が分からないため、頓挫

さて、いきなりですが、入口が分からない。

Spring Data JDBCのコードを読むと JdbcRepositoryFactoryJdbcRepositoryFactoryBean というクラスでRepositoryのinterfaceに対する実装を提供しているようなのですが、何をどうしたら自前のファクトリクラスが呼ばれるようになるのかが分かりません。spring-data-commons というSpring Dataを作るための共通モジュールがあるものの、これの使い方がよく分かりません。

Spring Data JDBCMETA-INF/spring.factories というファイルに org.springframework.data.repository.core.support.RepositoryFactorySupport=org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory という記述があったので、これか!? と思って真似てみたものの、やっぱり自前のファクトリクラスが呼ばれることはありませんでした。

何も分かりません。入口が分からないので、どうすることもできません。俺の物語はここで終わるのか?

アルゼンチンが優勝したので仕方なく

自分で作ることを諦めた1日後、ワールドカップの決勝戦がありました。これまでワールドカップをほぼ全試合見ているサッカー通であり事情通*1である僕としては、フランスの優勝間違いなしと判断をしたため、こんなツイートをしました。

結果はご存じの通り、サッカーの女神はリオネルメッシに微笑みました。エンバペ、凄かったのに。今後に期待するよ。

そんなわけで僕は時間と約束は守る男ですから(初耳)Spring Data SQLを開発すべく、調査を再開しました。

Spring Dataづくりの最初の第一歩

数時間ほどソースコードを読んだり、自分でコードを書いて無理矢理動かしてみたところ、ようやくSpring Dataの作り方が分かりました。

入口はアノテーション

最初の入口になるのは、アプリケーションの起動クラスに書く @EnableJdbcRepositories のようなアノテーションでした。いやさ、独自アノテーションを作ろうが、そのアノテーションを読む所の方が核心なんだから、アノテーションだけ作っても仕方ないでしょ、と思っていたのですが、そうではありませんでした。

独自アノテーションを作ることで、それをきっかけとして各クラスが呼べるようになるようです。

最低限必要なアノテーションは次の通りです。ここに書いたアノテーションがないと起動時に AnnotationRepositoryConfigurationSource というクラスのチェックで「このフィールドがないよ!」って怒られます。いや怒るくらいだったらアノテーションのベースを用意してくれよと思わなくもないのですが、とりあえず怒られるがまま最低限のアノテーションを作ります。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(SqlRepositoriesRegistrar.class)
public @interface EnableSqlRepositories {
    String[] value() default {};
    String[] basePackages() default {};
    Class<?>[] basePackageClasses() default {};
    ComponentScan.Filter[] includeFilters() default {};
    ComponentScan.Filter[] excludeFilters() default {};
    String repositoryImplementationPostfix() default "Impl";
    String namedQueriesLocation() default "";
    Class<?> repositoryFactoryBeanClass() default SqlRepositoryFactoryBean.class;
}

このアノテーションで2つの自作クラスを指定しています。ひとつが @Import に指定した SqlRepositoriesRegistrar もう一つが repositoryFactoryBeanClass() に指定した SqlRepositoryFactoryBean です。

SqlRepositoriesRegistrarとSqlRepositoryConfigExtension

SqlRepositoriesRegistrar は大した実装ではありません。spring-data-commonsRepositoryBeanDefinitionRegistrarSupport を実装したクラスを作ることで、Repositoryの登録ができるようになるようです。とりあえずSpring Data JDBCを真似て、最低限の実装だけで済ませます。

public class SqlRepositoriesRegistrar extends RepositoryBeanDefinitionRegistrarSupport {
    @Override
    protected Class<? extends Annotation> getAnnotation() {
        return EnableSqlRepositories.class;
    }

    @Override
    protected RepositoryConfigurationExtension getExtension() {
        return new SqlRepositoryConfigExtension();
    }
}

何やらカスタマイズするための RepositoryConfigExtension というクラスも必要になるようなので、spring-data-commonsRepositoryConfigurationExtensionSupport を継承して最低限の実装だけ済ませた SqlRepositoryConfigExtension というクラスを作りました。

public class SqlRepositoryConfigExtension extends RepositoryConfigurationExtensionSupport {
    @Override
    protected String getModulePrefix() {
        return "sql";
    }

    @Override
    public String getRepositoryFactoryBeanClassName() {
        return SqlRepositoryFactoryBean.class.getName();
    }
}

諸々の制御が必要になった時にはまた戻ってくるとして、今はいったん最低限の実装だけで突き進みます。

SqlRepositoryFactoryBeanとSqlRepositoryFactory

@EnableSqlRepository で指定していたもう一つのクラスが SqlRepositoryFactoryBean です。このクラスを経由して、独自のファクトリクラスのインスタンスを利用できる形になります。このクラスもSpring Data JDBCの実装を参考に TransactionalRepositoryFactoryBeanSupport の継承と ApplicationEventPublisherAware の実装として作成します。

public class SqlRepositoryFactoryBean<T extends Repository<S, ID>, S, ID extends Serializable>
        extends TransactionalRepositoryFactoryBeanSupport<T, S, ID> implements ApplicationEventPublisherAware {
    protected SqlRepositoryFactoryBean(Class<? extends T> repositoryInterface) {
        super(repositoryInterface);
    }

    @Override
    protected RepositoryFactorySupport doCreateRepositoryFactory() {
        return new SqlRepositoryFactory();
    }
}

↑このクラスの doCreateRepositoryFactory で返している SqlRepositoryFactory が核心です。Repositoryのインタフェースに対する実体を返すファクトリクラスになります。とりあえず突き進みたいので、すべて null を返す実装にします。null いいよね null。ぬるぬる。

public class SqlRepositoryFactory extends RepositoryFactorySupport {
    private final RelationalMappingContext context;

    @Override
    public <T, ID> EntityInformation<T, ID> getEntityInformation(Class<T> domainClass) {
        return null;
    }

    @Override
    protected Object getTargetRepository(RepositoryInformation metadata) {
        return null;
    }

    @Override
    protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
        return null;
    }
}

サンプルアプリケーションから使ってみる

ここまでで作った(何もしてない)自作Spring Dataを、サンプルアプリケーションで利用してみます。サンプルアプリケーションのdependencyに↑で作った自作Spring Dataのモジュールを入れたうえで、Spring Bootのアプリケーションを作ります。

サンプルアプリケーションを作る

アプリケーションクラスに、@EnableSqlRepositories アノテーションをつけます。

@SpringBootApplication
@EnableSqlRepositories
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

続いて、テーブルと1:1のクラスを record で作ります。recode は楽で良いですよね。ビルダーを生成してくれない所は恨んでいますが。

public record Emp (Long id, String name) {
}

さらに、そのクラスを使ったRepositoryインターフェイスを定義します。

@Repository
public interface EmpRepository extends CrudRepository<Emp, Long> {
}

最後に、そのRepositoryを呼び出すControllerを作成します。もう慣れたもんです。

@RestController
public class EmpController {
    EmpRepository empRepository;

    public EmpController(EmpRepository empRepository) {
        this.empRepository = empRepository;
    }

    @GetMapping("/")
    Iterable<Emp> emp() {
        return empRepository.findAll();
    }
}

絶対に動くことはなくエラーが出ることはわかりきっていますが、とりあえず起動してみます。

起動時のエラー

起動すると次のようなエラーが発生しました。ぬるぽー!

2022-12-23T00:32:37.874+09:00 ERROR 1285 --- [           main] o.s.boot.SpringApplication               : Application run failed

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'empRepository' defined in ninja.cero.data.sql.app.EmpRepository defined in @EnableSqlRepositories declared on App: Cannot invoke "Object.getClass()" because "target" is null
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1751) ~[spring-beans-6.0.2.jar:6.0.2]
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:599) ~[spring-beans-6.0.2.jar:6.0.2]
(略)
Caused by: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "target" is null
    at org.springframework.data.repository.core.support.RepositoryFactorySupport.getRepository(RepositoryFactorySupport.java:319) ~[spring-data-commons-3.0.0.jar:3.0.0]
    at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.lambda$afterPropertiesSet$5(RepositoryFactoryBeanSupport.java:279) ~[spring-data-commons-3.0.0.jar:3.0.0]
    at org.springframework.data.util.Lazy.getNullable(Lazy.java:229) ~[spring-data-commons-3.0.0.jar:3.0.0]
(略)

そりゃファクトリクラスの実装がぜんぶ return null; ですから、そうなるでしょうね。

ちなみにここで初めてヌルポが出たように書いていますが、実際には他の実装も全部 return null; にしていて、怒られたところだけ実装するというトライ&エラーの繰り返しでここまで進んでいます。エンジニアとして最低な仕事の仕方ですね!

せめてエラーがなくなるところまで実装する

エラーのまま放置しては面白くないので、もう少しだけ実装を追加してせめてエラーが出なくなるところまで進めてみます。

SqlRepositoryFactoryに実装を追加

targetがnullであることを怒られているため、SqlRepositoryFactorygetTargetRepositorygetRepositoryBaseClass に最低限の実装を追加します。

public class SqlRepositoryFactory extends RepositoryFactorySupport {
    @Override
    public <T, ID> EntityInformation<T, ID> getEntityInformation(Class<T> domainClass) {
        return null;
    }

    @Override
    protected Object getTargetRepository(RepositoryInformation metadata) {
        return getTargetRepositoryViaReflection(metadata);
    }

    @Override
    protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
        return DefaultRepository.class;
    }
}

getTargetRepository は親クラスに似たような名前のメソッドがあるのでそちらを使いました。また getRepositoryBaseClass では次のような実装のクラスを返しています。

public class DefaultRepository<T, ID> implements CrudRepository<T, ID>, PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
    @Override
    public <S extends T> S save(S entity) {
        return null;
    }

    @Override
    public <S extends T> Iterable<S> saveAll(Iterable<S> entities) {
        return null;
    }

    @Override
    public Optional<T> findById(ID id) {
        return Optional.empty();
    }

    @Override
    public boolean existsById(ID id) {
        return false;
    }

    @Override
    public Iterable<T> findAll() {
        return null;
    }
    // (略)
}

Spring Data JDBCSimpleJdbcRepository というクラスを参考に、3つのinterfaceを実装したクラスを作成しました。ただし全て空実装にしています。ここをしっかり実装すれば、基本的な機能は提供できるようになるのでしょうかね。

改めて起動してみる

ここまで実装できたので、またサンプルアプリケーションを起動してみます。

(略)
2022-12-23T01:04:24.576+09:00  INFO 4005 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2022-12-23T01:04:24.582+09:00  INFO 4005 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2022-12-23T01:04:24.582+09:00  INFO 4005 --- [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.1]
2022-12-23T01:04:24.631+09:00  INFO 4005 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2022-12-23T01:04:24.632+09:00  INFO 4005 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 732 ms
2022-12-23T01:04:24.932+09:00  INFO 4005 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2022-12-23T01:04:24.939+09:00  INFO 4005 --- [           main] ninja.cero.data.sql.App                  : Started App in 1.323 seconds (process running for 1.723)

今度はエラーが出ることなく、無事に起動まで完了しました!

せっかくなので試しに動かしてみましょう。

% curl localhost:8080 -i

HTTP/1.1 200 
Content-Length: 0
Date: Thu, 22 Dec 2022 16:14:34 GMT

特に何のエラーがでることもなく、レスポンスが返ってきました!!

上で作成した DefaultRepositoryfindAll が呼ばれて null が返ってきた結果なのでしょうね。

扉は開きました

ここまでの話をまとますと、Spring Dataを自作する入口は次のようになります。

  • アプリケーションの起動クラスにつける @EnableHogeHoge のようなアノテーションを作るところが入口
  • アノテーション@ImportRepositoryBeanDefinitionRegistrarSupport を継承したクラスを指定する
  • アノテーションrepositoryFactoryBeanClassTransactionalRepositoryFactoryBeanSupport を継承した RepositoryFactoryBean クラスを指定する
  • RepositoryFactoryBean クラスで RepositoryFactory を作って、Repository に対する実装クラスを返す

これで独自Spring Dataの扉を開くことができました。

しかし、セロさんの冒険はまだ始まったばかりです。

今後のセロ先生の活躍にご期待ください!

*1:一般的にはニワカと分類されるやつです

SpringのWebClientのリトライ機能を試してみました。

試してみましたブログです。

背景

仕事で数秒おきにWebのリクエストをリトライするような機能が必要になり、数秒待つのにスレッドを消費させたくないのでどうしたもんかなと思ってたところ、SpringのWebClientが使っているReactorの機能にリトライがあり、これを使えばスレッドを消費しなくて済みそうなので *1 どういう風に動くのか検証してみました。

検証したサンプルのソースコードGitHubに置いています。 github.com

その1 : 単純なリトライ

まずはMono#retryWhenを使ったシンプルなリトライを試してみます。

呼び出し側のコード

@GetMapping("/test1")
Mono<ResponseEntity<String>> test1() {
    return webClient.post()
            .uri("http://localhost:8080/error")
            .retrieve()
            .toEntity(String.class)
            .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1)));
}

retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1)) で1秒おきに3回リトライしています。

呼び出される側(エラーを起こす側)のコード

@PostMapping("/error")
ResponseEntity<String> error() {
    System.out.println("/error " + LocalDateTime.now());
    return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
}

現在時刻を表示した後、固定で503エラーを返しています。

実行結果

curlコマンド実行するとエラーが返ってきました。

% curl localhost:8080/test1
{"timestamp":"2022-12-21T11:55:49.153+00:00","path":"/test1","status":500,"error":"Internal Server Error","requestId":"9fdfe798-13"}

サーバ側のコンソールには次のように出力されています。

/error 2022-12-21T21:00:15.500676
/error 2022-12-21T21:00:16.538485
/error 2022-12-21T21:00:17.543379
/error 2022-12-21T21:00:18.549469
2022-12-21T21:00:18.565+09:00 ERROR 7455 --- [ctor-http-nio-3] a.w.r.e.AbstractErrorWebExceptionHandler : [53110703-1]  500 Server Error for HTTP GET "/test1"

reactor.core.Exceptions$RetryExhaustedException: Retries exhausted: 3/3
    at reactor.core.Exceptions.retryExhausted(Exceptions.java:306) ~[reactor-core-3.5.0.jar:3.5.0]
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    *__checkpoint ⇢ Handler ninja.cero.example.webflux.retry.TestController#test1() [DispatcherHandler]
    *__checkpoint ⇢ HTTP GET "/test1" [ExceptionHandlingWebHandler]
Original Stack Trace:
        at reactor.core.Exceptions.retryExhausted(Exceptions.java:306) ~[reactor-core-3.5.0.jar:3.5.0]
        at reactor.util.retry.RetryBackoffSpec.lambda$static$0(RetryBackoffSpec.java:68) ~[reactor-core-3.5.0.jar:3.5.0]
        at reactor.util.retry.RetryBackoffSpec.lambda$null$4(RetryBackoffSpec.java:560) ~[reactor-core-3.5.0.jar:3.5.0]
(略)
Caused by: org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error
    at org.springframework.web.reactive.function.client.WebClientResponseException.create(WebClientResponseException.java:337) ~[spring-webflux-6.0.2.jar:6.0.2]
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    *__checkpoint ⇢ 503 SERVICE_UNAVAILABLE from POST http://localhost:8080/error [DefaultWebClient]
Original Stack Trace:
        at org.springframework.web.reactive.function.client.WebClientResponseException.create(WebClientResponseException.java:337) ~[spring-webflux-6.0.2.jar:6.0.2]
(略)

まず /error で始まるメッセージのタイムスタンプが1秒ちょいおきになっており、きちんと間をおいてリクエストされていることが分かります。

またそのメッセージが4回出力されており、リトライ「3」回というのは、リクエスト「4」回になっていることが分かります。リクエストが3回って言ってるのに4回リクエストすると怒る人も世の中にいるので、リトライの話をする時にはリトライ回数なのかリクエスト回数なのか厳密に確認すると良いでしょう。

そして最終的に RetryExhaustedException: Retries exhausted: 3/3 として、3回リトライしたけどダメだった的な例外が発生して、スタックトレースが表示されています。おおむね想定通りです。

その2 : 失敗した後のリトライで成功する

続いて、リトライした後に成功するパターンを試してみます。

呼び出し側のコード

@GetMapping("/test2")
Mono<ResponseEntity<String>> test2() {
    return webClient.post()
            .uri("http://localhost:8080/error_twice")
            .retrieve()
            .toEntity(String.class)
            .doOnSuccess(r -> System.out.println(Thread.currentThread() + " Success " + LocalDateTime.now() + " " + r))
            .doOnError(th -> System.out.println(Thread.currentThread() + " Error " + LocalDateTime.now() + " " + th))
            .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1)));
}

先ほどとほとんど変わらないコードですが doOnSuccessdoOnError で、成功時・失敗時にそれぞれメッセージを出力するようにしました。

呼び出される側(エラーを起こす側)のコード

int counter = 0;

@PostMapping("/error_twice")
ResponseEntity<String> errorTwice() {
    counter++;
    System.out.println(counter + " /error_twice " + LocalDateTime.now());
    if (counter % 3 == 0) {
        return ResponseEntity.ok("OK");
    }

    return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
}

カウンターを作って、カウンターが3で割りきれる時だけ成功するようにしました。カウンターは最初AtomicIntegerで書いていたんですが、別にサンプルコードだしintで良いかとなってintにしました。良い子は真似しないでください。

実行結果

curlコマンド実行すると今度は正しくメッセージが返ってきました。

curl localhost:8080/test2
OK

サーバ側のコンソールには次のように出力されています。

1 /error_twice 2022-12-21T21:06:03.317283
Thread[reactor-http-nio-3,5,main] Error 2022-12-21T21:06:03.348214 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error_twice

2 /error_twice 2022-12-21T21:06:04.356433
Thread[reactor-http-nio-3,5,main] Error 2022-12-21T21:06:04.357669 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error_twice

3 /error_twice 2022-12-21T21:06:05.364996
Thread[reactor-http-nio-3,5,main] Success 2022-12-21T21:06:05.379515 <200 OK OK,OK,[Content-Type:"text/plain;charset=UTF-8", Content-Length:"2"]>

(読みやすいよう執筆時に空行を入れました)

カウンターが1と2の時はそれぞれ503が返り、3の時には200 OKを返していることが分かります。返却したメッセージも OK という文字列だったため、ちょっとメッセージ内に OK が多めに並んでいます。OK牧場

最終的に成功しているので例外が返ることはありません。逆に言えば、途中で例外が発生したことについて何か処理を入れたいのであれば doOnError の中で処理をきちんと書くことになります。

その3 : サーバからのレスポンスが1秒以上かかる

リトライ間隔は1秒としていますが、この1秒というのはリクエスト開始したタイミングから1秒なのか、レスポンスが返ってきてからの1秒なのか、もちろん後者だとは思いますが念のために確認しておきます。

呼び出し側のコード

@GetMapping("/test3")
Mono<ResponseEntity<String>> test3() {
    return webClient.post()
            .uri("http://localhost:8080/error_twice_await")
            .retrieve()
            .toEntity(String.class)
            .doOnSuccess(r -> System.out.println(Thread.currentThread() + " Success " + LocalDateTime.now() + " " + r))
            .doOnError(th -> System.out.println(Thread.currentThread() + " Error " + LocalDateTime.now() + " " + th))
            .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1)));
}

先ほどと呼び出しURL以外は変わっていません。

呼び出される側(エラーを起こす側)のコード

@PostMapping("/error_twice_await")
ResponseEntity<String> errorTwiceAwait() throws InterruptedException {
    Thread.sleep(2000L);

    counter++;
    System.out.println(counter + " /error_twice_await " + LocalDateTime.now());
    if (counter % 3 == 0) {
        return ResponseEntity.ok("OK");
    }

    return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
}

Thread.sleep(2000L) で2秒のスリープを入れています。Spring WebFlux(Netty)上でブロッキングなスリープをするという悪魔超人的な残虐行為ですが、検証なので問題ありません。良い子は真似しないでください。

実行結果

curlコマンド実行すると正しくメッセージが返ってきます。

curl localhost:8080/test3
OK

サーバ側のコンソールには次のように出力されています。

4 /error_twice_await 2022-12-21T21:21:30.103523
Thread[reactor-http-nio-3,5,main] Error 2022-12-21T21:21:30.106357 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error_twice_await

5 /error_twice_await 2022-12-21T21:21:33.110745
Thread[reactor-http-nio-3,5,main] Error 2022-12-21T21:21:33.113291 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error_twice_await

6 /error_twice_await 2022-12-21T21:21:36.119666
Thread[reactor-http-nio-3,5,main] Success 2022-12-21T21:21:36.122306 <200 OK OK,OK,[Content-Type:"text/plain;charset=UTF-8", Content-Length:"2"]>

ここで見るべきはメッセージが出力された時間で、ちょうど3秒ちょいおきに呼び出されていることが分かります。

サーバ側で2秒待ってからレスポンスを返し、そこから1秒待ってリトライしているので、合計3秒になるわけですね。これも想定通りです。問題ありません。

その4 : 最終的な失敗の時に処理を入れる

途中で失敗した場合には doOnError で処理ができると書きましたが、最終的な失敗にだけ処理を書きたい場合もあると思います。それも試してみました。

呼び出し側のコード

@GetMapping("/test4")
Mono<ResponseEntity<String>> test4() {
    return webClient.post()
            .uri("http://localhost:8080/error")
            .retrieve()
            .toEntity(String.class)
            .doOnError(th -> System.out.println(Thread.currentThread() + " Error1 " + LocalDateTime.now() + " " + th))
            .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1)))
            .doOnError(th -> System.out.println(Thread.currentThread() + " Error2 " + LocalDateTime.now() + " " + th));
}

doOnErrorretryWhen の前後で2回書いています。前のほうは Error1 という文字列、後のほうは Error2 という文字列を入れて出力しています。

とりあえず動かして結果を見てから説明しましょう。

実行結果

curlコマンド実行するとエラーが発生します。

curl localhost:8080/test4
{"timestamp":"2022-12-21T12:28:45.122+00:00","path":"/test4","status":500,"error":"Internal Server Error","requestId":"990c29b5-9"}

サーバ側のコンソールには次のように出力されています。

/error 2022-12-21T21:28:42.086369
Thread[reactor-http-nio-3,5,main] Error1 2022-12-21T21:28:42.087464 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error

/error 2022-12-21T21:28:43.094621
Thread[reactor-http-nio-3,5,main] Error1 2022-12-21T21:28:43.096406 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error

/error 2022-12-21T21:28:44.103211
Thread[reactor-http-nio-3,5,main] Error1 2022-12-21T21:28:44.104817 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error

/error 2022-12-21T21:28:45.107813
Thread[reactor-http-nio-3,5,main] Error1 2022-12-21T21:28:45.109541 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error

Thread[reactor-http-nio-3,5,main] Error2 2022-12-21T21:28:45.110772 reactor.core.Exceptions$RetryExhaustedException: Retries exhausted: 3/3
2022-12-21T21:28:45.125+09:00 ERROR 8812 --- [ctor-http-nio-3] a.w.r.e.AbstractErrorWebExceptionHandler : [990c29b5-9]  500 Server Error for HTTP GET "/test4"

reactor.core.Exceptions$RetryExhaustedException: Retries exhausted: 3/3
(略)

まず Error1 を含んだメッセージが WebClientResponseException$ServiceUnavailable という例外とともに4回出力されています。これがそれぞれのリクエストが失敗した際の処理です。

その後 Error2 を含んだメッセージが RetryExhaustedException とともに1回だけ出力されています。これが最終的に失敗した際の処理です。何か後処理を入れたければこの最後の doOnError に入れれば良いことが分かりました。

その5 : 最終的な失敗の時に例外をハンドリングする

最終的な失敗の時に例外を返すのではなく、任意のレスポンスを返したい場合もあると思います。そのパターンも試してみます。

呼び出し側のコード

@GetMapping("/test5")
Mono<ResponseEntity<String>> test5() {
    return webClient.post()
            .uri("http://localhost:8080/error")
            .retrieve()
            .toEntity(String.class)
            .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1)))
            .onErrorReturn(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body("リトライオーバー"));
}

最後の doOnError の代わりに onErrorReturn を使って503エラーのレスポンスオブジェクトを返すようにしました。これで例外をスローする代わりにオブジェクトを返すことができます。他にも発生した例外を元にして Function を書くことができる onErrorResume など、いくつかのエラーハンドリング用のメソッドが用意されています。

実行結果

curlコマンド実行するとエラーが発生します。

curl localhost:8080/test5 -i 
HTTP/1.1 503 Service Unavailable
Content-Type: text/plain;charset=UTF-8
Content-Length: 24

リトライオーバー

レスポンスのメッセージだけだとエラーだと分かりにくいので curl コマンドに -i オプションをつけて実行してステータスとヘッダを表示するようにしました。HTTPステータスが503となっており、上の onErrorReturn で作成したレスポンスオブジェクトの通りとなっていることが分かります。

サーバ側のコンソールには次のように出力されています。

/error 2022-12-21T21:45:07.584994
/error 2022-12-21T21:45:08.588875
/error 2022-12-21T21:45:09.598151
/error 2022-12-21T21:45:10.605840

例外は onErrorReturn でハンドリングされたため、スタックトレースなどは表示されていません。

その6 : 任意のHTTPステータスだけリトライする

ここまではWebClientのどのようなエラーでもリトライしていましたが、特定のエラー(503など)のみリトライしたい場合もよくあると思います。最後にそれを試してみます。

呼び出し側のコード

@GetMapping("/test6")
Mono<ResponseEntity<String>> test6() {
    return webClient.post()
            .uri("http://localhost:8080/error_503_404")
            .retrieve()
            .toEntity(String.class)
            .doOnError(th -> System.out.println(Thread.currentThread() + " Error " + LocalDateTime.now() + " " + th))
            .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1))
                    .filter(th -> {
                        if (th instanceof WebClientResponseException ex) {
                            return ex.getStatusCode().value() == 503;
                        }
                        return false;
                    }));
}

Retry#filter メソッドを使い、リトライする条件を指定しています。filter に渡した Predicatetrue を返したときはリトライして false を返した時にはリトライしないという挙動になります。ここでは発生した例外が WebClientResponseException であり、そのHTTPステータスコードが503の時だけ true を返すようにしています。

呼び出される側(エラーを起こす側)のコード

@PostMapping("/error_503_404")
ResponseEntity<String> errorTwice503() {
    counter++;
    System.out.println(counter + " /error_503_404 " + LocalDateTime.now());
    if (counter % 3 == 0) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    }

    return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
}

カウンターが3で割りきれる場合だけ 404 を返し、それ以外は 503 を返すという処理にしています。503 だったからリトライし続けて、成功したと思ったら 404 ってちょっと絶望しますけどね。まぁ内容は気にしないでください。

実行結果

curlコマンド実行するとエラーが発生します。

curl localhost:8080/test6
{"timestamp":"2022-12-21T13:01:31.209+00:00","path":"/test6","status":500,"error":"Internal Server Error","requestId":"9cb1d7f4-1"}

サーバ側のコンソールには次のように出力されています。

7 /error_503_404 2022-12-21T22:01:29.151704
Thread[reactor-http-nio-3,5,main] Error 2022-12-21T22:01:29.181986 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error_503_404

8 /error_503_404 2022-12-21T22:01:30.187425
Thread[reactor-http-nio-3,5,main] Error 2022-12-21T22:01:30.189003 org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable: 503 Service Unavailable from POST http://localhost:8080/error_503_404

9 /error_503_404 2022-12-21T22:01:31.196340
Thread[reactor-http-nio-3,5,main] Error 2022-12-21T22:01:31.198393 org.springframework.web.reactive.function.client.WebClientResponseException$NotFound: 404 Not Found from POST http://localhost:8080/error_503_404
2022-12-21T22:01:31.212+09:00 ERROR 12778 --- [ctor-http-nio-3] a.w.r.e.AbstractErrorWebExceptionHandler : [9cb1d7f4-1]  500 Server Error for HTTP GET "/test6"

org.springframework.web.reactive.function.client.WebClientResponseException$NotFound: 404 Not Found from POST http://localhost:8080/error_503_404
(略)

2回の503エラーが発生した後、3回目のリクエストで404エラーが返ってきていることが分かります。

そして3回目のリクエストではリトライをせず、そこで諦めてエラーで終了となっているため RetryExhaustedException は発生せず、代わりに最後に発生した WebClientResponseException$NotFound をスローした形となりました。

所感

全体的にナルホドナという挙動であり、違和感はありませんでした。

本当はもっと大量にリクエストを投げつけて、スレッドを消費せずにリトライ間隔を待ち受けできてるなーというのを確認したかったのですが、いったんここで力尽きたのでした。どうせだいたい動くっしょ。

そんなわけで、WebClientのリトライ機能について試してみたことは以上です。

いかがでしたか?

*1:スレッドを消費しないことは未検証です

海外旅行でSony Bank WALLETとSBIプラチナデビットが活躍した話。

行ってきました、JavaOne 2022。

JavaOneとしての開催は2017年以来、Oracle Code Oneを含めても2019年以来の開催です。僕自身としても、2020年1月のJfokus / FOSDEMとベビメタヨーロッパツアー参戦以来となる、久々の海外旅行でした。

さて今回はJavaに全く関係ない、お金の話題です。

さかのぼること2019年頃、海外旅行をお得にするためにソニー銀行住信SBIネット銀行(以降、SBI銀行)の口座を開設してそれぞれデビットカードを作っていました。しかし感染症の広がりにより海外で使い込むことはないまま2年半が過ぎてしまいましたが、今回の旅行でようやく使うことができました。

この2つの銀行については、過去のブログで比較しています。 cero-t.hatenadiary.jp

今回はこの2つの銀行が発行するデビットカードSony Bank WALLETとSBI銀行のデビットカード(旧ミライノデビット。以降、SBIデビット)を実際に使ってみてどれくらい役だったかについて書きたいと思います。

事前にやっていたこと

まずは事前にやっていたことを書いておきます。

外貨積立

2年ほど前から外貨積立を行っていました。ソニー銀行でドル、ポンド、ユーロを1万円ずつ、SBI銀行でドルを1万円ずつです。

旅行時点で外貨レートが積み立てた分よりも高ければ積み立てた分から払うし、安ければ別のクレカから払えば良いかな、ぐらいの軽い気持ちで始めた積立でしたが、よもやこんな円安になるとはという気持ちですね。

積立以外にも外貨定期預金や米国株の取引の都合でドルをまとめて買うタイミングがあり、1ドル平均110〜115円くらいのレートでドルを保有している形になっていますした。最近は1ドル150円前後の異常な水準ですから、今回の旅行中はすべて積み立てたドルで支払いました。

SBIデビットをプラチナに変更

ソニー銀行もSBI銀行もデビットカードは年会費無料ですが、SBI銀行の方を年会費11,000円のプラチナデビットカード(以降、SBIプラチナデビット)に変更しました。 www.netbk.co.jp

変更した理由は海外旅行とは全然関係なく、目当ては「モバイル端末の保険」でした。これはノートPCやスマートホンが故障した際に10万円まで補償を受けられるというものです。しかも自分だけでなく家族が所有している端末まで保証対象になります。ケータイキャリアの保証サービスや、Apple Careなどに入れば1台分で年間1万円くらいは掛かりますから、11,000円という年会費は家族持ちのガジェオタにとっては破格の安さです。補償を受けられるのは年に1回だけですが、壊れる可能性から考えれば年に1回で十分です。

海外旅行とは関係ないきっかけでプラチナカードにしましたが、結果的にはこれが助かることになりました(後述)

旅行中に助かったこと

さて、実際に旅行で助かったことについて書いてきます。

円安を無視

まずもって嬉しいのは、現在のドル円ルートを無視して支払いができることですね。これが一番です。

デビットカードで支払えば、ソニー銀行、SBI銀行それぞれのドル口座からドルを直接引き落とす形になるので、自分が買った時のレート(110〜115円)で払ったという感覚になります。周りの人たちが1ドル150円で計算している中、僕はこっそり110〜115円くらいで計算していました。

支払い時の手数料なし

海外でクレジットカードやデビットカードを使うと、支払い時に手数料が1.6%(楽天カードなど)〜2.2%(三井住友カードなど)くらい掛かります。たとえば1000ドル使うと3000円前後になる計算です。

この手数料ですが、まずソニー銀行Sony Bank WALLETは手数料が掛かりません。0円です。ヤバい。

またSBIデビットでは2.5%の手数料が掛かりますが、年間30回分までの決済では手数料分のポイントがキャッシュバックされます。1週間ほど旅行すれば決済回数が30回を越えるかも知れないため、旅行中はSony Bank WALLETを中心に使うようにしていました。

電車代をApple Pay経由で払える(SBIデビット)

電車のタッチ決済は世界中で日本が最も進んでいる印象がありますが、サンフランシスコにはClipperカード、ロサンゼルスではTAPカードというタッチ決済の仕組みがあり、2020年頃からついにスマホのタッチにも対応しました。

専用のアプリをインストールすることもできますが、iPhoneであればApple Payにこれらのカードを追加するだけで使えるようになります。これはモバイルSuicaも同じで、iPhoneモバイルSuicaアプリをインストールして設定することもできますし、Apple Payに直接Suicaを追加することもできます。Apple Pay単体ではチャージと支払いのみできますが、専用アプリをインストールすれば柔軟に設定できたり、定期券を買えたりできる、という点も同じです。

ClipperやTAPをApple Payに直接追加できる

今回の旅行ではロサンゼルスのTAPカードをApple Payに追加しました。この料金をドルで払うためにはApple Payで決済する必要があるのですが、Sony Bank WALLETがApple Payに対応してなかったため、SBIデビットを使いました。

ちなみにその後、1日券を買うためにTAPアプリをインストールしまして、アプリをインストールすればSony Bank WALLETからもチャージできるようになるので、どうしてもApple Payが必要だったというわけではありませんでした。

ロサンゼルス空港のラウンジが使える(SBIプラチナデビット)

SBIプラチナデビットでは「ラウンジキー」という海外ラウンジを使える仕組みがあり、年間3回までラウンジを使うことができます。今回の旅行中ではロサンゼルスの空港でラウンジを使いました。

ロサンゼルスのゲーミングラウンジ

ジュース1本とお菓子1つもらえて、Steam / Xbox S / PS5のいずれかを1時間プレイできるという謎のゲーミングラウンジでしたが、ざっくり3000円分くらいの価値でしょうか。これも年間3回とも使いきれば、年会費をペイできるような価値になります。

空港から自宅への荷物配送が無料(SBIプラチナデビット)

SBIプラチナデビットのサービスの一つに、海外旅行時の空港⇔自宅間での荷物の無料配送があります。僕は出国時に前もって荷造りして配送できるようなデキた人間ではないので、帰国時のみ利用しました。

帰国時の配送では、海外でSBIデビットを使ったレシートを見せる必要があるので、スーパーで買い物した時のレシートを1枚だけ取っておきました。

今回は成田空港の利用で、自宅に帰るまでに電車の乗り換えが何度かあったため配送できたのは楽で良かったですね。自腹で送ると2000円くらいでしょうか。

JALの機内Wi-Fiが使える(SBIプラチナデビット)

なんだかSBIプラチナデビットの特典の話ばかりになっているのですが、これも同カードの特典で、Boingo Wi-Fiが無料で使えるというものがあります。あまり聞き慣れないWi-Fiサービスですが、JAL国際線の機内で使うことができます。通常、18.80ドルかかるWi-Fiサービスが無料になるわけです。

飛行機の中からツイートするというプライスレスな体験を行うためにも、機内Wi-Fiは不可欠ですよね!

ただ往路では自分がBoingo Wi-Fiを使えることを認識してなかったので使いそびれてしまい、復路ではファーストクラスに乗ったためWi-Fiを無料で使えることになり、結果的には使わなかったのですが、また次回以降のフライトで活用したいと思います。

まとめ

最後に、両者のメリットをまとめておきます。

  • 両方に共通するメリット
    • 円高の時に仕込んだドルで決済できる
  • Sony Bank WALLETの良いところ
    • 海外利用時の決済手数料が掛からない(無制限)
  • SBIデビットの良いところ
    • Apple Payが使える
    • 海外利用時の決済手数料が掛からない(年間30回まで)
  • SBIプラチナデビットの良いところ
    • モバイル端末保険がつく
    • ラウンジが使える(年間3回まで)
    • 空港と自宅間の荷物配送が無料(国際線限定)
    • JAL国際線の機内Wi-Fiが使える

それぞれにメリットがあるので両方とも作っておくことをオススメしたいのですが、管理する口座を2つも増やしたくないというのであれば、、、難しいですね。冒頭に書いた銀行そのものの比較のブログも含めて悩んでもらうことになると思いますが、僕ならSBI銀行とプラチナデビットカードにするかなと思います。

いずれにせよ、管理する手間を多少かけても十分にペイするお得な銀行とデビットカードです。これで少しでも皆さんの海外旅行がお得になればと思います。

それでは!


大事なことなのですが、ブログを読んでソニー銀行を開設しよっかなという気持ちになった人は、2000円 or 4000円がもらえる紹介キャンペーンに招待するのでお知らせください!

ご家族・ご友人 紹介プログラム|MONEYKit - ソニー銀行

紹介すると僕の方も1500円もらえるのでWin-Winです!!

陸マイラーは何で税金を払うべきか。au Payの1択か?

友人から「自動車税や固定資産税などの税金は、何で払うのがお得なの?」と聞かれました。僕はau PAYで払っているのですが、他の支払いだとどうなるのかをきちんと調べたことがなかったので、調べてみました。これは調べてみましたブログです。

なお、前提として貯めるのはJALANAのマイルであり、JALカードANAカードを使っている想定です。

au PAYの場合 : クレカのマイル(0.5〜1.5%) + 0.5%のPonta(≒0.3%のJALマイル)

  • 使えるクレジットカードが多い
  • 残高チャージでクレジットカードのポイントが貯まる(一部カードを除く)
  • 請求書払いで0.5%のPontaがもらえる
  • PayPayより使える店や自治体が少ない

au PAYの特徴は チャージできるクレジットカードの種類が多い ことです。MastercardとAMEXのすべてのカードと、VISAとJCBは一部のカードが使えます。JCBビューカードが使えるため、僕はJALカードSuicaを登録しています。

またau PAYへのクレジットカードのチャージは 通常のショッピングと同じポイント還元 があります。これも他のPAYではなかなかない特徴です。ただし、JALカードの一部ブランドのようにポイント還元されないカードもあるため、事前にきちんと確認してください。僕が使っているJALカードSuicaは問題なく1%のマイルが還元されることを確認しています。

www.jal.co.jp

さらに、au PAYでは 請求書払いで0.5%のPontaが還元 されます。Pontaは50%のレートでJALマイルに交換できます。時々キャンペーンで60%のレートになるので、このキャンペーンを待って交換するのが良いと思います。つまりチャージ時の還元とは別に、0.25〜0.3%のマイル還元がある計算になります。

ANAのマイルを貯めている人はPontaが貯まってもあまり嬉しくないかも知れないので、Pontaが使える店で消化してしまうなり、レートが悪くともJALマイルから別のポイントを経由してANAマイルに交換することになりますね。

そんな還元率の良いau PAYですが、まだPayPayほどは使える店や自治体が多くありません。僕が住んでいる自治体は、昨年(2021年)の12月からやっとau PAYでの納税が可能になりました。そこだけがネックですね。

PayPayの場合 : ソフトバンクグループの回線を持っていればクレカのマイル(0.5〜1.5%) 持っていなければ現金同様

  • 使える店や自治体が多い
  • YahooカードかPayPayカードでしかチャージできない
  • 残高チャージでクレジットカードのポイントが貯まらない
  • 請求書払いの還元もない
  • ソフトバンクグループの回線ならまとめて支払いでポイントが貯まる

PayPayの特徴は 使える店や自治体が多い ことです。クレジットカードは使えないけどPayPayだけは使えるという店だってよくありました。自治体でもPayPayとLINE Payのみ対応しているところがまだあると思います。

使える場所が多いPayPayですが、クレジットカードからチャージしようと思うとYahooカードかPayPayカードしか使えないうえ PayPay残高にチャージしてもクレジットカード側でのポイントは貯まらない のです。PayPayの請求書払いはPayPay残高でしか払えないため、実質的に現金でチャージするのと変わりません。

またPayPay請求書払いで納税すると、かつては0.5%のPayPayボーナスが還元されていたのですが、それも2022年3月いっぱいでなくなり、いまは完全に 現金納付と同じ になってしまいました。

paypay.ne.jp

ただ一つだけPayPayのチャージで還元を受ける方法があり、それがソフトバンクグループの回線(ソフトバンク、ワイモバイル、LINEMO)の まとめて支払いからのチャージ です。まとめて支払いを使ってチャージすると回線料金の支払いに使っているクレジットカードに請求されるため、そこでクレジットカードのポイント還元が受けられます。

ただし、まとめて支払いは上限額が契約状況によって変わり、回線開通直後などは月に1万円や3万円くらいまでしか使えないこともあります。回線を維持していると毎月上限額が増えていき、上限の最大は10万円になります。ただし税金額が高い場合には月10万円でも足りない場合があるため、この方法でチャージする場合は 毎月、計画的にチャージする必要がある でしょうね。

僕は納税ではPayPayを使っていませんが、PayPayしか使えない店での支払いや、PayPay祭りのような還元が大きいイベントに備えて、ソフトバンクやワイモバイルの回線から定期的にチャージするようにしています。

LINE Payの場合 : 0.5%のVポイント(≒0.25%のマイル)

LINE Payについては実際に使っていないので、調べた限りの情報を書きます。間違っているところがあれば教えてもらえると嬉しいです。

まず大前提として LINE Payで貯まるLINEポイントはマイルと交換できません陸マイラーとしてはこの時点でやる気がゼロになります。

気を取り直してもう少し調べると LINE Payの残高にはクレジットカードからのチャージもできません。なかなか手強いですね。

ただLINE Payの請求書払いは、残高からの支払いだけでなく、クレジットカードから直接払う「チャージ&ペイ」という方法でも支払うことができます。光明が差してきました。

このチャージ&ペイは、Visa LINE Payクレジットカードか三井住友カードを使うことができます。三井住友カードであればマイルが貯まるのではと思ったのですが、なんと ANAカードは対象外 ですし、そもそもJALには三井住友カードがありません。終わりましたね。

www.smbc-card.com

ANAカード以外の三井住友カードであれば チャージ&ペイによる請求書払いで200円につき1ポイントのVポイント が貯まり、50%のレートでANAマイルに交換できるので、少しANAマイルを貯めることができますね。JALマイルに持っていくのは大変なので、JALマイルしか貯めていない場合はVポイントとして消化するのが良いでしょう。

クレジットカードの場合 : クレカのマイル(0.5〜1.5%) ただし手数料が1%程度

  • メジャーなクレジットカードはだいたい使える
  • 手数料が1%掛かる

ここまで三大Payを見てきましたが、条件が厳しかったり、ルールが複雑だったりして難しい面がありましたが、そもそもPayではなくクレジットカードで直接払ってしまえばどうでしょうか。

クレジットカード納付は、VISA、mastercard、JCB、AMEX、ダイナースなどのメジャーなクレジットカードを使うことができ、一部のカードを除いて 通常のショッピングと同様のポイント還元 が受けられます。

ただしクレジットカードで納付する場合、自治体にもよりますが、おおむね 1%前後のシステム利用料 が掛かります。一部のプラチナカードでない限り、クレジットカードの還元は0.5%〜1%ですから、なかなか判断が難しいところです。

1マイルの価値が最低2円以上あると考えれば、1%〜2%の還元になるわけですから損はしないと考えることもできますね。この辺りはマイルの使い方、捉え方次第ということになります。

まとめ

まとめ代わりに、1%還元されるJAL/ANAのクレジットカードを持っていて、10万円の納税をする場合の還元マイルを計算してみます。

  1. au PAY : 1000マイル + JAL 300マイル
  2. PayPay : 1000マイル(ソフトバンクグループの回線が必要)
  3. LINE Pay : ANA 250マイル(ANAカード以外の三井住友カードが必要)
  4. クレジットカード : 1000マイル(手数料1000円)

こうなりました。

見るからにau PAYの1強という感じではありますが、自治体ごとに使える支払い方法や、持っているカードや回線、また貯めているマイルの種類などで最適なものを選んでもらえればと思います。

いかがでしたか?