くちたと計算記

プログラミングのことを書きます

モジュールごとに分割したソースコードが読みづらいのは、プログラミング言語などの仕様に依存した問題だと感じた話

概要

インスタンスメソッドと関数のどちらが分かりやすいのか考えるうちに、いずれにせよ Java で普通に書いたら分かりづらいという結論に至りました。

Java でメソッドなどを呼び出すときコードを書くと、呼び出すメソッドで定義した仮引数の名前がコード上に表れないからです。だから、渡しているデータが呼び出したメソッドの中でどんな役割を果たすのか読み取ることが難しいです。

その状況下では、メソッドの引数を減らして引数の役割を推測しやすくすることが重要そうです。具体的には、強い関連がある引数同士を一つのオブジェクトにしたり、ビルダーパターンを使ったりする方法があると思います。

メソッド呼び出しのわかりづらさ

例 1

isAfter(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 1));

例 2

LocalDate.of(2024, 1, 1).isAfter(LocalDate.of(2024, 2, 1)); 

例 1 のようなコードは、第一引数より第二引数の方が後だったら true なのか、第二引数より第一引数の方が後だったら true なのか、はっきりしないコードだと思います。例 2 は、その点を克服できているコードだと思います。

この分かりづらさを生んでいるのは、以下の 3 点だと思います。

  1. 仮引数の名前が分からない
  2. 関数の引数が複数ある
  3. 語順が自然言語(英語)と異なる

この 3 点が揃うと、呼び出し元から引数の役割を推測するのが難しくなります。

プログラミング言語の仕様由来のわかりづらさ

メソッドを呼び出すコードが分かりづらくなってしまうのは言語仕様由来の問題でもあると思います。

以下のように書くことで、 例 1 のメソッドの仕様をほとんど維持したまま、わかりやすく表現できます。(※)

isAfter(test=LocalDate.of(2024, 1, 1),  compare=LocalDate.of(2024, 2, 1)); // 名前付き引数

isAfter({ test: LocalDate.of(2024, 1, 1),  compare: LocalDate.of(2024, 2, 1) }); // TypeScript でよくありますね。

LocalDate.of(2024, 1, 1) isAfter LocalDate.of(2024, 2, 1)); // 二つの変数をもつ関数を中置記法で書いてみました。

このように、メソッドの仕様を変えずに他のプログラミング言語で採用されている記法を取り入れるだけで、分かりやすさが変わったと思います。

Java で呼び出し元でも理解できるメソッドを宣言するには

二つの方向で考えました。

  1. 引数の数を減らして、仮引数の名前がなくても意味を推測できるようにする。
  2. 仮引数の名前を呼び出し元に書かせる。

すべての場合で問題を解決できるわけではありませんが、私は以下のようなコードを書くことで改善できるものもありそうだなと思いました。

インスタンスメソッドを使う

設計の観点で無理がないのであれば、関数ではなくインスタンスメソッドを選択しても良さそうです。関数の引数だったものをインスタンス変数に移動すれば、引数の数が減ります。また、語順の点でも分かりやすくなる可能性があります。

ただし、不変性などの制約を課さなかった場合に、テストのしやすさやバグの生みやすさの観点で、デメリットが大きくなるかもしれません。

強く関連するメソッドの引数同士は一つの引数としてまとめる

これを

public search(LocalDateTime registeredAtStart, LocalDateTime registeredAtEnd) {}

こうする。

record DatetimeSpan(LocalDate start, LocalDate end) {
  public DatetimeSpan {
  // validation
  }
}
public search(DatetimeSpan registeredAt) {}

このように関連する引数を一つの値としてみなすことで、メソッドの引数を減らせます。 また、引数間のバリデーションを DatetimeSpan 型に実装することで、あちこちに同じロジックを重複して実装せずに済みます。

ビルダーパターンを使用する

これは オブジェクトを生成するときに利用できる方法です。

Effective Java で、オプショナルな引数を多数含むコンストラクタを扱いやすくするための方法として、 Builder パターンが有効だとされています。 Builder のコンストラクタに必須の引数だけを指定することで、引数の数が少なくすっきりします。また、オプショナルな引数を設定するためのメソッドには仮引数の名前に相当するものが表れるので、その点でも分かりやすいと思います。

