Micronaut 2.0.0 に version up する

f:id:nemuzuka:20200611200142p:plain

前回 までの奴は micronaut 1.3.6 でした。

先日 2.0.0 がリリースされたので、それに追従してみましょう。

Session Authentication

https://micronaut-projects.github.io/micronaut-security/latest/guide/#session

依存 jar が変わります。

annotationProcessor "io.micronaut.security:micronaut-security-annotations"
implementation "io.micronaut.security:micronaut-security-session"

AuthenticationProvider の Deprecated なメソッドが削除されたので消しておきます。

application.yml 上の設定も変わります。何となく追えると思います。

Thymeleaf

依存 jar が変わります。

implementation "io.micronaut.views:micronaut-views-thymeleaf"

Thymeleaf 自体の定義は不要になりました。

差分みた方が早いかも

https://github.com/nemuzuka/micronaut-sample/pull/5/files

まとめ

IntelliJ のキャッシュをクリアしないと BeanInstantiationException が出て、@Singleton で登録したインスタンス@Inject で注入できないエラーになってしまいました。その時は IDE や Gradle のキャッシュをクリアしてみると良いかもしれません。 こいつのせいだったかも。

メジャーバージョン UP でしたが、そんなに依存していなかったので比較的スムーズでした。凝ったことしてると辛いかもしれません。せっかくアプリを作るのだから、バージョン UP に追従し続けることで、長く使えると良いですね。

バージョアップでデグレったことを検知する為にも、テストを書いておいた方が良いですよ。

Micronaut ことはじめ - テストを書いて歩こう (5)

f:id:nemuzuka:20200611200142p:plain

前回 までで一般的な Web アプリケーションを作るのに必要な Controller 部分のサンプルが溜まってきたと思います。あとは UseCase とか Repository が残っていますが、こいつらは Micronaut 依存が少ないので興味がある方は引き続きやっていただければと思います*1。 今回はテスト周りをやってみたいと思います。

コードはこちら (本文のコードは色々端折ってるのでコードをご確認ください)

Form のテスト

ここでは、Web ブラウザからのリクエストを受ける class を Form と呼んでいます。 Bean Validation で validation を行うのであれば、少なくともアノテーション(と設定)を正しく付与していることをテストする必要があります。

Validator

テストクラスはこんな感じです。

@MicronautTest // 1
class TaskFormTest {

  @Inject Validator validator; // 2

  @Test
  @DisplayName("validate でエラーが起きないケース")
  void testValidate() {
    // setup
    var sut = TaskForm.builder().taskName("name_0001").content("content_001").build();

    // exercise
    var actual = validator.validate(sut);

    // verify
    assertThat(actual).isEmpty();
  }

  @Test
  @DisplayName("null を渡した時")
  void testValidate_NullValue() {
    // setup
    var sut = new TaskForm();

    // exercise
    var actual = validator.validate(sut); // 3

    // verify
    var messages =
        actual
            .stream()
            .map(value -> value.getPropertyPath() + ":" + value.getMessage())
            .collect(Collectors.toList());
    assertThat(messages).containsOnly("taskName:must not be null", "content:must not be null");
  }
}
  1. @MicronautTest をつけることで、@Singleton をつけた class をテスト側で @Inject して使用することができます
  2. Validator を Inject します
  3. エラーがあると Validator#validate の戻り値に設定するので verify します

@Inject が spring で言う @Autowired です*2。 Validation の他に必要なテストがあれば追加していきます。

Controller の UnitTest

UnitTest では 依存する UseCase 等は Mock に置き換えて、メソッド呼び出し時を観点としたテストを行います。 Mock ライブラリとしてMockito を使っています。

テストクラスはこんな感じです。

@ExtendWith(MockitoExtension.class)  // 1
class AddControllerTest {

  @Mock private TaskUseCase taskUseCase; // 2

  @Mock private ConstraintViolationMessageConverter constraintViolationMessageConverter;

  @Mock private HttpRequest<Map<String, Object>> request;

  @InjectMocks AddController sut; // 3

  @Test
  @DisplayName("addTask のテスト.")
  void testAddTask() {
    // setup
    doNothing().when(taskUseCase).createTask(any());
    var taskForm = TaskForm.builder().taskName("タスク名").content("内容").build();

    // exercise
    var actual = sut.addTask(taskForm);

    // verify
    assertThat(actual)
        .isInstanceOfSatisfying(
            NettyMutableHttpResponse.class,
            response -> {
              assertThat(response.getHeaders().get(HttpHeaders.LOCATION)).isEqualTo("/tasks");
              assertThat(response)
                  .returns(HttpStatus.MOVED_PERMANENTLY, NettyMutableHttpResponse::getStatus);
            });
    verify(taskUseCase).createTask(taskForm.toCreateTaskUseCaseRequest()); // 4
  }
}
  1. @MicronautTest の代わりに @ExtendWith をつけます
  2. テスト対象 class が Inject している class を @Mock で mock 化します
  3. @InjectMocks がついていると @Mock を付けた class を Inject してインスタンスを生成します
    • 今回は AddController のコンストラクタの引数に @Mock を付けた class のインスタンスを受け取るようにしています
  4. mock 使ったら呼び出し回数や引数の verify をしましょう。そうしないとテストの意味が激減します

