🥗

Sysmac StudioとNX1でHTTPクライアントを実装してHTTPSを使う

2025/02/09に公開

はじめに

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

今回はTCP命令とTLS命令を使用してHTTP/1.1クライアントを実装し、HTTPSでサーバに問い合わせます。HTTPSと言ってもクライアント-サーバ間においてTLSを使用するに留まると考えてください。サーバ証明書の発行元の妥当性について、ユーザーに関与の余地がありません。HTTPクライアントは、以下のように記述して使用できるものです。

POU/プログラム/Test_HttpEchoの一部
  // POST /chat/api/v1 ContentType: application/json
  20:
    IF NewHttpClientTask(
         Context:=iHttpContext,
         ClientTask:=iHttpClientTask)
    THEN
      HttpPost(Context:=iHttpContext,
               Url:='https://echo.free.beeceptor.com/chat/api/v1',
               KeepAlive:=FALSE,
               ClientTask:=iHttpClientTask);
      SetHttpHeader(Context:=iHttpContext,
                    Key:='X-Api-Key',
                    Value:='xxxxxx');
      SetContentStr(
        Context:=iHttpContext,
        ContentType:='application/json',
        Content:='{"channel":"plc","name":"nx","msg":"Hi"}');
      iPostTick := 0;
      InvokeHttpClientTask(Context:=iHttpContext,
                           ClientTask:=iHttpClientTask);
      
      Inc(iState);
    END_IF;
  21:
    CASE HttpClientTaskState(Context:=iHttpContext,
                             ClientTask:=iHttpClientTask) OF
      HTTP_CLIENT_TASK_STATE_CLOSED:
        iState := iTransState;
      HTTP_CLIENT_TASK_STATE_RESPOND:
        CASE GetStatusCode(Context:=iHttpContext) OF
          200: // OK
            iPostRspContentType
              := GetContentType(Context:=iHttpContext); 
            GetContent(Context:=iHttpContext,
                       Out:=iRspBinBody,
                       Head:=0,
                       Size=>iRspBinBodySize);
            iPostRspContent
              := AryToString(In:=iRspBinBody[0],
                             Size:=iRspBinBodySize);
            
            iState := iTransState;
          204: // No Content
            iState := iTransState;
        ELSE
          Inc(iState);
        END_CASE;
      HTTP_CLIENT_TASK_STATE_REQUESTING:
        Inc(iPostTick);
      HTTP_CLIENT_TASK_STATE_ERROR:
        GetHttpClientError(Context:=iHttpContext,
                           Error=>iError,
                           ErrorID=>iErrorID,
                           ErrorIDEx=>iErrorIDEx);
        iState := iState + 7;
    END_CASE;

HTTPクライアントは、Hello WorldなHTTPクライアントではないため、それなりの分量(それでも不十分)があります。実装の大半はHTTPとして妥当なリクエストの生成とレスポンスの解釈に費やしています。それらの実装は目的やスタイルによって変わる上、内容はRFCに沿うものなので触れません。RFCとSysmacプロジェクトを確認すれば十分です。しかし、ソケット命令を使用する手順はPLC固有で目的によらず似たり寄ったりになるので、今回はその部分について確認します。マニュアルにある例よりも実践的なソケット命令の使用例です。プログラムを適切に設定したコントローラで動作させると、実際にサーバとやり取りするので気をつけてください。

HTTPについて学習したい場合は、他の言語で実装することをお勧めします。STは好意的に見たとしても適しているとは言えません。制約のあるPascalで、制約の強い環境で動かすためのソフトウェアを作ることになります。他の言語ではどうでもないことに数十行を要し、サイクルに負荷をかけ過ぎないよう適当に処理を区切ったりなど、HTTPとは関係のないことに溢れています。また、便利なライブラリもありません。URLエンコードやパーセントエンコードであればまだよいのですが、Deflateもとなると考えてしまいます。RFCは、以下から始めてみましょう。

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

Sysmacプロジェクト

プロジェクトは以下にあります。実機で動作確認まで行う場合、コントローラの型式変更とネットワーク設定が必要です。また、プロキシを必要とする環境では動作しません。HTTPクライアントはCONNECTメソッドを実装していません。

https://github.com/kmu2030/ExperimentalHttpClient

NX/NJでのHTTPクライアントの実用性

わざわざHTTPクライアントを実装することにメリットはあるのでしょうか。これについては、デメリットを考える方が分かりやすいです。Webブラウザが無かったらどうでしょうか。多くのモバイルアプリケーションもプロトコルとしてHTTPを使用していますから、それらも使えません。組み込みブラウザを使用しているモバイルアプリケーションなど以ての外です。サービス間のプロトコルとしてHTTPを使用するバックエンドも使えません。

Webの大部分にアクセスすることができるHTTPというツールが無いことにより、そこに蓄積したリソースにアクセスできないことは大きな損失でしかありません。OTの、ましてPLCにそのようなものは必要ないと考えることは自由です。考える必要があるのは、このような手段をもって付加価値を高めた競合が現れた場合にどのように対応するかです。

手ごろな手段としてゲートウェイ機器を搭載して対応することはできます。例にもれず高機能を謳っているでしょうから、場合によっては、機能的な優位性を訴求することもできます。競合が最終的に機能的な限界から同じゲートウェイ機器を採用したとしましょう。無駄な苦労を避けた自分たちは正しかったと安堵できるでしょうか。そうとは思えません。外部のエコシステムをどのように付加価値につなげるかということ、そのためのシステム構成について比較にならない知見を持ち合わせていると考える必要があります。

