🎮

C++とPythonでオンラインランキングシステムを一から作る

2023/08/09に公開1

はじめに

昨今のゲームはMobile/Pc/etc...とPlatform関わらず、オンラインユーザーデータ/ランキング/リアルタイム対戦/etc...と何らかの方法でネットワークにアクセスしています。今や繋がっていることが当たり前になっていますが、いざゲーム制作に実装しようとなった際にどう実装したら良いか分からない人も多いと思います。最近は情報も増えてきており、UnityやUnrealEngineならNetcode for GameObjectsMultiplayerが公式からリリースされており、公式リファレンスやプロダクトで使った実例の技術ブログ等日本語でもかなりの量の情報がありますが、ネイティブ開発で一から実装している情報は英語でもあまりみないように思います。
そこで今回はサーバーとの通信方法の解説をしながら、実際に通信を行ってスコアを送信するオンラインランキングシステムを、クライアント(アプリケーション)をC++/サーバーをPythonで実装してみたいと思います。
今回、よくあるクラウドものは使わず、Socket通信で、HTTP方式でDBAPIと通信を行い読み書きする方式にしました。ネットワーク/DBの知識を得ることが目的なので、SSL/TLSなどの暗号化やserialize(cereal)などの容量削減はしていません。

概要

  • Windows 10
  • サーバー
    • Python 3.9.11
      • requests
  • クライアント
    • C++ 20(Visual Studio 2022)
      • nlohmann_json
      • strconv

完全なソースはこちらにあります。
https://github.com/shirokuma1101/online-ranking-system-sample

仕様

GET Method

長いので隠してます
  • request
    http://192.168.1.x or http://192.168.1.x?limit=-1

  • response

    {
      "1": {
        "log_time": "xxx",
        "uuid": "xxxx-xxxx-xxxx-xxxx",
        "user_name": "xxx",
        "score": xxx
      },
      "2": {
        "log_time": "yyy",
        "uuid": "yyyy-yyyy-yyyy-yyyy",
        "user_name": "yyy",
        "score": yyy
      },
      "3": {
        "log_time": "zzz",
        "uuid": "zzzz-zzzz-zzzz-zzzz",
        "user_name": "zzz",
        "score": zzz
      },
      "4": {
        "log_time": "www",
        "uuid": "wwww-wwww-wwww-wwww",
        "user_name": "www",
        "score": www
      }
    }
    
  • request
    http://192.168.1.x?limit=3

  • response

    {
      "1": {
        "log_time": "xxx",
        "uuid": "xxxx-xxxx-xxxx-xxxx",
        "user_name": "xxx",
        "score": xxx
      },
      "2": {
        "log_time": "yyy",
        "uuid": "yyyy-yyyy-yyyy-yyyy",
        "user_name": "yyy",
        "score": yyy
      },
      "3": {
        "log_time": "zzz",
        "uuid": "zzzz-zzzz-zzzz-zzzz",
        "user_name": "zzz",
        "score": zzz
      }
    }
    
  • request
    http://192.168.1.x?uuid=xxxx-xxxx-xxxx-xxxx

  • response

    {
      "1": {
        "log_time": "xxx",
        "uuid": "xxxx-xxxx-xxxx-xxxx",
        "user_name": "xxx",
        "score": xxx
      }
    }
    

POST

長いので隠してます
  • request
    http://192.168.1.x
    • raw
      POST http://192.168.1.x
      HOST 192.168.1.x
      Content-Length: x
      Contetn-Type: "json/application"
      {

      "uuid": "xxxx-xxxx-xxxx-xxxx",
      "user_name": "xxx",
      "score": xxx
      }
  • response
    ""

準備

それぞれ導入方法は省略します。

Python

python 3.9.xをインストールしてください。(多分最新版python 3.11.4でも問題ないと思います)
インストール後は以下を実行してモジュールをインストールしておいてください。
pip install requests

C++

HTTP通信を行う際にjson形式でやり取りを行うので、C++は外部ライブラリを導入する必要があります。今回はnlohmann_jsonを利用しました。
https://github.com/nlohmann/json
また、エラー内容を取得するために文字コードの変換をする必要があるので、こちらのライブラリも導入します。
https://github.com/javacommons/strconv

サーバーの作成

前述したとおりサーバー側はPythonで実装します。

1. ファイル作成

orsapiserver.pyというファイル名で作成します。

2. モジュールのインポート

orsapiserver.py
# standard
import datetime
import json
import os
import sqlite3
import urllib.parse
from wsgiref.simple_server import make_server

今回、APIServerにはwsgirefsimple_serverを利用しています。

3. DB操作クラスの実装

orsapiserver.py
# online ranking system database
class ORSDB:

の中に書いていきます。

