技術屋卵の足跡

いつか振り返ったときにどれだけ這いずり回ったかを確認したい

調布祭プラレールのバックエンドの話

UEC koken Advent Calendar 2020 22日目の記事です。

調布祭プラレール企画のブログリレーは以下の通り。

以下コンテンツ


※技術系の記事ではないです。感想文的な

11月末に行った調布祭2020について、みんなで手をつないでアウトプットしましょうねということになったので、今までに書いた記事とは全く毛並みが異なるがとりあえず書いてみた。

調布祭がどうだとかどうゆう企画だとかいう情報はおそらく他の人が書いてくれているので、ここではバックエンドがどうなっているかだけを残す。

作ったもの

工数を最小化するために、決まった仕様を最小限に抑えて実装した。 言語は持ち運びが簡単で、メンバー内部で普及していたGo言語で。

機能としては(非常にありきたりな)以下の感じなバックエンドAPIサーバーを作った。 ネストしたものは構成技術など。

やっていることは比較的多いが、内容は簡単なもの。

  • http側からの通信をsocket通信に送信
  • 操作権管理
  • 動的に生成されるAPI
    • オンメモリDB的なMap
  • Basic認証を用いた権限分離
  • 静的ファイルのサーブ(簡易的)
  • httpで遠隔ログ監視
  • configファイルで簡単管理
  • 同時操作数の表示
    • mutex

要求

要求されたネットワーク構成を非常に簡単に図式化すると以下の通り。

f:id:pfpfdev:20201221225902p:plain
構成

要はめちゃくちゃ強固なFirewallがあってそれを介してユーザーが機器に対して指示を出したいという非常にありきたりな構成。これをうまく扱うためには外部にサーバーを用意して、双方から接続しにいくようなリバースプロキシ的なものを作ればよさそう。

方針

f:id:pfpfdev:20201221230429p:plain
用意するもの

バイス側はESP32やラズパイなどの貧弱なデバイスなので、なるたけ負荷を小さくすべくTCPコネクションを確立してずっと通信する方式に、ユーザー側はUI側との連携からHTTPベースでの実装になることが決まった。

バイスなどが一切完成しないままこのプログラムの作成を始めた感じだったので、このプログラムでは細かいことを決めずに大筋だけ決めてあとはデバイスからの指示を基にAPIを動的に生成するのが楽そうだったので、それができるように実装した。

APIとしてのリファレンス的な

バイス側からSocketでつないで、APIを自動的に作成しそのAPIにHTTPでアクセスすることでデータを橋渡しする。

大まかな流れは、デバイス側がどのようなコマンドを受け付けるかなどを初期化フェーズとしてサーバーに通知し、サーバーはその定義情報を基にAPIを作成、Web側に公開し、操作を受けたら定義されたコマンドとしてデバイスに通知するという感じである。

用語は以下の通り

  • Device
    • ESP32, Raspiなどで動作する通信プログラムのこと
  • Operable
    • 操作可能オブジェクト
    • Operableごとにコマンドを定義する
  • コマンド
    • 操作の単位

例: 調布駅 = Device

一番線ホーム = Operable

停車/発信 = コマンド

各要素から見たプロトコルは以下の通り。ちょうど内部向けに出したREADMEがちょうどよさげだったのでほぼほぼコピペしてみた。使うわけではないので、雰囲気が伝わればいいかな程度に。

IoT機器側

規定のポート番号に対して(8081とか)、TCPコネクションを立てる。 後述するプロトコルにより、http側からの制御を受けることができる。

プロトコル

TCPコネクションを確立した機器は固有の名前持つ。

通信は\nをデリミタとする一行を単位とする。 通信の中では語をで区切るCSV方式で通信する。

実際の通信は以下のような流れで行う。

--- Initial phase

IoT > <NAME>\n
IoT > <CMD> ...<ARGS>\n
IoT > FIN\n

--- Normal phase

Server > <CMD> <ARG>\n
IoT > <RES>\n
...

Initial phaseで実行することができるのは、以下のコマンド

  • ADD [OPERABLE_NAME]
    • 制御対象の機器(operable)を追加する
  • REG [OPERABLE_NAME] [COMMAND_NAME] [ARG_TYPE]
    • 追加済みのoperableにたいして、コマンドを定義する
    • [ARG_TYPE]にはOnOff,Hundredの二種類があり、前者は引数としてOn|Off,[0-9]{2}の文字列が送信される

