三思系列:前人用GreenDao留下的坑,全线被扣了绩效

前言

本篇文章,您将从一个GreenDao使用的事故开始,围观事故现场,并获得问题分析结论。跟随作者再次巩固GreenDao的整体设计,并实践 APTGradle Plugin 两种方案,通过不断地总结、对比和深度反思扫荡盲区,将知识融会贯通!

创作三思系列是我学习、总结、反思的一种方式,着重于:问题分析技术积累视野拓展了解三思系列

去年年末,出了一个可大可小的事故,导致开发、测试一条线都被扣了绩效

背景是这样的:

  • 项目的部分业务数据存储于 本地数据库
  • 数据库业务使用了ORM框架--GreenDao
  • 采用了类似 GreenDaoUpgradeHelper 的方案处理 "数据库版本升级"

然而,最终事故发生在调用 Migration 时,遗漏了Dao,如果读者对这类 粗犷的 升级方案有所了解,一定猜到了最终结果:表数据丢失!!!

很显然,导致最终结果的原因是多元的:

  • 前人采用的数据库升级方案就很危险
  • 特殊渠道包的更新频次低、时间跨度长,测试覆盖粒度不够细(仅回归主功能、增量实现、从主包同步的bug修改和优化)导致一直未发现问题
  • 轻易地相信了一个老项目,没有对基建部分进行详细的review
  • ...

作者按:可大可小的原因--性质比较恶劣的研发测试流程问题;值得庆幸的是这部分数据不会影响使用正确性,且发生在特殊用途的增量包中,影响范围很小,通过日志分析可回滚弥补。

显然,诸位亲爱的读者点进来,除了围观事故现场,还想看点别的!那自然不能辜负读者厚爱,本篇会同读者一起做一些有趣的事情。

前人的使用方式概览

在真正开始之前,我们还需要耐心地看一下前人的使用方式此乃前车之鉴,如果你的项目中也有类似的用法,可能需要尽早地、仔细地Review一遍

  • 正常的导包、应用plugin -- 没问题
  • gradle配置 GreendaoOptions -- 没问题, targetGenDir配置到了常规sourceSet中,增加一些代码提交和merge conflict 问题不大
  • 用注解标识Entity -- 参数都是默认的,问题不大,没有隐藏的大坑
  • 自实现了 DaoMaster.OpenHelper -- 没问题
  • 自定义了数据库升级的helper,类似前文提到的GreenDaoUpgradeHelper -- 坑比较大:
    • 性能问题
    • 临时表名产生的制约
    • 人工维护传入的dao -- 直接导致的事故

可能大多数项目的使用方式都是类似的,那么有三大问题丞待解决:

  • 需要人工维护升级的dao参数 -- 人的记性差,容易遗漏。不符合GreenDaoUpgradeHelper 等工具的设计初衷,即不需要人工维护升级细节
  • restore时的效率问题
  • 临时表名无形中产生的制约

限于篇幅,本篇只解决第一个问题,点出第二个问题,分析第三个问题。

GreenDao 如何进入升级(降级)

我们知道: Sqlite 存在有 PRAGMA 命令,可以在 SQLite 环境内控制各种环境变量和状态标志。而数据库的版本信息存储为 环境变量 user_version

通过以下sql进行查询和设置:

#查询
PRAGMA user_version;

#设置
PRAGMA user_version = {version}

而GreenDao配置的schemaVersion:

greendao {
    schemaVersion 1000
}

将通过gradle-task:greendao 写入生成的 DaoMaster 中,并作为 SQLiteOpenHelperversion 参数,与数据库的 user_version 比对后,判断是否需要进行创建、升级、降级。

public class DaoMaster extends AbstractDaoMaster {
    public static final int SCHEMA_VERSION = 1000;
    //...


    public static abstract class OpenHelper extends DatabaseOpenHelper {
        public OpenHelper(Context context, String name) {
            super(context, name, SCHEMA_VERSION);
        }

        public OpenHelper(Context context, String name, CursorFactory factory) {
            super(context, name, factory, SCHEMA_VERSION);
        }
        //...
    }
}