orsapiserver.py
# constants
DB_NAME = 'ors.db'
TABLE_NAME = 'ors'
KEY_LIST = ['log_time', 'uuid', 'user_name', 'score']
# queries
CREATE_NEW_TABLE       = f'CREATE TABLE {TABLE_NAME}(log_time TEXT, uuid TEXT, user_name TEXT, score INTEGER)'
INSERT_NEW_SCORE       = f'INSERT INTO {TABLE_NAME}(log_time, uuid, user_name, score) VALUES (?, ?, ?, ?)'
UPDATE_SCORE           = f'UPDATE {TABLE_NAME} SET log_time = (?), score = (?) WHERE uuid = (?)'
SEARCH_BY_UUID         = f'SELECT * FROM {TABLE_NAME} WHERE uuid = (?)'
COMPARE_SCORES_BY_UUID = f'SELECT * FROM {TABLE_NAME} WHERE score <= (?) AND uuid = (?)'
TOP_RANKING            = f'SELECT * FROM {TABLE_NAME} ORDER BY score DESC LIMIT (?)'
MY_RANKING             = f'SELECT * FROM(SELECT *, RANK() OVER(ORDER BY score DESC) AS ranking FROM {TABLE_NAME}) WHERE uuid = (?)'

ここでは定数としてクエリ(コマンドみたいなもの)をあらかじめ登録しておきます。クエリ中に変数を挿入したい場合は(?)とすることで可能になります。文字列formatでいう{}みたいなものです。データベースを触ったことない人はクエリがよくわからないと思いますが、ここではそれぞれの解説はしません。どういった動作するかは変数名をみて察してください。

orsapiserver.py
def _execute(self, query, params=()) -> list:
    with sqlite3.connect(self.DB_NAME) as conn:
        cur = conn.cursor()
        cur.execute(query, params)
        conn.commit()
        res = cur.fetchall()

    return res

def _get_log_time(self) -> str:
    return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')

まずPrivate(非公開)メソッドから紹介します。PrivateといってもPythonにPrivateは無い[1]ので先頭にアンダースコア(アンダーバー)を付けて使わないでねって視覚的に表します[2]。(なので実はアクセスできてしまいます。)

_execute()ではクエリをそのまま実行しているだけです。cur.fetchall()で実行結果をlistとして返してくれます。ちなみにの引数paramsNoneを渡してしまうと例外スローになってしまうので注意してください。

_get_log_time()は現在時刻を%Y-%m-%d %H:%M:%Sのフォーマットとして文字列で返すだけです。

orsapiserver.py
def write_new_score(self, uuid: str, user_name: str, score: int) -> None:
    # get log time
    log_time = self._get_log_time()
    # check if uuid exists
    if (self._execute(self.SEARCH_BY_UUID, [uuid])):
        # check if score is higher than the previous one
        if (self._execute(self.COMPARE_SCORES_BY_UUID, [score, uuid])):
            # update score
            self._execute(self.UPDATE_SCORE, [log_time, score, uuid])
    else:
        # insert new score
        self._execute(self.INSERT_NEW_SCORE, [log_time, uuid, user_name, score])

write_new_score()は新しいスコアをDB書き込みます。引数としてuuiduser_namescoreがあります。uuidを渡し、SEARCH_BY_UUIDで一度検索し、レコードが存在しない場合はINSERT_NEW_SCOREを、存在する場合は前回のスコアとCOMPARE_SCORES_BY_UUIDで比較し、前回のスコアよりも高かったらUPDATE_SCOREでスコアを更新しています。

orsapiserver.py
def get_top_ranking(self, limit: int) -> dict:
    # get ranking
    ranking = self._execute(self.TOP_RANKING, [limit])

    # convert list to dict
    ## (log_time, uuid, user_name, score) -> {ranking: {log_time, uuid, user_name, score}}
    if ranking:
        sorted_ranking = {}
        for i, e in enumerate(ranking, 1):
            sorted_ranking[i] = dict(zip(self.KEY_LIST, e))
        return sorted_ranking

    return {}

get_top_ranking()はトップlimitの情報を辞書として返します。引数limitに-1を渡すと全ての情報を取得できます。リストはソートされた状態で返されるので、enumerateでインデックスをランキングとしてそのままkeyにしています。コメントでも書いている通り変換している部分のフォーマットはこのようになります。

[
  ["xxx","xxx","xxx","xxx"],
  ["yyy","yyy","yyy","yyy"]
]

{
  "1": {
    "log_time": "xxx",
    "uuid": "xxx",
    "user_name": "xxx",
    "score": "xxx"
  },
  "2": {
    "log_time": "yyy",
    "uuid": "yyy",
    "user_name": "yyy",
    "score": "xxx"
  }
}

辞書にはkeyに整数型を指定できますが、jsonではkeyには文字列しか指定できないため、load時に勝手に変換してくれます。

