用 Java Jersey 搭建 REST API:从资源类到接口测试

运营资讯2小时前发布 jdkjadmin
0 00
用 Java Jersey 搭建 REST API:从资源类到接口测试

用 Java Jersey 搭建 REST API:从资源类到接口测试

Jersey 是 Jakarta RESTful Web Services(Jakarta REST)的参考实现之一。它把 HTTP 请求映射到 Java 方法:资源类描述 URL,注解描述路径和 HTTP 方法,实体类承载 JSON 数据。你可以用它为已有 Java 服务提供 REST 接口,也可以用它搭建体量适中、希望直接使用 Jakarta REST 编程模型的应用。

本文使用 Java 17、Jersey 3.1.6 和 Grizzly,搭建一个可运行的条目 API,支持创建、查询、替换和删除。示例包括输入检查和 HTTP 状态码,但刻意使用内存存储,不把演示代码包装成生产方案。需要注意,Jersey 3 使用 jakarta.* 命名空间;如果项目仍使用 Jersey 2,通常对应的是 javax.*,两套依赖和导入不能混用。

一、什么时候适合用 Jersey

当团队希望通过 Java 注解定义 REST 资源,并且需要选择嵌入式服务器或部署容器时,Jersey 是一个直接的选择。它适用于内部服务、已有 Jakarta 技术栈的系统、需要自定义 HTTP 行为的 API,以及希望用标准化接口减少框架绑定的项目。

如果项目已经统一采用 Spring Boot,团队依赖其自动配置、生态集成和运维工具,那么仅为一个小接口引入 Jersey 未必划算。反过来,Jersey 也不是完整的应用平台:认证授权、持久化、事务、配置管理和 API 文档都需要另行设计或集成。选择框架时应考虑团队已有基础,而不是只比较注解写法。

二、创建项目

先建立如下目录结构。启动类和资源类放在同一个包中,便于 Jersey 扫描:

jersey-api/
├── pom.xml
└── src/
    └── main/
        └── java/
            └── demo/
                ├── Main.java
                └── ItemResource.java

pom.xml 引入 Grizzly HTTP 容器、Jersey 的依赖注入支持和 Jackson JSON 提供器。这里用 Grizzly 在本地启动服务,不需要额外安装应用服务器。

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>demo</groupId>
  <artifactId>jersey-api</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <jersey.version>3.1.6</jersey.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.glassfish.jersey.containers</groupId>
      <artifactId>jersey-container-grizzly2-http</artifactId>
      <version>${jersey.version}</version>
    </dependency>
    <dependency>
      <groupId>org.glassfish.jersey.inject</groupId>
      <artifactId>jersey-hk2</artifactId>
      <version>${jersey.version}</version>
    </dependency>
    <dependency>
      <groupId>org.glassfish.jersey.media</groupId>
      <artifactId>jersey-media-json-jackson</artifactId>
      <version>${jersey.version}</version>
    </dependency>
  </dependencies>
</project>

将 jersey.version 固定为所选版本,避免不同模块无意中解析到不兼容的 Jersey 组件。本文代码以 Java 17 编译。Jackson 提供 JSON 与 Java 对象之间的转换;不注册 JSON 提供器时,资源方法即使返回 Java 对象,也不代表 Jersey 一定知道如何把它序列化为 JSON。

三、编写资源类

资源类是 Jersey API 的核心。类上的 @Path 定义资源路径,方法上的 @GET、@POST 等注解定义 HTTP 方法。下面的代码用一个并发 Map 模拟存储,因此可以完整运行和测试,但数据只存在于当前进程中。

package demo;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;

import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;

@Path("/items")
@Produces(MediaType.APPLICATION_JSON)
public class ItemResource {
    private static final ConcurrentMap<Long, Item> ITEMS =
            new ConcurrentHashMap<>();
    private static final AtomicLong NEXT_ID = new AtomicLong(1);

    public record Item(long id, String name, String description) {}
    public record ItemInput(String name, String description) {}
    public record ApiError(String error) {}

