从0搭建一个 Quarkus Web 项目 | 附源码

166

当前文档撰写时间: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 中与之对应的常用注解:

Spring

Quarkus

@GetMapping

@GET

@PostMapping

@POST

@PutMapping

@PUT

@DeleteMapping

@DELETE

@PathVariable

@PathParam

@RequestBody

@Consumes

@RequestParam

@QueryParam

@RequestMapping、@RestController、@Controller

@Path

@Service、@Component

@ApplicationScoped

@Autowired、@Resource

@Inject

@ExceptionHandler

@ServerExceptionMapper

@ConfigurationProperties

@ConfigMapping

@Value

@ConfigProperty

@Bean

@Produces

环境要求

以下是博主当前电脑的环境配置:

开始前,请确保已经安装并配置好 JDK17Gradle 7.6Docker Desktop(可选,Docker 用于进行 native 打包,构建本地可执行文件)。

Gradle 可以换成 Maven,Maven 最低版本:3.8.2

实践开始

1. 创建项目

使用 Quarkus 提供的在线构建工程的网站:https://code.quarkus.io

start

start

Build Tool 选择 Gradle(也可以选择 Maven),扩展选择:

  • RESTEasy Reactive - Web 基础,可以理解成 Spring 的 spring-web

  • RESTEasy 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.javaMyEntity.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

编译的文件在 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 上运行。

示例源码

Github | Gitee