mike-neckのブログ

Java or Groovy or Swift or Golang

Reactor Test の StepVerifier の withVirtualTime の使い方

f:id:mike_neck:20180221005726p:plain

Spring WebFlux で project Reactor が使われていて、まだ手に馴染んでいないので、 チュートリアル をやっていたところ、 StepVerifier#withVirtualTime の使い方がわからず、検索しても要領を得ず、いろいろ試して使い方がわかったので、そのメモ

tech.io


前提知識

Mono<T>Flux<T> のテストには StepVerifier を用いてテストを行う

@Test
void sampleUsageOfStepVerifier() {
  StepVerifier.create(Flux.just("foo", "bar", "baz"))
      .expectNext("foo")
      .expectNext("bar")
      .expectNext("baz")
      .verifyComplete();
}

Flux<T> などの内部のスケジューラーを操作するために StepVerifier#withVirtualTime(Supplier<Publisher<T>>) というメソッドがある

これで、100時間かかるようなスケジューリングをしている Flux でも数秒でテストできる。


問題

生成まで 1 秒、イベントの間隔が 1 秒で 0 から 1 ずつ単調増加する 3600 個の順列 Flux<Long> がある。この Flux<Long> に対して、次のテストをおこなえるコードを記述すること

  • 3600 個の要素があること
  • 任意の3箇所の値が正しい値であるかを検証すること
  • 2 秒以内に終わること

テスト対象の Flux<Long>

テストのデータになる Flux<Long> は次のように生成する。

Supplier<Flux<Long>> flux() {
  final Duration oneSecond = Duration.ofSecond(1L);
  return () -> Flux.interval(oneSecond, oneSecond)
      .take(3_600L)
      .log();
}

最初に書いたダメなテスト

1秒を3598回待つというコードをどのように書くのかわからず、とりあえず書いたコードです。

@Test
void tooLongFlux() {
  StepVerifier.withVirtual(flux())
      .expectSubscription() // 購読開始
      .expectNoEvent(Duration.ofSeconds(1L)) // 発生まで1秒
      .expectNext(0L) // 最初の要素
      .thenAwait(Duration.ofSeconds(1L)) // 1秒待つ
      .expectNext(1L) // 2回目の要素
      .thenAwait() // ん?1秒 x 3598 回 をどうやって待つ?
      .expectNextCount(3597L)
      .expectNext(3599L) // 最後の要素
      .verifyComplete(); // 終わったことを確認
}

これを実行すると次のようにログが表示されたところで動かなくなります。

[ INFO] (pool-1-thread-1) onSubscribe(FluxTake.TakeSubscriber)
[ INFO] (pool-1-thread-1) request(unbounded)
[ INFO] (pool-1-thread-1) onNext(0)
[ INFO] (pool-1-thread-1) onNext(1)

次に書いたイケてないコード

とりあえず、強引に1秒 x 3598回待つようにコードを書きます。

@Test
void tooLongFlux() {
  final StepVerifier.Step<Long> verifier = StepVerifier.withVirtual(flux())
      .expectSubscription()
      .expectNoEvent(Duration.ofSeconds(1L));
  final StepVerifier.Step<Long> intermediate = Interval.fromTo(0, 3599)
      .injectInto(verifier,
          (step, index) -> step.expectNext((long) index)
              .thenAwait(Duration.ofSeconds(1L)));
  intermediate.verifyComplete();
}

これはすべての要素を検査するので、正しいコードと言えば正しいコードです。とはいえ、一部を省略していいという緩めのテストなので、若干過剰でもあります。

やっとたどり着いたコード

ここで先ほどのコードでは 3600 秒待てばよいところを 3601 秒待っていることに気づいたので、 thenAwait はトータルで 3600 秒進ませればあとは間隔が空いていない Flux<T> と同じテストと変わらないのではないかと予想しました。

@Test
void tooLongFlux() {
  StepVerifier.withVirtual(flux())
      .expectSubscription()
      .expectNoEvent(Duration.ofSeconds(1L))
      .expectNext(0L)
      .thenAwait(Duration.ofSeconds(1L))
      .expectNext(1L)
      .thenAwait(Duration.ofSeconds(3600L - 2L)) // 3600 - 2 要素分スケジューラーを進ませる
      .expectNextCount(3600L - 3) // 値をテストする3個を除いた個数を飛ばす
      .expectNext(3599L)
      .verifyComplete();
}

