C++とPythonでオンラインランキングシステムを一から作る
はじめに
昨今のゲームはMobile/Pc/etc...とPlatform関わらず、オンラインユーザーデータ/ランキング/リアルタイム対戦/etc...と何らかの方法でネットワークにアクセスしています。今や繋がっていることが当たり前になっていますが、いざゲーム制作に実装しようとなった際にどう実装したら良いか分からない人も多いと思います。最近は情報も増えてきており、UnityやUnrealEngineならNetcode for GameObjects
やMultiplayer
が公式からリリースされており、公式リファレンスやプロダクトで使った実例の技術ブログ等日本語でもかなりの量の情報がありますが、ネイティブ開発で一から実装している情報は英語でもあまりみないように思います。
そこで今回はサーバーとの通信方法の解説をしながら、実際に通信を行ってスコアを送信するオンラインランキングシステムを、クライアント(アプリケーション)をC++/サーバーをPythonで実装してみたいと思います。
今回、よくあるクラウドものは使わず、Socket通信で、HTTP方式でDBAPIと通信を行い読み書きする方式にしました。ネットワーク/DBの知識を得ることが目的なので、SSL/TLSなどの暗号化やserialize(cereal)などの容量削減はしていません。
概要
- Windows 10
- サーバー
- Python 3.9.11
- requests
- Python 3.9.11
- クライアント
- C++ 20(Visual Studio 2022)
- nlohmann_json
- strconv
- C++ 20(Visual Studio 2022)
完全なソースはこちらにあります。
仕様
GET Method
長いので隠してます
-
request
http://192.168.1.x
orhttp://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
}
- raw
- response
""
準備
それぞれ導入方法は省略します。
Python
python 3.9.x
をインストールしてください。(多分最新版python 3.11.4
でも問題ないと思います)
インストール後は以下を実行してモジュールをインストールしておいてください。
pip install requests
C++
HTTP通信を行う際にjson形式でやり取りを行うので、C++は外部ライブラリを導入する必要があります。今回はnlohmann_jsonを利用しました。
また、エラー内容を取得するために文字コードの変換をする必要があるので、こちらのライブラリも導入します。サーバーの作成
前述したとおりサーバー側はPythonで実装します。
1. ファイル作成
orsapiserver.py
というファイル名で作成します。
2. モジュールのインポート
# standard
import datetime
import json
import os
import sqlite3
import urllib.parse
from wsgiref.simple_server import make_server
今回、APIServerにはwsgiref
のsimple_server
を利用しています。
3. DB操作クラスの実装
# online ranking system database
class ORSDB:
の中に書いていきます。
# 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でいう{}
みたいなものです。データベースを触ったことない人はクエリがよくわからないと思いますが、ここではそれぞれの解説はしません。どういった動作するかは変数名をみて察してください。
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
として返してくれます。ちなみにの引数params
でNone
を渡してしまうと例外スローになってしまうので注意してください。
_get_log_time()
は現在時刻を%Y-%m-%d %H:%M:%S
のフォーマットとして文字列で返すだけです。
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書き込みます。引数としてuuid
とuser_name
とscore
があります。uuidを渡し、SEARCH_BY_UUID
で一度検索し、レコードが存在しない場合はINSERT_NEW_SCORE
を、存在する場合は前回のスコアとCOMPARE_SCORES_BY_UUID
で比較し、前回のスコアよりも高かったらUPDATE_SCORE
でスコアを更新しています。
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
時に勝手に変換してくれます。
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番目と添え字アクセスして取得しています。
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クラスの実装
# online ranking system api server
class ORSAPIServer:
の中に書いていきます。
def __init__(self, orsdb: ORSDB, host: str = 'localhost', port: int = 5000):
self.orsdb = orsdb
self.host = host
self.port = port
コンストラクタです。引数としてORSDBのインスタンスとホスト名とポートを渡します。
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
ではヘッダーを指定します。
情報を取得したい場合はGETメソッド、情報を書き込みたい場合はPOSTメソッドを使用します。
GETメソッドでは、URLクエリで条件を指定できるようにしています。また、POSTメソッドでは今回はapplicaction/json形式でmessagebodyにjsonを送信しています。受け取った情報をパースしてそれぞれの関数を呼び出しているだけです。
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. 実行
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も実装します。
# 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
というファイル名で作成します。
完全なソースはこちらにあります。
2. ヘッダーのインクルード
#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. 関数の実装
namespace socket_helper {
メンバで保存する変数は無いので、名前空間に書いていきます。
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)と同じです。
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
を一番最後に差し込んでいます。
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()
に関しては直接的には扱わないので説明は割愛します。詳しくはこちらの記事を見てください。
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. 関数の実装
namespace ors_api_client
こちらもメンバで保存する変数はないので、名前空間に書いていきます。
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
のように一つの文字列として扱うので、それをホストとポート番号として分ける関数です。
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)
で判定してそれ以降を取得する処理になっています。
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の詳しい内容に関してはこちらの記事を参考にしてください。
6. UserDataの作成
ユーザーデータを保存するクラスを作成します。UserData.h
というファイル名で作成します。
7. クラスの実装
#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
を作成してください。
#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など知らない事も多くかなり大変なのではないかと思います。ゲーム開発ではどちらも実際に(自分が関わるかどうかはともかく)使用していますし、そういった技術があることを知っておくことはとても重要なので、是非ここから発展した何かを制作してほしいです。
-
アンダースコア2つで通常の方法ではアクセスできないが、完全な非公開ではない ↩︎
Discussion
クライアントとサーバー間のSocket通信の部分が勉強になりました。以前、APIのテストを効率化するツールを作っていた際に、C++のライブラリやPythonのモジュール選定に苦労した経験があるので、とても参考になりました