orsapiserver.py
def get_my_ranking(self, uuid: str) -> dict:
    ranking = self._execute(self.MY_RANKING, [uuid])

    # convert list to dict
    ## (log_time, uuid, user_name, score, ranking) -> {ranking: {log_time, uuid, user_name, score}}
    if ranking:
        return {str(ranking[0][-1]): dict(zip(self.KEY_LIST, ranking[0]))}

    return {}

get_my_ranking()は自分のランキング情報を辞書として返します。受け取ったリストは二次元配列のままなので、0番目と添え字アクセスして取得しています。

orsapiserver.py
def reset_ranking(self) -> None:
    # delete database file
    try:
        os.remove(self.DB_NAME)
    except FileNotFoundError:
        pass
    # create new table
    self._execute(self.CREATE_NEW_TABLE)

reset_ranking()はランキングをリセットします。処理的にはファイルを再生成しているだけです。

4. APIServerクラスの実装

orsapiserver.py
# online ranking system api server
class ORSAPIServer:

の中に書いていきます。

orsapiserver.py
def __init__(self, orsdb: ORSDB, host: str = 'localhost', port: int = 5000):
    self.orsdb = orsdb
    self.host = host
    self.port = port

コンストラクタです。引数としてORSDBのインスタンスとホスト名とポートを渡します。

orsapiserver.py
def _app(self, environ, response) -> list:

    header = [
        ('Access-Control-Allow-Origin', '*'),
        ('Access-Control-Allow-Headers', 'Content-Type'),
        ('Access-Control-Allow-Methods', 'GET, POST'),
    ]

    request_method = environ.get('REQUEST_METHOD')

    # GET
    if request_method == 'GET':
        query_string = environ.get('QUERY_STRING')
        if query_string:
            # parse query string
            qs = urllib.parse.parse_qs(query_string)

            # get my ranking
            uuid = qs.get('uuid')
            if uuid:
                res = self.orsdb.get_my_ranking(uuid[0])

            # get top ranking
            limit = qs.get('limit')
            if limit:
                # get top ranking
                res = self.orsdb.get_top_ranking(int(limit[0]))
        else:
            # get all ranking
            res = self.orsdb.get_top_ranking(-1)

        # convert dict to json
        res = json.dumps(res).encode('utf-8')
        # set header
        header.append(('Content-Type', 'application/json; charset=utf-8'))
        header.append(('Content-Length', str(len(res))))
        # set status
        status = '200 OK'

        # send response
        response(status, header)
        return [res]

    if request_method == 'POST':
        # get request body
        wsgi_input = environ.get('wsgi.input')
        if wsgi_input is None:
            response('400 Bad Request', header)
            return []

        # parse request body
        req = json.loads(wsgi_input.read(int(environ.get('CONTENT_LENGTH', 0))).decode('utf-8'))
        if req:
            uuid = req.get('uuid')
            user_name = req.get('user_name')
            score = req.get('score')
            if uuid and user_name and score:
                # write new score
                self.orsdb.write_new_score(uuid, user_name, score)
                response('200 OK', header)
                return []

        response('400 Bad Request', header)
        return []

APIの実際の処理になります。headerではヘッダーを指定します。

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers

情報を取得したい場合はGETメソッド、情報を書き込みたい場合はPOSTメソッドを使用します。
GETメソッドでは、URLクエリで条件を指定できるようにしています。また、POSTメソッドでは今回はapplicaction/json形式でmessagebodyにjsonを送信しています。受け取った情報をパースしてそれぞれの関数を呼び出しているだけです。

orsapiserver.py
def start(self) -> None:
    with make_server(self.host, self.port, self._app) as httpd:
        print(f'Serving on {self.host}:{self.port}...')
        httpd.serve_forever()

start()ではサーバーを開始します。引数をホスト名、ポート番号、サーバー側の処理を書いた関数の順に代入します。ブロッキングなので、これを呼び出した行以降はサーバーが終了するまで呼び出されませんので注意してください。

5. 実行

orsapiserver.py
def main():

    db = ORSDB()
    db.reset_ranking()
    ors_api_server = ORSAPIServer(db, '192.168.1.x')
    ors_api_server.start()


if __name__ == '__main__':
    main()

最後にmain()を呼び出すようにしてサーバー側の実装は完了です。

RequestCLIApplication

curlでそのままAPIを呼び出してもいいのですが、テスト時に面倒くさいのでPythonでCLIApplicationも実装します。

orsapiserverrqestmethod.py
# standard
import json
import uuid

# request
import requests

# debug
from pprint import pprint


# constants
DEFAULT_URL = 'http://192.168.1.x:5000'


def request(url, method, params=None) -> dict:
    if method == 'GET':
        return json.loads(requests.get(url).text)
    elif method == 'POST':
        return requests.post(url, json=params)


