🧅

NXからSlackにメッセージを投稿する

に公開

はじめに

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

今回は、NXからSlackにメッセージを投稿します。Slackへのメッセージ投稿はWeb APIを叩くだけです。Slackへのメッセージ投稿機能(SlackPostサービス)は、要不要が明確なので簡素化して既存プログラムに組み込みやすくしてみました。また、Slackに限らず外部サービスの使用には何らかのシークレット情報を扱うことになります。コントローラにシークレット情報をどのように配置するのかを検討してみました。

SlackPostサービスでは、以下のようなFUNを実行してメッセージを投稿します。

SlackPostAlarmMessage(Channel:=SLACK_CHANNEL,
                      When:=When,
                      Where:=Where,
                      What:=What,
                      Which:=Which,
                      Handling:=Handling);

LDでは以下のようになります。

LDでのSlackPostAlarmMessage FUNの使用例
LDでのSlackPostAlarmMessage FUNの使用例

上記のFUNはいずれもメッセージ投稿要求を行うだけです。別途メッセージの投稿処理を行うPOUを実行しておく必要があります。以下のようにメッセージの投稿処理を行うPOUを含むプログラムを適当なタスクで実行します。また、そのプログラムはシークレット情報を含むため他のPOUとは異なるパスワードで保護します。

POU/プログラム/SlackPostServiceRunner
CASE iState OF
    // STATE_INIT
    0:
        ConfigureSlackPostService(
            // HTTP クライアント用
            // コントローラへのTLSセッションの登録が必要
            TLSSessionName:='TLSSession0',
            // 排他制御キー
            LockKey:=9,
            // Slackへの最小投稿間隔
            MinPostInterval:=TIME#3s,
            // Slackボットのトークン
            Token:='YOUR_SLACK_BOT_TOKEN',
            // エラーの自動クリア
            AutoClearError:=TRUE,
            // ログ出力の使用
            UseLog:=TRUE,
            // ログのパス
            LogUrl:='file:///slackpost.log');
        
        IF iReload THEN
            ReloadSlackPostService();
            iReload := FALSE;
            
            iState := STATE_ACTIVE;
        ELSE
            Inc(iState);
        END_IF;
    1:
        InitSlackPostService();
        EnableSlackPostService();
        iService.Enable := TRUE;
    
        iState := STATE_ACTIVE;
    
    // STATE_ACTIVE
    10:
        // For manual control.
        IF iDisable THEN
            DisableSlackPostService();
            iDisable := FALSE;
        ELSIF iEnable THEN
            EnableSlackPostService();
            iEnable := FALSE;
        ELSIF iReload THEN
            iState := STATE_INIT;
        ELSIF iClearError THEN
            ClearSlackPostServiceError();
            iClearError := FALSE;
        END_IF;        
END_CASE;

iService();

サンプルプロジェクトを編集してコントローラで実行すると、Slackのチャンネルに以下のようなメッセージが流れてきます。Slackのインタラクティブな要素を活かして付帯的な作業をその場で済ませることもできます。

投稿したメッセージのSlackチャンネルでの表示例
投稿したメッセージのSlackチャンネルでの表示例

保守を考えるとSlackPostサービスを使用した機能の作りこみは程々にするのがよいです。ワークフローを本格的に運用する機運が出てきたら、M2M向けのメッセージ基盤を整備し、Slackとの連携機能を外部で構築して拡張に備えます。SlackはM2M向けのメッセージ基盤ではありません。コントローラでSlackの投稿を取得して何かすることもできますが、どこかで無理が出てきます。

Slackへのメッセージ投稿で何ができるか

Slackにメッセージを投稿できることで何ができるかは、ユーザー次第です。Slackのワークフローも活かしながら、障害対応と障害時の情報収集に使用してもよいかもしれません。コントローラからチャンネルに流したメッセージをNotionに流してレポートを作成してもよいかもしれません。あるいは、Kintoneに流してそのまま業務システムに流してもよいかもしれません。いずれにしてもユーザーのアイデア次第です。何も無いと言われてしまうと、通知が手元のデバイスに届きますとしか言えません。

Sysmacプロジェクト

以下に、ライブラリ、ライブラリ開発用プロジェクト、サンプルプロジェクトがあります。

https://github.com/kmu2030/ExperimentalSlackPostService

サンプルプロジェクトを動かす場合は気を付けてください。プログラムを停止しないと延々とメッセージを投稿し続けます。

サンプルプロジェクトの使い方

サンプルプロジェクト(SlackPostServiceExample.smc2)の実行には、Slack、サンプルプロジェクト、コントローラのそれぞれについて作業が必要です。Slackのアカウントは作成済みとします。Slackについては、今回の内容であればGeminiに問い合わせても問題ありません。

サンプルプロジェクトの使用には、最終的に以下の情報が必要です。

  • SlackボットのBot User OAuth Token
  • メッセージを投稿するSlackチャンネルのID
  • 使用コントローラの型式
  • コントローラをインターネット接続するための設定
  • コントローラのセキュアソケット設定のセッションNo

