@NotBlank のバリデーションがどう実装されているのか調査してみた

:::note warn
`@NotBlank` のバリデーションの実装や仕様を調査してまとめようと考えていたのですが、一つ疑問が残ってしまいました。もしご存知の方がいれば教えていただきたいです。
:::

# はじめに
`Spring`でバリデーションをする時に`BeanValidation`を用いることがあると思います。`javax.validation.constraints.NotBlank`の`@NotBlank`を使ったときに、文字列が全角スペースのみだった場合にチェックエラーとならず、自分の予想と異なりました。`@NotBlank`がどのような実装や仕様であるか調べたので、備忘録としてまとめます。

# TL;DR
- `Bean Validation`は Java オブジェクトの検証を行うための仕様であり、実装は`Hibernate Validator`でされています。そのため、`javax`のパッケージ配下でバリデーションの実装を探しても見つかりません。
- `@NotBlank`の実装は文字列の先頭と末尾のスペースを`trim()`で除去したのち、文字列の長さが 0 より大きいときは OK、そうでないときはチェックエラーとなる実装となっていました。
- 【疑問点】`@NotBlank`の仕様は「null でないかつ、ホワイトスペースではない文字が少なくとも 1 つ以上含む」でした。また、ホワイトスペースの定義には全角スペースも含まれているようでした。しかし、実装は全角スペースも除去する`strip()`ではなく`trim()`を使っており、`trim()`を使っている理由が不明のまま疑問点として残りました。

# `@NotBlank`のバリデーションの実装について
[NotBlankValidator](https://github.com/hibernate/hibernate-validator/blob/22620adef18f43f23a1c5d0e1faced68f25af550/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/NotBlankValidator.java#L30)に実装があり、下記のようになっています。

```java
	public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) {
		if ( charSequence == null ) {
			return false;
		}

		return charSequence.toString().trim().length() > 0;
	}
```

引数の`CharSequence`型の`charSequence`が`null`の時は`false`をリターンします。`charSequence`が`null`でない時は、前後のスペースを削除した後の文字列の長さが 0 より大きいかどうかをリターンします。`CharSequence`は文字列を操作するためのいくつかのメソッドを提供するインタフェースであり、`String`や`StringBuilder`などが`CharSequence`を実装しています。

# `@NotBlank`のバリデーションの仕様について
## `NotBlankValidator`の JavaDoc について
`isValid()`メソッドには下記のように記載されており、「`null`でないこと」と「前後のホワイトスペースを除去した後に空でないこと」をチェックすると書かれています。

```
Checks that the character sequence is not null nor empty after removing any leading or trailing whitespace.
```

## `BeanValidation`に記載されている仕様について
[Jakarta Bean Validation specification](https://jakarta.ee/specifications/bean-validation/3.0/jakarta-bean-validation-spec-3.0.html)の`@NotBlank`のところをみると、下記のように記載されています。`@NotBlank`のところでコードジャンプしても同様の記載が見られます。

```
The annotated element must not be null and must contain at least one non-whitespace character. Accepts CharSequence.
```

**ホワイトスペースの定義を確認すれば、全角スペースだけの時`@NotBlank`のバリデーション結果が OK で良いのか明らかにできそうです。**

## ホワイトスペースの定義
Javaのドキュメントの[Character](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Character.html#isWhitespace(char))にホワイトスペースの定義がありました。全角スペース(`U+3000`)がこの定義に含まれているかどうか確認すればよさそうです。`SPACE_SEPARATOR`の中に全角スペースがあるかどうか確認します。

```
Determines if the specified character is white space according to Java. A character is a Java whitespace character if and only if it satisfies one of the following criteria:
It is a Unicode space character (SPACE_SEPARATOR, LINE_SEPARATOR, or PARAGRAPH_SEPARATOR) but is not also a non-breaking space ('\u00A0', '\u2007', '\u202F').
It is '\t', U+0009 HORIZONTAL TABULATION.
It is '\n', U+000A LINE FEED.
It is '\u000B', U+000B VERTICAL TABULATION.
It is '\f', U+000C FORM FEED.
It is '\r', U+000D CARRIAGE RETURN.
It is '\u001C', U+001C FILE SEPARATOR.
It is '\u001D', U+001D GROUP SEPARATOR.
It is '\u001E', U+001E RECORD SEPARATOR.
It is '\u001F', U+001F UNIT SEPARATOR.
```

`SPACE_SEPARATOR`の定義をみると、`General category "Zs" in the Unicode specification.`とあり`Zs`に属するユニコード文字の一覧を見れば良さそうです。[Zsに所属するユニコード文字の一覧](https://www.compart.com/en/unicode/category/Zs)をみると、全角スペース(`U+3000`)がありました。

仕様と実装が異なるように見え、混乱してきたので、`String`の`trim()`メソッドの中身を見ていきます。

### `String`の`trim()`メソッドについて
`trim()`は文字列の先頭と末尾の文字が空白スペース（`U+0020`）以下であれば、取り除いて新しい文字列を返すようになっていました。つまり、全角スペース(`U+3000`)は空白スペース（`U+0020`）以下でないので、`trim()`では先頭と末尾に全角スペースがあっても取り除かれないことが分かりました。

<details><summary>trim()メソッドの詳細</summary>

`StringLatin1.trim()`と`StringUTF16.trim()`を呼んでいます。

```java:String.java
    public String trim() {
        String ret = isLatin1() ? StringLatin1.trim(value)
                                : StringUTF16.trim(value);
        return ret == null ? this : ret;
    }
```

それぞれの文字のアスキーコードが空白のアスキーコード(32)以下であれば、文字列の先頭と末尾の空白を削除して返す実装になっています。

```java:StringLatin1.java
    public static String trim(byte[] value) {
        int len = value.length;
        int st = 0;
        while ((st < len) && ((value[st] & 0xff) <= ' ')) {
            st++;
        }
        while ((st < len) && ((value[len - 1] & 0xff) <= ' ')) {
            len--;
        }
        return ((st > 0) || (len < value.length)) ?
            newString(value, st, len - st) : null;
    }
```

それぞれの文字をUnicode文字に変換して、その文字のユニコードが空白のユニコード（`U+0020`）以下であれば、文字列の先頭と末尾の空白を削除して返す実装になっています。

```java:StringUTF16.java
    public static String trim(byte[] value) {
        int length = value.length >> 1;
        int len = length;
        int st = 0;
        while (st < len && getChar(value, st) <= ' ') {
            st++;
        }
        while (st < len && getChar(value, len - 1) <= ' ') {
            len--;
        }
        return ((st > 0) || (len < length )) ?
            new String(Arrays.copyOfRange(value, st << 1, len << 1), UTF16) :
            null;
    }
```
</details>

# まとめ
- `@NotBlank`のバリデーションの実装は文字列の先頭と末尾のスペースを`trim()`で除去したのち、文字列の長さが 0 より大きいときは`true`、そうでないときは`false`となることが分かりました。`trim()`を使っているため、文字列が全角スペースのみの時は、`true`となることが分かりました。
- `Bean Validation`の仕様に従うなら、`trim()`ではなく`strip()`を使うべきなのかなと思いました。実装が`trim()`となっている理由や歴史的な背景があれば、教えていただきたいです。

# 参考情報
https://qiita.com/rebi/items/9c90d504bcd171c0e7fd

https://qiita.com/NagaokaKenichi/items/67a63c91a7db8717fc7d