以下の記事では、必須の引数も含めた Builder パターンが紹介されているので、機会があれば試してみたいです。

Java で Scala の Type Safe Builder パターンをエミュレートする

最後に

表面上の記法によってソースコードの読みやすさが大きく変わることが分かるので、プログラミング言語の選び方の基準の一つになりそうだと思います。それはそれとして、 Java を利用すると言う制約下でもできることはあるので、もっとどんな工夫をできるか考えたいですね。

参考資料

Spring で複数の ControllerAdvice を使うときは、一つのファイルに ControllerAdvice をまとめると見通しが良さそう

以下のような仕様のリクエスト共通処理を ControllerAdvice として実装しようと思いました。

  1. AuthenticationPrincipal に保存された情報をもとに、パッケージごとに Controller へのアクセス制御をおこなう。
  2. ページへのリクエストがあるたびに共通の処理を差し込む。

リクエストの共通処理で ControllerAdvice を書くと、 IDE でハンドラメソッド(@GetMapping などを付与したメソッド)と処理が分かれてしまうので、個人的には好きではありません。 それが複数ファイルに分かれると、さらに見通しが悪くなるんじゃないかと考えました。

そこで、一つのファイルにまとめてしまえば、見通しの悪さが改善されるのではないかと思いました。

public class ControllerAdviceConainer {
  @ControllerAdvive(basePackageClasses = Alpha.class)
  @Order(1)
  public static class AccessControlAlpha {
    @ModelAttribute
    public void method() {}
  }

  @ControllerAdvive(basePackageClasses = Beta.class)
  @Order(1)
  public static class AccesssControlBeta {
    @ModelAttribute
    public void method() {}
  }

  @ControllerAdvive(basePackageClasses = {Alpha.class, Beta.class})
  @Order(2)
  public static class Common {
    @ModelAttribute
    public void method() {}
  }
}

アクセス制御は Spring Security で実装すればよかったと実装し終わってから気づいたのは、ここだけの秘密です。 もっと上手いやり方があれば知りたいと思いました。

Nuxt3 の useFetch / useAsyncData で POST リクエストを送るときはリクエストがキャッシュされないように注意しよう

概要

useFetch で key を設定せずに POST リクエストを送信する実装をしてみたら、 ボタンを何回押しても一度しかリクエストが送信されなくなってしまいました。$fetch を直接使ったり、 key を適切に設定すれば解決しました。

前提知識

  • TypeScript
  • Nuxt 3

起きた問題

ログインフォームの submit イベントに、以下のようなイベントハンドラを登録しました。

const username = ref("")
const password = ref("")

const onSubmit = async () => {
  const formData = new FormData();
  formData.set("username", username.value);
  formData.set("password", password.value);
  const { error } = await useFetch("/api/login", {
    method: "POST",
    body: formData
  });
}

ところが、一度でも間違ったアカウント情報でログインを試行すると、正しいアカウント情報を入力しなおしてもログインできないという事象が発生しました。

原因

開発者ツールでネットワークを監視してみると、何度ログインを試行しても HTTP リクエストが一度しか送信されていないことが分かりました。これは、 useFetch と useAsyncData が HTTP リクエストをキャッシュしているからです。 useFetch は options で指定した key、 useAsyncData は引数で指定した key をキャッシュのキーに使っています。では明示的に key を指定しなかったときに何が key として使われるのかを確認してみます。

useFetch

以下のように、URL と オプションを使って自動的に key を生成すると書いてあります。

key: a unique key to ensure that data fetching can be properly de-duplicated across requests, if not provided, it will be automatically generated based on URL and fetch options

useFetch · Nuxt Composables

具体的にどのオプションを使っているのかは、以下のソースコードを見ればわかります。

const _key = opts.key || hash([autoKey, unref(opts.method as MaybeRef<string | undefined> | undefined)?.toUpperCase() || 'GET', unref(opts.baseURL), typeof _request.value === 'string' ? _request.value : '', unref(opts.params || opts.query), unref(opts.headers)])

nuxt/packages/nuxt/src/app/composables/fetch.ts at 38b6d88cfab3477edabfa37d6f97b95eefc6ae6c · nuxt/nuxt · GitHub

ソースコードによると、以下の文字列をもとに key を生成しているようです。

  • URL
  • HTTP メソッド
  • クエリパラメータ
  • HTTP ヘッダ