小さなメリットを考えることもできます。ちょっとした通知を手元のモバイルデバイスで受けられるようにしたいとします。しかも、多くの人が使い慣れたアプリケーションで、です。少し何かしたいだけなのに買い物をしたり、セットアップをしたりといったことは面倒で仕方がありません。運用まで考えたら尚更です。私であれば面倒なので、理由をつけてやらないかもしれません。しかし、結果として何かの機会を失っているかもしれません。これがテキストを少し打っていつもの作業をするだけで済むのであれば、ものぐさの本領を発揮してコードジェネレータまで作るかもしれません。少しの手間を減らせることは小さなメリットですが、間接的に大きなメリットに化けることもあります。

HTTPクライアントがあることのメリットは分かるのですが、それを自ら実装することに学習以外のメリットがあるのでしょうか。そもそもメーカーに依頼してライブラリを作成してもらうことができるかもしれません。自作であれば、否が応でもPLCのランタイム上だけで動作することになります。これは、ユーザープログラムとして動作することを強制し、プログラム全体がこれまでと同じリスクで動作することを意味します。もし、メーカーに作成してもらったライブラリがランタイム上の通常ではない状態、あるいはランタイム外で動作するとなったらそうはいきません。

移植性を考えても自作は有利です。そもそもIEC61131-3として規格化されているのですから、それを活かすことができます。もちろん、組み込みのソケット命令が無い環境もあれば、使い方が異なる環境もあります。しかしながら、そのような命令は全体のごく一部です。少なくとも私はそのように構築します。そうなっていれば少しの修正で移植することができます。また、テストコードとテストランタイム自体もPLCのランタイム上で動作することに意味があります。移植先でテストランタイム自体のテストコードを実行して問題がなければ、移植したコードのテストコードの実行結果も信頼できることになり、テストがパスするのであればとりあえず問題はないと判断できます。体感としてCODESYSへの移植難易度は高くありません。

HTTPからみたSysmac StudioとNX シリーズ

HTTPクライアントを実装するという視点から、Sysmc StudioとNXシリーズはどのように見えるでしょうか。Sysmac Studio及びNXシリーズはソフトウェアPLCを除いた国産PLCにあっては、ソケット命令が揃っており、かつ、性能的に必要十分です。TLSも自由度はありませんが使えます。ローエンドのNX1であっても内蔵Ethernetポートの性能がEtherNet/IPTMとして12000 ppsと公称しているため、ガッカリとはなりません。数年前に確認した際は、ユーザープログラムでも4000 pps程度までは行けました。明らかにユーザープログラムの帯域には上限を設けているようでしたが、そこまで使用することもないので問題ありません。送信パケットサイズは、バッファサイズに依存しているようで、最大2000 bytesです。

TLSについては1.2となっているので使えるのですが、相手方から送られてきた証明書の検証を行っているのか不明です。クライアント側の証明書や秘密鍵についての操作はできるのですが、ルート証明書についての操作ができません。コントローラが妥当としているCAの証明書が内蔵されていて検証しているのかもしれませんが、していないと考えておいた方が安全です。よって多くの例で妥当と言える安全性が担保できていない可能性があると認識する必要があります。そもそもですが、今回のような用途でなくとも注意がです。接続先が妥当であるかどうかは、重要なことです。サーバは身元が確かであり、クライアントは怪しいという前提なのかもしれません。

通信処理については問題ありませんが、機能面、特にTLSは十分とは言えません。しかしながら、HTTPクライアントを実装することはできます。処理負荷は、スループットとのトレードオフで工夫できるので問題ありません。

実は、Web関連のライブラリが2023年末ぐらいまでfrドメインのOMRON社サイトで公開されていました。しかし、2024年のいつごろからか見当たらなくなりました。海外のフォーラムを確認しても探している人がいたので、やはり無くなったようです。HTTPクライアントライブラリもあったのですが、制約の強いものでした。それ以上にどう見てもホラーなライブラリがあったので、怖かったのを覚えています。あったら便利なものでしたが、手を出せる代物ではありませんでした。

ソケット命令を使用するコード

HTTPクライアントは多くのPOUによって構成しますが、ソケット命令は1つのPOUにしかありません。HTTPクライアントのメインFB(HttpClientService)です。TLSを使用する場合も、ソケット命令の使用手順とコードの構造は同じです。ユーザーが細かい処理を行うことはありません。TCPコネクション確立の後、TLSセッションの確立を行います。接続処理はiState=180、切断処理はiState=190がエントリポイントです。

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

