画面の入力欄が空のとき、APIのリクエストボディをどうハンドリングすべきかについて考える。
フロントエンドの観点
画面の入力欄が空の状態での保存APIのリクエストボディについてフロントエンドの実装から考えてみたい。
フロントエンドで例えばVueを使用していて v-model
で <input />
とJavaScriptの変数(key
とする)をバインディングしているとすると、初期値はnull
やundefined
だが入力後に削除した場合は空文字になっていて、APIのリクエストボディには以下の3ケースどれもあり得るという事態になりやすい。
JSON | 備考 |
---|---|
{ "key": null } |
※JSの変数がnullになっている |
{ "key": "" } |
※JSの変数が空文字になっている |
{} |
※JSの変数がundefinedになっている |
フロントエンドで統一的に揃えることもすべきだが、システムの守りとしてバックエンド側(Spring Boot)で統一的に扱うことを考える。
データベースはどうあるべきか?
バックエンドAPIがどうあるべきか考える前に、データベースにどのように「値がない」を保存すべきか考えてみる。
「値がない」だけであれば、NULL
でも空文字""
でもどちらでも良さそうに思えるかもしれないが、以下の2点の理由からNULL
にすべきであると考える。
- 統一性
- 例えば
LEFT OUTER JOIN
した時に「値がない」場合はNULL
になる
- 例えば
- 制約
NOT NULL
という組み込みの制約機能がある
CHECK制約でNOT EMPTY
を実現することもできるが、組み込みの制約機能ではなく、データベースとして「値がない」を空文字で表現することを想定していない。
バックエンドAPIはどうあるべきか?
結論としては null
で統一すべきと考える。
以下、順を追って考えてみる。
業務ロジックから見る @NotEmpty
と @NotNull
ある画面入力欄が必須あるいは任意という要件があって、それを実現するために、フロントエンドの制御やデータベースの制約を入れていく。同様にJavaのBean Validationを考えてみる。
「必須」を表現する
入力欄が必須という要件に対して、@NotEmpty
と@NotNull
を使い分けるケースがそれほどない。
ユーザーが自由に文字を入力する欄をString変数で持つのであれば、文字数制限が大抵あるはずで、@Size
で最小文字数、最大文字数の制約を入れておけば、「必須」を表現するのに@NotEmpty
と@NotNull
を使い分ける必要がない。
String変数に入る値が固定値(例えばセレクトボックスから選択した文字)なのであれば、値の有効性チェックやそもそもenum変数で持つなどする。その中で空文字が有効であることは通常ないため、「必須」を表現するのにnull
と空文字を分けて考える必要がない。
List変数であれば、大抵は画面のチェックボックスなどと紐づくが、List変数内の要素が正しいかなどが同様に保証されていればよく、「必須」を表現するのにnull
なのか空リストなのかを区別する必要がない。
「必須」を表現するのに適しているのは @NotEmpty
か @NotNull
か
「必須」を表現するのに@NotEmpty
と@NotNull
を使い分ける必要はないものの、必須のString変数とList変数に@NotEmpty
と@NotNull
のどちらをつけるべきかということを考えたい。
@Size
や@Pattern
が併記されていない場合は、@NotNull
ではなく@NotEmpty
をつけるようにしておきたい。null
と空文字をnull
で統一する話とは別に、必須だけど空文字かもしれないという印象を与えるコードよりも、必須なのだからnull
でも空文字でもないということを@NotEmpty
で明示した方が良い。
@Size
や@Pattern
が併記されている場合は、空文字の時のNG判定が@Size
と@NotEmpty
で重複してしまう問題があるので、@NotNull
をつけるようにすべき。「必須だけど空文字かもしれない」問題については@Size
のmin
を見ればわかることである。
不正値はバリデーションでできるだけ早期に弾くべきなので、基本的には以下のように@Size
とともに@NotNull
を採用すべきである。
@NotNull
@Size(min = 1, max = 10)
private String key;
「任意」を表現する
任意の自由入力欄であれば、入力されていないことを示すのにnull
と空文字どちらになっているかということをJavaで厳密に区別する必要性はない。
null
で統一する
バックエンドAPI的には、null
と空文字を区別する必要性がないため(これはTypeScriptの型で不都合が発生しなければフロントエンドも同じ)、データベースのことを考慮して null
で統一すべきで、Spring Bootでの実現方法を考える。
フロントエンドではnull
、空文字、キーなしの3ケースが容易に発生するが、null
とキーなしはSpring Boot的にはどちらもnull
の変数になるため、一緒に考えられる。
JsonDeserializeをカスタムしたObjectMapperをBean登録する
Spring BootでJsonDeserialize
をカスタムしたObjectMapperをBean登録する。private final ObjectMapper objectMapper;
と宣言したフィールドにDIされるクラスやRequest / Responseのクラス <-> JSON間の自動変換で使用されるクラスが自分で定義したObjectMapperになり、アプリケーション全体のObjectMapperの挙動を変えられるようになる。
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
@Configuration
public class ObjectMapperConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
// StringDeserializerを登録する
.registerModule(new SimpleModule().addDeserializer(String.class, new StringDeserializer()))
;
}
public static class StringDeserializer extends JsonDeserializer<String> {
@Override
public String deserialize(JsonParser jsonParser, DeserializationContext ctx) throws IOException {
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
String value = node.textValue();
return "".equals(value) ? null : value;
}
}
}
※動作確認version
- Spring Boot 3.2.5
- Java 22
- Jackson 2.15.4
余談: プログラム的にはnullを排除して空文字で統一したくなる
Javaの層だけで考えると、NullPointerExceptionが発生する可能性が大幅に減るから、プログラミング的にはnull
を排除したくなってしまうかもしれない。
Listの場合でもlist != null && !list.isEmpty()
と書かなければいけなかったり、nullable listでもいい感じに扱ってくれるライブラリ(例えば!CollectionUtils.isEmpty(list)
)で書かなければいけなかったりするよりも、空リストで統一したくなるかもしれない。
それでも、nullで統一すべきだと考える。