画面の入力欄が空のとき、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で統一すべきだと考える。