VBScript 中的简单持久化事务字典





5.00/5 (1投票)
有时我们只有 VBScript,但仍然希望拥有类似数据库的功能、持久化和事务。
以简单性为美
前段时间,我正在做一个项目,用于在事件发生时发送电子邮件。我采取了一种非常简单的方法:“使其尽可能简单,然后使其更简单。”
- 使用 VBScript
- 不使用数据库
- 使其健壮且可重启
解决方案的一部分是一个用纯 VBScript 编写的 Scripting.Dictionary
对象的持久化、半事务性扩展。本文解释了它的工作原理并提供了代码。这种方法在 Python、PHP、Ruby 甚至 C# 等其他语言中都非常容易实现。
持久化和序列化
要将对象从内存持久化,我们需要对其进行序列化。这意味着将内存中分散的数据位提取出来并将其写入文件。文件是顺序字节数据,因此该过程就是序列化(尽管我们实际上可以随机访问文件)。
从 VBScript 序列化任何内容时,一个严峻的挑战是没有二进制格式。序列化和反序列化的最简单方法是使用文本流,其中一行代表一个序列化数据项或一组数据。由于 VBScript 的高速 Join 和 Split 运算符,将一组数据编码为一行最简单的方法是通过某种分隔符。但是,简单地连接字符串是不可行的,因为这些字符串本身可能包含分隔符或换行符。
Escape()
和 Unescape()
派上用场。这些函数最初用于 URL 转义字符串。URL 是 ASCII(每字符 8 位)标准,只允许字母、数字和少数其他符号。这意味着任何字符串,包括 Unicode,都可以被转义成一个永远不会包含换行符且永远不会包含逗号的字符串。因此,我们可以转义所有字符串,然后使用逗号分隔值来分组数据,每组数据占一行。
' This script give the following output
' %u0100%u0200%u0400%u0800%u1000%u2000%u4000%u8000%uFFFF
' -1
' Which shows the hex escaped character String and that the String when escaped and
' the unescaped is identical to the original. IE the Escape and Unescape methods work
' for unicode.
Dim s,es
s=chrw(256) & chrw(512) & chrw(1024) & chrw(2048) & _
chrw(4096) & chrw(8192) & chrw(16384) & chrw(32768) & chrw(65535)
es=Escape(s)
WScript.Echo es
es=Unescape(es)
WScript.Echo s=es
持久化、可重启性和事务日志
此开发的目的是创建一种非常简单的数据持久化方法,以便
- 脚本可以“知道”上次做了什么而重新启动。
- 如果脚本崩溃,或发生其他故障,它可以从中断处恢复。
- 数据存储的格式允许“查找”。
- 我们不必使用像 JET 或 MS SQL Server 这样复杂的数据库解决方案。
从要求 3 和 4 开始,我选择使用 Dictionary
对象。这些在 VBScript 中是标准的,因为它们随“Scripting.Dictionary
”脚本 COM 对象集一起提供。字典允许我们按键查找数据,并且它们不需要数据库。使用我上面讨论的 Escape 和 Unescape 技术,我们可以将 Dictionary
的值和键序列化到文件中。这就剩下要求 1 和 2。我通过事务日志来解决这些问题,并结合了回滚、前滚和提交。
通常,当程序员试图将数据存储到磁盘时,他们会编写代码一次性序列化整个数据结构。这意味着如果在写入文件的过程中程序崩溃,文件就会损坏,并且其中没有有用的数据。这也意味着每次数据更改时,如果该更改需要持久化,则必须再次将整个对象序列化到磁盘。
序列化的一个完全不同的思考方式是将对数据结构的更改存储到磁盘。数据结构是所有对其进行的更改的结果。如果程序以发生的顺序存储每一项更改,那么重新运行这些更改将重新创建数据。这正是许多事务性数据库实现其事务技巧的方式。它们在磁盘上存储过时的数据版本,然后通过写出更改来持久化最新信息。我采取了一种更简单的方法,只存储更改,因此只保留了事务日志。
事务日志方法很棒,但也有一些缺点
- 每次对数据进行更改时,都必须向磁盘写入一次,这会降低性能。
- 如果多个更改属于单个可重启的“工作单元”,那么只写入部分更改而不写入其他更改是不正确的。也就是说,为了可重启性,我们需要能够选择何时持久化更改,并将它们成组持久化。
- 对于异常处理,如果在这些工作单元中的一个发生异常(错误),那么能够回滚整个工作单元将很方便。
这就是回滚、前滚和提交发挥作用的地方。每次对我的字典进行更改时,都会在内存中记录一个撤销更改所需的操作记录,以及一个执行更改的操作记录。这两个记录分别称为回滚和前滚。只有当调用 Commit
时,前滚记录才会被写入磁盘。如果调用 Rollback
,则会反向读取撤销操作,并执行相应的操作来撤销工作单元(事务)中的所有更改。由于系统不是多线程的,因此我不愿称之为真正意义上的事务性。此外,无法让 FileSystemObject.TextStream
对象强制写入磁盘缓存,因此我将我的 TransDictionary
称为“半事务性”。
最后一个问题是,经过很长时间和大量数据更改后,事务日志会变得相当长。始终从完整的更改列表中重新创建数据可能会效率低下。为了解决这个问题,我添加了一个名为 CreateCleanLog
的方法,它写出创建数据所需的最小日志文件。其思路是写入干净的日志,备份旧的日志,然后用干净的日志替换旧的日志。
TransDictionary 的一些使用示例
Option Explicit
Const LogFile="C:\Logs\Log.txt"
Const CleanLogFile="C:\Logs\CleanLog.txt"
Dim transDict,rc
Set transDict = New TransDictionary
' If the log file does not exists, TransDictionary will create it
' otherwise it will read it and load the data containted into the
' dictionary
transDict.LoadLog(LogFile)
If transDict.Exists("Ran Count") Then
rc=transDict.Item("Ran Count")
rc=rc+1
Else
rc=1
End If
WScript.Echo "This script had been run " & rc & " times"
' VBScript will naturally convert the number into a String
' when it is stored. This sort of thing will fail if VBScript
' does not know a good way of converting the value to a String
' in which case you will have to do this yourself
transDict.SetValue "Ran Count",rc
' Now the dictionary has the new value but it is not committed
' so we can roll it back
WScript.Echo "The dictionary has the value " & transDict.Item("Ran Count")
transDict.Rollback
WScript.Echo "After rollabck it has the value " & transDict.Item("Ran Count")
transDict.SetValue "Ran Count",rc
' This commits the change and writes it to disk
transDict.Commit
' This writes a clean log file
transDict.CreateCleanLog CleanLogFile
第一次运行
Output:
The dictionary has the value 1
After rollabck it has the value
logFile:
R,Ran%20Count
S,Ran%20Count,1
cleanLogFile:
S,Ran%20Count,1
第二次运行
Output:
This script had been run 2 times
The dictionary has the value 2
After rollabck it has the value 1
logFile:
S,Ran%20Count,2
cleanLogfile:
R,Ran%20Count
S,Ran%20Count,1
R,Ran%20Count
S,Ran%20Count,2
代码
Class TransDictionary
Dim dictionary,logFile
Dim rollBk,rollFw,lfn,fso,clean
Public Sub Class_Initialize
Set Me.dictionary=CreateObject("Scripting.dictionary")
Set Me.rollBk=CreateObject("Scripting.dictionary")
Set Me.rollFw=CreateObject("Scripting.dictionary")
Me.clean=TRUE
End Sub
' This must be called immediately after the class
' is instantiated so that it can read and write its
' log file
Public Sub LoadLog(logFileName)
Me.lfn=logFileName
Set Me.fso=CreateObject("Scripting.FileSystemObject")
Set Me.logFile=Me.fso.OpenTextFile(Me.lfn,1,true)
While Not Me.logFile.AtEndOfStream
action Me.logFile.ReadLine()
Wend
Me.logFile.Close
Set Me.logFile=Me.fso.OpenTextFile(Me.lfn,8,false)
End Sub
Private Sub Class_Terminate
On Error Resume Next
Me.logFile.Close
End Sub
' This method takes the appropriate action given a row from
' a log file
Private Sub action(line)
Dim row
row=Split(line,",")
If row(0)="S" Then
internalSet Unescape(row(1)),Unescape(row(2))
ElseIf row(0)="R" Then
internalRemove Unescape(row(1))
End If
End Sub
' Adds a Remove record to the log file
Private Sub addRemove(key)
Me.rollBk.Add Me.rollBk.count,"S," & Escape(key) & _
"," & Escape(Me.dictionary.Item(key))
Me.rollFw.Add Me.rollFw.count,"R," & Escape(key)
End Sub
' Adds an Add record to the log file
Private Sub addSet(key,value)
Me.rollBk.Add Me.rollBk.count,"R," & Escape(key)
Me.rollFw.Add Me.rollFw.count,"S," & Escape(key) & "," & Escape(value)
End Sub
' Sets a key,value pair in the internal dictionary. It either adds or replaces
' the pair according it if the key is already in the internal dictionary
Private Sub internalSet(key,value)
If Me.dictionary.Exists(key) Then Me.dictionary.Remove key
Me.dictionary.Add key,value
Me.clean=False
End Sub
' Removes a key,value pair from the dictionary
Private Sub internalRemove(key)
If Me.dictionary.Exists(key) Then Me.dictionary.Remove key
Me.clean=False
End Sub
' This writes all the changes to the internal dictionary since the
' last commit to the log file.
Public Sub Commit
Dim i,c
c=Me.rollFw.Count -1
For i=0 To c
Me.logFile.WriteLine Me.rollFw.Item(i)
' this is the only way to force a flush of the
' text stream object :(
Me.logFile.Close
Set Me.logFile=Me.fso.OpenTextFile(Me.lfn,8,false)
Next
Me.rollFw.RemoveAll
Me.rollBk.RemoveAll
Me.clean=True
End Sub
' This reverts the internal dictionary to the state it was when Commit
' was last called - or if Commit has never been called, to the state it
' was immediately after having read the log file for the first time
Public Sub RollBack
Dim i,c
c=Me.rollBk.Count -1
For i=c To 0 Step -1
action Me.rollBk.Item(i)
Next
Me.rollBk.RemoveAll
Me.rollFw.RemoveAll
Me.clean=true
End Sub
' This creates a new log file which contains only records to
' recreate the internal dictionary. This cannot be done unless
' the internal dictionary is clean (IE no changes since start
' or the last commit). The resultant log file can be used as
' a direct replacement for the current log file and so this
' can be used to reduce the size and read performance hit of the
' log file next time the class is instantiated.
Public Sub CreateCleanLog(newFileName)
If Not Me.clean Then
Err.Raise -1,"Not Me.clean, commit or rollback first"
End If
Me.logFile.Close
Dim olfn
olfn=Me.lfn
Me.lfn=newFileName
Set Me.logFile=Me.fso.OpenTextFile(Me.lfn,2,true)
Dim k
For Each k In Me.dictionary.Keys
addSet k,Me.dictionary.Item(k)
Next
Commit
Me.logFile.Close
Me.lfn=olfn
Set Me.logFile=Me.fso.OpenTextFile(Me.lfn,8,false)
End Sub
' This method adds or replaces a key value pair in the internal
' dictionary. The change will not be reflected in the log file
' until a Commit is made. Rollback will remove the change unless
' a Commit is called and the change will not be persisted until a
' Commit is made.
Public Sub SetValue(key,value)
If Me.dictionary.Exists(key) Then
addRemove key
End If
addSet key,value
internalSet key,value
End Sub
' This method removes all key value pairs from the internal
' dictionary. The change will not be reflected in the log file
' until a Commit is made. Rollback will remove the change unless
' a Commit is called and the change will not be persisted until a
' Commit is made.
Public Sub RemoveAll
Dim k
For Each k In Me.dictionary.Keys
Remove(k)
Next
End Sub
' This method removes a key value pair from the internal
' dictionary. The change will not be reflected in the log file
' until a Commit is made. Rollback will remove the change unless
' a Commit is called and the change will not be persisted until a
' Commit is made.
Public Sub Remove(key)
If Me.dictionary.Exists(key) Then
addRemove(key)
internalRemove(key)
End If
End Sub
' This returns an array of all the values in the internal dictionary
Public Function Items()
Items=Me.dictionary.Items
End Function
' This removes the value associated with passed key in the internal
' dictionary or NULL if the key is not present.
Public Function Item(key)
Item=Me.dictionary.Item(key)
End Function
' This returns an array of all the keys in the internal dictionary
Public Function Keys()
Keys=Me.dictionary.Keys
End Function
' This returns true if the passed key is in the internal dictionary
' and false otherwise.
Public Function Exists(key)
Exists=Me.dictionary.Exists(key)
End Function
End Class