2020/04/05

Typescriptでsocketio

背景


仕事でTypescriptを使用する機会が出てきたので、使い方を調査した

記事の目的


TypescriptでSocketIO通信を実装する

Typescript


ここでは、Typescriptについて記載する。

Typescriptとは

Typescript

Typescriptはマイクロソフトによって開発され、メンテナンスされているフリーでオープンソースのプログラミング言語である。TypeScriptはJavaScriptに対して、省略も可能な静的型付けとクラスベースオブジェクト指向を加えた厳密なスーパーセットとなっている。C#のリードアーキテクトであり、DelphiとTurbo Pascalの開発者でもあるアンダース・ヘルスバーグがTypeScriptの開発に関わっている。TypeScriptはクライアントサイド、あるいはサーバサイド (Node.js) で実行されるJavaScriptアプリケーションの開発に利用できる。

利点

  • 型チェックが強力で柔軟であるため、型の間違いによるバグを抑制できる
  • VSCodeなどで入力補完が利用でき、JSDocでコメントを作成しておくとドキュメントが不要

テンプレートのパッケージ構成

Typescriptでnodeモジュールを作成するための最小構成
$ tree
.
├── dist                   #トランスパイル済みJavascriptファイルの格納先
├── node_modules           # npm installでインストールした依存関係のあるnodeモジュールの格納先
├── package-lock.json      # npm installでインストールしたnodeモジュール情報(自動で生成される)
├── package.json           # このモジュールの情報
├── param                  # パラメータファイルの格納先(オプショナル)
│   └── log-config.json   # log4js(ロガーモジュール)の設定ファイル
├── src                    # ソースファイルの格納場所
│   └── app.ts            # mainのTypescriptファイル
├── test                   # テストコードの格納場所
│   └── app.test.ts       # app.tsのテストコード
├── tsconfig.json          # Typescriptの設定ファイル
└── tslint.json            # ts-lint(Typescriptのlintツール)の設定ファイル

package.json

テンプレートのpackage.jsonファイルを記載する。
{
  "name": "sample_app",(mainのファイル名)
  "version": "1.0.0",(バージョン)
  "description": "This is sample code",(モジュールの説明)
  "main": "dist/app.js",(mainファイル)
  "directories": {(ディレクトリ構成)
    "src": "src",
    "dist": "dist",
    "param": "param",
    "test": "test"
  },
  "scripts": {(スクリプトの設定)
    "start": "npm run build && npm run watch", (ビルドして実行)
    "build": "npm install && npm run build-ts && npm run tslint",(ビルド)
    "serve": "nodemon dist/src/index.js",(サービスとして実行)
    "watch": "npm install concurrently && concurrently -k -p \"[{name}]\" -n \"Typescript,Node\" -c \"yellow.bold,cyan.bold,green.bold\" \"npm run watch-ts\" \"npm run serve\"",(ビルドしつつウォッチ)
    "test": "npm run build && jest --forceExit --detectOpenHandles",(テストコード実行)
    "test-ci": "jest --coverage --forceExit --runInBand",(テストコード実行し、コードカバレッジを表示)
    "build-ts": "tsc",(Typescriptのビルド)
    "watch-ts": "tsc -w",(Typescriptのウォッチ)
    "tslint": "tslint -c tslint.json -p tsconfig.json"(ts-lintを実行)
  },
  "author": "EmptySet",(作成者)
  "license": "MIT",(ライセンス)
  "devDependencies": {(依存モジュール、バージョン。npm install -Dでインストールされる)
    "@babel/core": "*",
    "@babel/preset-env": "*",
    "@babel/preset-react": "*",
    "@babel/preset-typescript": "*",
    "@types/jest": "*",
    "@types/uuid": "^7.0.2",
    "async-mutex": "^0.1.4",
    "babel-jest": "*",
    "jest": "^25.2.6",
    "log4js": "^6.1.2",
    "socket.io": "^2.3.0",
    "socket.io-client": "^2.3.0",
    "ts-jest": "^25.3.0",
    "tslint": "6.1.0",
    "typescript": "3.8.3"
  },
  "dependencies": {(依存モジュール、バージョン。npm installでインストールされる)
    "@types/express": "^4.17.4",
    "@types/jest": "^25.1.5",
    "@types/node": "^13.9.5",
    "@types/socket.io": "^2.1.4",
    "@types/socket.io-client": "^1.4.32",
    "express": "^4.17.1",
    "jest-cli": "^25.2.6",
    "log4js": "6.1.2",
    "tsc": "^1.20150623.0",
    "uuid": "^7.0.2"
  },
  "jest": {(jest(テストツール)の設定)
    "transform": {
      "^.+\\.ts$": "ts-jest"
    },
    "testMatch": [
      "**/test/**/*.test.ts"
    ],
    "moduleFileExtensions": [
      "ts",
      "js"
    ],
    "globals": {
      "ts-jest": {
        "tsConfig": "tsconfig.json"
      }
    }
  }
}

