Rust 小ネタ: serde で null と undefined (missing field) を区別する。
こんにちは。Fairy Devices株式会社 となんらかの関わりがある nogiro (Twitter (現 Twitter): @nogiro_iota) です。
以下の issue の話です。この issue を読めば、この記事を読む必要はほぼありません。
先に結論
- フィールドに 2 重に
Optionをつける。 -
#[serde(default)]をつけて、フィールドがないときはNoneへデシリアライズするようにする。 -
#[serde(deserialize_with = "deserialize_some")]でnullをSome(None)にデシリアライズする。 -
#[serde(skip_serializing_if = "Option::is_none")]をつけることで、Noneをシリアライズするときにフィールドを作らないようにする。(issue には載ってない。)
以下みたいな感じです。
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct S0 {
#[serde(
default,
deserialize_with = "deserialize_some",
skip_serializing_if = "Option::is_none"
)]
f0: Option<Option<String>>, // フィールドが無いときは `None`、null のときは `Some(None)`、値があるときは `Some(Some("value"))` にデシリアライズされる。
}
fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
T: serde::de::Deserialize<'de>,
D: serde::de::Deserializer<'de>,
{
Deserialize::deserialize(deserializer).map(Some)
}
動作例
以下の main 関数を書くと、
fn main() {
let test_data = r#"{}"#;
let de: S0 = serde_json::from_str(test_data).unwrap();
println!("S0: 0: deserialize: {test_data} => {de:?}");
let ser = serde_json::to_string(&de).unwrap();
println!("S0: 0: serialized: {ser}");
let test_data = r#"{"f0":null}"#;
let de: S0 = serde_json::from_str(test_data).unwrap();
println!("S0: 1: deserialize: {test_data} => {de:?}");
let ser = serde_json::to_string(&de).unwrap();
println!("S0: 1: serialized: {ser}");
let test_data = r#"{"f0":"1"}"#;
let de: S0 = serde_json::from_str(test_data).unwrap();
println!("S0: 2: deserialize: {test_data} => {de:?}");
let ser = serde_json::to_string(&de).unwrap();
println!("S0: 2: serialized: {ser}");
}
以下の出力になります。
S0: 0: deserialize: {} => S0 { f0: None }
S0: 0: serialized: {}
S0: 1: deserialize: {"f0":null} => S0 { f0: Some(None) }
S0: 1: serialized: {"f0":null}
S0: 2: deserialize: {"f0":"1"} => S0 { f0: Some(Some("1")) }
S0: 2: serialized: {"f0":"1"}
The Rust Playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=387013087dabc7830f3b9dbf429a25a0
動機
Rust で HTTP サーバーの PATCH メソッド のリクエストボディーをどう受け取るかを検討していました。RESTful Web API の設計指針 などを読んでいると、リソースが nullable な値のフィールドを持つとき、PATCH では以下の 3 通りの挙動ができると嬉しそうです。
- 該当のフィールドが指定されていないとき (missing field) は更新しない。
- 該当のフィールドに
nullが指定されていたときはリソースからフィールドを削除する。 - 該当のフィールドに値が指定されていた場合はその値に更新する。
Rust で serde を使って実装するやり方が前述の 先に結論 のものです。
解説
Rust で nullable な値を表現するときは、典型的には Option<T> を使います。実際に構造体のフィールドを Option<T> にすれば、「serde ではデシリアライズ前のデータにフィールドがない (missing field) とき None にデシリアライズされます」し、「フィールドの値が serde_json の serde_json::Value::Null として扱われるとき None にデシリアライズされます。」
つまり、「missing field のとき」と「null のとき」は同じ None にデシリアライズされます。しかし、「フィールドがあるかどうか?」は構造体へのデシリアライズの際に判定されることと、「null かどうか?」は値をデシリアライズするときに判定されることとの違いによりうまく区別することができます。
構造体のデシリアライズの際に #[serde(default)] を指定すると、「フィールドがないとき」に Option<Option<T>> の外側の Option が Default::default() の返す None となります。そして「フィールドがあるとき」には #[serde(deserialize_with = "deserialize_some")] で呼ばれる deserialize_some() によって null や "value" が、None から Some(None) へ、Some("value") が Some(Some("value")) へマップされることで型が一致することになります。
また、#[serde(skip_serializing_if = "Option::is_none")] はシリアライズするときに、外側の Option が None だった場合にフィールドを作成しないようにするため指定しています。実はこの処理自体は余談なんですが、この型を共有してクライアントも Rust で書くときに齟齬なく利用できます。
なお、3 値の enum を使えば意味的にわかりやすいとは思うが結局 #[serde(default)]、#[serde(skip_serializing_if = "Option::is_none")] が利用側に必要でむずかしい
以下のような、「missing field」「null」「値がある」を 3 値で表す enum を用意すれば意味論的にはわかりやすいです。
#[derive(Debug, Default)]
enum MissingOrNull<T> {
#[default]
MissingField,
Null,
Value(T),
}
しかし前に
#[serde(default)]をつけて、フィールドがないときはNoneへデシリアライズするようにする。#[serde(skip_serializing_if = "Option::is_none")]をつけることで、Noneをデシリアライズするときにフィールドを作らないようにする。
と書いた部分は、結局 MissingOrNull を「利用する」構造体に指定が必要になります。そうすると、型によって書くときのミスを防ぐことができるようにはならないのであまり嬉しくないなと思いました。
実装例と動作例
以下実装例です。
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct S1 {
#[serde(default, skip_serializing_if = "MissingOrNull::is_default")] // この属性マクロを省略できない。
f1: MissingOrNull<String>,
}
#[derive(Debug, Default)]
enum MissingOrNull<T> {
#[default]
MissingField,
Null,
Value(T),
}
impl<T> MissingOrNull<T> {
fn is_default(&self) -> bool {
matches!(self, MissingOrNull::MissingField)
}
}
// import のスコープを限定するためモジュール化する (単なるクセです。)
mod serde_impl {
use super::*;
use serde::de::{Deserialize, Deserializer};
use serde::ser::{Serialize, Serializer};
impl<'de, T> Deserialize<'de> for MissingOrNull<T>
where
T: Deserialize<'de>,
{
fn deserialize<D>(de: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let v = Option::<T>::deserialize(de)?;
match v {
Some(v) => Ok(Self::Value(v)),
None => Ok(Self::Null),
}
}
}
impl<T> Serialize for MissingOrNull<T>
where
T: Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
MissingOrNull::MissingField => None, // unreachable ではあるはずだけど、利用する構造体で `skip_serializing_if` の指定が必要なので panic はさせづらい。
MissingOrNull::Null => None,
MissingOrNull::Value(v) => Some(v),
}
.serialize(serializer)
}
}
}
動作例として以下の main 関数を書くと、
fn main() {
let test_data = r#"{}"#;
let de: S1 = serde_json::from_str(test_data).unwrap();
println!("S1: 0: deserialize: {test_data} => {de:?}");
let ser = serde_json::to_string(&de).unwrap();
println!("S1: 0: serialized: {ser}");
let test_data = r#"{"f1":null}"#;
let de: S1 = serde_json::from_str(test_data).unwrap();
println!("S1: 1: deserialize: {test_data} => {de:?}");
let ser = serde_json::to_string(&de).unwrap();
println!("S1: 1: serialized: {ser}");
let test_data = r#"{"f1":"1"}"#;
let de: S1 = serde_json::from_str(test_data).unwrap();
println!("S1: 2: deserialize: {test_data} => {de:?}");
let ser = serde_json::to_string(&de).unwrap();
println!("S1: 2: serialized: {ser}");
}
以下の出力になります。
S1: 0: deserialize: {} => S1 { f1: MissingField }
S1: 0: serialized: {}
S1: 1: deserialize: {"f1":null} => S1 { f1: Null }
S1: 1: serialized: {"f1":null}
S1: 2: deserialize: {"f1":"1"} => S1 { f1: Value("1") }
S1: 2: serialized: {"f1":"1"}
The Rust Playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=c55e0603196ba81dfcea0cc05bf74a47
終わり
と言っても私がやってる内容だと、クライアントからリソースの個々の値を指定して更新できてもあんまり嬉しくないことが多い (バックエンドが単なる DB の薄いラッパーとして振る舞うことが少ない) ので、kind フィールドとかで「どういう操作をするか?」を受け取る POST メソッドとして、バックエンドでいろいろ処理させる実装のほうが良い気はする。
Discussion