作業は以下の手順で行います。

  1. Slackボット(App)を作成してSlackワークスペースにインストール
  2. メッセージを投稿するSlackチャンネルにAppを追加
  3. サンプルプロジェクトを使用環境に合わせる
  4. サンプルプロジェクトのSlackPostサービスの設定を合わせる
  5. サンプルプロジェクトのデモプログラムのSlackチャンネルを合わせる
  6. コントローラのセキュアソケット設定にセッション登録
  7. コントローラにサンプルプロジェクトを転送
  8. Slackチャンネルにメッセージ投稿があることを確認

以下を参考として挙げます。

1. Slackボット(App)を作成してSlackワークスペースにインストール

以下のApp Manifestが示すAppをSlackのワークスペースにインストールします。ワークスペースにインストールしたら、Bot User OAuth Tokenを控えておきます。

AppManifest
display_information:
  name: NXNotifier
features:
  bot_user:
    display_name: NXNotifier
    always_online: false
oauth_config:
  scopes:
    bot:
      - calls:write
settings:
  org_deploy_enabled: false
  socket_mode_enabled: false
  token_rotation_enabled: false
2. メッセージを投稿するSlackチャンネルにAppを追加

メッセージを投稿するチャンネルにAppを追加します。Appを追加したチャンネルのIDを控えておきます。

3. サンプルプロジェクトを使用環境に合わせる

サンプルプロジェクトを使用環境に合わせて変更します。以下の変更が必要です。

  • コントローラの型式
    使用するコントローラの型式に変更します。
  • コントローラのネットワーク設定
    使用環境でインターネット接続可能な設定とします。DNSは特別な理由が無ければ、"1.1.1.1"のようなパブリックDNSを使用します。
4. サンプルプロジェクトのSlackPostサービスの設定を合わせる

POU/プログラム/SlackPostServiceRunnerを編集します。ConfigureSlackPostServceの引数を使用環境に合わせます。YOUR_SLACK_BOT_TOKENを1で控えたBot User OAuth Tokenで置き換えます。セキュアソケット設定のセッションNoをゼロ以外とする場合、TLSSession0の"0"を指定予定のNoに置き換えます。コントローラにSDカードを装着していない場合、UseLog:=TRUEを"UseLog:=FALSE"とします。できる限りコントローラにSDカードを装着してログ出力を行えるようにします。

POU/プログラム/SlackPostServiceRunner
CASE iState OF
    // STATE_INIT
    0:
        ConfigureSlackPostService(
            // HTTP クライアント用
            // コントローラへのTLSセッションの登録が必要
            TLSSessionName:='TLSSession0',
            // 排他制御キー
            LockKey:=9,
            // Slackへの最小投稿間隔
            MinPostInterval:=TIME#3s,
            // Slackボットのトークン
            Token:='YOUR_SLACK_BOT_TOKEN',
            // エラーの自動クリア
            AutoClearError:=TRUE,
            // ログ出力の使用
            UseLog:=TRUE,
            // ログのパス
            LogUrl:='file:///slackpost.log');
5. サンプルプロジェクトのデモプログラムのSlackチャンネルを合わせる

POU/プログラム/AutoPostDemoを編集します。定数を使用環境に合わせます。YOUR_SLACK_CHANNEL_IDを2で控えたSlackチャンネルIDで置き換えます。

POU/プログラム/AutoPostDemo
CASE iState OF
	0:
		SLACK_CHANNEL_ID := 'YOUR_SLACK_CHANNEL_ID';	
		iRand.Seed := 0;
		iRand.Execute := TRUE;
		
		iState := 5;
6. コントローラのセキュアソケット設定にセッション登録

コントローラに接続、プログラムモードに変更してセキュアソケット設定に4で指定したNoと同じNoのセッションを登録します。以下のように作業します。

TLSセッションの設定
TLSセッションの設定

7. コントローラにサンプルプロジェクトを転送

コントローラにサンプルプロジェクトを転送し、運転モードに切り替えます。ネットワークエラーが発生していないか確認します。ネットワークエラーが発生している場合、エラー原因を取り除きます。

エラーが発生した場合、以下の可能性があります。

  • TLSセッションIDとTLSセッション名の不一致  
       4で指定したTLSセッション名の番号と6で指定したTLSセッションIDが一致していることを確認します。
  • Slackで障害が発生している  
       SlackのステータスAPIで状況を確認します。
  • インターネットに接続できないか、名前解決ができない
       Slackドメインまでのルートをtracertで確認します。
8. Slackチャンネルにメッセージ投稿があることを確認

正常に立ち上がっている場合、以下のメッセージがチャンネルに投稿されます。

装置の起動メッセージ
装置の起動メッセージ

デモプログラムのメッセージに含まれるタイムスタンプは、POU/ファンクション/GetTimezoneの影響を受けます。使用環境のタイムゾーンに合わせてPOUを編集します。

