从0搭建一个 Quarkus Web 项目 | 附源码
当前文档撰写时间:2023-04-27,使用的 Quarkus 版本:3.0.1.Final
本文将教会你从 0 搭建一个 Quarkus Web 项目,实现目标:
编写 REST API
全局统一异常处理
数据库配置与操作
JWT 签发与认证
请求外部 API 接口
项目编译成 Jar
项目编译成 Native 可执行文件
最终实现一个调用 OpenAI GPT 模型接口实现机器人对话功能的 WEB 应用,效果图:
演示
源码放在文章结尾处。
Quarkus 是什么
传统的 Java 堆栈是为单体应用设计的,启动时间长,内存需求大,而当时还没有云、容器和 Kubernetes 的存在。Java 框架需要发展以满足这个新世界的需求。
Quarkus 的创建便是为了使 Java 开发人员能够为现代的、云原生的世界创建应用程序。Quarkus 是一个为 GraalVM 和 HotSpot 定制的 Kubernetes 原生 Java 框架,由最佳的 Java 库和标准精心打造。其目标是使 Java 成为 Kubernetes 和无服务器环境的领先平台,同时为开发者提供一个框架,以解决更广泛的分布式应用架构问题。
其主要特性有:
轻量级:Quarkus 采用 GraalVM 和 Substrate VM 技术,将 Java 应用转换为本地可执行二进制文件,大大减少了应用程序内存占用和启动时间。
快速启动:Quarkus 的启动时间通常只需要几毫秒,可以快速地启动和重启应用程序。
高效性:Quarkus 针对云原生应用的特点进行了优化,支持快速响应、高并发和低延迟的应用程序。
低内存消耗:Quarkus 的内存消耗非常低,可以在资源受限的环境下运行,如 Kubernetes 集群。
可观测性:Quarkus 支持集成 Prometheus 和 Grafana,提供实时监控和告警功能,帮助开发人员更好地了解应用程序的运行状态。
插件化:Quarkus 提供了丰富的扩展插件,可以轻松地集成各种技术栈,如 JPA、RESTEasy、Hibernate 等。
标准化:Quarkus 编程模型构建在经过验证的标准之上,按照行业规范构成。
Dev模式:Quarkus 提供了 Dev 模式,支持热更新和快速重载,提高了开发人员的开发效率。
面向微服务:Quarkus 支持构建和部署微服务应用程序,提供了与 Kubernetes 和 Istio 的无缝集成。
与 Spring 的常用注解对照
不出意外的话,相信 Spring 是你再熟悉不过的开发框架了,这里列举出 Spring 中在 Quarkus 中与之对应的常用注解:
环境要求
以下是博主当前电脑的环境配置:
操作系统:Windows 10
Java版本:JDK 17
依赖&构建:Gradle 7.6
开发工具:IntelliJ IDEA 2023.1
开始前,请确保已经安装并配置好 JDK17
、Gradle 7.6
、Docker Desktop
(可选,Docker 用于进行 native 打包,构建本地可执行文件)。
Gradle 可以换成 Maven,Maven 最低版本:3.8.2
实践开始
1. 创建项目
使用 Quarkus 提供的在线构建工程的网站:https://code.quarkus.io
start
Build Tool 选择 Gradle
(也可以选择 Maven),扩展选择:
RESTEasy Reactive
- Web 基础,可以理解成 Spring 的 spring-webRESTEasy Reactive Jackson
- 要编写 API,使用 Jackson 对参数对象进行序列化REST Client Reactive
- 调用 OpenAI 聊天接口需要用到的 HTTP 客户端REST Client Reactive Jackson
Hibernate Validator
- 用于对接口请求参数进行校验Hibernate ORM with Panache
- 实现 JPA 规范的 ORM 数据库操作框架JDBC Driver - H2
- H2 数据库驱动,要切换成 MySQL 数据,只需要跟换驱动依赖即可SmallRye JWT
- 用于 JWT token 的签发与身份认证
点击页面中蓝色的 Generate your application
按钮下载工程文件,解压到你的电脑某个文件夹中,接着先用文本编辑器打开 build.gradle
文件,修改 repositories
(用于加速依赖的下载)和 dependencies
部分为下面的内容:
repositories {
// 阿里仓库镜像, 国内用户加速依赖下载
maven {
url 'https://maven.aliyun.com/repository/public/'
}
mavenLocal()
mavenCentral()
}
dependencies {
// 省略已有依赖...
// 加密相关 -> BcryptUtil.class
implementation 'io.quarkus:quarkus-elytron-security-common'
// lombok
compileOnly 'org.projectlombok:lombok:1.18.26'
annotationProcessor 'org.projectlombok:lombok:1.18.26'
}
Maven 配置阿里云仓库可参考这篇指南:https://developer.aliyun.com/mvn/guide
使用 IDEA 打开这个项目,自动完成依赖文件的下载。项目初始化完成后,即可在 IDEA 中直接运行项目,访问 http://localhost:8080
,出现下面的界面表示项目搭建成功:
运行截图
2. 配置项目参数
编辑 src/main/resources/application.properties
配置文件,这是 Quarkus 默认加载的配置文件,使用过 Spring Boot 的话,你一定非常熟悉这种文件。
数据库
# 数据源,这里为了方便,使用的 H2 数据,以内存模式运行(懒得搭 MySQL)
quarkus.datasource.db-kind=h2
quarkus.datasource.jdbc.url=jdbc:h2:mem:quarkus
quarkus.datasource.username=root
quarkus.datasource.password=123456
# 打印 SQL 执行日志
quarkus.hibernate-orm.log.sql=true
详细配置可查阅官方文档:
https://quarkus.io/guides/datasource
https://quarkus.io/guides/hibernate-orm-panache
3. 编写业务代码
先删除初始工程中的所有类文件:GreetingResource.java
、MyEntity.java
3.1 数据库操作
创建三个包:
com.suimz.dao.entity
- 存放表结构的实体类com.suimz.dao.enums
- 将表中状态字段映射成枚举类com.suimz.dao.repository
- 操作数据库的具体方法实现
创建用户状态枚举类: MemberStatus.java
package com.suimz.dao.enums;
import jakarta.persistence.AttributeConverter;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
@Getter
@AllArgsConstructor
public enum MemberStatus {
// 禁止
PROHIBIT(0),
// 正常
NORMAL(1),
;
private int value;
public static MemberStatus get(Integer value) {
return value == null ? null : Arrays.stream(values())
.filter(item -> item.getValue() == value)
.findFirst().orElse(null);
}
/**
* 转换器 - 数据库字段的值映射为枚举对象
*/
public static class Converter implements AttributeConverter<MemberStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(MemberStatus attribute) {
return attribute == null ? null : attribute.value;
}
@Override
public MemberStatus convertToEntityAttribute(Integer dbData) {
return MemberStatus.get(dbData);
}
}
}
创建用户实体类: Member.java
package com.suimz.dao.entity;
import com.suimz.dao.enums.MemberStatus;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity(name = "member") // 指定表名
public class Member {
@Id
// GenerationType.IDENTITY 表示使用数据库的自增主键
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String username;
private String password;
// 将字段转为枚举
@Convert(converter = MemberStatus.Converter.class)
private MemberStatus status;
@Column(name = "latest_login_time")
private LocalDateTime latestLoginTime;
}
注解说明(都是 JPA 规范中的注解):
@Entity(name = "member")
标明该类为实体类,name 属性执行表名,不设置的话,默认操作的表名同类名,即:Member
。@Id
设置当前字段为表主键。@GeneratedValue
指定ID生成策略,这里设置成表自增主键。@Column
指定属性对应表中的字段名,不加此注解表示表中的字段名跟属性名相同。@Convert
指定将该字段的值转为枚举对象。
创建操作用户表的 Repository 类: MemberRepository.java
package com.suimz.dao.repository;
import com.suimz.dao.entity.Member;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.LocalDateTime;
@ApplicationScoped
public class MemberRepository implements PanacheRepositoryBase<Member, Integer> {
/**
* 根据用户名查找
* @param username 用户名
* @return 用户
*/
public Member findByUsername(String username){
return find("username", username).firstResult();
}
/**
* 用户名是否存在
* @param username 用户名
*/
public boolean isExistByUsername(String username){
return count("username", username) > 0;
}
/**
* 修改最后登录时间
* @param id 用户ID
* @param time 登录时间
*/
public boolean updateLoginTime(Integer id, LocalDateTime time) {
return update("latestLoginTime = ?1 where id = ?2", time, id) > 0;
}
}
说明:
@ApplicationScoped
表示将当前类交给 CDI 管理,可以理解成 Spring 中的@Repository @Component @Service
。Repository 类需实现
PanacheRepositoryBase<实体类, 主键类型>
接口。PanacheRepositoryBase
接口中内置了通用的操作方法,如:findById()
、listAll()
等。类中的数据库查询语法为 Hibernate 的 HQL
详细的数据库操作文档可查阅:https://quarkus.io/guides/hibernate-orm-panache
3.2 调用外部API
本教程对接 OpenAI GPT 3.5 模型的聊天接口,需要提前准备 apiKey(https://platform.openai.com/overview) ,当然,没有 apiKey‘ 也不影响项目的运行,只是无法使用聊天功能,无伤大雅。
首先,在 application.properties
中增加配置项:
# OpenAI
quarkus.rest-client.openai-api.url=https://api.openai.com
quarkus.rest-client.openai-api.headers.Authorization=Bearer sk-xxx
quarkus.rest-client.openai-api.connect-timeout=60000
quarkus.rest-client.openai-api.verify-host=false
上面是 io.quarkus:quarkus-rest-client-reactive
扩展中的配置参数,其中 openai-api
为自定义的key,表示一组 Rest Client 的配置。
接着,我们创建一个访问 OpenAI 接口的 interface OpenAiService.java
:
package com.suimz.openai;
import com.suimz.openai.bean.ChatCompletionRequest;
import com.suimz.openai.bean.ChatCompletionResult;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey="openai-api")
public interface OpenAiService {
@POST
@Path("/v1/chat/completions")
ChatCompletionResult createChatCompletion(ChatCompletionRequest request);
}
@RegisterRestClient
声明当前类为 Rest Client,configKey 为上一步定义的 openai-api
。
这样就写好了一个通过 POST 请求 https://api.openai.com/v1/chat/completions
接口的实现,可在需要使用的类中通过 @RestClient
注入。
quarkus-rest-client-reactive
扩展详细的操作可以查阅:https://cn.quarkus.io/guides/rest-client-reactive
3.3 全局异常处理
先创建一个用于我们项目自身业务逻辑的异常基类 BizException.java
:
package com.suimz.exception;
import jakarta.ws.rs.core.Response;
import lombok.Getter;
@Getter
public class BizException extends RuntimeException {
// 异常绑定的HTTP响应状态码, 默认为200
private Response.Status httpStatus = Response.Status.OK;
// 接口返回json结构中的状态标识
private int status = Response.Status.INTERNAL_SERVER_ERROR.getStatusCode();
public BizException(String msg) { super(msg); }
public BizException(String msg, Response.Status httpStatus) {
this(msg, httpStatus.getStatusCode());
this.httpStatus = httpStatus;
}
public BizException(String msg, Integer status) {
super(msg);
this.status = status;
}
}
接着创建一个统一接口响应结构的类 R.java
:
package com.suimz.bean;
import jakarta.ws.rs.core.Response;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class R<T> {
// 状态码
private Integer status;
// 描述信息
private String message;
// 响应数据
private T data;
public static R ok() {
return R.<Void>ok(null);
}
public static <T> R<T> ok(T data) {
return R.<T>builder().status(200).message("success").data(data).build();
}
public static R error() {
return R.error(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), "服务异常,请稍后再试");
}
public static R error(Response.Status status) {
return R.error(status.getStatusCode(), status.getReasonPhrase());
}
public static R error(String error) {
return R.error(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), error);
}
public static R error(Integer status, String error) {
return R.<Void>builder()
.status(status)
.message(error)
.build();
}
}
最后开始处理异常 ExceptionMapper.java
:
package com.suimz.exception;
import com.suimz.bean.R;
import io.quarkus.hibernate.validator.runtime.jaxrs.ResteasyReactiveViolationException;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.UnauthorizedException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
@Slf4j
@ApplicationScoped
public class ExceptionMapper {
// 业务异常
@ServerExceptionMapper(BizException.class)
public RestResponse<R> map(BizException e) {
return RestResponse.status(e.getHttpStatus(), R.error(e.getStatus(), e.getMessage()));
}
// 接口请求参数校验失败
@ServerExceptionMapper(ResteasyReactiveViolationException.class)
public RestResponse<R> map(ResteasyReactiveViolationException e) {
return RestResponse.status(Response.Status.BAD_REQUEST, R.error(Response.Status.BAD_REQUEST.getStatusCode(), e.getConstraintViolations().iterator().next().getMessage()));
}
// JWT 校验失败
@ServerExceptionMapper({UnauthorizedException.class, AuthenticationFailedException.class})
public RestResponse<R> map(Throwable e) {
return RestResponse.status(Response.Status.UNAUTHORIZED, R.error(Response.Status.UNAUTHORIZED));
}
// 其他异常
@ServerExceptionMapper(Exception.class)
public RestResponse<R> map(Exception e) {
log.error(e.getMessage(), e);
return RestResponse.status(Response.Status.INTERNAL_SERVER_ERROR, R.error());
}
}
核心是使用 @ServerExceptionMapper
注解在方法上接收指定的异常,进行处理并返回自定义格式的数据给客户端。
3.4 JWT 身份认证
在 application.properties
中增加配置项:
# JWT 认证
# 发签↓
smallrye.jwt.sign.key.location=jwt/privateKey.pem
smallrye.jwt.new-token.issuer=https://example.com/issuer
smallrye.jwt.claims.sub=smz
# token有效时长(单位秒),15天
smallrye.jwt.new-token.lifespan=1296000
# 验签↓
mp.jwt.verify.publickey.location=jwt/publicKey.pem
mp.jwt.verify.issuer=${smallrye.jwt.new-token.issuer}
mp.jwt.verify.sub=${smallrye.jwt.claims.sub}
# native 打包时,将文件打入原生可执行文件中
quarkus.native.resources.includes=${smallrye.jwt.sign.key.location}, ${mp.jwt.verify.publickey.location}
用 OpenSSL 生成私钥,获得 publicKey.pem 和 privateKey.pem 文件:
$ openssl genrsa -out rsaPrivateKey.pem 2048
$ openssl rsa -pubout -in rsaPrivateKey.pem -out publicKey.pem
$ openssl pkcs8 -topk8 -nocrypt -inform pem -in rsaPrivateKey.pem -outform pem -out privateKey.pem
quarkus-smallrye-jwt
扩展详细的操作可以查阅:https://cn.quarkus.io/guides/security-jwt
3.5 编写 Service
用户 MemberService.java
:
package com.suimz.service;
import com.suimz.dao.entity.Member;
import com.suimz.dao.repository.MemberRepository;
import com.suimz.exception.BizException;
import io.quarkus.elytron.security.common.BcryptUtil;
import io.smallrye.jwt.build.Jwt;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.core.Response;
import java.time.LocalDateTime;
@ApplicationScoped
public class MemberService {
@Inject
private MemberRepository memberRepository;
/**
* 用户注册
* @param username
* @param password
*/
@Transactional
public void register(String username, String password) {
// 是否已被注册
if (memberRepository.isExistByUsername(username))
throw new BizException("该账号已被注册");
// 创建实体
Member member = Member.builder()
.username(username)
.password(BcryptUtil.bcryptHash(password)) // 对密码加密
.build();
// 入库
memberRepository.persistAndFlush(member);
}
/**
* 用户登录
* @param username
* @param password
* @return 用户 token
*/
@Transactional
public String login(String username, String password) {
// 从库中查找出用户
Member member = memberRepository.findByUsername(username);
if (member == null)
throw new BizException("用户不存在", Response.Status.NOT_FOUND);
// 校验密码
if (!BcryptUtil.matches(password, member.getPassword()))
throw new BizException("密码或账号有误", Response.Status.FORBIDDEN);
// 生成token
String token = Jwt.claim("UID", member.getId()).sign();
// 记录登录时间
memberRepository.updateLoginTime(member.getId(), LocalDateTime.now());
return token;
}
}
聊天ChatService.java
:
package com.suimz.service;
import com.suimz.bean.ChatReq;
import com.suimz.bean.ChatResp;
import com.suimz.exception.BizException;
import com.suimz.openai.OpenAiService;
import com.suimz.openai.bean.ChatCompletionRequest;
import com.suimz.openai.bean.ChatCompletionResult;
import com.suimz.openai.bean.ChatMessage;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import java.util.Arrays;
@ApplicationScoped
public class ChatService {
@RestClient
private OpenAiService openAiService;
/**
* 聊天
* @param uid 当前用户ID
* @param req
* @return
*/
public ChatResp chat(Integer uid, ChatReq req) {
try {
// 请求 OpenAI 对话接口
ChatCompletionResult result = openAiService.createChatCompletion(
// 请求参数
ChatCompletionRequest.builder()
.model("gpt-3.5-turbo") // 对话模型
.temperature(1.0) // 0-2
.user(String.valueOf(uid)) // 可选
.messages(Arrays.asList(new ChatMessage("user", req.getText())))
.build()
);
return ChatResp.builder()
.text(result.getChoices().stream().findFirst().get().getMessage().getContent())
.build();
} catch (Exception e) {
throw new BizException(e.getMessage());
}
}
}
3.6 编写接口
在 Quarkus 中,接口统一被称为资源,用 Resource 表示,可以理解成 Spring 中的 Controller
。
用户 MemberResource.java
:
package com.suimz.resource;
import com.suimz.bean.LoginReq;
import com.suimz.bean.R;
import com.suimz.exception.RequestParamErrorBizException;
import com.suimz.service.MemberService;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/api/member")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class MemberResource{
@Inject
private MemberService memberService;
/**
* 用户注册
* @param req
*/
@POST
@Path("/register")
public R register(@Valid LoginReq req) {
if (req == null) throw new RequestParamErrorBizException();
memberService.register(req.getUsername(), req.getPassword());
return R.ok();
}
/**
* 用户登录
* @param req
* @return 用户 token
*/
@POST
@Path("/login")
public R<String> login(@Valid LoginReq req) {
if (req == null) throw new RequestParamErrorBizException();
return R.ok(memberService.login(req.getUsername(), req.getPassword()));
}
}
聊天 ChatResource.java
:
package com.suimz.resource;
import com.suimz.bean.ChatReq;
import com.suimz.bean.ChatResp;
import com.suimz.bean.R;
import com.suimz.exception.RequestParamErrorBizException;
import com.suimz.service.ChatService;
import io.quarkus.security.Authenticated;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.jwt.JsonWebToken;
@Path("/api/chat")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class ChatResource {
@Inject
private ChatService chatService;
@Inject
protected JsonWebToken jwt;
/**
* 聊天
* @param req
*/
@POST
@Authenticated // 必须通过 JWT 身份认证
public R<ChatResp> chat(@Valid ChatReq req) {
if (req == null) throw new RequestParamErrorBizException();
Integer uid = Integer.valueOf(jwt.getClaim("UID"));
ChatResp resp = chatService.chat(uid, req);
return R.ok(resp);
}
}
4. 本地调试
通过 IDEA 直接运行该项目即可,得力于 Quarkus 完善的热加载机制,开发过程中对类以及配置文件的修改,不需要重启工程,会自动即时刷新。
5. 打包部署
5.1 默认编译
在 IDEA 中直接运行 Quarkus 提供的 quarkusBuild
脚本进行编译:
BUILD
编译的文件在 build/quarkus-app
整个目录下,可通过 java -jar quarkus-run.jar
运行。
5.2 完整编译
将项目编译成一个jar文件,在 application.properties
中增加配置项:
quarkus.package.type=uber-jar
然后再运行 quarkusBuild
脚本,编译的文件在 build/example-simple-quarkus-chat-1.0.0-SNAPSHOT-runner.jar
,可通过 java -jar example-simple-quarkus-chat-1.0.0-SNAPSHOT-runner.jar
运行。
5.3 Native编译
Quarkus 使用 GraalVM 构建原生可执行文件,这里通过 Docker,来进行build,否则就需要安装 GraalVM 环境以及C环境,总之,比较麻烦,建议使用 Docker。
在 application.properties
中修改&增加配置项:
# native 表示进行原生构建
quarkus.package.type=native
# 在 docker 中进行构建原生可执行文件
quarkus.native.container-build=true
然后再运行 quarkusBuild
脚本,编译过程比较久,根据电脑配置而定,大概需要5-10分钟,如果没有安装 Docker 的话,运行时会报下面的错误:
No container runtime was found. Make sure you have either Docker or Podman installed in your environment.
编译出来的文件在 build/example-simple-quarkus-chat-1.0.0-SNAPSHOT-runner
,此文件可直接在 Linux 上运行。