PAYATHON 2026

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 仍由业务复杂度决定。

备注:内容仅供参考。