メッセージ投稿が無い場合、以下の可能性があります。

  • ボットトークン、チャンネルの指定に誤りがある
    SlackとSysmacプロジェクトの設定が一致しているか確認します。半角スペースなどが混入していないかも確認します。
  • チャンネルにAppが追加されていない
    Slackのチャンネル詳細を確認し、Appが追加されていることを確認します。
  • Slackへのアクセスに失敗している
    7を参考にネットワーク接続を再確認します。

ログ出力を使用している場合、ログを確認します。ログのresponseプロパティが空欄である場合、Slackへのアクセスに失敗しています。空欄でない場合、レスポンスの内容に従って対応します。

SlackPostサービスの構成と機能

SlackPostサービスは、Slackへのメッセージ投稿という単機能に集中し、扱いやすさに重点を置きます。複数のSlackPostサービスを同時に使用する必要性はないので、ユーザー向けにはシングルトンにして扱いやすくしました。ユーザーには、SlackPostサービスを実行するためのFBと、メッセージ投稿FUN、少数のサービス制御FUNを提供することにしました。結局のところ、Slackにメッセージを投稿することが最も価値のある機能であると判断してのことです。SlackPostサービスは、以下の要求を満たしてSlackへのメッセージ投稿機能を実現するものとしました。

  1. 障害の影響を受けない
  2. 使用/不使用を任意に切り替えられる
  3. 設定と構成のホットリロードができる
  4. LDのプログラムに組み込める
1. 障害の影響を受けない

障害とは、コントローラのLANケーブル抜けやトラフィックを捌くローカルスイッチの不具合からDNSサーバやSlackサービスのダウンを含めた広範な障害のことです。これらの障害が生じたとしても機能を損なわなないことが必要です。当然ですが、障害が解消するまでメッセージの投稿はできません。また、内部バッファーに余裕が無くなればそれ以後のメッセージは破棄されます。要は、機能できないときは問題を起こさずじっとしていて、機能できるようになったら何もせずとも再度機能するということです。

2. 使用/不使用を任意に切り替えられる

装置の運転状態とは無関係に機能の使用/不使用を簡単に切り替えられることは必要です。容易に運転を停止できる装置ばかりではありません。また、万が一の障害時の一次対応はコントローラを操作可能なエンジニアではなく、納品先のオペレータか納品元の営業員である可能性が高く、冗談抜きに物理ボタンを押すだけで使用/不使用を切り替えられる単純さとその実装が必要です。システム的な対応で済むものであっても、物理的なオペレーションとフィードバックが必要になることもあります。

3. 設定と構成のホットリロードができる

ここでのホットリロードとは、装置を止めることなく設定を変更して反映できるということです。2と同様、装置の運転を停止しなくても設定を変更して反映できなければ困ります。また、単に設定を変更できるだけではなく、サービスの処理に影響を与えることなく設定を反映することが必要です。

4. LDに組み込める

LDに組み込むかは別にして、LDに組み込める程度に単純化することは必要です。現実的にLDのコードは多数存在し、そのコンテクストにおいてソフトウェア開発が為されているのが大多数です。また、単純さは機能の豊富さよりも重要です。

シークレット情報をどこに置くか

シークレット情報は、プログラムの他のPOUと異なるパスワードで保護した専用POUのコードとして配置するのが現実的です。専用POUは、シークレット情報を返すFUNか、サービスのファンクションブロックを実行するPOUとします。コードに配置するというのはアンチパターンに思えるかもしれませんが、保護される領域が限られているためこのような判断になります。

IDEでコントローラに接続すると、変数の値をモニタすることができます。そのため、変数が保持するシークレット情報もモニタすることができます。これは、どれだけ対策をしても避けられません。最終的にデータを送出するファンクションブロックの引数であるバッファー変数を参照できるからです。つまり、コントローラに接続してモニタすることが可能な主体に対して、シークレット情報を隠蔽することは本質的に不可能です。

IDEでコントローラに接続するという行為は、例えコントローラの設定でプログラム変更を禁止していたとしてもリスクのある、通常必要としない行為です。そのような行為を行えている時点で、既に相当程度の権限があると考えられます。そのため、その権限がもつリスクよりコントローラに配置するシークレット情報のもつリスクが小さいのであれば、コントローラにシークレット情報を配置することについて一応の説明はつきます。最終的にはメリットとデメリットのトレードオフによる判断です。

シークレット情報の配置場所は以下があります。

  1. SDカード内のファイル
  2. 保持変数もしくは定数
  3. 制御プログラムの一部としてPOUに記述
  4. 専用POUのコードに記述
  5. OPC UAで外部から注入
  6. 外部の管理サービス
1. SDカード内のファイル