tsconfig.json

tsconfig.jsonファイルを記載する。
{
    "compilerOptions": {
        "module": "commonjs",(モジュールコードのバージョン)
        "target": "es2017",(ECMAScriptのバージョン)
        "noImplicitAny": true,(Any型を許容しない)
        "moduleResolution": "node",(構造の単位)
        "sourceMap": true,(tsファイルとjsファイル間のマップファイルを作成)
        "outDir": "./dist",(ビルド後のファイルの格納先)
        "baseUrl": ".",(ソースファイルのベースのパス)
        "rootDir":"./src",(ルートディレクトリ)
        "paths": {(パスの定義)
            "*": [
                "node_modules/*",
                "src/*",
                "test/*"
            ]
        }
    },
    "include": [(includeするファイル)
        "src/**/*"
    ]
}

tslint.json

tslint.jsonファイルを記載する。
{
    "rules": {
        "class-name": true,
        "comment-format": [
            true,
            "check-space"
        ],
        "indent": [
            true,
            "spaces"
        ],
        "one-line": [
            true,
            "check-open-brace",
            "check-whitespace"
        ],
        "no-var-keyword": true,
        "quotemark": [
            true,
            "double",
            "avoid-escape"
        ],
        "semicolon": [
            true,
            "always",
            "ignore-bound-class-methods"
        ],
        "whitespace":[
            true,
            "check-branch",
            "check-decl",
            "check-operator",
            "check-module",
            "check-separator",
            "check-type"
        ],
        "typedef-whitespace": [
            true,
            {
                "call-signature": "nospace",
                "index-signature": "nospace",
                "parameter": "nospace",
                "property-declaration": "nospace",
                "variable-declaration": "nospace"
            },
            {
                "call-signature": "onespace",
                "index-signature": "onespace",
                "parameter": "onespace",
                "property-declaration": "onespace",
                "variable-declaration": "onespace"
            }
        ],
        "no-internal-module": true,
        "no-trailing-whitespace": true,
        "no-null-keyword": true,
        "prefer-const": true,
        "jsdoc-format": true
    }
}

/src/app.ts

app.tsを記載する。
/**
 * SocketIO sample
 * @summary This is socketIO sample code (get rtt)
 * @author EmptySet
 * @version 1.0
 * @todo Something
 */

// ロガーモジュール
import * as Logger from "log4js";
Logger.configure("./param/log-config.json");
// SocketIOのモジュール
import * as socketio_client from "socket.io-client";
import * as socketio from "socket.io";
// UUID作成モジュール
import * as UUID from "uuid";
// Expressモジュール(サーバアプリ)
import * as express from "express";
// HTTPサーバモジュール
import * as http from "http";

// データの型を設定
/**
 * Header in data
 */
interface Header {
    "type": string;
    "uuid": string;
    "version": number;
    "time_stamp": string;
}
/**
 * Body in data
 */
interface Body {
    "time_stamp": Array<number>;
}

/**
 * Data format
 */
export interface Data {
    "header": Header;
    "body": Body;
}

/**
 * SocketIOクライアントクラス
 */