CASE iState OF
  // STATE_INIT
  0:
    iSktTCPConnect.Execute := FALSE;
    iSktClose.Execute := FALSE;
    iSktTLSConnect.Execute := FALSE;
    iSktTLSDisconnect.Execute := FALSE;
    iSktTCPSend.Execute := FALSE;
    iSktTCPRcv.Execute := FALSE;
    
    Context.TCPEstablished := FALSE;
    Context.TLSEstablished := FALSE;
    Clear(Context.Socket);
    Clear(Context.TLSHandle);
    Clear(Context.Host);
    Clear(Context.Port);
    Clear(Context.Error);
    Clear(Context.ErrorID);
    Clear(Context.ErrorIDEx);
    Context.TaskKey := 1;
    
    iState := STATE_WAIT;

  // STATE_WAIT
  10:
    IF NOT Enable THEN
      iState := STATE_FINALIZE;
    ELSIF Context.ClientTask.Invoke THEN      
      
      iState := 100;
    END_IF;
  11:  
    Context.ClientTask.Done := TRUE;
    Context.ClientTask.Active := FALSE;
    Context.ClientTask.Invoke := FALSE;

    Dec(iState);
  
  // STATE_FINALIZE
  90:
    IF Context.TCPEstablished THEN
      iTransState := iState + 1;
      
      iState := STATE_DISCONNECT;
    ELSE
      Inc(iState);
    END_IF;
  91:
    IF iError THEN
      iState := iState + 7;
    ELSE
      iState := iState + 8;
    END_IF;
  98:
    Context.Error := iError;
    Context.ErrorID := iErrorID;
    Context.ErrorIDEx := iErrorIDEx;
    
    Inc(iState);
  99:
    iState := STATE_DONE;

  // Send the request then receive the response.
  100:
    iTimeoutTick
      := TO_UINT(TimeToNanoSec(Context.Request.Timeout)
           / TimeToNanoSec(GetMyTaskInterval()));
    IF NOT Context.TCPEstablished THEN
      Context.Host := Context.Request.Host;
      Context.Port := Context.Request.Port;
      Context.UseTLS := Context.Request.UseTLS;
      Context.KeepAlive := Context.Request.KeepAlive;
      
      iTransState := iState + 1;
      iState := STATE_CONNECT;
    ELSE
      iState := iState + 2;
    END_IF;
  101:
    IF iError THEN
      iState := iState + 7;
    ELSE
      Inc(iState);
    END_IF;
  102:
    HttpClientService_MergeHeaders(Context:=Context);
    iSendTaskState := STATE_SEND_TASK_SEND;
    
    Inc(iState);
  103:
    Dec(iTimeoutTick);
    IF iSendTaskState = STATE_SEND_TASK_DONE THEN
      IF iSendTaskError THEN
        iError := TRUE;
        iErrorID := iSendTaskErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
        
        iState := iState + 5;
      ELSE
        iRecvTaskState := STATE_RECV_TASK_RECV;
      
        Inc(iState);
      END_IF;
    ELSIF iTimeoutTick < 1 THEN
      iState := iState + 3;
    END_IF;
  104:
    Dec(iTimeoutTick);
    IF iRecvTaskState = STATE_RECV_TASK_DONE THEN
      IF iRecvTaskError THEN
        iError := TRUE;
        iErrorID := iRecvTaskErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
        
        iState := iState + 4;
      ELSE
        IF Context.KeepAlive THEN
          iState := iState + 5;
        ELSE  
          iTransState := iState + 1;
          
          iState := STATE_DISCONNECT;
        END_IF;
      END_IF;
    ELSIF iTimeoutTick < 1 THEN
      iState := iState + 2;
    END_IF;
  105:
    IF iError THEN
      iState := iState + 3;
    ELSE
      iState := iState + 4;
    END_IF;
  106: // timeout
    iSendTaskState := STATE_SEND_TASK_CANCEL;
    iRecvTaskState := STATE_RECV_TASK_CANCEL;
    
    Inc(iState);
  107:
    IF iSendTaskState = STATE_SEND_TASK_DONE
      AND iRecvTaskState = STATE_RECV_TASK_DONE
    THEN
      iError := TRUE;
      iErrorID := 16#0001;
      iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
      
      Inc(iState);
    END_IF;
  108: // error
    Context.Error := iError;
    Context.ErrorID := iErrorID;
    Context.ErrorIDEx := iErrorIDEx;
    
    Inc(j);
    
    iTransState := iState + 1;
    iState := STATE_DISCONNECT;
  109:    
    iState := STATE_WAIT + 1;

  // Establish the connection.
  180: // tcp
    iError := FALSE;
    Clear(iErrorID);
    Clear(iErrorIDEx);
  
    iSktTCPConnect.Execute := FALSE;
    iSktTLSConnect.Execute := FALSE;
    
    Inc(iState);
  181:
    iSktTCPConnect.DstTcpPort := Context.Port;
    iSktTCPConnect.DstAdr := Context.Host;
    iSktTCPConnect.Execute := TRUE;

    Inc(iState);
  182:
    IF iSktTCPConnect.Done OR iSktTCPConnect.Error THEN
      iSktTCPConnect.Execute := FALSE;
      
      IF iSktTCPConnect.Error THEN
        iError := TRUE;
        iErrorID := iSktTCPConnect.ErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
        
        iState := iState + 6;
      ELSE
        Context.Socket := iSktTCPConnect.Socket;
        Context.TCPEstablished := TRUE;
                
        IF Context.UseTLS THEN
          iState := iState + 3;
        ELSE
          iState := iTransState;
        END_IF;
      END_IF;
    END_IF;
  185: // tls
    iSktTLSConnect.TLSSessionName := Context.TLSSessionName;
    iSktTLSConnect.Socket := Context.Socket;
    iSktTLSConnect.Execute := TRUE;
    
    Inc(iState);
  186:
    IF iSktTLSConnect.Done OR iSktTLSConnect.Error THEN
      iSktTLSConnect.Execute := FALSE;
      
      IF iSktTLSConnect.Error THEN
        iError := TRUE;
        iErrorID := iSktTLSConnect.ErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
        
        iState := iState + 2;
      ELSE
        Context.TLSHandle := iSktTLSConnect.Handle;
        Context.TLSEstablished := TRUE;
        
        iState := iState + 3;
      END_IF;
    END_IF;
  188: // error
    Inc(iState);
  189:
    iState := iTransState;

  // Close the connection.
  190:
    iError := FALSE;
    Clear(iErrorID);
    Clear(iErrorIDEx);
  
    iSktTLSDisconnect.Execute := FALSE;
    iSktClose.Execute := FALSE;
    IF Context.TLSEstablished THEN      
      Inc(iState);
    ELSE
      iState := iState + 5;
    END_IF;
  191:
    iSktTLSDisconnect.Handle := Context.TLSHandle;
    iSktTLSDisconnect.Execute := TRUE;

    Inc(iState);
  192:
    IF iSktTLSDisconnect.Done OR iSktTLSDisconnect.Error THEN
      iSktTLSDisconnect.Execute := FALSE;
      
      Context.TLSEstablished := FALSE;

      IF iSktTLSDisconnect.Error THEN
        iError := TRUE;
        iErrorID := iSktTLSDisconnect.ErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
      END_IF;
      
      iState := iState + 3;
    END_IF;
  195:
    iSktClose.Socket := Context.Socket;
    iSktClose.Execute := TRUE;
    
    Inc(iState);
  196:
    IF iSktClose.Done OR iSktClose.Error THEN
      iSktClose.Execute := FALSE;
      
      Context.TCPEstablished := FALSE;
      
      IF iSktClose.Error AND NOT iError THEN
        iErrorID := iSktTLSDisconnect.ErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
      END_IF;
      
      iState := iState + 3;
    END_IF;
  198: // error
    Inc(iState);
  199:
    iState := iTransState;

  // STATE_DONE
  1000:
    Busy := FALSE;
    
    Inc(iState);