たとえば次のように送信することで、{LED,speed}と{power}を制御することができるIoT機器が接続されているとみなす。

Device1 
ADD ope1
REG ope1 LED OnOff    
REG ope1 speed Hundred
ADD ope2
REG ope2 power OnOff  
FIN

Normal phaseではIoT機器から情報を受け取ることはせず、http側からの要求に基づいて定義されたコマンドが呼び出される。

上の場合では

ope1 LED On
ope1 speed 50
ope2 Off

などが送信されるので、それをハンドリングしレスポンスとして1行を送信する。

Normal phaseでは一定時間(10秒)以上通信がないIoT機器は落ちたと判断してコネクションを切断する。 それを避けるためには定期的に(3秒程度)\nだけを送信する必要がある。

HTTP側

HTTPサーバーは規定のポート(8080)で実行される。 APIのエンドポイントは以下のようにマッピングされている。

GET /devices
    接続済みのIoT機器一覧を表示する。
    {
        "DeviceName":{
            "Name":"DeviceName",
            "Operables":{
                "OperableName":{
                    "Name":"OperableName",
                    "Operations":{
                        "CommandName":{
                            "Cmd":"CommandName",
                            "Type":"OnOff|Hundred"
                        },...
                    }
                },...
            }
        },...
    }
GET /device/{name}
    デバイスの詳細情報を表示する
    {
        "Name":"DeviceName",
        "Operables":{
            "OperableName":{
                "Name":"OperableName",
                "Operations":{
                    "CommandName":{
                        "Cmd":"CommandName",
                        "Type":"OnOff|Hundred"
                    },...
                }
            },...
        }
    }
GET /units
    ユニットの情報を一覧表示する
    {
        "UnitName":{
            "Name":"UnitName",
            "Operables":{
                "ope1":{
                    "Name":"ope1",
                    "Operations":{
                        "LED":{
                            "Cmd":"LED",
                            "Type":"OnOff"
                        },
                        "speed":{
                            "Cmd":"speed",
                            "Type":"Hundred"
                        }
                    }
                },
                "ope2":{
                    "Name":"ope2",
                    "Operations":{
                        "power":{
                            "Cmd":"power",
                            "Type":"OnOff"
                        }
                    }
                },...
            },
            "Queue":[
                {
                    "LastTime":"2020-11-13T21:33:16.9206603+09:00",//最終アクセス(有効化)時刻
                    "Until":"2020-11-13T21:34:28.7914384+09:00",//制御権の有効期限の目安(割り当てられてないならば意味なし)
                    "IsAlive":true
                },...
            ],
        },...
    }
POST /units
    ユニットの構成を設定する
    以下のフォーマットをBODYで送信する
    BASIC認証が必要
    {
        "UnitName":{
            "DeviceName of operables":["OperableName1","OperableName1"],
            "Other Device":["OperableName1","OperableName1"]
        }
    }
POST /units/{unitName}
    整理番号(制御に必要なトークン)の確保
    ここで確保したら/units/{unitName}で順番確認
    定期的にtokenの有効化を行う
    {
        "Token":randomuint64
    }
GET /units/{unitName}?token={token}
    tokenの更新を行う
    定期的にここにアクセスしTokenの有効化を行う(10~30秒に一度程度)
    返り値は現在の順番(1が先頭)
    {
        "Order":1
    }    
    トークンが指定されなければユニットに関する情報を取得する
    この場合の返り値は/unitsの限定的なものにつき省略
GET /units/{unitName}/{operableName}?cmd={cmdName}&arg={arg}
    操作を行う
    unitsのUserのIdと同じtokenの人が操作できる
GET /log?offset={offset}
    ログの取得
    Offsetを指定すると続きを読み取れる
    ラウンドロビンとか考えてないので定期的に再起動したほうがいいかもしれない{要検証}
    BASIC認証が必要
    5秒に一度くらい読み取って、logファイルへのアクセスログをフィルタリングすれば良い気が
    {
        "Log":"LogText\n",
        "Offset":123
    }

まとめ

