CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / JHIPSTER / P-226 · JHipster 项目开发

JHipster 开发 11:REST 层设计——生成的代码够用吗

第 6 篇讲 JDL 建模时说过,一次 jhipster jdl 会把实体的前后端代码全部生成出来。但生成只是起点——真正每天打交道的是这些代码的边界:哪些直接用,哪些要改,哪些必须绕开。这一篇聚焦后端 REST 层,把我们团队五年里对生成代码”动手”的经验梳理成一套决策框架。结论先说:80% 的 CRUD 场景生成代码直接够用,剩下的 20% 需要 extension 而不是 modification——这个区别决定了你升级 JHipster 时的痛苦程度。

生成代码的五层解剖

Customer 实体为例,JHipster 生成的后端结构:

src/main/java/com/mycompany/app/
├── domain/Customer.java                    // JPA 实体 + Bean Validation
├── repository/CustomerRepository.java      // Spring Data JPA
├── service/CustomerService.java           // 业务服务(可选 dto)
├── service/dto/CustomerDTO.java           // 传输对象
├── service/mapper/CustomerMapper.java     // MapStruct 转换器
└── web/rest/CustomerResource.java         // REST Controller

各层职责与”该不该改”的判断:

职责改动频率建议策略
ResourceHTTP 语义:路径、状态码、Header尽量不改,新增端点另开类
Service事务边界、业务规则、编排生成的是骨架,业务往这写
Repository数据访问、派生查询生成的是空接口,查询方法往这加
DTO契约形状、序列化随 JDL 再生成同步更新
Mapper实体与 DTO 互转几乎不改,MapStruct 编译期生成

关键认知:Service 是设计给你写代码的地方,Resource 和 Mapper 是设计给你少写代码的地方。分清这一点,后面所有决策都顺理成章。

Resource 层:薄薄的一层,别塞业务

生成的 CustomerResource 长这样(节选):

@RestController
@RequestMapping("/api/customers")
public class CustomerResource {

    private final Logger log = LoggerFactory.getLogger(CustomerResource.class);

    private final CustomerService customerService;

    public CustomerResource(CustomerService customerService) {
        this.customerService = customerService;
    }

    @PostMapping("")
    public ResponseEntity<CustomerDTO> createCustomer(@Valid @RequestBody CustomerDTO customerDTO)
        throws URISyntaxException {
        log.debug("REST request to save Customer : {}", customerDTO);
        if (customerDTO.getId() != null) {
            throw new BadRequestAlertException("A new customer cannot already have an ID", ENTITY_NAME, "idexists");
        }
        CustomerDTO result = customerService.save(customerDTO);
        return ResponseEntity
            .created(new URI("/api/customers/" + result.getId()))
            .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, result.getId().toString()))
            .body(result);
    }

    @GetMapping("")
    public ResponseEntity<List<CustomerDTO>> getAllCustomers(
        @org.springdoc.core.annotations.ParameterObject Pageable pageable) {
        log.debug("REST request to get a page of Customers");
        Page<CustomerDTO> page = customerService.findAll(pageable);
        return ResponseEntity.ok().body(page.getContent());
    }
}

值得注意的细节:

  1. 每个方法一行 log.debug,日志规范是内置的;
  2. createCustomer 显式拒绝带 id 的请求体,防幂等歧义;
  3. @Valid 触发 Bean Validation,错误由统一异常翻译处理(后文展开);
  4. 构造器注入、无字段注入,符合现代 Spring 风格。

生成的端点全集(JHipster 约定):

方法路径语义
POST/api/customers创建,返回 201 + Location
PUT/api/customers/{id}全量更新
PATCH/api/customers/{id}部分更新(非空字段)
GET/api/customers分页列表
GET/api/customers/{id}详情,不存在返回 404
DELETE/api/customers/{id}删除,返回 204

这套约定与 OpenAPI 文档(springdoc 生成,dev 模式访问 /v3/api-docs 与 swagger-ui)自动同步,团队不需要手写接口文档。

分页与排序:从 X-Total-Count 到 Page 模型

分页是 JHipster REST 约定里变化最大的一块,值得单独讲。

早期 JHipster 的做法:GET /api/customers?page=0&size=20&sort=name,asc 返回数组,总数放在响应头 X-Total-Count,外加 Link header 指向上下页。前端 Angular 用一个共享的 item-count 组件解析。

新版本(我们在 JHipster 9 上验证)改为直接返回 Page 模型