def make_request() -> dict:
    # get url
    print(f'if empty, use default url. ({DEFAULT_URL})')
    url = input('Input url: ')
    if not url:
        url = DEFAULT_URL

    # get request method
    print('1. GET')
    print('2. POST')
    print('if empty, use default method. (GET)')
    method = input('Select request method: ')
    if method == '1' or not method:
        method = 'GET'
    elif method == '2':
        method = 'POST'
    else:
        raise ValueError('Invalid request method')

    # get params
    params = None

    if method == 'GET':

        print('if empty, skip.')
        _uuid = input('Input uuid: ')
        if _uuid:
            url += f'?uuid={_uuid}'
        else:
            print('if empty, skip.')
            limit = input('Input limit: ')
            if limit:
                url += f'?limit={str(limit)}'

    elif method == 'POST':
        _uuid = str(uuid.uuid4())
        user_name = input('Input user_name: ')
        score = input('Input score: ')
        params = {
            'uuid': _uuid,
            'user_name': user_name,
            'score': score
        }

    return {
        'url': url,
        'method': method,
        'params': params
    }


def main():
    pprint(request(**make_request()))


if __name__ == '__main__':
    main()

詳しい説明はしませんが、HTTPリクエストを作成し、Pythonのrequestsモジュールを使って呼び出しています。

クライアントの作成

こちらはC++で実装します。C++ではsocketを使ったライブラリから実装します。

1.SocketHelperの作成

自作した他のヘッダーを利用しているので、ソースコードのコピペだと動かないですが、Githubの方では同梱しているので適宜読み替えてください。SocketHelper.hというファイル名で作成します。

https://github.com/shirokuma1101/game-libraries/blob/main/Inc/ExternalDependencies/Socket/SocketHelper.h

完全なソースはこちらにあります。

2. ヘッダーのインクルード

SocketHelper.h
#include <cassert>
#include <cstdint>
#include <string>
#include <string_view>
#ifdef _WINSOCKAPI_
#error Please include SocketHelper.h before winsock.h (Maybe in Windows.h)
#endif
#include <WinSock2.h>
#include <WS2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
#include <Windows.h>

#include "Convert.h"
#include "Assert.h"
#include "Macro.h"

#include "strconv.h"

#undef GetAddrInfo

インクルードファイルを記述します。#ifdefの部分は、WinSock2.hよりも前にWindows.hをインクルードするとコンパイルエラーになるので、それを防ぐために書いています。

3. 関数の実装

SocketHelper.h
namespace socket_helper {

メンバで保存する変数は無いので、名前空間に書いていきます。

SocketHelper.h
using PORT           = uint16_t;
constexpr int BUFFER = 4096;
constexpr int IPv4   = AF_INET;
constexpr int IPv6   = AF_INET6;
constexpr int TCP    = SOCK_STREAM;
constexpr int UDP    = SOCK_DGRAM;

マクロなどをわかりやすい名前に変えています。ちなみにポートの範囲はuint16bit(0~25565)と同じです。

SocketHelper.h
inline std::string GetWSAErrorDetail() {
    LPVOID msg_buf = nullptr;
    FormatMessage(
        FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, NULL,
        WSAGetLastError(), MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPTSTR)&msg_buf, 0, NULL
    );
    std::string error_detail = wide_to_sjis((LPCTSTR)msg_buf);
    LocalFree(msg_buf);
    return error_detail;
}
inline std::string MakeErrorDetails(std::string_view detail, int err) {
    return std::string(detail) + "Error code: " + std::to_string(err) + "(" + std::to_string(WSAGetLastError()) + ")\n" + GetWSAErrorDetail();
}
inline std::string CheckRecvData(char* buf, int recv_byte) {
    if (recv_byte) {
        if (recv_byte > BUFFER) {
            assert::ShowError(ASSERT_FILE_LINE, "Buffer overflow.");
        }
        buf[recv_byte] = '\0';
        return std::string(buf, recv_byte);
    }
    return std::string();
}

直接は使用しない関数です。Githubの方ではマクロで非公開関数になっています。
GetWSAErrorDetail()MakeErrorDetails()ではエラーコードをもとにエラー文を表示する関数です。CheckRecvData()ではchar*で受け取った文字列をstd::stringに変換しています。文字列受信関数で受け取ったデータは終端文字がないため、終端文字\0を一番最後に差し込んでいます。

