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 で書くときに齟齬なく利用できます。
#[serde(default)]
、#[serde(skip_serializing_if = "Option::is_none")]
が利用側に必要でむずかしい
なお、3 値の enum を使えば意味的にわかりやすいとは思うが結局 以下のような、「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