WebSocket APIのフレームワークを作ってみた(Node.js)

# はじめに
REST APIはexpressとかフレームワークやライブラリが充実していて，簡単にAPIサーバが立てられます．
しかし，WebsocketでAPIを作ろうとするとそう簡単にはできません．(結構面倒です)
実際，[工期が短いのにWebsocketでAPIを作ってしまった](https://qiita.com/rubbishmt/items/9bfccb5bd1aca3bb7b24)ことがあり大変でした．
そこで，何とかexpressライクにAPIを簡単に実装できるような物があったらと思って今回このフレームワークを作成しました．

## 開発環境
Node.js

## 使用した技術とか
- websocket
- jsonrpc

# 解説
## インターフェース
メッセージの送受信にはJSON-RPCという規格を採用しました．
その名前の通り，サーバにある関数をクライアントからJSON形式のフォーマットで呼び出すような規格です．
jsonrpcの例は以下の通りです.

```
---> {"jsonrpc": "2.0", "id": 0, "method": "sum", "params": {"num": [1, 2]}}
<--- {"jsonrpc": "2.0", "id": 0, "result": 3}
```
詳しくは[ここ](https://www.jsonrpc.org/specification)

## ルーティング(らしきもの)
REST形式のAPIのフレームワーク(express)では特定のURIにアクセスしたときの挙動をこんな感じで設定することができます．
```
app.get("/specified/path", (req, res) => { do.something() });
```
これをルーティングと言います．
今回開発したフレームワークでは，この「特定のパスに対する挙動の設定」を「特定の呼び出し関数名に対する挙動の設定」と置き換えて，こんな感じでルーティング？できるようにしました．

``` routerExample.js
const api = require('websocket-jsonrpc-api-server');
const portNum = 8888;
api.startServer(portNum);  
const router = api.router;

//引数reqはclass JsonRpcのインスタンス
router.bindRoute("get/nHoge", (req) => { 
  const params = req.getParams();
  let n = 0;
  
  if(params.hasOwnProperty('n')){
    try{
      n = Number(params.n);
    }catch(e){
      throw 'params.n should be number';
    }
  }else{
    throw 'field n should be contained into params';  //when error occurred, send error message to throw exception
  }
  
  let hoges = "";
  for(let i = 0; i < n; i++){
    hoges += "hoge";
  }
  
  return hoges;  //return to send message as success
});
```

## Subscription
今までの説明はREST APIで良くあるような, request/response形式のAPIの再発明みたいなものです．
Websocketの旨味は手軽に双方向通信を行う事ができるという点です．
そこで，このフレームワークでは，サーバ側が任意のタイミングでクライアントにデータを送信できるようなものを用意しました．
例えば，センサーデータの値が更新されるごとにクライアントに送信する，サーバの時間を定期的にクライアントに送信する等．
このフレームワークでは，このような任意のタイミングでクライアントにデータを(一斉)送信する呼び出し関数をSubscriptionと呼び，上で解説したrequest/response形式のものをRouteと呼ぶことにしました．
APIにSubscriptionを設定するには，こんな感じに書きます．

```  subscriptionExample.js
//(登録した)クライアントに定期的(ミリ秒)に関数を実行し，結果を一斉送信する.
router.bindSubscriptionByInterval("get.currentTime.by.interval", () => {
  const currentTime = Date.now().toString();
  return {"time": currentTime};
}, 1000);

//イベントが発火した時に関数を実行し, (登録した)クライアントに実行結果を一斉送信する
const { EventEmitter } = require('events');
const event = new EventEmitter();
router.bindSubscriptionByEvent("get.currentTime.by.event", () => {
  const currentTime = Date.now().toString();
  return {"time": currentTime};
}, event)

setInterval( () => {
  event.emit('result');  //routerに登録したsubscriptionの関数を実行させる.
}, 10);

setTimeout( () => {
  //routerにbindしたsubscriptionに登録しているクライアントに任意のメッセージを一斉送信する.
  event.emit('notice', 'subscription.get.currentTime.by.event is deleted');  
  router.unbindSubscription("get.currentTime.by.event");  //delete subscription from router
}, 300000);

```

また，SubscriptionをサポートするためにデフォルトのRoute(特定のSubscriptionに登録，特定のSubscriptionを解除，全てのSubscriptionを解除)が用意されています．この時, 呼び出し関数名は"register/subscriptions", "delete/subscriptions", "delete/allSubscriptions"となっています.
例えば，
```
---> {"jsonrpc": "2.0", "id": 1, "method": "register/subscriptions", "params": {"subscriptions": ["subscription.get.currentTime.by.interval"]}}
<--- {"jsonrpc": "2.0", "id": 1, "result": "accepted."}
<--- {"jsonrpc": "2.0", "method": "get.currentTime.by.interval", "result": {"time": "1647349214755"}}
<--- {"jsonrpc": "2.0", "method": "get.currentTime.by.interval", "result": {"time": "1647349215755"}}
<--- {"jsonrpc": "2.0", "method": "get.currentTime.by.interval", "result": {"time": "1647349216755"}}
<--- {"jsonrpc": "2.0", "method": "get.currentTime.by.interval", "result": {"time": "1647349217755"}}
<--- {"jsonrpc": "2.0", "method": "get.currentTime.by.interval", "result": {"time": "1647349218755"}}
<--- {"jsonrpc": "2.0", "method": "get.currentTime.by.interval", "result": {"time": "1647349219755"}}
---> {"jsonrpc": "2.0", "id": 2, "method": "delete/subscriptions", "params": {"subscriptions": ["subscription.get.currentTime.by.interval"]}}
//or {"jsonrpc": "2.0", "id": 2, "method": "delete/allSubscriptions", "params": ""}
<--- {"jsonrpc": "2.0", "id": 2, "result": "accepted."}

```
こんな感じで使います.

# 例
具体例として，このフレームワークを使って超簡易なチャットAPIを作ってみました．
```  main.js
const deployPort = 9999;

const { EventEmitter } = require('events');
const wjs = require('websocket-jsonrpc-api-server');

const logger = wjs.logger;
const logFile = logger.logFile;
const errLogFile = logger.errLogFile;
const sendLog = logger.sendLog;

//start logging, to write log, this is required
logger.enableLogger();

//start server on your port
wjs.startServer(deployPort);

//get router
const router = wjs.router;

const Chat = require('./chat.js').Chat;
let chats = [];

/**
 * reqest should be jsonrpc
 * params should be like 
 * "params": {
 *      "n": 15
 * }
 */
router.bindRoute("get/recent", (req) => {
    const params = req.getParams();
    let n = 10;
    
    if(params.hasOwnProperty('n')){
        n = params.n;
    } 

    n = Number(n);
    const recents = chats.slice(-n);

    let res = [];
    for(let i = 0; i < recents.length; i++){
        res.push(recents[i].toString());
    }

    return {"chats": res};
});



const chatEvent = new EventEmitter();
/**
 * request should be jsonrpc
 * params should be like 
 * "params": {
 *      "talk": "something to talk to chat"
 * }
 */
router.bindRoute("post/chat", (req) => {
    const params = req.getParams();
    const requester = req.getRequesterId();
    const time = new Date();
    console.log("date: " + time);

    let chat;
    if(params.hasOwnProperty('talk')){
        if(typeof(params.talk) == "string"){
            chat = new Chat(time, requester, params.talk);
        }else{
            throw 'params.talk should be string';
        }
    }else{
        throw 'field talk should be constained into params';
    }

    chats.push(chat);
    console.log(chats);
    chatEvent.emit('result');
    return "accepted.";
});

//when new chat pushed, broadcast latest to all clients
router.bindSubscriptionByEvent("chat", () => {
    return chats[chats.length-1].toString();
}, chatEvent);

```

```  chat.js
class Chat{
    /**
     * @constructor
     * @param {timestamp} time - instance of Date
     * @param {string} requester - requester name
     * @param {string} talk - content
     */
    constructor(time, requester, talk){
        this.time = time;
        this.requester = requester;
        this.talk = talk;
    }

    toString = function(){
        const dt = this.time;
        let tstr = `${dt.getHours()}:${dt.getMinutes()}:${dt.getSeconds()}`;
        let str = `${tstr}: ${this.requester} - ${this.talk}`;
        console.log(str);
        return str;
    }
}

exports.Chat = Chat;
```

この例は超簡易なので，本当にこのフレームワークを使う必要があるかは疑問ですが，もっと凝ればsubscriptionを動的に生成してチャットルームを作ったり消したりなんかもできるかと思います．

# おわりに
今回作成したフレームワーク(websocket-jsonrpc-api-server)は[npm](https://www.npmjs.com/package/websocket-jsonrpc-api-server), [github](https://github.com/Nikkei225Futures/websocket-jsonrpc-api-server) に公開しています.
(追記): v2.3.0でWSS(WebSocket over SSL/TLS)に対応しました．