团队一步一步走过来,习惯使用纯粹的 SQL 来设计表和执行查询,为了满足非常丰富复杂的查询需求,我们的 SQL 可以写成几百行甚至上千行。
刚开始的时候,需求变化非常快,想法圈在整个产品发布前迭代了接近 30 个版本,几乎两周一次推倒重来,我们开始考虑如何在已有的基础上做一些抽象工作以减少劳动力。
外面很多的数据框架不能照搬过来用(很难满足如此快速复杂的变化,学习过程可能是指数增长),反思我们的初衷,是一个能够辅助目前工作的小工具,简单做这么几件事情就好:
- 设计一个定义数据的格式,并以此生成一个类,对应一个表
- 生成好创建,删除表等常见的 SQL
- 生成一个把
Cursor
转成对象的方法用于读取 - 生成一个把对象变成
ContentValue
的方法用于写入和更新
定义
2012 年写过一个 Protobuf 的解析器,格式定义上刚刚够用(表名、类型、字段名、顺序),两个地方可作为配置扩展,就直接拿过来使用。
message Book { // 表名
option database "WeRead"; // 所在的数据库
optional int32 id = 1 [hashFrom="bookId"]; // 类型、字段名、顺序
optional string bookId = 2;
optional string author = 3;
optional string title = 4 [default = "无标题"];
// ...
}
对应这样的一份定义,生成同名类文件,包含以上需要的功能,就已经满足我们的大部分需求了,这是第一个大版本。
关系
基于 SQL 设计业务逻辑,不可避免的需要管理 1:1、1:n、m:n 的关系。
1:1 关系
可以通过声明一个自定义类型的变量来定义:
optional User author = 1;
存储的时候,如果是已经存在的数据对象,取其主键保存。查询的时候,需要在 SQL 里手动 JOIN 进来。SQL 语句要区分表名,这样使得从 Cursor 到对象可以嵌套调用。
User user = new User();
user.convertFrom(cursor);
this.user = user;
如果是简单的不需要关系查询的对象,直接序列化保存:
this.style = parseObject(cursor.getString(i), Style.class);
1:n 和 m:n 关系
1:n 可以看作 m:n 的子集。如果是已经存在的数据对象,且需要被关联查询的,通过中间表来管理关系,取其主键放入中间表。中间表的操作逻辑可以通过定义来生成,关系的保证,在关联着的两个对象的写操作时同步进行,放到一个事务里。
db.beginTransactionNonExclusive();
try {
updateSelf();
updateRelation();
db.setTransactionSuccessful();
} finally {
db.endTransaction()
}
同样的如果是简单的结构,直接序列化为数组存储即可:
this.style = parseArray(cursor.getString(i), Style.class);
依旧需要手动在 SQL 语句里自行 JOIN 关系表来实现查询。这是第二个大版本。
缓存
对外透明的缓存是最好的,调用者无需考虑性能,但是同样对于复杂查询,缓存的设计就会变得很头疼。
由于大部分表设计已经有明确的主键,从主键做 key 来对整一行做缓存可能是最简便的方法,有几个好处:
- 缓存构造 SQL 到生成对象的过程可以框架来做,达到透明使用的目的
- 调用者通过主键读取内容,主键通过 Hash 生成,得到了 remoteId 可转为主键查询,可应付大部分单个对象的查询
- 对于列表查询,如果主键在缓存中,直接从缓存中读出,不再需要从 CursorWindow 复制出来
- 对于跨表查询通过主键引用的,也可先从缓存中获取
- 缓存策略根据不同的表自定义配置
这个缓存实现基于 Guava Cache,可定制的缓存策略很多,也方便跟踪命中率,查询性能等,可针对其统计数据制定不同的优化策略。
这几种使用场景可以覆盖大部分情况,由于缓存中的数据整一行所有数据都存在,部分字段查询的时候把缓存对象给出去也没问题。
缓存更新
这个模型的一个大前提是缓存里的数据不可更改,数据更新的时候通过 SQLite 的 UpdateHook 来清掉缓存数据下一次重新加载。
脏数据逃逸
对于已经逃逸出去的对象(被其他对象引用的旧数据),缓存本身已经追踪不到,如何快速的确定这个数据是否已被更新也是一个挺有意思的事情,可以有两个思路:
- 每次查询出来的对象,把对象的放入另外一个池子里通过主键标记起来,更新的时候找出来标识为脏数据
- 每次查询出来的对象,记录一个查询时间,数据更新的时候单独记录一个时间,需要检查数据是否脏了的时候比较这两个时间
这两个做法的出发点都是由使用者决定是否要检查数据,都是被动拉取更新的做法,比较节省资源。
升级
经常跟 SQLite 打交道,库升级是不可避免的,通过 pragma table_info
来获得原库的 schema,比较最新的结构,添加必要的列。每次定义一修改,版本号就加一,保证能触发库升级的 onUpgrade
回调。
SQLite 本就不支持修改和删除列,所以遇到没有的就添加,并适当回调出去用于升级的时候抄写数据等等。
最后
一步一步演变过来,这个框架有效地支持了两年来的大大小小近百个迭代,两年时间协议文档高达 400 次的提交,从最简单的代码生成工具,根据业务需要,演变为一个完全定制化可灵活扩展并适用于团队使用的工具。