自制工具:CSV程式碼生成器:自動生成CSV檔案對應的C++實體類和欄位型別解析程式碼
更有開發效率地使用CSV檔案
為了更有效率地使用CSV檔案,我製作了一個工具:Code程式碼生成器。
這個工具可以對CSV檔案進行簡單地配置,自動生成這個CSV檔案對應的C++資料結構和欄位型別解析函式程式碼。
工程專案只要加入這些自動生成的程式碼,就可以更方便地使用來自CSV配置檔案的資料。
用工具自動生程式碼,可以省去了手工編寫、手工維護那些大量的、無聊繁瑣的型別定義、資料轉換的程式碼的過程,
還可以防止手工程式設計可能的錯誤。
工具截圖如下:
CSV程式碼生成器的下載地址
程式的CSV資料夾下有一些CSV檔案,可供參考。
回顧一下之前寫的CSV類
資源下載頁面評論的反響還不錯。
無意中在網上也看到一些人基於我寫的CSV類進行擴充套件、修改、發表博文的,
這哥們貌似對我的類進一步做了封裝,支援更靈活、更細粒的資料訪問。
自從寫了那個CSV解析類後,我就在後來的幾個cocos2d-x的專案中一直用它。
為了使用簡單方便,我都是在程式執行初時,把所有的csv表讀入記憶體保留,
用的時候查表獲取資料,而不是需要時再讀取磁碟。
這樣做的好處不僅是使用簡單,而且也加快了訪問速度,
實時讀取磁碟的速度太慢,如果csv表加密了的話,還要經過一層解密,
這些都可能會造成延遲卡頓。
若表太大不適合常駐記憶體,那麼可能已經不適合用csv儲存了,
應該考慮用Sqlite等資料庫。
我的CSV類設計的介面很簡單。
主要就是一個Parse解析函式,可以從記憶體中解析CSV資料。
一個GetGrid函式,返回解析的結果。
解析結果是一個vector< vecotr< string > > 型別的資料結構,
用來模擬二維陣列,表示原始的CSV網格資料。
CSV類遵循SRP(單一職責原則),它的用處就是對CSV資料流進行解析。
在實際專案中使用自己的CSV類的總結
最早使用CSV類,我是寫了一個函式,
把 vector< vecotr< string > > 原始CSV表資料轉換成
unordered_map< string , unordered_map< string , string > > 來用。
這樣的資料結構表示了它是一種用Key來訪問內容的結構。
最外層的map的key是一個記錄的key,
內層的map的key是這條記錄的欄位名。
圖示如下:
對於以上的表,要訪問Id為12的記錄的TaskName欄位的值可以這樣做:
unordered_map< string , unordered_map< string , string > > Table ;
.... // 呼叫函式,把vector< vector< stirng > > 原始CSV表資料轉換成Table的型別。
Table[ "12" ][ "TaskName" ] ; // 訪問資料
約定某一列為記錄的Key,某一行為欄位的Key進行資料轉換。
資料轉換的好處是,讓程式碼更清晰,適應性更強。
要獲取具體的值,不依賴於這個值所在的CSV表中的單元格位置。
只要索引的Key不變,單元格的位置改變是不影響的,
還是可以通過Key索引到內容。
在遊戲專案中,經常會通過工廠函式創建出實體。
這些實體會根據配置的資料進行初始化。
我傳給工廠Create函式的就是這個實體在CSV表中某條記錄的Key,
表示用這條記錄來建立實體。
因為之前的2級對映的資料結構是無型別的,所有的欄位值都是string型別,
所以,在工廠函式中,就有了大量的資料型別轉換函式,
atoi , atof , Split等把資料轉換後再填到實體上。
結果,實體工廠函式塞滿了大量的型別轉換,把string轉換成各種不同的型別。
有一天我突然覺得,某些CSV表字段太多,工廠函式實在太長,
每次建立一個實體,都要進行大量的資料型別轉換,會影響效能。
轉換應該只進行一次。
考慮了一段時間,我覺得應該為每個CSV表手工定義一個數據結構,預先轉換好欄位的值。
對於像這樣的表:
應該有一個對應的資料結構:
例如 一個對應的標頭檔案應該如下
#include <string>
using namespace std ;
// 道具資訊資料結構
class PropInfo
{
public:
// 欄位的ID
string Id ;
// 欄位的備註
string Remark ;
// 使用說明
int UseTip ;
// 價格
int Price ;
} ;
// 道具資訊表。道具資訊資料結構的集合
unordered_map< string , Prop > TableProp ;
這個想法冒出後,在新的小專案中,
我就為每個CSV表手工建立了對應的XXX.h標頭檔案,
標頭檔案中,包含了這個表表示的實體class的定義,
並且配套了一個解析轉換函式,
把從CSV原始資料中讀入的string值轉換成各種不同的實體欄位的型別,然後對應賦值。
在程式執行初時,就把表型別都轉換成對應的專屬class資料結構,
原先的Create工廠函式,就消除了對欄位做型別轉換的職責。
這樣幹了一段時間後,發現用於配置的CSV表不斷增多,
並且時常會在原有的表上進行增加,刪除欄位。
一直手工增加,維護那些對應的class類,寫類定義,寫類欄位的解析轉換程式碼,
讓我感覺有點不太科學。
這些大量、無聊,重複、無技術含量的工作佔用了我的精力和時間,
我的精力應該集中在更加高層的設計和演算法的實現上。
觀察自己寫的那些程式碼,突然想到:這些程式碼是否可以用工具來自動生成和維護?
身為深圳華強北第一程式猿!
拒絕做碼工!
拒絕寫那些有規律的重複無聊的程式碼!
幾年前在的一家公司就有嘗試用過《動軟.Net程式碼生成器》來做專案。
我不妨自己設計一個CSV程式碼生成器來替我做那些勞動。
CSV程式碼生成器對CSV表資料的規定
有了做這個工具的想法後,騰出了一些時間,用了幾天設計和實現。
並且對錶的結構做了一些定義,以便於能讓工具正確作用在其上。
表資料有2種風格排列:欄位橫著排。欄位豎著排。
欄位橫著排如下圖:
第2行是欄位的註釋。
第3行是欄位名。
從第4開始往後,是記錄。記錄是豎著堆疊的。
欄位豎著排如下圖:
第1列從第2行開始是欄位的註釋。
第2列從第2行開始是欄位名。
從第3列第2行開始,是記錄。記錄橫著向右排列。
這2種風格的表排列都是等價的,只是在Excel中看起來不同。
表的第一行用作保留。可以表示表本身的一些資料。
第一行第一列,目前有2種可能的取值:
FieldOrientation=Landscape
FieldOrientation=Portrait
前者表示CSV表的欄位是橫向排列的,也就是第一種風格。
後者表示CSV表的欄位是縱向排列的,第二種風格。
我寫了一個類,輸入CSV的原始資料,可以轉換成邏輯上的用關鍵字索引的
unordered_map< string , unordered_map< string , string > > 結構,
內部通過CSV表第一行第一列單元格的內容進行判斷。
程式碼如下:
標頭檔案:
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
using namespace std ;
typedef unordered_map< string , unordered_map< string , string > > TableWithKey ;
/*
CSV原始網格型資料轉換器。
*/
class CsvRawGridDataConvert
{
public:
CsvRawGridDataConvert( );
~CsvRawGridDataConvert( );
public :
//轉換成帶關鍵字索引的表
static void ToTableWithKey( const vector< vector< string > >& GridData ,
unordered_map< string , unordered_map< string , string > >& Ret ) ;
private :
/*
處理橫向風格的表格
KeyColumnIndex 指定主鍵列
ColumnHeaderIndex 指定列頭的行索引
DataStartIndex 資料列開始的索引
*/
static void ProcessLandscape(
const vector< vector< string > >& GridData ,
unordered_map< string , unordered_map< string , string > >& Ret ,
int KeyColIdx = 0 ,
int HeaderRowIdx = 0 ,
int RecordStartRowIdx = 1
) ;
/*
處理縱向風格的表格
*/
static void ProcessPortrait(
const vector< vector< string > >& GridData ,
unordered_map< string , unordered_map< string , string > >& Ret ,
int KeyRowIdx = 0 ,
int HeaderColIdx = 0 ,
int RecordRowIndex = 1
) ;
};
實現檔案:
#include "CsvRawGridDataConvert.h"
CsvRawGridDataConvert::CsvRawGridDataConvert( )
{
}
CsvRawGridDataConvert::~CsvRawGridDataConvert( )
{
}
void CsvRawGridDataConvert::ToTableWithKey( const vector< vector< string > >& GridData , unordered_map< string , unordered_map< string , string > >& Ret )
{
// 通過 0,0 單元格判斷表型別
auto str = GridData[ 0 ][ 0 ] ;
if ( str == "FieldOrientation=Landscape" )
{
ProcessLandscape( GridData , Ret , 0 , 2 , 3 ) ;
}
else if ( str == "FieldOrientation=Portrait" )
{
ProcessPortrait( GridData , Ret , 1 , 1 , 1 ) ;
}
}
void CsvRawGridDataConvert::ProcessLandscape( const vector< vector< string > >& GridData ,
unordered_map< string , unordered_map< string , string > >& Ret ,
int KeyColIdx ,
int HeaderRowIdx ,
int RecordStartRowIdx )
{
Ret.clear( ) ;
// 獲取列名
const auto& ColHeader = GridData[ HeaderRowIdx ] ;
for ( size_t row = RecordStartRowIdx ; row < GridData.size( ) ; ++row )
{
const string& Key = GridData[ row ][ KeyColIdx ] ;
auto& Row = Ret[ Key ] ;
for ( size_t col = 0 ; col < GridData[ row ].size( ) ; ++col )
{
const string& ColName = ColHeader[ col ] ;
Row[ ColName ] = GridData[ row ][ col ] ;
}
// end for
}
// end for
}
void CsvRawGridDataConvert::ProcessPortrait( const vector< vector< string > >& GridData ,
unordered_map< string , unordered_map< string , string > >& Ret ,
int KeyRowIdx ,
int HeaderColIdx ,
int RecordRowIndex )
{
Ret.clear( ) ;
for ( size_t row = RecordRowIndex ; row < GridData.size( ) ; ++row )
{
const auto& KeyRecord = GridData[ KeyRowIdx ][ RecordRowIndex ] ;
const string& Key = GridData[ row ][ HeaderColIdx ] ;
for ( size_t col = 0 ; col < GridData[ row ].size( ) ; ++col )
{
Ret[ KeyRecord ][ Key ] = GridData[ row ][ col ] ;
}
}
}
CSV程式碼生成器的使用
首先點選選單“CSV檔案 -> 開啟CSV檔案” 開啟一個按照上述規定的CSV表。
可以開啟多個不同的CSV表,這些CSV表以Tab頁的形式排列。
然後可以設定每個表的每個欄位的程式碼生成配置。
如下圖:
上圖中,可以設定欄位的註釋,欄位的型別,欄位的解析函式 等。
然後點“輸出 -> 輸出程式碼”
可以把這個CSV檔案表示的C++類程式碼給生成出來。
生成的C++程式碼如下:
/*
本程式碼由“CSV程式碼生成器”生成。
該軟體作者:Siliphen(李鋒)
CSDN Blog:http://blog.csdn.net/stevenkylelee
*/
#pragma once
#include "SiliphenCodeGenHeader.h"
class Sprite
{
public:
/*
欄位ID
*/
string Id ;
/*
檔名
*/
string FileName ;
/*
位置
*/
Point Position ;
/*
透明度
*/
float Opacity ;
/*
縮放
*/
float Scale ;
/*
本地Z序
*/
int LocalZOrder ;
/*
全域性Z序
*/
int GlobalZOrder ;
/*
錨點
*/
Point AnchorPoint ;
};
// CSV資料錶轉換器
class CsvTableConvertSprite
{
public:
// CSV資料錶轉換成實體資料表
static void Convert( const unordered_map< string , unordered_map< string , string > >& csvTable , unordered_map< string , Sprite >& Table )
{
const string* pStr = 0 ;
for ( auto it = csvTable.begin( ) , end = csvTable.end( ) ; it != end ; ++it )
{
const auto& Ci = it->second ;
Sprite item ;
pStr = &Ci.find( "Id" )->second ;
Parser::ParseString( *pStr , item.Id ) ;
pStr = &Ci.find( "FileName" )->second ;
Parser::ParseString( *pStr , item.FileName ) ;
pStr = &Ci.find( "Position" )->second ;
Parser::ParsePoint( *pStr , item.Position ) ;
pStr = &Ci.find( "Opacity" )->second ;
Parser::ParseFloat( *pStr , item.Opacity ) ;
pStr = &Ci.find( "Scale" )->second ;
Parser::ParseFloat( *pStr , item.Scale ) ;
pStr = &Ci.find( "LocalZOrder" )->second ;
Parser::ParseInt( *pStr , item.LocalZOrder ) ;
pStr = &Ci.find( "GlobalZOrder" )->second ;
Parser::ParseInt( *pStr , item.GlobalZOrder ) ;
pStr = &Ci.find( "AnchorPoint" )->second ;
Parser::ParsePoint( *pStr , item.AnchorPoint ) ;
Table[ item.Id ] = item ;
} // end for
}
};
以上生成的程式碼實際上和cocos2d-x的Sprite類名衝突了。
可以設定生成的實體類名,比如,加一個字首:CiSprite,CfgSprite什麼的。
Ci:ConfigItem 配置項。Cfg:Config。
應該為這些自動生成的類加一個統一的字首或者字尾,防止名字衝突。
我個人比較喜歡字首的做法,這會讓有相同字首的東西以列表形式在一起顯示時排列很整齊。
工具只生成標頭檔案,欄位型別解析轉換的實現程式碼也放到標頭檔案中。
Remark欄位沒生成出來,因為設定中,沒有勾選“是否生成程式碼”。
每個欄位從string的解析函式也生成了。
但解析的過程,是呼叫一些Parser::ParseXXX方法。
每個標頭檔案都會有一句:#include "SiliphenCodeGenHeader.h"。
每次“輸出程式碼”都會複製程式的“Prefabs”資料夾下的所有檔案到目標目錄中。
這個 SiliphenCodeGenHeader.h 標頭檔案就是從 Prefabs資料夾下複製的。
如果使用者想修改SiliphenCodeGenHeader.h 裡面的內容,可以到程式的Prefabs資料夾下修改原始模板。
SiliphenCodeGenHeader.h標頭檔案中,會包含#include "Parser.h",
這個 Parser.h 有一些預設的Parser::ParseXXX 方法的實現。我自己編寫的預設實現 ^_^
如果使用者設定的欄位是一種工具不知道的型別,那怎麼自動生成程式碼呢?
比如有一個是UserCustom型別的欄位。
在“型別”一欄中輸入使用者自定義的型別名。
在”解析函式“一欄中輸入自己實現的解析函式名。
如下圖:
解析函式名的簽名應該是:void ( const string& str , 使用者自定義型別& Ret )
在工具的Prefabs資料夾下編寫MyParser類,
實現 staitcvoid ParseUserCustom(const string& str,UserCustom& Ret ) 函式。
然後在 SiliphenCodeGenHeader.h 中加上一句 #include "MyParser.h" 包含自己寫的類的標頭檔案就可以了。
工具會自動在Convert函式中生成呼叫 MyParser::ParseUserCustom 的語句。
其實,我的工具類似QT的程式碼生成系統。
輸出程式碼的話,會把所有開啟的CSV檔案的一併輸出程式碼到目標目錄下,如下圖:
使用者配置完每個CSV檔案的每個欄位後,希望儲存這些配置以便於下次使用。
有時候,一個CSV表增加了一些欄位,會想用工具再生成一次程式碼,
而之前的配置過的欄位不想再重新配置。
使用者只需要點“配置->儲存配置”,儲存下當前的配置即可。
工具會記下,當前打開了多少個CSV檔案,這些CSV檔案的欄位是如何配置的。
當一些CSV表結構改變時,從CSV中刪除的欄位不會在工具中顯示,
從CSV表中增加的欄位會顯示出來,使用預設的配置。
使用工具生成的程式碼的流程
Setp 1 :
先用指定平臺的檔案讀取函式,把整個CSV檔案從磁碟讀到記憶體。
比如C語言的fopen,Win32的CreateFile , QT,cocos2d-x 引擎的檔案讀取函式。
Setp2 :
使用我的CSV類的Parse方法解析記憶體的CSV資料流。
也許CSV檔案需要加密,使用者自己處理解密再把明文資料傳給CSV類解析。
這一步會得到CSV原始網格資料,資料結構是:vector< vector< string > >
Setp3 :
把 Setp 2 的結果轉換為使用關鍵字Key來索引數值的結構:
unordered_map< string , unordered_map< string , string > >
使用這種資料結構的程式碼更易讀、易維護。
這一步會把各種不同資料組織的錶轉換成統一的格式,遮蔽差異方便下一步處理。
上面我提供了一份自己的實現。
Setp4.
把 Setp 3 的結果,傳給工具自動生成的解析函式。
這一步會輸出另一種指定結構的表,記錄的欄位有專有的資料型別。
到了這一步,表資料才是程式最終能直接使用的資料型別。
工具其實只是做第4步的自動化工作。
注意,工具要求CSV表具備指定的格式,
也就是上面說的縱向和橫向排列的格式。
編寫、使用這個工具的意義
在新的小專案中,我用自己的工具,生成了總共1000多行無趣的C++程式碼。
以後CSV表改變,也是用工具來重新生成一遍程式碼,不人工修改。
也就是說,表結構的維護也交給工具了。
手工去編寫那些程式碼雖然不難,也可能並不費時。
但會耗掉人的精力和時間。
想象一下,如果某個表增加了一個欄位,那麼,我們要去修改2個地方:
1.去那個表對應的class中增加一個欄位。
2.去那個表對應的轉換函式中增加對這個欄位的轉換程式碼的呼叫。
專案的檔案稍微多點後,要找到表對應的class檔案也需要一點精力。
幹嘛要把精力放在這種事上呢,能省一點就算一點。
CSV表的欄位註釋修改了,那麼也要去程式碼中手工修改那個欄位的註釋。
用工具只是點點滑鼠的事,更加方便維護。
用工具其實更切合資料驅動的程式設計思想。
類可以通過CSV表的定義自動生成出來,
也就是說,CSV表的設定驅動了類程式碼的生成。
這剛好就是資料驅動的思想。
總結
自己動手豐衣足食。
寫完這個工具蠻高興的,可以給自己的工作帶來些許方便。
使用工具也是一種流程化的建立,流程化是能提高生產力的。
也許這個工具現在還不完善,在以後的實踐使用中,再慢慢完善它吧。