策划们离不开的 Excel
我相信至少在国内的游戏策划圈, Excel 是每天必不可少的存在。倒不是因为要用它制作数值表格,一切文档最终都一定是用 Excel 写的。但作为一个程序员,我相当的痛恨 Excel 文件,就好像我当初痛恨 word 一样。只有几个字就不要保存成 doc 文件啦,可现在已经没有人用 word 了,大家全转去 Excel 了。如果有可能,策划一定愿意在单元格里写脚本的,这样可以将重点标红。
提取 Excel 中的文字信息并不复杂,但真正的麻烦在于 Excel 文件对版本管理工具是极不友好的。甚至你打开一次 Excel 文件再保存关闭,也会生成一个完全不同的新版本。这是因为,文件中记录了最后修改的时间(是的,Excel 不信任文件系统里的时间);还有激活的单元格是哪一个。在这种环境下,多人协作的版本控制工具用起来绝对是一个悲剧。
我大概花了一周时间来试图解决一系列问题。结果不算成功,也不算失败。这里记录一下上周踩过的坑。
问题源于我们的项目中,策划把一切他们能生产的东西都记在了诸多的 excel 表格里。当然,和上世纪的程序员一样,大家都尽量自己维护自己的那块文件,所以即使在版本管理工具下,也基本没有冲突。但是总有那么 1% 的机会,几个人会修改同一张表格的,尤其在项目压力大时,往往实现功能的程序也会打开表格对里面的数据做一些修改。在版本控制工具下,冲突就在所难免了。尤其是我们刚刚让策划从 svn 迁移到 git 下,git 的工作流的复杂性很容易让策划的脑子不够用了(实际上受 Excel 文件格式限制,他们也只需要一个版本备份工具,其它本来就是多余的)。我开始动念头来解决问题。
首先,xlsx 文件其实是一个标准 zip 压缩包,里面打包了一系列 xml 文件。如果仅仅是需要一个文本格式,那么只需要把包解开,用一种非压缩的形式重新打包即可。
对于一些嵌入的图片,只需要用 base64 编码。由于嵌入表格的图片多半不会修改,所以并不会造成版本间的差异。
一开始,我以为这项工作两小时就能搞定,事后发现,太天真了。
我写了一个 lua 的小程序,可以读出 zip 包里的文件,对文件名排序,然后按文件名/内容的次序依次把文件连在一起形成一个大文本文件(其中的2进制内容使用 base64 编码)。这样处理后,xlsx 文件基本就是一个文本文件了。为了对版本管理工具友好,我对 xml 里的标签后增加了适当的分行。这样处理以后,版本管理工具基本能识别出表格数据每个版本的差异。
第2步,可以动手消除一些对版本有影响却对我们没有意义的数据段。比如文件的最后修改时间、激活的单元格等。这样、如果打开一个 excel 文件,保存后就不会产生差异。
那么,这是一个新的文件格式。怎么让 Excel (或 wps 等兼容产品)打开它编辑呢?
虽然第一反应是给 excel 写一个插件。但我知道拿不是一两个小时可以搞定的。所以我选择了一条弯路。写了一个脚本,可以生成一个临时目录/文件,在用户想打开一个自定义格式文件时,先转换为标准的 xlsx 临时文件,让关联的软件(excel 或 wps 等)编辑它。我们可以监控这个文件的变更时间,来即使把临时文件转换回去。当这个临时文件可写时,就表示已经停止编辑这个文件了(excel 对打开的文件有文件锁定)。这时,可以删除临时文件。
让自定义文件格式关联到这个脚本(我用 lua 编写的十多行程序),策划就可以直接双击自定义格式文件编辑了。
我天真的以为这可以解决大部分问题。但一试用就发现了问题。
策划很容易在表格中嵌入 ole 对象,比如他们最喜欢的 visio 绘图。对于 Ole 对象,是以2进制对象形式存在文件里的。但是、visio 这种 COM 对象序列化后有同样的问题:COM 对象其实也是一个包,而包里同样有时间戳。这同样会导致没有修改过的对象每次持久化的结果是不同的。
解决这个问题并不复杂。我注意到 7zip 是可以打开 COM 对象文件的。在网上下载 7zip 的源代码,很容易就写出一个 COM 对象文件的解码程序(大约几十行 C 代码)。我们要做的仅仅是把包里的文件时间抹掉即可。
如果进行到这里就停住,我就不会认为我在浪费时间。起码我解决了一个问题:没有编辑的 excel 文件无论保存多少次都是无差异的。文件格式变成了文本,大致上可(被程序员)读了。
可我贪心的想把问题解决的彻底点,设计一个自己的格式会更好一些。就是一个比 csv 格式强一些的,保留了有限的版面格式信息(包括单元格的宽高、颜色、边框等)的易于用文本编辑器编辑修改的文件。
我们可以把重要的信息都集中放在一起,不重要的格式信息放在另一部分。如果版本冲突,可以自由抛弃一个版本的格式信息而采用另一个版本的,这样不至于损坏数据本身。
csv 格式设计的很糟糕(比如那个双引号)。也不利于做版本比较合并(易于人编辑且容易合并的格式应该是一个单元格一行,而不是按行保存。且 csv 会丢失重要的公式文本。
我设计的格式大致是这样的:
[!sheet] name:名字 [data] A1:123 B1:234 C1:=SUM(A1,B1) A2:Hello World [value] B1:357 [style] ...
在数据段保留有公式信息,把公式计算值放在 value 段。然后把版面信息全部放去 style 段。这样,即使 value 和 style 段全破坏,也可以直接用 excel 打开修复整张表格。有了这种格式,程序员会更愿意用 vi 打开文件直接编辑,他们绝对不想再安装 excel 的(尤其是对 linux 程序员)。找到想修改的单元格比 csv 文件更容易,也可以用最自然的 \ 转义。和 excel 原始格式不同,我们不把 string 保存在表格数据之外的地方(excel 文件会生成一个叫 sharedstrings 的 xml 文件保存所有的字符串,在表格数据里只做 index 引用)。且把更容易引起冲突的公式计算值隔离开(这样,不会因为有人分别修改了 A1 和 B1 而导致 C1 的计算值在两个版本中都不正确)。
当我大致实现完后,我发现我严重低估了 excel 的板式描述的复杂性。要完全复原 excel 里看到的所有版式信息,需要记录和分析很多的数据才能保持一致。全部实现完这样所需要的时间远远超过我的预期,所以我不想在这上面浪费时间了。
当然,做这件事情让我好好了解了一下 excel 文档的结构(大致浏览了 ISO-IEC-29500-1)。这里,我不想把半成品的工具开源。但可以提供一个副产品:我写了一个小程序,可以把 excel 转换为文本。这个工具可以帮助我们在命令行 grep excel 里的数据。需要这个工具的同学可以去我的 github 页面看 xlsx2txt 这个项目 。
另一些值得记一下的东西:
上周我考察了许多 xlsx 的读写库,有 go 版本的,python 版本的,lua 版本的。
lua 版本我找到一个叫 xlsxwriter 的东西。简单的阅读发现,性能上有许多提高的空间。学会一门语言,不在于学会它的语法,而在于熟悉这种语言下解决问题的各种惯用法。比如 lua 的字符串处理简单而强大,你需要熟悉它。
function Xmlwriter._escape_attributes(attribute) attribute = string.gsub(attribute, '&', '&amp;') attribute = string.gsub(attribute, '"', '&quot;') attribute = string.gsub(attribute, '<', '&lt;') attribute = string.gsub(attribute, '>', '&gt;') return attribute end
这个函数有问题吗?
这样写会更好一些:
local escape_attrib_tbl = { ['&'] = '&amp;', ['"'] = '&quot;', ['<'] = '&lt;', ['>'] = '&gt;', } function Xmlwriter._escape_attributes(attribute) attribute = string.gsub(attribute, '[&"<>]', escape_attrib_tbl) return attribute end
如果你想知道能好多少,可以跑一下测试 。