アクセス権やログイン済みか?の状態のテストはここでは行いません。Java のロジックとして想定通りかの観点でテストを行います。Micronaut はあまり関係ないです。

IntegrationTest

画面が絡んでくるとどこまでやるべきか悩ましいです。 ひとまず雑に要素の存在チェックをすることにしました。E2E のテストは別でやると思ってます。

@MicronautTest // 1
class AddControllerIntegrationTest {

  @Inject IntegrationTestHelper integrationTestHelper; // 2

  @Inject TaskUseCase taskUseCase;

  @Test
  @DisplayName("GET /tasks/add のテスト")
  void testIndex() throws Exception {
    // setup
    var client = integrationTestHelper.login(); // 3
    var request = integrationTestHelper.buildGetRequest("/tasks/add");

    // exercise
    var response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

    // verify
    assertResponseWithBody(
        response,
        HttpStatus.OK,
        html -> {
          assertElementExists(html, "input[name='csrfToken']"); // jsoup でドキュメントの要素を verify
          assertTextEquals(html, "h3.title.is-3", "Task 登録");
        });
  }

  @Test
  @DisplayName("POST /tasks/add のテスト")
  void testAdd() throws Exception {
    assertThat(taskUseCase.allTask()).isEmpty();

    // setup
    var client = integrationTestHelper.login();
    var csrfToken = integrationTestHelper.getCsrfToken(client, "/tasks/add"); // 4

    var postParameter =
        "csrfToken=" + csrfToken + "&taskName=task_name_001&content=task_content_001";
    var request = integrationTestHelper.buildPostRequest("/tasks/add", postParameter);

    // exercise
    var response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

    // verify
    assertResponseWithLocation(
        response,
        HttpStatus.MOVED_PERMANENTLY,
        location -> assertThat(location).isEqualTo("/tasks"));

    // 永続化していること
    var actual = taskUseCase.allTask();
    assertThat(actual).hasSize(1);
    var actualTask = actual.get(0);
    assertThat(actualTask.getTaskId()).isNotNull();
    assertThat(actualTask)
        .returns("task_name_001", TaskUseCaseResult::getTaskName)
        .returns("task_content_001", TaskUseCaseResult::getContent);
  }
}
  1. @MicronautTest をつけます。netty が立ち上がります
  2. テスト用に使う Helper を用意しておきます
  3. login 状態にした client を取得してアクセスします
  4. CSRF トークンを取得してアクセスします

テスト用に起動した内部サーバとの通信を Running the Embedded Server のように

@Inject
@Client("/")
HttpClient client; 

でやりたかったのですが、今回のサンプルは Session Authentication を組み込んでいるので、ログイン済みの状態を示すために Cookie が必要でした。 なので、io.micronaut.http.client.HttpClient でなく、java.net.http.HttpClient を使用することにしました。 また、ログイン成功時の Cookie を保持した HttpClient や CSRF トークンを取得する処理を Helper として定義しています。

IntegrationTest ではブラウザからのリクエストを受けた時の振る舞いを確認します。 なので、

  • ログインはしているが、操作する権限を持っていないユーザがリクエストした
  • エンドポイント呼び出し時に RDBMS に登録しているか
  • リクエストパラメータの Validation でエラーになった時

等もこのテストでの確認ポイントになります。

例えば AWSSDK を mock して...と言う時は、Using Mockito Mocks を参考に Mock にすることができます*3。 テスト用に設定を変えたい時は @Property@MicronautTest の propertySources を指定すれば良さそうです。

まとめ

どこまでテスト書くかは時間との兼ね合いかもしれませんが、テストがあればデグレしてない安心感を得られます。 フレームワーク依存の部分もテストがあれば version up する時に気付きやすくもなります。面倒かもだけどテスト大事です。

こうしてみると Spring でできたことができないケースは少ないと思います。 テストの起動も Spring よりは早い印象を受けます。ちょっとした API サーバはもちろんですが、Micronaut で大体いける気がしてきました。

*1:私が Repository に使うなら doma2 ですかね

*2:spring でも @Inject 使えるんですね...

*3:ちょっと面倒ですけどね

Micronaut ことはじめ - 共通処理を組み込もう (4)

f:id:nemuzuka:20200611200142p:plain

