从peewee切换到tortoise-orm异步orm库的过程记录,主要为tortoise-orm的使用记录。

引入

之前编写的很多kook平台的机器人都是基于python的异步框架khl.py的,关于khl.py可以去wiki网站khl-py.eu.org上了解一下,作为一个对接kook平台的sdk,khl.py易用性还不错,上手也很容易。

最开始,我的机器人都是直接使用json文件作为“数据库”的,可以和python的dict直接对接,效果其实还不错,但数据到了一定量级之后,json文件写入硬盘的时候(因为json无法实现实时保存)经常会出现异常情况,比如断头了或者断尾了,此时数据就损坏了。

于是我开始切换成真正的数据库MySQL。最开始用的是pymysql, 这个库是一个很基础的mysql链接库,需要手写sql进行操作。后来发现这样弄太麻烦了,于是切换成了orm库peewee。orm库的作用就是将数据库的表以数据结构的形式展现在编程语言中,我们使用预先封装好的函数和这些数据结构进行操作,由orm库将这些操作转成对应的sql,再去操作数据库。

使用peewee之后是方便了非常多,但还有一个问题,khl.py是异步的库,而peewee不是。在先前,我的所有操作都是查询、插入、更新这些简单的操作,每次都是只涉及一张表的,问题还不算很大。但在我的商店机器人中,购买商品的时候就涉及到了多个数据库,此时就需要引入事务了。而peewee无法在异步上下文中依照预期处理事务。

比如商店机器人的购买操作涉及到发起购买、扣钱、给出奖励、插入购买记录四个操作。其中有两个操作涉及到了数据库。在之前经常会出现扣了钱之后,机器人无法自动给出奖励的情况(奖励是给用户上一个kook的角色)。此时就变成了用户钱扣了但是没拿到货了,很是尴尬。

这里扣的“钱”是机器人记录的虚拟金币,不是真的钱。

虽然我可以将这四个步骤的顺序改成购买、给出奖励、扣钱、插入购买记录,但是这样也可能会有问题,万一扣钱失败了,那不就等于白送人家了?而且还不容易被用户发现自己钱没扣。所以,最好的方式还是给这种情况上一个事务,保证用户的钱扣了,东西也能给到用户,最终正常插入购买记录。

在同步的上下文中,这个流程如下:

1
2
3
4
5
6
7
收到请求
事务开启
扣钱
给出奖励
插入购买记录
事务结束
发送购买成功消息给用户

但是在异步上下文中,这个流程会有变化,假设A和B是两个异步上下文(协程):

1
2
3
4
5
6
7
8
A 收到用户丁的请求
A 事务开启
A 用户丁:扣钱
B 异步切换:另外一个用户甲扣除了200金币
A 用户丁:给出奖励
B 异步切换:另外一个用户乙获得了300金币
A 用户丁:插入购买记录
A 事务结束

此时我们A提交的事务,虽然我们想达成的目标是让其只和A异步上下文做过的事情有关系, 但在异步切换了之后,如果A操作的这个事务被回滚,B做的操作也会被回滚,这就不符合我们的预期了。因为A和B异步上下文在做的操作之间没有任何关系,回滚其他协程的操作不符合数据一致性。

所以,我需要将不支持异步上下文的orm库peewee更改为原生支持异步上下文的tortoise-orm,才能在异步场景中正常处理事务,避免出现上文所述情况。

安装tortiose-orm

安装方式很简单,用pip安装就行了

1
pip install tortoise-orm

基本使用

创建数据库和表

以mysql和sqlite3为例,tortoise-orm使用的是数据库连接字符串来连接目标数据库的。所以可以初始化如下。这里的config是从一个json配置文件中读取出来的,连接字符串的每个部分可以通过config的key看出来是什么内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 数据库连接配置
db_config = {
'connections': {
'default':
f"mysql://{config['sql']['user']}:{config['sql']['password']}@{config['sql']['host']}:{config['sql']['port']}/{config['sql']['database']}"
if config['sql']['type'] == 'mysql'
else f"sqlite://{config['sql']['database']}.db"
},
'apps': {
'models': {
'models': ['__main__'],
'default_connection': 'default',
}
}
}

注意,数据库连接配置中的models指定了数据库定义文件的模组位置,需要修改为你定义了你的表结构的文件名字。比如你的表对应的类结构是在utils/data.py里面配置的,这里也需要对应修改Models,在列表中添加'utils.data',否则会出现如下异常错误。

1
tortoise.exceptions.ConfigurationError: default_connection for the model <class 'utils.data.Types.RollLog'> cannot be None

配制好了这个config之后,使用如下方式创建数据库和表。注意,调用下面这个函数之前,要保证表的类已经被注册了,否则自然是无法创建的。

因为tortoise-orm是一个异步的库,所以所有操作都需要在async函数下用await执行。tortoise-orm中有一个run_async函数,可以直接在同步上下文中运行async函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from tortoise import Tortoise, run_async
# 对其他表类进行声明
# ...
# 声明结束后调用下面的函数
async def tortoise_init_db():
"""初始化数据库"""
await Tortoise.init(
db_url=db_config['connections']['default'], # 数据库连接URL
modules={'models': db_config['apps']['models']['models']} # 模型模块
)
await Tortoise.generate_schemas() # 建表

# 运行
run_async(tortoise_init_db())