节选 SQLiteOpenHelper 一段代码如下:

db.beginTransaction();
try {
    if (version == 0) {
        onCreate(db);
    } else {
        if (version > mNewVersion) {
            onDowngrade(db, version, mNewVersion);
        } else {
            onUpgrade(db, version, mNewVersion);
        }
    }
    db.setVersion(mNewVersion);
    db.setTransactionSuccessful();
} finally {
    db.endTransaction();
}

您一定注意到:此处已经开启了数据库事务,如果将升级任务置于额外的线程执行,也存在风险!做好设计,减少甚至杜绝表结构变更 是最佳实践!

升级Helper概览

几乎所有可以搜索到的工具,均以以下流程作为实现方案:

表名关系:诸如 临时表名 = {原表名}+"_TEMP"

作者按:为了方便,下文以 tempTableName 指代临时表名,oTableName 指代Entity对应的表名

1.创建临时表

  • 删除临时表 DROP TABLE IF EXISTS {tempTableName} -- 看似没有毛病,但如果存在业务设计的临库临表,就被误删除了
  • 创建临时表 CREATE TEMPORARY TABLE {tempTableName} AS SELECT * FROM {oTableName}

比较奇怪的是:为何不:

  1. 先判断临时表名是否存在,如存在则抛错,
  2. 然后再判断新增表是否会和临时表重名,如果存在则抛错,
  3. 继而在同一数据库内使用 ALTER TABLE {oTableName} RENAME TO {tempTableName} 修改表名

作者按:此处仅为一个设想,是Sqlite支持的SQL,但并未在Android项目中实践验证以及推理可能出现的问题。

可以预见的是:即便增加校验,也无法避免用户绕开GreenDao进行数据库操作所带来的隐性冲突可能。

GreenDaoUpgradeHelper在新的临时数据库中处理临时表,作者公司项目中的代码在原数据库中处理

2.调用DaoMaster删除表

逻辑借用了GreenDao生成的代码,细节忽略

3.调用DaoMaster生成表

逻辑借用了GreenDao生成的代码,细节忽略

4.restore数据 根据 tempTableNameoTableName 两张表的结构,构建SQL,迁移数据,细节忽略

小结

至此,我们已经完成了问题2、3的基本分析:

  1. 人工维护需要升级的dao
  2. restore时的效率问题 -- 不需要升级的表也进行了I/O,不需要变更的字段也进行了I/O
  3. 临时表名无形中产生的制约 -- 同库情况下产生制约,创建无冲突的临库则无影响,但会增加I/O

显而易见:问题2、3可以通过 "健壮的、可靠的数据库设计以降低升级数据库的需求"、"更加细致、高效的升级SQL" 加以解决。

作者按:虽然前文为它们花费了较长的篇幅,但它们不是这篇文章的主角,以后时间充裕的话,我会考虑造一个更好用、高效的轮子

而问题1的原因更加明显:GreenDao 并没有设计相关功能 用以提供需要升级的DAO信息 。而从数据库升级的算法流程分析,需要的DAO信息为全部的DAO类集合即可

经过前文大篇幅的分析,我们已经完成了第一次扣题:思危 -- 发现、分析危险。

如果有读者已经很不幸地处于危险之中,则需要开始思退。

两种可行的额外技术手段

此时让我们退一步,冷静地思考下:为什么先前的开发人员选择了 人工维护DAO类的Collection 呢?

诚然,GreenDao 没有帮助开发者维护 需要升级的表信息 ,这种小事也没有必要提issue;

进一步思考:GreenDao将升级都交由开发者自行维护,Entity也由开发者自行创建,更没有理由提供这一信息;

更进一步思考:GreenDao还存在着 高级用法 ,此时表可以交由开发者创建、维护。当自由度提升,没有 可靠的机制 帮助GreenDao判断开发者需要哪些信息;

终极思考:是否GreenDao提供了,但开发者没注意到?

但不用担心,我们自己动手,依旧可以丰衣足食,虽然方案的出发点本身存在不合理之处