これを実行したところ、先ほどのテストと同様にパスできました。

thenAwait(Duration) はその都度待つ時間をあらわすでのはなく、一気に時間を飛ばすというメソッドでした。 thenAwait(Duration) の挙動がいまいち分かりづらかったので、このテストについて長時間ハマってしまいました。


まとめ

非常に長い Flux<T> をテストする場合は、 StepVerifier#withVirtualTime でスケジュールを調整できる StepVerifier.Step<T> を作った上で、 thenAwait(Duration) で進ませたい時間を進ませることによって、すべての要素のテストが実施できる。

リファクタリングの手順 メソッドの抽出 -> メソッドの別クラスへの移動

IntelliJ での基本的なリファクタリングの手順です。


次のような二つのクラスを考えます。

public class WithAccessTokenSecurityContextFactory implements WithSecurityContextFactory<WithAccessToken> {

    @Override
    public SecurityContext createSecurityContext(final WithAccessToken annotation) {
        final SecurityContext context = SecurityContextHolder.createEmptyContext();
        final GivenAccessToken accessToken = new GivenAccessToken(annotation);
        final AccessTokenEntity accessTokenEntity = accessToken.accessTokenEntity(); // ここから
        final Authentication authentication = new Authentication(accessTokenEntity);
        final OAuth2Authentication oauth2 = authentication.oauth2(); // ここまでを一つにまとめたい
        context.setAuthentication(oauth2);
        return context;
    }
}
class GivenAccessToken {
    GivenAccessToken(final WithAccessToken accessToken) {
        // 省略
    }
    AccessTokenEntity accessTokenEntity() {
        // 省略
    }
}

このように7行もあるメソッドは長すぎるし、利用するクラスが実装を知りすぎているので、一つにまとめて見通しを良くしたいところです。

1. メソッドの抽出

最初に WithAccessTokenSecurityContextFactory の内部に private メソッドとして抽出します。

  • コード内で「ここから」〜「ここまで」とした範囲を選択する

f:id:mike_neck:20180215225007p:plain

  • Refactor > Extract > Method を選択する。あるいは + + M を押す

f:id:mike_neck:20180215225049p:plain

  • ダイアログでメソッドの可視性、型、メソッド名、パラメーターを確認してメソッドの抽出を実行

f:id:mike_neck:20180215225235p:plain

抽出が完了したら次のような形になります。

public class WithAccessTokenSecurityContextFactory implements WithSecurityContextFactory<WithAccessToken> {

    @Override
    public SecurityContext createSecurityContext(final WithAccessToken annotation) {
        final SecurityContext context = SecurityContextHolder.createEmptyContext();
        final GivenAccessToken accessToken = new GivenAccessToken(annotation);
        final OAuth2Authentication oauth2 = oauth2(accessToken);
        context.setAuthentication(oauth2);
        return context;
    }

    private OAuth2Authentication oauth2(final GivenAccessToken accessToken) {
        final AccessTokenEntity accessTokenEntity = accessToken.accessTokenEntity();
        final Authentication authentication = new Authentication(accessTokenEntity);
        return authentication.oauth2();
    }
}

2. メソッドの別クラスへの移動

抽出したメソッドおよび抽出されたメソッドは短くなりますが、今のままだと WithAccessTokenSecurityContextFactoryGivenAccessTokenOAuth2Authentication の内部実装を知りすぎています。このような操作は GivenAccessToken が抑えておけばよい内容です。したがって、 oauth2 メソッドを GivenAccessToken に移動します。

  1. oauth2 メソッドにカーソルをあわせる
  2. Refactor > Move を選択する。あるいは F6

f:id:mike_neck:20180215225916p:plain

  • ダイアログで移動先のクラスを選択して移動を実行

f:id:mike_neck:20180215230032p:plain

移動完了後は次のようになっている

public class WithAccessTokenSecurityContextFactory implements WithSecurityContextFactory<WithAccessToken> {

    @Override
    public SecurityContext createSecurityContext(final WithAccessToken annotation) {
        final SecurityContext context = SecurityContextHolder.createEmptyContext();
        final GivenAccessToken accessToken = new GivenAccessToken(annotation);
        final OAuth2Authentication oauth2 = accessToken.oauth2();
        context.setAuthentication(oauth2);
        return context;
    }
}
class GivenAccessToken {
    GivenAccessToken(final WithAccessToken accessToken) {
        // 省略
    }
    AccessTokenEntity accessTokenEntity() {
        // 省略
    }
    private OAuth2Authentication oauth2() {
        final AccessTokenEntity accessTokenEntity = accessTokenEntity();
        final Authentication authentication = new Authentication(accessTokenEntity);
        return authentication.oauth2();
    }
}

