BDSM?你看错了,是DBM.Sqlite3!详解Python官方实现

341 阅读13分钟

BDSM?你看错了,是DBM.Sqlite3!详解Python官方实现

今天逛 Python 官方文档的时候,发现了一个老瓶装新酒的小玩意,那就是 dbm 模块的新成员。

啥是 dbm?

用官方的话来说,dbm 是 “一个针对多种 DBM 数据库的泛用接口”。

听起来是不是有点抽象?那我们说得简单点:你可以把它当作一个用来 “存字典到硬盘”的小工具!比如你平时用 Python 写个 dict 保存配置或者用户数据,那程序一结束,数据就没啦。而 dbm 呢,它就像是一个神奇的盒子,把这些键值对稳稳地存进文件里,下次启动还能继续用,超级方便!

而且名字里的 DBM,其实是 Database Manager 的缩写,指的就是一类老牌但轻量的 “键值数据库”格式。dbm 模块的厉害之处在于:它能用统一的方式操作不同的 DBM 实现,比如 dbm.gnudbm.ndbmdbm.dump,甚至还可以自动根据你系统支持的情况选一个。

新玩意是啥?

前面这些模块 Python 一直都支持。但在 Python 3.13 版本,dbm 模块新加入了一个熟悉的名字:dbm.sqlite3sqlite3 大家都很熟,是个强大的嵌入式 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关闭方法了

我上我也行?

到这里是不是感到很简单啊,有没有种我上我也行的感觉?你的感觉是对的。如果我们要把pydanticdbm.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 直接复制的

总结

这会真的结束了,从突然发现官方文档出现了个新玩意,到写示例、看源码、对于不会的特性去查文档查资料,最后进行自己的扩展。相信大家应该感觉到我上我也行了吧!