烦恼一般都是想太多了。

0%

PIL.15.数据文件与序列化

当我们在处理数据文件的时候,写入总是比读出更加的容易。因为我们在写入一个文件的时候,我们很清楚的知道我们会写入什么东西;但读文件的时候呢,我们就不能预知我们会读到什么内容了。一个健壮的程序不仅要考虑正确的文件中包含的数据,还要优雅的处理那些不好的文件数据。因此,写出健壮的输入函数是比较困难的。本章我们会介绍使用 Lua 来避免所有这些读取数据的代码,而只需要将这些数据以合适的形式写出就行了。确切点说,我们将数据写出成为 Lua 程序,在运行的时候重建数据。

使用一个完全的程序语言来进行数据描述虽然很灵活,但是会带来两个问题。一个是安全问题,”数据” 文件可能会在我们的程序中疯狂运行。我们可以通过将这些文件在沙盒中运行来避免。

另外一个问题就是性能。Lua 不仅运行快,而且编译也很快。

数据文件

表构造器 提供了一个非常有趣的文件格式。在写出文件的时候做很少的额外工作,数据的读回变得非常简单。这个技巧就是将我们的数据文件写成 Lua 代码,然后在执行的时候进行数据的重建工作。使用 表构造器 ,这些文件种的 chunks 看起来就和原始的数据文件一什么区别。

我们来看一个例子。如果我们的数据文件存在于一种预定义的格式中,比如是 CSV 或者 XML,我们只有很少的选择。然而,如果我们将要做的事情是建立我们自己的文件,那么我们就可以使用 Lua 的表构造器作为我们的格式,我们将每个数据机构表示为一个 Lua 的构造器。我们不会向下面这样写文件:

Donald E. Knuth,Literate Programming,CSLI,1992
Jon Bentley,More Programming Pearls,Addison-Wesley,1990

而是会这样写:

Entry{"Donald E. Knuth",
"Literate Programming",
"CSLI",
1992}

Entry{"Jon Bentley",
"More Programming Pearls",
"Addison-Wesley",
1990}

这里我们需要记住的是,Entyr {}Entry( {} ) 是一样的,都是调用一个以表作为单个参数的函数,事实上,当参数是字符串的时候我们也可以这样做。这样,当我们在定义好了一个适当的 Entry 函数后,我们就直接执行我们的数据文件了,简而言之,下面的例子会统计文件中记录的数量:

local count = 0
function Entry() count = count + 1 end
dofile("data")
print("number of etnries:" .. count )

下面这个例子就会将作者的姓名放在一个集合中,并打印出来:

local authors = {}      -- a set to collect authors
function Entry (b) authors[b[1]] = true end
dofile("data")
for name in pairs(authors) do print(name) end

在这个例子中,注意一下这中 事件驱动 的方式,Entry 函数表现得就像是一个回调函数,其在 dofile 的调用中,每个数据项都会调用一次。

当文件的大小不在我们考虑范围内时,我们可以使用 key-value 对来进行表示:

Entry{
author = "Donald E. Knuth",
title = "Literate Programming",
publisher = "CSLI",
year = 1992
}

Entry{
author = "Jon Bentley",
title = "More Programming Pearls",
year = 1990,
publisher = "Addison-Wesley",
}

我们将这种形式称为 自描述形式 ,因为每个数据都已经有了一个简短的描述了。这种形式更加的易读;必要的时候编辑起来也很方便;还允许我们对形式做一些小的改变而不需要改变全部的数据文件。比如,当我们添加了一个新字段的时候,我们只需要在程序内做很小的改动就可以在这个字段不存在的时候给予一个默认值。

local authors = {}      -- a set to collect authors
function Entry (b) authors[b.author] = true end
dofile("data")
for name in pairs(authors) do print(name) end

当某些项没有 author 时,我们可以在程序中进行适配:

function Entry (b)
authors[b.author or "unknown"] = true
end

序列化

我们经常需要序列化数据,也就是说将它们转换为字节流或者是字符串,这样我们就可以将它存在一个文件中或者通过网络传输。我们可以用 Lua 代码的形式来表示序列化的数据,而在执行代码的时候,可以在程序中重建数据。

通常,如果我需要恢复一个全局变量,我们的 chunk 看起来会是 varname = exp 的形式,这里,exp 是一个用来创建值的表达式。我们来看看怎么样写一个创建值的代码。对于数字类型的值:

function serialize (o)
if type(o) == "number" then
io.write(tostring(o))
else
end
end

如果我们将一个浮点数以十进制的形式写出,我们会有丢失精度的危险。我们可以用16进制的形式来避免这个问题。使用 %a% 格式化符,读入的浮点值会和原始的数据一致。在 Lua 5.3 后,我们必须区分整型与浮点数,以便他们能正常的恢复:

local fmt = {integer = "%d", float = "%a"}

function serialize (o)
if type(o) == "number" then
io.write(string.format(fmt[math.type(o)], o))
else other cases

对于字符串就更简单了:

if type(o) == "string" then
io.write("'", o, "'")

但是,但字符串有特殊字符的时候(引号或者换行)那么写出的数据文件将不是一个有效的Lua 程序。
我们可能会尝试改变引用符号来解决这个问题:

if type(o) == "string" then
io.write("[[", 0, "]]")
end