前回 はエラーハンドリングを組み込んでみました。今回は CSRF 対策を共通処理っぽく入れてみたいと思います。

コードはこちら

CSRF

これです

SpringBoot だと @EnableWebSecurity 付ければ有効になるアレです。

Micronaut の場合は自分でやる必要がありそうです。なるほど。

View をレンダリングする時にデータを埋め込む

html を返す Controller それぞれで csrf トークンを生成してサーバ上の Session とレスポンスに設定する処理を書いてもいいですけど、人間なのでうっかり忘れることもあると思います。なので、共通的な処理として定義した方が良さそうだと考えました。

参考にしたのはこちら*1

ViewModelProcessor

JavaDoc には

Implementers of ViewModelProcessor process the ModelAndView and modify it prior to rendering by either adding or removing entries.

と書いてあります。レンダリングの前に処理を差し込むことができそうです。

@Slf4j
@Singleton
public class CsrfViewModelProcessor implements ViewModelProcessor {

  public static final String CSRF_PARAMETER_KEY = "csrfToken";
  @Override
  public void process(
      @Nonnull HttpRequest<?> request, @Nonnull ModelAndView<Map<String, Object>> modelAndView) {

    if (Objects.equals(request.getMethodName(), "GET")) {  // 1
      setCsrfForGet(request, modelAndView);
    } else {
      setCsrfForPost(request, modelAndView);
    }
  }

  private void setCsrfForGet( // 2
      HttpRequest<?> request, ModelAndView<Map<String, Object>> modelAndView) {
    var modelOpt = modelAndView.getModel();
    if (modelOpt.isEmpty()) {
      return;
    }

    var sessionOpt = SessionForRequest.find(request);
    if (sessionOpt.isEmpty()) {
      return;
    }

    sessionOpt.ifPresent(
        session -> {
          var csrf = UUID.randomUUID().toString();
          session.put(CSRF_PARAMETER_KEY, csrf);
          var responseMap = modelOpt.orElseThrow(() -> new AssertionError("invalid"));
          responseMap.put(CSRF_PARAMETER_KEY, csrf);
        });
  }

  private void setCsrfForPost( // 3
      HttpRequest<?> request, ModelAndView<Map<String, Object>> modelAndView) {

    var requestParameterOpt = request.getBody(Map.class);
    if (requestParameterOpt.isEmpty()) {
      return;
    }

    var modelOpt = modelAndView.getModel();
    if (modelOpt.isEmpty()) {
      return;
    }

    @SuppressWarnings("unchecked")
    var requestParameter =
        (Map<String, Object>) requestParameterOpt.orElseThrow(() -> new AssertionError("invalid"));
    var responseMap = modelOpt.orElseThrow(() -> new AssertionError("invalid"));

    var csrf = (String) requestParameter.get(CSRF_PARAMETER_KEY);
    responseMap.put(CSRF_PARAMETER_KEY, csrf);
  }
}

ViewModelProcessor を implements して @Singleton つけると有効になります。

  1. request パラメータの method で処理を分けます
  2. GET の時は csrf 用の token を作成し、Session とレスポンスに設定します
    • GET だけで良いかはケースバイケースかも
  3. POST の時はリクエストパラメータの csrf 用の token をレスポンスに設定します
    • POST だけで良いかはケースバイケースです
    • formタグは、GET と POST メソッドしかサポートしておらず、Micronaut は SpringBoot で言うところの HiddenHttpMethodFilter の仕組みがないので、このコードでは GET と POST しか送らない強い気持ちでやってます。*2

html 側に <input type="hidden" name="csrfToken" th:value="${csrfToken}"/> 的なのを設定するとレンダリングした html に埋め込まれているのが確認できます。

f:id:nemuzuka:20200614153843p:plain

POST の時に csrf チェック

チェックは filter を使ってみましょう。

OncePerRequestHttpServerFilter

コードは以下のような形になります。

@Slf4j
@RequiredArgsConstructor
@Filter(patterns = "/**", methods = HttpMethod.POST) // 1
public class CsrfFilter extends OncePerRequestHttpServerFilter {

  private final ViewsRenderer viewsRenderer;

  @Override
  public int getOrder() {
    return LOWEST_PRECEDENCE;
  }