利便性が高く運用自動化の余地もあるのですが、ファイルを暗号化する手段がありません。FTPが有効だと公開状態になります。例えFTPのアクセス制限を行っていたとしてもそこが侵害されたら芋づる式に侵害されることになります。また、FTPを使用しているということは、コントローラの制御プログラム含めSDカード操作を行うということです。そうなると誤操作でファイルが削除されてしまう可能性もあります。仮にこれらのリスクを前提とし、それを軽減する仕組みを構築して利便性を選択するとします。そのような対応ができるのであれば、適切なメッセージ基盤を運用し、必要とする外部サービスとの連携を行った方がよいという判断になる可能性が高いと思います。

2. 保持変数もしくは定数

保持変数は、コントローラ操作で消去される可能性があります。さらに、保持変数はコントローラからのバックアップ取得が可能であり、内容は平文です。そのため、コントローラの操作権限設定は必須です。定数は、コントローラへのプログラム転送時に書き込みを行うため、運転中の変更ができません。ホットリロードが不要であれば定数での運用も可能ですが、情報を保護するために結局は専用POU内に配置することになります。

3. 制御プログラムの一部としてPOUに記述

これは、制御プログラムがどのような権限で管理されているかによります。制御プログラムの参照/変更とシークレット情報取り扱いについての権限が同一であるならば問題ありませんが、そうでないならば分ける必要があります。

4. 専用POUのコードに記述

専用POU内のコードにシークレット情報を記述すれば、オンラインエディットが可能であり、アクセスも制御プログラムと分離できます。そもそも、装置のコントローラにアクセスして操作できる時点で相当程度の権限があることになるので、シークレット情報が参照できることはあまり問題にならないかもしれません。今回のSlackボットは、必要最低限のスコープしか付与せず、影響も特定のSlackワークスペースに限られます。装置の制御プログラムへのアクセスと比べるとリスクが小さい可能性が高いです。

注意が必要なのは、コントローラから取得するプログラムのバックアップにシークレット情報が含まれることです。これが問題になるのであれば、消去の可能性を前提に保持変数を使用します。しかし、コントローラからは保持変数の取得も可能なので問題が無くなるわけではありません。何の保護もないのでむしろ問題が深刻になる可能性があります。

5. OPC UAで外部から注入

OPC UAといっても、いわゆる公開変数は使用しません。OPC UA Methodを使用します。NX/NJは、PackML仕様に対応するためにOPC UA Methodをサポートしています。OPC UA Methodはアクセスに関する身元確認を伴ったRPCと考えることができます。任意のファンクションブロックインスタンスを登録できるので、保持変数を内部に持ったファンクションブロックを登録し、OPC UA Methodとしてコールすることで、シークレット情報を書き込むことができます。保持変数同様に消去される可能性はあります。あるいは、保持変数とせず起動する度に書き込んでコントローラに情報を残さないという運用も考えられます。

今回の用途に適任なのですが、OPC UA Methodで書き込むためのツールが必要、かつ、セキュリティを含めて適切な運用がなされることが前提になります。そこまでの対応ができるのであれば、メッセージ基盤を運用するほうが負担も小さく拡張性も含め妥当だと判断する可能性が高いと思います。OPC UA Methodは、PackMLに限らず応用の可能性があります。

6. 外部の管理サービス

シークレット情報を外部の管理サービスで管理し、かつ、それをコントローラから取得できるような環境を構築できるのであれば、メッセージ基盤と外部サービス連携のためのアプリケーションの構築と運用は問題なく行えます。コントローラから直接外部サービスにアクセスする必要性はありません。

実装

SlackPostサービスの実装は、ライブラリ開発用のSysmacプロジェクトで確認できます。実装は2つのFBと、多数のヘルパーFUNで構成します。メッセージ投稿要求のキューはメッセージ投稿を処理するPOUのコンテクスト変数に内蔵し、同POUがキューを管理するようにしました。これは、ボットによる投稿は概ねチャンネルあたり1秒に1回までで処理に余裕があること、多重化するのであれば、その対象はメッセージ投稿を処理するPOUが呼び出しているHTTPクライアントであることによります。

SlackPostMessageService

SlackPostサービスで実際にメッセージ投稿を処理するのは、SlackPostMessageService FBです。メッセージ投稿の処理はメッセージ投稿要求があるときに、Slack APIを叩くだけです。Slack APIはRESTfulではなく変則的なのが気がかりです。

POU/ファンクションブロック/SlackPostMessageService
Done := FALSE;
IF Enable AND NOT Busy THEN
    Busy := TRUE;
    
    iState := STATE_INIT;
END_IF;