方案1:注解处理技术

GreenDao使用 @Entity 对实体类进行注解,例如:


@Entity(indexes = {
        @Index(value = "text, date DESC", unique = true)
})
public class Note {
    @Id
    private Long id;

    @NotNull
    private String text;
    private String comment;
    private java.util.Date date;

    @Convert(converter = NoteTypeConverter.class, columnType = String.class)
    private NoteType type;
}

依据 @Entity 注解,通过APT机制,我们可以很轻易的收集Entity对应的 AbstractDao 类信息

作者按:APT stands for Annotation Processing Tool. Sun shipped an API for APT in JDK 1.5, which can be viewed at 一个你不愿意打开,打开了也不乐意看的网站😂

方案2:GreenDao插件

众所周知,GreenDao通过Gradle Plugin完成了:

  • Entity 发现
  • Entity 中表字段关系、索引、约束分析,源码级代码插桩
  • Dao 生成
  • DaoMaster生成

如果我们可以 "入侵" 这一系列的流程,显然也可以达成目标,毕竟,生成的DaoMaster类头注释了:knows all DAOs.

/**
 * Master of DAO (schema version 1000): knows all DAOs.
 */
public class DaoMaster extends AbstractDaoMaster {
    //...
}

小时牛刀--注解处理

先简单回顾一下Entity注解的源码:


/**
 * Annotation for entities
 * greenDAO only persist objects of classes which are marked with this annotation
 */
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface Entity {

    /**
     * Specifies the name on the DB side (e.g. table name) this entity maps to. By default, the name is based on the entities class name.
     */
    String nameInDb() default "";

    /**
     * Indexes for the entity.
     * <p/>
     * Note: To create a single-column index consider using {@link Index} on the property itself
     */
    Index[] indexes() default {};

    /**
     * Advanced flag to disable table creation in the database (when set to false). This can be used to create partial
     * entities, which may use only a sub set of properties. Be aware however that greenDAO does not sync multiple
     * entities, e.g. in caches.
     */
    boolean createInDb() default true;

    /**
     * Specifies schema name for the entity: greenDAO can generate independent sets of classes for each schema.
     * Entities which belong to different schemas should <strong>not</strong> have relations.
     */
    String schema() default "default";

    /**
     * Whether update/delete/refresh methods should be generated.
     * If entity has defined {@link ToMany} or {@link ToOne} relations, then it is active independently from this value
     */
    boolean active() default false;

    /**
     * Whether an all properties constructor should be generated. A no-args constructor is always required.
     */
    boolean generateConstructors() default true;

    /**
     * Whether getters and setters for properties should be generated if missing.
     */
    boolean generateGettersSetters() default true;

    /**
     * Define a protobuf class of this entity to create an additional, special DAO for.
     */
    Class protobuf() default void.class;

}

作用在 TYPE 上,保存至源码级别,显然没有限制。

剩下来的工作非常简单:

  • 实现 AbstractProcessor 并完成SPI注册,进入到AnnotationProcessor流程
  • 通过获取 Entity 注解类对应的 TypeElement ,判断项目是否正确配置
  • 获取被注解的类,收集必要的信息
  • 可选项1:甄别是否会出现临时表表名冲突,编译期抛错优于运行期
  • 可选项2:排除Kotlin类等干扰项,GreenDao仅支持Java
  • 生成代码

具体代码可参考 GreenDaoCollector 中的 greendao-collector 部分

作者按:相信诸位读者对APT都有一定程度的掌握,如果掌握程度还不够熟练,在有时间条件的基础下,可以结合本案例展开一次练习

额外的风险

看至此处,您一定已经对 可选项2:排除Kotlin类等干扰项,GreenDao仅支持Java 这句话进行了思考,并对我将 Entity 注解的源码全文粘贴于上这一 水字数 的行为表示了鄙夷。

但是请注意:

  • GreenDaoCollector 没有考虑注解中 boolean createInDb() default true; 等方法
  • GreenDao存在一些限制,例如不支持Kotlin,GreenDaoCollector 采用了一个取巧的方案来甄别排除不支持的类
  • 越精密的机器越容易出现故障,对于复杂的机制也是如此