  @Override
  protected Publisher<MutableHttpResponse<?>> doFilterOnce(
      HttpRequest<?> request, ServerFilterChain chain) {
    if (validateCsrfRequest(request)) { // 2
      return chain.proceed(request);
    }
    return Publishers.just(
        HttpResponse.status(HttpStatus.FORBIDDEN)
            .body(viewsRenderer.render("forbidden", Collections.EMPTY_MAP))
            .contentType(MediaType.TEXT_HTML));
  }
  private boolean validateCsrfRequest(HttpRequest<?> request) {

    if (Objects.equals(request.getUri(), UriBuilder.of("/login").build())) { // 3
      return true;
    }

    try {
      var session =
          SessionForRequest.find(request)
              .orElseThrow(() -> new IllegalStateException("Session is empty."));
      var sessionValue =
          session
              .get(CsrfViewModelProcessor.CSRF_PARAMETER_KEY, String.class)
              .orElseThrow(
                  () ->
                      new IllegalStateException(
                          "Session(" + CsrfViewModelProcessor.CSRF_PARAMETER_KEY + ") is empty."));

      @SuppressWarnings("unchecked")
      Map<String, Object> body = request.getBody(Map.class).orElse(Map.of());
      var requestValue = (String) body.get(CsrfViewModelProcessor.CSRF_PARAMETER_KEY);
      return Objects.equals(sessionValue, requestValue);  // 4
    } catch (IllegalStateException e) {
      log.info("Invalid request: {}", e.getMessage(), e);
      return false;
    }
  }
}

OncePerRequestHttpServerFilter を extends して @Filter つけると有効になります。

  1. patterns や methods で適応条件を指定できます
  2. csrf リクエストが正しいかチェックし、正しければ後続処理を行います
    • 不正だった場合、src/main/resources/views/forbidden.htmlレンダリングするようにしています
  3. 本来はログインの時も CSRF トークンチェックを入れた方が良いと思うのですが、むやみに Session 作られたくないのでひとまず除外しました
  4. session 上に格納している CRSF トークンとリクエストパラメータで設定している CSRF トークン値を比較します

Micronaut を起動してログイン成功した後に http://localhost:8080/tasks/add にアクセスして、csrfToken の値を書き換えて

f:id:nemuzuka:20200614155504p:plain

登録するボタンをクリックすると

f:id:nemuzuka:20200614155534p:plain

filter のチェックでエラー画面がレスポンスされました。

まとめ

今回は共通処理について書きました。SpringBoot のように至れり尽くせり感は無いですが、欲しけりゃ自分で好きなように作りな、というこスタンスは嫌いではありません。Web アプリ面倒ですね...。

*1:コード見た方が早いです

*2:SpringBoot もデフォルト off ですからね...

Micronaut ことはじめ - Error Handling で異常ケースも良い感じに (3)

f:id:nemuzuka:20200611200142p:plain

前回は認証機能を組み込んでみました。今回はエラーハンドリングを組み込んでみましょう。

コードはこちら

認証でエラー発生時

ログイン画面で認証失敗時、Micronaut の Session Authentication の仕組みに乗っかると、ステータスコード 401 を返します。 そのままブラウザに出すと、ちょっとかっこ悪い。

f:id:nemuzuka:20200612101949p:plain

良い感じにハンドリングしましょう。

参考にしたのは、こちら

Global Error Handling

Micronaut には、レスポンスする Http ステータスコードに応じて処理を差し込む仕組みがあります。コードは以下のような感じです。

@Slf4j
@RequiredArgsConstructor
@Controller("/errors")
public class GlobalErrorHandler {
  @Error(status = HttpStatus.UNAUTHORIZED, global = true) // 1
  public HttpResponse<?> unauthorized() {
    return HttpResponse.redirect(UriBuilder.of("/login").build()); // 2
  }
}

この設定は以下を示します。

  1. UNAUTHORIZED の時に動作します
  2. 今回は /login にリダイレクトするようにしました
    • メソッドの引数に HttpRequest を指定することもできるので、入力値を含めて画面をレンダリングすることも可能です

Micronaut を起動してログインに失敗する値を設定すると、ログイン画面が表示されるはずです。

また、ログイン後に存在しない URL を指定した時は Micronaut ステータスコード 404 を返します。そのままブラウザに出すのもちょっとあれなので画面を返すようにします。先ほどの GlobalErrorHandler に追加します。

private final ViewsRenderer viewsRenderer; // Injection 対象

@Error(status = HttpStatus.NOT_FOUND, global = true)
public HttpResponse<?> notFoundForPage() {
  return HttpResponse.ok(viewsRenderer.render("notFound", Collections.EMPTY_MAP))
      .contentType(MediaType.TEXT_HTML);
}

ViewsRenderer がでてきました。ViewsRenderer#render を呼び出すことでテンプレートエンジンを元に html をレスポンスすることができます。

