本文乃Siliphen原创,转载请注明出处:http://blog.csdn.net/stevenkylelee
更有开发效率地使用CSV文件
为了更有效率地使用CSV文件,我制作了一个工具:Code代码生成器。
这个工具可以对CSV文件进行简单地配置,自动生成这个CSV文件对应的C++数据结构和字段类型解析函数代码。
工程项目只要加入这些自动生成的代码,就可以更方便地使用来自CSV配置文件的数据。
用工具自动生代码,可以省去了手工编写、手工维护那些大量的、无聊繁琐的类型定义、数据转换的代码的过程,
还可以防止手工编程可能的错误。
工具截图如下:
CSV代码生成器的下载地址
http://download.csdn.net/detail/stevenkylelee/8546461
程序的CSV文件夹下有一些CSV文件,可供参考。
回顾一下之前写的CSV类
上次发的CSV解析类:《CSV文件格式解析器的实现:从字符串Split到FSM》
http://blog.csdn.net/stevenkylelee/article/details/38440663
资源下载页面评论的反响还不错。
无意中在网上也看到一些人基于我写的CSV类进行扩展、修改、发表博文的,
比如:《Cocos数据篇[3.4](5) ——CSV文件解析》
http://shahdza.blog.51cto.com/2410787/1614802
这哥们貌似对我的类进一步做了封装,支持更灵活、更细粒的数据访问。
自从写了那个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表的设置驱动了类代码的生成。
这刚好就是数据驱动的思想。
总结
自己动手丰衣足食。
写完这个工具蛮高兴的,可以给自己的工作带来些许方便。
使用工具也是一种流程化的建立,流程化是能提高生产力的。
也许这个工具现在还不完善,在以后的实践使用中,再慢慢完善它吧。