🍅

Sysmac StudioとNXでJSONを扱う

2025/02/26に公開

はじめに

本記事は、PLC(Programmable Logic Controller)向けのソフトウェア開発に従事、または関心のある方で、Structured Text(ST)による開発に興味がある方向けです。OMRON社のSysmac Studioとコントローラ(NX1またはNX5)を使用します。

今回は、Sysmac StudioとNXでJSONを処理します。JSONパーサの詳細には触れず、JSONパーサへの要求について述べます。JSONはデータ交換フォーマットのデファクトスタンダードと言える状況にあるので、扱えるに越したことはありません。コントローラのSDカードに保存したJSONファイルを設定ファイルとして使用することもできます。JSONの生成は以下のように行えるものとしました。

iNow :=GetTime();
JSONContext_init(iJson);
JSON_ARRAY(Context:=iJson);
FOR i := 0 TO 2 DO
    JSON_OBJECT(Context:=iJson);
        JSON_STRING(Context:=iJson,
                    Key:='product_id',
                    Value:=UINT_TO_STRING(i));
        JSON_UINT(Context:=iJson,
                  Key:='amount',
                  Value:=(i + 1) * 100);
        // DTの出力は高コスト(50us以上)なので、可能ならエポック秒等が好ましい。
        JSON_DATE_AND_TIME(Context:=iJson,
                           Key:='timestamp',
                           Value:=iNow, Timezone:='+09:00');
    JSON_OBJECT_CLOSE(Context:=iJson);
END_FOR;
JSON_ARRAY_CLOSE(iJson);
// [{"product_id":0,"amount":100,"timestamp":"YYYY-MM-DDThh:mm:ss+09:00"},...]
JSONContext_toString(iJson, iJsonStr);

JSONのパースと値の取得は以下のように行えるものとしました。

iJsonStr := '{"command":10, "payload":{"product_id":"xxxxx-xxx", "amount":500, "start_at":"2025-02-25T13:00:00+09:00"}}';
iJsonBinSize := StringToAry(In:=iJsonStr, AryOut:=iJsonBin[0]);
JSONContext_init(iJson);
// JSONParserは、1サイクルでパースを終わらせる。
// そのため、サイズの大きなJSONはタスク時間を圧迫する可能性がある。
iParser(Execute:=TRUE,
        Context:=iJson,
        Data:=iJsonBin,
        // Dataの開始位置を指定する。
        Head:=0,
        // Dataのサイズを指定する。
        Size:=iJsonBinSize);

JSON_TO_STRING(Context:=iJson,
               Key:='payload.product_id',
               Value=>iProductId,
               Default:='');
JSON_TO_UINT(Context:=iJson,
             Key:='payload.amount',
             Value=>iAmount,
             Default:=0);