useAsyncData

key: a unique key to ensure that data fetching can be properly de-duplicated across requests. If you do not provide a key, then a key that is unique to the file name and line number of the instance of useAsyncData will be generated for you.

useAsyncData · Nuxt Composables

公式リファレンスによると、ファイル名と行番号ごとにユニークな値を key として生成しているようです。

対策

以下のいずれかの対策をすれば良さそうです。

  1. POST リクエストを送信するときには、直接 $fetch を使う。
  2. HTTP リクエストをキャッシュしてほしくないときには、 key を明示的に指定する。

POST リクエストを送信するときには、直接 $fetch を使う。

リクエストをキャッシュしているのは useFetch と useAsyncData の仕様なので、それらを使わないという考え方です。 useFetch のキャッシュの条件を見るに、これらの Composable は GET リクエスト用なのかなと考えてしまいました。

HTTP リクエストをキャッシュしてほしくないときには、 key を明示的に指定する。

以下のように、 key がリクエスト毎に変わるように設定すれば良さそうです。タイムスタンプでなくても、 uuid を使ってもいいですね。

const username = ref("")
const password = ref("")

const onSubmit = async () => {
  const formData = new FormData();
  formData.set("username", username.value);
  formData.set("password", password.value);
  const { error } = await useFetch("/api/login", {
    method: "POST",
    body: formData,
    { key: `/api/login?${new Date()}` }
  });
}

GET リクエストの場合は、どの key でリクエストをキャッシュしてほしいか考えたうえで、 key を設定したほうが良さそうです。

おまけ

基本的にどのリクエストもキャッシュしてほしくないという場合には、 useFetch のラッパーを作ってもいいかもしれません。

公式のリファレンスに載っている実装例をもとに、 デフォルトの key を設定する Composable を書いてみました。 Use Custom Fetch Composable · Nuxt Examples

export function useCustomFetch<T> (url: string | (() => string), options: UseFetchOptions<T> = {}) {

  const defaults: UseFetchOptions<T> = {
    key: `${url}?${new Date()}`,
  }
  const params = defu(options, defaults)
  return useFetch(url, params)
}

参考 Web ページ

Nuxt3 で Provide / Inject を使って画面遷移先に Vuetify の Snackbar を表示させる

概要

Nuxt3 と Vuetify を使って SPA を実装しています。 ログイン成功したときなどに、 遷移先に Snackbar でメッセージを表示させたいと考えました。 ところが、以下のように pages 配下のコンポーネントに Snackbar を書くと、遷移先のページには Snackbar を表示することができませんでした。

<script lang="ts" setup>
const message = ref<string | null>(null);
</script>
<template>
  <v-main>
    <v-btn @click="message='ログインに成功しました。'">ログイン</v-btn>
    <v-snackbar :model-value="message !== null">{{ message }}</v-snackbar>
  </v-main>
</template>

そこで、 以下のようなプログラムを実装しました。

  1. レイアウトで、 v-snackbar に表示する文字列を更新する関数を子コンポーネント( Page コンポーネント)に provide する。
  2. 1 で provide した関数を 子コンポーネント(Page コンポーネント)に inject して利用する。

実装したプログラム

公式ガイドを参考に実装しました。

ja.vuejs.org

ja.vuejs.org

1. レイアウトで、 v-snackbar に表示する文字列を更新する関数を子コンポーネントに provide する。

export const SNACKBAR_PROVIDE_KEY = Symbol() as InjectionKey<{ showSnackbar: (message: string) => void }>;
<script lang="ts" setup>
const _message = ref<string | null>(null);

function showSnackbar(message: string) {
  _message.value = message;
}

provide(SNACKBAR_PROVIDE_KEY, {
  showSnackbar: showSnackbar
});
</script>
<template>
  <div>
    <v-app>
      <slot/>
      <v-snackbar :model-value="_message !== null">{{ _message }} </v-snackbar>
    </v-app>
  </div>
</template>

2. 1 でprovide した関数を 子コンポーネント(Page コンポーネント)に inject して利用する。

<script lang="ts" setup>
const { showSnackbar } = inject(SNACKBAR_PROVIDE_KEY) as typeof SNACKBAR_PROVIDE_KEY extends InjectionKey<infer T> ? T : never;
</script>
<template>
  <v-main>
    <v-btn @click="showSnackbar('ログインに成功しました。')">ログイン</v-btn>
  </v-main>