    @GET
    public List<Item> list(@QueryParam("q") String query) {
        String q = query == null ? "" : query.trim().toLowerCase();
        return ITEMS.values().stream()
                .filter(item -> item.name().toLowerCase().contains(q))
                .sorted(Comparator.comparingLong(Item::id))
                .toList();
    }

    @GET
    @Path("/{id}")
    public Item get(@PathParam("id") long id) {
        Item item = ITEMS.get(id);
        if (item == null) {
            throw new NotFoundException();
        }
        return item;
    }

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public Response create(ItemInput input, @Context UriInfo uriInfo) {
        validate(input);
        long id = NEXT_ID.getAndIncrement();
        Item item = new Item(id, input.name().trim(), input.description());
        ITEMS.put(id, item);

        return Response.created(
                uriInfo.getAbsolutePathBuilder().path(Long.toString(id)).build())
                .entity(item)
                .build();
    }

    @PUT
    @Path("/{id}")
    @Consumes(MediaType.APPLICATION_JSON)
    public Item replace(@PathParam("id") long id, ItemInput input) {
        validate(input);
        if (!ITEMS.containsKey(id)) {
            throw new NotFoundException();
        }
        Item item = new Item(id, input.name().trim(), input.description());
        ITEMS.put(id, item);
        return item;
    }

    @DELETE
    @Path("/{id}")
    public Response delete(@PathParam("id") long id) {
        if (ITEMS.remove(id) == null) {
            throw new NotFoundException();
        }
        return Response.noContent().build();
    }

    private static void validate(ItemInput input) {
        if (input == null || input.name() == null
                || input.name().trim().isEmpty()) {
            throw new jakarta.ws.rs.BadRequestException(
                    "name must not be blank");
        }
        if (input.name().trim().length() > 100) {
            throw new jakarta.ws.rs.BadRequestException(
                    "name must be at most 100 characters");
        }
        if (input.description() != null && input.description().length() > 1000) {
            throw new jakarta.ws.rs.BadRequestException(
                    "description must be at most 1000 characters");
        }
    }
}

这组方法表达了常见的资源约定:GET /items 获取集合,GET /items/{id} 获取单项,POST /items 创建资源,PUT /items/{id} 替换资源,DELETE /items/{id} 删除资源。创建成功时返回 201,并通过 Location 响应头指出新资源地址;删除成功返回 204,表示没有响应实体。

@QueryParam("q") 把查询字符串映射为可选参数。未提供时参数为 null,示例将其视作空查询并返回全部条目。查询参数的长度、字符集或取值范围,并不会因为用了 @QueryParam 就自动受到业务限制;需要在应用代码或网关层明确规定并执行。

四、参数限制与错误边界

示例主动校验名称非空、名称长度不超过 100 个 Java 字符,以及描述长度不超过 1000 个 Java 字符。这些是本 API 自己定义的业务规则,不是 Jersey 对所有参数的默认限制。Java 的 String.length() 按 UTF-16 代码单元计数;它不等价于 UTF-8 字节数,也不总等价于用户感知的字符数。如果限制针对数据库列宽、网络载荷或可见字符,应按对应单位设计校验。

路径参数 long id 由 Jersey 转换。无法转换为数字的路径值通常会导致客户端错误响应;不存在的数字 ID 则由代码明确转成 404。JSON 请求体缺字段、类型不符或格式错误,也可能在进入资源方法之前就由消息体读取器拒绝。不要假设所有错误都会经过资源方法中的校验逻辑。

示例使用 BadRequestException 和 NotFoundException 触发标准状态码,但不同错误路径的响应实体格式不一定相同。代码中的 ApiError 目前没有被使用,因此不要据此假定错误响应已经统一为 JSON。正式 API 通常应实现 ExceptionMapper,把校验失败、资源不存在和未预期异常转换为稳定的错误结构,同时避免向客户端暴露堆栈、SQL 或内部配置。