{
  "content": [ { "id": 1, "name": "张三", "...": "..." } ],
  "totalElements": 137,
  "totalPages": 7,
  "number": 0,
  "size": 20
}

两种方式对比:

维度X-Total-Count 响应头Page 响应体
总数获取解析 headerJSON 字段,调试直观
分页元信息需要额外 Link headertotalElements/totalPages 齐全
前端类型手动组装分页对象一个接口类型全包
curl 调试-i 看 header直接可读

排序参数 sort=name,asc&sort=id,desc(多字段重复传参)由 Spring 的 Pageable 直接解析,sort 里的属性名会校验为实体的安全字段,防止注入排序表达式。踩坑提醒:关联实体的排序要用 sort=address.city,asc 这种路径写法,且实体上要有对应的 fetch 配置,否则会撞上 LazyInitializationException 或 N+1 查询。

生成代码没有的:批量操作

JHipster 的生成端点是单资源语义,没有批量创建、批量更新、批量删除。而企业场景里”批量导入 500 条""勾选删除”是高频需求。我们的做法是在生成代码旁边加扩展端点

@RestController
@RequestMapping("/api/customers")
public class CustomerBatchResource {

    private final CustomerService customerService;

    public CustomerBatchResource(CustomerService customerService) {
        this.customerService = customerService;
    }

    @PostMapping("/batch")
    public ResponseEntity<List<CustomerDTO>> createBatch(@Valid @RequestBody List<CustomerDTO> dtos) {
        if (dtos.size() > 500) {
            throw new BadRequestAlertException("Batch size exceeds limit", "customer", "batch.tooLarge");
        }
        List<CustomerDTO> result = customerService.saveAll(dtos);
        return ResponseEntity.ok().body(result);
    }
}

要点:

  1. 单独的类,不动生成的 CustomerResource
  2. 同样的 @RequestMapping("/api/customers") 不冲突,因为路径 /batch 不重叠;
  3. 上限保护必须有——我们早期没限制,一次 8 万条的导入把 Tomcat 线程池和 JVM 堆一起打爆;
  4. saveAll 在 Service 层标注 @Transactional,保证整批原子性(真正的批量 insert 还需要 Hibernate 的 jdbc.batch_size 配置配合,见第 26 篇性能调优)。

超过千条的导入不该走同步 HTTP,正确答案是消息化——这正是下一篇 Kafka 的入口。

事务边界划在哪

生成的 CustomerService 方法自带 @Transactional,这个”默认”经常被新人误解:

@Service
@Transactional
public class CustomerService {

    public CustomerDTO save(CustomerDTO customerDTO) { ... }

    @Transactional(readOnly = true)
    public Page<CustomerDTO> findAll(Pageable pageable) { ... }
}

团队约定(写在 AGENTS.md 里,AI 和新人都遵守):

  • 事务只包住 Service 的公开方法,Resource 层永不 @Transactional
  • 读方法显式 readOnly = true,Hibernate 可以跳过 dirty checking,还能路由到只读副本;
  • 一个事务里的 SQL 控制在个位数,跨网络的调用(HTTP/RPC/发消息)不许出现在事务内;
  • 自调用陷阱:同类里 methodA()this.methodB()methodB 上的 @Transactional 不生效,需要拆类或注入自身代理——这是被问过最多的”玄学问题”。

DTO 与 MapStruct:为什么不直接吐实体

JDL 里 dto = CustomerDTO 选项决定 Service 是否走 DTO 层。无 DTO 时 Resource 直接序列化实体,小项目够用,但生产系统我们一律开 DTO,原因:

  1. 契约稳定:实体加字段(比如内部用的 internalScore)不会自动泄漏到 API;
  2. 关系可控:实体直接序列化懒加载关联,要么 LazyInitializationException 要么 JSON 无限递归,DTO 是解药;
  3. 组合自由:DTO 可以跨实体聚合字段,CustomerDTO 里放 orderCount 很自然,实体里放很别扭。

生成的 Mapper 是 MapStruct 接口,编译期生成实现,没有反射开销:

@Mapper(componentModel = "spring")
public interface CustomerMapper extends EntityMapper<CustomerDTO, Customer> {

    @Mapping(target = "address", source = "address", qualifiedByName = "addressIdToAddress")
    CustomerDTO toDto(Customer customer);