class Client {
    // ロガー
    protected logger: Logger.Logger;
    // SocketIOクライアント
    protected io: SocketIOClient.Socket;
    // SocketIOのパラメータ
    protected url: string;
    protected port: number;
    protected token: string;
    protected namespace: string;
    // 接続状態フラグ(true: 接続, false: 未接続)
    protected is_connection: boolean;

    // コンストラクタ
    /**
     * Set parameters of socketIO
     * @param url Server URL (e.g. azure.com)
     * @param port Port number (e.g. 80)
     * @param token Token in query
     * @param namespace Socket namespace
     */
    constructor(url: string, port: number, token: string, namespace: string) {
        // ロガー名を設定
        this.set_logger("client");
        // クライアントのパラメータを設定
        this.url   = url;
        this.port  = port;
        this.token = token;
        this.namespace = namespace;
        // 接続状態を初期化
        this.is_connection = false;
    }

    /**
     * Check connection status
     * @param class_name Please set class name
     */
    set_logger(class_name: string) {
        this.logger = Logger.getLogger(class_name);
    }

    /**
     * Check connection status
     * @return Connect(true) or not(false)
     */
    protected check_connection(): boolean {
        return this.is_connection;
    }

    /**
     * Set connection status
     * @param flg Connect(true) or not(false)
     */
    protected set_connection(flg: boolean) {
        this.is_connection = flg;
    }

    /**
     * Connect to server
     */
    protected connect() {
        this.logger.info("Try connect to server");
        // 接続重複時に、前の接続を切断
        if (this.check_connection()) {
            this.logger.warn("Connection is duplicated. Disconnect from old connection.");
            this.disconnect();
        }
        // サーバーに接続
        const uri: string = `http://${this.url}:${this.port}/${this.namespace}`;
        try {
            this.io = socketio_client.connect(uri, {
                transports: ["websocket"],
                forceNew: true,
                reconnection: true,
                query: {
                    token: this.token
                }
            });
        } catch (e) {
            this.logger.error(`Cannot connect to server ${e}`);
        }
    }

    /**
     * Disconnect from server
     */
    protected disconnect() {
        this.logger.info("Disconnect from server");
        // サーバーから切断
        try {
            this.io.disconnect();
        } catch (e) {
            this.logger.error(`Cannot disconnect from server. ${e}`);
        } finally {
             this.set_connection(false);
        }
    }

    /**
     * Run client module.
     * Connect to server and set events
     */
    async run () {
        this.connect();

        this.io.on("connect", (socket: socketio.Socket) => {
            this.set_connection(true);
            this.logger.info(`Connected to server. id: ${this.io.id}`);
            // 接続したソケットに対して、コールバックイベントを設定
            this.set_event(socket);
        });
        // 切断イベントを設定
        this.io.on("disconnect", (socket: socketio.Socket) => {
            this.set_connection(false);
            this.logger.info(`Disconnected from server. id: ${this.io.id}`);
        });
    }
    /**
     * Set callback events
     * @param socket socket instance
     */
    async set_event(socket: socketio.Socket) {}
}
/**
 * SocketIO server class
 */
export class Server {
    // ロガー名を設定
    protected logger: Logger.Logger;
    // SocketIOサーバー関連
    protected server: http.Server;
    protected io: SocketIO.Server;
    protected nsp_1: SocketIO.Namespace;
    protected nsp_2: SocketIO.Namespace;
    // SocketIOサーバーのパラメータ
    protected url: string;
    protected port: number;
    protected token: string;
    protected namespace: Array<string>;
    // 接続数
    protected number_of_connection: number;

    // コンストラクタ
    /**
     * Set parameters of socketIO
     * @param url Server URL (e.g. azure.com)
     * @param port Port number (e.g. 80)
     * @param token Token in query
     * @param namespace Socket namespace
     */
    constructor(url: string, port: number, token: string, namespace: ArrayArray<string>) {
        // ロガーを設定
        this.set_logger("server");
        // パラメータを設定
        this.url   = url;
        this.port  = port;
        this.token = token;
        this.namespace = namespace;
        // 接続数を設定
        this.number_of_connection = 0;
        // サーバーを設定
        const app: express.Express = express();
        this.server = http.createServer(app);
        this.io = socketio(this.server, {
            pingInterval: 1000,
            pingTimeout: 5000
        });
    }

