用 Java Jersey 构建 RESTful API:从项目配置到接口测试

影视资讯3小时前发布 jdkjadmin
0 00
用 Java Jersey 构建 RESTful API:从项目配置到接口测试

用 Java Jersey 构建 RESTful API:从项目配置到接口测试

Jersey 是 Jakarta RESTful Web Services(JAX-RS)的参考实现,适合用注解定义 HTTP 资源:用路径和 HTTP 方法描述接口,用 Java 方法处理请求,再由运行时负责参数绑定、内容协商和响应生成。它尤其适用于需要标准 REST 编程模型、希望把资源逻辑放进 Java 服务中的项目。

本文使用 Java 17、Jersey 3.1.6、Jackson 和 Grizzly,构建一个可以本地启动的图书 API。示例包括查询、创建、替换和删除图书,并通过 curl 验证请求。代码使用内存集合保存数据,因此可以直接运行,但不适合生产持久化。

一、先明确 Jersey 解决什么问题

Jersey 帮助实现 HTTP API,不等于完整的应用平台。它可以处理 JAX-RS 资源、请求与响应、参数绑定、内容协商和异常映射;但数据库事务、身份认证、授权、限流、审计、配置管理和生产部署,仍需由应用或周边基础设施提供。

如果要构建轻量 REST 服务,或在已有 Jakarta/Java 应用中采用标准资源注解,Jersey 是一个合适的选择。如果项目已经依赖其他 Web 框架,先比较团队现有技术栈、部署方式和运维能力,不必为了使用 Jersey 而额外引入一套运行时。

本文使用 Jersey 3.x,对应的是 jakarta.ws.rs 命名空间。Jersey 2.x 使用旧的 javax.ws.rs 命名空间,二者不能通过简单改包名混用;选依赖时要确保应用代码、Jersey 版本及其他 Jakarta 组件彼此兼容。示例以 Java 17 为运行环境。

二、创建 Maven 项目

新建项目目录,例如 jersey-books,在根目录创建 pom.xml。这里用 Grizzly 提供嵌入式 HTTP 容器,便于本地启动;Jackson 为 JSON 实体提供读写能力。