长度校验也不能替代请求体大小限制。服务器或反向代理应限制过大的请求;否则攻击者可以在业务校验之前发送很大的 JSON。具体限制应根据服务器版本、代理配置及部署方式核实,不能只靠 DTO 上的字段规则推断网络层已经安全。

五、启动 Jersey 服务

使用 ResourceConfig 注册资源类和 Jackson 功能,再由 Grizzly 启动 HTTP 服务。将下面文件保存为 src/main/java/demo/Main.java:

package demo;

import org.glassfish.grizzly.http.server.HttpServer;
import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory;
import org.glassfish.jersey.jackson.JacksonFeature;
import org.glassfish.jersey.server.ResourceConfig;

import java.net.URI;

public class Main {
    public static void main(String[] args) throws Exception {
        URI baseUri = URI.create("http://localhost:8080/");
        ResourceConfig config = new ResourceConfig()
                .register(ItemResource.class)
                .register(JacksonFeature.class);

        HttpServer server =
                GrizzlyHttpServerFactory.createHttpServer(baseUri, config);
        Runtime.getRuntime().addShutdownHook(new Thread(server::shutdownNow));

        System.out.println("Listening on " + baseUri + "api/items");
        Thread.currentThread().join();
    }
}

资源类路径为 /items。还需要给应用配置根路径,才能形成文中使用的 /api/items。可以通过 @ApplicationPath 配置,但在嵌入式启动场景中更直观的做法,是在 ResourceConfig 中设置应用路径:

ResourceConfig config = new ResourceConfig()
        .register(ItemResource.class)
        .register(JacksonFeature.class)
        .property("jersey.config.server.applicationPath", "/api");

将这段替换前一个示例中的 ResourceConfig 初始化代码。修改后启动地址为 http://localhost:8080/api/items。也可以不用应用前缀,将客户端请求直接发往 /items;关键是服务端配置与实际测试地址保持一致。

在包含 Maven 的环境中,可以先编译,再使用 IDE 运行 demo.Main。若希望从命令行启动,可在项目中添加 Maven Exec 插件,或打包为包含依赖的应用。执行 mvn package 只会按默认生命周期编译和打包,并不会自动生成可独立运行、包含全部依赖的可执行 JAR。

六、用 curl 做接口测试

启动服务后,创建一个条目:

curl -i -X POST http://localhost:8080/api/items \
  -H 'Content-Type: application/json' \
  -d '{"name":"Jersey 入门","description":"一个测试条目"}'

正常情况下,响应状态为 201,响应头中包含新条目的 Location,响应体类似:

{"id":1,"name":"Jersey 入门","description":"一个测试条目"}

通过集合接口查看数据,或按查询参数筛选:

curl -i http://localhost:8080/api/items
curl -i 'http://localhost:8080/api/items?q=jersey'

查询名称中包含 jersey 的条目时,示例将查询词和名称转为小写后做子串匹配。这个搜索逻辑只适合演示;它没有分页、相关性排序、语言相关的大小写规则,也没有针对大集合优化。

使用完整替换更新,请求体中需要包含 API 期望的字段:

curl -i -X PUT http://localhost:8080/api/items/1 \
  -H 'Content-Type: application/json' \
  -d '{"name":"Jersey REST API","description":"已更新"}'

删除资源:

curl -i -X DELETE http://localhost:8080/api/items/1

还应测试边界情况:空名称应得到客户端错误;不存在的数字 ID 应返回 404;不存在的路由应返回 404;发送非 JSON 内容类型时,服务端不应把请求当作符合约定的 JSON 处理。测试时用 -i 查看状态码和响应头,而不只是检查响应正文。

七、从手工测试走向自动化

curl 适合快速验证路由、状态码和 JSON,但不适合长期回归。自动化集成测试应启动应用或测试容器,向真实 HTTP 端点发送请求,并验证状态码、响应头和实体。至少覆盖创建后查询、非法输入、未知 ID、替换、删除,以及删除后再次查询的行为。

