Eisen's Blog

© 2022. All rights reserved.

配置 jacoco 以提供更合理的测试覆盖率

2022 May-07

最近在做一些代码的重构和基础库的迁移,这样的工作绝大部分时候不产生新的功能点,每次更换了类库后也都会将原来对应的测试同步迁移过来,保证新的代码和原来的代码一样工作。不过在迁移的过程中我发现 jacoco 所提示的代码覆盖率越来越低,让我很慌。为了搞明白这是啥原因,做了一些调研,这里把一些结论记录在这里加深印象,也便于后续查看。

screenshot for openbayes server code converage before

代码测试覆盖率是什么意思

Intro to JaCoCo 这里讲的非常明白了,代码测试覆盖率(或者说代码覆盖率)讲的是在跑测试的时候,到底有多少代码被执行了。按照粒度来分可以有以下几种:

  1. Line coverage 按照字节码统计的多少 instruction 被执行了
  2. Branch converage 按照 if/else switch 等分支统计多少分支被执行了

由于代码确实会有很多防御性的 if/else 导致其实每一个分支并不是很对等,所以我个人感觉 Line coverage 会稍微好一些。

不过要注意,测试覆盖率只反映你多少代码在测试的时候被执行了,执行了一次就算是执行了,但事实上不同的参数会导致不同的结果,很多边界条件是否被测试到也表现不出来。因此 100% 测试覆盖率不表示所有代码都是对的了。如何写测试本身是一件极其复杂的事情,有些编程思路如 TDD 都是围绕测试进行的,我也讲不明白。

是什么导致覆盖率越来越低

为了搞明白为什么我的测试覆盖率一降再降,我需要用 JaCoCo 给我生成一下报告,我去看一下到底哪些地方的哪些代码测试出了问题。

我的项目是用 gradle 管理的,执行如下命令重新跑一下测试并生成报告:

./gradlew cleanTest test jacocoTestReport

然后去项目目录 build/reports/jacoco/test/html 打开 index.html 看看情况,发现核心为题在于很多生成的代码被纳入了代码测试覆盖中。具体来讲有两个方面的内容:

  1. Lombok 生成的很多代码
  2. 引入 QueryDSL 后其和对应的 Entity 生成的 Q + Entity 的名称的代码

那问题就显而易见了,尤其是后者,每次迁移一个 JPA 的 Entity 类型就会对应生成一段 Q + Entity 的代码,这部分代码统统成了测试覆盖率的分母,覆盖率能不低么。

User 对应的 QUser 文件
User 对应的 QUser 文件

如何改进

这部分的很多信息在 Exclusions from Jacoco Report 可以找到。

修改 JaCoCo 配置,保证代码覆盖率统计的合理性

我们只需要测试自己写的代码,生成的代码不应纳入统计范围。这里直接粘贴下 gradle 里面 jacocoTestReport 的配置:

jacocoTestReport {
    // rule from https://bottom-to-top.tistory.com/36
    // 过滤 QA-QZ 开头的所有类,对应了 querydsl 生成的 Q + Entity 的格式
    def Qdomains = []
    for(qPattern in "**/QA" .. "**/QZ"){
        Qdomains.add(qPattern + "*")
    }
    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, exclude: [
                    'com/openbayes/graphql/**',          // 所有 graphql 生成的代码
                    'com/openbayes/application/data/**', // 所有的 DTO 所在的包
                    'db/migration/**',                   // 所有的数据库 migration 代码
                    '**/*Exception*',                    // 所有包含 Exception 的异常类
                    '**/*Mixin*',                        // 所有 Jackson Mixin 
                    '**/*Command',                       // 所有带 Command 的类,也是 DTO
            ] + Qdomains)
        }))
    }
    reports {
        csv.required = true
    }
}