src/main/resources/views/notFound.html を配置して Micronaut を起動してログイン成功した後に存在しない URL を叩いた時(e.g. http://localhost:8080/hoge`)、notFound.html の画面が表示されるはずです。

f:id:nemuzuka:20200612104114p:plain

サーバサイドの validation

ユーザビリティのために js で入力値をチェックするかもしれませんが、サーバサイドでも validation した方が安心できます。Bean Validationを元にやってみましょう。

http://localhost:8080/tasks/add で表示される画面です。

f:id:nemuzuka:20200612105158p:plain

Form

入力パラメータを受け取る Form はこんな感じ。*1

@Data
@Introspected
public class TaskForm {

  /** task_name. */
  @NotNull
  @Size(min = 1, max = 256)
  private String taskName;

  /** content. */
  @NotNull
  @Size(min = 1, max = 1024)
  private String content;
}

@Introspected が必要になります。@NotNull@Size が付いてます。これが validation のルールになります。Spring っぽいですね。

Controller

登録処理を行う Controller はこんな感じ。

@Controller("/tasks/add")
@RequiredArgsConstructor
public class AddController {
  @Post
  @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
  public HttpResponse<String> addTask(@Body @Valid TaskForm taskForm) {
    var uri = UriBuilder.of("/tasks").build();
    return HttpResponse.redirect(uri);
  }
}

見やすさのためにひとまず登録処理自体は省いてます。 @Body で body パラメータをマッピング@Valid で validation を行うようになります。

Local Error Handling

Controller のメソッドが呼ばれる前に validation を行うのですが、不正な入力があると ConstraintViolationException を throw するのでそれをハンドリングする処理が必要になります。

AddController 内にメソッド追加します。

@View("tasks/edit") // 1
@Error(exception = ConstraintViolationException.class) // 2
public Map<String, Object> onFailed(
    HttpRequest<Map<String, Object>> request, ConstraintViolationException ex) {
  Map<String, Object> responseMap = new HashMap<>();
  responseMap.put(
      "errors",
      constraintViolationMessageConverter.violationsMessages(ex.getConstraintViolations())); // 3
  request.getBody(TaskForm.class).ifPresent(form -> responseMap.put("taskForm", form)); // 4
  return responseMap;
}
  1. この Error Handling でレスポンスする view を指定します
  2. ConstraintViolationException を catch したら動作します
  3. view をレンダリングする時に参照するデータとして、エラーメッセージを設定します。ConstraintViolationException から情報を生成する為に自前で ConstraintViolationMessageConverter を作成しています。詳細はコードを見てみてください
  4. view をレンダリングする時に参照するデータとして form 情報を設定します。

このようにしておくことで、validation エラーが発生した時に再度入力画面を表示するようになります。

Micronaut を起動してログイン成功した後に http://localhost:8080/tasks にアクセスして、項目を未入力で登録すると validation のエラーが表示されるようになるはずです。

f:id:nemuzuka:20200612113622p:plain

SpringBoot の方式とは違いますが(BindingResult とかではない)、Exception を throw してハンドリングする方式もメソッドの見通しはよくなるのでこれはこれでアリかな、と思います。

Thymeleaf のタグ

SpringBoot を使っていると普通に使ってた th:field="*{title}" とかの記法ですが、Micronaut では使えず、th:value="*{title}" で設定しないといけません。input タグの name や id も自分で設定します。

textarea は th:value ではなく、

<textarea th:utext="*{content}"></textarea>

のように th:utext を使用します。

このあたりは Spring の拡張機能のありがたみを感じられるところでしょうか。

まとめ

今回は、グローバルなエラーハンドリングと Controller 毎のローカルなエラーハンドリングについて書きました。ちょっと面倒に思うかもしれませんが、アノテーションで簡単な validation ができるのは楽ができて良いと思います。

*1:struts っぽいですけど Form で通じますよね...?

Micronaut ことはじめ - Session Authentication を添えて(2)

f:id:nemuzuka:20200611200142p:plain

前回 は Thymeleaf を使ってログイン画面をレスポンスしました。次は認証機能を組み込んでみましょう。

コードはこちら

Session Authentication

自前で作るのも良いですけど、用意してくれたやつに乗っかりましょう。今回は、Session Authentication を組み込みます。

認証成功時にサーバサイドの Session に認証情報を入れて、かつ、それを紐付ける ID をブラウザの Cookie に入れておき、サーバにアクセスする度に Cookie も送って「このリクエストは認証済みのやつか?」を判断する奴です。昔ながらの安心する奴。

参考にしたのは、こちら

build.gradle

これを追加します。

annotationProcessor "io.micronaut:micronaut-security"
compile "io.micronaut:micronaut-security"
compile "io.micronaut:micronaut-security-session"

application.yml

ちょっと長いです

micronaut:
  security:
    enabled: true
    intercept-url-map:
      -
        pattern: /login   -> 1
        access:
          - isAnonymous()
      -
        pattern: /public/**   -> 2
        access:
          - isAnonymous()
      -
        pattern: /tasks/**   -> 3
        access:
          - SYSTEM_ADMIN
      -
        pattern: /**   -> 4
        access:
          - isAuthenticated()
    endpoints:
      login:
        enabled: true -> 5
    session:
      enabled: true  -> 6
      login-success-target-url: /tasks  -> 7
      unauthorized-target-url: /login  -> 8

この設定は以下を示します。

  1. /login は未ログイン状態でもアクセスできるように isAnonymous()
  2. /public/** も未ログイン状態でもアクセスできるように isAnonymous()
  3. /tasks/** は、ログイン状態で、かつ role に SYSTEM_ADMIN を持つ
  4. その他の path はログイン状態の必要がある
  5. true にすると Micronaut の LoginController が有効になります
  6. true にすると Session Authentication が有効になります
  7. ログイン成功時に遷移する URL を指定します
  8. 未ログイン状態、ログイン状態でもrole を持っていないユーザがアクセスした時に遷移する URL を指定します

ログイン画面

Micronaut の LoginController を使う時、

  • POST method
  • Content-Type が application/x-www-form-urlencoded or application/json
  • パラメータ名は username / password

でなければなりません。なので、ログイン画面では form タグの methodPOST を設定し、Login ボタン click 時に submit します。input タグの name 属性も合わせてください。

認証処理

AuthenticationProviderUserPassword

今回のケースでは認証処理は AuthenticationProvider を implements します。

@Singleton // 1
@RequiredArgsConstructor // lombok
public class AuthenticationProviderUserPassword implements AuthenticationProvider {

  private final SystemConfigurationProperties systemConfigurationProperties;  // 2

  @Override
  public Publisher<AuthenticationResponse> authenticate(
      @Nullable HttpRequest<?> httpRequest, AuthenticationRequest<?, ?> authenticationRequest) {
    return Flowable.create(
        emitter -> {
          if (Objects.equals(
                  authenticationRequest.getIdentity(), systemConfigurationProperties.getIdentity())
              && Objects.equals(
                  authenticationRequest.getSecret(), systemConfigurationProperties.getSecret())) {
            // 認証成功時、UserDetails を設定する  3
            var userDetails =
                new UserDetails(
                    (String) authenticationRequest.getIdentity(),
                    Collections.singletonList(Role.SYSTEM_ADMIN.name())); // 4
            emitter.onNext(userDetails);
          } else {
            emitter.onError(new AuthenticationException(new AuthenticationFailed()));
          }
          emitter.onComplete();
        },
        BackpressureStrategy.ERROR);
  }
}

何となく Spring っぽい感じがしませんか。

  1. Spring で言うところの @Bean とか @Service とかに相当します。@Singleton だとインスタンスは1つだけ作られます。Thread Safe でお願いします
  2. プロパティファイルの情報を保持する為の class として SystemConfigurationProperties を別途定義しました
  3. RDBMS に保存していた情報と付き合わせるのが一般的でしょうけど、今回はプロパティに定義した値と比較するようにしました
  4. この認証に成功した時は role に SYSTEM_ADMIN を設定しました。application.yml の設定が生きてきます。

これで認証成功時は UserDetails インスタンスが Session 上に格納されるようになります。

SystemConfigurationProperties

プロパティファイルの情報を保持する class です。

@Data // lombok
@ConfigurationProperties("system.admin") // 1
public class SystemConfigurationProperties {

  /** システム管理者ID. */
  private String identity;

  /** システム管理者パスワード. */
  private String secret;
}