CASE iState OF
    // STATE_INIT
    0:
        Clear(iError);
        Clear(iErrorID);
        Clear(iErrorIDEx);
        Context.MinPostInterval := Settings.MinPostInterval;
        Context.LockKey := Settings.LockKey;
        Context.TLSSessionName := Settings.TLSSessionName;
        SLACK_TOKEN := Settings.Token;
        SLACK_REFRESH_TOKEN := Settings.RefreshToken;
        SLACK_CLIENT_ID := Settings.ClientID;
        SLACK_CLIENT_SECRET := Settings.ClientSecret;

        // Convert TIME to task cycles.
        iMinIntervalTick
            := TO_UINT(MAX(TimeToNanoSec(Context.MinPostInterval), TimeToNanoSec(TIME#1s)) / TimeToNanoSec(GetMyTaskInterval()));
    
        iHttpClientService.Enable := FALSE;
        
        Inc(iState);
    1:
        IF NOT iHttpClientService.Busy THEN
            InitHttpClientService(Context:=iHttpClientContext,
                                  TLSSessionName:=Context.TLSSessionName);

            iHttpClientService.Enable := TRUE;
            
            Inc(iState);
        END_IF;
    2:
        IF iHttpClientService.Busy THEN            
            IF Context.CtrlReload THEN
                Context.CtrlReload := FALSE;
            ELSE
                Context.NextKey := 1;
                Context.Active := TRUE;
            END_IF;
            
            iState := STATE_WAIT;
        END_IF;
    
    // STATE_WAIT
    10:
        IF NOT Enable THEN
            iState := STATE_SHUTDOWN;
        ELSIF Context.CtrlReload THEN
            iState := STATE_INIT;
        ELSIF SlackPostMessageService_hasRequest(Context) THEN
            iTransState := iState + 1;
            iState := STATE_POST_MESSAGE;
        END_IF;
    11:    
        IF iError THEN
            Context.Error := iError;
            Context.ErrorID := iErrorID;
            Context.ErrorIDEx := iErrorIDEx;
            
            iState := STATE_SHUTDOWN;
        ELSE
            iWaitTick := iMinIntervalTick;
            
            Inc(iState);
        END_IF;
    12:
        Dec(iWaitTick);
        IF NOT Enable
            OR iWaitTick < 1
        THEN
            iState := STATE_WAIT;
        END_IF;
        
    // STATE_POST_MESSAGE
    100:        
        IF SlackPostMessageService_dequeueRequest(Context:=Context,
                                                  Request:=iRequest)
        THEN
            Clear(iError);
            Clear(iErrorID);
            Clear(iErrorIDEx);
            
            Inc(iState);
        END_IF;
    101:
        IF NewHttpClientTask(
               Context:=iHttpClientContext,
               ClientTask:=iHttpClientTask)
        THEN
            HttpPost(Context:=iHttpClientContext,
                     Url:='https://slack.com/api/chat.postMessage',
                     KeepAlive:=FALSE,
                     ClientTask:=iHttpClientTask);
            SetHttpHeader(Context:=iHttpClientContext,
                          Key:='Authorization',
                          Value:=CONCAT('Bearer ', SLACK_TOKEN));
            SetContent(Context:=iHttpClientContext,
                       ContentType:='application/json; charset=utf-8',
                       ContentLength:=iRequest.Size,
                       Content:=iRequest.Message,
                       Head:=0,
                       Size:=iRequest.Size);

            InvokeHttpClientTask(Context:=iHttpClientContext,
                                 ClientTask:=iHttpClientTask);
            
            Inc(iState);
        END_IF;
    102:
        CASE HttpClientTaskState(Context:=iHttpClientContext,
                                 ClientTask:=iHttpClientTask) OF
            HTTP_CLIENT_TASK_STATE_CLOSED:
                // Unintended closing.
                iError := TRUE;
                iErrorID := ERROR_UNINTENDED_CLOSING;
                iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
            
                Inc(iState);
            HTTP_CLIENT_TASK_STATE_RESPOND:
                ClearRequest(iHttpClientContext);
                
                CASE GetStatusCode(Context:=iHttpClientContext) OF
                    200: // Will ok
                        GetContent(Context:=iHttpClientContext,
                                   Out:=iResponseBody,
                                   Head:=0,
                                   Size=>iResponseBodySize);

                        // check the response is {"ok":true,...}.
                        IF iResponseBodySize > 10
                            AND iResponseBody[6] = 16#74
                            AND iResponseBody[7] = 16#72
                            AND iResponseBody[8] = 16#75
                            AND iResponseBody[9] = 16#65
                        THEN
                            iState := iState + 7;                        
                        ELSE
                            iError := TRUE;
                            iErrorID := ERROR_INVALID_REQUEST;
                            iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16)
	                                          OR TO_DWORD(iErrorID);
                            MemCopy(In:=iResponseBody[0],
                                    AryOut:=Context.LatestErrorResponse[0],
                                    Size:=iResponseBodySize);
                            Context.LatestErrorResponseSize := iResponseBodySize;
                            
                            Inc(iState);
                        END_IF;
                ELSE
                    // Unintended status.
                    iError := TRUE;
                    iErrorID := ERROR_UNINTENDED_RESPONSE_STATUS;
                    iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16)
                                      OR TO_DWORD(iErrorID);
                    
                    Inc(iState);
                END_CASE;
            HTTP_CLIENT_TASK_STATE_ERROR:
                GetHttpClientError(Context:=iHttpClientContext,
                                   Error=>iError,
                                   ErrorID=>iErrorID,
                                   ErrorIDEx=>iErrorIDEx);
                Inc(iState);
        END_CASE;
    103:
        Context.LatestErrorRequest := iRequest;
                
        iState := iState + 6;
    109:        
        iState := iTransState;
    
    // STATE_SHUTDOWN
    900:
        Context.Active := FALSE;
        iHttpClientService.Enable := FALSE;
    
        Inc(iState);
    901:
        IF NOT iHttpClientService.Busy THEN
            iState := iState + 8;
        END_IF;
    909:
        iState := STATE_DONE;
    
    // STATE_DONE
    1000:
        Busy := FALSE;
        Done := TRUE;
        
        Inc(iState);
END_CASE;

iHttpClientService(Context:=iHttpClientContext);

// Stat
IF Busy THEN
    CASE iPrevState OF
        1:
            iQueueCapacity := SizeOfAry(Context.RequestQueue);
        100:
            iPostTick:= 0;            
        101..102:
            Inc(iPostTick);
        109:
            IF iError THEN
                Inc(Context.Stat.FailTimes);
            ELSE
                Context.Stat.MaxMessageSize := MAX(Context.Stat.MaxMessageSize, iRequest.Size);
                Context.Stat.MinMessageSize := MIN(Context.Stat.MaxMessageSize, iRequest.Size);
                Context.Stat.TotalPostMessageSize := Context.Stat.TotalPostMessageSize + iRequest.Size;
            
                Context.Stat.LatestPostTick := iPostTick;
                Context.Stat.LatestMessageSize := iRequest.Size;
                
                Inc(Context.Stat.PostTimes);
            END_IF;
    END_CASE;
    
    IF Context.Active THEN
        Context.Stat.RequestQueueUtilization
            := TO_REAL(Context.Size) / iQueueCapacity;
    END_IF;
END_IF;
iPrevState := iState;

SlackPostMessageSingletonService

SlackPostMessageServiceをシングルトンとして実行するのが、SlackPostMessageSingletonService FBです。シングルトンといってもグローバル変数にあるコンテクスト変数を使用するだけです。SlackPostMessageServiceの実行と合わせてエラーを監視し、SDカードにログ出力をします。

POU/ファンクションブロック/SlackPostMessageSingletonService
IF Enable AND NOT Busy THEN
    Busy := TRUE;
    
    iState := STATE_INIT;
END_IF;

CASE iState OF
    // STATE_INIT
    0:    
        iService.Enable := FALSE;
        
        Inc(iState);
    1:
        IF gSlackPostServiceSingleton.Enable THEN            
            Inc(iState);
        END_IF;
    2:
        iService.Settings := gSlackPostServiceSingleton.ServiceSettings;
        iService.Enable := TRUE;
        
        Inc(iState);
    3:
        IF iService.Busy THEN
            gSlackPostServiceSingleton.Busy := TRUE;
            
            iState := STATE_ACTIVE;
        END_IF;
    
    // STATE_ACTIVE
    10:
        IF NOT (gSlackPostServiceSingleton.Enable AND Enable AND iService.Busy)
        THEN
            iService.Enable := FALSE;
            
            iState := STATE_SHUTDOWN;
        ELSIF gSlackPostServiceSingleton.Reload THEN
            iState := STATE_RELOAD;
        END_IF;
    
    // STATE_RELOAD
    20:
        iService.Settings := gSlackPostServiceSingleton.ServiceSettings;
        gSlackPostServiceSingleton.ServiceContext.CtrlReload := TRUE;
        
        Inc(iState);
    21:
        IF NOT gSlackPostServiceSingleton.ServiceContext.CtrlReload THEN
            gSlackPostServiceSingleton.Reload := FALSE;
            
            iState := STATE_ACTIVE;
        END_IF;
        
    // STATE_SHUTDOWN
    900:
        IF NOT iService.Busy THEN
            gSlackPostServiceSingleton.Busy := FALSE;
            
            Inc(iState);
        END_IF;
    901:
        IF NOT Enable THEN
            iState := STATE_DONE;
        ELSIF SlackPostMessageService_hasError(
                  gSlackPostServiceSingleton.ServiceContext)
        THEN
            IF gSlackPostServiceSingleton.AutoClearError THEN
                SlackPostMessageService_clearError(
                    gSlackPostServiceSingleton.ServiceContext);
                
                iState := STATE_INIT;
            ELSE        
                Inc(iState);
            END_IF;
        ELSE
            iState := STATE_INIT;
        END_IF;
    902:
        IF NOT Enable THEN
            iState := STATE_DONE;
        ELSIF NOT SlackPostMessageService_hasError(
                      gSlackPostServiceSingleton.ServiceContext)
        THEN
            iState := STATE_INIT;
        END_IF;
    
    // STATE_DONE
    1000:
        gSlackPostServiceSingleton.ServiceContext.CtrlReload := FALSE;
        Busy := FALSE;
        
        Inc(iState);
END_CASE;

iService(Context:=gSlackPostServiceSingleton.ServiceContext);

// Logger task
CASE iStateLogger OF
    // STATE_LOGGER_INIT
    0:
        iLogger.Enable := FALSE;
        
        Inc(iStateLogger);
    1:
        CASE iState OF
            10,20:
                IF gSlackPostServiceSingleton.UseLog THEN
                    iLogger.Enable := TRUE;
                
                    iStateLogger := STATE_LOGGER_OPEN;
                END_IF;
        END_CASE;
    
    // STATE_LOGGER_OPNE
    5:
        LogStream_configure(Context:=iLog,
                            LockKey:=gSlackPostServiceSingleton.ServiceContext.LockKey + 1,
                            MaxRetryTimes:=100,
                            RetryInterval:=10,
                            // 0 will flush the data immediately.
                            FlushThreshold:=0);
        LogStream_open(Context:=iLog,
                       Url:=gSlackPostServiceSingleton.LogUrl);
        
        Inc(iStateLogger);
    6:
        IF LogStream_isActive(iLog) THEN
            iStateLogger := STATE_LOGGER_WATCH;
        END_IF;
    
    // STATE_LOGGER_WATCH
    10:
        IF iState > STATE_SHUTDOWN THEN
            iStateLogger := STATE_LOGGER_SHUTDOWN;
        ELSIF SlackPostMessageService_hasError(
                  gSlackPostServiceSingleton.ServiceContext)
        THEN
            // Generate error log
            // The log format is JSONL.
            //
            // TODO: More standard and efficient way of generating messages.
            //
            SlackPostMessageService_getError(
                Context:=gSlackPostServiceSingleton.ServiceContext,
                ErrorID=>iErrorID,
                ErrorIDEx=>iErrorIDEx);
            iLogMsgSize
                := StringToAry(In:=CONCAT('{"timestamp":', LINT_TO_STRING(DtToSec(GetTime())),
                                          CONCAT(',"error_id":"', WORD_TO_STRING(iErrorID), '"'),
                                          CONCAT(',"error_id_ex":"', DWORD_TO_STRING(iErrorIDEx), '"'),
                                          ',"response":'),
                               AryOut:=iLogMsg[0]);
            
            SlackPostMessageService_getErrorResponse(
                Context:=gSlackPostServiceSingleton.ServiceContext,
                Out:=iLogMsg,
                Head:=iLogMsgSize,
                Size=>iSize);
            IF iSize = 0 THEN
                iLogMsgSize
                    := iLogMsgSize + StringToAry(In:='null',
                                                 AryOut:=iLogMsg[iLogMsgSize]);
            ELSE
                iLogMsgSize := iLogMsgSize + iSize;
            END_IF;

            SlackPostMessageService_getErrorRequest(
                Context:=gSlackPostServiceSingleton.ServiceContext,
                Request:=iRequest);
            iLogMsgSize
                := iLogMsgSize + StringToAry(In:=',"request":',
                                             AryOut:=iLogMsg[iLogMsgSize]);
            SlackPostMessageRequest_copyMessageToBin(Request:=iRequest,
                                                     Out:=iLogMsg,
                                                     Head:=iLogMsgSize,
                                                     Size=>iSize);
            IF iSize = 0 THEN
                iLogMsgSize
                    := iLogMsgSize + StringToAry(In:='null',
                                                 AryOut:=iLogMsg[iLogMsgSize]);
            ELSE
                iLogMsgSize := iLogMsgSize + iSize;
            END_IF;
            iLogMsgSize
                := iLogMsgSize + StringToAry(In:='}$n',
                                             AryOut:=iLogMsg[iLogMsgSize]);
            
            LogStream_write(Context:=iLog,
                            In:=iLogMsg,
                            Head:=0,
                            Size:=iLogMsgSize);
            
            Inc(iStateLogger);
        ELSIF LogStream_hasError(iLog) THEN
            iStateLogger := STATE_LOGGER_SHUTDOWN;
        ELSIF iState = STATE_RELOAD THEN
            iStateLogger := STATE_LOGGER_REALOAD;
        END_IF;
    11:
        IF iState > STATE_SHUTDOWN THEN
            iStateLogger := STATE_LOGGER_SHUTDOWN;
        ELSIF NOT SlackPostMessageService_hasError(
                      gSlackPostServiceSingleton.ServiceContext)
        THEN
            Dec(iStateLogger);
        ELSIF LogStream_hasError(iLog) THEN
            iStateLogger := STATE_LOGGER_SHUTDOWN;
        ELSIF iState = STATE_RELOAD THEN
            iStateLogger := STATE_LOGGER_REALOAD;
        END_IF;
    
    // STATE_LOGGER_RELOAD
    20:
        LogStream_close(iLog);
        
        Inc(iStateLogger);
    21:
        IF iState > STATE_SHUTDOWN THEN
            iStateLogger := STATE_LOGGER_SHUTDOWN;
        ELSIF iLogger.Done THEN
            iStateLogger := STATE_LOGGER_INIT;
        END_IF;
        
    // STATE_LOGGER_SHUTDOWN
    900:
        iLogger.Enable := FALSE;
        
        Inc(iStateLogger);
    901:
        IF NOT iLogger.Busy THEN
            iStateLogger := STATE_LOGGER_INIT;
        END_IF;
END_CASE;

iLogger(Context:=iLog);

実際に使用するには

ライブラリの品質という私の課題は別にして、実際に使用するには以下を考慮する必要があります。

  • 用途と目的を絞る
    メッセージが矢継ぎ早に投稿され、情報で埋めつくされるチャンネルは誰も見たくありません。通知するかどうかの基準は、オペレータやマネジャーがアクションを起こす必要があるかどうかです。Slackはコミュニケーションツールであり、コラボレーションツールです。アラーム通知の手段の一つではありません。もちろん、アラーム通知の専用チャンネルを用意してそこに全てのアラームを投稿することがダメなのではありません。適当なAIサービスに参照させて面白いことができるかもしれません。オペレーションの誤り検知や、予防が行えるかもしれません。

  • 限定的な使用に留める
    簡単で便利だと色々と使いたくなるものですが程々にします。そのような傾向が出てきたら、M2M向けのメッセージ基盤を整備し、外部連携アプリケーションを運用します。用途を分けて並列に運用するのはよいと思います。

  • メッセージは事前に確認する
    投稿するメッセージは、コントローラに組み込む前に別途cURL等で投稿して不備が無いか確認します。SlackPostサービスは、エラーであるレスポンスしかログに出力しません。部分的に問題があるとするレスポンスはスルーします。

  • 効率的なメッセージ生成
    適当なテンプレートエンジンを実装するのがよいです。Mustacheであればいくつかの仕様をサポートしないことで、無理なく実装することができます。

  • 十分なタスク設計
    メッセージ生成を効率的に行えたとしても短い周期で実行しているタスクで処理できるほどに軽くなることはありません。しっかりとタスク設計を行い、装置制御に影響を与えないようにする必要があります。全てのPOUを単一のタスクで実行する構成のソフトウェアでSlackPostサービスを使用できる可能性は小さいです。

また、以下のメリットを活かし、デメリットを軽減することが必要です。

メリット

  • ローコード/ノーコードツールと連携しやすい
  • 大きな決断とリソースを必要としない
  • 捨てやすい

メリットに共通していることは軽いということです。ローコード/ノーコードは、Slackがそのようなツールと連携しやすいことによります。大きな決断とリソースを必要としないのは、Slackにフリープランがあり、コントローラ側のコードが単純だからです。そして、捨てやすいというのは、後始末がSlackのワークスペースからボットアプリをアンインストールし、コントローラのSlackPostサービスを止めるだけで完了するからです。

デメリット

  • 主体的に考える必要のある領域が広い
  • 比較的広範な技術スタックを必要とする
  • 情報の取り扱いに注意が必要

気を付ける必要があるのは、情報の取り扱いです。SlackをEnterprise Gridで使用しているのであれば心配はない(情報共有についての認識と合意があり、それを監査しているはず)のですが、そうでない場合は共有して良いかどうかの確認が必要です。しかしながら、BYODでの一般的なコミュニケーションツールの使用が非公認にせよ行われている環境も多いように思われるので、最終的にはユーザーの判断次第なのかもしれません。

まとめ

今回は、NXからSlackにメッセージを投稿しました。アクセスに制限のあるWeb APIであれば何でも良かったのですが、Slackはハブとして適当なサービスなので選択しました。コントローラからWeb APIを叩くことのほうに関心があったかもしれませんが、手続きなので特別には取り上げませんでした。私の主題は、簡単に組み込める機能とした場合、どこにシークレット情報を配置するかにありました。使えたとしてもまともに運用できない機能は作っても仕方がないからです。一応、NXにWebサービス向けの外部連携機能を組み込んで使えるだろうというのが現在の判断です。今後もコントローラのセキュリティ機能は拡充されると思いますので、より適した方法が出てくるかもしれません。

コントローラにWebサービス向けの外部連携機能を組み込むことのメリットは、何と言っても外部連携を手軽に行えることです。手軽であればアイデアを素早く確認し、期待したものでなければすぐに取りやめることができます。機能をしっかりと設計することでプラグインとして既存ソフトウェアへの追加や、不要である場合の削除を簡単に行うこともできます。デメリットは、複雑であったり変化の激しい外部サービスには向かないことです。新進のWebサービスはもちろん、安定したWebサービスであっても過度な機能を構築すると対応が追いつかなくなります。

Discussion