可以看到,我这里主要是按照 classDirectory 对类进行了过滤,包括了以下内容:

  1. 各种生成的代码:graphql 生成代码、querydsl 生成代码
  2. 各种 DTO 和 Exception:*Command data/**
  3. 实在不太好测试的东西,比如 migration 脚本、比如 Jackson 的 Mixin

对 querydsl 这部分的过滤比较 tricky ,因为其默认生成的名字是 Q + Entity,本身过滤就有点难,如果采用 **/Q* 的形式会导致个别以 Q 开头但不是 querydsl 生成的类也被纳入过滤的范围,这里我采用的是 https://bottom-to-top.tistory.com/36 的方法,过滤掉由 QA-QZ 开头的所有的类。

这里过滤 querydsl 的方法其实官方还有其他方案,但目前 gradle 这边支持不是很好,我目前用的 querydsl 版本为 5.0 有一些 PR 还没有纳入进来,后续如果官方有了更好支持会考虑做相应的调整。

通过注解过滤 Lombok 的代码

JaCoCo 本身是考虑了要过滤掉生成的代码的,它提供了一个规则:

Starting from JaCoCo 0.8.2, we can exclude classes and methods by annotating them with a custom annotation with the following properties:

  • The name of the annotation should include Generated.
  • The retention policy of annotation should be runtime or class.

简单的翻一下,就是在 JaCoCo 0.8.2 后,通过提供一个带有 Generated 名称的 RetentionPolicyCLASS 或者 RUNTIME 的注解,JaCoCo 会帮你自动过滤这些类。

Lombok 也对这部分做了支持,只要提供一个配置 lombok.config 就能让 Lombok 给自己生成的代码添加上相应的注解了:

lombok.addLombokGeneratedAnnotation = true

最后

在做了上述两方面的修改后,测试覆盖率重回 70% 了。

openbayes server code coverage after


使用 Automa 抓取少量微博数据

2022 April-14

最近需要抓一点点微博的数据,于是又开始做爬虫的老行当了。不过这次抓的数据相对比较少,本来是觉得会很快,可惜还是因为种种原因多折腾了两个小时。这里对这部分工作做一个记录。

我个人一直觉得爬虫是个很重要的东西,尤其是针对机器学习应用的研发,好的数据、新的数据、源源不断的新的数据是好的模型的前提。甚至不用做什么机器学习模型,仅仅从好的数据发掘的一些规则都很有帮助。所以,可以快速的抓数据、快速的抓各种各样的数据是一个很重要的能力。这里就不展开讲了,还是控制篇幅,以这次任务为主。

任务

从自己的微博首页或者随便一个主题下面抓点纯文本的微博数据,用于测试命名实体识别模型。

以前的方案

很久以前(大约还在读研究生的时候)那个时候的微博有一个移动版本,这个版本里面各种静态 html 非常好爬,所以难点基本就是发现 m.weibo.com 的存在...

不过很遗憾,现在不论是 weibo.com 还是 m.weibo.com 都不再是纯 html 了,都会动态获取数据了,需要有 js 的运行时环境才能以比较自然的方式抓取数据,毕竟目前 weibo 的数据动态加载策略、参数啥的有点复杂了,去解析它更麻烦。

Headless Chrome

对于动态获取数据的页面来说,可以采用 headless browwser 配合 puppeteer 来模拟页面的操作以加载并获取数据。不过我觉得这样做很明显有这么几个缺点:

  1. puppeteer 需要用 nodejs 编写,本身难度会比较大
  2. 这种 headless 的脚本相对来说都挺脆弱的,页面展示稍微慢一点,或者元素稍微做了点变化,整个脚本就凉凉了,这个问题不是在有 puppeteer 之后采用的,在以前用 selenium 或者 casperjs 的年代也同样发生

针对这个问题,我觉得有两个思路可以大大缓解:

  1. 建立一个组件库,包含在浏览器实现各种行为的最佳实践,尽量规避自己写代码所引入的缺陷
  2. 有一些类似于工作流的可视化界面,可以快速的建立一套爬取流程,虽然页面经常会发生变化,但由于我每次建立爬虫的成本大大降低了,所以我就不会那么在乎了