SocketHelper.h
inline SOCKET Create(int family = IPv4, int type = TCP, int protocol = 0) {
    WSADATA wsa_data{};
    SecureZeroMemory(&wsa_data, sizeof(wsa_data));

    if (MACRO_FAIL_CHECK(WSAStartup(WINSOCK_VERSION, &wsa_data), err)) {
        switch (err) {
        case WSAEFAULT:
        case WSAEINPROGRESS:
        case WSAEPROCLIM:
        case WSASYSNOTREADY:
        case WSAVERNOTSUPPORTED:
            assert::ShowWarning(ASSERT_FILE_LINE, "https://docs.microsoft.com/en-us/windows/win32/api/winsock/nf-winsock-wsastartup");
            assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("WSAStartup failed.", err));
            break;
        default:
            assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("Unknown error.", err));
            break;
        }
        return SOCKET();
    }

    if (!protocol) {
        if (type == TCP) {
            protocol = IPPROTO_TCP;
        }
        else if (type == UDP) {
            protocol = IPPROTO_UDP;
        }
    }

    return socket(family, type, protocol);
}

Create()でソケットのディスクリプタを作成します。Windowsではソケットを作成する際にWSAStartup()を呼び出す必要があります。

inline void Close(SOCKET* sock, bool wsa_cleanup = true) {
    if (MACRO_FAIL_CHECK(closesocket(*sock), err)) {
        assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("closesocket failed.", err));
        return;
    }
    *sock = SOCKET();
    if (!wsa_cleanup) return;
    if (MACRO_FAIL_CHECK(WSACleanup(), err)) {
        assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("WSACleanup failed.", err));
    }
}

Close()でソケットを終了します。これを呼び出さないと再起動するまで裏で残り続けてしまい再利用できなくなるので注意してください。Close()でも同じくWindowsではWSACleanup()を呼び出す必要があります。

inline void SetNonBlocking(SOCKET* sock) {
    u_long mode = 1;
    if (MACRO_FAIL_CHECK(ioctlsocket(*sock, FIONBIO, &mode), err)) {
        assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("non-blocking mode failed.", err));
    }
}
inline void SetBlocking(SOCKET* sock) {
    u_long mode = 0;
    if (MACRO_FAIL_CHECK(ioctlsocket(*sock, FIONBIO, &mode), err)) {
        assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("blocking mode failed.", err));
    }
}

SetNonBlocking()SetBlocking()では後述するConnect()時のブロッキングを設定します。ホストへの接続か完了するまで処理がブロックされるので接続に時間がかかる場合はその分処理がブロックされてしまいます。なので基本的にはSetNonBlocking()とtimeoutを使います。

inline bool GetAddrInfo(std::string_view host, PORT port, ADDRINFO* addr_info) {
    SecureZeroMemory(addr_info, sizeof(*addr_info));

    ADDRINFO* result = nullptr, * next = nullptr;
    ADDRINFO hints{};
    SecureZeroMemory(&hints, sizeof(hints));
    hints.ai_flags    = AI_CANONNAME;
    hints.ai_family   = AF_INET;     // IPv4限定 AF_UNSPEC:全て受け入れる
    hints.ai_socktype = SOCK_STREAM; // TCPで送信
    hints.ai_protocol = IPPROTO_TCP; // 受け取りをTCPに限定

    if (MACRO_FAIL_CHECK(getaddrinfo(host.data(), std::to_string(port).c_str(), &hints, &result), err)) {
        assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("Domain not found.", err));
        return false;
    }

    if (!result) {
        assert::ShowError(ASSERT_FILE_LINE, "Domain not found.");
        return false;
    }

    *addr_info = *result;
    for (next = result; next != NULL; next = next->ai_next) {
        SOCKET sock = Create(next->ai_family, next->ai_socktype, next->ai_protocol);
        if (Connect(&sock, *next, 1000)) {
            Close(&sock);
            *addr_info = *result;
            continue;
        }
        Close(&sock);
    }
    //freeaddrinfo(result);

    return true;
}

IPアドレスまたはホスト名から送信先の情報を持った構造体(ADDRINFO型)を取得します。引数としてここでfalseが返される(&addr_infoがnullptr)場合は今後の接続処理は全てエラーになります。

inline std::string GetIPAddr(const ADDRINFO& addr_info) {
    // If ai_addr is a nullptr, return an empty string
    if (!addr_info.ai_addr) return std::string();

    // Cast ai_addr to SOCKADDR_IN type
    SOCKADDR_IN* sock_addr_in = reinterpret_cast<SOCKADDR_IN*>(addr_info.ai_addr);

    // Convert sock_addr_in->sin_addr to dst using inet_ntop function
    // Return dst as std::string if the conversion is successful
    char dst[32];
    inet_ntop(addr_info.ai_family, &sock_addr_in->sin_addr, dst, sizeof(dst));
    return std::string(dst);
}

ADDRINFOを使ってIPアドレスを文字列で取得します。

inline void Bind(SOCKET* sock, const ADDRINFO& addr_info) {
    if (MACRO_FAIL_CHECK(bind(*sock, addr_info.ai_addr, convert::SizeOf<int>(*addr_info.ai_addr)), err)) {
        assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("bind failed.", err));
    }
}

