AWS LambdaでSocket通信を実装、単体テストするまで


1. Socket通信

皆さんはSocket通信ライフされておりますでしょうか。昨今ではあまり利用されることはなくなったのかも知れませんが、異システム間をネットワーク接続してデータ連係するには手頃に使える方法の1つとして頭の片隅にあっても損はないかも知れません。

2. なぜSocket通信か

皆さんJSONに馴染みはありますでしょうか。
JSONは異システム間で交換するデータ形式として好んで採用される形式の1つです。

{
  "KEY": "これはバリュー",
  "CHILDREN": {"IKURADEMO": "いけるよ", "LUCKY_NUMBER": 777}
}

こんな形をしたフォーマットです。この記事を見ている方であれば99%ご存じのことと思います。
何故、JSONフォーマットが便利なのか。可読性や省スペースといった理由もありますが、大きな理由の1つが「どんな環境でもだいたい扱えるようになっている」という汎用性だと思います。

JavaScriptでもJavaでもObjective-Cでも、大概の言語でパース(JSON形式とローカルの変数の間で相互変換)できる環境が整っているため、JSON形式で送っておけば受け手も容易に取り込んで処理が出来るというメリットがあるのです。

Socket通信にもそんな汎用性が高くあらゆる環境で利活用できる素地があるというメリットがあるのです。
最近はhttpが使えればその方が勝手が良い場合もありますが、ポートを開けばダイレクトに相互通信できるソケット通信は今でも便利な代物なのです。

3. AWS Lambda(Node.js)でSocket通信

時は平成(2020年現在)、時代はクラウドです。
かつてのSocket通信技術はなりを潜めたように見えますがそんな事はありません。既存のシステムとの接続システムをクラウドに再構築したり、Webサーバー不要だったりとちょくちょく登場するのがSocket通信なのです。

みんな大好きAWS Lambda(Node.js)でもこのSocket通信は利用できます。
準備は簡単です。以下はクライアント(アクセスしに行く側)の準備ですが2行で終わりです。

const NET = require('net');
let client = new NET.Socket();

3-1. クライアント編

const NET = require('net');
let client = new NET.Socket();

exports.handler = async (event) => {
  // タイムアウトするまではサーバーからの戻りを待ちます
  await socketConnection();
  client = null;
};

async function socketConnection() {
  const promise = new Promise((resolve) => {
    let result = null;
    client.setEncoding('utf8');

    // データを受け取ったら直ぐに閉じます
    client.on('data', function(data) {
      console.log('client: socket received');
      result = data;
      console.log(result);
      client.end();
    });    

    // 閉じたらresolveします
    client.on('close', function() {
      console.log('client: socket closed');
      client.destroy();
      resolve(result);
    });

    // タイムアウトの設定も出来ます。自前でsetTimeoutからreject()という方法もありますが……
    client.setTimeout(3000);
    client.on('timeout', () => {
      console.log('client: socket timeout');
      client.end();
    });

    // お膳立てしたら接続します。ポートとサーバーアドレスを忘れずに。
    client.connect(9999, 'mysocket.iketeruyonesore.com', function() {
      let data = "test-data";
      client.write(data);
      console.log('client: data sent');
    });
  });
  return promise;
}

詳細については https://nodejs.org/api/net.html こちらでメソッド、プロパティが一望できます。

3-2. サーバー編

もしもNode.jsでSocketサーバーを作るとしたらこんな形になります。

const NET = require('net');

NET.createServer(function(server) {
  console.log(`server: socket server created`);

  server.on('data', function(data){
    console.log(`server: ${data} received from ${server.remoteAddress}:${server.remotePort}`);
    const response = "great!";
    server.write(response);
  });

  server.on('close', function(){
    console.log('server: socket closed');
  });

}).listen(9999);

ポート9999でSocketへの書き込みを待機するようになります。

4. バイナリとアスキー

Socket通信のややこしいところはデータがバイト情報でやり取りされるという点です。
バイナリでは1(数字の1)という情報もASCIIの"1"は31となります。
これらを混在させる事も出来るので電文がどんな構造になっているかしっかり意識してコーディングする必要もあります。

電文の作成にはBufferを使うと便利です。

4-1. Buffer登場

// ヘキサ(これを受け取ってconsole.logに渡しても文字化けする)
const bufferHex = new Buffer.from("0123abcd", "hex");

// ASCII(これを受け取ってconsole.logに渡せば"0123abcd"と再現される)
const bufferAsc = new Buffer.from("0123abcd", "ascii");

4-2. 2つのBufferを繋げる

// 両者をくっつけて1つの電文にする事もできる
const bufferCon = Buffer.concat([bufferHex, bufferAsc]);

4-3. 2バイトごとにひとかたまりに

// 2バイトごとにひとかたまりにして扱いたい場合は便利なメソッドもある
const bufferByte = Buffer.alloc(2).writeInt16BE(123, 2 * 0);

こうしたメソッドを駆使して双方のシステム間で取り決めた電文フォーマットを組み立てたり、ほどいたりしながら情報を交換するというわけです。

5. クライアントのテスト方法(Lambdaトリック)

Lambdaはサーバーレスですので、IPアドレスが固定されておらず基本的にLambdaに直接アクセスすることは出来ません。
API Gatewayを仲介するとその時点でREST APIということになりますし、EC2経由にしてしまったらEC2上にSocketサーバーを起動した方が手っ取り早いという話になります。

しかしながら、Socketクライアントのテストをしたい場合なんらかのSocketサーバーを対向に用意する必要があります。そこで便利な方法があります。同じLambda内でテスト用のSocketサーバーを起動する方法です。
前述の通りSocketクライアントはasync/await/Promiseによってサーバーからのレスポンスを待機するようになっていますので、クライアントから接続する前にサーバープロセスを起動することにより自己完結するテスト環境を構築できるのです。

非同期にてサーバーを先に起動して置いて……クライアントは同期処理で通信を全うするまで処理を待機させる。
これで、快適なSocket開発生活を送れるようになります。

const NET = require('net');
let client = new NET.Socket();


exports.handler = async (event) => {

  // ----------------------------------------
  // サーバーを起動しておく
  // ----------------------------------------
  NET.createServer(function(server) {
    console.log(`server: socket server created`);

    server.on('data', function(data){
      console.log(`server: ${data} received from ${server.remoteAddress}:${server.remotePort}`);
      const response = "great!";
      server.write(response);
    });

    server.on('close', function(){
      console.log('server: socket closed');
    });

  }).listen(9999);

  // タイムアウトするまではサーバーからの戻りを待ちます
  await socketConnection();
  client = null;
};

async function socketConnection() {
  const promise = new Promise((resolve) => {
    let result = null;
    client.setEncoding('utf8');

    // データを受け取ったら直ぐに閉じます
    client.on('data', function(data) {
      console.log('client: socket received');
      result = data;
      console.log(result);
      client.end();
    });    

    // 閉じたらresolveします
    client.on('close', function() {
      console.log('client: socket closed');
      client.destroy();
      resolve(result);
    });

    // タイムアウトの設定も出来ます。自前でsetTimeoutからreject()という方法もありますが……
    client.setTimeout(3000);
    client.on('timeout', () => {
      console.log('client: socket timeout');
      client.end();
    });

    // ----------------------------------------
    // お膳立てしたら接続します。接続先は自分自身!
    // ----------------------------------------
    client.connect(9999, '127.0.0.1', function() {
      let data = "test-data";
      client.write(data);
      console.log('client: data sent');
    });
  });
  return promise;
}