LoginSignup
1
1

More than 1 year has passed since last update.

WebSocketを使ってAPIを作ってみた

Last updated at Posted at 2022-01-18

なぜ作ったのか

大学の授業で何かシステムを作れとの課題が出たから(グループワーク)

実行環境

開発言語: node.js
使用したDB: MySQL
サーバ: AWS(EC2 t2.micro)

仕様書を書こう

複数人でチームを組んでやる授業だったので,仕様書を書かなければ情報が共有できません.という事で仕様書を書きます.APIの仕様書は以前クライアントのプログラムを作っていた時にデザインが良かったslateを使いました.
今回作成したAPIでは,JSON-RPC形式のメッセージをやり取りする形で通信を行います.(ただし,様々な事情によりエラー時のレスポンスが本来のJSON-RPCの形式になっていません.)
image.png
あと,クライアントのデータ等はサーバにあるDB(MySQL)に保存しておくことにします.

データベース

データベースはquickdbdというサービスを使ってみました.ER図を書けばテーブル定義書もデータベースを作るSQL文も自動で出力してくれるので便利でした.ただし,日本語を使えないのがちょっと難点.
image.png

実装

WebSocketによる通信

基本的には,RPCの形式でクライアントからメッセージを受け取って,解析,呼び出し関数の実行及び結果の返却という流れで行います.
サーバのコードを書いてみます.この例では,localhost:8889にサーバを立てています.

server.js
const server = require('ws').Server;
const ws = new server({ port: 8889 });
const api = require('./api.js');
const db = require('./db.js');
let uuid4 = require('uuid4');

ws.on('connection', sock => {

    sock.on("message", msg => {
        console.log("=============================");
        console.log("msg from arrived");

        msg = api.jsonParser(msg);      // if can not parse msg, msg == false
        msgId = api.getMsgID(msg);      // if id not included, msgId == -1

        console.log("msg: " + JSON.stringify(msg));
        //if msg can not parse, return errorMsg(400 bad req)
        if (msg == false) {
            api.errorSender(sock, "400", msgId);
        } else {
            result = api.methodExecuter(sock, msg, msgId);
        }

    });

    sock.on("close", () => {
        console.log("============================");
        console.log("disconnected");
    });
});

/**
 * 1秒ごとにトークンを監視し, 有効期限が切れたトークンを削除する.
 * @function
 */
let tokenManager = setInterval(async () => {
    mute();
    const currentTime = Math.round((new Date()).getTime() / 1000);
    const query_deleteToken = `delete from auth_token where expiry < ${currentTime}`;
    let delInfo = await db.queryExecuter(query_deleteToken);
    let affectedRows = delInfo[0].affectedRows;
    unmute();
    if(affectedRows > 0){
        console.log("================tokenManager=================");
        console.log("token deletions: " + affectedRows);
    }
}, 1000);

/**
 * 1秒ごとに予約情報を監視し, 予約終了時間が過ぎた予約情報のis_expiredをtrueにする.
 * @function
 */
let reservationManager = setInterval(async () => {
    mute();
    const currentTime = Math.round((new Date()).getTime() / 1000);
    const query_expireReservation = `update reservation set is_expired=1 where time_end < ${currentTime} and is_expired=0`;
    let changeInfo = await db.queryExecuter(query_expireReservation);
    let affectedRows = changeInfo[0].affectedRows;
    unmute();
    if(affectedRows > 0){
        console.log("================reservationManager=================");
        console.log("reservation expires: " + affectedRows);
    }
}, 1000);


var ___log = console.log;
function mute() {
    console.log = function(){};
}
/**
 * Un-silences console.log
 */
function unmute() {
    console.log = ___log;
}

api.jsでは,メッセージを各呼び出し関数に振り分ける処理などを行っています.

api.js
exports.jsonParser = jsonParser;
exports.errorSender = errorSender;
exports.warnSender = warnSender;
exports.resultSender = resultSender;
exports.getMethodName = getMethodName;
exports.methodExecuter = methodExecuter;
exports.getMsgID = getMsgID;
exports.isNotSQLInjection = isNotSQLInjection;
exports.isObjectEmpty = isObjectEmpty;