END_CASE;

// Client task state.
IF iState <> iPrevState AND Context.ClientTask.Active THEN
  CASE iPrevState OF
    102:
      Context.ClientTask.State
        := HTTP_CLIENT_TASK_STATE#HTTP_CLIENT_TASK_STATE_REQUESTING;
    104:
      Context.ClientTask.State
        := HTTP_CLIENT_TASK_STATE#HTTP_CLIENT_TASK_STATE_RESPOND;
    108:
      Context.ClientTask.State
        := HTTP_CLIENT_TASK_STATE#HTTP_CLIENT_TASK_STATE_ERROR;
    190:
      Context.ClientTask.State
        := HTTP_CLIENT_TASK_STATE#HTTP_CLIENT_TASK_STATE_CLOSING;
    199:
      Context.ClientTask.State
        := HTTP_CLIENT_TASK_STATE#HTTP_CLIENT_TASK_STATE_CLOSED;
  END_CASE;
END_IF;
iPrevState := iState;

// Send task.
CASE iSendTaskState OF
  // Init
  0:
    iSendTaskError := FALSE;
    Clear(iSendTaskErrorID); 
  
    iSktTCPSend.Execute := FALSE;
    iSktTLSWrite.Execute := FALSE;
    
    iHttpRequestStream(
      Execute:=FALSE,
      Request:=Context.Request,
      Out:=iSendBuffer,
      OutHead:=iSendBufferHead);
    iSendBufferHead := 0;

    iSendTaskState := 10;
    
  // Send
  10:
    iHttpRequestStream(
      Execute:=TRUE,
      Request:=Context.Request,
      Out:=iSendBuffer,
      OutHead:=iSendBufferHead,
      OutSize=>iSendBufferSize);
    
    iSendBufferHead := 0;
    IF Context.TLSEstablished THEN
      iSktTLSWrite.Handle := Context.TLSHandle;
      iSktTLSWrite.Execute := TRUE;
      iSktTLSWrite.SendDatSize := iSendBufferSize;
      
      iSendTaskState := iSendTaskState + 2;
    ELSE
      iSktTCPSend.Socket := Context.Socket;
      iSktTCPSend.Execute := TRUE;
      iSktTCPSend.Size := iSendBufferSize;
      
      Inc(iSendTaskState);
    END_IF;
  11: // http
    IF iSktTCPSend.Done OR iSktTCPSend.Error THEN
      iSktTCPSend.Execute := FALSE;
      
      IF iSktTCPSend.Error THEN
        iSendTaskError := TRUE;
        iSendTaskErrorID := iSktTCPSend.ErrorID;
        
        iSendTaskState := STATE_SEND_TASK_DONE;
      ELSE
        IF iHttpRequestStream.Done THEN
          iHttpRequestStream.Execute := FALSE;
          
          iSendTaskState := STATE_SEND_TASK_DONE;
        ELSE
          Dec(iSendTaskState);
        END_IF;
      END_IF;
    END_IF;
  12: // https
    IF iSktTLSWrite.Done OR iSktTLSWrite.Error THEN
      iSktTLSWrite.Execute := FALSE;
      
      IF iSktTLSWrite.Error THEN
        iSendTaskError := TRUE;
        iSendTaskErrorID := iSktTLSWrite.ErrorID;
      ELSE
        IF iHttpRequestStream.Done THEN
          iHttpRequestStream.Execute := FALSE;
          
          iSendTaskState := STATE_SEND_TASK_DONE;
        ELSE
          iSendTaskState := iSendTaskState - 2;
        END_IF;
      END_IF;
    END_IF;
    
  // Cancel
  20:
    iSktTCPSend.Execute := FALSE;
    iSktTLSWrite.Execute := FALSE;
    
    Inc(iSendTaskState);
  21:
    IF NOT (iSktTCPSend.Busy OR iSktTLSWrite.Busy) THEN
      iSendTaskState := STATE_SEND_TASK_DONE;
    END_IF;