inline void Listen(SOCKET* sock, int backlog) {
    if (MACRO_FAIL_CHECK(listen(*sock, backlog), err)) {
        assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("listen failed.", err));
    }
}

inline SOCKET Accept(SOCKET* sock, ADDRINFO* addr_info) {
    SecureZeroMemory(addr_info, sizeof(*addr_info));
    int size = convert::SizeOf<int>(*addr_info->ai_addr);
    return accept(*sock, addr_info->ai_addr, &size);
}

Bind()Listen()Accept()に関しては直接的には扱わないので説明は割愛します。詳しくはこちらの記事を見てください。

http://www.ne.jp/asahi/hishidama/home/tech/socket/

https://qiita.com/Michinosuke/items/0778a5344bdf81488114

inline bool Connect(SOCKET* sock, const ADDRINFO& addr_info, int time_out_ms = 0) {
    if (time_out_ms) {
        SetNonBlocking(sock);
        if (MACRO_FAIL_CHECK(connect(*sock, addr_info.ai_addr, convert::SizeOf<int>(*addr_info.ai_addr)), err)) {
            if (err == SOCKET_ERROR) {
                err = WSAGetLastError();
                SetBlocking(sock);
                if (err != WSAEWOULDBLOCK) {
                    assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("Unexpected error occurred.", err));
                    return false;
                }
            }
            else {
                assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("Unexpected socket error occurred.", err));
                return false;
            }
        }

        fd_set readfds{}, writefds{}, exceptfds{};
        timeval timeout{};
        FD_ZERO(&readfds);
        FD_ZERO(&writefds);
        FD_ZERO(&exceptfds);
        FD_SET(*sock, &readfds);
        FD_SET(*sock, &writefds);
        FD_SET(*sock, &exceptfds);
        SecureZeroMemory(&timeout, sizeof(timeout));
        timeout.tv_usec = convert::MSToUS(time_out_ms);

        // if return 0 timeout
        if (MACRO_SUCCESS_CHECK(select(convert::SizeOf<int>(*sock + 1), &readfds, &writefds, &exceptfds, &timeout), err)) {
            assert::ShowWarning(ASSERT_FILE_LINE, "Timeout: " + std::string(addr_info.ai_canonname));
            return false;
        }
        if (FD_ISSET(*sock, &readfds) || FD_ISSET(*sock, &writefds)) {
            return true;
        }
    }
    else {
        if (MACRO_FAIL_CHECK(connect(*sock, addr_info.ai_addr, convert::SizeOf<int>(*addr_info.ai_addr)), err)) {
            assert::ShowError(ASSERT_FILE_LINE, detail::MakeErrorDetails("Cannot connect.", err));
            return false;
        }
        else {
            return true;
        }
    }
    return false;
}

Connect()で接続します。time_out_msでタイムアウトミリ秒を設定できます。

inline int Send(SOCKET sock, std::string_view data) {
    return send(sock, data.data(), static_cast<int>(data.size()), 0);
}
inline int Send(SOCKET sock, std::string_view data, const SOCKADDR& sock_addr) {
    return sendto(sock, data.data(), static_cast<int>(data.size()), 0, &sock_addr, sizeof(sock_addr));
}

inline std::string Recv(SOCKET sock) {
    char buf[BUFFER];
    return detail::CheckRecvData(buf, recv(sock, buf, BUFFER, 0));
}
inline std::string Recv(SOCKET sock, ADDRINFO* addr_info) {
    char buf[BUFFER];
    int size = convert::SizeOf<int>(*addr_info->ai_addr);
    return detail::CheckRecvData(buf, recvfrom(sock, buf, BUFFER, 0, addr_info->ai_addr, &size));
}

文字列を送信、受信する関数です。これでSocketHelperの実装は終わりです。

4. OrsApiClientの作成

Online Ranking SystemのAPIClientを作成していきます。OrsApiClient.hというファイル名で作成します。

5. 関数の実装

OrsApiClient.h
namespace ors_api_client

こちらもメンバで保存する変数はないので、名前空間に書いていきます。

OrsApiClient.h
constexpr char CRLF[] = "\r\n";
constexpr char CRLFCRLF[] = "\r\n\r\n";

enum class Method {
    GET,
    POST,
};

inline void AddCrlf(std::string* str) {
    str->append(CRLF);
}

inline std::tuple<std::string, socket_helper::PORT> SplitUrl(std::string str) {
    if (auto pos = str.find_last_of(":"); pos != std::string::npos) {
        return { str.substr(0, pos).data(), std::stoi(str.substr(pos + 1).data())};
    }
    return { str.data(), 80 };
}

分かりやすいように改行コードを定数としてと付与する関数を用意しています。SplitUrl()では、SocketHelperで実装した関数はホスト名とポートが分かれていますが、普通はlocalhost:8080のように一つの文字列として扱うので、それをホストとポート番号として分ける関数です。

