オンプレミスのデータをクラウド経由で取得する方法を考えた
きっかけ
私が勤めている会社では以下のような構成のシステムをパッケージ販売しています。
オンプレミス型の構成でクラウドに対応しておりません。
図にある各クライアント領域の専用ソフトですが、なかなかのレガシーな環境で開発しております。
ユーザー各位からは「おたく(私の勤め先のこと)はクラウドに対応しないんですか?」「外出先からもシステムを使いたいのですが」と言われると、営業から伝え聞きます。
時代の潮流に合わせて「すわ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接続をサーバ側から見た部分のイメージです。
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
オブジェクトを使用してリクエストに対するレスポンスを受ける処理を行います。
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
リクエストに付随して送信するパラメータです。
- organizationId
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
のインスタンスを生成します。
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
Usingapp.listen(3000)
will not work here, as it creates a new HTTP server.
https://socket.io/docs/v4/server-initialization/ より引用
RequestRepeaer
オブジェクトの生成
2. ソケット接続時にRequestRepeaer
オブジェクトを生成します。
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. 外出先からリクエストを受け付けるルーティングの作成
外出先からローカルサーバーへのリクエストを受け付けるルーティングを作成します。
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. ローカルサーバーインスタンスの生成
ローカルサーバーインスタンスと、テスト用ルーティングを作成します。
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リクエストを発行してレスポンスを得ることを期待しています。
対応する処理を生成します。
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
ライブラリを使用します。
GetRequestParameter
はRepeatRequest#parameter
の型を指定します。
前述の中継リクエストの
{
...
"parameter": {
"url": "http://localhost:8080/fetch-users-list"
}
}
"parameter"プロパティの型がGetRequestParameter
であることを定義しています。
RepeatGetRequestHandler
は抽象クラスAbstractRepeatRequestHandler
を継承し、getResponse
メソッドを実装します。
メソッド内でXMLHttpRequest
を発行し、取得したレスポンスデータを返します。
リクエストデータにあるurlパラメータですが、ローカルサーバ内におけるurlを指定しています。
3. ソケット開通時にRequestReceiverオブジェクトの作成とイベントハンドラの登録
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を使用してテストしました。
- ローカルサーバーを中継サーバーに接続
http://localhost:8080/connect
にGETリクエストを発行します。 - 中継サーバーに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