Spring っぽいですね。

  1. プロパティの prefix を指定します

yml をみてもらうとわかると思いますが、プロパティの名前を合わせることで Micronaut がこのインスタンス生成時に良い感じに値を設定してくれます。この class も Bean 登録されるので、AuthenticationProviderUserPassword のコンストラクタに含めておくと Injection されるのです。

認証後に遷移する画面

認証後に遷移するのは /tasks と設定したので、レスポンスを返すように Controller と html を作っておきます。 Micronaut を起動して http://localhost:8080/tasks に直接アクセスするとログイン画面に戻ってしまうのが確認できると思います。

Application Configuration & 認証処理

起動時のパラメータでプロパティファイルを変更する仕組みが Micronaut にありますSpringBoot にもあります。

例えば、ローカル環境 / CI 環境 / 本番環境で振る舞いを変えたい時に使用します。*1

今回はapplication-local.yml という名前のローカル環境のプロパティファイルを作ります。

system:
  admin:
    identity: scott
    secret: tiger

認証情報を新しく設定しました。 これを SystemConfigurationProperties にマッピングさせるには、MICRONAUT_ENVIRONMENTS を使用して起動します。

$ MICRONAUT_ENVIRONMENTS=local ./gradlew run

http://localhost:8080 にアクセスすると、ログイン画面に遷移しましたね。 そこで application-local.yml に指定した認証情報を入力して、Login ボタンをクリックすると...

f:id:nemuzuka:20200611220651p:plain