const method = require("./methods.js");


/**
 * メソッドが存在し, 実行が成功すれば結果をクライアントに送信する. 
 * 実行に失敗した場合は各メソッド内でクライアントにエラーメッセージを送信する.
 * @param {ws.sock} sock socket
 * @param {JSONObject} msg JSONデータ 
 * @param {JSONObject} msgId メッセージのID 
 * @returns {false} エラーの場合のみ返却する.
 */
async function methodExecuter(sock, msg, msgId){
    //get msg.method, ignore method is valid or not here.
    methodName = getMethodName(msg);
    console.log("methodName: " + methodName);

    //get msg.params, if no params=>err
    params = paramParser(msg);
    if(params == false){
        errorSender(sock, "453", msgId);
        return false;
    }

    let result;

    if(methodName == "register/user"){
        result = await method.registerUser(params, sock, msgId);
        console.log("result-register/user");
        console.log(result);

    }else if(methodName == "register/restaurant"){
        result = await method.registerRestaurant(params, sock, msgId);
        console.log("result-register/restaurant");
        console.log(result);

    }else if(methodName == "register/admin"){
        result = await method.registerAdmin(params, sock, msgId);
        console.log("result-register/admin");
        console.log(result);

    }else if(methodName == "login"){
        result = await method.login(params, sock, msgId);
        console.log("result-login");
        console.log(result);

    }else if(methodName == "logout"){
        result = await method.logout(params, sock, msgId);
        console.log("result-logout");
        console.log(result);

    }......//中略
     ......  

    }else if(methodName == "ping"){
        result = await method.pong(params, sock, msgId);
        console.log("pong");
        console.log(result);

    }else{
        // no valid method found
        console.log("404 - not found");
        errorSender(sock, "404", msgId);
        return false;
    }

    if(result != false){
        resultSender(sock, result, msgId);
    }
}


/**
 * 受信したメッセージが正しくJSONに変換できるか
 * @param {string} msg 受信したメッセージ
 * @returns {false | JSONObject} JSONに変換できるならメッセージが, 変換できないならfalseが返却される
 */
 function jsonParser(msg){
    try{
        parsedMsg = JSON.parse(msg);
    }catch(error){
        if(error instanceof SyntaxError){
            console.log("400 - bad request");
            return false;
        }
        return false;
    }
    console.log("valid json");
    return parsedMsg;
}

/**
 * パラメータが存在するかチェックし, 存在するならオブジェクトを返す
 * @param {JSONObject} msg JSON message
 * @param {ws.sock} errSock 
 * @returns {JSONObject | false} メッセージにパラメータが含まれていない->false, else -> パラメータのJSONデータ
 */
 function paramParser(msg){
    if(msg.hasOwnProperty("params") == false){
        return false;
    }

    return msg.params;
}

/**
 * 呼び出し関数名を返却する.
 * @param {JSONObject} msg JSONデータ
 * @returns {boolean | string} 存在しない->false, 存在する->メソッド名を返却する
 */
 function getMethodName(msg){
    console.log(msg);
    if(msg.hasOwnProperty("method") == false){
        return false;
    }

    return msg.method;
}

/**
 * メッセージのIDを返却する.
 * @param {JSONObject} msg message 
 * @returns {int | string} メッセージのID, 無ければ-1が返却される
 */
function getMsgID(msg){
    if(msg.hasOwnProperty("id") == false){
        return -1;
    }

    return msg.id;
}


/**
 * エラーが発生した場合にクライアントにエラー情報を返却
 * @param {ws.sock} sock socket
 * @param {string} reason エラーメッセージ
 * @param {string | int} id メッセージのID
 */
 function errorSender(sock, reason, id){
    let errMsg = {
        "jsonrpc": "2.0",
        "id": id,
        "result": {
            "status": "error",
            "reason": reason
        }
    }
    sock.send(JSON.stringify(errMsg));
}

/**
 * クライアントに警告を送信する.
 * @param {ws.sock} sock socket
 * @param {string} reason エラーメッセージ
 * @param {string | int} id メッセージのID
 */
