第 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
各层职责与”该不该改”的判断:
| 层 | 职责 | 改动频率 | 建议策略 |
|---|---|---|---|
| Resource | HTTP 语义:路径、状态码、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());
}
}
值得注意的细节:
- 每个方法一行
log.debug,日志规范是内置的; createCustomer显式拒绝带 id 的请求体,防幂等歧义;@Valid触发 Bean Validation,错误由统一异常翻译处理(后文展开);- 构造器注入、无字段注入,符合现代 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 响应体 |
|---|---|---|
| 总数获取 | 解析 header | JSON 字段,调试直观 |
| 分页元信息 | 需要额外 Link header | totalElements/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);
}
}
要点:
- 单独的类,不动生成的
CustomerResource; - 同样的
@RequestMapping("/api/customers")不冲突,因为路径/batch不重叠; - 上限保护必须有——我们早期没限制,一次 8 万条的导入把 Tomcat 线程池和 JVM 堆一起打爆;
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,原因:
- 契约稳定:实体加字段(比如内部用的
internalScore)不会自动泄漏到 API; - 关系可控:实体直接序列化懒加载关联,要么
LazyInitializationException要么 JSON 无限递归,DTO 是解药; - 组合自由: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 客户端后)必须有版本策略:
- 路径版本优先:破坏性变更时新建
/api/v2/customers,v1 标记@Deprecated,OpenAPI 文档里注明替代端点; - 非破坏性变更不加版本:DTO 加可选字段、新增端点属于兼容变更,直接加;
- 废弃要有周期:v1 至少保留两个发布周期,HTTP 层加
Deprecation与Sunset响应头,监控 v1 流量归零后再删; - 微服务间通信同样适用:gateway 聚合多服务时版本号放服务自己的路径里(
/api/v2/orders),不要在 gateway 层做全局版本拼接,那是耦合的起点。
小结
- 生成的五层各有分工:Service/Repository 是业务画布可放心改,Resource/Mapper 尽量保持可再生成状态;
- 分页已从 X-Total-Count 响应头演进为 Page 响应体,排序参数直达 Pageable,注意关联排序的坑;
- 批量操作生成器不给,用独立扩展类补,加上限保护与事务包裹;
- 错误一律走 ExceptionTranslator 统一翻译,errorKey 与前端 i18n 单点映射;
- 决策框架一句话:会被再生成覆盖的改动都是错位的改动,搬到扩展类去。
系列导航
- 上一篇:缓存策略与性能
- 下一篇:异步与消息——Kafka 集成
- 相关阅读:JDL 从入门到驯服 · 微服务 vs 单体——怎么选 · 升级策略