文本数据库Jardb项目总结(一)
项目地址: https://github.com/andytt/jardb
想法
这两天时间,闲来无事,写了一个基于文件的数据库……用Python写的,效率也不能够有所期望。实现这个数据库,其实是受到了Node.js中有一个lowdb的项目的影响:文本、轻便、API极其好用。不需要支持多线程,不需要支持事务管理,不需要太多的性能。这个数据库主要还是在一些本地应用、小项目里面,提供类似应用配置管理、小型数据存储,给人以方便大概是最大的目的了吧。。。(当然最后的性能测试也超过了我的预期,见后)
其实写这个项目的时候我也有自己的打算,一来用Python多练练面对对象编程。一来,体验一下开源项目的流程。所以在这个项目里面,我花了不少经历提供了详细的文档和单元测试(96%),也算是拿出来给大家献丑了。
其实在已经有一个TinyDb的Py项目了,关注的人挺多,这个项目和我的思路都差不多,自然是需要先好好研究一番,阅读完代码之后,差不多就能够开始自己动手了。
设计之初我就这个几个初步的考虑,要不要使用引入事件机制、亦或者多线程的实现?大概在写完事件循环的部分之后,放弃了这个想法。倒不是因为实现困难,而是由于我的目标是对标小型化的,本地的数据库,轻便好用才是王道,负担重了反而显得不伦不类。
存储格式
存储格式的话,json是一个很好的选择:通用、方便、表达能力足够。。。另外一个就是基于pickle模块的二进制文本。最后便是直接存在内存里面。当然,作为一个超轻的框架,加密之类的功能不再实现,虽然也提供了结构,直接暴露了原始的存储格式,稍加封装,就能够实现这些功能了。最初打开数据库的时候,就需要指明数据库的文件位置和存储类型,用类似URL的格式来实现挺好的,比如:”json://database.db”……
实现起来不难,用一个父类BaseStorage做接口,提供了write和read两个功能,而后由三个子类,分别实现json、pickle、memory的读写功能就行了。为了安全,存储的时候,都会将之前版本改为‘.bac’文件,且在写入的时候,先写入‘.swp’文件中,希望这样做能够提高一点安全性吧。
日志
日志模块,在我看来怕是这个框架的一个败笔。。。本来想实现一个适合这个数据库的日志类提供管理,最后却后悔没有直接用logging。。。只有一个类dblogging,其中根据配置的不同,可以选择把日志输出到终端或者是写进日志文件去。。。为了轻耦合,我选择了装饰器的模式:
def logs(self,func):
'''
A decorator
If self.log is a file path,logs will be write into it.
Otherwise if self.debug is on, logs will be printed in the terminal.
You should know that both of them will cause the performance loss,
especially when logs are shown in the terminal.
'''
def wrapper(*args, **kwargs):
if self.log != '':
self.file.write("Jardb: function [ %s ] \targs:\t" % func.__name__)
self.file.write(str(args)+'\n')
elif self.debug:
print("Jardb: function [ %s ] \targs:" % func.__name__)
print(args)
return func(*args, **kwargs)
wrapper.__doc__ = func.__doc__
return wrapper
其实功能很简单,就是在调用函数之前,把函数名和相关参数全部记录下来,然后再调用这个函数就成。当然,由于被调用的函数提供了Docstring,所以需要最后的wrapper.__doc__ = func.__doc__
这句话,把被调函数文档作为装饰器的文档。
这样实现的话,被调用函数可以不需要调用任何日志相关的函数,只需要在函数定义的时候,打上@logs就行了。缺点也很明显,性能会有影响的,每一次函数调用的时候,都相当于两次函数调用。好在我们对性能并不奢望就是了。
存储结构
在Jardb的数据库设计之中,我想提供类似.plist,.ini,.conf等配置文件的解析的功能,于是提供了一个别的数据库没有的类型DbConfig。通俗的话说就是一个记录,直接查在数据库中,而不是在某张表中。这样的话,少了一级结构,更加简洁。。。另一大类就是通俗的“表”和“记录”的二级结构了。
类名 | 父类 | 从属于 | 备注 |
---|---|---|---|
DbbaseObject | object | None | 抽象接口 |
DbBase | DbbaseObject | None | 数据库 |
DbConfig | DbbaseObject | DbBase | 配置 |
DbTable | DbbaseObject | DbBase | 表 |
DbRecord | DbbaseObject | DbTabel | 记录 |
“库”、“表”、“记录”、“Config”,都是DbBase的子类,实现了统一的结构,包括encode和decode等函数。。。
encode和decode的设计指出的考虑就是我们存储的时候,不可能直接序列化存储这些类和对象,自然需要一个更加紧凑的方法来保存数据和数据间的关系。在Javascript里面有json,python里自然就是dict和list的嵌套结构了。
整个数据库就是一个字典,其中key就是table或者config的名字,而后如果是一个python字典,则表示是一个config,字典的内容就是config的内容。如果是一个list,则代表是一个table,list的第一个元素是一个字典,表述了这个表的性质,类似:{‘id’:[‘AutoIncrease’],’UserName’:[‘NotNull’,’Unique’],’password’:[‘NotNull’]}.
list的第二项还是一个列表,其中记录了所有的记录的值。
所以解析后,一个数据库可能会长这样:
{"Users":[{},[{"Name": "123", "ps": "123"}, {"Name": "1234", "ps": "1234"}]], \
"Article":[{}, [{"Name": "xxx", "Author": "123"}]],\
"config": {"user": 1, "secure": 2}}
而后就能很好的保存,或者解析成json、xml等其他类型了。
数据库操作和查询
操作都定义在了query.py中,其中有一个接口类:BaseQuery和两个子类ConfigQuery和TableQuery。设计的时候受到了lowdb的api的影响,许多成员函数的返回值都是self,也就是查询类的对象自身。这么设计的话,可以很方便的连续操作。
from jardb import jardb
db = jardb('json://database.db') # 定义类
db.open() # 打开数据库、读取内容
col = db.get_table('Users') #获得‘Users’表的Query对象
# 下面就是非常方便的api演示:
# 找到is_admin为False的所有记录,排序后,获得10个记录,然后删除
col.find({'is_admin':False}).sort('id').get(10).remove()
# 找到id为123的记录,更改is_admin 为True。
col.find({'id':123}).update({'is_admin':True})
我还提供了一个用python 逻辑表达式来查询的函数filter,用起来很很方便,记得变量名加$就行了,比如:
# 展示math和chinese都大于90的记录的值。并按照english来排序
col.filter('$math > 90 and $ chinese > 90').sort('english').value()
这里使用了eval,不够安全,所以不要把这个功能直接暴露给使用者。(内置了一个超级简单的检查)
为了保证数据完整性,每一个Table或者给Config仅能够申请一个query对象。重复申请将会应用同一个对象。
自动保存
一直很纠结要不要加上这样一个功能?如果要实现,应该怎么实现?最后还是使用了一个守护进程来实现这样一个功能。。。
首先当数据被修改的时候,需要通知守护进程,使用了另外一个装饰器,做了几件事:
- 获得save_lock锁,由于对数据对象操作的时候,需要封锁整个database内存区域,此时禁止自动保存。
- 执行被调用的函数完成CRUD功能。
- 如果执行期间发生异常,将其写入日志。
- 通知autosave对象,内存有修改
- 放弃save_lock锁。
def getChange(self,func):
'''
A Decorator which notifies the database has been changed.
'''
def wrapper(*args, **kwargs):
self.save_lock.acquire()
try:
ans = func(*args, **kwargs)
except Exception as e:
self.need_change = True
self.save_lock.release()
dblog.write_log("Jardb: Error!")
dblog.write_log(e.args)
return None
self.need_change = True
self.save_lock.release()
return ans
wrapper.__doc__ = func.__doc__
return wrapper
autosave被设计成一个线程类,继承自threading.Thread,当监听到有写入的时候,就实现自动保存:
- 获得save_lock锁,此时,主进程所有写入操作全部暂停,防止读写不完全。
- 解析整个数据库对象,写入自身的一片缓存区域。
- 放弃save_lock锁
- 获取file_lock锁。这个锁是为了防止主进程,手动使用save()等api导致同时向文件写内容。
- 写入缓存区内容
- 放弃file_lock锁。
- 睡眠一会
这是autosave类的run()函数。当监听到主程序退出(_is_run被重置)后,也自动退出。
def run(self):
while self._is_run.is_set():
if self.need_change:
self.save_lock.acquire()
self._backup = self._object.show()
self.need_change = False
self.save_lock.release()
file_lock.acquire()
if self._type == 'json':
with open(self._filename+'.swp','w') as f:
json.dump(self._backup,f)
elif self._type == 'file':
with open(self._filename+'.swp','wb') as f:
pickle.dump(self._backup,f)
if os.path.exists(self._filename+'.bac'):
os.remove(self._filename+'.bac')
if os.path.exists(self._filename):
os.rename(self._filename,self._filename+'.bac')
os.rename(self._filename+'.swp',self._filename)
file_lock.release()
Cache
之前有讲过,DbTable中每一个字段都可以有一些属性,目前设计有三个:
- AutoIncrease 适合于id之类的,每次插入都可以自增。
- Unique 插入的时候检查是否唯一。
- NotNull 插入的是否,不能没有值。
Cache类就是为了使得检查这些属性,以及生成自动编号加速而设计的。通过缓存当前id最大值,和已有的的值,来加速审查。
性能
说起来我自己都吓了一大跳,测试性能的结果超过了我的预期:
测试脚本如下:
import jardb
import datetime
content = {"Users":[{},[{"Name": "123", "ps": "123"}, {"Name": "1234", "ps": "1234"}]], "Article":[{}, [{"Name": "xxx", "Author": "123"}]], "config": {"user": 1, "secure": 2}}
t = []
t.append(datetime.datetime.now())
db = jardb.jardb('json://./database1.db',autosave = True,debug = True)
t.append(datetime.datetime.now())
db.create(content)
t.append(datetime.datetime.now())
db.create_table('Blog',{'id':['AutoIncrease','Unique'],'data':['NotNull','Unique']})
t.append(datetime.datetime.now())
col = db.get_table('Blog')
t.append(datetime.datetime.now())
for i in range(0,1000000):
col.add({'data':i,'star':True,'admin':False,'Username':str(i)})
t.append(datetime.datetime.now())
col.filter("$data % 3 == 0").remove()
t.append(datetime.datetime.now())
col.update({'star':False,'ad':True})
t.append(datetime.datetime.now())
col.filter("$data % 4 == 0").value()
t.append(datetime.datetime.now())
col.find({'star':False}).get(10).sort('id').map('id')
t.append(datetime.datetime.now())
for i in range(1,10):
print((t[i]-t[i-1]).seconds)
在打开自动保存的情况下,结果如下(测试环境:MacBook Pro 16):
0
0
0
0
132
21
0
17
0
结果分析:
* 从结果上来看,性能还是不错的,真的是出乎了我的医疗:100万数据插入就132秒,筛选出33万的数据并删除用了21秒、查找约15万的数据用了17s。
* 然而也是从一定程度上可以理解的,数据模型太简单了;这个数据库太简陋了;对数据库的操作,其实只在内存中进行,而其保存为文件是滞后的,也是缓慢的,从一定程度上可能会带来数据安全方面的考量。
* 从几次的结果来看,打开日志记录和自动保存对性能影响还是挺大的(下降大约1/2),特别是自动保存的是否,可以明显的观测到性能下降了至少1个数量级。
* 这只是非常简单的CRUD操作,涉及到几个表之间的联合查询等复杂功能的是否,就不是Nosql说擅长的方面了,其性能也不得而知。
总结
从设计之初,就没有考虑过使用B+B-B~模型(要追求性能就不可能用python实现了),而且也就在个别地方做了不充分的优化。直接导致这个数据库的缺点非常明显:线程不安全、单例化严重、完全没有并发、内存占用严重(没有优化)。当然带来的好处也是直观的:
- 轻便:不依赖任何第三方库
- 支持:在python2.7以及3.3以上版本中全部通过测试
- 好用:尽可能提供更好用的API
写这个小程序,其实代码本身并没有多少,只有9个文件、1200行代码,其中还有一半是各种注释……从构思到最后上传Pypi大概也就用了3-4天的时间。这么匆忙的时间里面,从构思到实现上都有很多不合理的地方,之后还是要花时间慢慢消化、改进吧。也希望诸君给给意见。
大二的这个寒假已经开始了快十天了,之后时间里,大概想做这么几件事情吧:
- 如果有钱的话买一个stm32玩一玩,毕竟给皓神怂恿着报了电设的课,毕竟是信院的学生!
- 要把《Python自然语言处理》看完!
- 继续维护之前那个markdown编辑器,已经有了很好的改进想法了!
- 如果有时间的话,能不能开始下一个小项目呢?
- 预习下学期课程之类的事情,大概也只有谈健这样的巨佬才会做了吧?
在这一个小小的个人项目之中,我也第一次使用了Travis CI做持续集成、coveralls做单元测试,尽自己的可能提供更加详细的文档和注释,以及一个License和ReadMe。算是一个比较完整的项目吧。也是我第一次上传pypi,虽然被使用的机会不大,但自己还是挺激动的。之后可能会再写一篇文章来记录下使用这些网站和程序来做版本管理的事情。