    /**
     * Check connection status
     * @param class_name Please set class name
     */
    protected set_logger(class_name: string) {
        this.logger = Logger.getLogger(class_name);
    }

    /**
     * Check connection status
     * @return Connect(true) or not(false)
     */
    protected check_connection(): number {
        return this.number_of_connection;
    }

    /**
     * Add number of connection
     */
    protected add_connection() {
        this.number_of_connection ++;
        this.logger.debug(`Number of connection is ${this.number_of_connection}`);
    }

    /**
     * Add number of connection
     */
    protected remove_connection() {
        if (this.number_of_connection == 0) {
            this.logger.error("Number of connection is invalid");
            return;
        }
        this.number_of_connection --;
        this.logger.debug(`Number of connection is ${this.number_of_connection}`);
    }

    protected listen() {
        this.server.listen(this.port, this.url);
    }

    /**
     * Disconnect from server
     */
    protected disconnect(socket: socketio.Socket) {
        this.logger.info("Disconnect from server");
        // 切断処理
        try {
            socket.disconnect();
            this.logger.info(`Disconnected from server. (ID: ${socket.id})`);
        } catch (e) {
            this.logger.error(`Cannot disconnect from server. ${e}`);
        }
    }

    /**
     * Run server module.
     * Listen and set events
     */
    async run () {
        this.listen();
        // 接続イベント
        this.io.on("connect", (socket: socketio.Socket) => {
            this.logger.info(`Connected to client. id: ${socket.id}`);
            this.add_connection();
        });
        // 切断イベント
        this.io.on("disconnect", (socket: socketio.Socket) => {
            this.logger.info(`disconnected from client. id: ${socket.id}`);
            this.remove_connection();
        });
        // 独自のイベントを設定
        this.set_event();
    }
    /**
     * Set callback events
     * @param socket socket instance
     */
    async set_event() {
  // Namespace: this.namespace[0]のイベント設定
        this.io.of(this.namespace[0]).on("connect", (socket: socketio.Socket) => {
            this.logger.info(`Connected to client. id: ${socket.id}`);
            // this.namespace[0]のその他イベント設定
            socket.on("ping_", (msg: Data) => {
                this.logger.info(`Get ping. msg: ${JSON.stringify(msg)}`);
                const data: Data = msg;
                // 時刻取得
                const d = new Date();
                data.header.time_stamp = d.toISOString();
                data.body.time_stamp.push(d.getTime() / 1000);
                this.io.of(this.namespace[1]).json.emit("ping_", data);
            });
        });
  // Namespace: this.namespace[1]のイベント設定
        this.io.of(this.namespace[1]).on("connect", (socket: socketio.Socket) => {
            this.logger.info(`Connected to client. id: ${socket.id}`);
            // this.namespace[1]のその他イベント設定
            socket.on("pong_", (msg: Data) => {
                this.logger.info(`Get pong. msg: ${JSON.stringify(msg)}`);
                const data: Data = msg;
                // 時刻取得
                const d = new Date();
                data.header.time_stamp = d.toISOString();
                data.body.time_stamp.push(d.getTime() / 1000);
                this.io.of(this.namespace[0]).json.emit("pong_", msg);
            });
        });
    }
}
// Ping送信クラス(Clientクラスを継承)
/**
 * Ping class
 */
