Rust 小ネタ: `serde_dynamo` で `AttributeValue::B` を扱う。~JSON Base64 を添えて~
前置き: serde_dynamo を使った AttributeValue::B のシリアライズ、デシリアライズの実装
こんにちは。Fairy Devices株式会社 となんらかの関わりがある nogiro (Twitter (現 Twitter): @nogiro_iota) です。最近 Amazon DynamoDB (DynamoDB) を利用するプロダクトを作っています。
Rust で Vec<u8> を見るとバイト配列だと思ってしまいますが、DynamoDB にはデータ型である AttributeValue にバイト配列を扱うための B という形式が用意されています。
同じバイト配列なので、うまく変換できると嬉しそうです。serde_dynamo で Vec<u8> を AttributeValue に変換してみましょう。
以下でプロジェクトを用意して、
mkdir ~/tmp/rust-sandbox-serde_dynamo-byte-array/
cd $_
cargo init
cargo add --features serde/derive,serde_dynamo/aws-sdk-dynamodb+1 serde serde_dynamo serde_bytes aws-sdk-dynamodb
以下のソースコードを書いて、
use aws_sdk_dynamodb::types::AttributeValue;
use serde::{Deserialize, Serialize};
fn main() {
let v = S0 {
binary: vec![0, 1, 2, 3],
};
let ser: AttributeValue = serde_dynamo::to_attribute_value(v).unwrap();
println!("serialized: {ser:?}");
let de: S0 = serde_dynamo::from_attribute_value(ser).unwrap();
println!("deserialized: {de:?}");
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct S0 {
binary: Vec<u8>,
}
実行すると以下の出力になります。
serialized: M({"binary": L([N("0"), N("1"), N("2"), N("3")])})
deserialized: S0 { binary: [0, 1, 2, 3] }
あれ?B ではなく、数からなる配列 (N の L) となっていることが確認できます。バイト配列になっていないと無駄に容量 [1] を使ってしまうのでこれでは困ります。
serde_dynamo の README を読むと、serde_bytes を使うと B に変換することができそうです。
#[derive(Debug, Clone, Deserialize, Serialize)]
struct S1 {
#[serde(with = "serde_bytes")] // with で指定するだけで良い。
binary: Vec<u8>,
}
属性マクロを追加した構造体 S1 を使うよう main 関数を書き換えると以下の出力になります。
serialized: M({"binary": B(Blob { inner: [0, 1, 2, 3] })})
deserialized: S1 { binary: [0, 1, 2, 3] }
無事 B にシリアライズすることができました。
終わり、ではなくて、
「無事シリアライズできました。終わり。」にできれば良かったのですが、プロダクトで既に一部データを数からなる配列として DynamoDB に書き込んでしまっていたのでした。ということで、パッチワーク的ですが serde_bytes の代わりに自作モジュールを作ることを考えてみます。依存クレートを増やしたくない場合にも使えますので、そういう人も参考にしてください。(実際には Fairy Devices で書き込んでいたのは開発環境だけなので、データを消せば良いだけではあります [2]。)
特に「既に書き込んでしまっていた場合」には、「バイト配列 (B)」と「数からなる配列 (N からなる L)」の両方を Vec<u8> にデシリアライズできれば嬉しいのですが、serde_bytes は対応していません。
use aws_sdk_dynamodb::types::AttributeValue;
use serde::{Deserialize, Serialize};
// S0 をシリアライズして `L` にして、S1 にデシリアライズする。
fn main() {
let v = S0 {
binary: vec![0, 1, 2, 3],
};
let ser: AttributeValue = serde_dynamo::to_attribute_value(v).unwrap();
println!("serialized: {ser:?}");
// => serialized: M({"binary": L([N("0"), N("1"), N("2"), N("3")])})
let de: S1 = serde_dynamo::from_attribute_value(ser).unwrap();
// => thread 'main' panicked at src/main.rs:12:58:
// => called `Result::unwrap()` on an `Err` value: Error(ExpectedBytes)
// => note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
println!("deserialized: {de:?}");
}
こういう 自業自得な 込み入ったことは、自分で Visitor を書くことで対応できます。以下に BytesVisitor を作って Visitor トレイトを impl する例を載せます。
// `with_bytes` モジュールを作成して with から利用できるようにする。
use serde::de::{Deserializer, Visitor};
use serde::ser::Serializer;
/// `AttributeValue::B` にシリアライズするため明示的に `serialize_bytes` を呼ぶ。
/// デフォルトだと `AttributeValue::L` に `AttributeValue::N` が並んでいる形になる。
pub fn serialize<S>(v: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_bytes(v)
}
/// `Vec<u8>` へデシリアライズするための `Visitor` を明示的に呼ぶ。
/// (単に `<Vec<u8>>::deserialize()` を呼ぶと `visit_seq()` が呼ばれたり、`<&[u8]>::deserialize()` を呼ぶと `visit_bytes()` がない `BytesVisitor` が使われるので自前で実装する。
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(BytesVisitor)
}
/// `Vec<u8>` へ `AttributeValue::B` と `AttributeValue::L` からデシリアライズする `Visitor`。
/// (`L` の中の `AttributeValue::N` はデフォルトの `Visitor` でデシリアライズされる。)
struct BytesVisitor;
impl<'de> Visitor<'de> for BytesVisitor {
type Value = Vec<u8>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("`AttributeValue::B` or `AttributeValue::L`")
}
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(v.to_vec())
}
/// 以下を参考に実装。
/// - <https://docs.rs/serde/1.0.219/src/serde/de/size_hint.rs.html#12>
/// - <https://docs.rs/serde/1.0.219/src/serde/de/impls.rs.html#1168>
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
const MAX_PREALLOC_BYTES: usize = 1024 * 1024;
let capacity = std::cmp::min(seq.size_hint().unwrap_or_default(), MAX_PREALLOC_BYTES);
let mut values = Vec::<u8>::with_capacity(capacity);
while let Some(value) = seq.next_element()? {
values.push(value);
}
Ok(values)
}
}
動作を確認してみましょう。
mod with_bytes;
use aws_sdk_dynamodb::types::AttributeValue;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
struct S2 {
#[serde(with = "with_bytes")]
binary: Vec<u8>,
}
fn main() {
// `L` にシリアライズしたものをデシリアライズできる。(S0 -> AttributeValue -> S2)
let v = S0 {
binary: vec![0, 1, 2, 3],
};
let ser: AttributeValue = serde_dynamo::to_attribute_value(v).unwrap();
println!("serialized: {ser:?}");
// => serialized: M({"binary": L([N("0"), N("1"), N("2"), N("3")])})
let de: S2 = serde_dynamo::from_attribute_value(ser).unwrap();
println!("deserialized: {de:?}");
// `B` にシリアライズしたものをデシリアライズできる。(S2 -> AttributeValue -> S2)
let v = S2 {
binary: vec![0, 1, 2, 3],
};
let ser: AttributeValue = serde_dynamo::to_attribute_value(v).unwrap();
println!("serialized: {ser:?}");
// => serialized: M({"binary": B(Blob { inner: [0, 1, 2, 3] })})
let de: S2 = serde_dynamo::from_attribute_value(ser).unwrap();
println!("deserialized: {de:?}");
}
無事 L からも B からも Vec<u8> へデシリアライズできることが確認できました。
AttributeValue::B を扱う new type を作る。
余談: JSON で Base64 エンコードされたデータを扱う。
new type を作る前置きですが、Rust で Base64 エンコードされたデータを扱ったことがあるなら、類似の状況だなあと思われたかもしれません。(例えば、ユーザーデータにアイコン画像のサムネイルデータが有るなど。)
Base64 エンコードされたデータは以下のようにシリアライズ、デシリアライズします。[3]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
struct S3 {
#[serde(with = "with_base64")]
binary: Vec<u8>,
}
mod with_base64 {
use base64::prelude::{BASE64_STANDARD, Engine as _};
use serde::de::{Deserialize, Deserializer, Error as DeError};
use serde::ser::{Serialize, Serializer};
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let de = BASE64_STANDARD
.decode(s.as_bytes())
.map_err(DeError::custom)?;
Ok(de)
}
pub fn serialize<S>(v: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let ser = BASE64_STANDARD.encode(v);
ser.serialize(serializer)
}
}
実装例と動作例
base64 と serde_json を以下で依存に追加します。
cargo add base64 serde_json
JSON にシリアライズ、デシリアライズするときに Base64 エンコード、デコードされることを確認する main 関数を以下のように実装します。
fn main() {
let v = S3 {
binary: vec![0, 1, 2, 3],
};
let ser = serde_json::to_string(&v).unwrap();
println!("serialized: {ser:?}");
let de: S3 = serde_json::from_str(&ser).unwrap();
println!("deserialized: {de:?}");
}
// S3 の定義などは省略
以下の出力になります。
serialized: "{\"binary\":\"AAECAw==\"}"
deserialized: S3 { binary: [0, 1, 2, 3] }
よくやるやつ: Base64 を扱う new type を作る。
ニュータイプイディオム を使って、シリアライズ、デシリアライズの際に Base64 エンコード、デコードするような「Vec<u8> のニュータイプ」を用意するというのも、実装したことがあるかもしれません。
今回は、上記で with 向けに作った deserialize()、serialize() がすでにあります。Deserialize トレイト、Serialize トレイトを Vec<u8> のニュータイプに impl するとき、横着してそれらを呼び出せば楽に実装できます。以下みたいな感じですね。
#[derive(Debug, Clone)]
struct Base64String(Vec<u8>);
mod serde_base64_impl {
use super::*;
use serde::de::{Deserialize, Deserializer};
use serde::ser::{Serialize, Serializer};
impl<'de> Deserialize<'de> for Base64String {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
with_base64::deserialize(deserializer).map(Self)
}
}
impl Serialize for Base64String {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
with_base64::serialize(&self.0, serializer)
}
}
}
実装例と動作例
上で作成した Base64String を使う main 関数を以下のように実装します。
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
struct S4 {
binary: Base64String,
}
fn main() {
let v = S4 {
binary: Base64String(vec![0, 1, 2, 3]),
};
let ser = serde_json::to_string(&v).unwrap();
println!("serialized: {ser:?}");
let de: S4 = serde_json::from_str(&ser).unwrap();
println!("deserialized: {de:?}");
}
// Base64String の定義などは省略
以下の出力になります。デシリアライズ後の結果に Debug によって Base64String(、) が追加されていますね。
serialized: "{\"binary\":\"AAECAw==\"}"
deserialized: S4 { binary: Base64String([0, 1, 2, 3]) }
本題: AttributeValue::B を扱う new type を作る。
それなら、AttributeValue でも同様のことができるはずです。(B と L の両対応をした実装はこっそり src/with_bytes.rs に書いていたので、with_bytes モジュールの deserialize() と serialize() を利用します。)
#[derive(Debug, Clone)]
struct AttributeValueBytes(Vec<u8>);
mod serde_b_impl {
use super::*;
use serde::de::{Deserialize, Deserializer};
use serde::ser::{Serialize, Serializer};
impl<'de> Deserialize<'de> for AttributeValueBytes {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
with_bytes::deserialize(deserializer).map(Self)
}
}
impl Serialize for AttributeValueBytes {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
with_bytes::serialize(&self.0, serializer)
}
}
}
以下の main 関数を書いて B と L に両対応できているか確認します。
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
struct S5 {
binary: AttributeValueBytes,
}
fn main() {
// `L` にシリアライズしたものをデシリアライズできる。(S0 -> AttributeValue -> S5)
let v = S0 {
binary: vec![0, 1, 2, 3],
};
let ser: AttributeValue = serde_dynamo::to_attribute_value(v).unwrap();
println!("serialized: {ser:?}");
// => serialized: M({"binary": L([N("0"), N("1"), N("2"), N("3")])})
let de: S5 = serde_dynamo::from_attribute_value(ser).unwrap();
println!("deserialized: {de:?}");
// => deserialized: S5 { binary: AttributeValueBytes([0, 1, 2, 3]) }
// `B` にシリアライズしたものをデシリアライズできる。(S5 -> AttributeValue -> S5)
let v = S5 {
binary: AttributeValueBytes(vec![0, 1, 2, 3]),
};
let ser: AttributeValue = serde_dynamo::to_attribute_value(v).unwrap();
println!("serialized: {ser:?}");
// => serialized: M({"binary": B(Blob { inner: [0, 1, 2, 3] })})
let de: S5 = serde_dynamo::from_attribute_value(ser).unwrap();
println!("deserialized: {de:?}");
// => deserialized: S5 { binary: AttributeValueBytes([0, 1, 2, 3]) }
}
無事 L でも B でも AttributeValueBytes へデシリアライズできていることが確認できました。
終わり
似たようなサンプルばかりになってのっぺりした記事になってしまった……記事がのっぺりしないための対策などもコメントお待ちしています。
-
実際にどう無駄なのかは DynamoDB の実装を見てみないとわかりませんが、
Lには 多様な型のデータを混在できる ため、Bより容量は大きくなりそうです。 ↩︎ -
消せば良いのですが、本番環境で書き込んでしまっていた場合にどうすると良いかを考える良い機会でした。対応としては、データ型が複数ある状態を解消するためバッチ処理をしても良いかもしれません。 ↩︎
-
serde_with でも可能です。 ↩︎
Discussion