</template>

最後に

グローバルに近い状態と状態を変更する関数を公開するのは、あまり良いパターンではないかもしれません。 ただ、 Snackbar をページ遷移とは切り離して管理したいと思ったので、このような実装を考えました。 もし、もっとスマートなやり方があれば教えていただけると嬉しいです。

参考 Web サイト

ja.vuejs.org

ja.vuejs.org

Spring Boot で REST API を実装するときに、認証だけ FormData を使うと Spring Security の設定で楽できる

まとめ

  1. UsernamePasswordAuthenticaitonFilter が FormData から認証情報を読み込むので、 それを仕様にしてしまったほうが実装が楽になる。
  2. UsernamePasswordAuthenticaitonFilter を使えないなら、 JSON から認証情報を読み込んでも実装コストとコード量はそんなに増えない。
  3. やっぱり DSL で設定を記述したい。

本文

Spring Security 標準の UsernamePasswordAuthenticationFilter は ログインフォームから入力されたユーザー名とパスワードを処理する仕様になっている。 REST API では認証情報を JSON 形式で送信したくなるが、その場合は JSON 形式のリクエストボディから認証情報を取り出す Filter を実装する必要がある。

class JsonUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        Map<String, String> content;
        try {
            content = objectMapper.readValue(request.getInputStream(), new TypeReference<>() {
            });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        String username = content.getOrDefault(getUsernameParameter(), "");
        String password = content.getOrDefault(getPasswordParameter(), "");
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
                password);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

