Spring Boot キャンプ : Spring Boot + YAML編

Spring Bootキャンプシリーズ、Spring Boot + YAML編です。

## 今回の目的

Spring BootアプリのプロパティファイルをできるかぎりYAML化してみます。

## 今回使用するライブラリ

* spring-boot-starter:2.2.0.M4
* yaml-resource-bundle:1.1
* spring-boot-starter-test:2.2.0.M4
* lombok:1.18.8

## プロパティファイル vs YAML

**プロパティの管理**

プロパティファイル（.properties）ではプレーンなKey Valueとしてプロパティを定義します。
このため、プロパティの意味的なまとまりを表現しづらく、管理が大変です。

```properties
server.port=8080
logging.file.name=logfile
server.servlet.context-path=/context
```

YAMLは構造的なKey Valueとしてプロパティを定義します。
プロパティファイルと比べると、整理されていることが分かります。

```yaml
server:
  port: 8080
  servlet:
    context-path: /context
logging:
  file:
    name: logfile
```

**日本語のサポート**

古いJavaの慣習に則り、プロパティファイルはネイティブコードで書かれることが多く、IDEのプロパティファイルエディタ等では、`native2ascii`して人間が読める形で表示しています。このため日本語を使用する場合、読むためにIDEが必要となるケースが多くなります。
YAMLならこのようなことはありません。

> プロパティファイルもUTF-8で書けば`native2ascii`不要ですが、多くのIDEは古い前提に立っています。

**YAMLのサポート**

YAMLを使用する場合、SnakeYAML等のライブラリが別途必要になります。
Spring Bootではデフォで依存関係に入るため、意識する必要はありません。

## Spring Bootで扱うプロパティファイル

Spring Bootでは、アプリケーションの設定値をプロパティファイルで管理します。

* Spring Bootの設定ファイル
  - application.properties
  - `@PropertySource`
  - `@TestPropertySource`
* メッセージ定義ファイル
  - messages.properties
* 入力チェックエラーメッセージ定義ファイル
  - ValidationMessages.properties

アプリケーションの実装によっては、もう少し扱うプロパティファイルが増えますが、今回は上記について触れます。

## Spring Bootの設定ファイル

Spring Bootアプリの設定を行うファイルには、以下の種類があります。

| name | description |
|:-----|:------------|
| application.properties | Spring Bootの設定ファイル（標準） |
| `@PropertySource`      | Spring Frameworkの設定ファイル（追加） |
| `@TestPropertySource`  | Spring Testの設定ファイル（テスト用） |

