こちらでは、PicoGWのプラグイン開発の概要を述べます。
概要
PicoGW本体(以後コアシステムと呼ぶ)の機能はAPIサーバーとプラグインの発見・管理のみに限られていて、それ以外の機能はフロントエンドやネットワーク機能も含め、すべてプラグインによって実装されています。このためにコアシステムのソースコードは、ライブラリを除外すると0.5MBに満たない程度です。また、デフォルトのプラグイン構成での実行時も、安定状態のメモリ使用量は40MBをわずかに上回る程度しかありません。これがPicoを標榜する理由でもあります。
いずれにせよ、PicoGWの機能のほとんどはプラグインで実装されているので、プラグインの実装を理解することで、PicoGWの機能を拡張したり、改変することができるようになります。
本ドキュメントに関する質問・要望等は、node-picogwのissuesにて受けつけますので、ぜひ必要に応じて投げていただければ幸いです。
なお、早く実装例が見たいという方は、以下の実動するプラグインは実装がシンプルなのでご覧ください。
localStorageを用いたデータベースプラグイン / server role (roleは後述します)
https://github.com/KAIT-HEMS/node-picogw-plugin-db
名前付きパイプ / client role
https://github.com/KAIT-HEMS/node-picogw-plugin-namedpipe
プラグインの取り込み
PicoGWのプラグインはグローバルインストールされた通常のnpmパッケージとして提供されます。コアシステムの実行開始時にグローバルインストール済みのパッケージをスキャンし、その名前が picogw-plugin- からはじまっているものがPicoGW用プラグインと認識され、とりこまれます。
PicoGWをインストールするといくつかのプラグインが同時にインストールされますが、APIサーバとして動作させるために本当に絶対に必要なのはadminプラグインだけです(他所ではwebやdb,macroもmandatoryだと書いていますが、それはフロントエンドや、より上位レイヤーのサービスを用いる時に追加でプラグインをインストールするのが不便だから必須扱いにしたほうが利便性が高いと考えたからでして、実は正確ではありません)。そのため、adminプラグインだけは特別扱いで最初に初期化されて、その後に他のプラグインを初期化するようになっています。
role
プラグインにはその種別を表す role という属性値があり、プラグイン内に含まれるpackage.json内のpicogwキーの下で宣言します。roleはserver,client, httpから最低一つが用いられます。例えば、echonet pluginのroleは server 、macro pluginはserver,clientで、web pluginはhttpです。
- server属性とは最も通常のプラグインの機能、つまり何らかの通信プロトコルを実装して、WebAPIからアクセスできる機能を実装することを示しています。
- client属性とは、他のプラグインのAPIを内部的に呼び出すプラグインであることを示します。例えばmacroプラグインでは自動的に他のAPIのポーリングをして、家全体の電源状態を把握する機能を持っているため、この属性値を持っています。
- http属性とはフロントエンドを作るプラグインで、このプラグインからは設定画面を表示する機能をフックし、独自のUIを実装できるようになっています。システム内に一つしか存在できません
この種別を記述しておくことで、プラグインから利用できるPicoGWコアシステムの機能が限定されます。なお、種別は複数持つことができますが、server属性を持たないプラグインは自身のAPIをホストしないことになるので、フロントエンド内の階層構造には出現しません。設定画面中にwebプラグインが表示されていないのはそのためです。
プラグインの基本的な構造
プラグインは、オブジェクトを一つmodule.exportsする必要があります。
module.exports = {
init: function(pluginInterface){
},
};
このオブジェクトは初期化コールバック関数を示すinitキーを必ず含みます。
さらに、server roleを持つ場合はonCallコールバックを加えます。
module.exports = {
init: function(pluginInterface){
},
onCall: function(method, path, args){
}
};
initコールバック
initはプラグイン初期化時に一度だけ呼ばれます。初期化に時間がかかりそうなときはPromiseを返したほうがよいです。
引数はpluginInterfaceというオブジェクトで、roleに指定した機能をメソッドとして呼び出せるようになっています。
pluginInterfaceはinit()終了後も保管しておき、必要な時に利用します。
- プラグイン内でのエラーメッセージ表示には pluginInterface.log を用います。
- プラグイン内でのlocalStorage利用には、pluginInterface.localStorageを用います。デフォルトでは~/.picogw/storage以下にプラグインごとに分類されて保存されます。このように書き込みディレクトリを一カ所にまとめることで、overlayfsなどでの運用が容易になります。
pluginInterfaceには様々なメソッドが含まれています。一覧を以下に示します。別タブで開く場合はこちらをどうぞ。。
なお、 _ からはじまる名前のメンバーを見つけるかもしれませんが、直接アクセスしないでください。
picogw起動時に、特定のプラグインだけに向けた引数を付け加えることができます。以下の形式になります。
picogw --plugin-args-[plugin名] A=B
引数に指定したkey-valueは、init()の引数のpluginInterfaceの.cmd_optsのメンバーとしてアクセスできます。
必ずA=Bというふうに、イコールで区切られたkey-valueにしてください。複数指定したい場合は、引数から複数回書いてください。
picogw --plugin-args-[plugin名] A=B --plugin-args-[plugin名] C=D
onCallコールバック
onCallは、WebAPIクライアントからRESTに対応するCRUD系メソッドがコールされた時に呼ばれるコールバックです。引数はひとつめがmethod (‘GET’,’PUT’,’POST’,’DELETE’のどれか)で、2つめがpath(/v1/プラグイン名/を除いたリソース文字列)、3つめがargsです。たとえばHTTP GETで /v1/plugin1/a/b?p=q へのアクセスがあったとすると、plugin1プラグインのonCallの引数としては ‘GET’,’a/b’,{p:’q’} が渡ってきます。
このようにargsは、GETの場合はGET引数をJSONオブジェクトに展開したものとなり、それ以外の場合はBody内(JSON形式を想定)をJSONオブジェクトに変換したものが渡ってきます。
リクエストに応じた返答を作ってreturnするか、時間がかかる場合はPromiseを返し、返答ができるタイミングでresolveしてください。
Publishについて
PicoGWにはPubSubの機能があります。PubSubは、serverプラグインで実装できます。プラグイン側からすべきことは、publishしたいタイミングで:
pluginInterface.server.publish(path, args);
を呼び出すだけです。pathには、/v1/プラグイン名/ を除いたパスを記述します。argsはクライアントに通知するJSONオブジェクトです。
メッセージのルーティングはコアシステムが行います。
APIペイロードの制約
APIの中身を流れるデータ、つまりペイロードの本体はプラグインによって実装されているため自由度は高いですが、コアシステムとの連携上の制約・API階層をウォークスルーするための制約・PicoGW開発コンセプト上の理由などから本文書で述べる最低限のルールに従ってください。
- リソース(パス名)に使える文字は 英小文字、 数字、 -_*+.() とします。
大文字を使わないのはAPI使用者の混乱を避けるためです。例えばエアコンのことをAirConditionerと書くのか、airConditionerか、またはairconditionerと書くのかという問題が生じないようにするためです。全部小文字にすると視認性が悪くなりますが、想起しやすく間違えにくいという利点があり、PicoGWではこの利点の方を重視することとしました。ただし、プラグイン側で大文字指定をはじくべきだというわけではありません。例えばAirConditionerと指定された時、これを小文字に自動変換して受け付けるか、エラーを返すかという判断はプラグインに任されています。
- リソース末尾のスラッシュの有無によって機能を切り替えてはいけません。
これもAPI使用者の混乱を避けるためで、/v1/plugin1/a/ と /v1/plugin1/a は同じ意味に解釈しなければならないというルールです。
- APIの返答は常にJSONオブジェクトであり、HTTP RESTでアクセスする場合のステータスコードは200にします。
PicoGWにはHTTP REST APIとWebSocket APIがあり、プラグイン実装時にはこれらを区別しません。エラーが起こった時に、その内容はHTTPのステータスコードで表しきれないことが多いため、常に返答はJSON Objectとし、その内容を見て判断することとします。そもそも返すべきステータスコードをプラグイン側から指定する機能はありません。
なお、エラー時の返答オブジェクトの形式は:
{
"errors": [ {"error":"Error1 msg"} ]
}
のように、エラーオブジェクトの配列とします。各エラーオブジェクトのerrorキーは必須で、その値は文字列とします。
- 引数に info=’true’ (trueは真理値ではなく文字列)が含まれる GETアクセス への正常時返答はAPI階層をウォークスルーするための情報です。
API階層をウォークスルーするための情報とは:
① その階層がleaf(リソースの末端)であるか否かどうかということ
② leafでない場合は、次の階層のリソース名一覧
③ 自然言語によるリソースの説明文
それに加え、設定情報をGUIから入力できるようにしたり、テスト機能を実現するためのメンバーがあります。
④ settings
⑤ settings_schema
⑥ test
です。
①と③は _info というキー内に格納されます。 _info は以下の形式をとります。
"_info": {
"leaf": true|false, // ①
"doc":{ // ③
"short": "One-line description",
"long": "Long description"
}
}
これらすべて省略可です。もし_infoが含まれない場合、このノードはleafで、説明文は存在しないと解釈されます。_infoがあってその中にleafキーがない場合もleafであると解釈されます。docも省略可です。つまり、GET引数に info:’true’ があっても返答に変化がないとすれば、それはリーフノードであると解釈されます
②は、例えば /v1/plugin1/a/a , /v1/plugin1/a/b , /v1/plugin1/a/c という3つのリソースが存在する時、 /v1/plugin1/a への、引数に info=’true’ を与えられた GETアクセス への返答は、その下位に3つのリソースがあるということを示すためにキー a,b,c を含まなければなりません。そして、返答のルート、および下位リソース情報内の両方に、前項で述べた _info のキーがあるものと期待されます。例えば、このようになります。
{
"a": {"_info":{"leaf":true}},
"b": {}, // _infoを省略すると、leafであると解釈される
"c": {
"_info":{
"leaf":false
,"doc":{"short":"Resource c is not a leaf."}
}
},
"_info":{ "leaf":false }
}
なお、上記の例からもわかると思いますが、下層リソースのオブジェクトがリーフでない場合、そのさらに次の層のリソース一覧を含む必要はありません。
⑥ testはPicoGWのGUIから、定型パラメータのAPIテストを行うための引数指定用フィールドです。開発中のα版の機能で、色々と怪しい動作をします。この機能は、GUI上のノード(リーフでないところ)で右クリックすることで出現する「Test API」メニューを選択することで実行できます。
例えば、あるリーフノードへ、’_doc’という引数を与えたときの返答に、testフィールドが以下のように含まれているとします。
...,
leaf: true,
test: [
{
method:'POST',
args:{
version: 0,
parameter: 'Hey'
},
wait:500,
}
]
...,
すると、テスト機能発動時には、POSTメソッドで、引数には
{
version: 0,
parameter: 'Hey'
}
というオブジェクトが渡りつつ、APIが呼び出されます。
testは配列として与えられることからも分かるように、複数のテスト呼び出しを次々と行うことが可能です。この呼び出しの間の時間間隔を指定するのがwaitパラメータです。この場合は、次の呼び出しまで500ms待つことになります。
testの指定の、おかしな点(ToDo)
+ このtestパラメータ指定は、API単位で行われるにもかかわらず、GUI上はそのAPIノードでのTest API呼び出しでは発動せず、その親ノードへのTest API呼び出しにより設定されます。これはそういうふうに作っていなかったため(あるノード内に含まれているAPIを全部テストする、という目的で作り始めたもののため)ですが、直感に反するような気もするので、そのうちリーフノードへのTest API呼び出しでも起動するよう機能追加したいと思います。
+ GETメソッドとして指定した場合、実際に呼び出されるAPIの引数はここで指定したものそのものではなく、{value: {…} }のように、valueでwrapされたものになってしまいます。これは単に$.ajaxで呼び出しているだけなのですが、なぜvalueが付与されてしまうのか、原因不明です。
プラグイン運用ルール
PicoGWで重要視していることは、常に上位互換性を保つということです。なぜなら、PicoGWのように建物や物体に紐付いたようなシステムは長期運用が想定されますので、某インターネット企業のように数年でAPIが使えなくなったりすると大きな問題が生じます。テクノロジーの進歩速度は増す一方ですが、物質世界の変化はそんなに早くないのです。せっかく予算を投入してビル管理システムを開発し、いざこの建物を50年使おうと思っても、そこで用いるソフトウェアが5年で使えなくなるようでは困るでしょう。従って、プラグイン開発者は互換性がなくなるような変更は避けてください。
具体的には、ひとたびnpmにプラグインをリリースしたら、以下の変更は禁止とします。
- 正常返答に含む情報を減らす、または改変すること
例えば、あるリソースへのアクセス時、最初はタイムスタンプが入っていたがどこかでタイムスタンプを含まないようにしたり、タイムスタンプのフォーマットを変更することは避けてください。
- 正常返答できていたリクエストがエラーを返すようになること
例えば、当初airConditionerとしてアクセスできていたものが、小文字のみ受け付けるようになってエラーが返るような変更は許されません。
逆に、次の変更は許されます。
- 返答に含む情報を増やすこと
例えば返答に含まれていなかったキーを増やすことは、上位互換ですので問題ありません。
- エラーが返っていたものが正常処理するようになること
例えば、/v1/plugin_1に対するアクセスを、一旦エラーにしておいて公開を先行させ、実装が進んだら正常な返答を返すような運用は可能です。
もしどうしても上位互換性を失うような変更をしたい場合は、新しいプラグイン名を作り、そのAPIとして新しいペイロードを返すようにしてください。このときも、旧プラグインは残し、非公開にすることは避けてください。
この方針は、npmパッケージとして登録する時に守っていただきたいものです。従って、APIを吟味しつつプラグイン開発をしている最中はnpmパッケージとして登録・公開すべきではありません。幸いnpmにはtarballやローカルディレクトリなどからパッケージをグローバルインストールすることもできるので、APIが落ち着いた時点でnpmにアップすればよいわけです。もし開発中にnpm上での名前を取られてしまう可能性を気にする場合は、エラーだけ返すダミープラグインを、希望する名前でnpmに先行して登録しておくとよいです。
その他Tips
- process.exit(99) で終了
PicoGWの本体はforeverというデーモン化パッケージの中で走らせているので、普通に終了しても自動的に再起動されます。しかし、終了コード99で終了した時に限り、自動再起動なくそのまま終了します。