function warnSender(sock, reason, id){
    let warnMsg = {
        "jsonrpc": "2.0",
        "id": id,
        "result": {
            "status": "warn",
            "reason": reason
        }
    }

    sock.send(JSON.stringify(warnMsg));
}

/**
 * クライアントに実行結果を返却する
 * @param {ws.sock} sock socket
 * @param {JSONObject} result 実行結果
 * @param {string | int} msgId メッセージのID
 */
function resultSender(sock, result, msgId){
    let msg = {
        "jsonrpc": "2.0",
        "id": msgId,
        "result": result
    }

    sock.send(JSON.stringify(msg));
}

/**
 * SQLインジェクションの可能性のある文字列を検出する.
 * @param {string} param SQL文を組み立てる際に使用する各パラメータ
 * @returns {boolean} false->SQLインジェクションの可能性がある, true->正当な文字列
 */
function isNotSQLInjection(param){
    let checkStrs = ["=", "<", ">", ";", "'", "*", "?", "|"];

    //if input = false = number, return true;
    if(isNaN(param) == false){
        return true;
    }

    for(let i = 0; i < checkStrs.length; i++){
        if(param.indexOf(checkStrs[i]) != -1){
            return false;
        }
    }
    console.log("param has no problem");
    return true;
}

/**
 * オブジェクトが空かどうか判定する
 * @param {Object} obj 判定したいオブジェクト
 * @returns {boolean} true->空, false->空でない
 */
function isObjectEmpty(obj) {
    for (var key in obj) {
      if (Object.prototype.hasOwnProperty.call(obj, key)) {
        return false;
      }
    }
    return true;
}

methods.jsで,呼び出し関数の処理を書いていきます.基本的にはクライアントから受け取ったメッセージのパラメータのバリデーションを行って,DBをごちゃごちゃして結果を返す処理になっています. (これ,書いていて超巨大ファイルになってしまったので分けた方が良かった...と後になって思いました())
あと,DBの設計が適当だったので,本来なら書かなくていいコードを書かないといけなかったのも反省です.

methods.js

exports.registerUser = registerUser;
・・・ //中略
exports.pong = pong;

const api = require("./api.js");
const db = require("./db.js");
const sha256 = require("crypto-js/sha256");
const uuid4 = require('uuid4');
const { appendFile } = require("fs");
/**
 * 利用者アカウント登録APIを実行する. パラメータ不足などのエラーがあればクライアントに
 * エラーメッセージを送信し, 関数はfalseを返却する. 
 * @param {JSONObject} params メッセージに含まれていたパラメータ
 * @param {ws.sock} errSock エラー時に使用するソケット
 * @param {int | string} msgId メッセージに含まれていたID
 * @returns {false | JSONObject} 実行が成功 -> JSONObject, else -> false
 */
async function registerUser(params, errSock, msgId) {
    let requiredParams = ["user_name", "password"];
    if(checkParamsAreEnough(params, requiredParams, errSock, msgId) == false){
        return false;
    }

    if (api.isNotSQLInjection(params.user_name) == false) {
        api.errorSender(errSock, "params.user_name contains suspicious character, you can not register such name", msgId);
        return false;
    }


    //get usernames from db;
    let query_getUserNames = "select * from user;";
    console.log("getName qry: " + query_getUserNames);
    let res = await db.queryExecuter(query_getUserNames);
    if (res == false) {
        console.error("error on query executer");
        api.errorSender(errSock, "error while reading user", msgId);
        return false;
    } else {
        res = res[0];
    }

    let userName = params.user_name;
    let password = sha256(params.password);
    console.log("password: " + password);
    let maxId = 0;
    //search name duplication
    for (let i = 0; i < res.length; i++) {
        if (res[i].user_name == userName) {
            console.log("username dupulicated: " + userName);
            api.errorSender(errSock, "user_name has already taken by other user", msgId);
            return false;
        }
        if (maxId < res[i].user_id) {
            maxId = res[i].user_id;
        }
    }

    query_insertUser = `insert into user(user_id, user_name, birthday, gender, email_addr, address, password, num_vicious_cancels) values (${maxId + 1}, '${userName}', '1900/01/01', 'no gender set', 'no email_addr set', 'no address set', '${password}', 0)`;
    console.log("insert qry: " + query_insertUser);
    res = await db.queryExecuter(query_insertUser);

    if (res == false) {
        console.error("error while inserting data");
        api.errorSender(errSock, "error while inserting data", msgId);
        return false;
    }

    let result = {
        "status": "success",
    }

    return result;
}