今回のAPIはGo言語で作成したので、基本的に上のような仕様や使い方が決まれば、おおよその実装は自明となる。 特に技術的に難しいこともなく、引っ掛かりポイントすらまともにないような単純なプログラムなので、ソースコードを載せたり解説したりするのはしない。

ただこれだけでは味気ないので、気を使った点を最後にまとめておく。

データの管理方法

今回のシステムではデバイス側と常に通信をする必要があるので、デバイスのConnベースでデータの管理を行った。

Connが作成されたら、上のプロトコルで通信し適当に情報を付け加えたものをオンメモリのDBもどきとしてMapに保存し、通信が切れたらMapから削除するようにしている。

バイス側とはCSV的な文字列で通信し、ユーザー側とはjsonで通信するので、データ自体も構造体を丁寧に作成し、Marshal/Unmarshalなどの既存関数を活用できるような実装を意識した。

安定性

今回のプログラムは大学の内部にあるプログラムと通信するため、変なことをしてしまうと大学内部に攻撃されてしまう足掛かりになりかねなかった。そこでプログラム的に安全な書き方を常に意識した。

Go言語自体、危険なコードができにくいような体系ではあるが、go routineなどの資源のリークやバッファー周り、レースコンディションなど一般的に気を付けるべき点というのは一通り網羅した。

ログ

今回のプログラムは前提として、通信先すべてのプログラムがこのプログラムと通信することをデバッグする必要があったり、ほかにもどういう動作をしている人がいるのかといったことも、オンライン上で誰がどう弄っているかわからなかったりということもあった。

したがってログとしてほぼすべての情報を出力し、ログを見ることでどういう動作をしているかがよくわかるようにした。 またプログラムをGCP上に展開したので、他の人に運用を任せる際にGCPsshしてもらうのはうまくない。 そこでWeb側にもログの出力先を用意し、ポーリングでログが見られるようにAPIを用意した。

デバッグ時は非常に役立ったし、(容量/監視コストを無視するならば)ログは正義だなあと痛感した。 当日はGoogle Search Consoleとか同時操作人数とかの表示も併せて、リアルタイムでどこの県からどんな操作をしているのかが手に取るようにわかり、見ている分でも面白かったことも併記しておく。

コンフィグファイル

適当なコンソールのソフトを作った場合に、完成度として大きく評価される部分はコマンドライン引数まで丁寧に実装したかという点だと思う。今回のプログラムはほかの人が運用する際に設定としてまとめていた方が便利そうだったので、コンフィグファイルを用意した。

内容は以下のような感じ。

# デフォルトの設定
# 項目が指定されていなくてもハードコーティング済み

LogPath: "/tmp/iot_gateway.log"

HttpServer:
  Port: 8080
  BasicAuth:
    User: "someone"
    Password: "somepassword"
  StaticPath:
    # 静的ファイルのサーブ
    # フォルダ名をプレフィクスとしてファイルをサーブ
    - "/tmp" #この場合は http://server.address/tmp/file_in_the_dir.txt

SocketServer:
  Port: 8081

Strategy:
  # デバイスからの生存通知が途切れたと判断する時間
  DeviceCycle: 10 #sec
  # 制御権を与える時間
  ControlCycle: 90 #sec

上の設定は超危険な雰囲気でよろしくないが、yamlで書くだけでまっとうなソフトを書いている気がするので満足できる。 yamlを読むと単純に構造体として設定にアクセスできるので、非常に使い勝手がよい。

最後に

今までチームで開発する際には、時間がたっぷりある状態で計画を練りまくった状態で進めていたので、今回のような行き当たりばったりで実装する感じはやはりやりにくいなあとは感じてしまう。結局実装した機能のうち半分くらいは使えないまま終わってしまったり...

プログラム自体は適当に抽象化したので、どこかで機会があったら使えるといいなとも思う。


一緒に作成してくれた皆さんはお疲れ様でした。機会があればまたやりましょう。 当日見てくれた人はありがとうございました。

アドベントカレンダーはあともうちょっと続きます。 明日はRaspiのプログラムとか、全く想定していなかった実装で現れたESPのプログラムとこのAPIサーバーの中間プログラムをマッハで完成させてくれたgotti君の出番です。