📑

オンプレミスのデータをクラウド経由で取得する方法を考えた

2023/02/12に公開

きっかけ

私が勤めている会社では以下のような構成のシステムをパッケージ販売しています。
パッケージシステム構成
オンプレミス型の構成でクラウドに対応しておりません。
図にある各クライアント領域の専用ソフトですが、なかなかのレガシーな環境で開発しております。
ユーザー各位からは「おたく(私の勤め先のこと)はクラウドに対応しないんですか?」「外出先からもシステムを使いたいのですが」と言われると、営業から伝え聞きます。
時代の潮流に合わせて「すわAWS!!」もしくは「いざGCP!!」「Azure!!」と行きたいところですが、なかなかそうもいかないなか、なんとかオンプレミス領域内(以下、事業所内とする)のデータを、オンプレミス領域の外(以下、外出先とする)から参照できる方法はないかと考え、思いついた内容を示します。

概要

概要図
クラウド上に中継サービスを配置します。
図上の事業所A、事業所Bは各事業所のオンプレミスです。各事業所のサーバ内にLAN内限定のWEBサーバー(以下、ローカルWEBサーバーとする)を配置します。
ローカルWEBサーバーはローカルのデータベースを操作する機能を有するとします。
ローカルWEBサーバーはクラウド上の中継サービスとWebSocket接続します。ローカルWEBサーバー中継サービスがチャットしているようなイメージです。
一方で中継サービスはXMLHttpRequestを受け付けるAPIも持ちます。
外出先の職員はこのAPIにリクエストします。リクエストの内容には中継サービスと接続している自身のローカルWEBサーバーを識別するIDと、ローカルWEBサーバーへのリクエストが含まれています。
中継サービスは該当する事業所とのWebSocketを見つけ出し、WebSocketにリクエストを流します。
ローカルWEBサーバーはWebSocketから流れてきたリクエストに応じた処理を行い(SQLSserverからデータを抽出する等...)、生成したレスポンスをWebSocketを使って中継サービスに返却します。
レスポンスを受けた中継サービスはリクエスト発行元の外出先の職員にレスポンスを返します。

実践

コードはGitHubに全容を公開しております。
GitHub YamaDash82/request-repeater

Socket.IOライブラリを活用しています。

npm install --save sokcet.io socket.io-client

以下で作成したクラスを説明します。

RequestRepeaterクラス

中継サービス側から見たローカルサーバーとの通信を取り扱います。
下図のWebSockets接続をサーバ側から見た部分のイメージです。
RequestRepeaterイメージ

export class RequestRepeater {
  //イベント名
  static repeatRequestEventName = 'repeat-request';
  public readonly organizationId: string;

  constructor (
    private socket: Socket
  ) { 
    if (!("organizationId" in this.socket.handshake.auth)) {
      throw new Error('SocketにorganizationIdパラメーターが存在しません。');
    }

    this.organizationId = this.socket.handshake.auth.organizationId;
    console.log(`RequestRepeater生成:${this.organizationId}`);
  }

  //リクエスト送信処理
  send(parameter: any): Promise<any> {
    const eventName = RequestRepeater.repeatRequestEventName;

    return new Promise((resolve, reject) => { 
      this.socket.emit(
        eventName, 
        parameter, 
        (err: any, res: any) => {
          if (err) return reject(err);

          return resolve(res);
        }
      );
    });
  }
}

プロパティ抜粋

  • socket: Socket
    Socket.ioライブラリが提供するSocketクラスのインスタンスです。
    これを用いてローカルサーバーとの通信を行います。
  • static repeatRequestEventName:string
    socketを使用してローカルサーバーと中継リクエスト処理を行う際のイベント名です。

メソッド抜粋

  • send(parameter: any): Promise<any>
    ローカルサーバーに引数で受けた中継リクエストを送信し、受信したレスポンスを返却するメソッドです。
    return new Promise((resolve, reject) => { 
      this.socket.emit(
        eventName, 
        parameter,                      //送信するリクエスト内容
        (err: any, res: any) => {       //レスポンスを受け取るコールバック
          if (err) return reject(err);
    
          return resolve(res);
        }
      );
    }); 
    
    Socket#emit()メソッドの第三引数のコールバックで、ローカルサーバーからのレスポンスを受信します。