而 webscraper 则恰恰提供了以上两个东西。

webscraper

webscraper 是一个 chrome 扩展,其实它的内部依然是类似与 puppeteer 的东西,不过它做成了一个扩展,并提供了一些非常强大的封装,可以让你通过点点点就能实现一个数据爬虫的流程。

webscraper screenshot

封装程度高到如下的样子:

1. 提供丰富的组件,对应不同的抓取模式

webscraper element type

包括:

  1. 滚动到底部的瀑布加载
  2. 各种类型的分页
  3. 表格

2. 内置 element selector 可以在可视化界面完成页面元素的筛选

element selector

通过点点点,就能简历一个 css selector 来定位所要抓取的 html 元素路径。

3. 建立层级数据结构

在步骤 2 定位了想要抓取的具体数据后,可以在该数据上建立层级结构,标记具体想要抓取的数据结构,非常方便数据的结构化。

webscraper structure

有关这个东西有一个 卤蛋工作室 很详细的对它做了系列教程,感兴趣的话推荐一看,我这里就简单说说。不过很遗憾,这个东西在如今的微博也不太能用了。虽然现在的微博很符合它所提供的「滚动到底部」的抓取模式,但目前的微博出于性能的考虑,对自己的这个微博列表做了元素重用,导致不论怎么滚动,最多就只有这么多个元素了。那么套用 webscraper 的「滚动到底部」就只能获取这么多个微博而已了。

dynamic content refresh

Automa

Automa 也是一个 chrome 的扩展,相对于 webscraper 只用来做爬虫,automa 可以用来做各种流程自动化的东西,比 webscraper 灵活不少。除此之外,很多思路和 webscraper 一脉相承。简单看了看教程我就做出来如下一个流程:

weibo scrape in automa

其中的 javascript code 内容如下:

function inViewport (element) {
  if (!element) return false;
  if (1 !== element.nodeType) return false;

  var html = document.documentElement;
  var rect = element.getBoundingClientRect();

  return !!rect &&
    rect.bottom >= 0 &&
    rect.right >= 0 && 
    rect.left <= html.clientWidth &&
    rect.top <= html.clientHeight;
}

setTimeout(() => {
  var elements = document.querySelectorAll("article");

  elements.forEach(e => {
    if (inViewport(e)) {
      const text = e.querySelector('.detail_wbtext_4CRf9').innerText;
      console.log(text);
      automaNextBlock({ text: text }, true);
    }
  });
}, 1000);

和 webscraper 一直滚动到满意的个数的元素后再开始解析数据不同,我做的 automa 的流程是每次滚动一点点页面,然后就获取当前视图中的微博元素并把其内容存下来。而获取当前视图的元素就是用的上面的 javascript code 了。

小结

Automa 相对 webscraper 来说更加灵活,可以更加灵活的定制自己的抓取流程,后续可能还会有其他场景的使用吧。


JPA 处理子类关联

2022 April-03

最近陷入了 JPA 的泥坑,在遇到今天提及的这个问题上折腾了很久,一度怀疑人生打算重新用 MyBatis 算了。不过幸好昨天有了一些还能接受的解决方案,这里做一个记录。

问题阐述

标题说就是让 JPA 处理子类的关联,这里我先贴一下示例代码:

@Entity
@Inheritance
@DiscriminatorColumn(name = "type")
class Plan {
  long id;
  String name;
}

@Entity
@DiscriminatorValue("ComputationPlan")
class ComputationPlan extends Plan {
  @ManyToOne
  private Resource resource;
}

@Entity
@DiscriminatorValue("StoragePlan")
class StoragePlan extends Plan {
  private long size;
}

@Entity
class Resource {
  long id;
  String name;
}

interface PlanRepository extends JpaRepository<Plan, Long> {
}

