BDSM?你看错了,是DBM.Sqlite3!详解Python官方实现
今天逛 Python 官方文档的时候,发现了一个老瓶装新酒的小玩意,那就是 dbm 模块的新成员。
啥是 dbm?
用官方的话来说,dbm 是 “一个针对多种 DBM 数据库的泛用接口”。
听起来是不是有点抽象?那我们说得简单点:你可以把它当作一个用来 “存字典到硬盘”的小工具!比如你平时用 Python 写个 dict 保存配置或者用户数据,那程序一结束,数据就没啦。而 dbm 呢,它就像是一个神奇的盒子,把这些键值对稳稳地存进文件里,下次启动还能继续用,超级方便!
而且名字里的 DBM,其实是 Database Manager 的缩写,指的就是一类老牌但轻量的 “键值数据库”格式。dbm 模块的厉害之处在于:它能用统一的方式操作不同的 DBM 实现,比如 dbm.gnu、dbm.ndbm、dbm.dump,甚至还可以自动根据你系统支持的情况选一个。
新玩意是啥?
前面这些模块 Python 一直都支持。但在 Python 3.13 版本,dbm 模块新加入了一个熟悉的名字:dbm.sqlite3。sqlite3 大家都很熟,是个强大的嵌入式 SQL 数据库。但有时为了简单地保存点配置、KV 数据,用它就显得小题大做了。而现在,sqlite3 被封装进了 dbm,你可以像操作字典一样使用它,享受 sqlite 的强大,同时保留 dbm 的简单。这下用起来就爽多了了!
咋用的?
用法可谓是相当简单,就是一个open, 一个close即可
demo
from dbm import sqlite3 as dbm
def main():
db = dbm.open("res/data.db", "c")
print(db.get(b'user_id')) # Output: 1
db['user_id'] = 1
print(db.get(b'name')) # Output: John Doe
db['name'] = 'John Doe'
print(db.get(b'age')) # Output: 30
db.setdefault(b"age", b"30")
for k, v in db.items():
print(f"{k.decode()}: {v.decode()}")
print(f"keys: {db.keys()}") # Output: keys: [b'age', b'name', b'user_id']
print(f"values: {list(db.values())}") # Output: values: [b'30', b'John Doe', b'1']
db.close()
if __name__ == '__main__':
main()
第一次运行结果
None
None
None
age: 30
name: John Doe
user_id: 1
keys: [b'age', b'name', b'user_id']
values: [b'30', b'John Doe', b'1']
因为我们第一次本地数据库中还没有内容,所以输出的都是空的
第二次运行结果
b'1'
b'John Doe'
b'30'
age: 30
name: John Doe
user_id: 1
keys: [b'age', b'name', b'user_id']
values: [b'30', b'John Doe', b'1']
第二次就成功的把本地数据库文件的内容存了下来,可以看出很简单
总结
哈哈哈,看到这俩字是不是大失所望?这就结束了?就这就这?居然这么水,呸,拉黑!肯定不会就这么结束的,还请继续往下看
咋实现的?
可以经过上面看到使用起来也是很简单的,但是里面的啥样呢?让我们去瞅瞅:
源码
由于源码并不长,这里就直接贴出来
import os
import sqlite3
from pathlib import Path
from contextlib import suppress, closing
from collections.abc import MutableMapping
BUILD_TABLE = """
CREATE TABLE IF NOT EXISTS Dict (
key BLOB UNIQUE NOT NULL,
value BLOB NOT NULL
)
"""
GET_SIZE = "SELECT COUNT (key) FROM Dict"
LOOKUP_KEY = "SELECT value FROM Dict WHERE key = CAST(? AS BLOB)"
STORE_KV = "REPLACE INTO Dict (key, value) VALUES (CAST(? AS BLOB), CAST(? AS BLOB))"
DELETE_KEY = "DELETE FROM Dict WHERE key = CAST(? AS BLOB)"
ITER_KEYS = "SELECT key FROM Dict"
class error(OSError):
pass
_ERR_CLOSED = "DBM object has already been closed"
_ERR_REINIT = "DBM object does not support reinitialization"
def _normalize_uri(path):
path = Path(path)
uri = path.absolute().as_uri()
while "//" in uri:
uri = uri.replace("//", "/")
return uri
class _Database(MutableMapping):
def __init__(self, path, /, *, flag, mode):
if hasattr(self, "_cx"):
raise error(_ERR_REINIT)
path = os.fsdecode(path)
match flag:
case "r":
flag = "ro"
case "w":
flag = "rw"
case "c":
flag = "rwc"
Path(path).touch(mode=mode, exist_ok=True)
case "n":
flag = "rwc"
Path(path).unlink(missing_ok=True)
Path(path).touch(mode=mode)
case _:
raise ValueError("Flag must be one of 'r', 'w', 'c', or 'n', "
f"not {flag!r}")
# We use the URI format when opening the database.
uri = _normalize_uri(path)
uri = f"{uri}?mode={flag}"
try:
self._cx = sqlite3.connect(uri, autocommit=True, uri=True)
except sqlite3.Error as exc:
raise error(str(exc))
# This is an optimization only; it's ok if it fails.
with suppress(sqlite3.OperationalError):
self._cx.execute("PRAGMA journal_mode = wal")
if flag == "rwc":
self._execute(BUILD_TABLE)
def _execute(self, *args, **kwargs):
if not self._cx:
raise error(_ERR_CLOSED)
try:
return closing(self._cx.execute(*args, **kwargs))
except sqlite3.Error as exc:
raise error(str(exc))
def __len__(self):
with self._execute(GET_SIZE) as cu:
row = cu.fetchone()
return row[0]
def __getitem__(self, key):
with self._execute(LOOKUP_KEY, (key,)) as cu:
row = cu.fetchone()
if not row:
raise KeyError(key)
return row[0]
def __setitem__(self, key, value):
self._execute(STORE_KV, (key, value))
def __delitem__(self, key):
with self._execute(DELETE_KEY, (key,)) as cu:
if not cu.rowcount:
raise KeyError(key)
def __iter__(self):
try:
with self._execute(ITER_KEYS) as cu:
for row in cu:
yield row[0]
except sqlite3.Error as exc:
raise error(str(exc))
def close(self):
if self._cx:
self._cx.close()
self._cx = None
def keys(self):
return list(super().keys())
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def open(filename, /, flag="r", mode=0o666):
"""Open a dbm.sqlite3 database and return the dbm object.
The 'filename' parameter is the name of the database file.
The optional 'flag' parameter can be one of ...:
'r' (default): open an existing database for read only access
'w': open an existing database for read/write access
'c': create a database if it does not exist; open for read/write access
'n': always create a new, empty database; open for read/write access
The optional 'mode' parameter is the Unix file access mode of the database;
only used when creating a new database. Default: 0o666.
"""
return _Database(filename, flag=flag, mode=mode)
逐步分析
我们这里把源码大体分为五个部分:
- 外部函数
- 内部函数
- 常量
- 异常类
- 内部类
外部函数
这个的外部函数就只有一个open函数。使用方法和文件的 open 很像,函数内部是直接返回的内部类_Database的实例
内部函数
这里内部只有一个_normalize_uri。作用是把字符串的路径转为以file:/开头的字符串。我们在看下面使用的地方:
uri = _normalize_uri(path)
uri = f"{uri}?mode={flag}"
try:
self._cx = sqlite3.connect(uri, autocommit=True, uri=True)
except sqlite3.Error as exc:
raise error(str(exc))
可以看出这里是使用的是uri模式,并设置的自动提交,但是我们经查阅 python 的官方文档后,可以发现只是提到了:* 在 3.4 版本发生变更:* 增加了 *uri* 参数。 但是并没有提到 uri 参数到底是啥格式,然后我们在进行找 sqlite3 的官方文档就会发现这里面详细的提到了 uri 的格式,这时我们也就可以理解了他为啥要这么写。
常量
这里的常量主要是分成了两个部分,一个是 sql 语句的定义,一个是错误信息的定义。这种习惯就非常好,看着清晰优雅。
异常类
这里的异常类是一个叫做error的并且继承自OSError。大家可能会比较疑惑异常类为啥不直接继承自Exception呢?其实如果看代码里面使用的地方会发现,也只是返回了个字符串,并没有用的其他的,和正常的也差不多,但是官方还是这么做了,大概是因为明确语义,更加明确了异常的层级,这种写法有没有必要就看大家习惯了
内部类
来到重点了,这里才是所有的逻辑实现的地方。
先看这个的基类:MutableMapping,官方文档是这么解释的:只读的与可变的 映射 的抽象基类,说人话就是来模拟dict的。但是这个了是抽象类,也就是说也要自己实现关于映射的一些相关方法:
-
__len__这个是可以让类的对象进行
len(...)的方法。内部实现就是调用执行 sql 的方法_execute,去执行GET_SIZE这个语句,然后返回对应的结果 -
__getitem__这个是可以让类的对象进行
xxx['xxx']的方法。内部实现同样的执行的 sql 语句,但是这里使用的 sqlite3 的变量替换语法,放在 sql 注入,然后在查找不到的时候抛出了KeyError异常 -
__setitem__这个是可以让类的对象进行
xxx['xxx'] = 123的方法。内部实现同样的执行的 sql 语句 -
__delitem__这个是可以让类的对象进行
del xxx['xxx']的方法。内部实现同样的执行的 sql 语句。这里不同的是添加了rowcount的判断,如果这个是空的也就是没有这个 key,这里也会抛出 KeyError`异常 -
__iter__这个方法是让这类的对方可以支持迭代的方法,也就是后面的
.items()、.keys()、.values()都是依赖的这个函数,然后这个返回要返回的也只是key而已,并不少 key、value 都要返回,只不过在使用items()的时候会自动去调用__getitem__而已。这里的内部实现,是执行的获取所有 keys 的 sql,然后进行yield迭代返回的。 -
keys这个方法其实不属于
MutableMapping抽象基类里面的,但是他的作用大家应该都知道,是获取所以的 key。但是这里他的实现是return list(super().keys())为啥呢,我们先看看与之对应的 values 方法,因为源码里面没有实现values(...)方法,我们就直接使用父类自带的 values(...)试试:print(f"values: {db.values()}") # output: values: ValuesView(<dbm.sqlite3._Database object at 0x103179010>)可以看出这里返回的是
ValuesView,是没有实际内容的,我们在照葫芦画瓢,也用 list 包围一下看看;print(f"values: {db.values()}") # output: values: [b'30', b'John Doe', b'1']如果你在
print(f"values: {db.values()}")这个打下断点之后,同时在__iter__、__getitem__也打断点, 或者是暂时修改下源码也行,比如我暂时修改下源码:def __getitem__(self, key): print(f"getting key:", key) with self._execute(LOOKUP_KEY, (key,)) as cu: row = cu.fetchone() if not row: raise KeyError(key) return row[0] def __iter__(self): try: with self._execute(ITER_KEYS) as cu: for row in cu: print(f"key:", row[0]) yield row[0] except sqlite3.Error as exc: raise error(str(exc)) ...... print(f"values: {list(db.values())}") key: b'age' getting key: b'age' key: b'name' getting key: b'name' key: b'user_id' getting key: b'user_id' values: [b'30', b'John Doe', b'1']会发现使用了 list 包围之后才会真正的去执行对应的方法,是每次迭代获取 key,然后调用
__getitem__这样才达到的 获取 values 的效果,回到我们之前 keys 的问题上面,这时为啥使用list(super().keys())`获取 keys 就显而易见了, 不用过多的解释了
接下来就是其他的一些方法:
-
上下文管理器之
__enter__上下文管理器不必多说,就是用来支持with dbm.open(..) as db:的语法,这里是进入用的,直接返回自身的实例 -
上下文管理器之
__exit__这个是退出上下文时使用的。实现也很简单,直接就是调用自身的close()方法 -
__init__这里开头是判断了是否有_cx属性,其目的就是为了防止重复初始化,如果重复初始化了就使用error抛出自定义的异常。 然后就是对路径的处理,先是使用了 3.10 的新特性match case语法对flag进行了处理,在使用_normalize_uri处理了路径,得到一个可以链接的uri。 在进行了带异常处理的连接行为后进行了下面的代码:with suppress(sqlite3.OperationalError): self._cx.execute("PRAGMA journal_mode = wal")这段代码还是挺有意思的,先看看
PRAGMA journal_mode = wal是啥意思:- PRAGMA:PRAGMA 语句是特定于 SQLite 的 SQL 扩展,用于修改 SQLite 库的操作或查询 SQLite 库的内部(非表)数据。出自PRAGMA 声明_SQlite 中文网
- journal_mode: 此指令查询或设置与当前数据库连接关联的数据库的日志模式. sqlite 官网
- WAL: WAL 日志模式使用预写日志 (而非回滚日志)来实现事务。WAL 日志模式具有持久性;设置后,它会在多个数据库连接之间以及关闭并重新打开数据库后保持有效。处于 WAL 日志模式的数据库只能由 SQLite 版本 3.7.0 (2010-07-21)或更高版本访问
所以上面的 sql 命令其目的就是为了增加性能的,但是呢这个特性并不是 sqlite 所有的版本都支持,在不支持的版本上执行上可能会报错的,按照我们平时的做法是这样的:
try: self._cx.execute("PRAGMA journal_mode = wal") except sqlite3.OperationalError: pass但是这里官方这个给出了个更优雅的做法,就是使用:
with suppress(sqlite3.OperationalError):其意思是和上面一样的,这是官方文档中的介绍可以自行的去看一下:关于contextlib.suppress的官方文档 -
_execute这个是真正执行 sql 语句的地方。流程呢也很简单,先是判断的_cx是否存在,不存在则是没有初始化或没有初始化成功,抛出异常。然后通过带异常处理的方式去执行_cx.execute(...)。但是值得注意的是这里是被closing(...)包裹住的。他其实就是一个快捷的上下文管理器,可以在退出上下文的时候自动去执行目标上面的close()方法,还记得我们之前调用_execute方法的时候也都是使用的with self._execute(...) as cu的形式来用的,这正是closing(...)功劳。关于contextlib.closing的官方文档 -
close方法 这个就是调用sqlite实例_cx的close关闭方法了
我上我也行?
到这里是不是感到很简单啊,有没有种我上我也行的感觉?你的感觉是对的。如果我们要把pydantic和dbm.sqlite3结合在一起该怎么弄呢?
代码
话不多说直接上代码:
import os
import sqlite3
from contextlib import suppress, closing
from pathlib import Path
from sqlite3 import Connection
from pydantic import BaseModel, PrivateAttr
BUILD_TABLE = """
CREATE TABLE IF NOT EXISTS Dict (
key BLOB UNIQUE NOT NULL,
value BLOB NOT NULL
)
"""
STORE_KV = "REPLACE INTO Dict (key, value) VALUES (CAST(? AS BLOB), CAST(? AS BLOB))"
ITER_ALl = "SELECT * FROM Dict"
class Error(OSError):
pass
_ERR_CLOSED = "DBM object has already been closed"
def _normalize_uri(path):
path = Path(path)
uri = path.absolute().as_uri()
while "//" in uri:
uri = uri.replace("//", "/")
return uri
class Database(BaseModel):
_cx: Connection | None = PrivateAttr(default=None)
@classmethod
def open(cls, path: str | Path, /, flag: str = 'c', mode: int = 0o666):
ins = cls()
path = os.fsdecode(path)
match flag:
case "r":
flag = "ro"
case "w":
flag = "rw"
case "c":
flag = "rwc"
Path(path).touch(mode=mode, exist_ok=True)
case "n":
flag = "rwc"
Path(path).unlink(missing_ok=True)
Path(path).touch(mode=mode)
case _:
raise ValueError("Flag must be one of 'r', 'w', 'c', or 'n', "
f"not {flag!r}")
# We use the URI format when opening the database.
uri = _normalize_uri(path)
uri = f"{uri}?mode={flag}"
try:
ins._cx = sqlite3.connect(uri, autocommit=True, uri=True)
except sqlite3.Error as exc:
raise Error(str(exc))
# This is an optimization only; it's ok if it fails.
with suppress(sqlite3.OperationalError):
ins._cx.execute("PRAGMA journal_mode = wal")
if flag == "rwc":
ins._execute(BUILD_TABLE)
with ins._execute(ITER_ALl) as cu:
_data: dict = cu.fetchall()
for _item in _data:
setattr(ins, _item[0].decode(), _item[1].decode())
return ins
def _execute(self, *args, **kwargs):
if not self._cx:
raise Error(_ERR_CLOSED)
try:
return closing(self._cx.execute(*args, **kwargs))
except sqlite3.Error as exc:
raise Error(str(exc))
def __setattr__(self, key: str, value):
super().__setattr__(key, value)
if key in self.model_fields:
self._execute(STORE_KV, (key, value))
def close(self):
if self._cx:
self._cx.close()
self._cx = None
关键点:
- 类继承自BaseModel,这个是
pydantic模型的基类,继承自这个没啥问题 - 添加类方法
open(...)这里采用的是类方法而不是重写__init__的方式,是因为pydantic对__init__进行了特殊处理,重写起来很不方便。open方法内容除了之前初始化的内容外添加的就是执行了获取所有数据,然后通过setattr进行实例的初始化 - 内部变量:
_cx,由于是基于pydantic的,所以要使用PrivateAttr(default=None)证明是隐私属性 __setattr__方法,会在包含在model_fields内的字段进行更新close直接复制的
总结
这会真的结束了,从突然发现官方文档出现了个新玩意,到写示例、看源码、对于不会的特性去查文档查资料,最后进行自己的扩展。相信大家应该感觉到我上我也行了吧!