privateメソッドに一度抽出しているが、これを飛ばしてメソッド抽出+メソッドを別クラスへ移動することはできない(っぽい)

Spring WebFlux Security で ユーザー認証を実装する + ハンドラーでユーザーの情報を取り出す

Spring Security の WebFlux Security Configuration という公式のドキュメントにあるセクションにも書いてあるのだが、ユーザー認証の実装はこれまでの Spring Security にある UserDetails の Reactive 版である ReactiveUserDetails を実装したオブジェクトをBean登録すれば実現できる。

サンプルとして、Cassandraに保存してあるユーザーの情報を以てユーザー認証をおこなうアプリケーションを作ってみた。


環境

  • Spring Boot: 2.0.0.RC1
  • Java: 8
  • Cassandra: 3.11.1

ユーザーのテーブル

ユーザーをあらわすクラスを作る

@Table("users")
@Value
public class User {
  @PrimaryKey
  private final String id;
  @Indexed
  private final String username;
  @JsonIgnore
  private final String password;
  @JsonIgnore
  private final Set<UserRole> roles;
  private final LocalDateTime created;

  public static User createNew(final String username, final String password) {
    final String id = UUID.randomUUID().toString();
    return new User(id, username, password, UserRole.forNormalUser(), LocalDateTime.now());
  }
}

ユーザー名(username)に @Indexed をつけておかないと、Cassandraでユーザー名をキーにユーザーを取得できないので注意。


ユーザーのロールをあらわすクラスを作る

public enum UserRole implements GrantedAuthority {
  USER, ADMIN;
  @Override
  public String getAuthority() {
    return name();
  }
}

User の Cassandra の Repository インターフェースを作る

@Repository
public interface UserRepository extends ReactiveCrudRepository<User, String> {
  Mono<User> findByUsername(final String username);
}

戻り値が Mono<User> にするあたりが Web Flux っぽい(よくわかってない)


これまでの Spring Security のように UserDetails を実装したクラスをつくるのだが、 org.springframework.security.core.userdetails.User と名前を被らせてしまったので、 AuthenticatedUser というクラスを作る

public class AuthenticatedUser implements UserDetails {
  private final User user; // <- 先程作った User の方
  public AuthenticatedUser(final User user) {
    this.user = user;
  }
  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return user.getRoles();
  }
  // その他メソッドは割愛
}

この AuthenticatedUser を ユーザー名から取り出すサービスクラス ReactiveUserDetailsService をBean登録する

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
  private final UserRepository userRepository;
  // コンストラクターは割愛

  @Bean
  ReactiveUserDetailsService userDetailsService() {
    return username -> userRepository.findByUsername(username)
        .map(AuthenticatedUser::new);
  }

  @Bean
  PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
  }
}

PasswordEncoderNoOpPasswordEncoder を使ってしまっているが、どこかに提供するアプリケーションでもないので、ここでは不問。プロダクションで使う場合は、適切なものを使うべき。


このまま動かしても、ユーザーがCassandraにないので、当然401 UnAuthorized が返されまくる。そこで、アプリケーション起動時にCassandraにユーザーを登録する。

@SpringBootApplication(exclude = { CassandraDataAutoConfiguration.class })
@EnableWebFlux
@Slf4j
public class App {
  private final UserRepository userRepository;
  // コンストラクター割愛

  @Bean
  CommandLineRunner commandLineRunner() {
    return args -> {
        final User user = saveUser(User.createNew("foo", "bar"))
            .block();
        log.info("created user: {}", user);
  }

  @Transactional
  Mono<User> saveUser(final User user) {
    return userRepository.save(user);
  }
}

これでユーザー認証が実現できた


さらに認証したユーザーの情報をそのまま返すurlを作ってみる。

認証の情報は ServerRequest#principal() というメソッドを実行すると UsernamePasswordAuthenticationToken というクラスのインスタンス(Mono に包まれている)が返されるので、 UsernamePasswordAuthenticationToken#getPrincipal というメソッドで取得できる。

Mono<ServerResponse> me(final ServerRequest request) {
  final Mono<User> user = request.principal()
      .cast(UsernamePasswordAuthenticationToken.class)
      .map(UsernamePasswordAuthenticationToken::getPrincipal)
      .cast(AuthenticatedUser.class)
      .map(AuthenticatedUser::getUser);
  return ServerResponse.ok()
      .contentType(MediaType.APPLICATION_JSON)
      .body(user, User.class);
}

@Bean
RouterFunction<ServerResponse> userRouterFunction() {
  return route(GET("/users/me"), this::me);
}

Spring Web Flux を少しさわってみただけの話

Spring 5 から Reactive Web というのになるらしく、サーバーの性能が落ちにくくなると聞いた(違うかもしれない)ので、 家で試してみることにした。

環境