END_CASE;

// Recv task.
CASE iRecvTaskState OF
  0:
    iSktTCPRcv.Execute := FALSE;
    iSktTLSRead.Execute := FALSE;
    
    iHttpResponseParser(
      Execute:=FALSE,
      Context:=Context.Response,
      Response:=iRecvBuffer,
      ResponseHead:=iRecvBufferHead,
      ResponseSize:=0);

    iRecvTaskError := FALSE;
    Clear(iRecvTaskErrorID); 

    iRecvBufferHead := 0;
    
    Inc(iRecvTaskState);
  1:  
    IF Context.TLSEstablished THEN
      iSktTLSRead.Handle := Context.TLSHandle;
      iSktTLSRead.Execute := TRUE;
      iSktTLSRead.RcvDatSize := 2000;
      
      iRecvTaskState := 11;
    ELSE
      iSktTCPRcv.Socket := Context.Socket;
      iSktTCPRcv.Execute := TRUE;
      iSktTCPRcv.Size := 2000;
      
      iRecvTaskState := 10;
    END_IF;

  10: // http
    IF iSktTCPRcv.Done OR iSktTCPRcv.Error THEN
      iSktTCPRcv.Execute := FALSE;
      
      IF iSktTCPRcv.Error THEN
        iRecvTaskError := TRUE;
        iRecvTaskErrorID := iSktTCPRcv.ErrorID;
        
        iRecvTaskState := STATE_RECV_TASK_DONE;
      ELSE
        iRecvTaskState := iRecvTaskState + 2;
      END_IF;
    END_IF;
  11: // https
    IF iSktTLSRead.Done OR iSktTLSRead.Error THEN
      iSktTLSRead.Execute := FALSE;
      
      IF iSktTLSRead.Error THEN
        iRecvTaskError := TRUE;
        iRecvTaskErrorID := iSktTCPRcv.ErrorID;
        
        iRecvTaskState := STATE_RECV_TASK_DONE;
      ELSE
        Inc(iRecvTaskState);
      END_IF;
    END_IF;
  12:
    iRecvBufferSize := iRecvBufferSize + iRecvBufferHead;
    iRecvBufferHead := 0;
    
    iHttpResponseParser(
      Execute:=TRUE,
      Context:=Context.Response,
      Response:=iRecvBuffer,
      ResponseHead:=iRecvBufferHead,
      ResponseSize:=iRecvBufferSize);
    
    IF iHttpResponseParser.Done THEN
      iRecvTaskState := 1000;
    ELSE
      i := iRecvBufferSize - iRecvBufferHead;
      IF i > 0 THEN
        MemCopy(In:=iRecvBuffer[iRecvBufferHead], AryOut:=iRecvBuffer[0], Size:=i);
      END_IF;
      iRecvBufferHead := i;

      IF Context.TLSEstablished THEN
        iSktTLSRead.Handle := Context.TLSHandle;
        iSktTLSRead.Execute := TRUE;
        iSktTLSRead.RcvDatSize := 2000;
        
        Dec(iRecvTaskState);
      ELSE
        iSktTCPRcv.Socket := Context.Socket;
        iSktTCPRcv.Execute := TRUE;
        iSktTCPRcv.Size := 2000;
        
        iRecvTaskState := iRecvTaskState - 2;
      END_IF;
    END_IF;

  // Cancel
  20:
    iSktTCPRcv.Execute := FALSE;
    iSktTLSRead.Execute := FALSE;
    
    Inc(iRecvTaskState);
  21:
    IF NOT (iSktTCPRcv.Busy OR iSktTLSRead.Busy) THEN
      iRecvTaskState := STATE_RECV_TASK_DONE;
    END_IF;
END_CASE;

iSktTCPConnect();
iSktClose();
iSktTLSConnect();
iSktTLSDisconnect();

IF Context.TLSEstablished THEN
  iSktTLSWrite(SendDat:=iSendBuffer[iSendBufferHead]);
  iSktTLSRead(RcvDat:=iRecvBuffer[iRecvBufferHead], RcvSize=>iRecvBufferSize);
ELSE
  iSktTCPSend(SendDat:=iSendBuffer[iSendBufferHead]);
  iSktTCPRcv(RcvDat:=iRecvBuffer[iRecvBufferHead], RcvSize=>iRecvBufferSize);
END_IF;

接続処理

まず、TCP命令について少し確認します。TCP命令を含むソケット命令は十分に抽象化されており、ユーザーが細かに処理を行う必要はありません。いくつかのパラメータを設定して処理を実行するだけです。これは、データの読み書きについても同じです。TCPコネクション確立処理の例外は、全てエラーとしてしまうのが安全です。コードレベルでパラメータ不正以外の例外から復帰する余地はありません。コントローラの設定不備や、ネットワーク障害はどうにもできません。また、パラメータ不正についても、何もせずユーザーに通知すれば十分です。