    @Named("addressIdToAddress")
    @BeanMapping(ignoreByDefault = true)
    @Mapping(target = "id", source = "id")
    Address toAddress(Long id);
}

我们的补充实践:跨实体聚合的转换方法不塞进生成的 Mapper,而是新建 CustomerViewMapper 或在 Service 里手动组装——生成 Mapper 保持在再生成时可整体替换的状态。

统一错误体与异常翻译

JHipster 的错误处理是”ExceptionTranslator + 标准错误体”的组合。@RestControllerAdvice 捕获所有异常翻译成统一 JSON:

@RestControllerAdvice
public class ExceptionTranslator implements ProblemHandling {

    @ExceptionHandler
    public ResponseEntity<ProblemDetail> handleBadRequestAlert(BadRequestAlertException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
        pd.setTitle(ex.getErrorKey());
        pd.setProperty("entity", ex.getEntityName());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(pd);
    }
}

前端拿到的永远是结构一致的错误体(前端有对应的 Error 模型消费它):

{
  "type": "about:blank",
  "title": "idexists",
  "status": 400,
  "detail": "A new customer cannot already have an ID",
  "entity": "customer"
}

团队约定:业务异常不自己拼 JSON,全部走异常体系。自定义异常继承 BadRequestAlertException,errorKey 用驼峰点号风格(order.alreadyShipped),前端 i18n 文件里按 key 翻译成中文提示——错误码与文案的映射只存在一处。

改生成代码,还是扩展它

这是本篇的核心决策,用表格说清:

场景策略理由
新增业务方法(Service 内)直接改生成文件Service 本来就是业务画布
新增查询(Repository 内)直接改生成文件Spring Data 惯例就是往接口加方法
新增 REST 端点新建 Resource 类不污染生成文件
修改现有端点语义新建扩展类 + 新路径旧端点保留给存量客户端
校验规则变化改 JDL 再生成前后端 + changelog 同步
修改分页默认值改配置/新端点不改生成的 Resource

扩展的两种姿势:

组合(推荐):扩展类注入生成的 Service/Repository,复用其能力。

@Service
@Transactional
public class CustomerReportService {

    private final CustomerRepository customerRepository; // 生成的接口

    public CustomerReportDTO buildMonthlyReport(YearMonth month) {
        return customerRepository.findActiveCustomers().stream()
            .map(c -> toReportRow(c, month))
            .collect(collectingAndThen(toList(), this::assemble));
    }
}

继承(慎用)class ExtCustomerService extends CustomerService。Spring 上下文里会有两个 bean,注入歧义、事务代理、再生成时父类签名变化都会传导进来。五年里我们只在一个遗留模块用过继承,后来重构成了组合。

改生成文件的红线检查:如果一段修改后的生成代码,在下一次 jhipster entity Customer 再生成时会被覆盖或产生冲突,那它就应该搬家到扩展类里。.jhipster/Customer.json 是实体的真源,代码结构跟着它走。

API 版本化实践

JHipster 生成的端点都在 /api/ 下,不带版本号。内部系统短期没问题,长期演进(尤其是有 App 客户端后)必须有版本策略:

  1. 路径版本优先:破坏性变更时新建 /api/v2/customers,v1 标记 @Deprecated,OpenAPI 文档里注明替代端点;
  2. 非破坏性变更不加版本:DTO 加可选字段、新增端点属于兼容变更,直接加;
  3. 废弃要有周期:v1 至少保留两个发布周期,HTTP 层加 DeprecationSunset 响应头,监控 v1 流量归零后再删;
  4. 微服务间通信同样适用:gateway 聚合多服务时版本号放服务自己的路径里(/api/v2/orders),不要在 gateway 层做全局版本拼接,那是耦合的起点。

小结

  • 生成的五层各有分工:Service/Repository 是业务画布可放心改,Resource/Mapper 尽量保持可再生成状态;
  • 分页已从 X-Total-Count 响应头演进为 Page 响应体,排序参数直达 Pageable,注意关联排序的坑;
  • 批量操作生成器不给,用独立扩展类补,加上限保护与事务包裹;
  • 错误一律走 ExceptionTranslator 统一翻译,errorKey 与前端 i18n 单点映射;
  • 决策框架一句话:会被再生成覆盖的改动都是错位的改动,搬到扩展类去。

系列导航

← 软件工程 011:面向过程管理:Scrum方式 目录 网络协议 011:工具:网络抓包神器 tcpdump 使用详解 →
← 返回文章列表