还在用PUT更新局部数据?Jakarta REST 4.0 的“合并补丁”,优雅!
Jakarta REST 4.0 是 Jakarta EE 11 中的一次重要更新,其中大部分工作都集中在内部整理(housekeeping)上。例如,在现代化 Jakarta REST 的 TCK(技术兼容性套件)方面付出了巨大的努力。此外,新版本还移除了对 ManagedBean
和 JAXB
规范的支持。
对于开发者而言,有几个值得注意的 API 变更:
• 新增了一些便捷的方法来检查请求头的值,特别是那些包含由令牌分隔的列表的请求头,包括
HttpHeaders#containsHeaderString
、ClientRequestContext#containsHeaderString
、ClientResponseContext#containsHeaderString
、ContainerRequestContext#containsHeaderString
和ContainerResponseContext#containsHeaderString
。• 新增了一个方法
UriInfo#getMatchedResourceTemplate
,用于检索当前请求所有匹配路径的 URI 模板。• 增加了对 JSON Merge Patch 的支持。
前两个是较小的改进。让我们来仔细看看 JSON Merge Patch。
JSON Merge Patch 简介
JSON Merge Patch 在 RFC 7386 中被定义如下:
本规范定义了 JSON Merge Patch 格式及其处理规则。Merge Patch 格式主要旨在与 HTTP PATCH 方法结合使用,作为一种描述对目标资源内容进行一系列修改的方式。
考虑以下示例 JSON 文档:
{"title": "我的第二篇文章","author": {"givenName": "Hantsy","familyName": "Bai"},"tags": ["second", "article"],"content": "这是我第二篇文章的内容"
}
假设你想把 tags
更新为 ["JAX-RS", "RESTEasy", "Jersey"]
,并把 author
改为 {"givenName": "Jack", "familyName": "Ma"}
。你可以发送一个这样的请求:
PATCH /articles/2 HTTP/1.1
Host: localhost
Content-Type: application/merge-patch+json{"author": {"givenName": "Jack","familyName": "Ma"},"tags": ["JAX-RS", "RESTEasy", "Jersey"]
}
经过合并补丁(Merge Patch)操作后,最终得到的 JSON 文档将是:
{"title":"我的第二篇文章","author":{"givenName":"Jack","familyName":"Ma"},"tags":["JAX-RS","RESTEasy","Jersey"],"content":"这是我第二篇文章的内容"
}
(注意:title
和 content
字段没有在补丁中提供,所以它们保持不变。而 author
和 tags
字段则被补丁中的新值完整替换了。)
让我们通过一个简单的 REST 资源示例,来演示如何在代码中实现这个过程。
示例项目
假设我们需要管理一个文章集合,用一个 Article
类来表示:
- •
Article.java
(使用 Java Record)import java.time.LocalDateTime; import java.util.List;publicrecordArticle(Integer id,String title,Author author,String content,List<String> tags,LocalDateTime publishedAt ) {// record 提供了不可变性,这些 "with" 方法用于创建新的、已修改的副本public Article withId(int id) {returnnewArticle(id, title, author, content, tags, publishedAt);}public Article withTags(List<String> tags) {returnnewArticle(id, title, author, content, tags, publishedAt);}public Article withAuthor(Author author) {returnnewArticle(id, title, author, content, tags, publishedAt);} }
- •
Author.java
(使用 Java Record)public record Author(String givenName, String familyName) {}
record
的序列化和反序列化。
ArticleRepository
是一个简单的内存中的存储库:
import jakarta.enterprise.context.ApplicationScoped;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;@ApplicationScoped
publicclassArticleRepository {privatestaticfinal ConcurrentHashMap<Integer, Article> articles = newConcurrentHashMap<>();privatestaticfinalAtomicIntegerID_GEN=newAtomicInteger(1);static { // 初始化一些数据varid1= ID_GEN.getAndIncrement();articles.put(id1,newArticle(id1, "我的第一篇文章",newAuthor("Hantsy", "Bai"),"这是我的第一篇文章",List.of("first", "article"),LocalDateTime.now()));varid2= ID_GEN.getAndIncrement();articles.put(id2,newArticle(id2, "我的第二篇文章",newAuthor("Hantsy", "Bai"),"这是我的第二篇文章",List.of("second", "article"),LocalDateTime.now()));}public List<Article> findAll() {return List.copyOf(articles.values());}public Article findById(int id) {return articles.get(id);}public Article save(Article article) {if (article.id() == null) {varid= ID_GEN.getAndIncrement();article = article.withId(id);}articles.put(article.id(), article);return article;}
}
现在,让我们看看 ArticleResource
(JAX-RS 资源类):
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.json.*;
import jakarta.json.bind.Jsonb;
import jakarta.json.bind.JsonbBuilder;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.StringReader;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;@Path("articles")
@RequestScoped
publicclassArticleResource {@InjectArticleRepository repository;Jsonb jsonb;@PostConstructpublicvoidinit() {jsonb = JsonbBuilder.create();}@GETpublic Response getArticles() {return Response.ok(repository.findAll()).build();}@GET@Path("{id}")public Response getArticle(@PathParam("id") Integer id) {return Response.ok(repository.findById(id)).build();}@POST@Consumes(MediaType.APPLICATION_JSON)public Response createArticle(Article article) {varsaved= repository.save(article);return Response.created(URI.create("/articles/" + saved.id())).build();}// JSON Patch (RFC 6902) 示例1: 对资源集合进行批量操作@PATCH@Consumes(MediaType.APPLICATION_JSON_PATCH_JSON)public Response saveOrUpdateAllArticles(JsonArray patch) {varall= repository.findAll();varresult= Json.createPatch(patch).apply(Json.createReader(newStringReader(jsonb.toJson(all))).readArray());List<Article> articles = jsonb.fromJson(jsonb.toJson(result),newArrayList<Article>() {}.getClass().getGenericSuperclass());articles.forEach(repository::save);return Response.noContent().build();}// JSON Patch (RFC 6902) 示例2: 对单个资源进行操作@PATCH@Path("{id}")@Consumes(MediaType.APPLICATION_JSON_PATCH_JSON)public Response updateArticle(@PathParam("id") Integer id, JsonArray patch) {vartarget= repository.findById(id);varpatchedResult= Json.createPatch(patch).apply(Json.createReader(newStringReader(jsonb.toJson(target))).readObject());vararticle= jsonb.fromJson(jsonb.toJson(patchedResult), Article.class);repository.save(article);return Response.noContent().build();}// JSON Merge Patch (RFC 7386) 示例: 对单个资源进行合并更新@PATCH@Path("{id}")//@Consumes(MediaType.APPLICATION_MERGE_PATCH_JSON) // 在 Jakarta REST 4.0 中添加的常量@Consumes("application/merge-patch+json")// 直接使用媒体类型字符串public Response mergeArticle(@PathParam("id") Integer id, JsonObject patch) {vartargetArticle= repository.findById(id);varmergedResult= Json.createMergePatch(patch).apply(Json.createReader(newStringReader(jsonb.toJson(targetArticle))).readObject());vararticle= jsonb.fromJson(jsonb.toJson(mergedResult), Article.class);repository.save(article);return Response.noContent().build();}
}
为了进行比较,我们还包含了两个 JSON Patch (由 RFC 6902 定义,并在 Java EE 8 / JAX-RS 2.1 中实现) 的示例端点:一个用于处理操作数组,另一个用于处理单个资源实体。
接下来,让我们创建一个 Arquillian 测试来验证这些功能:
(原文此处展示了一个非常长的 Arquillian 测试类 ArticleResourceTest
,包含了对 GET
, POST
, JSON Patch 和 JSON Merge Patch 的完整测试代码,此处为简洁起见,不再重复展示。该测试类的核心作用是:)
1. 部署测试环境: 使用 Arquillian 和 ShrinkWrap 创建一个包含所有必要类的
.war
测试包,并将其部署到一个嵌入式的应用服务器中。2. 客户端测试: 测试代码作为客户端运行,通过 HTTP 请求与部署好的服务进行交互。
3. 验证 JSON Patch: 发送
Content-Type
为application/json-patch+json
的PATCH
请求,并验证资源是否被正确地局部修改(例如,替换某个字段、移除某个字段、添加一个元素到数组中)。4. 验证 JSON Merge Patch: 发送
Content-Type
为application/merge-patch+json
的PATCH
请求,并验证资源是否被正确地合并更新(例如,author
和tags
字段被完整替换)。
(原文此处对 testGetArticleByIdAndMergePatch
测试方法进行了详细解释,说明了如何首先获取资源,然后基于修改后的版本创建补丁对象 Json.createMergeDiff(...)
,接着发送 PATCH
请求,最后再次获取资源以验证补丁是否成功应用。)
警告
Jakarta REST 客户端 API 目前还没有提供一个像现有的 get()
或 post()
那样便捷的 patch()
方法。相关讨论可见:jakartaee/rest#1276
。
可以从我的 GitHub 仓库获取完整的示例项目。
最后的思考
在过去的十年里,我开发了许多后端的 RESTful API 应用。然而,我注意到一个日益增长的趋势:越来越多的客户选择 Spring WebMvc 或 WebFlux 作为他们的首选框架,而不是 Jakarta REST。虽然像 RESTEasy 和 Quarkus 这样的库和框架帮助填补了一些空白,但 Jakarta REST 本身的发展一直很缓慢。像 JSON Patch 以及这个版本中新引入的 JSON Merge Patch 这样的功能,在真实世界的 RESTful API 开发中很少被使用。就连 Spring 也曾孵化过一个名为 Spring Sync
的项目来解决类似的需求,但后来也已被放弃。