TLSセッション確立処理については、SktTLSConnect命令の"TLSSessionName"に注意が必要です。これは、コントローラのセキュリティソケット設定のセッションIDから機械的に決定します。次のように決まります。

TLSSession{セッションID}

なぜセッションIDではないのか、どうして名前が勝手に決まっていて、かつ、セキュアソケット設定ではそれが分からないのかは仕様としか言いようがありません。TLS命令は半端な感じがあるので将来的に変更があるかもしれません。それほど頻繁に使用しませんし、多数使用することもありません。TLSについて学んでおけば、注意の必要な変更かどうかの判断はつくはずです。納品済みのコントローラについては、納品先の規格ないし適用法と組織ポリシー次第で対応が要求されることになりますが、対応が必要となると恐らくコントローラ交換になると思います。セキュリティー関係に柔軟な対応は期待できません。

コードは以下になります。ユーザーは淡々と実行して成功を祈るだけです。

  // Establish the connection.
  180: // tcp
    iError := FALSE;
    Clear(iErrorID);
    Clear(iErrorIDEx);
  
    iSktTCPConnect.Execute := FALSE;
    iSktTLSConnect.Execute := FALSE;
    
    Inc(iState);
  181:
    iSktTCPConnect.DstTcpPort := Context.Port;
    iSktTCPConnect.DstAdr := Context.Host;
    iSktTCPConnect.Execute := TRUE;

    Inc(iState);
  182:
    IF iSktTCPConnect.Done OR iSktTCPConnect.Error THEN
      iSktTCPConnect.Execute := FALSE;
      
      IF iSktTCPConnect.Error THEN
        iError := TRUE;
        iErrorID := iSktTCPConnect.ErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
        
        iState := iState + 6;
      ELSE
        Context.Socket := iSktTCPConnect.Socket;
        Context.TCPEstablished := TRUE;
                
        IF Context.UseTLS THEN
          iState := iState + 3;
        ELSE
          iState := iTransState;
        END_IF;
      END_IF;
    END_IF;
  185: // tls
    iSktTLSConnect.TLSSessionName := Context.TLSSessionName;
    iSktTLSConnect.Socket := Context.Socket;
    iSktTLSConnect.Execute := TRUE;
    
    Inc(iState);
  186:
    IF iSktTLSConnect.Done OR iSktTLSConnect.Error THEN
      iSktTLSConnect.Execute := FALSE;
      
      IF iSktTLSConnect.Error THEN
        iError := TRUE;
        iErrorID := iSktTLSConnect.ErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
        
        iState := iState + 2;
      ELSE
        Context.TLSHandle := iSktTLSConnect.Handle;
        Context.TLSEstablished := TRUE;
        
        iState := iState + 3;
      END_IF;
    END_IF;
  188: // error
    Inc(iState);
  189:
    iState := iTransState;

切断処理

切断処理は、接続処理とは逆順に行います。まずTLSセッションを切断し、次にTCPコネクションを切断します。切断処理は、例外が発生したとしても一通り実行します。内部的なリソースの解放を確実に行ってもらうためです。切断処理については、TCPとTLSで大きな差はありません。どちらも処理を実行し、完了を待つだけです。TCPは_sSocket構造体、TLSはHandleであるDWORD値を指定します。

コードは以下になります。例外が発生しても気にせず処理を続行します。

  // Close the connection.
  190:
    iError := FALSE;
    Clear(iErrorID);
    Clear(iErrorIDEx);
  
    iSktTLSDisconnect.Execute := FALSE;
    iSktClose.Execute := FALSE;
    IF Context.TLSEstablished THEN      
      Inc(iState);
    ELSE
      iState := iState + 5;
    END_IF;
  191:
    iSktTLSDisconnect.Handle := Context.TLSHandle;
    iSktTLSDisconnect.Execute := TRUE;

    Inc(iState);
  192:
    IF iSktTLSDisconnect.Done OR iSktTLSDisconnect.Error THEN
      iSktTLSDisconnect.Execute := FALSE;
      
      Context.TLSEstablished := FALSE;

      IF iSktTLSDisconnect.Error THEN
        iError := TRUE;
        iErrorID := iSktTLSDisconnect.ErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
      END_IF;
      
      iState := iState + 3;
    END_IF;
  195:
    iSktClose.Socket := Context.Socket;
    iSktClose.Execute := TRUE;
    
    Inc(iState);
  196:
    IF iSktClose.Done OR iSktClose.Error THEN
      iSktClose.Execute := FALSE;
      
      Context.TCPEstablished := FALSE;
      
      IF iSktClose.Error AND NOT iError THEN
        iErrorID := iSktTLSDisconnect.ErrorID;
        iErrorIDEx := SHL(UINT_TO_DWORD(iState), 16) OR TO_DWORD(iErrorID);
      END_IF;
      
      iState := iState + 3;
    END_IF;
  198: // error
    Inc(iState);
  199:
    iState := iTransState;

リクエスト送信