请注意一下代码注入。如果一个恶意的用户想将你的程序重定向来保存一些如 “ ]]..os.execute(‘rm *‘)..[[ “(具体而言,他将这些字符串以地址的形式提供),你最终的 Lua 程序将会这样的:

varname = [[ ]]..os.execute('rm *')..[[ ]]

你在加载数据文件的时候,将会有非常坏的结果。

一个简单且安全的方式就是使用 %q 格式化符。这个选项被设计来让我们可以以一种 Lua 安全的读回的形式来保存字符串。其会用双引号将字符串包起来,加上合适反引特殊字符。

a = 'a "problematic" \\string'
print(string.format("%q", a)) --> "a \"problematic\" \\string"

使用这个特性,我们的序列化函数就会有变化了:

function serialize (o)
if type(o) == "number" then
io.write(string.format(fmt[math.type(o)], o))
elseif type(o) == "string" then
io.write(string.format("%q", o))
else other cases
end
end

5.3.3 扩展了 %q 选项,以使其与数字型进行工作(包括 nil 和 Boolean)。现在我们的序列化函数更可以变化了:

function serialize (o)
local t = type(o)
if t == "number" or t == "string" or t == "boolean" or
t == "nil" then
io.write(string.format("%q", o))
else other cases
end
end

另外一种保存字符串的形式是对长字符串使用注释 [=[...]=]。然而,这个注释主要还是为了在我们不想要改变某些字符串的怎么值时,来进行代码的编写。在自动生成的代码中,想比 %q 其会更容易的反引有问题的字符

如果想要对进行自动代码生成使用长字符串的注释,那么就需要注意一些细节了。

  1. 选择合适数量的等号。一个合适的数字要比原始字符串中出现等号数量的最大值大。 因为包含长等号序列的字符串是常见的(例如,注释分隔了源代码的一部分),所以我们应将注意力集中在用方括号括起来的等号序列上。
  2. 第二个细节是Lua总是忽略长字符串开头的换行符。避免此问题的一种简单方法是始终添加一个要忽略的换行符。

我们可以像下面那样对任意字符串进行引用

function quote (s)
-- find maximum length of sequences of equals signs
local n = -1
for w in string.gmatch(s, "]=*%f[%]]") do
n = math.max(n, #w - 1) -- -1 to remove the ']'
end

-- produce a string with 'n' plus one equals signs
local eq = string.rep("=", n + 1)

-- build quoted string
return string.format(" [%s[\n%s]%s] ", eq, s, eq)
end

其会将任意字符格式化长字符注释形式。

  1. 调用 gmatch 来创建一个迭代器,遍历每个出现符合形式 ]=*%f[%]]的字符串,其目的出得出出现了连续 = 号的最大值。
  2. 将等号数量设置为最大值 + 1。
  3. 格式化。同时还会加上一个始终会被忽略的 \n

(我们可能会想使用更简单的模式’] = *]’,该模式在第二个方括号中不使用前向引用模式,但是这里有一个微妙之处。假设字符串为“] =] ==]”。第一个匹配是“] =]”,其后,字符串中剩下的是“ ==]”,因此没有其他匹配;在循环的最后,n将是一个而不是两个。前向引用模式不会占用括号,因此在接下来的匹配中它将保留在字符串中。)

保存表 - 无循环

下一个任务是保存表。有几种方式来完成这个任务,这需要根据我们对这个表的结构的假设来。没有单一的算法能够满足所有的情况。简单的表可以使用简单的算法,同时其输出也更加的简洁。

function serialize (o)
local t = type(o)
if t == "number" or t == "string" or t == "boolean" or
t == "nil" then
io.write(string.format("%q", o))
elseif t == "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(string.format(" [%s] = ", serialize(k)))

这样修改后,我们高了函数的健壮性

serialize{a=12, b='Lua', key='another "one"'}

这个表之前的输出是:

{
a = 12,
b = "Lua",
key = "another \"one\"",
}

而现在则变成这样了。

{
["a"] = 12,
["b"] = "Lua",
["key"] = "another \"one\"",
}

实际上我们也可以在写出键的时候测试一下其是否需要 方括号。

保存表 - 有循环

为了处理通用结构的表,(比如有共享表和循环)我们需要一种不同的方式。构造器无法创建这样的表,所以我们不会使用它。为了表示循环,我们需要名称,所以我们的下一个函数会将要保存的表及其名称作为参数进行处理。同时,我们必须跟踪我们已经保存的表的名字,在我们的遇到的循环的时候进行使用。我们会使用一个额外的表来进行跟踪,这个表会使用之前那些保存的表作为索引,而使用名称来作为其值。

function basicSerialize (o)
-- assume 'o' is a number or a string
return string.format("%q", o)
end

function save (name, value, saved)
saved = saved or {} -- initial value
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 already saved?
io.write(saved[value], "\n") -- use its previous name
else
saved[value] = name -- save name for next time
io.write("{}\n") -- create a new table
for k,v in pairs(value) do -- save its fields
k = basicSerialize(k)
local fname = string.format("%s[%s]", name, k)
save(fname, v, saved)
end
end
else
error("cannot save a " .. type(value))
end
end

这里我们限制我表内只有整型和字符串两种键。函数 basicSerialize 用来序列号这两种类型,返回结果。save 函数就干了比较难的活。saved 参数保存的是已经保存了的表。看我们下面的例子:

a = {x=1, y=2; {3,4,5}}
a[2] = a -- cycle
a.z = a[1] -- shared subtable

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]

输出的顺序可能会不同,这依赖于遍历表的顺序。这个算法保证在新的定义中新的节点都是已定义的。

如果我们想将有共享部分的几个字进行保存,我们可以用相同的 saved 参数来调用 save() 函数。如:

a = {{"one", "two"}, 3}
b = {k = a[1]}

如果我们想单独的保存他们,结果将不会有相同的部分。然而,如果我们使用相同的 saved 表,那么结果就会是有相同部分的:

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]