RequestRepeatersControllerクラス

中継サービス側で使用します。
複数のローカルサーバーとの接続(先述のRequestRepeater)を取りまとめ、APIで受け付けたXMLHttpRequestに含まれるリクエストデータを、目的のRequestRepeaterオブジェクトを使用してリクエストに対するレスポンスを受ける処理を行います。
RequestRepeatersControllerイメージ

export class RequestRepeatersController {
  private repeaters: RequestRepeater[] = [];

  constructor() { }

  find(organizationId: string): RequestRepeater | null {
    const found = this.repeaters.find(repeater => {
      return repeater.organizationId === organizationId;
    });

    return found || null;
  }

  add(socket: Socket): void;
  add(repeater: RequestRepeater): void;
  add(arg: Socket | RequestRepeater) {
    if (arg instanceof RequestRepeater) {
      const tempRepeater = arg;

      if (this.repeaters.find(rpt => rpt.organizationId === tempRepeater.organizationId)) {
        //既に接続済の接続要求があった時、接続済例外をスローする。
        throw new AlreadyConnectedException();
      }
  
      this.repeaters.push(tempRepeater);
      
    } else {
      const socket = arg;

      if (!("organizationId" in socket.handshake.auth)) {
        throw new Error('SocketにorganizationIdパラメーターが存在しません。');
      }

      if (this.repeaters.find(repeater => repeater.organizationId === socket.handshake.auth.organizationId)) {
        //既に接続済の接続要求があった時、接続済例外をスローする。
        throw new AlreadyConnectedException();
      }

      this.repeaters.push(new RequestRepeater(socket))
    }
  }

  remove(organizationId: string) {
    //削除するソケットのindexを取得。
    const foundIndex = this.repeaters.findIndex(repeater => repeater.organizationId === organizationId);

    //削除対象のソケットが見つからなければ何もせずに終了する。
    if (foundIndex < 0) return;

    //該当のソケットを削除する。
    this.repeaters.splice(foundIndex, 1);
  }

  async send(
    parameter: RepeatRequest<any>
  ): Promise<any> {
    const foundRepeater = this.repeaters.find(repeater => repeater.organizationId === parameter.organizationId);

    if (!foundRepeater) throw new Error('対象のローカルサーバーが中継サーバーに接続されていません。');

    return await foundRepeater.send(parameter);
  }
}

プロパティ抜粋

  • repeaters: RequestRepeater[]
    複数のRequestRepeaterを持ちます。

メソッド抜粋

  • add(socket: Socket)add(repeater: RequestRepeater)
    repeatersプロパティに追加するメソッドです。
    中継サービスローカルサーバーからWebSockets接続された時に呼び出します。

  • send(parameter: RepeatRequest<any>)
    受け付けた中継リクエストを、目的のRequestRepeaterオブジェクトを見つけてローカルサーバーに送信し、レスポンスを得るメソッドです。
    引数の型であるRepeatRequestは以下です。

    export class RepeatRequest<PT> {
      organizationId!: string;
      handlerId!: string;
      parameter?: PT;
    }
    
    • organizationId
      目的のローカルサーバーを識別するIDです。
      外出先の事業所A職員が中継サービスにリクエストを送信するとき、事業所Aを識別するIDです。
      これにより、事業所A職員のリクエストが事業所Bのローカルサーバーに送信される、というようなことを防ぎます。
    • handlerId
      ローカルサーバーで実行される処理を指定するIDです。
    • parameter
      リクエストに付随して送信するパラメータです。

RepeatRequestReceiver

ローカルサーバー側で使用します。
ローカルサーバーから見た中継サービスとの通信を取り扱います。
RepeatRequestReceiverイメージ

プロパティ抜粋

  • socket: Socket
    socket.io-clientライブラリが提供するSocketクラスのインスタンスです。
    これを利用して中継サービスからのメッセージを受信し、対応するレスポンスを返却します。

  • requestHandlers: AbstractRepeatRequestHandler<any, any>[]
    中継リクエストを受けて実行するハンドラー群です。
    AbstractRepeatRequestHandlerについては後述します。