プロパティの読み込みには２種類の方法があり、[機能に違い](https://docs.spring.io/spring-boot/docs/2.2.0.M4/reference/htmlsingle/#boot-features-external-config-vs-value)があります。

* `@ConfigurationProperties`を付与したBeanで読み込み（Type-Safe）
* `@Value`で読み込み（**Not** Type-Safe）

基本的には、タイプセーフにプロパティを扱える`@ConfigurationProperties`を利用した読み込みをお勧めします。

**src/main/resources/application.properties**

```properties
app.config.app-name=Sample
```

**src/main/java/*/properties/AppConfigProperties.java**

```java
@Getter
@Setter
@ConfigurationProperties(prefix = "app.config")
public class AppConfigProperties {
    private String appName;
}
```

**src/main/java/*/service/AppService.java**

```java
@Service
public class AppService {
    @AutoWired
    private AppConfigProperties properties;

    public void test() {
        properties.getAppName(); // -> "Sample"
    }
}
```

### application.propertiesのYAML対応

application.propertiesは標準でYAMLに対応しています。
application.propertiesをapplication.ymlに変更するだけでOKです。

### `@PropertySource`のYAML対応

`@PropertySource`で読み込むプロパティファイルは標準でYAMLに対応していません。[Spring JIRA#SPR-13912](https://jira.spring.io/browse/SPR-13912?redirect=false)で議論され、対応しないこととなったようです。

拡張ポイントがあり、`@PropertySource(factory)`属性でファイルの読み込みをカスタマイズ可能です。
独自にPropertySourceFactoryインターフェイスを実装して、YAML形式のプロパティファイルを読み込んでみます。

**src/main/java/*/YamlPropertySourceFactory.java**

```java
public class YamlPropertySourceFactory implements PropertySourceFactory {

    // (1)
    @Override
    public PropertySource<?> createPropertySource(String name, EncodedResource resource) throws IOException {
        return name == null ? new YamlResourcePropertySource(resource) : new YamlResourcePropertySource(name, resource);
    }
    
    // (2)
    public static class YamlResourcePropertySource extends PropertiesPropertySource {

        public YamlResourcePropertySource(EncodedResource resource) throws IOException {
            this(getNameForResource(resource.getResource()), resource);
        }

        public YamlResourcePropertySource(String name, EncodedResource resource) throws IOException {
            super(name, loadYamlProperties(resource));
        }

        // (3)
        private static String getNameForResource(Resource resource) {

            String name = resource.getDescription();
            if (!StringUtils.hasText(name)) {
                name = resource.getClass().getSimpleName() + "@" + System.identityHashCode(resource);
            }
            return name;
        }

        // (4)
        private static Properties loadYamlProperties(EncodedResource resource) throws FileNotFoundException {

            YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
            factory.setResources(resource.getResource());

            try {
                return factory.getObject();
            } catch (IllegalStateException e) {
                // (5)
                Throwable cause = e.getCause();
                throw cause instanceof FileNotFoundException ? (FileNotFoundException) cause : e;
            }
        }
    }
}
```

(1) PropertySourceFactoryインターフェイスを実装して、createPropertySourceメソッドでYAML形式のプロパティファイルを読み込みます。

(2) PropertiesPropertySourceインターフェイスを実装したYamlResourcePropertySourceクラスで、実際にプロパティファイルを読み込みます。

(3) PropertySourceの名前を決定するgetNameForResourceメソッドは、properties形式で読み込むResourcePropertySourceクラスからそのまま持ってきています。特にこだわりのない部分です。

(4) SpringのYamlPropertiesFactoryBeanクラスを利用して、YAML形式のプロパティファイルを読み込みます。

(5) `@PropertySource(ignoreResourceNotFound)`属性が`true`の場合、指定したファイルが見つからない場合に無視します。
「指定したファイルが見つからない」ことは`FileNotFoundException`がスローされたかどうかで判断されますが、**YamlPropertiesFactoryBeanクラスではYAMLファイルが見つからない場合に`IllegalStateException`がスローされるため、中から`FileNotFoundException`を取り出してあげる必要があります。**

**src/main/java/*/YamlPropertySource.java**

```java
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@PropertySource(value = "", factory = YamlPropertySourceFactory.class) // (1)
public @interface YamlPropertySource {

    @AliasFor(annotation = PropertySource.class, attribute = "name")
    String name() default "";

    @AliasFor(annotation = PropertySource.class, attribute = "value")
    String[] value();

    @AliasFor(annotation = PropertySource.class, attribute = "ignoreResourceNotFound")
    boolean ignoreResourceNotFound() default false;

    @AliasFor(annotation = PropertySource.class, attribute = "encoding")
    String encoding() default "";
}
```

YAML形式のプロパティファイルを読み込む`@PropertySource`拡張アノテーションを作成しておきます。
アノテーション作成は必須ではありませんが、作っておくと楽です。

(1) `@PropertySource(factory)`属性に、先ほど実装したYamlPropertySourceFactoryクラスを指定します。
それ以外の属性は`@PropertySource`にそのまま伝播されるようにしています。

**src/main/java/*/Application.java**

```java
@SpringBootApplication
@YamlPropertySource("classpath:/settings.yml")
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
```

メインクラスにさきほど作成した`@YamlPropertySource`を付与します。

**src/main/resources/settings.yml**

```yml
app:
  config:
    app-name: Sample
```

`@PropertySource`でYAML形式のプロパティファイルを読み込むことができるようになりました！

### `@TestPropertySource`のYAML対応

`@TestPropertySource`で読み込むプロパティファイルは標準でYAMLに対応していません。


`@PropertySource`の`factory`属性のようにファイルの読み込みをカスタマイズできるポイントがなく、YAMLに対応させることはできませんでした。

> ちなみに、properties形式以外にXML形式にも対応していますw

## メッセージ定義ファイル

メッセージ定義ファイルでは、画面等に表示するメッセージの定義、およびメッセージの国際化が可能です。

| name | description |
|:-----|:------------|
| messages.properties | 標準で利用するメッセージ定義ファイル |

メッセージの読み込みには、２種類の方法があります。

* MessageSourceのBeanで読み込み（Controller、Service等で利用）
* テンプレートエンジンの機能で読み込み（Thymeleaf等のViewで利用）

**src/main/resources/messages.properties**

```properties
message.user-not-found=User {0} Not Found!
```

**src/main/java/*/service/AppService.java**

```java
@Service
public class AppService {
    @AutoWired
    private MessageSource messageSource;

    public void test(String userName) { // <- "Tom"
        messageSource.getMessage("message.user-not-found", userName, LocaleContextHolder.getLocale()); // -> "User Tom Not Found!"
    }
}
```

### messages.properties

messages.propertiesは標準でYAMLに対応していません。[Spring JIRA#SPR-15353](https://jira.spring.io/browse/SPR-15353?redirect=false)では対応されておらず、十分な議論もなされていないようです。

Spring Bootとしての拡張ポイントはありませんが、MessageSourceのBeanを上書きすることでファイルの読み込みをカスタマイズ可能です。
MessageSourceでは、国際化対応のためResourceBundleによりファイルを読み込む必要がありますが、ここではOSS公開されているライブラリ`yaml-resource-bundle`を利用してYAMLを読み込んでみます。

**pom.xml**

```xml
<dependency>
    <groupId>net.rakugakibox.util</groupId>
    <artifactId>yaml-resource-bundle</artifactId>
    <version>1.1</version>
</dependency>
```

yaml-resource-bundleを依存ライブラリに追加します。

**src/main/java/*/MessageSourceConfig.java**

```java
@Configuration
public class MessageSourceConfig {

    // (1)
    @Bean
    @ConfigurationProperties(prefix = "spring.messages")
    public MessageSourceProperties messageSourceProperties() {
        return new MessageSourceProperties();
    }

    // (2)
    @Bean("messageSource")
    public MessageSource messageSource(MessageSourceProperties properties) {

        // (3)
        YamlMessageSource messageSource = new YamlMessageSource();

        // (4)
        if (StringUtils.hasText(properties.getBasename())) {
            messageSource.setBasenames(StringUtils
                    .commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(properties.getBasename())));
        }
        if (properties.getEncoding() != null) {
            messageSource.setDefaultEncoding(properties.getEncoding().name());
        }
        messageSource.setFallbackToSystemLocale(properties.isFallbackToSystemLocale());
        Duration cacheDuration = properties.getCacheDuration();
        if (cacheDuration != null) {
            messageSource.setCacheMillis(cacheDuration.toMillis());
        }
        messageSource.setAlwaysUseMessageFormat(properties.isAlwaysUseMessageFormat());
        messageSource.setUseCodeAsDefaultMessage(properties.isUseCodeAsDefaultMessage());
        return messageSource;
    }

    // (3')
    private static class YamlMessageSource extends ResourceBundleMessageSource {
        @Override
        protected ResourceBundle doGetBundle(String basename, Locale locale) throws MissingResourceException {
            return ResourceBundle.getBundle(basename, locale, YamlResourceBundle.Control.INSTANCE);
        }
    }
}

```

(1) Spring Boot設定ファイルのプロパティ`spring.messages.*`を受け取るための`@ConfigurationProperties`Beanを定義します。

(2) `messageSource`Beanを定義します。

> `messageSource`という名前のBeanを定義することで、Spring BootのMessageSourceAutoConfigurationが無効になり、Auto-ConfigではMessageSourceがセットアップされなくなります。Auto-Configを理解しないと適切にカスタマイズできないポイントですね。

(3) YamlResourceBundleクラスを利用してYAML形式のメッセージ定義ファイルを読み込みます。

(4) 生成したYamlMessageSourceにMessageSourcePropertiesのプロパティをすべてセットします。
これにより、Spring Boot標準のResourceBundleMessageSourceと完全に同じように扱うことができます。

**src/main/resources/messages.yml**

```yaml
message:
  user-not-found: User {0} Not Found!
```

messages.ymlを読み込むことができるようになりました！

## 入力チェックエラーメッセージ定義ファイル

入力チェックエラーメッセージ定義ファイルでは、Bean Validationによる入力チェックエラー時に表示するメッセージの定義、およびメッセージの国際化が可能です。

| name | description | Spring MVC Validation | Method Validation |
|:-----|:------------|:---------------------:|:-----------------:|
| messages.properties | Spring MVC Validationで利用する入力チェックエラーメッセージ定義ファイル | ○ | × |
| ValidationMessages.properties | Bean Validationで利用する入力チェックエラーメッセージ定義ファイル | ○ | ○ |


messages.propertiesはSpring MVCのValidation（リクエストパラメータ）には適用されますが、Method Validation（Serviceの引数・戻り値等）には適用されません。このため、基本的に**入力チェックエラーメッセージはValidationMessages.propertiesに定義する**ことになります。

ValidationMessages.propertiesはBean Validation（Hibernate Validator）により自動的に読み込まれます。

**src/main/resources/ValidationMessages.properties**

```properties
javax.validation.constraints.NotNull.message=Must not be null!
```

**src/main/java/*/service/AppService.java**

```java
@Service
@Validated
public class AppService {
    public void test(@NotNull String userName) { // <- null
        // -> throw ConstraintViolationException!
    }
}
```

### ValidationMessages.properties

Hibernate Validatorの標準では、ValidationMessages.propertiesはYAMLに対応していません。
メッセージ定義ファイルの読み込みに拡張ポイントがあり、ResourceBundleLocatorインターフェイスを拡張することで変更することが可能なようです。

ただ、Spring側でもう少し簡単に対策できるため、今回はそちらを紹介します。

#### messages.propertiesをMethod Validationに適用する

Spring MVCおよびMethod ValidationからBean Validationを呼び出すLocalValidatorFactoryBeanでは、ValidationMessages.propertiesの代わりにMessageSourceをBean Validationのメッセージ定義ファイルとして利用することができます。

これを利用して、入力チェックエラーメッセージはすべてmessages.propertiesに定義することができます。

**src/main/java/*/ValidationConfig.java**

```java
@Configuration
public class ValidationConfig {
    @Bean
    public static LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
        factoryBean.setValidationMessageSource(messageSource);
        return factoryBean;
    }
}
```

LocalValidatorFactoryBeanのBean定義を上書きし、setValidationMessageSourceメソッドでMessageSourceをセットします。

**src/main/resources/messages.yml**

```yaml
# Size: "size must be between {2} and {1}." ## (1)
javax:
  validation:
    constraints:
      Size.message: "size must be between {min} and {max}." ## (2)
```

(1)ではSpring MVC Validationの入力チェックエラーメッセージを定義しています。（デフォ）
(2)ではBean Validationの入力チェックエラーメッセージを定義しています。（今回の拡張）

(1)はSpring MVC Validationにしか適用されないのに対して、(2)はMethod Validationにも適用されます。
(1)ではパラメータの解決に`{数字}`を指定するのに対して、(2)では`{属性名}`を指定します。

ValidationMessages.propertiesの代わりにmessages.ymlでメッセージを定義することができました！

## まとめ

YAMLを使うには何とも中途半端なサポート状況ですね。
もっともapplication.properties以外はSpring Frameworkの機能なので、Spring Bootで独自に拡張することを避けているのかもしれませんが。