OrsApiClient.h
inline std::string GetResponseMessageBody(std::string_view response) {
    if (auto crlf_pos = response.find(CRLFCRLF); crlf_pos != std::string::npos) {
        return response.substr(crlf_pos + sizeof(CRLFCRLF) - 1).data();
    }
    return std::string();
}

GetResponseMessageBody()はHTTPResponseを受け取った際に、MessageBodyの部分を取得する関数です。MessageBodyよりも前の部分には必ず空白の1行が存在するので、response.find(CRLFCRLF)で判定してそれ以降を取得する処理になっています。

OrsApiClient.h
inline json Request(std::string_view url, Method method, const json& params = {}) {

    if (method == Method::GET) {
        // split url into host and port
        auto [host, port] = SplitUrl(url.data());

        // create socket and connect to host
        SOCKET sock = socket_helper::Create();
        ADDRINFO addr_info;
        socket_helper::GetAddrInfo(host, port, &addr_info);
        if (!socket_helper::Connect(&sock, addr_info, 5000)) {
            return json();
        }

        // query string
        std::string query;
        if (params.size()) {
            // start query string
            query.append("?");
            for (const auto& [key, value] : params.items()) {
                query += std::format("{}={}&", key, value.get<std::string>());
            }
            // remove last &
            query.pop_back();
        }

        // request line
        std::string request_line;
        request_line = std::format("GET {} HTTP/1.1", query);
        AddCrlf(&request_line);

        // header fields
        std::string header_fields;
        header_fields = std::format("Host: {}:{}", host, port);
        AddCrlf(&header_fields);

        // finally send the request
        std::string http_request = request_line + header_fields + CRLF;

        // send request
        socket_helper::Send(sock, http_request);

        // receive response
        int recv_limit = 1024;
        int recv_count = 0;
        std::string response;
        std::string message_body;
        while (++recv_count < recv_limit) {
            int content_length = -1;
            response += socket_helper::Recv(sock);

            // Determine if all data has been received by checking the Content-Length header field
            if (auto pos = response.find("Content-Length: "); pos != std::string::npos) {
                content_length = std::stoi(response.substr(pos + sizeof("Content-Length: ") - 1).data());
            }
            else if (auto pos = response.find("content-length: "); pos != std::string::npos) {
                content_length = std::stoi(response.substr(pos + sizeof("content-length: ") - 1).data());
            }

            // if all data has been received, break
            message_body = GetResponseMessageBody(response);
            if (content_length != -1 && message_body.size() == content_length) {
                break;
            }
        }

        std::string content_type;
        // check if Content-Type is application/json
        if (auto pos = response.find("Content-Type: "); pos != std::string::npos) {
            content_type = response.substr(pos + sizeof("Content-Type: ") - 1);
        }
        else if (auto pos = response.find("content-type: "); pos != std::string::npos) {
            content_type = response.substr(pos + sizeof("content-type: ") - 1);
        }
        if (content_type.find("application/json") == std::string::npos) {
            throw std::exception("Content-Type is not application/json");
        }

        // close socket
        socket_helper::Close(&sock);

        // return message body as json
        return json::parse(message_body);
    }

    else if (method == Method::POST) {
        // split url into host and port
        auto [host, port] = SplitUrl(url.data());

        // create socket and connect to host
        SOCKET sock = socket_helper::Create();
        ADDRINFO addr_info;
        socket_helper::GetAddrInfo(host, port, &addr_info);
        socket_helper::Connect(&sock, addr_info, 5000);

        // request line
        std::string request_line;
        request_line = std::format("POST {} HTTP/1.1", url);
        AddCrlf(&request_line);

        // header fields
        std::string header_fields;
        header_fields = std::format("Host: {}:{}", host, port);
        AddCrlf(&header_fields);
        header_fields += std::format("Content-Type: application/json");
        AddCrlf(&header_fields);
        header_fields += std::format("Content-Length: {}", params.dump().size());
        AddCrlf(&header_fields);

        // finally send the request
        std::string http_request = request_line + header_fields + CRLF + params.dump();

        // send request
        socket_helper::Send(sock, http_request);

        // close socket
        socket_helper::Close(&sock);
    }

    return json();
}

それぞれの処理はコメントを書いているので処理の流れだけ説明します。

パラメータは引数paramsとしてjson型で受け取ります。GETメソッドの場合はjsonをURLクエリとして、POSTメソッドの場合はRequestBodyに代入しています。

GETメソッドの場合、ヘッダーを作成しURLクエリと組み合わせて送信します。送信後に受信しなければならないのですが、一度のRecvでは受け取れないのでwhile文で回しContent-LentghヘッダーからMessageBodyの長さを取得し、同じなら全て受け取り完了という処理にしています。

