シリアライザとしてGoogleが開発したFlatBuffersというものがあります。
デシリアライズが爆速ということで、ゲームなどで使えそうな感じなのですが、イマイチ人気が無いように思えます。
データを作るまでの手間が面倒なのが理由の1つではないかと思い、SQLのデータベースの内容をそのままFlatBuffersでシリアライズしてくれるツールを作成しました。
これにより、データの構造定義はSQLのテーブルで行えば良いことになります。
ソーシャルゲームの場合、マスターデータの定義は大体サーバーエンジニアが行うことが多く、クライアントはその内容をJSONやSQLiteのDBファイルとして受け取ったりする感じですが、その変換対応が終わるまでクライアント側は待ちの状態になったりしますが、このツールを使えばSQLのデータテーブルが出来た時点でクライアント側にもデータ供給が可能で、テーブルの変更にも即座に対応ができます。
準備
マスターデータ用のデータベースを用意します
ツールではMySQLを想定しています。
必要なテーブルを作成します。
サンプルが欲しい場合はMySQL公式のsakila-dbなどを利用しましょう。
FlatBuffersのツールをインストールします
flatcというビルドツールがあり、これを利用できるようにします。
macならbrew install flatbuffers
でOKです。超簡単。
念のためコンソールでflatcと入力してパスが通っていることを確認してください。
pythonをインストールします
コンバートに流行りのPythonを使用しますのでインストールします。
作業用フォルダを適当に作成します
そして以下のファイルを置いてください。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import sys
import os
import os.path
import re
import shutil
import time
import sqlite3
import pymysql.cursors
_isSqliteDb = False
def GetConvName(_str):
_convName = ''
_names = re.split('[ _]', _str)
for _name in _names:
_convName = _convName + _name.capitalize()
return _convName
class SchemaInfo:
def __init__(self, _keyName, _keyType, _fbsKeyType):
self.keyName = _keyName
self.keyType = _keyType
self.fbskeyName = GetConvName(_keyName)
self.fbsKeyType = _fbsKeyType
#カラム名とタイプの取得
def GetShemaInfo(_tableName, _conn):
_keys = []
_cur = _conn.cursor()
if _isSqliteDb == True:
# sqlite
_order = "select sql from sqlite_master where type = 'table' and name = '" + _tableName + "'"
_cur.execute(_order)
_schema = _cur.fetchone()[0]
_startPos = _schema.find(_tableName) + len(_tableName) + 1
_schema = _schema[_startPos:-5]
for _item in _schema.split(','):
_itemTmp = _item.split(' ')
if _itemTmp[1] == 'integer':
_fbsKeyType = "int"
elif _itemTmp[1] == 'real':
_fbsKeyType = "float"
elif _itemTmp[1] == 'numeric':
_fbsKeyType = "int"
else:
_fbsKeyType = "string"
_keys.append(SchemaInfo(_itemTmp[0], _itemTmp[1], _fbsKeyType))
else:
# mysql
_order = "SHOW COLUMNS FROM " + _tableName
_cur.execute(_order)
_schema = _cur.fetchall()
for _item in _schema:
_type = _item['Type']
_isUnsigned = _type.find('unsigned') >= 0
if _type.find('tinyint') >= 0:
_fbsKeyType = "ubyte" if _isUnsigned else 'byte'
elif _type.find('smallint') >= 0:
_fbsKeyType = "ushort" if _isUnsigned else 'short'
elif _type.find('bigint') >= 0:
_fbsKeyType = "ulong" if _isUnsigned else 'long'
elif _type.find('mediumint') >= 0 or _type.find('int') >= 0:
_fbsKeyType = "uint" if _isUnsigned else 'int'
elif _type.find('float') >= 0:
_fbsKeyType = "float"
elif _type.find('double') >= 0:
_fbsKeyType = "double"
else:
_fbsKeyType = "string"
_keys.append(SchemaInfo(_item['Field'], _item['Type'], _fbsKeyType))
return _keys
def GetShemaInfoFromFbsKeyName(_keys, _name):
for _item in _keys:
if _item.fbskeyName == _name:
return _item
return None
def Create_Fbs(_conn, _list, _dbMasterName):
_fd = open('param.fbs', 'w')
_fd.write("// IDL file for our master's schema.\n")
_fd.write("\n")
_fd.write("namespace param;\n")
_fd.write("\n")
for _name in _list:
Create_Master(_name, _fd, _conn)
_fd.write("table " + _dbMasterName + "\n")
_fd.write("{\n")
for _name in _list:
_fd.write(' ' + _name[1] + 'Master:' + _name[1] + "Master;\n")
_fd.write("}\n")
_fd.write("\n")
_fd.write("root_type " + _dbMasterName + ";\n")
_fd.close()
def Create_Master(_name, _fd, _conn):
print("create " + _name[0])
_keys = GetShemaInfo(_name[0], _conn)
_fd.write("table " + _name[1] + "Element\n")
_fd.write("{\n")
for _key in _keys:
_fd.write(" " + _key.fbskeyName + ":" + _key.fbsKeyType + ";\n")
_fd.write("}\n\n")
_fd.write("table " + _name[1] + "Master\n")
_fd.write("{\n")
_fd.write(" data:[" + _name[1] + "Element];\n")
_fd.write("}\n\n\n")
def Create_Conv(_conn, _list):
for _name in _list:
_elementName = _name[1] + 'Element'
_masterName = _name[1] + 'Master'
_keyInfos = GetShemaInfo(_name[0], _conn)
_startString = _elementName + "." + _elementName + "Start"
_endString = _elementName + "." + _elementName + "End"
_fd1 = open('conv' + _masterName + '.py', 'w')
_fd1.write("import utility\n")
if _isSqliteDb:
_fd1.write("import sqlite3\n")
else :
_fd1.write("import pymysql.cursors\n")
_fd1.write("from param import " + _elementName + "\n")
_fd1.write("from param import " + _masterName + "\n")
_fd1.write("\n")
_fd1.write("class conv" + _masterName + "(object):\n")
_fd1.write("\n")
_fd1.write(" @classmethod\n")
_fd1.write(" def Create(self, _conn, _builder):\n")
_fd1.write(" _cmd = '" + _name[0] + "'\n")
_fd1.write(" print(\"convert \" + _cmd)\n")
_fd1.write("\n")
_fd1.write(" _cur = _conn.cursor()\n")
_fd1.write(" _cur.execute(\"SELECT * FROM \" + _cmd)\n")
_fd1.write(" _list = _cur.fetchall()\n")
_fd1.write("\n")
_fd1.write(" _elements = []\n")
_fd1.write(" for _row in _list:\n")
_fd2 = open('param/' + _elementName + '.py', 'r')
_strline = _fd2.readline()
while _strline and _strline.find(_elementName + "Start(") < 0:
_strline = _fd2.readline()
_strline = _fd2.readline()
_params = []
while _strline and _strline.find(_elementName + "End(") < 0:
_endPos = _strline.find("):")
_strline = _elementName + '.' +_strline[4:(_endPos + 1)]
_strline = _strline.replace('(builder', '(_builder')
_params.append(_strline)
_strline = _fd2.readline()
for _param in _params:
_fbsArgName = _param[(_param.rfind(', ') + 2):-1]
_keyInfo = GetShemaInfoFromFbsKeyName(_keyInfos, _fbsArgName)
_argName = "_" + _fbsArgName
_writeText = " " + _argName + " = "
if _keyInfo.fbsKeyType == 'int' or _keyInfo.fbsKeyType == 'uint' or \
_keyInfo.fbsKeyType == 'short' or _keyInfo.fbsKeyType == 'ushort' or \
_keyInfo.fbsKeyType == 'long' or _keyInfo.fbsKeyType == 'ulong' or \
_keyInfo.fbsKeyType == 'byte' or _keyInfo.fbsKeyType == 'ubyte':
_writeText = _writeText + "utility.GetInt(_row['" + _keyInfo.keyName + "'])\n"
elif _keyInfo.fbsKeyType == 'float':
_writeText = _writeText + "utility.GetFloat(_row['" + _keyInfo.keyName + "'])\n"
elif _keyInfo.fbsKeyType == 'double':
_writeText = _writeText + "utility.GetDouble(_row['" + _keyInfo.keyName + "'])\n"
else:
_writeText = _writeText + "_builder.CreateString(utility.GetStr(_row['" + _keyInfo.keyName + "']))\n"
_fd1.write(_writeText)
_fd1.write(" " + _startString + "(_builder)\n")
for _param in _params:
_startPos = _param.rfind(', ') + 2
_param = _param[:_startPos] + '_' + _param[_startPos:]
_fd1.write(" " + _param + "\n")
_fd1.write(" _elements.append(" + _endString + "(_builder))\n")
_fd1.write("\n")
_fd1.write(" " + _masterName + "." + _masterName + "StartDataVector(_builder, len(_elements))\n")
_fd1.write(" for i in range(len(_elements)):\n")
_fd1.write(" _builder.PrependUOffsetTRelative(_elements[i])\n")
_fd1.write(" _data = _builder.EndVector(len(_elements))\n")
_fd1.write("\n")
_fd1.write(" " + _masterName + "." + _masterName + "Start(_builder)\n")
_fd1.write(" " + _masterName + "." + _masterName + "AddData(_builder, _data)\n")
_fd1.write(" return " + _masterName + "." + _masterName + "End(_builder)\n")
_fd1.close()
_fd2.close()
def Create_ConvBaseFile(_list, _dbParam, _outName, _dbMasterName):
_fd1 = open('convAll.py', 'w')
_fd1.write("#!/usr/bin/env python\n")
_fd1.write("# -*- coding: utf-8 -*-\n")
_fd1.write("import sys\n")
_fd1.write("import os\n")
_fd1.write("sys.path.append('../flatbuffers')\n")
_fd1.write("import flatbuffers\n")
if _isSqliteDb:
_fd1.write("import sqlite3\n")
else :
_fd1.write("import pymysql.cursors\n")
for _name in _list:
_masterName = _name[1] + "Master"
_fd1.write("import conv" + _masterName + "\n")
_fd1.write("from param import DbMaster\n")
_fd1.write("\n")
_fd1.write("def Convert(_conn, _outName):\n")
_fd1.write(" _builder = flatbuffers.Builder(0)\n")
_fd1.write("\n")
for _name in _list:
_masterName = _name[1] + "Master"
_fd1.write(" _" + _masterName + " = conv" + _masterName + ".conv" + _masterName + "().Create(_conn, _builder)\n")
_fd1.write("\n")
_DbMasterStr = _dbMasterName + "." + _dbMasterName
_fd1.write(" " + _DbMasterStr + "Start(_builder)\n")
for _name in _list:
_masterName = _name[1] + "Master"
_fd1.write(" " + _DbMasterStr + "Add" + _masterName + "(_builder, _" + _masterName + ")\n")
_fd1.write(" _totalData = " + _DbMasterStr + "End(_builder)\n")
_fd1.write(" _builder.Finish(_totalData)\n")
_fd1.write("\n")
_fd1.write(" _final_flatbuffer = _builder.Output()\n")
_fd1.write(" _fd = open(_outName, 'wb')\n")
_fd1.write(" _fd.write(_final_flatbuffer)\n")
_fd1.write(" _fd.close()\n")
_fd1.write("\n")
_fd1.write("if __name__ == \"__main__\":\n")
if _isSqliteDb:
_fd1.write(" _conn = sqlite3.connect('app.db')\n")
_fd1.write(" _conn.row_factory = sqlite3.Row\n")
else:
_fd1.write(" _conn = pymysql.connect(host='" + _dbParam[1] + "', user='" + _dbParam[2] + "', password='" + _dbParam[3] + "', db='" + _dbParam[4] + "', charset='utf8', cursorclass=pymysql.cursors.DictCursor)\n")
_fd1.write(" Convert(_conn, '" + _outName + "')\n")
_fd1.write(" _conn.close()\n")
_fd1.close()
def Create_UtilityFile():
_fd1 = open('utility.py', 'w')
_fd1.write(\
"def GetInt(_str):\n"\
" return 0 if _str is None or _str == 'None' else int(_str)\n"\
"\n"\
"def GetFloat(_str):\n"\
" return 0 if _str is None or _str == 'None' else float(_str)\n"\
"\n"\
"def GetDouble(_str):\n"\
" return 0 if _str is None or _str == 'None' else double(_str)\n"\
"\n"\
"def GetStr(_str):\n"\
" if isinstance(_str, unicode):\n"\
" return _str.encode('utf-8')\n"\
" if isinstance(_str, str):\n"\
" return _str\n"\
" return \"\" if _str is None else str(_str)\n"\
)
_fd1.close()
if __name__ == "__main__":
_dbParam = []
_isSqliteDb = False
_settingName = 'Setting.txt'
_inName = 'TableName.txt'
_outName = 'db_master.dat'
_dbMasterName = 'DbMaster'
_flatparam = '-c'
#for argv in sys.argv:
if os.path.isfile(_settingName):
_fd1 = open(_settingName, 'r')
_dbParam = _fd1.read().splitlines()
_fd1.close()
_isSqliteDb = _dbParam[0] == "sqlite"
_list = []
_fd1 = open(_inName, 'r')
_line = _fd1.readline().rstrip("\n")
while _line:
_list.append(_line.split(','))
_line = _fd1.readline().rstrip("\n")
if _isSqliteDb:
_conn = sqlite3.connect(_dbParam[1])
_conn.row_factory = sqlite3.Row
else:
_conn = pymysql.connect(host=_dbParam[1], user=_dbParam[2], password=_dbParam[3], db=_dbParam[4], charset='utf8', cursorclass=pymysql.cursors.DictCursor)
# Create working directory
shutil.rmtree("conv", True)
time.sleep(0.1)
os.makedirs("conv")
os.chdir('conv')
_fd = open('__init__.py', 'w')
_fd.close()
Create_UtilityFile()
Create_Fbs(_conn, _list, _dbMasterName)
os.system('flatc ' + _flatparam + ' -p param.fbs')
Create_Conv(_conn, _list)
Create_ConvBaseFile(_list, _dbParam, _outName, _dbMasterName)
_conn.close()
os.chdir('..')
os.system('python conv/convAll.py')
変換用定義ファイルを作成します
SQLへの接続情報および、どのテーブルを参照するかの定義を書いたファイルを作成します。
makefbs.pyと同じフォルダに入れます。
mysql (SQL選択)
localhost (接続先アドレス)
root (ユーザー名)
Pass-123 (パスワード)
sakila (DB名)
actor_info,SampleActorInfo (DBテーブル名,出力クラス名)
customer_list,SampleCustomerList
film_list,SampleFilmList
変換用FlatBuffersファイルをコピーします
flatbuffersというフォルダを作成し、公式からダウンロードしてきたpython用のソースをコピーします。
変換します
コンソールにてpython ./makefbs.py
を実行します。
MySQLに接続して指定されたテーブル構造を解析し、FlatBuffersのスキーマを自動生成します。
その後flatcを実行して、最終的なバイナリデータまで作成します。
db_master.datというデータファイルと、convという作業フォルダが作成されます。
このツールはC言語用のヘッダファイルを出力するようになっています。
makefbs.pyの_flatparam
の変数を書き換えることにより、他の言語にも対応が可能です。
convフォルダの中にparam_generated.h
というファイルができていますので、これをデシリアライズの際に使用します。
出来たデータを使ってみる
#include <iostream>
#include <fstream>
#include <sys/stat.h>
#include "param_generated.h"
int main(int argc, const char * argv[])
{
struct stat results;
if (stat(argv[1], &results) != 0)
{
std::cout << "file not found\n";
return -1;
}
char* buff = new char[results.st_size];
std::ifstream file;
file.open(argv[1], std::ios::in | std::ios::binary);
if (file)
{
file.read(buff, results.st_size);
auto data = param::GetDbMaster(buff);
auto actorMaster = data->SampleActorInfoMaster()->data();
auto num = actorMaster->size();
for (int i = 0; i < num; i++)
{
auto data = actorMaster->Get(i);
std::cout << data->ActorId() << " " << data->FirstName()->c_str() << " " << data->LastName()->c_str() << "\n";
}
}
delete[] buff;
return 0;
}
サンプルでは、actor_infoというテーブルがSampleActorInfoMasterというstd::vectorクラスにまとめられます。
各行のデータは、SampleActorInfoElementクラスとして定義されていて、Get()で取得できます。
そして複数のテーブル情報をparam::GetDbMasterが持っているという感じになります。
取得自体はstd::vectorでまとめられているので簡単だと思います。
テーブル構造が変わるといちいちヘッダファイルを更新しなくていけない手間はありますが、SQLのテーブル構造がそのままデータ化されるのは随分手間が省けて便利なのではないかと思います。
カスタム前提で作っているので、現状ではテーブルのカラムは全てシリアライズされてしまいますが、命名規則を決めてシリアライズされないカラムを作れるようにするなど、各プロジェクトに合わせてmakefbs.py
をいじってもらうといいんじゃないかと思います。