/tasks が表示できました。また、ブラウザの開発ツールで SESSION Cookie の存在を確認できるはずです。

まとめ

今回は、認証を組み込んでみました。起動パラメータによってプロパティの認証情報も application.yml の値が上書きされていることが確認できたと思います。

Spring 使ったことがある方は使えそうな気がしませんか?

*1:大体私は環境変数でプロパティを上書きするので CI 用の設定ファイルとか本番用の設定ファイルを作りませんが、個人の開発環境用にプロパティを作ります

Micronaut ことはじめ - Thymeleaf と仲良し(1)

f:id:nemuzuka:20200611200142p:plain

事の発端は

www.amazon.co.jp

でした。

SpringBoot で作ったアプリを Heroku で公開しようとした時にメモリを食いすぎて起動できなかったので、「Java でそういうの無理かな」と思ってた時に出会ったのがきっかけです。

実際のメモリ消費量がどのくらいなのかわからないのですが、まずはやってみようと言う事で調べた事を書き連ねていきます。

コードはこちら

Thymeleaf

www.thymeleaf.org

最近のアプリでも使います...よね?何でもかんでも SPA じゃないと思いたい...と言うことでこいつと仲良しになってみます。

参考にしたのは、こちら

build.gradle

これを追加します。

implementation "io.micronaut:micronaut-views-thymeleaf"
implementation "org.thymeleaf:thymeleaf:3.0.11.RELEASE"

application.yml

build.gradle に依存関係を追加すれば使えるようになるのですが、静的ファイルも取れるようにするのに以下の設定を追加します。

micronaut:
  router:
    static-resources:
      default:
        enabled: true
        mapping: /public/**
        paths: classpath:public

これで、"/public/***" の形で静的ファイルを取ってくることができます。

ログイン画面

Web アプリケーションって大体ログインから始まりますよね?と言うことでまずはログイン画面を出すようにしましょう。

LoginController

Controller はこんな感じです。

@Controller("/login")
public class LoginController {
  @Get
  @View("login")
  public HttpResponse<String> index() {
    return HttpResponse.ok();
  }
}

やっぱり Spring っぽいですね。

@View にはテンプレートファイルを指定します。src/main/resources/views/login.html に html ファイルを配置しました。

実行

Micronaut 起動します。

$ ./gradlew run

SpringBoot より早い気がします。リフレクションベースでないので、class が増えた時もこんな感じで起動が早いと良いのですが...。

で、http://localhost:8080/login

にアクセスすると、以下のような画面が表示されます。

f:id:nemuzuka:20200611202055p:plain

メモ

試し方が悪かったのかもしれませんが、micronaut.views.thymeleaf.cacheable を false にして template を変更した時に更新は反映されませんでした...。再起動しないと html の確認ができないのはちょっとツライです。

まとめ

今回はリクエストを受けてレスポンスを返すところまでやりました。 SpringBoot の開発に慣れている方は特に問題なく入れるんじゃないかと思います。

気が向いたら次回書きます。

Doma2 で JSON 型を使う

魔がさして、RDBMSJSON 型を導入しようとしてみました*1

前提条件

データ構造

create table customers
(
    customer_id   varchar(255) not null
        constraint customers_pkey
            primary key,
    customer_code varchar(255) not null,
    customer_name varchar(255) not null,
    attribute     json
);

エンティティクラス、ドメインクラス

Doma での エンティティクラスは以下のような感じです。

@Entity
@Table(name = "customers")
@Data
public class Customer {

  @Id
  @Column(name = "customer_id")
  private String customerId;

  @Column(name = "customer_code")
  private String customerCode;

  @Column(name = "customer_name")
  private String customerName;

  /** 永続化時に JSON カラムにするプロパティ. */
  @Column(name = "attribute")
  private Attribute attribute;
}

Attribute class は Domaドメインクラス相当です。こいつをまるっと JSON 型に入れたり取り出したりしちゃおうというのが今回のゴールです。

@Data
public class Attribute {

  @JsonProperty("memo")
  private String memo;

  @JsonProperty("age")
  private Integer age;

  @JsonProperty("point")
  private Long point;
}

手順

1. DomainConverter 作成

Attribute に対する DomainConverter を作成します。 型パラメータの String は JSON 文字列になります。

@ExternalDomain
public class AttributeConverter implements DomainConverter<Attribute, String> {

  private static final ObjectMapper MAPPER = new ObjectMapper();

  @Override
  public String fromDomainToValue(Attribute attribute) {
    try {
      return MAPPER.writeValueAsString(attribute);
    } catch (JsonProcessingException e) {
      throw new RuntimeException(e);
    }
  }

  @Override
  public Attribute fromValueToDomain(String value) {
    try {
      return value == null ? null : MAPPER.readValue(value, Attribute.class);
    } catch (JsonProcessingException e) {
      throw new RuntimeException(e);
    }
  }
}