メソッド抜粋

  • init
    コンストラクタから呼び出される初期化処理です。
    以下を見てください。initメソッドからの抜粋です。
    Socketオブジェクトに中継サーバーからの中継リクエストイベント発火に応じるイベントハンドラーを作成している部分です。
    this.socket.on(tempEventName, async (data: RepeatRequest<any>, callback: (err: any, res: any) => void) => {
    
      //対象のハンドラを検索する。
      const foundHandler = this.requestHandlers.find(handler => {
        return handler.handlerId === data.handlerId;
      });
    
      if (!foundHandler) throw new Error(`handlerIdで指定されたイベントハンドラーが見つかりませんでした。`);
    
      try {
        const res = await foundHandler.getResponse(data.parameter);
        return callback(null, res);
      } catch(err) {
        return callback(err, null);
      }
    });
    
    さらに抜粋して以下の部分を見てください。
    //対象のハンドラを検索する。
    const foundHandler = this.requestHandlers.find(handler => {
      return handler.handlerId === data.handlerId;
    }); 
    
    自身が持つハンドラー群から、中継リクエストに指定されているhandlerIdのハンドラーを見つけます。
    以下の部分で、コールバックを使って中継サービスにハンドラーの結果を返しています。
    const res = await foundHandler.getResponse(data.parameter);
    return callback(null, res); 
    

AbstractRepeatRequestHandler

中継リクエストを受けて実行する処理をクラス化した抽象クラスです。

//抽象中継リクエストイベントハンドラー
//PT:パラメータ型
//RT:返却値型
export abstract class AbstractRepeatRequestHandler<PT, RT> {
  constructor (
    public readonly handlerId: string
  ) { }

  abstract getResponse(parameter: PT): RT | Promise<RT>;
  abstract getResponse(): RT | Promise<RT>;
  abstract getResponse(arg?: any): RT | Promise<RT>;
}

プロパティ

  • handlerId:string
    イベントハンドラーを識別するIDです。

メソッド

  • getResponse(parameter: PT): RT | Promise<RT>getResponse(): RT | Promise<RT>
    実行する処理をこのメソッドにコーディングします。

使用例

Expressフレームワークでの使用例を示します。
当例ではローカル端末でふたつの異なるポートのWEBサーバーを建てて、クラウド側と、ローカル側と見立てています。

  • クラウドに該当:http://localhost:3000
  • ローカルに該当:http;//localhost:8080
    コード全容はGitHubリポジトリのexample/use-expressを参照してください。

中継サーバー(クラウド側)

当記事で作成しているライブラリをGitHubリポジトリからnpmコマンドでインストールします。

npm isntall --save yamadash82/request-repeater

1. サーバーインスタンスの生成

Expressフレームワークと、socket.ioライブラリを利用してサーバーインスタンス、ReqeustRepeatersControllerのインスタンスを生成します。

cloud-server/server.ts
import Express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import { 
  RepeatRequest, 
  RequestRepeatersController, 
  AlreadyConnectedException, 
} from '@yamadash82/request-repeater';

const app = Express();
...

const repeatersController = new RequestRepeatersController(); //←

const httpServer = createServer(app);

const io = new Server(httpServer);

httpServer.listen(3000);

注意
app.listen(3000, () => { })ではありません。

Caution
Using app.listen(3000) will not work here, as it creates a new HTTP server.
https://socket.io/docs/v4/server-initialization/ より引用

2. RequestRepeaerオブジェクトの生成

ソケット接続時にRequestRepeaerオブジェクトを生成します。