そのうえ、 HttpSecurity#formLogin で設定している項目を Filter にセッターで設定する必要がある。

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, AuthenticationManager authenticationManager) throws Exception {
        JsonUsernamePasswordAuthenticationFilter filter = new JsonUsernamePasswordAuthenticationFilter();
        filter.setFilterProcessesUrl("/api/login");
        filter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            response.setStatus(HttpServletResponse.SC_OK);
        });
        filter.setAuthenticationFailureHandler((request, response, exception) -> {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        });
        filter.setAuthenticationManager(authenticationManager);
        return httpSecurity
                .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
                .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
                .csrf(csrf -> csrf
                                .ignoringRequestMatchers("/api/login")
                                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                )
                .build();
    }

    @Bean
    public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setPasswordEncoder(passwordEncoder);
        authenticationProvider.setUserDetailsService(userDetailsService);
        return new ProviderManager(authenticationProvider);
    }

    @Bean
    public InMemoryUserDetailsManager inMemoryUserDetailsManager(PasswordEncoder passwordEncoder) {
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        inMemoryUserDetailsManager.createUser(User.builder().username("user").password(passwordEncoder.encode("password")).roles("USER").build());
        return inMemoryUserDetailsManager;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

この方法は以下の理由で嫌だと感じた。

  • UsernamePasswordAuthenticationFilter の実装と HttpSecurity#formLogin のデフォルトの振る舞いを活用できないので、記述量が増える。
  • もともと DSL で設定できていたものをセッターでダラダラ設定するのが嫌。

そもそも認証フィルタをカスタマイズしないと要件を満たせないなら、 JSON から読み込ように設定することによる記述量の増加は気にならないとは思う。 それはそれとして、 DSL による設定のしやすさは捨てがたいので、 認証フィルタをカスタマイズするなら DSL まで実装したいと思った。

テンプレートメソッドパターンの別解を考えたがクラスから知識が漏れ出しただけだった

テンプレートメソッドパターンとは振る舞いに関するデザインパターンの一つ。

ja.wikipedia.org

公開する振る舞いのうち一部をサブクラスでのみアクセス可能な抽象メソッドとして抽出している。 この方法を用いることで、公開する振る舞いの仕様を守りながら一部の処理をサブクラスで差し替えることができるようになっている。

別の実現方法として、抽象化している部分を別の抽象に委譲して、元の抽象クラスを具象クラスにすることもできるのではと思った。 関数型プログラミングでは抽象化した処理を関数の引数として扱うという話を聞いたので、クラス指向でもコンストラクタの引数を抽象化する解決策が有り得ると試してみた。 Wikipediaに載っている例を拝借すると、以下のようなコードになると思う。

class StringLister {
  private final StringListFormatter formatter;

  StringLister (StringListFormatter formatter) {
    this.formatter = formatter;
  }
  public String display(String[] items) {
      StringBuilder result = new StringBuilder(this.formatter.header());
      for (String item : items) {
          result.append(this.formatter.decorateItem(item));
      }
      result.append(this.formatter.footer());
      return result.toString();
  }
}

interface StringListFormatter {
  String header();
  String decorateItem(String item);
  String footer();
}

class PlainTextStringFormatter extends StringListFormatter {
  public String header() {
      return "";
  }
  public String decorateItem(String item) {
      return " - " + item + "\r\n";
  }
  public String footer() {
      return "";
  }
}

書いてみて思ったが、わたしが書いたコードは分かりづらい。 テンプレートメソッドパターンの方が以下の点で優れていると感じた。

  • 拡張方法が分かり易い。
  • リストの整形という知識を一つの型(StringLister)に閉じ込められている。

他のクラスに処理を委譲すると、何の知識も持たない抜け殻のようなクラスが生まれることがあることに気づけて良かったと思う。 クラスの持つ知識や責務の観点から気付きを得たが、高階関数を使った解法でも同じようなことは気になるものだろうか。 その細かい感覚を捉えられるほど、私はまだ関数型プログラミングをよく知らない。

Spring Web MVC のアノテーション `ModelAttribute` の挙動と、 `SessionAttributes` との併用でうまくいかなかったこと

概要

入力フォームを Spring Web MVC の Annotation Controllers で実装しているときに、 @ModelAttribute@SessionAttributes を使ったところ、すんなりと上手くいかない部分がありました。 そこで、起きた問題と解決策を記事に書きました。

前提

  • Java 17
  • Kotlin 1.7
  • Spring Boot 3.0

プロジェクトを作るときに spring-boot-starter-webspring-boot-starter-thymeleaf を依存ライブラリとして追加しました。

結論

  1. @SessionAttributes とメソッド引数の @ModelAttribute を組み合わせるときは、あらかじめ HTTP セッションにインスタンスが必要である
  2. HTTP セッションに保存されたインスタンスのフィールドを書き換えるには、フィールドを可変にする必要がある

上手くいかなかったところ

上手くいかなかったところが二つありました。 起きた問題と解決策を一つずつ説明します。

リクエストハンドラで @ModelAttribute を付けた引数の解決に失敗する

問題

以下のように PostArticleController を実装しました。

data class PostArticleForm(val headline: String = "", val body: String = "")

@SessionAttributes(PostArticleController.FORM_MODEL_NAME)
@Controller
class PostArticleController {
    companion object {
        const val FORM_MODEL_NAME = "postArticleForm"
    }

    @GetMapping("article/new")
    fun viewForm(@ModelAttribute(FORM_MODEL_NAME) postArticleForm: PostArticleForm): ModelAndView {
        return ModelAndView("article/new")
    }
}

GET /article/new にリクエストを送信したところ、以下のようなエラーが発生しました。

org.springframework.web.HttpSessionRequiredException: Expected session attribute 'postArticleForm'

原因と解決策

当初は、デフォルトコンストラクタを使って生成したインスタンスをモデルに追加する程度の意味で @ModelAttribute を使っていました。

You can use the @ModelAttribute annotation on a method argument to access an attribute from the model or have it be instantiated if not present.

Spring Web MVCより引用

しかし、 @SessionAttributes を追加したことで、以下のように「HTTP セッションからインスタンスを取り出す」という挙動に変わってしまい、エラーが発生するようになりました。

  • Retrieved from the model where it may have been added by a @ModelAttribute method.
  • Retrieved from the HTTP session if the model attribute was listed in the class-level @SessionAttributes annotation.
  • Obtained through a Converter where the model attribute name matches the name of a request value such as a path variable or a request parameter (see next example).
  • Instantiated using its default constructor.
  • Instantiated through a “primary constructor” with arguments that match to Servlet request parameters. Argument names are determined through JavaBeans @ConstructorProperties or through runtime-retained parameter names in the bytecode.

Spring Web MVC 公式リファレンスより引用

そこでリストの一つ目に載っている、@ModelAttribute メソッドからモデルにインスタンスを追加する処理を実装しました。

data class PostArticleForm(val headline: String = "", val body: String = "")

@Controller
@SessionAttributes(PostArticleController.FORM_MODEL_NAME)
class PostArticleController {
    companion object {
        const val FORM_MODEL_NAME = "postArticleForm"
    }
    @ModelAttribute(FORM_MODEL_NAME)
    fun postArticleForm(): PostArticleForm {
        return PostArticleForm()
    }
}

再度 GET /article/new にリクエストを送信したところ、入力画面が表示されるようになりました。

送信されたフォームデータを @ModelAttribute を付けた引数にバインドできない

問題

以下のように PostArticleController を実装しました。

data class PostArticleForm(val headline: String = "", val body: String = "")

@Controller
@SessionAttributes(PostArticleController.FORM_MODEL_NAME)
class PostArticleController {
    companion object {
        const val FORM_MODEL_NAME = "postArticleForm"
    }

    @ModelAttribute(FORM_MODEL_NAME)
    fun postArticleForm(): PostArticleForm {
        return PostArticleForm()
    }

    @GetMapping("article/new")
    fun viewForm(@ModelAttribute(FORM_MODEL_NAME) postArticleForm: PostArticleForm): ModelAndView {
        return ModelAndView("article/new")
    }

    @PostMapping("article/post")
    fun post(
        @ModelAttribute(FORM_MODEL_NAME) @Validated postArticleForm: PostArticleForm,
        sessionStatus: SessionStatus
    ): RedirectView {
        println(postArticleForm)
        sessionStatus.setComplete()
        return RedirectView("/")
    }
}

入力画面からフォームを送信したところ、 引数 postArticleForm 内のフィールドがいずれも空文字列になっていました。 @SessionAttributes を付ける前までは、送信されたフォームデータを受け取れていました。

原因と解決策

これは HTTP セッションに保存されたインスタンスのフィールドが不変だから起きている問題でした。 @SessionAttributes を付ける前までは、下記 5 点目のように、受け取ったフォームデータを使ってプライマリコンストラクタを呼び出し、インスタンスを生成していました。

  • Retrieved from the model where it may have been added by a @ModelAttribute method.
  • Retrieved from the HTTP session if the model attribute was listed in the class-level @SessionAttributes annotation.
  • Obtained through a Converter where the model attribute name matches the name of a request value such as a path variable or a request parameter (see next example).
  • Instantiated using its default constructor.
  • Instantiated through a “primary constructor” with arguments that match to Servlet request parameters. Argument names are determined through JavaBeans @ConstructorProperties or through runtime-retained parameter names in the bytecode.

Spring Web MVC 公式リファレンス より引用

しかし、 @SessionAttributes を付けると、 HTTP セッションに保存されたインスタンスを取り出し、受け取ったフォームデータでフィールドを書き換えることになります。 Spring Web MVC の公式リファレンス では、 JavaBeans 規約に則ったアクセッサが公開されているフィールドがデータバインディングの対象になるとされています。

In the context of web applications, data binding involves the binding of HTTP request parameters (that is, form data or query parameters) to properties in a model object and its nested objects.

Only public properties following the JavaBeans naming conventions are exposed for data binding — for example, public String getFirstName() and public void setFirstName(String) methods for a firstName property.

Spring Web MVC 公式リファレンス より引用

Kotlin の公式リファレンス によると、 var で定義したフィールドは setter が生成されるようです。そこで、クラス PostArticleForm のフィールドを val ではなく var で定義しました。

data class PostArticleForm(var headline: String = "", var body: String = "")

再度、入力画面からフォームを送信したところ、送信されたフォームデータを受け取れるようになりました。

想定している入力だけを受け取ることや、実装時の間違いを減らすことを考えると、以下 2 点を徹底したほうが良いと考えました。

  • 入力値をバインドするクラスには入力値に対応するフィールドだけを定義する
  • 入力フォームはユーザーがデータの書き換えを行うのだから、それに対応するクラスのフィールドも var にする

最後に

Spring Framework のリファレンスを見れば、わからないことはちゃんと書いてあったので良かったです。 Spring Boot のリファレンスだけでは分からないこともあるので、分からないことがあったら Spring Framework のリファレンスも参照することをお勧めします。

引用/参考サイト