测试可以使用 JUnit 配合 Jersey Test Framework;Grizzly 测试容器能够避免为每个测试单独管理外部服务器。测试需要注意数据隔离:当前示例把数据放在静态 Map 中,测试之间共享状态。应在测试开始时清理数据,或者改为可注入、可重置的存储组件。否则测试顺序一变,ID 和结果就可能不同。

如果只进行外部接口冒烟测试,也可以在 CI 中启动服务后运行 curl 或 HTTP 客户端脚本。不过脚本不应假设自增 ID 永远从 1 开始;更稳妥的做法是从创建响应中读取 ID,再将其用于后续请求。

八、示例和生产系统的边界

内存 Map 不是持久化方案

进程重启会清空所有数据;多实例部署时,各实例的数据还会彼此独立。用 ConcurrentHashMap 只能提供部分并发访问能力,不能替代数据库事务、唯一约束或跨实例一致性。示例中的先检查再写入也不构成针对真实数据库的原子业务操作。

PUT 与 PATCH 不要混为一谈

示例的 PUT 是完整替换:客户端要提交本接口定义的完整输入对象。PATCH 通常表示部分修改,其字段缺失、显式传 null 和清空字段之间的语义需要单独定义。不要把只接收一部分字段的操作随意标成 PUT,以免客户端无法判断替换规则。

并发更新需要明确策略

示例没有版本号或条件请求,多个客户端同时更新时,后写入的内容会覆盖先写入的内容。对重要资源,可考虑数据库乐观锁、版本字段、ETag 与 If-Match 等机制,并明确冲突时返回什么状态。是否需要这些机制取决于资源并发特征,不是 Jersey 自动提供的行为。

HTTP 状态码不是访问控制

本示例没有身份认证和授权,任何能访问服务的人都可以读写条目。部署到网络环境之前,应根据威胁模型接入认证、按资源或操作授权,并配置 HTTPS。CORS 只影响浏览器跨域读取策略,不负责认证,也不能代替服务端授权。

不要把输入校验当作安全边界的全部

服务端应校验所有不可信输入,但校验规则要与具体用途匹配。若名称会被写入数据库,应使用参数化查询并由数据库约束兜底;若内容随后显示在网页中,应由输出上下文进行恰当编码。字符串长度限制本身不能防止 SQL 注入、跨站脚本或资源耗尽。

状态码与返回结构应保持稳定

接口应约定创建、查询、更新和删除各自的成功与失败语义,并在文档和自动化测试中固定下来。客户端不应依赖服务器内部异常文本。生产服务还需要日志、指标、超时和健康检查;日志应避免记录密码、令牌或其他敏感数据。

九、常见错误排查

  • 请求路径返回 404:核对资源类的 @Path、应用路径配置、主机端口和请求方法。类路径与应用前缀是两层配置,最终 URL 是它们组合后的结果。
  • 返回对象时出现 500 或无法序列化:确认 JSON 提供器依赖已加入并注册,且 Content-Type 与资源方法的 @Consumes 匹配。
  • 启动时出现 javax 与 jakarta 类型冲突:检查 Jersey 主版本与导入。Jersey 3 面向 Jakarta REST,应使用 jakarta.ws.rs;不要把 Jersey 2 的 API 包混进同一应用。
  • 请求体无法映射到 DTO:确认 JSON 字段名、类型和结构与输入类型一致。资源方法参数可以由 JSON 提供器读取,但字段不匹配或 JSON 格式错误时,方法可能根本不会执行。
  • 错误响应格式不一致:为应用实现异常映射器,并分别测试资源不存在、参数无效、请求体解析失败和未预期异常等路径。

结语

用 Jersey 搭建 REST API 的基本过程并不复杂:准备运行容器和 JSON 提供器,通过资源类映射 URL 与 HTTP 方法,再为输入校验、错误响应和接口测试定义明确规则。本文的代码适合作为本地练习和 API 结构示例;要用于生产,还需要把内存存储替换为可靠的数据层,补齐认证授权、请求大小限制、并发控制、日志和自动化测试。真正决定接口是否可靠的,不只是注解,而是这些边界是否被清楚定义并经过验证。

© 版权声明

相关文章