cloud-server.ts
io.on('connection', (socket) => {
  try {
    //addメソッドで、socketオブジェクトを元にRequestRepeterを生成しています。
    repeatersController.add(socket);

    //ソケット切断時のイベントハンドラを登録します。
    socket.on('disconnect', (reason) => {
      //RequestRepeatersControllerの保持するRequestRepeater群から該当のRequestRepeaterを除外します。
      repeatersController.remove(socket.handshake.auth.organizationId);
    });
  } catch (err) {
    if (err instanceof AlreadyConnectedException) {
      //既に接続済み状態で、接続要求があった場合の処理。
      console.error(`${err.message}接続を維持します。`);
    } else {
      //それ以外の例外時、切断する。
      socket.disconnect();
      console.log(`サーバサイドでエラー:${err}`);
    }
  }
});

3. 外出先からリクエストを受け付けるルーティングの作成

外出先からローカルサーバーへのリクエストを受け付けるルーティングを作成します。

cloud-server/server.ts
app.post('/request-repeat', async (req, res) => {
  const body: RepeatRequest<any> = req.body;
  
  try {
    //RequestRepeatersController#sendメソッドで、該当のローカルサーバーにリクエストを転送し、レスポンスを取得します。
    const resData = await repeatersController.send(body);

    return res.json(resData);
  } catch(err) {
    return res.json(err instanceof Error ? err.message : 'リクエスト中継処理でエラーが発生しました。');
  }
});

このルーティングに送るデータはRepeatRequest型です。

{
  "organizationId": "organization001", 
  "handlerId": "find-user-info", 
  "parameter": {
    "userId": 1234
  }
}
  • organizationId
    このリクエストデータが送信される先を指定します。
  • handlerId
    ローカルサーバーで処理するハンドラを指定します。
  • parameter
    ローカルサーバーに送信するリクエストに付随するパラメータがある場合、指定します。

ローカルサーバー側

1. ローカルサーバーインスタンスの生成

ローカルサーバーインスタンスと、テスト用ルーティングを作成します。

local-server/server.ts
import Express from 'express';

const app = Express();

//テストデータ
const users: { userId: number, userName: string }[] = [
  { userId: 1, userName: '山田 太郎' }, 
  { userId: 2, userName: '山田 二朗' }, 
  { userId: 3, userName: '山田 花子' }
];

app.get('/fetch-users-list', (req, res) => {
  return res.json(users);
});

app.listen(8080, () => {
  console.log('local server runnning on port:8080.');
});

このコードをビルドして実行し、http://localserver:8080/fetch-users-listにアクセスすると、usersの内容が返されます。Node.jsサーバーにおける通常の処理です。

2. 中継サーバーから中継リクエストがあった際のイベントハンドラの作成

外出先からローカルサーバーへのGetリクエストに対応する例を示します。
AbstractRepeatRequetを継承して作成します。
外出先から中継サーバーを経由して以下のようなリクエストデータが送られてくるとします。

{
  "organizationId": "organization001", 
  "handlerId": "get-request", 
  "parameter": {
    "url": "http://localhost:8080/fetch-users-list"
  }
}

このデータはローカルサーバ内で指定したurlにGetリクエストを発行してレスポンスを得ることを期待しています。
対応する処理を生成します。

local-server/server.ts
import SupserAgent from 'supseragent';

class GetRequestParameter {
  url!: string;
  parameter!: any
}

export class RepeatGetRequestHandler<RT> extends AbstractRepeatRequestHandler<GetRequestParameter, RT> {
  getResponse(parameter: GetRequestParameter): RT | Promise<RT>;
  getResponse(): RT | Promise<RT>;
  async getResponse(parameter?: any): Promise<RT> {
    if (!parameter) throw new Error('GetRequestParameter型のパラメータが設定されていません。');
    
    try {
      const url: string = parameter.url;

      const res = await SuperAgent(url).query(parameter.parameter);
      
      return res.text as RT;
    } catch(err) {
      throw err;
    }
  }
}

XMLHttpRequestの発行にsuperagentライブラリを使用します。
GetRequestParameterRepeatRequest#parameterの型を指定します。
前述の中継リクエストの

{
  ...
  "parameter": {
    "url": "http://localhost:8080/fetch-users-list"
  }
}

"parameter"プロパティの型がGetRequestParameterであることを定義しています。