・・・・ //中略

/**
 * pingに対してメッセージを返却するAPI
 * @param {Object} params メッセージに含まれていたパラメータ
 * @param {ws.sock} errSock エラー時に使用するソケット
 * @param {int | string} msgId メッセージに含まれていたID
 * @returns {Object} 常にObjectが返却される.
 */
async function pong(params, errSock, msgId){
    return result = {
        "status": "success",
        "pong": "pong"
    }
}


/**
 * 時間(HH:MM)の形式が正しいか判断する. 正しくなければクライアントにエラーを送信する.
 * @param {string} time パラメータの時間
 * @param {ws.sock} errSock エラー時に使用するソケット
 * @param {int | string} msgId メッセージに含まれていたID
 * @returns {boolean} 正しい->true, 正しくない->false
 */
function checkTimeSyntax(time, errSock, msgId){
    let splitTime = time.split(':');
    if(splitTime.length != 2){
        api.errorSender(errSock, "invalid time syntax", msgId);
        return false;
    }
    for(let i = 0; i < splitTime.length; i++){
        if(splitTime[i].length != 2){
            api.errorSender(errSock, "invalid time syntax", msgId);
            return false;
        }
    }
    if(splitTime[0] >= 24 || splitTime[0] < 0){
        api.errorSender(errSock, "invalid time syntax(hour is out of range)", msgId);
        return false;
    }
    if(splitTime[1] >= 60 || splitTime[0] < 0){
        api.errorSender(errSock, "invalid time syntax(minute is out of range)", msgId);
        return false;
    }
    return true;
}

/**
 * YYYY/MM/DDの形式が正しいか判断する. 正しくなければエラーを送信する.
 * @param {string} yyyymmdd YYYY/MM/DD
 * @param {ws.sock} errSock エラー時に使用するソケット
 * @param {int | string} msgId メッセージに含まれていたID
 * @returns {false | Array} 正しい->splitedArgument(array), 正しくない->false
 */
function checkYYYYMMDDSyntax(yyyymmdd, errSock, msgId){
    let splitYYYYMMDD = yyyymmdd.split('/');
    if(splitYYYYMMDD.length != 3){
        api.errorSender(errSock, "invalid format of YYYY/MM/DD", msgId);
        return false;
    }
    if(splitYYYYMMDD[0].length != 4 || splitYYYYMMDD[1].length != 2 || splitYYYYMMDD[2].length != 2){
        api.errorSender(errSock, "invalid format of YYYY/MM/DD", msgId);
        return false;
    }

    //if not number
    if (isNaN(splitYYYYMMDD[0]) || isNaN(splitYYYYMMDD[1]) || isNaN(splitYYYYMMDD[2])) {
        api.errorSender(errSock, "invalid format of YYYY/MM/DD", msgId);
        return false;
    }

    //year
    if (splitYYYYMMDD[0] < 1900) {
        api.errorSender(errSock, "invalid format of YYYY/MM/DD(year is too old)", msgId);
        return false;
    }
    //month
    if (splitYYYYMMDD[1] > 12 || splitYYYYMMDD[1] < 1) {
        api.errorSender(errSock, "invalid format of YYYY/MM/DD(month is out of range)", msgId);
        return false;
    }
    //day
    if (splitYYYYMMDD[2] > 31 || splitYYYYMMDD[2] < 1) {
        api.errorSender(errSock, "invalid format of YYYY/MM/DD(day is out of range)", msgId);
        return false;
    }

    return true;

}