有一个基类 Plan 它包含两个子类 ComputationPlanStoragePlan 其中 ComputationPlan 会包含额外的关联 Resource,而 StoragePlan 则只包含一个 size 字段。Plan 采用单表继承,实现了子类的存储。而我希望通过 PlanRepository.findAll() 获取全部的 Plan 的时候可以同时获取 ComputationPlan.resource 字段而避免出现 sql n + 1 的问题。

解决方案

由于 Plan 没有 resource 属性,我似乎没办法直接使用 select p from Plan p left join fetch p.resource 这样子的 JPQL 语句。JPA inheritance @EntityGraph include optional associations of subclasses stackoverflow 这个问题也做了很详细的介绍,需求基本和我是一致的。它提供了两个方案:

第一个方案,用一个 hibernate 一级缓存的 trick: 只要已经获取了同样 id 的 Entity 就不会再次从 session 中覆盖。那么这里可以先用 JPQL select p from ComputationPlan p left join fetch Resource r 获取全部的 ComputationPlan 然后再执行 JPQL select p from Plan p 获取全部 Plan,这样子原来已经在 session 缓存里的 ComputationPlan 一定已经有 resource 字段了,这个问题就解决了。可这个方式就很 tricky 了,而且如果我有很多子类怎么办?请求多次?而且如果我有分页呢?是不是就不知道要获取那些内容了呢?

第二个方案,是用一个第三方的类库 blaze persistence。这个方案也是其作者之一作答的,不过在做了简单的搜索后,且不说这个方案到底行不行,这个类库实在是有点小众,并且作为个人维护的项目,其后续的开发进展也有点堪忧,让人不太敢让公司的项目对它有什么依赖...不过其提供的答案里的 HQL 倒是给了我一些启示:

SELECT
  a.id, 
  v.id,
  TYPE(v), 
  CASE WHEN TYPE(v) = TextValue THEN v.text END,
  CASE WHEN TYPE(v) = RatingValue THEN v.rating END,
  CASE WHEN TYPE(v) = MCValue THEN s.id END
FROM Answer a
LEFT JOIN a.value v
LEFT JOIN v.selected s

其中 v 是一个 Value 类型,它也有多个子类,并且只有一个子类有 selected 属性,那么既然人家子类没有 selected 都能强行这么用,那么 Plan.resource 是不是也是可行的呢?做了个尝试:

interface PlanRepository extends JpaRepository<Plan, Long> {
  @Query("select p from Plan p left join fetch p.resource")
  List<Plan> findAll();
}

执行一下发现是可行的...虽然在 intellij 中 p.resource 高亮着波浪线报错了,但是还是可以编译通过。

show intellij error message in jpa

测试下生成的 SQL 以及返回的结果,都是正确的,也就是说,其实 JPQL 也是支持这种父类 left join 子类属性的。到此为止问题就解决了。

用 EntityGraph 进行额外的尝试

虽然看起来方案很简单,但出于对 intellij 的信任,也是花了好久的时间才顶着这个报错去尝试的。在此之前也尝试了 EntityGraph 中的 subClassSubGraph 但感觉这个东西应该是有 bug 并不能成功:

@NamedEntityGraph(
    name = "all",
    attributeNodes = {
        @NamedAttributeNode("id"),
        @NamedAttributeNode("name")
    },
    subgraphs = {
        @NamedSubgraph(
            name = "resource",
            type = Resource.class,
            attributeNodes = {
                @NamedAttributeNode("id"),
                @NamedAttributeNode("name"),
            }
        )
    },
    subclassSubgraphs = {
        @NamedSubgraph(
            name = "computePlan",
            type = ComputePlan.class,
            attributeNodes = {
                @NamedAttributeNode(value = "resource", subgraph = "resource")
            }
        )
    }
)
class Plan {
  ...
}

使用上述的 NamedEntityGraph 依然会出现 LazyInitializationException 的问题,并且其生成的 SQL 语句也明显没有对 Resource 资源进行 left join 操作。