2. DomainConverters に追加

DomainConverter を @DomainConverters に登録します。 今回のサンプルでは DomainConverterProvider クラスで管理しています。 この辺Doma に教えてます。

3. Dao インターフェース作成

最初の CustomerDao はこんな感じでした。

@Dao
public interface CustomerDao {

  @Select
  @Sql("SELECT * FROM customers WHERE customer_id = /*customerId*/'customer_001'")
  Optional<Customer> selectByCustomerId(String customerId);

  @Insert
  int insert(Customer customer);
}

4. 実行

テストクラスから実行します。

PostgreSQL で実行した所、

org.seasar.doma.jdbc.SqlExecutionException: [DOMA2009] The SQL execution is failed.
PATH=[null].
SQL=[insert into customers (customer_id, customer_code, customer_name, attribute) values ('b9d9900c-27da-4d88-b9a6-028b1a1d32ae', 'CUSTOMER_001', 'NAME_001', '{"memo":"memo_001","age":30,"point":343}')].
The cause is as follows: org.postgresql.util.PSQLException: ERROR: column "attribute" is of type json but expression is of type character varying
  ヒント: You will need to rewrite or cast the expression.
  位置: 98
The root cause is as follows: org.postgresql.util.PSQLException: ERROR: column "attribute" is of type json but expression is of type character varying
  ヒント: You will need to rewrite or cast the expression.
  位置: 98

どうも INSERT 時の JSON 型の扱いがよくないようです。

今度は H2 で実行してみましょう。

java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `examples.domain.Attribute` (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('{"memo":"memo_001","age":30,"point":343}')
 at [Source: (String)""{\"memo\":\"memo_001\",\"age\":30,\"point\":343}""; line: 1, column: 1]

SELECT の結果(JSON 文字列)を受け取って Javaインスタンスを生成する所に問題があるようです。 これは困った。

5. RDBMSJSON 型を使う時

どうも、JSON 型を使う時は TEXT 型のように扱うのではダメなようです。

ということは、RDBMS 毎に SQL を変更しないといけないのか...

6. sql ファイルを用意する

Doma 2 では、SQL ファイル名をつけ分けることによって使い分けができそうです。素敵!

insert-h2.sql

-- H2 用の JSON カラムを含む INSERT
insert into customers (
    customer_id,
    customer_code,
    customer_name,
    attribute
) values (
    /* customer.customerId */'id_001',
    /* customer.customerCode */'code_001',
    /* customer.customerName */'name_002',
    /* customer.attribute */'{"hoge"::30}' FORMAT JSON
)

insert-postgres.sql

-- PostgreSQL 用の JSON カラムを含む INSERT
insert into customers (
    customer_id,
    customer_code,
    customer_name,
    attribute
) values (
    /* customer.customerId */'id_001',
    /* customer.customerCode */'code_001',
    /* customer.customerName */'name_002',
    /* customer.attribute */'{"hoge"::30}'::json
)

CustomerDao#insert を SQL ファイルを使用するように設定しなおします。

@Dao
public interface CustomerDao {

  @Select
  @Sql("SELECT * FROM customers WHERE customer_id = /*customerId*/'customer_001'")
  Optional<Customer> selectByCustomerId(String customerId);

  @Insert(sqlFile = true) // sqlFile=true 追加
  int insert(Customer customer);
}

7. 再度実行

テストクラスから実行します。 どちらも通りました。やりましたね。

まとめ

PostgreSQL だと JSON 型より JSONB 型を使いそうですが、JSON 文字列として JSON 型にデータを入れる、JSON 型からデータを取り出すことができました。

実際に JSON 関数を使った生 SQL 発行するパターンは未検証ですが、RDBMS 毎にちがうでしょうし、運用時に SQL 叩くことを想定したら TEXT 型よりも JSON 型の方が楽だろう、というモチベーションだったのでアプリケーションからは JSON 関数を意識した SELECT 文は発行しないとは思うので、必要があったら検証することにします。

H2 と PostgreSQL 両方に対応しようとしてたのは、

  • Repository の Unit テスト / Integration テストは H2 を使って早く CI を回す
  • 本番は PostgreSQL

の構成でやりたかったからです。ただ、INSERT の時点で RDBMS を意識しないといけないのはちょっとアレなので、Unit テストも PostgreSQL で CI した方が良いのかなーと思い始めました*2

コード

ここにおきますここから fork しました。Doma2 素敵です。

*1:アンチパターンだというのはごもっともなのですが、それはそれで

*2:テストクラスが増えてって、CircleCI の parallelism 増やした時に、その分 PostgreSQL コンテナが立ち上がると思うのでリソース足りるのかなーというのが心配