JSON_TO_DATE_AND_TIME(Context:=iJson,
                      Key:='payload.start_at',
                      Value=>iStartAt,
                      Default:=DT#1970-01-01-00:00:00);	

JSONパーサの実装については、STとして強調する事柄も無く冗長なので扱いません。JSONパーサでやることは、入力データのトークン化とトークンからJSONの構造を表現するデータ構造を作成することです。データ構造はJSONの構造を再現する必要も、構文木である必要もありません。アクセサの要求に応えられればどのような構造でも問題ありません。JSONパーサのように、一度使い始めると多用する可能性があるものは、出自を異にする実装がいくつか存在することが好ましいと考えています。問題が生じた場合に代替手段が無いと大事になります。JSONがどのようなものであるかを改めて扱うことはしません。以下を参照してください。

https://datatracker.ietf.org/doc/html/rfc8259

Sysmacプロジェクト

ライブラリと使用例は以下にあります。使用例は、一連のテストを含んでいるためサイズが大きいです。テストは5分程かかります。テスト結果を確認する場合、出力ファイルが多いので"❌ Test suite"があるかgrepしてください。該当するファイルが無ければ全てのテストをパスしています。

https://github.com/kmu2030/JSONLib

JSONパーサへの要求

JSONパーサは、JSON文字列を解析してプログラム内で使用できる形に変換します。私はそれに加えて次のことを要求しました。いずれも実際に使用するのであれば、満たして欲しいことです。

値は使用時に評価する

文字列として表現している値を任意の型へ変換することは大きなコストです。パースしたJSONの一部を使うためにパース時に全ての要素について特定の型に変換することは無駄でしかありません。また、文字列値のSTRING型への変換はUTF-8のバイト列として危険ではないか、Unicodeエスケープシーケンスがあれば、それが妥当であるかの確認が必要になるかもしません。ASCII文字しか受け取らないことが確約されているJSONについて検査を行うことは無駄であるかもしれませんし、無条件にSTRING型へ変換することが危険である場合もあるかもしれません。

例えば、MQTTでサブスクライブして取得したメッセージのJSONについて、文字列である値を使用する場合、無条件にSTRING型に変換することは危険であると考えるのが妥当です。どのようなインフラも侵害される可能性があります。あるいは、メッセージの発行元にバグがあるかもしれません。無条件に値を受け入れるのであれば道連れです。ある値がASCIIだけで構成されるのであれば、そうであることを確認して使用します。そうでない値であれば、JSON自体を破棄して反応しないようにします。どの程度の検査が必要かはアプリケーション次第であるため、使用者が判断して実施する必要があります。少なくともパーサは使用者からの要求が無い限り値を評価することは避けます。パーサはJSONの構造と仕様を満たしているかだけに注目するようにします。

不必要にSTRING型に変換しない

PLCで文字列処理が主となるアプリケーションを作成する可能性はまずありません。JSONは文字列だからと文字列に変換して処理をすることは避けます。不必要にUTF-8のバイト列をSTRING型にすることは意図しないリスクを持ち込むことになります。不必要とは、JSONデータを使用する上でSTRING型ではなければできないこと以外でJSONデータをSTRING型にすることです。

Unicodeエスケープシーケンスは、バイト列としてみればASCIIです。ところが、その内容をSTRING型として使用するためにシーケンスの内容をバイト列としてSTRING型に変換すると同じリスクを持ち込むことになります。そもそも、PLCでの処理においてエスケープシーケンスで表現した値の内容について扱う必要性は無いか、あるとすれば特殊な要件です。

UTF-8の評価については、以下を参考にしてください。有限状態機械での表現まで提示されているので、それを実装すればよいです。高速化については、対象がPLCで努力が報われる可能性が小さいので程々がよいと思います。

https://zenn.dev/mod_poppo/articles/utf8-validation

タスクをハングさせない

ジェネレータ、パーサ共にタスクをハングさせることだけは避ける必要があります。多くの場合、タスクのハングは非常停止を引き起こす重大エラーです。JSONに係る処理の失敗による非常停止が許容される可能性はゼロです。処理的にジェネレータがタスクをハングさせる可能性は小さいですが、パーサは簡単に無限ループに陥ってタスクをハングさせる可能性があります。パーサの十分なテストは必須です。また、確実にハングさせないために内部の処理ループにタイムアウトによるサーキットブレーカを設ける必要があるかもしれません。タスクタイムオーバーならエラーで済みます。

JSONTestSuiteをパスする

自作したJSONパーサを使用するのであれば、少なくともJSONTestSuiteをパスするようにします。JSONTestSuiteは、JSONパーサを評価するための有益なテストセットを含んでいます。JSONパーサはABNFで記された規則からジェネレータでパーサを生成せずとも、フルスクラッチで構築できます。目の前にパースしたいJSONもあるでしょう。程なくしてパーサは完成し、パースしたかったJSONのパースに成功します。そのパーサをすぐに使うことは避け、JSONTestSuiteのJSONをパースした結果を見て使うかどうか判断します。厳しい結果になる可能性が高いと思います。"できる"と"使える"の間には大きな差があります。使えるものが必要ならひと踏ん張りです。

重要なことは、JSONを適切にパースできるかではなく、パーサがハングしないかです。JSONTestSuiteのパースの成否が違ったとしても、そのパーサを使用する環境では問題にならないかもしれません。そうであれば、無理に全てに対応させないという判断もあり得ます。しかし、パーサが特定のJSONでハングすることはリスクです。悪意のある第三者が任意のJSONを送り込めるようになった場合、パーサをハングさせることができるからです。あるいは、バグのあるJSON生成元が偶然にもパーサをハングさせるJSONを生成するかもしれません。

JSONの操作

パースしたJSONへの操作は、値の読み出しに限定しています。他の処理系のように値を追加したり、変更したりする操作はありません。そのような操作はJSONを扱うことではなく、汎用的な辞書型を作成することを意味します。

パス指定による要素操作

JSONに対してキーまたはインデックスで構成するパスを指定しての要素操作は、オーソドックスなアクセス手段です。

iJsonStr := '{"command":10, "payload":{"product_id":"xxxxx-xxx", "amount":500, "start_at":"2025-02-25T13:00:00+09:00"}}';
iJsonBinSize := StringToAry(In:=iJsonStr, AryOut:=iJsonBin[0]);
JSONContext_init(iJson);
// JSONParserは、1サイクルでパースを終わらせる。
// そのため、サイズの大きなJSONはタスク時間を圧迫する可能性がある。
iParser(Execute:=TRUE,
        Context:=iJson,
        Data:=iJsonBin,
        // Dataの開始位置を指定する。
        Head:=0,
        // Dataのサイズを指定する。
        Size:=iJsonBinSize);

IF JSON_TO_UINT(Context:=iJson,
                Key:='command',
                Value=>iCommand,
                Default:=1000)
THEN
    CASE iCommand OF
        // Schedule production.
        10:
            Clear(iProductId); Clear(iAmount); Clear(iStartAt);
            iOk := TRUE;
            iOk := iOk
                AND JSON_TO_STRING(Context:=iJson,
                                   Key:='payload.product_id',
                                   Value=>iProductId,
                                   Default:='');
            iOk := iOk
                AND JSON_TO_UINT(Context:=iJson,
                                 Key:='payload.amount',
                                 Value=>iAmount,
                                 Default:=0);
            iOk := iOk
                AND JSON_TO_DATE_AND_TIME(Context:=iJson,
                                          Key:='payload.start_at',
                                          Value=>iStartAt,
                                          Default:=DT#1970-01-01-00:00:00);    
            // Validate parameters.
            // ...
            // Do schedule procedure.
            // ...
    ELSE
        // Invalid command.
        iError := TRUE;
    END_CASE;
ELSE
    // Unknown json.
    iError := TRUE;
END_IF;

JSONのオブジェクトはキーの重複を許容します。そのようなオブジェクトを扱うとなると、パスで任意の要素を取得するFUNのシグネチャが面倒なことになります。しかし、そのようなJSONは稀であるため汎用用途のFUNはキーに重複が無いことを前提にしています。仮に重複があった場合、最初に見つかった値を返します。

キーが重複しているオブジェクトは、コレクション操作で扱います。コレクション操作として、オーソドックスな要素配列の取得とイテレータによる順次操作を用意しました。扱いやすさとリーソス効率のいずれを比較してもイテレータが有利です。

要素配列取得によるコレクション操作

オブジェクトや配列のようなコレクションの要素を操作するために、要素の配列を返すFUNを定義することは自然なことです。しかし、今のことろ使う場面を思いつきません。このFUNが返す要素の構造体は、値をSTRING型として保持するため、バイト列である値をSTRING型にするという副作用があります。

iJsonStr := '{"command":10, "payload":{"product_id":"xxxxx-xxx", "amount":500, "start_at":"2025-02-25T13:00:00+09:00"}}';
iJsonBinSize := StringToAry(In:=iJsonStr, AryOut:=iJsonBin[0]);
JSONContext_init(iJson);
iParser(Execute:=TRUE,
        Context:=iJson,
        Data:=iJsonBin,
        Head:=0,
        Size:=iJsonBinSize);

IF JSON_TO_UINT(Context:=iJson,
                Key:='command',
                Value=>iCommand,
                Default:=1000)
THEN
    CASE iCommand OF
        // Schedule production.
        10:
            IF JSONContext_getJSONElementChildrenByKey(
                   Context:=iJson,
                   Key:='payload',
                   Children:=iElements,
                   Num=>iElementNum)
            THEN
                Clear(iProductId); Clear(iAmount); Clear(iStartAt);
                CASE iElementNum OF
                    3:
                        iOk := TRUE;
                        FOR i := 0 TO iElementNum - 1 DO
                            IF iElements[i].Key = 'product_id' THEN
                                iProductId := iElements[i].Value;
                            ELSIF iElements[i].Key = 'amount' THEN
                                iAmount := STRING_TO_UINT(iElements[i].Value);
                            ELSIF iElements[i].Key = 'start_at' THEN
                                // DTのユーティリティーは未実装のため手抜き。
                                JSON_TO_DATE_AND_TIME(Context:=iJson,
                                                      Key:=iElements[i].Path,
                                                      Value=>iStartAt);
                            ELSE
                                iOk := FALSE;
                                EXIT;
                            END_IF;
                        END_FOR;
                ELSE
                    iOk := FALSE;
                END_CASE;
                // Validate parameters.
                // ...
                // Do schedule procedure.
                // ...
            ELSE
                // Invalid payload.
                iError := TRUE;
            END_IF;
    ELSE
        // Invalid command.
        iError := TRUE;
    END_CASE;
ELSE
    // Unknown json.
    iError := TRUE;
END_IF;

イテレータによるコレクション操作

イテレータは制約の強い環境で、リソース消費を抑えつつ処理を均すための良い手段です。扱う可能性のある最大の要素数に対応するための大きな配列が不要で、不要な要素は処理をスキップしてリソースを節約できます。また、呼び出し時に要素の取得を行うためサイクルに処理を分割しやすいのも利点です。このFUNも要素の構造体が、値をSTRING型として保持するため、バイト列である値をSTRING型にするという副作用があります。

iJsonStr := '{"command":10, "payload":{"product_id":"xxxxx-xxx", "amount":500, "start_at":"2025-02-25T13:00:00+09:00"}}';
iJsonBinSize := StringToAry(In:=iJsonStr, AryOut:=iJsonBin[0]);
JSONContext_init(iJson);
iParser(Execute:=TRUE,
        Context:=iJson,
        Data:=iJsonBin,
        Head:=0,
        Size:=iJsonBinSize);

IF JSON_TO_UINT(Context:=iJson,
                Key:='command',
                Value=>iCommand,
                Default:=1000)
THEN
    CASE iCommand OF
        // Schedule production.
        10:
            IF JSON_ITERATOR(Context:=iJson, Iterator:=iIterator, Key:='payload') THEN
                iOk := TRUE;
                Clear(iProductId); Clear(iAmount); Clear(iStartAt);
                WHILE JSON_ITERATOR_NEXT(Context:=iJson,
                                         Iterator:=iIterator,
                                         Element:=iElement)
                DO
                    IF iElement.Key = 'product_id' THEN
                        iProductId := iElement.Value;
                    ELSIF iElement.Key = 'amount' THEN
                        iAmount := STRING_TO_UINT(iElement.Value);
                    ELSIF iElement.Key = 'start_at' THEN
                        // DTのユーティリティーは未実装のため手抜き。
                        JSON_TO_DATE_AND_TIME(Context:=iJson,
                                              Key:=iElement.Path,
                                              Value=>iStartAt);
                    ELSE
                        iOk := FALSE;
                        EXIT;
                    END_IF;
                END_WHILE;
                // Validate parameters.
                // ...
                // Do schedule procedure.
                // ...
            ELSE
                // Invalid payload.
                iError := TRUE;
            END_IF;
    ELSE
        // Invalid command.
        iError := TRUE;
    END_CASE;
ELSE
    // Unknown json.
    iError := TRUE;
END_IF;

まとめ

JSONはMQTTを使用する場合にメッセージフォーマットとして採用することがあると思います。多くの処理系は、メッセージ交換であればとりあえずJSONという判断ができるほどに環境が整っているため、MQTTインフラ上のメッセージフォーマットとして採用し易いためです。PLCの処理系にも標準的にJSONを扱えるものはあります。NXはメーカーが提供するMQTTクライアントライブラリがあり、コントローラの処理能力としても十分であるためMQTTの使用を意図しているのですが、メッセージを扱うための環境が十分とは言えません。

NXの開発環境とコントローラの処理能力を考えれば、十分なJSONライブラリを作成できそうであることは想像できます。しかし、ユーザーで実装せよというのはなかなか酷な気もします。ただ、JSONライブラリがあったとしても、要求を満たせないのであれば自作せざるを得ないことに変わりありません。作ろうと思えば作れる環境であることは幸いでした。また、フルスクラッチであることで品質とライセンスを把握し易くなることも利点です。

JSONデータを操作することは、UTF-8エンコード、Unicodeエスケープシーケンスに係るリスクに曝されることになります。NXの処理系の文字列型がヌル終端であることも注意が必要です。これらは、JSONデータをバイト列として扱うか文字列として扱うかに依らないと考えることが妥当です。パーサについては、データをストリーム処理するのであれば、Walker型のパーサがあってもよいと思います。

Discussion