リクエストの送信は、接続がHTTPなのかHTTPSなのかによって命令を使い分けます。HTTPであればSktTCPSendを、HTTPSであればSktTLSWriteを使用します。HTTPリクエストの生成は専用のFBに集約しているので、このコードはデータを送ることに専念します。いずれの命令も送信用バッファとして2000 bytesまでしか受け付けないので、バッファは要素数が2000のByte型配列を使用します。送信処理は、命令を実行して完了を待つだけです。

// Send task.
CASE iSendTaskState OF
  // Init
  0:
    iSendTaskError := FALSE;
    Clear(iSendTaskErrorID); 
  
    iSktTCPSend.Execute := FALSE;
    iSktTLSWrite.Execute := FALSE;
    
    iHttpRequestStream(
      Execute:=FALSE,
      Request:=Context.Request,
      Out:=iSendBuffer,
      OutHead:=iSendBufferHead);
    iSendBufferHead := 0;

    iSendTaskState := 10;
    
  // Send
  10:
    iHttpRequestStream(
      Execute:=TRUE,
      Request:=Context.Request,
      Out:=iSendBuffer,
      OutHead:=iSendBufferHead,
      OutSize=>iSendBufferSize);
    
    iSendBufferHead := 0;
    IF Context.TLSEstablished THEN
      iSktTLSWrite.Handle := Context.TLSHandle;
      iSktTLSWrite.Execute := TRUE;
      iSktTLSWrite.SendDatSize := iSendBufferSize;
      
      iSendTaskState := iSendTaskState + 2;
    ELSE
      iSktTCPSend.Socket := Context.Socket;
      iSktTCPSend.Execute := TRUE;
      iSktTCPSend.Size := iSendBufferSize;
      
      Inc(iSendTaskState);
    END_IF;
  11: // http
    IF iSktTCPSend.Done OR iSktTCPSend.Error THEN
      iSktTCPSend.Execute := FALSE;
      
      IF iSktTCPSend.Error THEN
        iSendTaskError := TRUE;
        iSendTaskErrorID := iSktTCPSend.ErrorID;
        
        iSendTaskState := STATE_SEND_TASK_DONE;
      ELSE
        IF iHttpRequestStream.Done THEN
          iHttpRequestStream.Execute := FALSE;
          
          iSendTaskState := STATE_SEND_TASK_DONE;
        ELSE
          Dec(iSendTaskState);
        END_IF;
      END_IF;
    END_IF;
  12: // https
    IF iSktTLSWrite.Done OR iSktTLSWrite.Error THEN
      iSktTLSWrite.Execute := FALSE;
      
      IF iSktTLSWrite.Error THEN
        iSendTaskError := TRUE;
        iSendTaskErrorID := iSktTLSWrite.ErrorID;
      ELSE
        IF iHttpRequestStream.Done THEN
          iHttpRequestStream.Execute := FALSE;
          
          iSendTaskState := STATE_SEND_TASK_DONE;
        ELSE
          iSendTaskState := iSendTaskState - 2;
        END_IF;
      END_IF;
    END_IF;
    
  // Cancel
  20:
    iSktTCPSend.Execute := FALSE;
    iSktTLSWrite.Execute := FALSE;
    
    Inc(iSendTaskState);
  21:
    IF NOT (iSktTCPSend.Busy OR iSktTLSWrite.Busy) THEN
      iSendTaskState := STATE_SEND_TASK_DONE;
    END_IF;
END_CASE;

レスポンス受信

レスポンスの受信も、接続がHTTPなのかHTTPSなのかによって命令を使い分けます。HTTPであればSktTCPRcvを、HTTPSであればSktTLSReadを使用します。HTTPレスポンスのパースは専用のFBに集約しているので、このコードはデータを受信することに専念します。いずれの命令も受信用バッファとして2000 bytesまでしか受け付けませんが、レスポンスパーサは適当な区切りを認識しない限りデータを消費しない作りのため、消費しなかったデータを貯められるよう受信用バッファは要素数が8192のByte型配列を使用します。受信処理も命令を実行して完了を待つだけです。

