この記事は Nature Remo Advent Calendar 2022/ モダン言語による組み込み開発 Advent Calendar 2022の13日目その2です。今日は17日な気がしますが、13日その2です。 例のごとく2つのアドカレに投げてますあが、2個目なので許してください。
その1はこちら
その1の方はM5Stackアドベントカレンダーにも投げてますので、こちらもよろしければどうぞ。
ファームウェアエンジニアの井田です。 今年8月半ばからNatureでファームウェアエンジニアをやっています。(2回目)
前回はM5PaperでRemo/Remo Eのセンサデータを表示するプログラムの概要を説明しました。 今回はその中で出てきた、Nature Remo Cloud API (以下Cloud API) から取得したJSONデータを解析する部分の実装についての話です。
Cloud APIのデータ (再掲)
まずおさらいとして、前回掲載した Cloud APIのJSONデータ例を再度掲載しておきます。
https://api.nature.global/1/devices
のJSONデータの例
[ { "name": "Remo", "id": "12948215-568a-49ca-be45-c556e8140c56", "created_at": "2022-10-07T05:57:52Z", "updated_at": "2022-10-07T05:57:52Z", "mac_address": "24:6f:28:00:11:22", "bt_mac_address": "24:6f:28:22:33:44", "serial_number": "1W012345678901", "firmware_version": "Remo/1.10.0", "temperature_offset": 1, "humidity_offset": 0, "users": [ { "id": "50937884-2550-46b1-9b0a-503410c06f6d", "nickname": "Hoge Fuga", "superuser": true } ], "newest_events": { "hu": { "val": 66, "created_at": "2022-10-07T05:58:00Z" }, "il": { "val": 27, "created_at": "2022-10-07T06:05:47Z" }, "mo": { "val": 1, "created_at": "2022-10-07T06:05:57Z" }, "te": { "val": 26.6, "created_at": "2022-10-07T06:06:01Z" } } }, ]
https://api.nature.global/1/appliances
のJSONデータの例
[ { "id": "081c5163-ee9e-486e-ba4d-e86a16ea4c9b", "device": { "name": "Remo E lite", "id": "159c34f6-d99a-46ca-a50a-3440ba7f8c8e", "created_at": "2022-10-08T07:49:56Z", "updated_at": "2022-10-08T07:52:43Z", "mac_address": "34:ab:95:00:11:22", "bt_mac_address": "34:ab:95:33:44:55", "serial_number": "4W012345678901", "firmware_version": "Remo-E-lite/1.7.2", "temperature_offset": 0, "humidity_offset": 0 }, "model": { "id": "1eb17958-9a47-4000-8b9d-b3dffaf9616c", "manufacturer": "", "name": "Smart Meter", "image": "ico_smartmeter" }, "type": "EL_SMART_METER", "nickname": "スマートメーター", "image": "ico_smartmeter", "settings": null, "aircon": null, "signals": [], "smart_meter": { "echonetlite_properties": [ { "name": "cumulative_electric_energy_effective_digits", "epc": 215, "val": "7", "updated_at": "2022-10-22T11:38:14Z" }, { "name": "normal_direction_cumulative_electric_energy", "epc": 224, "val": "1097158", "updated_at": "2022-10-22T11:38:14Z" }, { "name": "cumulative_electric_energy_unit", "epc": 225, "val": "2", "updated_at": "2022-10-22T11:38:14Z" }, { "name": "measured_instantaneous", "epc": 231, "val": "397", "updated_at": "2022-10-22T11:38:14Z" } ] } } ]
JSONパーサーの設計方針
JSONパーサーの設計方針として、ESP32上で問題なく動かすために、なるべく省メモリかつ実行時のメモリ確保を行わないようにします。
まず、JSONの解析に必要なバッファの長さを考えます。結論から言うと、対象のJSONデータ中に含まれるスカラー値 (文字列や数値) を十分格納できるだけのバッファが必要です。 文字列の場合は、ダブルクォートで囲むので、スカラー値の長さ + 2の長さが必要です。
実行時のメモリ確保を行わない方針ですので、コンパイル時にあらかじめ処理できるスカラー値の長さの上限から、バッファ長を決めておきます。
また、パーサーの状態管理のために、 {}
や []
のネストの深さ分のステートのスタックが必要となります。こちらも解析対象のJSONデータに基づいて最大値を決めておきます。
Cloud APIのJSONの場合、 デバイス名やユーザー名、アプライアンス名のフィールドはユーザーにより設定可能な文字列ですので、それなりに長くなる可能性があります。 スタック深さは各APIのJSONデータを見ると、devicesは4、appliancesは6の深さが必要なことがわかります。 パーサーの初期ステートもスタックに積む構成にするため、実際にはデータの深さ + 1のスタックが必要です。以下にAPIとバッファ長、スタック深さの表を示します。
対象 | バッファ長 | スタック深さ |
---|---|---|
/1/devices | 48 | 5 |
/1/appliances | 64 | 7 |
JSONパーサーの大枠の処理
JSONパーサーの大枠の処理は単純です。
- 入力ストリームからバッファの空きに読めるだけ読みこむ
- バッファの先頭から続いている 空白文字 (スペースやタブ、改行文字) を読み飛ばす
- バッファの文字列を現在のステートにしたがって解析する
- 解析結果にしたがってステートを更新する
1, 2の処理により、現在のところ入力ストリームからバッファへ読みこめるだけのデータが読みこまれており、空白を読み飛ばした位置以降には全く何もないか、空白文字以外の何かしらの文字列がある状態になっています。
この状態で、空白を読み飛ばした位置以降の文字列が、現在のパーサーの状態で期待する文字列であるかどうかを解析します。
例えば、解析処理の初期状態の場合、JSONの規則としては次の4つの可能性があります。
1は空白を除いて空のJSONデータだった場合に該当します。空なので解析結果も空です。
2はオブジェクトの開始記号が来たので、これ以降は対応する }
が現れるまではキーと値のペアの列が来るはずです。
3は配列の開始記号なので、これ以降は対応する ]
が現れるまで、値の列が来るはずです。
4はスカラー値を見つけたので、スカラー値を出力してここで終わりです。
この時、 {
や [
が来たかどうあを判定するのは簡単ですが、JSONのスカラー値かどうかを解析するのは結構面倒です。
こういった解析処理を簡単に書くためのライブラリとして、 Rustには nom crateがあります。
nomによるJSONスカラー値の解析
nomはいわゆる パーサー・コンビネータ と呼ばれる機能を提供するcrateです。指定した一文字の入力を受理する関数を返す関数や空白文字を受理する関数、複数の関数を受け取ってそのうち入力を受理するものの結果を返す関数を構築する関数、と言ったパーサーを構築するための関数を組み合わせて、受理する入力の集合 (言語) を定義します。
例えば、以下のコードは char
パーサーを使って指定した文字 'a'
を1文字入力して返す関数を構築する例です。
use nom::character::streaming::{char}; use nom::IResult; fn main() { let r: IResult<&str, char, ()> = char('a')("abc"); println!("{:?}", r); // Ok(("bc", 'a')) let r: IResult<&str, char, ()> = char('a')("bac"); println!("{:?}", r); // Err(Error(())) }
char('a')
は &str
を受け取って IResult<&str, char, ()>
を返す関数を返します。入力 "abc"
を与えると、先頭の a
を入力として受理し、残りの文字列 "bc"
と受理した文字列 'a'
を返します。一方、入力 "bac"
を与えるとエラーを返します。
nomに定義されているパーサーの詳細はnomのドキュメントに記載されています。
以下の json_scalar_value
関数はJSONのスカラー値を解析して返すパーサーを構築します。
#[derive(Clone, Copy, Debug, PartialEq)] pub enum JsonScalarValue<'a> { Null, Boolean(bool), String(&'a str), Number(JsonNumber), } fn json_string<'a, E: ParseError<&'a str> + ContextError<&'a str>>( i: &'a str, ) -> IResult<&'a str, &'a str, E> { context( "string", preceded(char('\"'), cut(terminated(escaped_str, char('\"')))), )(i) } fn json_null<'a, E: ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, (), E> { value((), tag("null"))(input) } fn json_boolean<'a, E: ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, bool, E> { alt((value(true, tag("true")), value(false, tag("false"))))(input) } fn json_scalar_value<'a, E: ParseError<&'a str> + ContextError<&'a str>>( _is_eof: bool, ) -> impl FnMut(&'a str) -> IResult<&'a str, JsonScalarValue, E> { alt(( map(json_string, |s| JsonScalarValue::String(s)), map(json_boolean, |b| JsonScalarValue::Boolean(b)), map(json_null, |_| JsonScalarValue::Null), terminated( |i| { recognize_float_or_exceptions(i).and_then(|p| { JsonNumber::try_parse(p.1) .map(|n| (p.0, JsonScalarValue::Number(n))) .or(Err(nom::Err::Failure(E::from_error_kind( i, ErrorKind::Float, )))) }) }, peek(one_of(",]} \t\n\0")), ), )) }
json_scalar_value
関数が返す関数は、入力したJSONスカラー値の型に応じて JsonScalarValue
列挙型の値を返します。
入力したJSONスカラー値の型 | 解析結果 |
---|---|
文字列 | JsonScalarValue::String(s) |
数値 | JsonScalarValue::Number(n) |
ブール値 (true/false) | JsonScalarValue::Boolean(b) |
null | JsonScalarValue::Null |
数値型の解析は複雑ですが、幸いなことに、nomには recognize_float_or_exceptions
関数が定義されており、符号の有無や指数表記 (+1.0e-3
など) を含めた数値を解析できます。
json_scalar_value
では recognize_float_or_exception
を用いて数値型の解析を行っています。
作成したJSONパーサーを使ったJSON解析処理
作成したJSONパーサーは以下のAPIを持ちます。 JSONパーサーを表す Parser
型は、前述の解析用バッファ buffer
フィールド、ステートのスタック state_stack
などのフィールドを持ちます。
また、実際に解析を実行する parse
関数を実装します。
use heapless::Vec; use embedded_io::{blocking::Read, Io}; #[derive(Clone, Copy, Debug)] enum ParserState { Start, End, MapStart, MapKey, KeyDelimiter, MapValue, MapPairDelimiter, ArrayStart, ArrayValue, ArrayValueDelimiter, Pop, } #[derive(Clone, Copy, Debug, PartialEq)] pub enum JsonNode<'a> { StartMap, EndMap, StartArray, EndArray, Key(JsonScalarValue<'a>), Value(JsonScalarValue<'a>), } pub enum ParserCallbackAction { Nothing, End, } pub struct Parser<const BUFFER_SIZE: usize, const MAX_DEPTH: usize> { state: ParserState, buffer: Vec<u8, BUFFER_SIZE>, state_stack: Vec<ParserState, MAX_DEPTH>, bytes_remaining: Option<usize>, } impl<const BUFFER_SIZE: usize, const MAX_DEPTH: usize> Parser<BUFFER_SIZE, MAX_DEPTH> { pub const fn new() -> Self { Self { state: ParserState::Start, buffer: Vec::new(), state_stack: Vec::new(), bytes_remaining: None, } } pub fn parse<I: Read, F, CallbackError>( &mut self, reader: &mut I, mut callback: F, ) -> Result<bool, ParserError<I::Error, CallbackError>> where F: for<'node> FnMut(JsonNode<'node>) -> Result<ParserCallbackAction, CallbackError> { //... } }
parse
関数は文字を入力元となる embedded_io::Read
を実装した型の参照と、JSONの解析内容を通知・処理するクロージャ callback
を受け取ります。
クロージャはJSONの各種要素を見つけるたびに、見つけたJSONの要素を表す JsonNode
列挙型の参照を引数として呼び出され、処理を継続するか否かを表す ParserCallbackAction
を返します。
JsonNode
列挙型は以下の値を持ちます。
値名 | 内容 |
---|---|
StartMap | オブジェクト(マップ)の開始 |
EndMap | オブジェクト(マップ)の終了 |
StartArray | 配列の開始 |
EndArray | 配列の終了 |
Key(JsonScalarValue<'a>) | オブジェクト(マップ)のキー |
Value(JsonScalarValue<'a>) | 値 |
例として、以下のJSONデータを解析した場合のクロージャに渡される JsonNode
値を順に示します。
[ { "name": "Remo", "users": [ { "id": "50937884-2550-46b1-9b0a-503410c06f6d", } ], "newest_events": { "hu": { "val": 66, "created_at": "2022-10-07T05:58:00Z" }, "il": { "val": 27, "created_at": "2022-10-07T06:05:47Z" }, } }, ]
StartArray StartMap Key(JsonScalarValue::String("name")) Value(JsonScalarValue::String("Remo")) Key(JsonScalarValue::String("users")) StartArray StartMap Key(JsonScalarValue::String("id")) Value(JsonScalarValue::String("50937884-2550-46b1-9b0a-503410c06f6d")) EndMap EndArray Key(JsonScalarValue::String("newest_events")) StartMap Key(JsonScalarValue::String("hu")) StartMap Key(JsonScalarValue::String("val")) Value(JsonScalarValue::Number(66)) Key(JsonScalarValue::String("created_at")) Value(JsonScalarValue::String("2022-10-07T05:58:00Z")) EndMap Key(JsonScalarValue::String("il")) StartMap Key(JsonScalarValue::String("val")) Value(JsonScalarValue::Number(27)) Key(JsonScalarValue::String("created_at")) Value(JsonScalarValue::String("2022-10-07T06:05:47Z")) EndMap EndMap EndMap EndArray
例えば users.id
が 50937884-2550-46b1-9b0a-503410c06f6d
のデバイスの hu.val
を取得したい場合は、
Key(JsonScalarValue::String("users")) StartArray StartMap Key(JsonScalarValue::String("id")) Value(JsonScalarValue::String("50937884-2550-46b1-9b0a-503410c06f6d"))
の順で呼び出されたことを検出し、その後、
Key(JsonScalarValue::String("hu")) StartMap Key(JsonScalarValue::String("val")) Value(JsonScalarValue::Number(66))
の順で呼び出されたときの Value
の内容を保存するようなクロージャを渡します。
実際にはいちいちアプリケーションの実装側でこれらの処理を実装するのは手間がかかるので、 remo-api
crateには、JSONパーサーを用いて devices
や appliances
を解析し、デバイスやアプライアンスの内容の構造体を構築する read_devices
read_appliances
関数を実装しています。
まとめ
serde
で実装したらメモリ不足でpanicしたときはどうしようかと思いましたが、nom
が優秀なおかげで、割と簡単に省メモリなJSONパーサーと、それを使ったCloud APIの返すJSONデータのパーサーを実装できました。
このあたりはcargoという優秀なパッケージ・マネージャ、Rustのパターンマッチなどの現代的な言語機能のおかげと思います。Rustはいいぞ!
なお、現在エンジニア積極採用中ですので、組込みRustを含む組込み開発環境の改善に興味がある方をお待ちしております。
カジュアル面談もやってますので、ぜひ。
Natureのミッション、サービス、組織や文化、福利厚生についてご興味のある方は、ぜひCulture Deckをご覧ください。