<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>example</groupId>
    <artifactId>jersey-books</artifactId>
    <version>1.0-SNAPSHOT</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.media</groupId>
            <artifactId>jersey-media-json-jackson</artifactId>
            <version>${jersey.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <mainClass>example.App</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

创建目录 src/main/java/example,下面将添加两个类:应用入口 App 和资源类 BookResource。Maven 首次构建时会下载依赖。请确认命令行中的 Java 与 Maven 编译配置相符,可用 java -version 和 mvn -version 检查。

三、定义资源和数据模型

JAX-RS 资源类通过 @Path 声明路径,通过 @GET、@POST、@PUT、@DELETE 绑定 HTTP 方法。方法参数则通过 @PathParam、@QueryParam 或请求实体接收数据。

以下示例将数据放在并发 Map 中,并用原子计数器分配 ID。它避免了简单多线程场景下集合结构损坏,但不提供数据库事务、跨进程共享或重启后持久化;更新与删除也不是数据库级事务操作。

package example;

import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;

import java.net.URI;
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("/api/books")
@Produces(MediaType.APPLICATION_JSON)
public class BookResource {
    private static final ConcurrentMap<Long, Book> BOOKS =
            new ConcurrentHashMap<>();
    private static final AtomicLong NEXT_ID = new AtomicLong(1);

    public static class Book {
        public long id;
        public String title;
        public String author;

        public Book() {
        }

        public Book(long id, String title, String author) {
            this.id = id;
            this.title = title;
            this.author = author;
        }
    }

    public static class BookInput {
        public String title;
        public String author;
    }

    public static class ErrorMessage {
        public String error;

        public ErrorMessage() {
        }

        public ErrorMessage(String error) {
            this.error = error;
        }
    }

    @GET
    public List<Book> list(
            @QueryParam("limit") @DefaultValue("100") String limitText) {
        int limit = parseLimit(limitText);
        return BOOKS.values().stream()
                .sorted(Comparator.comparingLong(book -> book.id))
                .limit(limit)
                .toList();
    }

    @GET
    @Path("/{id}")
    public Response get(@PathParam("id") String idText) {
        long id = parseId(idText);
        Book book = BOOKS.get(id);
        if (book == null) {
            return notFound();
        }
        return Response.ok(book).build();
    }

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public Response create(BookInput input, @jakarta.ws.rs.core.Context UriInfo uriInfo) {
        String problem = validate(input);
        if (problem != null) {
            return badRequest(problem);
        }

        long id = NEXT_ID.getAndIncrement();
        Book book = new Book(id, input.title.trim(), input.author.trim());
        BOOKS.put(id, book);

        URI location = uriInfo.getAbsolutePathBuilder()
                .path(Long.toString(id))
                .build();
        return Response.created(location).entity(book).build();
    }

    @PUT
    @Path("/{id}")
    @Consumes(MediaType.APPLICATION_JSON)
    public Response replace(@PathParam("id") String idText, BookInput input) {
        long id = parseId(idText);
        String problem = validate(input);
        if (problem != null) {
            return badRequest(problem);
        }

        Book old = BOOKS.get(id);
        if (old == null) {
            return notFound();
        }

        Book updated = new Book(id, input.title.trim(), input.author.trim());
        BOOKS.put(id, updated);
        return Response.ok(updated).build();
    }

    @DELETE
    @Path("/{id}")
    public Response delete(@PathParam("id") String idText) {
        long id = parseId(idText);
        if (BOOKS.remove(id) == null) {
            return notFound();
        }
        return Response.noContent().build();
    }

    private static String validate(BookInput input) {
        if (input == null) {
            return "JSON 请求体不能为空";
        }
        if (input.title == null || input.title.isBlank()) {
            return "title 不能为空";
        }
        if (input.author == null || input.author.isBlank()) {
            return "author 不能为空";
        }
        if (input.title.trim().length() > 200) {
            return "title 不能超过 200 个字符";
        }
        if (input.author.trim().length() > 120) {
            return "author 不能超过 120 个字符";
        }
        return null;
    }

    private static int parseLimit(String text) {
        try {
            int limit = Integer.parseInt(text);
            if (limit < 1 || limit > 100) {
                throw new NumberFormatException();
            }
            return limit;
        } catch (NumberFormatException exception) {
            throw new BadRequestException("limit 必须是 1 到 100 之间的整数");
        }
    }

    private static long parseId(String text) {
        try {
            long id = Long.parseLong(text);
            if (id <= 0) {
                throw new NumberFormatException();
            }
            return id;
        } catch (NumberFormatException exception) {
            throw new BadRequestException("id 必须是正整数");
        }
    }

    private static Response badRequest(String message) {
        return Response.status(Response.Status.BAD_REQUEST)
                .entity(new ErrorMessage(message)).build();
    }

    private static Response notFound() {
        return Response.status(Response.Status.NOT_FOUND)
                .entity(new ErrorMessage("图书不存在")).build();
    }
}

这里的字段长度限制是应用自行制定的业务规则,不是 Jersey 或 HTTP 协议规定的限制。Java 的 String.length() 以 UTF-16 代码单元计数,对某些非基本多文种平面字符可能与用户感知的字符数不同。如果业务要求按 Unicode 码点或字素簇计数,应明确采用相应的计数方式。

列表接口只实现了简单的数量上限,没有游标、总数或稳定分页语义。真实系统通常还要定义排序规则、分页一致性以及查询条件;不能把本例的 limit 当作完整分页方案。

PUT 在这里表示用输入中的 title 和 author 替换资源字段,不支持局部更新。若需要只改一个字段,可另行设计 PATCH 接口,并明确补丁格式和缺失字段的语义。不要把 PUT 请求体中的省略字段擅自解释成“保持原值”,否则接口的替换语义会变得含糊。

四、启动 Jersey 与 Grizzly

入口类注册资源和 Jackson 功能,再启动 Grizzly HTTP 服务。示例将 API 放在 http://localhost:8080/ 下。

package example;

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

import java.io.IOException;
import java.net.URI;

public class App {
    public static void main(String[] args) throws IOException {
        URI baseUri = URI.create("http://0.0.0.0:8080/");
        ResourceConfig config = new ResourceConfig()
                .register(BookResource.class)
                .register(JacksonFeature.class);

        HttpServer server =
                GrizzlyHttpServerFactory.createHttpServer(baseUri, config);

        Runtime.getRuntime().addShutdownHook(new Thread(server::shutdownNow));
        System.out.println("Jersey API 已启动: http://localhost:8080/api/books");
        Thread.currentThread().join();
    }
}

0.0.0.0 表示监听本机所有网络接口,而不是只接受本机回环连接。在开发机上如不需要局域网访问,可以改为 127.0.0.1。生产环境的监听地址和端口应由部署配置决定,并结合网络隔离、防火墙或反向代理进行控制。

在项目根目录运行:

mvn compile exec:java

如果启动时报缺少 JSON MessageBodyReader/Writer,先检查是否包含 jersey-media-json-jackson 并注册 JacksonFeature。不同 Jersey 模块版本混用也可能导致运行时链接错误,因此示例中的 Jersey 组件使用相同版本号。

五、用 curl 验证接口

1. 创建图书

curl -i -X POST http://localhost:8080/api/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"Java 网络编程","author":"李明"}'

成功时通常返回 201 Created,响应体包含分配的 ID,并带有指向新资源的 Location 响应头。具体响应头可用 -i 查看。

2. 查询列表和单条资源

curl -i 'http://localhost:8080/api/books'
curl -i 'http://localhost:8080/api/books?limit=10'
curl -i 'http://localhost:8080/api/books/1'

列表默认最多返回 100 条,允许的 limit 范围为 1 到 100。这里的数据存在进程内存中,重启后会清空;运行多个进程时,每个进程也会有各自独立的数据。

3. 全量替换与删除

curl -i -X PUT http://localhost:8080/api/books/1 \
  -H 'Content-Type: application/json' \
  -d '{"title":"Java 网络编程(第二版)","author":"李明"}'

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

删除成功返回 204 No Content,因此响应没有 JSON 实体。对不存在的 ID,本例返回 404 Not Found。

4. 验证错误输入

curl -i 'http://localhost:8080/api/books?limit=0'

curl -i -X POST http://localhost:8080/api/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"   ","author":"李明"}'

curl -i http://localhost:8080/api/books/not-a-number

第一条请求和无效 ID 都由示例主动抛出 BadRequestException。资源方法返回的业务错误使用统一的 ErrorMessage 对象;而 Jersey 对异常、无法解析的 JSON、错误媒体类型等请求问题如何生成响应,还可能受到异常映射器和运行时配置影响。若 API 需要所有错误响应都遵守同一格式,应实现并测试明确的 ExceptionMapper,不要假设框架默认错误体符合业务格式。

六、参数、媒体类型和状态码的边界

  • 路径参数与查询参数:@PathParam 适合资源标识等路径组成部分,@QueryParam 适合筛选、排序、分页等可选查询条件。参数传入后仍要按业务规则验证,类型转换成功并不代表值合理。
  • JSON 请求体:@Consumes("application/json") 声明资源接受的请求媒体类型,@Produces("application/json") 声明可产生的响应类型。客户端发送 JSON 时应设置 Content-Type: application/json;请求的响应格式则可用 Accept 表达。媒体类型不匹配时,服务器可能返回 415 或 406。
  • HTTP 状态码:创建资源通常使用 201 并提供 Location;读取成功通常为 200;删除成功可用 204;请求无效常用 400;资源不存在常用 404。状态码设计应反映实际处理结果,不要把所有异常都改成 200。
  • 输入校验:示例只检查必填字段和长度。生产接口还要根据业务校验数值范围、字段组合、重复资源和授权关系。校验失败的细节应避免泄露不必要的内部信息。

JAX-RS 的注解声明接口映射,不会自动替你定义所有参数上限。HTTP 服务器、反向代理、JSON 提供程序和应用本身可能分别配置请求体大小、头部大小、连接超时或解析限制;这些限制与资源方法的字段长度校验不是一回事。应根据所使用的容器和部署环境查看对应配置,并通过实际请求验证,不能把某一个框架的默认值当成跨环境保证。

七、常见错误与排查方式

使用了错误的包名

Jersey 3 代码应导入 jakarta.ws.rs.*。若误用了 javax.ws.rs.*,常见结果是编译失败、资源无法发现,或依赖冲突。请先确认 Jersey 主版本,再检查 IDE 自动导入和依赖树。

资源方法没有被注册

本例使用 register(BookResource.class) 显式注册资源,减少扫描配置造成的歧义。若使用包扫描,应确认包名正确、资源类可见,并且扫描的配置确实被 Grizzly 使用。

JSON 实体无法读写

确认 JSON 扩展依赖在运行时 classpath 中,Jackson 功能已注册,资源声明的媒体类型与请求一致。反序列化失败也可能来自格式错误、字段类型不匹配或请求体为空。不要把这些问题一概判断为路由未命中。

接口返回 404,但服务已经启动

核对基础 URI、资源类上的 @Path、方法上的 @Path 和请求 HTTP 方法。比如资源路径是 /api/books,单条资源才匹配 /api/books/{id}。还要区分“没有匹配到路由”与“路由匹配但 ID 不存在”:它们都可能呈现 404,但排查方向不同。

误把 PUT 当成局部更新

示例的 PUT 需要完整提供 title 和 author。若请求漏掉 author,校验会失败;这符合本例的全量替换约定。局部更新应使用明确的 PATCH 设计,或者定义其他清晰的更新语义。

直接使用内存 Map 作为生产数据层

并发集合不能替代持久化、事务和多实例协调。服务重启后数据丢失,多个实例也无法看到彼此写入的数据。生产实现通常应把资源层与服务层、数据访问层分开,并根据持久化方案设计事务、唯一约束和并发更新策略。

八、安全与生产化注意事项

示例没有身份认证或授权,任何能够访问服务的人都可以创建、读取、替换和删除数据。增加 @PathParam 校验、限定字符串长度或把集合换成并发容器,都不会自动产生访问控制。生产环境应在应用或可信网关中实施身份认证和逐资源授权,并确保敏感操作只能由有权限的主体执行。

还应根据实际威胁模型评估 TLS、请求体大小与超时限制、速率限制、审计日志、错误信息暴露、依赖漏洞和跨域策略。CORS 是浏览器对跨源访问的控制机制,不是身份认证,也不能阻止非浏览器客户端直接请求 API。

本例把原始校验消息直接返回给调用方,适合演示,但生产接口要谨慎决定错误详情;不应在错误响应中泄露堆栈、SQL、文件路径或内部配置。部署前还要确认管理端口、监听地址和日志中是否意外包含个人信息或凭证。

如果允许用户提交 HTML、脚本或富文本,不能因为响应是 JSON 就认为内容天然安全。输出到网页时,前端仍须按上下文正确编码;若服务会主动生成 HTML,则需要额外的内容安全处理。接口输入校验也不能取代输出编码。

九、下一步如何扩展

当示例接口跑通后,可以逐步增加自动化测试、Bean Validation、异常映射器和持久化层。Bean Validation 可减少手工校验代码,但需要添加兼容的验证 API 与实现,并明确如何把违反约束的结果转为稳定的 HTTP 响应;它不会自动替你确定业务规则或统一错误格式。

集成测试可以在测试进程内启动 Jersey/Grizzly,发送 HTTP 请求并断言状态码、响应头和 JSON 字段。测试时应关注边界值:空请求体、缺失字段、超长输入、非法 ID、重复操作、未知资源、错误 Content-Type,以及并发请求。若采用外部数据库,还应验证事务和重启后的数据状态。

从项目结构看,资源类适合负责 HTTP 层映射和响应,不宜把复杂业务、数据库调用和授权规则全部塞进方法中。随着需求增长,可把业务逻辑抽到服务层,把持久化交给数据访问层,并通过依赖注入管理对象生命周期。这样既更容易单元测试,也能减少 HTTP 实现细节对业务逻辑的影响。

至此,一个 Jersey REST API 已具备可运行的最小闭环:Maven 管理依赖,Grizzly 提供本地 HTTP 服务,Jersey 将路径映射到资源方法,Jackson 处理 JSON,curl 验证接口行为。这个起点适合学习和原型验证;要进入生产,还需补齐持久化、统一错误约定、身份认证与授权、部署级限制和自动化测试。

© 版权声明

相关文章