POSTメソッドの場合、ヘッダーを作成し、jsonをdumps()で文字列に変更したものを組み合わせて送信しています。受け取るものは特にないので処理には含めていません。

HTTPRequest/Responseの詳しい内容に関してはこちらの記事を参考にしてください。

https://atmarkit.itmedia.co.jp/ait/articles/1508/31/news016.html

6. UserDataの作成

ユーザーデータを保存するクラスを作成します。UserData.hというファイル名で作成します。

7. クラスの実装

UserData.h
#pragma once

#include "OrsApiClient.h"
#pragma comment(lib, "Rpcrt4.lib")

class UserData
{
public:

    static constexpr char URL[] = "192.168.1.15:5000";

    UserData(std::string_view user_name, int score) {
        uuid        = GetUuid();
        userName    = user_name;
        this->score = score;
    }

    void UpdateScore(int score) {
        this->score = score;
    }

    void UploadScore() {
        json params;
        params["uuid"]      = uuid;
        params["user_name"] = userName;
        params["score"]     = score;
        ors_api_client::Request(URL, ors_api_client::Method::POST, params);
    }

    json GetMyRanking() {
        json params;
        params["uuid"] = uuid;
        return ors_api_client::Request(URL, ors_api_client::Method::GET, params);
    }

    json GetTopRanking(int limit = 3) {
        json params;
        params["limit"] = std::to_string(limit);
        return ors_api_client::Request(URL, ors_api_client::Method::GET, params);
    }

private:

    std::string GetUuid() {
        GUID guid = GUID_NULL;
        if (FAILED(CoCreateGuid(&guid))) {
            return "";
        }

        RPC_WSTR str;
        if (RPC_S_OK == UuidToString(&guid, &str)) {
            return wide_to_sjis((PWCHAR)str);
        }
        return "";
    }

    std::string uuid;
    std::string userName;
    int         score;

};

#pragma comment(lib, "Rpcrt4.lib")はUUID(WindowsではGUID)を作成する関数CoCreateGuidで必要です。
コンストラクタで引数としてユーザー名とスコアを渡します。UpdateScore()ではスコアを更新するだけです。サーバー側の値を更新するにはUploadScore()を呼び出す必要があります。また、GetMyRanking()で自分の順位情報を持ったjsonを、GetTopRanking()では引数で指定個数分の上位ランキングをjsonで返します。

動作確認

いよいよ動作確認です。main.cppを作成してください。

main.cpp
#include "UserData.h"
#include "OrsApiClient.h"

void ShowRanking(const json& j)
{
    std::cout << "========== Ranking ==========" << std::endl;
    for (const auto& [key, value] : j.items()) {
        std::cout << std::format("{}st) {} / {}", key, value["user_name"].get<std::string>(), value["score"].get<int>()) << std::endl;
    };
    std::cout << "=============================" << std::endl;
}

int main()
{
    // UserDataを新規作成
    UserData userData = UserData("myname", 250);
    UserData userData1 = UserData("test1", 100);
    UserData userData2 = UserData("test2", 200);
    UserData userData3 = UserData("test3", 300);
    UserData userData4 = UserData("test4", 400);
    // UserDataをアップロード
    userData.UploadScore();
    userData1.UploadScore();
    userData2.UploadScore();
    userData3.UploadScore();
    userData4.UploadScore();

    // 自分の順位を取得
    ShowRanking(userData.GetMyRanking());

    // トップ3のランキングを取得
    ShowRanking(userData.GetTopRanking(3));

    // スコアを更新
    userData.UpdateScore(450);
    userData.UploadScore();

    // トップ3のランキングを取得
    ShowRanking(userData.GetTopRanking(3));
}

サーバーを起動した状態で実行してください。コンソールウィンドウに取得した情報が表示されていたら成功です!


クライアント(C++)側


サーバー(Python)側

おわりに

2023/08/09 画像のコメントが逆だったのを修正

解説を挟んでいたら思ったより長くなってしまいました。あまり幅広くプログラミングをしていなかった人に対してはデータベースやPythonなど知らない事も多くかなり大変なのではないかと思います。ゲーム開発ではどちらも実際に(自分が関わるかどうかはともかく)使用していますし、そういった技術があることを知っておくことはとても重要なので、是非ここから発展した何かを制作してほしいです。

脚注
  1. アンダースコア2つで通常の方法ではアクセスできないが、完全な非公開ではない ↩︎

  2. PEP8 メソッド名とインスタンス変数 ↩︎

神戸電子専門学校ゲーム技術研究部

Discussion

HikaruooHikaruoo

クライアントとサーバー間のSocket通信の部分が勉強になりました。以前、APIのテストを効率化するツールを作っていた際に、C++のライブラリやPythonのモジュール選定に苦労した経験があるので、とても参考になりました