Quarkus使用感受及对响应式编程和虚拟线程的看法
文章介绍了作者使用Quarkus实现后台系统的经验,重点讨论了响应式编程的优缺点及实现中的技术挑战。Quarkus适合云原生和微服务场景,但生态不如Spring丰富。文章详细探讨了数据库响应式CRUD、用户登录态设计、CSRF防护等技术要点,并分享了响应式编程中的常见问题,如CPU密集型任务处理、用户登录态设计、DTO投影和数据库分页查询。最后,作者对比了响应式编程与Project Loom的虚拟线程,认为虚拟线程可能逐渐取代响应式编程。
文章目录
前言
大概是从21年开始,借着Spring Native发布的契机开始了解到Spring WebFlux,进而了解到响应式编程以及在这方面较为拿手的Quarkus,今年寒假在家便尝试用Quarkus实现本站的后台系统。
先说我个人对Quarkus的看法:
优势:适合云原生,微服务的场景,毕竟quarkus一开始就以此为目标,把大量工作放到编译期完成,并且原生支持GraalVM Native;在某些场景下体验比SpringBoot好太多(比如dev热更新,springboot会在检测到文件改动就会立即热重启,改一会代码重启好几次都是常态,而quarkus是在收到请求后进行,改完代码刷新一下浏览器才判断是否需要重启,并执行热更新操作)。
劣势:生态没有Spring家族丰富,如果是为找工作为目的则没必要学(感觉国内没几家公司在用),国内的文档和文章极其匮乏;并且高度注重于微服务领域,以至于没有SpringBoot那么通用,比如其默认web实现中连session都没有。
项目实现要点
既然用了Quarkus,又不是云原生、微服务领域,那就剩下响应式编程这一特点值得学习一下了,文章后面也会谈谈经过实际使用后对响应式编程的看法。
回顾之前用SpringBoot实现的博客系统,并阅读了Quarkus的官方文档后,我总结了几个技术差异,需要在Quarkus里重新设计:
- 数据库响应式CRUD
- 用户登录态的设计
- CSRF防护
- 邮件服务
响应式踩过的坑
Quarkus的响应式编程建立在Vert.x和Mutiny之上,得益于Mutiny优秀的API设计,我们可以避免很多回调地狱的出现。但是如果你对Vert.x以及Mutiny并不熟悉,那么可能会写出与你预想流程大相径庭的代码。
1、CPU密集型任务
Quarkus中编写的响应式API代码都会运行在vert.x的事件循环(eventloop)线程中,事件循环线程的数量是固定的(与CPU核心数量相关),所以你的代码不应该阻塞事件循环线程,包括IO、长时间的CPU密集型任务,这也是响应式编程难以推广开来的原因之一:One reactive, all should be reactive!
数据库异步IO有Quarkus帮我们解决(那么代价是什么呢),而根据文档所述,CPU密集型任务应该运行在worker线程中,web项目中有什么典型的CPU密集型任务呢?那必是密码哈希算法了。
现在密码哈希算法中较为流行的BCrypt算法,在我笔记本的i7-9750H上hash一次需要100ms左右,如果按照4核的服务器来简单计算,整个web服务每秒最多能接收40个登录请求!所以我们需要将CPU密集型的任务放在worker线程中运行。
// UserService.java// 检查用户名和密码public Uni<User> checkUser(String username, String password) { return userRepository.findByName(username) .firstResult() .emitOn(Infrastructure.getDefaultWorkerPool()) // 由于BCrypt算法耗时较长,会阻塞eventloop,所以放在worker线程中执行 .onItem().ifNotNull().transformToUni(user -> { // 检查密码 if (BCrypt.checkpw(password, user.getPassword())) { return Uni.createFrom().item(user); } else { return Uni.createFrom().failure(new RuntimeException("密码错误")); } });}2、用户登录态的设计
quarkus-resteasy-reactive依赖用于实现响应式web服务,但是由于quarkus是面向微服务的,这个依赖并没有实现session的,也就是说你无法通过session记录用户登录状态!我们可以通过引入quarkus-undertow依赖来实现servlet,进而获取session(未验证),但是为了一个session而引入servlet容器显得没有必要。
一般的微服务架构中会用到JWT、OAuth2等认证授权架构,quarkus也提供了SecurityContext、IdentityProvider等接口用于实现认证授权、并且还提供了一系列依赖便于我们实现认证授权。
但有时候我们只是想实现一个认证简单,可控的授权系统,那么可以通过缓存和Filter来完成,我现在的做法是这样的:
在登录接口完成认证后,生成一个token和用户信息绑定放在缓存中,并将token通过cookie(httpOnly)返回给前端,这样用户每次访问API接口时,Filter将cookie中的token取出来,在缓存中查找对应的用户信息,并放在请求上下文(RoutingContext)中,而授权系统也可以通过请求上下文获取用户信息,验证用户权限。
3、DTO投影
由于mybatis官方尚不支持响应式(估计没戏):Hope to add Reactive Relational Database Access · Issue #1444 · mybatis/mybatis-3,所以quarkus官方支持的ORM只有hibernate,hibernate有个痛点就是DTO投影非常麻烦,特别是联表一对多查询时,往往要写出以下这种代码:
// ArticleService.java// 获取文章及对应的评论列表,文章和评论是一对多关系public Uni<ArticleView> findById(Long id) { return sessionFactory.withSession(session -> session .createQuery(""" select new com.example.dto.ArticleView( a.id, a.title, a.content ) from Article a where a.id = ?1 """, ArticleView.class) .setParameter(1, id) .getSingleResult() .call(articleView -> session.createQuery(""" select new com.example.dto.CommentView( c.id, c.nickname, c.content ) from Comment c where c.article.id = ?1 """, ArticleView.class) .setParameter(1, id) .getResultList() .invoke(articleView::setComments) ) );}看起来就很不优雅,也很不ORM,如果不考虑响应式支持的情况下,我们可以通过引入blaze-persistence-integration-quarkus依赖,来获取强大的dto投影支持,但现在想要简化这部分工作只有通过Panache(quarkus-hibernate-reactive-panache)依赖中有限的投影功能来完成,代码量并没有实质性减少。博主现在也没找到一个合适的方式来简化dto投影。
4、数据库查询分页
hibernate作为一个全自动ORM框架,提供了分页的功能,只要遵循设计范式编写实体类,就不会出现类似Mybatis PageHelper在一对多关系查询分页不准确的情况。
但在hibernate reactive中,想把分页数据和分页后的查询结果封装在一起,还需要注意一些细节,这里使用Panache依赖来简化查询操作。
// ArticleService.java// 分页查询文章public Uni<Page<Article>> findList(int page, int size) { final int queryPage = page - 1; // 注意panache分页页码从0开始 Uni<List<Article>> query = articleRepository.findAll(); // 查询文章列表
// 查询总数和分页查询属于两个异步操作,而一个hibernate session无法同时执行两个查询,所以需要使用chain将它们按顺序执行 // 而不是使用Uni.combine().all().unis()来合并两个操作 return query.count() // 先获取文章总数 .chain(total -> query.page(queryPage, size) // 再进行分页查询 .list() .map(articleList -> Page.of(page, size, total, articleList)) /* 这里就可以将页码、每页数量、文章总数、文章列表封装为一个分页对象 */ );}响应式编程和Project Loom(虚拟线程)
jdk19已经将虚拟线程作为预览特性加入了java,一时间大家都在讨论虚拟线程和响应式编程的未来,在我看来,随着虚拟线程的到来,响应式编程会慢慢淡出历史舞台。在IO密集十分普遍的后端领域,响应式编程和虚拟线程解决的问题是一致的,即如何用减少操作系统线程(OS线程)的频繁创建/销毁。
众所周知,业务逻辑可分为CPU密集型和IO密集型两种,CPU在执行IO操作时,将大部分工作分派给DMA完成,所以CPU此时是空闲的,如果按照传统处理方式,一个请求对应一个线程,那么线程在等待IO时什么都不做,会浪费很多线程资源,因为Java线程和OS线程是一一对应的,创建代价十分昂贵。
响应式编程的解决办法是将所有操作封装为回调,在处理请求1的IO操作时让线程空闲出来去处理请求2的任务,等到请求1的IO任务完成后再运行回调;而Project Loom的解决办法则是将Java线程和分为平台线程和虚拟线程两种,平台线程即原来的Java线程,和OS线程是一对一关系,而虚拟线程则是由JVM实现的轻量级的线程,和OS线程是多对多关系,一个OS线程可以调度成百上千的虚拟线程,在一个虚拟线程执行IO操作时,对应的OS线程可以转而执行另一个虚拟线程,这样我们可以继续按照传统的一个请求对应一个线程的方式去编写代码,而将那些复杂的调度交给JVM去处理。
到这里,大家可以看出来区别了,响应式编程要求我们写代码从平时的命令式风格转换到响应式风格,将一步一步的操作封装为一个一个的回调,另外变量共享也是一个问题,所以大家写着会感觉很反人类,而虚拟线程从底层帮我们把这一切封装好了,只留下我们熟悉的Thread API,我们可以继续用命令式代码来完成业务逻辑,剩下的交给JVM管理。从我的角度来看,不少第三方库并不打算支持响应式,也就几乎没有选择响应式编程的必要了。