/**
 * パラメータが十分かチェックする
 * @param {Object} params メッセージに含まれていたパラメータ
 * @param {string[]} checkParamNames チェックするパラメータの配列
 * @param {ws.sock} errSock エラー時に使用するソケット
 * @param {int | string} msgId メッセージに含まれていたID
 * @returns {boolean} true->パラメータが全て含まれている. false->パラメータが足りない
 */
function checkParamsAreEnough(params, checkParamNames, errSock, msgId){
    let numErr = 0;
    for(let i = 0; i < checkParamNames.length; i++){
        if(params.hasOwnProperty(checkParamNames[i]) == false){
            api.errorSender(errSock, `param ${checkParamNames[i]} is not included`, msgId);
            numErr++;
        }
    }

    if(numErr != 0){
        return false;
    }
    return true;
}

/**
 * トークン情報をチェックする.
 * @param {string} token トークンの文字列(UUID)
 * @param {ws.sock} errSock エラー時に使用するソケット
 * @param {int | string} msgId メッセージに含まれていたID
 * @returns {false | Object} false->エラー, object->成功
 */
async function checkToken(token, errSock, msgId){
    let query_getTokenInfo = `select * from auth_token where token_id = '${token}'`;
    let tokenInfo = await db.queryExecuter(query_getTokenInfo);
    tokenInfo = tokenInfo[0][0];
    if(api.isObjectEmpty(tokenInfo)){
        api.errorSender(errSock, "401", msgId);
        return false;
    }
    console.log(tokenInfo);
    return tokenInfo;
}

db.jsでは,データベース(MySQL)にクエリを投げて結果を受け取る処理を行います.

db.js

const mysql = require('mysql2/promise');

const dbConfig = {
    host: "localhost",
    user: "ゆーざ名",
    password: "ぱすわーど",
    database: "でーたべーす名"
}

/**
 * SQL文を実行して結果を返却する
 * @param {Object} query 実行するSQL文
 * @returns {string} 実行結果
 */
exports.queryExecuter = async function queryExecuter(query){
    console.log("query: " + query);
    const pool = await mysql.createPool(dbConfig);
    let result;
    try{
        //console.log(connection);
        console.log("connect");
        result = await pool.execute(query);

    }catch(err){
        console.log("error on queryExecuter: " + err);
        result = false;
    }finally{
        await pool.end();
    }
    return result;
}

ログイン/ログアウト, 認証関連

今回,自分達のグループで作成したシステムではAPI実行にトークンによる認証を使う事にしたので,まずはこれを実装します.このトークンにアカウントIDやら権限,失効時間などを紐づけて管理します.トークンは衝突しにくくて予測できない良い感じのUUIDを使うことにしました.

例えば,auth_tokenテーブルにこれらを格納することにして,
auth_token
- token_id: "トークン(UUID)の文字列"
- token_issuer_id: "トークンの発行者ID"
- token_permission: "トークンの権限"
- expiry: "トークンが失効する時間(timestamp)"
のように管理しています.

ログイン時にユーザ名とパスワードが一致していればトークンを発行します.その後,各情報へアクセスするとき(API実行時)にトークンをリクエストメッセージに添付しておくことでAPIの実行許可が得られるようにしておきます.

おわりに

あれ,これWebSocketじゃなくてもよかったんじゃね?
確かにレスポンスが速かった気はしないでもないけど,クライアントがAndroidアプリケーションだったのでライブラリが少ない関係で若干不便でした()
あと開発期間が1か月と短かったので,所々適当な感じが多くてすいません(言い訳)
コード全体などはgithubにあげてあります.
この記事の趣旨からは外れますが,一番難しかったのはチームで開発することでした.
責任感の無い奴が多すぎてブチ切れそうでした.
何もやらないのにミーティングだけ参加して適当な事ばっかり言う奴,分からないからと言って何もしない奴(調べすらしない),自分でクライアントはandroidアプリ開発やろうと言ったのに全く調べもせず提出間際に私に言語仕様やら超基本的な事項を聞いてくる奴etc, 君たち3年間一体何をやってたのと思いました.
工学部でこの有様なのでジョブ型採用なんて一生流行らないと思います.

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1