  • Spring Boot: 2.0.0.RC1
  • Java: 1.8

ビルドファイル

以下の curl コマンドで取得できるもの

curl https://start.spring.io/build.gradle -d dependencies=security,webflux -d bootVersion=2.0.0.RC1

なお、依存ライブラリーは次のようになっている

dependencies {
  compile('org.springframework.boot:spring-boot-starter-security')
  compile('org.springframework.boot:spring-boot-starter-webflux')
  testCompile('org.springframework.boot:spring-boot-starter-test')
  testCompile('io.projectreactor:reactor-test')
  testCompile('org.springframework.security:spring-security-test')
}

ハンドラーファンクション

リクエストをさばくために、 org.springframework.web.reactive.function.server.ServerRequest を受け取って、 reactor.core.publisher.Mono または reactor.core.publisher.Flux を返す関数、または メソッドを作る

// クラス名は MyHandler
Mono<ServerResponse> hello(final ServerRequest serverRequest) {
  return ServerResponse.ok()
      .contentType(MediaType.APPLICATION_JSON)
      .body(
          Mono.just(new Message("hello", OffsetDateTime.now(ZoneId.of("Z")))),
          Message.class);
}

なお、jsonマッピングするクラスは次のようなクラス

@Value
static class Message {
  final String text;
  final OffsetDateTime time;
}

ルーターファンクション

ハンドラーファンクションに対して、URLをマッピングする、ルーターファンクション(org.springframework.web.reactive.function.server.RouterFunction)をBean登録する

// クラス名は MyConfig
@Bean
RouterFunction<ServerResponse> routerFunction(
    final SpringWebfluxDemoApplication springWebfluxDemoApplication) {
  return route(GET("/hello"), myHandler::hello);
}

なお、このメソッドのある MyConfig クラスには @org.springframework.web.reactive.config.EnableWebFlux アノテーションをつける

@EnableWebFlux
@Configuration
public class MyConfig {
  // 省略
}

セキュリティ

元々セキュリティをつけようとは思っていなかったのだが、間違えてつけてしまったので、その設定もしてみる

Reactive web ではないアプリケーションの場合は UserDetailsService を Bean 登録していたが、 Reactive web であるアプリケーションの場合には ReactiveUserDetailsService を Bean 登録する

ドキュメントを読んでいると、 MapReactiveUserDetailsService なるクラスがあるので、そちらを利用する

@Bean
ReactiveUserDetailsService userDetailsService() {
  final UserDetails userDetails = User.withDefaultPasswordEncoder()
      .username("foo")
      .password("bar")
      .roles("BAZ", "QUX")
      .build();
  return new MapReactiveUserDetailsService(userDetails);
}

いつもの

あとはいつものとおり、 @SpringBootApplication アノテーションをつけたクラスと、 main メソッドを作る

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

動かしてみた

アプリケーションを起動して curl でアクセスしてみる

f:id:mike_neck:20180208030819p:plain

$ curl -v http://localhost:8080/hello -H "Authorization: Basic $(echo -n 'foo:bar' | openssl base64)"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Authorization: Basic Zm9vOmJhcg==
> 
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: application/json
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XSS-Protection: 1 ; mode=block
< 
* Connection #0 to host localhost left intact
{"text":"hello","time":1518024505.503000000}

まあ、なんか、動いた


OffsetDateTime のところがタイムスタンプになっていたので Jackson のカスタマイズ方法をドキュメントから探して CodecCustomizer を Bean 登録すればよいことがわかったのだが、これはうまくいかなかった…

@Bean
CodecCustomizer addJacksonJsr310Support() {
  return configurer -> configurer.customCodecs()
      .encoder(new Jackson2JsonEncoder(objectMapper(), MimeTypeUtils.APPLICATION_JSON));
}

おわり