Spring MVC のロケール周りのメモ

Spring MVC のロケール周りについて調べたのでメモ、以下のバージョンで調べました。

- Spring Boot : 3.3.5
- Spring Framework : 6.1.14

結論からいうと `LocaleResolver` がポイントとなっているようです。

## `Locale` が必要なパターン

どういったときに ロケールを意識するかのユースケースをはじめに紹介します。

### `MessageSource` を使う

Spring では `MessageSource` を使うことでメッセージを取得することができます。

Spring Bootでは `messages.properties` を用意しておくことでメッセージのハードコーディングを避けることができます。

以下、利用例です。

``` properties:messages.properties
hello=こんにちは
```

``` java
@Autowired
private MessageSource messageSource;

@GetMapping("/hello")
public Hello1Response hello() {
  final String hello = messageSource.getMessage("hello", null, null);
  // 省略
}
```

上記のように `MessageSource#getMessage` メソッドを使うことで 外部ファイルに定義された値を取得することができます。

### 多言語対応が必要な場合

多言語対応が必要な場合は 言語ごとに `messages.properties` を定義します。

デフォルト

``` properties:messages.properties
hello=こんにちは
```

英語用

``` properties:messages_en.properties
hello=Hello
```

また、`MessageSource#getMessage` はいくつかオーバーロードされたものがありますが、それぞれ引数に`Locale` を受け取ることができます



``` java
@GetMapping("/hello")
public Hello1Response hello() {
  
  // 日本語(こんにちは)
  final String jaHello = messageSource.getMessage("hello", null, Locale.JAPANESE);
  
  // 英語(Hello)
  final String enHello = messageSource.getMessage("hello", null, Locale.ENGLISH);

  // 省略
}
```

上記のように `Locale` を渡すことで、それぞれの言語のメッセージを取得できます。

(前の例のように`null` を渡した場合は 実装クラスにもよりますが `Locale.getDefault` が利用されます。)


## リクエストに紐づいたロケールを使いたい

先の例の方法だと至る所で言語別の分岐が必要になってしまいます。

できれば利用しているユーザーのリクエストに応じたロケールを使いたいものです。

### `ServletRequest#getLocale`

`ServletRequest` には `getLocale` というメソッドがあり、`Accept-language:` リクエストヘッダーに紐づくロケールを取得することができます。

画面のない WebAPI などではこれでもよいかもしれないですが今回はSpringの仕組みの紹介をするので...

### `Controller` のハンドラメソッドの引数で `Locale` が受け取れる

``` java
@GetMapping("/hello")
public Hello1Response hello(Locale locale) {
  
  final String hello = messageSource.getMessage("hello", null, locale);
}
```

上記のように メソッドの引数に `Locale` を定義することでリクエストに紐づいた ロケールを取得することができます。

ハンドラメソッドの引数は `HandlerMethodArgumentResolver` というインターフェースを使って解決されるのですが、`Locale` の場合は `ServletRequestMethodArgumentResolver` が利用されています。

ここでは `RequestContextUtils.getLocale` メソッドを呼び出しておりその先では `LocaleResolver` を使ってロケールの解決が行われています。

これが今回のポイントとなるインターフェースです。

上記で登場したそれぞれのメソッドは以下を参照してください。