作者按:这一取巧的方案为:使用GreenDao时一般会为Entity生成相应的构造函数和Getter、Setter,追加的构造函数代码会被 @Generated 注解,基于此排除不支持的类,但请注意这一行为可以被关闭而造成误判。

很显然,该方案将承受巨大的风险,它仿刻了GreenDao的结果,但采用了不同的机制 ,出现问题的机率会大增!

渐入佳境--插件魔改

莫非命也,顺受其正,是故知命者不立乎岩墙之下。尽其道而死者,正命也;桎梏死者,非正命也。

防祸于先而不致于后伤情。知而慎行,君子不立于危墙之下,焉可等闲视之

-- 《孟子》

此时我们已经充分意识到第一个方案的风险,让我们来思考第二个方案,并做出对比。

您一定知道:GreenDao plugin 提供了两个 Gradle Task:

  • greendaoPrepare
  • greendao

作者按:GreenDao的插件并未开源,我们选择尊重GreenDao的团队,文中不讨论通过反编译才能得到的信息

窥一斑而见全豹--分析其设计

如果您先前了解过Gradle Plugin,那一定知道Gradle的Task均有其输入和输出。

而顾名思义,greendaoPrepare 一定是一个准备工作,将它的输出做为 greendao Task的输入,

结合 GreenDaoCollector 项目中的sample可以获知,其输出为:

// 相对路径:build/cache/greendao-candidates.list

1649475279008
{略去}/GreenDaoCollector/app/src/main/java/osp/leobert/android/gdc/entity/JavaDemoEntity.java
{略去}/GreenDaoCollector/app/src/main/java/osp/leobert/android/gdc/entity/JavaDemoEntityTemp.java

并且您一定注意到了,JavaDemoEntityTemp 已经被全文注释

从结果来看, greendaoPrepare 一定不是 基于编译或者基于AST 的方案,它必然是一个从源码文件中 快速筛选 可能存在Entity的方案,用以减少 greendao Task 的性能开销,可以很轻易的推断出 通过字符串匹配 实现这一功能。

言归正传,您一定知道:gradle借助pom文件实现library依赖管理,通过greendao plugin 的pom文件可知: 插件依赖 greendao-code-modifier

greendao-code-modifier 同样未开源,通过pom文件分析其依赖:

作者按:相关的pom文件,可以于MavenCentral中检索,或者查看gradle/maven 的本地cache,或者在 GreenDaoCollector 的files目录中 查阅。考虑到阅读体验,重要部分摘录附于文末

至此,我们得出结论:

通过 greendaoPrepare 任务,基于源码内容做字符串检索,快速筛选出可能是Entity的源码,信息输出到文件:greendao-candidates.list

读取greendao-candidates.list 文件内容,基于jdt分析其源码语法树(AST)

基于AST和注解解析Entity、主键、索引、约束、关联等

调用greendao-generator生成源码

作者按:JDT 是eclipse为Java提供的一组工具,可以实现APT、支持Java editing等

如果您对Intellij中 与Java、Kotlin源码相关 的插件开发有一定的了解,对其早期使用的 lombok-ast 和后来使用的 uast 一定不会感到陌生,JDT也是类似的工具。

言归正传,我们只需要改变 greendao-generator 生成代码的实现即可实现需求!

阅读 greendao-generator 源码后,您将获悉:它因为 业务非常复杂 而使用FreeMaker作为模板引擎生成源码。

难道要使用字节码技术,直接修改加载模板处的源码,增加新的模板,这么轻易就放大招了吗?

大象无形--利用加载机制做文章

可以发现,greenDao对FreeMaker的初始化代码如下:

public class DaoGenerator {
    private Configuration getConfiguration(String probingTemplate) throws IOException {
        Configuration config = new Configuration(Configuration.VERSION_2_3_29);
        config.setClassForTemplateLoading(getClass(), "/");
    }
}

简单查看源码:

public void setClassForTemplateLoading(Class resourceLoaderClass, String basePackagePath) {
    setTemplateLoader(new ClassTemplateLoader(resourceLoaderClass, basePackagePath));
}

/**
 * A {@link TemplateLoader} that can load templates from the "classpath". Naturally, it can load from jar files, or from
 * anywhere where Java can load classes from. Internally, it uses {@link Class#getResource(String)} or
 * {@link ClassLoader#getResource(String)} to load templates.
 */
public class ClassTemplateLoader extends URLTemplateLoader {
//ignore 看类注释
}

如果您了解 Class.getResource(String name) 方法,并且对打包有了解,则可以得出结论:

关键点在于 ClassLoader#getResource 并且存在 Parent-delegate 机制。

只需要在plugin中增加同名模板,在plugin被运行时,该模板将被先加载

作者按:此处不再展开,否则十篇文章也写不完。

实现目标只需要两步

  • 新建插件,通过继承或者使用组合,直接使用GreenDao插件逻辑,选用组合,因为无法继承
  • 新增同名模板,并增加相关逻辑用以生成代码,最终选用dao-master模板

重点代码如下: 具体代码可参考 GreenDaoCollector 中的 greendao-plugin-wrapper 部分

//插件
class GreenDaoPluginWrapper : Plugin<Project> {
    private val wrapper: Plugin<Project> = Greendao3GradlePlugin()
    override fun apply(project: Project) {
        wrapper.apply(project)
    }
}
//模板
/**
* Master of DAO (schema version ${schema.version?c}): knows all DAOs.
*/
public class ${schema.prefix}DaoMaster extends AbstractDaoMaster{
  public static final int SCHEMA_VERSION=${schema.version?c};
  
  // all dao need to create in db, do not modify, created by leobert
  public static final List<Class<? extends AbstractDao<?, ?>>>allDao=new java.util.ArrayList();
  
      static {
  <#list schema.entities as entity>
  <#if!entity.skipCreationInDb>
          allDao.add(${entity.classNameDao}.class);
  </#if>
  </#list>
      }
          
      //其他略
}

读者应该还记得前文的 Entity 注解以及我提到的只言片语,开发者可以自行创建数据库,或者从某处获得数据库直接使用,那么可以通过 boolean createInDb() 等配置 令GreenDao忽略表的创建与删除。

示例模板中通过 <#if!entity.skipCreationInDb> 的判断,排除了无需GreenDao协助建表的DAO。但请务必留心,数据库表升级永远是业务级别的工作,框架和工具再好,也需要 根据实际业务进行调整

应用插件后效果如下:

// THIS CODE IS GENERATED BY greenDAO, DO NOT EDIT.

/**
 * Master of DAO (schema version 1): knows all DAOs.
 */
public class DaoMaster extends AbstractDaoMaster {
    public static final int SCHEMA_VERSION = 1;

    // all dao need to create in db, do not modify, created by leobert
    public static final List<Class<? extends AbstractDao<?, ?>>> allDao = new java.util.ArrayList();

    static {
        allDao.add(JavaDemoEntityDao.class);
    }

    /** Creates underlying database table using DAOs. */
    public static void createAllTables(Database db, boolean ifNotExists) {
        JavaDemoEntityDao.createTable(db, ifNotExists);
    }

    /** Drops underlying database table using DAOs. */
    public static void dropAllTables(Database db, boolean ifExists) {
        JavaDemoEntityDao.dropTable(db, ifExists);
    }
    //其他略
}

与方案1对比

显而易见,方案2借用了greendao的插件业务逻辑,除了在模板中增加少许内容,再无其他,无论风险性还是简易性均优于方案1。

模板中基于 <#if!entity.skipCreationInDb> 判断,收集的dao信息与 createAllTables、dropAllTables 中保持一致!当然,亦可以移除判断,收集所有的dao ,** 需结合业务做出选择**。

不具备编译时临时表名冲突检测功能。

前车之鉴--大彻大悟,一个错误犯两次

前车已覆,后未知更,何觉时?

-- 《荀子·成相》

这句话讲的是成语前车之鉴,让我们回顾一下:

导致问题的原因:

  • 前人采用的数据库升级方案就很危险
  • 特殊渠道包的更新频次低、时间跨度长,测试覆盖粒度不够细,导致一直未发现问题
  • 轻易地相信了一个老项目,没有对基建部分进行详细的review
  • ...

升级方案丞待解决的问题:

  • 需要人工维护升级的dao参数,容易遗漏,存在风险
  • restore时的效率问题
  • 临时表名无形中产生的制约

采用的手段

使用了 APT包装插件替换模板 两种技术手段,为一个 非最佳的数据库表升级方案 解决了 需要人工维护参数 的问题。

显而易见,最佳的实践方案应当为:

替换一套健壮、高效的数据库升级方案: 自动收集需要升级的表 -- 排除无效迁移工作提升效率、使用自动化替代人工规避人的错误。同时满足 可测性

那么立足当下,方案1、2就是最佳实践了吗?请重读下面这两句话:

  • 轻易地相信了一个老项目,没有对基建部分进行详细的review
  • Master of DAO (schema version 1000): knows all DAOs.

同一个错误又犯了一次: 我们轻易地否定了一个老项目,没有对它的代码进行详细的review

val allDao: List<Class<AbstractDao<*, *>>> = DaoMaster(
    SQLiteDatabase.create(null)
).newSession().allDaos.map {
    it.javaClass
}.toCollection(arrayListOf())

DaoMaster 的模板中,为其构造函数实现了所有Dao的注册,无论是否创建表,它们都会被汇总到 Map<Class<?>, AbstractDao<?, ?>> AbstractDaoSession#entityToDao 中。

但您需要始终牢记,这是在为一个非最佳方案服务,一旦使用了更好的方案,这一方式将不再适用!

总结

本篇,我们从一个事故开始,展开了问题分析,并提出技术方案,实现方案并进行了知识巩固,通过不断地总结、对比和深度反思扫荡盲区!

如果单纯的服务于解决问题,这篇博客将不会存在,区区10行代码即可。但三思系列是学习、总结、反思的一种方式,着重于:问题分析技术积累视野拓展 ,看完这一篇,我相信你收获的内容 远远超过 一个bug的解法。

项目源码

GreenDao

作为数据库版本升级方案示例的: GreenDaoUpgradeHelper

Sample代码,APT, Plugin 及部分资料均开源 :GreenDaoCollector

pom文件摘要

<!--greendao-gradle-plugin-3.3.0.pom-->
<groupId>org.greenrobot</groupId>
<artifactId>greendao-gradle-plugin</artifactId>
<version>3.3.0</version>
<name>greenDAO Gradle Plugin</name>
<description>Gradle Plugin for greenDAO, the light and fast ORM for Android</description>
<url>https://github.com/greenrobot/greenDAO</url>
    <!--  略-->
<dependencies>
<dependency>
    <groupId>org.greenrobot</groupId>
    <artifactId>greendao-code-modifier</artifactId>
    <version>3.3.0</version>
    <scope>compile</scope>
</dependency>
<!--    略-->
</dependencies>


    <!--greendao-code-modifier-3.3.0.pom-->
<groupId>org.greenrobot</groupId>
<artifactId>greendao-code-modifier</artifactId>
<version>3.3.0</version>
<name>greenDAO Code Modifier</name>
<description>Code modifier for greenDAO, the light and fast ORM for Android</description>
<url>https://github.com/greenrobot/greenDAO</url>
    <!--  略-->
<dependencies>
<dependency>
    <groupId>org.greenrobot</groupId>
    <artifactId>greendao-api</artifactId>
    <version>3.3.0</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>org.greenrobot</groupId>
    <artifactId>greendao-generator</artifactId>
    <version>3.3.0</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>org.greenrobot</groupId>
    <artifactId>greenrobot-jdt</artifactId>
    <version>3.20.0</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>org.greenrobot</groupId>
    <artifactId>essentials</artifactId>
    <version>3.0.0-RC1</version>
    <scope>compile</scope>
</dependency>
<!--  略-->
</dependencies>