第 11 章 数据文件与持久化
当我们处理数据文件的,一般来说,写文件比读取文件内容来的容易。因为我们可以很好的控制文件的写操作,而从文件读取数据常常碰到不可预知的情况。一个健壮的程序不仅应该可以读取存有正确格式的数据还应该能够处理坏文件(对数据内 容和格式进行校验,对异常情况能够做出恰当处理)。正因为如此,实现一个健壮的读取数据文件的程序是很困难的。
文件格式可以通过使用 Lua 中的 table 构造器来描述。我们只需要在写数据的稍微做一些做一 点额外的工作,读取数据将变得容易很多。方法是:将我们的数据文件内容作为 Lua 代码写到 Lua 程序中去。通过使用 table 构造器,这些存放在 Lua 代码中的数据可以像其他普通的文件一样看起来引人注目。
为了更清楚地描述问题,下面我们看看例子.如果我们的数据是预先确定的格式,比 如 CSV(逗号分割值),我们几乎没得选择。(以后,介绍如何在 Lua中处理 CSV文件)。但是如果我们打算创建一个文件为了将来使用,除了 CSV,我们可以使用 Lua 构造器来我们表述我们数据,这种情况下,我们将每一个数据记录描述为一个 Lua 构造器。将下面的代码
Donald E.Knuth,Literate Programming,CSLI,1992
Jon Bentley,More ProgrammingPearls,Addison-Wesley,1990
写成
Entry{"Donald E. Knuth", "Literate Programming", "CSLI",
1992}
Entry{"Jon Bentley", "More Programming Pearls", "Addison-Wesley",
1990}
记住 Entry{...}与 Entry({...})等价,他是一个以表作为唯一参数的函数调用。所以,前面那段数据在 Lua程序中表示如上。如果要读取这个段数据,我们只需要运行我们的 Lua 代码。例如下面这段代码计算数据文件中记录数:
local count = 0
function Entry (b) count= count + 1end
dofile("data")
print("number of entries:" .. count)
下面这段程序收集一个作者名列表中的名字是否在数据文件中出现,如果在文件中 出现则打印出来。(作者名字是 Entry 的第一个域;所以,如果 b 是一个 entry 的值,b[1] 则代表作者名)
local authors = {} -- a set tocollect authors
function Entry (b) authors[b[1]] = true end
dofile("data")
for name in pairs(authors) do print(name) end
注意,在这些程序段中使用事件驱动的方法:Entry 函数作为回调函数,dofile 处理 数据文件中的每一记录都回调用它。当数据文件的大小不是太大的情况下,我们可以使 用 name-value 对来描述数据:
Entry{
author = "Donald E. Knuth",
title = "Literate Programming",
publisher = "CSLI",
year =1992
}
Entry{
author = "Jon Bentley",
title = "More Programming Pearls",
publisher = "Addison-Wesley",
year =1990
}
(如果这种格式让你想起 BibTeX,这并不奇怪。Lua 中构造器正是根据来自 BibTeX 的灵感实现的)这种格式我们称之为自描述数据格式,因为每一个数据段都根据他的意 思简短的描述为一种数据格式。相对 CSV 和其他紧缩格式,自描述数据格式更容易阅读 和理解,当需要修改的时候可以容易的于工编辑,而且不需要改动数据文件。例如,如果我们想增加一个域,只需要对读取程序稍作修改即可,当指定的域不存在时,也可以赋予默认值。使用 name-value 对描述的情况下,上面收集作者名的代码可以改写为:
<pre name="code" class="csharp">local authors = {} -- a set to collectauthors
function Entry (b) authors[b.author] = true end
dofile("data")
for name in pairs(authors) do print(name) end
现在,记录域的顺序无关紧要了,甚至某些记录即使不存在 author 这个域,我们也只需要稍微改动一下代码即可:
function Entry (b)
if b.author then authors[b.author] = true end
end
Lua不仅运行速度快,编译速度也快。例如,上面这段搜集作者名的代码处理一个2MB 的数据文件时间不会超过1 秒。另外,这不是偶然的,数据描述是Lua 的主要应用 之一,从Lua 发明以来,我们花了很多心血使他能够更快的编译和运行大的 chunks。
11.1序列化
我们经常需要序列化一些数据,为了将数据转换为字节流或者字符流,这样我们就 可以保存到文件或者通过网络发送出去。我们可以在Lua代码中描述序列化的数据,在这种方式下,我们运行读取程序即可从代码中构造出保存的值。
通常,我们使用这样的方式 varname= <exp>来保存一个全局变量的值。varname 部 分比较容易理解,下面我们来看看如何写一个产生值的代码。对于一个数值来说:
function serialize (o)
if type(o) == "number" then
io.write(o)
else ...
end
对于字符串值而言,原始的写法应该是:
</pre><p></p><pre name="code" class="csharp">if type(o) == "string" then
io.write("'", o, "'")
然而,如果字符串包含特殊字符C比如引号或者换行符),产生的代码将不是有效的Lua 程序。这时候你可能用下面方法解决特殊字符的问题:
if type(o) == "string" then
io.write("[[", o, "]]")
千万不要这样做!双引号是针对于写的字符串的而不是针对自动产生的字符串。如 果有人恶意的引导你的程序去使用" ]]..os.execute('rm *')..[[ "这样的方式去保存某些东西 (比如它可能提供字符串作为地址)你最终的 chunk将是这个样子:
varname = [[ ]]..os.execute('rm *')..[[ ]]
如果你 load 这个数据,运行结果可想而知的。为了以安全的方式引用任意的字符串, string 标准库提供了格式化函数专门提供"%q"选项。它可以使用双引号表示字符串并且可以正确的处理包含引号和换行等特殊字符的字符串。这样一来,我们的序列化函数可 以写为:
function serialize (o)
if type(o) == "number" then
io.write(o)
elseif type(o) == "string" then
io.write(string.format("%q", o))
else ...
end
11.1.1保存不带循环的 table
我们下一个艰巨的任务是保存表。根据表的结构不同,采取的方法也有很多。没有一种单一的算法对所有情况都能很好地解决问题。简单的表不仅需要简单的算法而且结 果文件也需要看起来也更美观。
我们第一次尝试如下:
function serialize (o)
if type(o) == "number" then
io.write(o)
elseif type(o) == "string" then
io.write(string.format("%q", o))
elseif type(o) == "table" then
io.write("{\n")
for k,v in pairs(o) do
io.write(" ", k, " = ")
serialize(v)
io.write(",\n")
end
io.write("}\n")
else
error("cannot serialize a ".. type(o))
end
end
尽管他很简单,但他的确很好的解决了问题。只要表结构是一个树型结构(也就是 说,没有共享的子表并且没有循环),他甚至可以处理嵌套表(表中表)。对于所进不整 齐的表我们可以少作改进使结果更美观,这可以作为一个练习尝试一下。(提示:增加一 个参数表示缩进的字符串,来进行序列化)。前面的函数假定表中出现的所有关键字都是合法的标示符。如果表中有不符合 Lua语法的数字关键字或者字符串关键字,上面的代 码将碰到麻烦。一个简单的解决这个难题的方法是将:
io.write(" ", k, " = ")
改为
<pre name="code" class="csharp">io.write(" [")
serialize(k)
io.write(") = ")
这样一来,我们改善了我们的函数的健壮性,比较一下两次的结果:
-- result ofserialize{a=12, b='Lua', key='another "one"'}
-- 第一个版本
{
a = 12,
b = "Lua",
key = "another \"one\"",
}
-- 第二个版本
{
["a"] = 12,
["b"] = "Lua",
["key"] = "another \"one\"",
}
我们可以通过测试每一种情况,看是否需要方括号,另外,我们将这个问题留作一 个练习给大家。
11.1.2保存带有循环的 table
针对普通拓扑概念上的带有循环表和共享子表的 table,我们需要另外一种不同的方 法来处理。构造器不能很好地解决这种情况,我们不使用。为了表示循环我们需要将表名记录下来,下面我们的函数有两个参数:table 和对应的名字。另外,我们还必须记录己经保存过的 table 以防止由于循环而被重复保存。我们使用一个额外的 table 来记录保 存过的表的轨迹,这个表的下表索引为 table,而值为对应的表名。
我们做一个限制:要保存的 table 只有一个字符串或者数字关键字。下面的这个函数 序列化基本类型并返回结果。
function basicSerialize (o)
if type(o) == "number" then
return tostring(o)
else -- assume it is astring
return string.format("%q", o)
end
end
关键内容在接下来的这个函数,saved 这个参数是上面提到的记录己经保存的表的 踪迹的 table。
function save (name, value,saved)
saved =saved or {} -- initialvalue
io.write(name, " = ")
if type(value) == "number" or type(value) == "string" then
io.write(basicSerialize(value),"\n")
elseif type(value) == "table" then
if saved[value] then -- value alreadysaved?
-- use itsprevious name
io.write(saved[value],"\n")
else
saved[value] = name -- save namefor next time
io.write("{}\n") -- createa new table
for k,v in pairs(value) do -- saveits fields
local fieldname = string.format("%s[%s]", name,basicSerialize(k))
save(fieldname, v,saved)
end
end
else
error("cannot save a ".. type(value))
end
end
举个例子: 我们将要保存的 table 为:
a ={x=1, y=2; {3,4,5}}
a[2] = a --cycle
a.z = a[1] --shared sub-table
调用save('a', a)之后结果为:
a = {}
a[1]= {}
a[1][1] = 3
a[1][2] = 4
a[1][3] = 5
a[2] = a
a["y"] = 2
a["x"] = 1
a["z"] = a[1]
(实际的顺序可能有所变化,它依赖于 table 遍历的顺序,不过,这个算法保证了一个新的定义中需要的前面的节点都己经被定义过)
如果我们想保存带有共享部分的表,我们可以使用同样 table 的 saved参数调用 save
函数,例如我们创建下面两个表:
a = {{"one", "two"}, 3}
b = {k = a[1]}
保存它们:
save('a', a)
save('b', b)
结果将分别包含相同部分:
<pre name="code" class="csharp">a = {}
a[1]= {}
a[1][1] = "one"
a[1][2] = "two"
a[2] = 3
b = {}
b["k"] = {}
b["k"][1] = "one"
b["k"][2] = "two"
然而如果我们使用同一个 saved 表来调用 save函数:
local t = {}
save('a', a, t)
save('b', b, t)
结果将共享相同部分:
a = {}
a[1]= {}
a[1][1] = "one"
a[1][2] = "two"
a[2] = 3
b = {}
b["k"] =a[1]
上面这种方法是 Lua 中常用的方法,当然也有其他一些方法可以解决问题。比如, 我们可以不使用全局变量名来保存(chunk 构造一个 local 值然后返回他);通过构造一 张表,每张表名与其对应的函数对应起来等。Lua 给予你权力,由你决定如何实现。