- [ServletRequestMethodArgumentResolver#resolveArgument](https://github.com/spring-projects/spring-framework/blob/v6.1.14/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ServletRequestMethodArgumentResolver.java#L166)
- [RequestContextUtils#getLocale](https://github.com/spring-projects/spring-framework/blob/v6.1.14/spring-webmvc/src/main/java/org/springframework/web/servlet/support/RequestContextUtils.java#L147)


## `LocaleResolver` インターフェース

`LocaleResolver#resolveLocale(HttpServletRequest)` メソッドを使うことで現在のリクエストに紐づいたロケールを取得することができます。

このインターフェースには
- AcceptHeaderLocaleResolver
- CookieLocaleResolver
- SessionLocaleResolver
- FixedLocaleResolver

などの実装クラスがあります。 それぞれ、リクエストヘッダー、Cookie, セッション、固定値をもとにロケールを取得するようになっています。

デフォルトでは `AcceptHeaderLocaleResolver` が使われているようで, Spring Bootだと [WebMvcAutoConfiguration](https://github.com/spring-projects/spring-boot/blob/v3.3.5/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java#L447) で `ConditionalOnMissingBean(name = DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME)` で定義されているので、上書き定義することで `LocaleResolver` を変更することができそうです。

セッションに保存すると セッションが切れたときに画面が元の言語に戻ってしまうので今回はCookieを使ったBean定義の例を示します。

``` java
@Bean(name = DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME)
public LocaleResolver localeResolver(WebProperties webProperties) {
  final AcceptHeaderLocaleResolver headerLocaleResolver = new AcceptHeaderLocaleResolver();
  headerLocaleResolver.setDefaultLocale(webProperties.getLocale());

  // Cookie名はデフォルトだとSpringを使っていることがわかる名前になっているので返る
  final CookieLocaleResolver localeResolver = new CookieLocaleResolver("appLocale");
  // Cookieから取得できなかったときは AcceptHeaderLocaleResolverに処理を委譲
  localeResolver.setDefaultLocaleFunction(headerLocaleResolver::resolveLocale);
  return localeResolver;
}
```

上記の例は `CookieLocaleResolver` をBean定義していますが、もしCookieから取得できなかった場合は `AcceptHeaderLocaleResolver` に処理を委譲してリクエストヘッダから取得するようにしています。 setDefaultLocaleFunction は無理にセットしなくてもデフォルトで `ServletRequest#getLocale` から取得するようになっています。

### ロケールを保存する。

`LocaleResolver` を使うことでそれぞれの場所から`Locale`を取得できますが、取得元の場所に保存しておかないと役に立ちません。

ドキュメントによると `LocaleChangeInterceptor` を使うとできるとありますが [実装](https://github.com/spring-projects/spring-framework/blob/v6.1.14/spring-webmvc/src/main/java/org/springframework/web/servlet/i18n/LocaleChangeInterceptor.java#L116) を見ればわかる通り `ServletRequest#getParameter` を利用しています。

`Thymeleaf` などを使った画面の場合はこれでもよいですが SPAのようにフロントエンドとJSONでやり取りする場合はこれではうまくいきません。このメソッドの実装を参考にしつつ、ロケールを設定する方法を考える必要があります。例えば以下のようにエンドポイントを用意する方法もあります。

```java
@Autowired
private LocaleResolver localeResolver;

@PostMapping("setLocale")
public ResponseEntity<?> setLocale(@RequestBody SetLocaleRequest req, HttpServletRequest request,
    HttpServletResponse response) {

  // ロケールの設定
  // LocaleChangeInterceptor を参考
  Locale locale = StringUtils.parseLocale(req.getLang());
  localeResolver.setLocale(request, response, locale);

  return ResponseEntity.ok().build();
}
```

あとはログイン成功時などにもユーザーの言語設定に紐づいたロケールを保存しておくと良いかもしれないですね。


::: note warn
注意
`AcceptHeaderLocaleResolver` では `setLocale`は `UnsupportedOperationException` がスローされる実装になっているので注意してください。
:::


## おまけ

### `LocaleContextHolder` について

`LocaleContextHolder`というクラスの`getLocale` というstaticメソッドを使うことで `LocaleResolver` によって解決されたロケールを取得することができます。

`LocaleContextHolder` にロケールが設定されるタイミングは2か所あり

- [RequestContextFilter](https://github.com/spring-projects/spring-framework/blob/v6.1.14/spring-web/src/main/java/org/springframework/web/filter/RequestContextFilter.java#L112)
- [FrameworkServlet](https://github.com/spring-projects/spring-framework/blob/v6.1.14/spring-webmvc/src/main/java/org/springframework/web/servlet/FrameworkServlet.java#L1070)

で設定されています。

`RequestContextFilter` では `ServletRequest#getLocale` で取得したロケールが設定されており、`FrameworkServlet` 実際は `DispatcherServlet` では `LocaleResolver` で解決されたロケールが設定されています。

つまり `DispatcherServlet` 以降の処理である `HandlerInterceptor` やハンドラメソッドなどでは `LocaleResolver` の値が取れますが、それより手前の処理である サーブレットフィルターではこの値をとることができません。

なので利用箇所については意識しておく必要がありそうです。

(`LocaleResolver` から取得した値をセットするサーブレットフィルターを作るのはどうなんだろうか？🤔)


## おわりに

- `LocaleResolver` の実装クラスをBean定義することでロケールの取得元を変更できること
- ハンドラメソッドの引数に `Locale` を定義したり、 `LocaleResolver` を直接利用することで現在のリクエストに紐づくロケールを取得することができること
- ロケールの変更には `LocaleResolver#setLocale` を実行することで変更できること

を紹介しました。
多言語対応する機会がないと利用しないかもしれないですが考え方だったりは覚えておくと何かの役に立つかもしれません。