export class Ping extends Client {
    private socket: socketio.Socket;
    /**
     * Emit ping
     */
    ping() {
        this.logger.info("Ping");
        // 接続状態をチェック
        if (!this.check_connection()) {
            this.logger.warn("Has not connect to server.");
            return;
        }
        // 時刻を取得
        const d = new Date();
        // 送信するデータを定義
        const data: Data = {
            header: {
                type: "PingPong",
                time_stamp: d.toISOString(),
                uuid: UUID.v4(),
                version: 0
            },
            body: {
                time_stamp: [
                    d.getTime() / 1000
                ]
            }
        };
        // データを送信
        try {
            this.io.compress(true).emit("ping_", data);
            this.logger.info("Emit ping data");
            this.logger.debug(`Ping data: ${JSON.stringify(data)}, ${this.io.nsp}`);
        } catch (e) {
            this.logger.error(`Cannot emit pong. ${e}`);
        }
    }
    // pongデータを受信し、RTTを算出
    /**
     * Get pong data and calculate RTT
     * @param msg Pong message
     */
    protected get_rtt(msg: Data): number {
        const data: Data = msg;
        // 時刻を算出
        const d = new Date();
        data.header.time_stamp = d.toISOString();
        data.body.time_stamp.push(d.getTime() / 1000);
        // RTTを算出
        const rtt: number = data.body.time_stamp[data.body.time_stamp.length - 1] - data.body.time_stamp[0];
        this.logger.info(`RTT: ${rtt} [s]`);
        return rtt;
    }
    /**
     * Set callback events
     * @param socket socket instance
     */
    async set_event (socket: socketio.Socket) {
        // ロガー名を設定
        this.socket = socket;
        this.io.on("pong_", (msg: Data) => {
            this.get_rtt(msg);
        });
    }
}
// Pong返信クラス(Clientクラスを継承)
/**
 * Pong class
 */
export class Pong extends Client {
    /**
     * Get ping message and emit pong message
     * @param msg Ping message
     */
    protected pong(msg: Data, socket: socketio.Socket) {
        this.logger.info("Pong");
        const data: Data = msg;
        // 時刻を取得
        const d = new Date();
        data.header.time_stamp = d.toISOString();
        data.body.time_stamp.push(d.getTime() / 1000);
        // Pongデータを送信
        try {
            this.io.compress(true).emit("pong_", data);
            this.logger.info("Emit pong data");
            this.logger.debug(`Pong data: ${data}`);
        } catch (e) {
            this.logger.error(`Cannot emit pong. ${e}`);
        }
    }
    /**
     * Set callback events
     * @param socket socket instance
     */
    async set_event (socket: socketio.Socket) {
        // ロガー名を設定
        this.io.on("ping_", (msg: Data) => {
            this.pong(msg, socket);
        });
    }
}

// Sleep関数
/**
 * Sleep function
 * @param milliseconds Sleep time [ms]
 */
function sleep(milliseconds: number) {
    return new Promise<void>(resolve => {
      setTimeout(() => resolve(), milliseconds);
    });
  }

// main関数(async: 非同期)
async function main() {
    const logger: Logger.Logger = Logger.getLogger("system");
    const server = new Server("localhost", 10000, "secret", ["ping", "pong"]);
    server.run();
    // await: 同期待ち
    await sleep(1000);
    const ping = new Ping("localhost", 10000, "secret", "ping");
    ping.set_logger("ping");
    ping.run();
    await sleep(1000);
    const pong = new Pong("localhost", 10000, "secret", "pong");
    pong.set_logger("pong");
    pong.run();

    await sleep(1000);
    ping.ping();
}

exports.main = main;

if (require.main == module) {
    main();
}

/test/app.test.ts

テストコードapp.test.tsを記載する。
/**
 * SocketIO sample test
 * @summary This is socketIO sample test code (get rtt)
 * @author EmptySet
 * @version 1.0
 * @todo Something
 */

// app.tsをインポート
import * as rtt from "../src/app";
// ロガーのインポート
import * as Logger from "log4js";
Logger.configure("./param/log-config.json");

const logger = Logger.getLogger("test");

describe("Ping class module test", () => {
    const ping = new rtt.Ping("localhost", 10000, "secret", "ping");
    it("Set parameters", () => {
        expect(ping["url"]).toBe("localhost");
        expect(ping["port"]).toBe(10000);
        expect(ping["token"]).toBe("secret");
        expect(ping["namespace"]).toBe("ping");
    });
    it("Check connection status function", () => {
        expect(ping["check_connection"]()).toBe(false);
        ping["set_connection"](true);
        expect(ping["check_connection"]()).toBe(true);
        ping["set_connection"](false);
        expect(ping["check_connection"]()).toBe(false);
    });
    it("Check rtt calculation function", () => {
        const d = new Date();
        const data = {
            header: {
                type: "PingPong",
                time_stamp: "2020-04-01T00:00:00.000Z",
                uuid: "test",
                version: 0
            },
            body: {
                time_stamp: [
                    d.getTime() / 1000,
                    1500000000,
                    1500000001
                ]
            }
        };
        expect(ping["get_rtt"](data)).toBeLessThanOrEqual(0.01);
    });
});