RepeatGetRequestHandlerは抽象クラスAbstractRepeatRequestHandlerを継承し、getResponseメソッドを実装します。
メソッド内でXMLHttpRequestを発行し、取得したレスポンスデータを返します。
リクエストデータにあるurlパラメータですが、ローカルサーバ内におけるurlを指定しています。

3. ソケット開通時にRequestReceiverオブジェクトの作成とイベントハンドラの登録

local-server/server.ts
let requestReciever: RepeatRequestReceiver;
let socket: Socket;

app.get('/connect', async (req: Express.Request, res: Express.Response, next: Express.NextFunction) => {
  try {
    //中継サーバーとのソケット接続を取得する。
    socket = io('http://localhost:3000', {
      auth: {
        //組織ID
        organizationId: 'organization001'
      }
    });

    await (async () => {
      return new Promise<boolean>((resolve, reject) => {
        socket.on('connect', () => {
          console.log(`接続成功`);
          return resolve(true);
        });
    
        socket.on('connect_error', (err) => {
          console.log(`接続失敗:${err}`);
          socket.disconnect();
          return reject(err);
        });
      })      
    })();

    setTimeout(() => {
      socket.on('disconnect', () => {
        console.log('切断');
      });
    });

    requestReciever = new RepeatRequestReceiver(socket);

    requestReciever.setHandler(new RepeatGetRequestHandler<any>('get-request'));

    res.send('中継サーバーに接続');
  } catch(err) {
    res.send('中継サーバーに接続中にエラーが発生しました。' + (err instanceof Error ? err.message : ''));
  }
});

http://localhost/connectにアクセスしたとき、中継サーバーとSocket接続し、Socketを元にReqeustReceiverオブジェクトを生成します。
Socket接続を取得する部分で、組織IDを記述している箇所、当例ではリテラルでorganization001をセットしていますが、複数のローカルサーバーを接続する場合は、動的に変えるなどして、重複しないようにしてください。
socketの切断時のイベントハンドラを設定している部分ですが、setTimeoutを用いないとイベントが捕捉されませんでした。

//期待した挙動にならなかった例
socket.on('disconnect', () => {
  console.log('切断');
});

//期待した挙動になった例
setTimeout(() => {
  socket.on('disconnect', () => {
    console.log('切断');
  });
});

先述したイベントハンドラクラスを登録している部分です。イベントハンドラ名get-requestを指定して登録します。

requestReciever.setHandler(new RepeatGetRequestHandler<any>('get-request'));

動作確認

GitHubリポジトリの内容で動作確認する手順を示します。

# ディレクトリを移動して実行します。
cd examples/use-express
npm install

# ディレクトリ移動して実行します。
cd examples/use-express/cloud-server
npm install
npm run build
npm start

# 別ターミナルを開き実行します。
cd examples/use-express/local-server
npm install
npm run build
npm start

当方はThunderClientを使用してテストしました。

  1. ローカルサーバーを中継サーバーに接続
    http://localhost:8080/connectにGETリクエストを発行します。
  2. 中継サーバーにPOSTリクエストを発行
    http://localhost:3000/request-repeatにPOSTリクエストで下記内容を送信します。
    {
      "organizationId": "organization001", 
      "handlerId": "get-request", 
      "parameter": {
        "url": "http://localhost:8080/fetch-users-list"
      }
    }
    
    下記のレスポンスが取得できれば成功です。
    "[{\"userId\":1,\"userName\":\"山田 太郎\"},{\"userId\":2,\"userName\":\"山田 二朗\"},{\"userId\":3,\"userName\":\"山田 花子\"}]"
    

当例では同一マシン上での確認ですが、当例のサーバーサイドの部分をrender.comというホスティングサービスにデプロイし、ローカルマシンへの中継ができることを確認しました。後日その部分も当記事にアップしようと思います。

感想等

当初のきっかけに記述した課題をクリアするにはまだまだだです。
実をいうと途中からはこのREST APIとWebSocketsの組み合わせを実装したいという個人的な興味が原動力に変わり、これがなかなかどうして...少し形にできて良かったです(語彙力...)。
Zennへの投稿これが初めてになります。また何か思いついたら投稿させていただこうと思います。

Discussion