// Recv task.
CASE iRecvTaskState OF
  0:
    iSktTCPRcv.Execute := FALSE;
    iSktTLSRead.Execute := FALSE;
    
    iHttpResponseParser(
      Execute:=FALSE,
      Context:=Context.Response,
      Response:=iRecvBuffer,
      ResponseHead:=iRecvBufferHead,
      ResponseSize:=0);

    iRecvTaskError := FALSE;
    Clear(iRecvTaskErrorID); 

    iRecvBufferHead := 0;
    
    Inc(iRecvTaskState);
  1:  
    IF Context.TLSEstablished THEN
      iSktTLSRead.Handle := Context.TLSHandle;
      iSktTLSRead.Execute := TRUE;
      iSktTLSRead.RcvDatSize := 2000;
      
      iRecvTaskState := 11;
    ELSE
      iSktTCPRcv.Socket := Context.Socket;
      iSktTCPRcv.Execute := TRUE;
      iSktTCPRcv.Size := 2000;
      
      iRecvTaskState := 10;
    END_IF;

  10: // http
    IF iSktTCPRcv.Done OR iSktTCPRcv.Error THEN
      iSktTCPRcv.Execute := FALSE;
      
      IF iSktTCPRcv.Error THEN
        iRecvTaskError := TRUE;
        iRecvTaskErrorID := iSktTCPRcv.ErrorID;
        
        iRecvTaskState := STATE_RECV_TASK_DONE;
      ELSE
        iRecvTaskState := iRecvTaskState + 2;
      END_IF;
    END_IF;
  11: // https
    IF iSktTLSRead.Done OR iSktTLSRead.Error THEN
      iSktTLSRead.Execute := FALSE;
      
      IF iSktTLSRead.Error THEN
        iRecvTaskError := TRUE;
        iRecvTaskErrorID := iSktTCPRcv.ErrorID;
        
        iRecvTaskState := STATE_RECV_TASK_DONE;
      ELSE
        Inc(iRecvTaskState);
      END_IF;
    END_IF;
  12:
    iRecvBufferSize := iRecvBufferSize + iRecvBufferHead;
    iRecvBufferHead := 0;
    
    iHttpResponseParser(
      Execute:=TRUE,
      Context:=Context.Response,
      Response:=iRecvBuffer,
      ResponseHead:=iRecvBufferHead,
      ResponseSize:=iRecvBufferSize);
    
    IF iHttpResponseParser.Done THEN
      iRecvTaskState := 1000;
    ELSE
      i := iRecvBufferSize - iRecvBufferHead;
      IF i > 0 THEN
        MemCopy(In:=iRecvBuffer[iRecvBufferHead], AryOut:=iRecvBuffer[0], Size:=i);
      END_IF;
      iRecvBufferHead := i;

      IF Context.TLSEstablished THEN
        iSktTLSRead.Handle := Context.TLSHandle;
        iSktTLSRead.Execute := TRUE;
        iSktTLSRead.RcvDatSize := 2000;
        
        Dec(iRecvTaskState);
      ELSE
        iSktTCPRcv.Socket := Context.Socket;
        iSktTCPRcv.Execute := TRUE;
        iSktTCPRcv.Size := 2000;
        
        iRecvTaskState := iRecvTaskState - 2;
      END_IF;
    END_IF;

  // Cancel
  20:
    iSktTCPRcv.Execute := FALSE;
    iSktTLSRead.Execute := FALSE;
    
    Inc(iRecvTaskState);
  21:
    IF NOT (iSktTCPRcv.Busy OR iSktTLSRead.Busy) THEN
      iRecvTaskState := STATE_RECV_TASK_DONE;
    END_IF;
END_CASE;

テストプログラム

Sysmacプロジェクトには以下のテストプログラムがあります。動作には、インターネット接続と名前解決が必要なので、コントローラ設定-内蔵EtherNet/IPポート設定-TCP/IP設定にてゲートウェイとDNSサーバを指定します。

  • POU/プログラム/Test_UnitTest
    単体テストです。シミュレータで実行します。リクエスト生成とレスポンスパーサのテストを行います。デバッグ設定を有効にしているので実機では動作しません。
  • POU/プログラム/Test_HttpEcho
    BeeceptorのHTTPエコーサーバを使用して、いくつかのHTTPメソッドのテストを行います。テスト化していないので、ウォッチウィンドウで変数の値を確認します。

TLSの使用には、コトンローラでのセキュアソケット設定が必要です。セキュアソケット設定はプログラムモードのコントローラにオンラインして操作します。今回のテストプログラムには、IDがゼロでクライアント証明書が無いセッションを使用します。以下のように操作します。

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

コントローラにSysmacプロジェクトを転送して、ウォッチウィンドウを表示、いくつかの変数をモニタした状態で運転モードに切り替えると、以下のようにBeeceptorのHTTPエコーサーバからレスポンスが返ってきます。

Test_HttpEchoのHTTPレスポンスのモニタ
Test_HttpEchoのHTTPレスポンスのモニタ

エラーが発生する場合、以下の可能性があります。自身の管理下にないネットワークからアクセスする場合、ほぼ何らかの制約があります。可能であれば、制約がないことを確認できるテスト用のネットワークを使用します。

  • セキュアソケット設定に不備がある
    IDがゼロになっているか確認します。
  • HTTPエコーサーバが落ちている
    手元の端末からHTTPエコーサーバにpingを打って応答を確認します。
  • インターネットに接続していない、または、名前解決ができない
    tracertでHTTPエコーサーバまでの経路を確認します。

まとめ

ソケット命令は、シリアル通信の延長として素朴な平文によるやり取りに使用することはあると思いますが、HTTPクライアントのようにそれ以外の用途にも十分使用することができます。HTTP自体には触れませんでした。Sysmac StudioやNXは学ぶ為の環境ではなく、使う為の環境です。Sysmac Studioを使ってHTTPについて学んでも苦痛なだけです。HTTPクライアントを実装して使えるということで十分です。もう少し機能と品質を上げたら、どこかのWebサービスにアクセスして実験してみましょう。認証と認可が難所です。

Sysmac StudioとNXを使用してどこまでHTTPクライアントを実装できるかについて、WebSocketは、フレーム処理が必要ですが問題なく実装できます。そして、HTTPクライアントが実装できるのでHTTPサーバも実装できます。ただし、TLSの関係でHTTPSの使用はできないので、実用性は限られます。しかし、WebSocketサーバには利用価値があります。それなりにスループットを出せるのでブラウザで10msオーダーのリアルタイム更新グラフ等面白いものを作れます。HTTP/2については、ALPNを使用できないので実装できません。QUICは異世界の話です。

Discussion