実行時

$npm run start
...
10:39:17 PM - Starting compilation in watch mode...
[Typescript] 
[Node] [nodemon] 2.0.2
[Node] [nodemon] to restart at any time, enter `rs`
[Node] [nodemon] watching dir(s): *.*
[Node] [nodemon] watching extensions: js,mjs,json
[Node] [nodemon] starting `node dist/src/index.js dist/src/app.js`
[Node] [2020-04-05T22:39:19.023] [INFO] ping - Try connect to server
[Node] [2020-04-05T22:39:19.066] [INFO] server - Connected to client. id: 9tfP7UDHqlCblBzRAAAA
[Node] [2020-04-05T22:39:19.067] [DEBUG] server - Number of connection is 1
[Node] [2020-04-05T22:39:19.076] [INFO] server - Connected to client. id: /ping#9tfP7UDHqlCblBzRAAAA
[Node] [2020-04-05T22:39:19.077] [INFO] ping - Connected to server. id: /ping#9tfP7UDHqlCblBzRAAAA
[Node] [2020-04-05T22:39:20.041] [INFO] pong - Try connect to server
[Node] [2020-04-05T22:39:20.044] [INFO] server - Connected to client. id: wTjYvamapXXZV1yDAAAB
[Node] [2020-04-05T22:39:20.045] [DEBUG] server - Number of connection is 2
[Node] [2020-04-05T22:39:20.046] [INFO] server - Connected to client. id: /pong#wTjYvamapXXZV1yDAAAB
[Node] [2020-04-05T22:39:20.047] [INFO] pong - Connected to server. id: /pong#wTjYvamapXXZV1yDAAAB
[Node] [2020-04-05T22:39:21.044] [INFO] ping - Ping
[Node] [2020-04-05T22:39:21.046] [INFO] ping - Emit ping data
[Node] [2020-04-05T22:39:21.046] [DEBUG] ping - Ping data: {"header":{"type":"PingPong","time_stamp":"2020-04-05T13:39:21.044Z","uuid":"cb6087b4-68d1-447a-b4d2-8e1271b9f735","version":0},"body":{"time_stamp":[1586093961.044]}}, /ping
[Node] [2020-04-05T22:39:21.048] [INFO] server - Get ping. msg: {"header":{"type":"PingPong","time_stamp":"2020-04-05T13:39:21.044Z","uuid":"cb6087b4-68d1-447a-b4d2-8e1271b9f735","version":0},"body":{"time_stamp":[1586093961.044]}}
[Node] [2020-04-05T22:39:21.050] [INFO] pong - Pong
[Node] [2020-04-05T22:39:21.051] [INFO] pong - Emit pong data
[Node] [2020-04-05T22:39:21.051] [DEBUG] pong - Pong data: [object Object]
[Node] [2020-04-05T22:39:21.051] [INFO] server - Get pong. msg: {"header":{"type":"PingPong","time_stamp":"2020-04-05T13:39:21.050Z","uuid":"cb6087b4-68d1-447a-b4d2-8e1271b9f735","version":0},"body":{"time_stamp":[1586093961.044,1586093961.048,1586093961.05]}}
[Node] [2020-04-05T22:39:21.052] [INFO] ping - RTT: 0.00800013542175293 [s]
[Typescript] 
...

まとめ


  • TypescriptでSocketIOの通信モジュールを実装し、nodeで実行する方法を調査、記載した

参考文献



変更履歴


  1. 2020/04/05: 新規作成

0 件のコメント:

コメントを投稿

MQTTの導入

背景 IoTデバイスの接続環境構築のため、MQTT(mosquitto)の導入を行った。 記事の目的 MQTT(mosquitto)をUbuntuに導入する mosquitto ここではmosquittoについて記載する。 MQTT MQTT(Message Qu...