表的注册方式如下,需要继承model类,然后通过fields来定义每一个字段。类中需要有一个子类Meta,标注这个表的表名和唯一键约束,这样就ok了。具体的字段类型可以通过代码补全在fields的成员中看一下,和数据库的字段类型基本都是对的上的,问题不大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from tortoise import fields
from tortoise.models import Model
from tortoise import Tortoise

class UserLog(Model):
"""用户信息存放"""

id = fields.IntField(pk=True)
guild_id = fields.CharField(max_length=24, null=False)
user_id = fields.CharField(max_length=12, null=False)
coin_num = fields.IntField(default=0)

class Meta:
table = "userlog"
unique_together = (("guild_id", "user_id"),)

def __str__(self):
return str(self.__dict__)

备注:做CRUD操作的时候,orm库一般都会自动为我们提交事务,插入数据到数据库中,不需要手动commit了。

查询数据

使用get函数,传入对应参数,即可获取到数据。不存在时会抛出DoesNotExist异常。

1
user_log = await UserLog.get(guild_id=guild_id, user_id=user_id)

如果不想让他抛出异常,可以使用get_or_none函数来替代,找不到数据的时候会返回None。

插入数据

插入数据的方式如下,使用create函数即可。

1
user_log = await UserLog.create(guild_id=guild_id, user_id=user_id, coin_num=coin_num)

还可以使用get_or_create函数,当数据不存在的时候创建,数据存在的时候获取。这样就避免了我们手动查询一下数据是否存在再去插入或者获取的麻烦。

1
2
3
4
user_log,status = await UserLog.get_or_create(
guild_id="3566823018281801",
user_id="1000017244"
)

这个函数有两个返回值,第一个返回值是一个UserLog对象,第二个返回值是一个bool值,代表是否执行了create操作。如果数据库中已有该数据,则返回为False;不存在该数据,会插入新数据并返回True。

更新数据

更新数据的方式和peewee类似,都是使用对象的save函数,只是tortoise-orm的save函数需要用await执行而已。

1
2
3
user_log = await UserLog.get(guild_id=guild_id, user_id=user_id)
user_log.coin_num = new_coin_num
await user_log.save() # 保存修改

如上方式是更新单个数据,如果更新多个数据,可以使用如下方式。filter中传入的是过滤字段,即sql中where的部分;update传入的是需要更新的数据。最终返回值是更新了多少条记录。

1
ret = await UserLog.filter(guild_id=guild_id).update(coin_num=100)

删除数据

删除数据使用的是delete函数

1
2
user_log = await UserLog.get(guild_id=guild_id, user_id=user_id)
await user_log.delete()

手动处理事务

前文提到了异步上下文中的事务,使用方式如下

1
2
3
4
5
6
7
from tortoise.transactions import in_transaction

async def manual_transaction_func():
async with in_transaction() as connection:
user_log = await UserLog.create(guild_id="123", user_id="789", coin_num=50, using_db=connection)
user_log.coin_num = 200
await user_log.save(using_db=connection)

在async with结构体中的所有数据库操作都会被自动提交或者回滚。

执行sql

tortoise-orm也支持取出数据库连接,直接执行sql语句。

1
2
3
conn = Tortoise.get_connection("default")
await conn.execute_script(f"""执行多条sql""")
await conn.execute_query("执行单条sql")

如果有需要,可以用这种方式来执行sql。

和peewee的差别

这两个orm库在CRUD这方面的差距主要为函数方面,peewee在插入的时候是直接通过类名的构造函数创建出一个对象,然后使用save方式插入到数据库中。而tortoise-orm提供了get、create、update等方法来直接插入或者更新数据。不过peewee创建对象的方式在tortoise-orm中也支持。

另外,在查询的时候,peewee的名称是select,tortoise-orm的名字是filter。

其他方面的差距就不大了,遇到其他巨大差异的时候再来此处记录。

这两个库最大的差距还是tortoise-orm支持异步场景下的事务,这个挺重要的,上文的场景已经提到过为什么我需要一个异步的事务支持了。

还需要注意的是,tortoise-orm库支持了异步await的save操作之后,就更容易出现由于协程并发导致的数据不一致的问题。此时可以用如下方式打开tortoise-orm的日志,进行问题定位。

1
2
3
4
5
6
7
import logging

# 配置日志格式和级别
logging.basicConfig(level=logging.DEBUG)
# 设置 tortoise 和 SQLAlchemy 的日志级别
logging.getLogger("tortoise").setLevel(logging.DEBUG)
logging.getLogger("db_client").setLevel(logging.DEBUG)

解决异步数据冲突的问题,可以在save的时候提供update_fields参数(一个str的列表),手动指定需要更新的数据列,减少当前save操作和其他协程中的save操作出现的字段冲突,如果两个save函数调用操作的不是相同的字段,那么冲突就会大幅减少,sql执行效率也会增加。

1
await obj.save(update_fields=['列1','列2'])

举个简单的例子,直接调用save函数,orm会执行全列更新,即将当前对象的所有字段都写入update语句中。比如下面这个update语句(开启debug日志后可以看到)

image.png

但我们指定了需要更新的列的时候,orm就可以直接通过主键来更新对应列,此时sql执行效率就更高了。如下所示,我指定了更新coin_num字段,此时直接通过主键更新单一列,执行效率肯定是更高的,和其他save操作的冲突也会更小。

image.png

The end

对tortoise-orm的基本介绍到这里就结束啦,好久没写博客了(主要是不知道写啥)。避免大家以为我“挂了”,水一篇文章吧(手动狗头)。搞得好像真的有人看我的博客一样。