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 コンテナが立ち上がると思うのでリソース足りるのかなーというのが心配

Mock を極力使わないようにしていた私が Mockito を使わないとムズムズするカラダになった理由

2020.05.28 追記: やっぱり mock 使わない方が良さそうに思えてきましたので、この記事から考えが変わってます

はじめに

以前、私の著書では

時間が限られた中、本番に使用しない Mock オブジェクトを作成するよりも、依存する実クラスを使用してテストケースを増やした方が時間もかからず、効率的にテストが行えると思いませんか?

とか書いていたのですが、最近はそうでもなくなっているので書き散らかしてみようと思います。 (10年以上前に書いた奴ですからね...しょうがない)

前提条件

  • SpringBoot 使った Kotlin アプリのサーバサイド(Java でも同じことができます)
    • UseCase とか Repository とか layer に分けて DI します

どんなテストでムズるか?

例えば Controller がこんな流れだったとします。

http://www.plantuml.com/plantuml/png/SoWkIImgAStDuKfCBialKd04aLnWKa7NJi6vA3Mn93KaiJZRiI3JEJ-lf2W_9oUrY0kxE9gMqE9KvyJYL0KhXMIu65TUVacgGb5cUaQ9bG9CmPFTIr_Ex7tSkEvf-xBd4zeUDwv_shdfMSji1PpssEXYjQSejRWWFwyu5U81X2fC3pYavgK0VGq0

CreateTaskApiController の責務として、CreateTaskUseCase#createTask の呼び出し & 結果によるレスポンスのハンドリングがあると思います。 (SpringBoot だと Bean Validation を使った validation も責務になりますがここでは割愛します)

CreateTaskApiController では受け取ったリクエストパラメータを元に CreateTaskUseCase#createTask のパラメータを生成します。 パラメータ生成処理自体はメソッド化なり他のクラスに処理を移譲することで Unit テストが容易に書けるでしょう。

ただ、CreateTaskController が CreateTaskUseCase#createTask に対して「想定通りのパラメータを設定したか?」の観点でテストを行う時はどうでしょう? UseCase 以降でパラメータをそのまま永続化するのであれば永続化したデータを参照することで確認はできるかもしれません。 ですが、Controller の Unit テストなのに永続化したデータまで意識するのは責務を超えている感があります。

Mock を使う

そこで Controller の Unit テストに Mockito を使って UseCase を Mock 化します。

コードはこちらです

// setup
val taskUseCaseResult = TaskUseCaseResultFixtures.create()
whenever(createTaskUseCase.createTask(any())).thenReturn(taskUseCaseResult)

↑は、引数関係なく呼び出されたら TaskUseCaseResult インスタンスを返す Mock として定義しています。

で、Controller 呼び出しを行います。 (Controller なので MockMvc 経由で呼び出していますが、UseCase の Unit テストであればメソッド呼び出しで構いません)

事前に定義した Mock が想定した結果を返すのは当たり前なので Mock の呼び出しを verify します。 観点としては、Mock の引数が想定通りか? と言う点です。

// mock のパラメータに対する verify
argumentCaptor<CreateTaskUseCaseParameter>().apply {
    verify(createTaskUseCase).createTask(capture())
    val capturedParameter = firstValue
    val expectedParameter = parameter.toParameter()
    assertThat(capturedParameter).isEqualTo(expectedParameter)
}

↑ capture キャプチャすると、Mock 呼び出し時点のインスタンスが取れるので、そのインスタンスが想定通りか verify を行います*1

この観点が抜けていると、ただテストを通しているだけになるので価値が半減します。any() でお茶を濁さないようにしましょう*2。 mock 使ったら引数の verify も忘れずに。

また、インスタンスのプロパティ1つずつ verify するとプロパティ増えた時にテストの追従を忘れる可能性があるので、まるっと比較する方が後からテスト壊したことに気付きやすくなるのでオススメです*3

テストクラスのメンテナンスコストを意識すると...

Controller の Unit テストをするなら、Controller の責務を超えた verify は避けた方が良いと思います。

API 呼び出しから一連の流れは Integration テストで行うべきで、この時は永続化した内容、発行したイベント等を verify する必要があるでしょう。 Controller の Unit テストなのに実クラスを DI していると RDBMS の構造が変わってしまった時にテストクラスにも影響を及ぼします。

これはテストクラスの肥大化にもつながり、テストクラスのメンテナンスコストが余計にかかります。

Unit テスト / Integration テストそれぞれの観点のテストクラスを作る必要があると思っているので、

  • Unit テスト: Mock をフルに使い、テスト対象クラスのメソッドだけ意識する
    • Repository の Unit テストに関しては、実際の RDBMS (やコンテナ上の DynamoDB Local とか S3Mock)にアクセスする
  • Integration テスト: 必要な箇所だけ Mock にして API 呼び出しから、レスポンスや永続化したデータの verify を行う

とした方が、ケース毎にどこまでやるのか考えずに済むと思います。

まとめ

「呼び出し先のメソッドのパラメータを正しく渡せているか」を気に始めたら、Mock 好きじゃなかった私も Mock 使わないとムズムズするようになった、と言うお話でした。

これは Controller だけでなく、UseCase や Application Service、Domain Service でも同じことが言えます。 他言語の Mock フレームワークで capture 相当の機能ってあるんでしょうか?あれば他言語でもストレスなく開発できそうだなぁ。

とはいえ、Mockito の version up に追従しなきゃいけなくなった時は「できるだけ使わない」に戻りそうですけどね...

*1:このケースは素直に verify すれば良いのですが、capture 使うサンプルとして書きたかった

*2:自戒も込めて

*3:equals メソッドの実装に影響を受けるのでケースバイケースかもしれません

【読了】ドメイン駆動設計 モデリング/実装ガイド

https://booth.pm/ja/items/1835632

「エリック・エヴァンスのドメイン駆動設計」や「実践ドメイン駆動設計」でわかった気になったけど実際にどうすれば良いかいまいちイメージがつかなかった人に是非読んでもらいたいです。 QA 形式で実際に実践する時に躓きそうなポイントも書いてあるので身近に詳しい人がいなくても始められると思います。相談相手として良い本です。

CQRS の概念をわかりやすくまとめてくれている点でも読む価値ありです。

「CQRS = データソース分離」と思われることがありますが、これは誤解です。

「CQRS = イベントソーシング」も誤解です。

誤解してました。気づかせてもらいました。

本書を一通り読んだ後に「エリック・エヴァンスのドメイン駆動設計」や「実践ドメイン駆動設計」を読むと「そういうことか」となる点が多いんじゃないかと思います。 プロジェクトにドメインモデリングを適用してみたい、と思った時にメンバー間の認識合わせとして最初に読んでおく本として最適だと思います。

本当に DDD で難しいのはドメインエキスパートの見極めと会話だと思うのですが、開発する上でこのようにしておけば、変更があった時の影響範囲を少なくすることができると考えています。

業務ロジックを抱え込んで肥大化しているトランザクションスクリプトな XXXService をメンテするのに限界を感じる前に手を打ってみませんか?