Quarkus 为何使用 resources 而非 services 和 controllers?
结论
Quarkus 中的 Resource 基本对应 Spring MVC 的 Controller。这个名称来自 Jakarta REST(原 JAX-RS)中的“资源类”,与项目是否拆分控制器、业务逻辑和数据访问无关。
在简单示例中,Resource 经常直接调用 EntityManager。这样代码较短,也便于展示框架功能。正式项目是否需要增加 Service、Repository 和 DTO,取决于业务复杂度:
- 只有简单 CRUD 时,可以采用较薄的分层,也可以让
Resource直接访问持久化层。 - 涉及业务规则、权限判断、跨实体操作或事务编排时,通常需要引入
Service。 - API 对外提供、实体包含敏感字段或接口需要长期兼容时,适合使用请求和响应 DTO,避免直接暴露 JPA Entity。
Quarkus 不要求使用特定的目录或分层结构。Resource、Service 和 Repository 是项目自行划分的角色。
Resource 为什么不叫 Controller
Jakarta REST 以“资源”为中心组织 HTTP API。例如:
@Path("/products")
public class ProductResource {
}
这里的 ProductResource 是 /products 这个 HTTP 资源的入口,通常负责:
- 解析路由和参数;
- 处理 HTTP 状态码、请求头和响应头;
- 校验输入;
- 调用业务服务;
- 把业务结果转换为 HTTP 响应。
这些职责与 Spring MVC 的 @RestController 很接近,只是名称不同。
Quarkus 示例经常省略 Service,主要是为了减少展示代码。示例写法可以正常运行,但不代表 Quarkus 建议把所有逻辑都放进 Resource。
什么时候需要 Service
是否需要 Service,可以看业务逻辑有没有独立存在的必要。
如果接口只是查询一张表,类似下面的代码,未必值得专门增加一层:
return entityManager.find(Product.class, id);
遇到以下情况时,独立的 Service 通常更合适:
- 一次操作会修改多个实体;
- 需要检查业务状态或业务约束;
- 需要调用外部系统;
- 需要统一控制事务边界;
- 同一个业务操作会被 REST、消息消费者或定时任务调用;
- 需要脱离 HTTP 环境进行单元测试。
一种常见的职责划分是:Resource 处理 HTTP,Service 处理用例和业务规则,持久化代码负责数据库操作。分层是为了把变化控制在合适的位置,不是为了增加类的数量。
Resource 直接访问 Entity 是否合适
直接访问 Entity 不一定会造成安全漏洞。不过,如果请求体直接绑定到 Entity,响应也原样返回 Entity,就容易遇到以下问题。
客户端可能修改不应修改的字段
假设 Entity 包含这些字段:
private Long id;
private boolean approved;
private String internalRemark;
如果更新接口接收完整 Entity,通用属性复制工具可能会把 approved 和 internalRemark 一并写入数据库,形成批量赋值风险。手动把 id 设为 null 只能保护这一个字段,其他字段的授权问题仍然存在。
数据库模型会变成 API 契约
Entity 字段改名、拆表或调整关联关系,都可能影响 JSON 结构,导致数据库设计和外部 API 相互绑定。
序列化可能产生意外结果
双向关联可能造成循环引用。延迟加载字段在序列化时可能触发额外查询,也可能因持久化上下文已经关闭而读取失败。
通用更新难以表达业务规则
Updatable.updateProperties(existingEntity, newEntity) 用起来很省事,却很难准确处理这些问题:
- 哪些字段允许更新?
null表示清空字段,还是表示客户端没有提供该字段?- 某个字段是否只有管理员可以修改?
- 修改状态前是否需要检查当前状态?
这些规则通常与具体实体有关,不适合藏在通用反射工具里。
对当前 GenericResource 的具体改进
这段代码可以作为学习 CRUD 的骨架,但不适合直接充当所有业务实体的统一接口基类。
更新当前 EntityManager 已经管理的实体时,一般无须再次调用 merge():
T existingEntity = entityManager.find(entityClass, id);
updateEntity(existingEntity, entity);
在 @Transactional 方法中,find() 返回的对象通常处于 managed 状态。修改字段后,事务提交时会通过脏检查把变更写入数据库。merge() 主要用于把 detached entity 的状态合并回持久化上下文。
下面这条查询则依赖实体名称与 Java 类的简单名称完全一致:
"FROM " + entityClass.getSimpleName()
如果实体通过 @Entity(name = "...") 指定了其他名称,查询就会失效。getAll() 还应考虑分页,否则数据量增加后会一次加载整张表。
通用代码也不宜决定所有字段该如何创建和更新。基类可以复用查找、删除等机械操作,字段映射和业务校验则更适合留在具体业务代码中。
推荐的分层示例
下面使用 DTO 隔离 HTTP 数据和 JPA Entity。示例省略了 Entity 和部分异常响应代码。
请求与响应模型:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record CreateProductRequest(
@NotBlank String name,
@NotNull BigDecimal price
) {
}
public record UpdateProductRequest(
@NotBlank String name,
@NotNull BigDecimal price
) {
}
public record ProductResponse(
Long id,
String name,
BigDecimal price
) {
}
业务服务:
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import java.util.List;
@ApplicationScoped
public class ProductService {
@Inject
EntityManager entityManager;
public List<ProductResponse> findAll() {
return entityManager
.createQuery("FROM Product ORDER BY id", Product.class)
.setMaxResults(100)
.getResultStream()
.map(this::toResponse)
.toList();
}
public ProductResponse findById(Long id) {
return toResponse(findEntity(id));
}
@Transactional
public ProductResponse create(CreateProductRequest request) {
Product product = new Product();
product.name = request.name();
product.price = request.price();
entityManager.persist(product);
return toResponse(product);
}
@Transactional
public ProductResponse update(Long id, UpdateProductRequest request) {
Product product = findEntity(id);
product.name = request.name();
product.price = request.price();
return toResponse(product);
}
@Transactional
public void delete(Long id) {
entityManager.remove(findEntity(id));
}
private Product findEntity(Long id) {
Product product = entityManager.find(Product.class, id);
if (product == null) {
throw new NotFoundException("Product with id " + id + " does not exist.");
}
return product;
}
private ProductResponse toResponse(Product product) {
return new ProductResponse(
product.id,
product.name,
product.price
);
}
}
HTTP 资源:
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
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.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.List;
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {
@Inject
ProductService productService;
@GET
public List<ProductResponse> getAll() {
return productService.findAll();
}
@GET
@Path("/{id}")
public ProductResponse get(@PathParam("id") Long id) {
return productService.findById(id);
}
@POST
public Response create(
@Valid CreateProductRequest request,
@jakarta.ws.rs.core.Context UriInfo uriInfo) {
ProductResponse created = productService.create(request);
URI location = uriInfo.getAbsolutePathBuilder()
.path(created.id().toString())
.build();
return Response.created(location)
.entity(created)
.build();
}
@PUT
@Path("/{id}")
public ProductResponse update(
@PathParam("id") Long id,
@Valid UpdateProductRequest request) {
return productService.update(id, request);
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") Long id) {
productService.delete(id);
return Response.noContent().build();
}
}
使用 @Valid 和 Bean Validation 注解前,项目需要配置相应的验证扩展。具体依赖名称应以当前 Quarkus 版本的文档为准。
按照这种结构,Resource 无须了解数据库操作,Service 也不依赖 HTTP 的 Response,Entity 不会直接成为外部 API 的输入和输出模型。
还需要注意的几个问题
- 分层不等于每个实体都必须机械地创建
Resource、Service和Repository。没有独立职责的转发层只会增加维护成本。 @Transactional通常放在执行业务操作的服务方法上,使事务范围覆盖完整用例。- 更新接口应明确列出允许修改的字段,使用通用反射复制时尤其要谨慎。
- 列表接口应支持分页、排序和必要的过滤条件。
- 可以通过
ExceptionMapper统一把业务异常转换为404、409等 HTTP 响应。 - 身份验证和方法级授权可以放在 HTTP 边界,与业务数据有关的权限规则仍由业务层执行。
- 使用 Hibernate ORM with Panache 时,可以通过 Active Record 或 Repository Pattern 简化持久化代码,但是否需要
Service仍由业务复杂度决定。
备注:内容仅供参考。
