CHARLIE SAYS

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

JHipster 开发 23:自定义业务模块——不改生成代码的哲学

上一篇算过账:升级成本几乎正比于你手改过的生成文件数。这篇讲怎么把业务代码写进 JHipster 工程而不弄脏生成代码——不是教条式的”一行不许动”,而是一套可执行的分层策略:能不改就不改,必须改就标记,改得多了就固化进 blueprint。

为什么”不改生成代码”是核心纪律

生成代码的价值在于可再生.jhipster/Customer.json 在,jhipster entity Customer --single-entity 随时能重演这份代码。一旦你往 CustomerResource.java 里塞了二十行业务分支,这个文件就”变质”了——重新生成会覆盖你的逻辑,不重新生成它就从此脱轨于版本演进。两难一旦出现,升级(第 22 篇)就从 diff 管理退化为考古。

所以纪律的目标很单纯:让”删掉所有生成文件再重新生成”永远是一个可恢复操作,业务代码零损失。

业务代码的三种组织形态

按业务体量递进:

形态一:同工程新包(小项目默认)。 在生成的包结构旁边开平行包:

com/mycompany/myapp/
├── domain/  repository/  service/  web/rest/   # 生成区(可再生)
└── biz/                                            # 手写区
    ├── order/
    │   ├── OrderFacade.java                   # 业务入口,组合生成的 Service
    │   ├── OrderPricingEngine.java
    │   └── dto/
    ├── inventory/
    └── shared/                                # 跨域工具

规则只有一条:依赖方向必须是 biz → 生成区,反向禁止。生成代码不知道 biz 的存在,重新生成才无感。

形态二:独立 Maven module(中大型项目)。 业务成规模后拆模块,生成器只管”应用壳”:

myapp/
├── pom.xml                    # 聚合根
├── myapp-generated/           # JHipster 生成的应用(少动)
├── myapp-order/               # 订单域:domain/service/api
├── myapp-inventory/
└── myapp-common/
<!-- myapp-generated/pom.xml -->
<dependency>
  <groupId>com.mycompany</groupId>
  <artifactId>myapp-order</artifactId>
  <version>${project.version}</version>
</dependency>

代价是 JHipster 默认单工程结构要手工调整(.yo-rc.json 仍描述生成壳),收益是业务模块可以独立测试、独立演进,甚至复用到别的工程。我们在订单域超过 3 万行时做了这次拆分。

形态三:前端 feature 目录。 Angular 侧同理,生成的 main/ 结构旁边开 feature 区:

src/main/webapp/app/
├── entities/  home/  admin/              # 生成区
└── features/
    ├── order-center/
    │   ├── order-center.component.ts
    │   ├── order-center.routes.ts        # lazy route 独立加载
    │   └── order-center.service.ts
    └── inventory-board/

路由挂载到生成的 layout 下,生成的实体页面与手写 feature 页面并存。

组合优先于修改

业务逻辑需要基于生成的 CustomerService 扩展时,第一反应该是包一层而不是进去改:

@Service
public class OrderService {

    private final CustomerService customerService;   // 生成的
    private final OrderRepository orderRepository;   // 生成的(若是生成实体)
    private final OrderPricingEngine pricingEngine; // 手写的

    public OrderService(CustomerService customerService,
                        OrderRepository orderRepository,
                        OrderPricingEngine pricingEngine) {
        this.customerService = customerService;
        this.orderRepository = orderRepository;
        this.pricingEngine = pricingEngine;
    }

    public OrderDTO placeOrder(PlaceOrderCmd cmd) {
        var customer = customerService.findOne(cmd.customerId())
            .orElseThrow(() -> new CustomerNotFoundException(cmd.customerId()));
        var order = pricingEngine.quote(customer, cmd.items());
        // ... 领域逻辑全在自己文件里
        return new OrderDTO(orderRepository.save(order));
    }
}

生成代码提供的是能力(CRUD、查询、权限),业务模块负责编排。升级时生成 Service 内部怎么变(比如 9.x 的重构)只要方法签名稳定,你的编排层纹丝不动。

需要替换行为时:覆盖 Spring Bean

当组合不够用(必须改变生成 Bean 本身的行为)时,用 Spring 的装配机制覆盖而不是直接编辑生成类:

// 方式一:@Primary 让手写 Bean 在注入点胜出
@Service
@Primary
public class AuditingUserService extends tech.mycompany.myapp.service.UserService {

    private final AuditClient auditClient;

    public AuditingUserService(UserRepository userRepository, AuditClient auditClient) {
        super(userRepository);
        this.auditClient = auditClient;
    }

    @Override
    public User save(User user) {
        auditClient.recordUserChange(user);
        return super.save(user);
    }
}
// 方式二:条件装配,配置开关切换实现
@Service
@ConditionalOnProperty(name = "myapp.notification.provider", havingValue = "sms")
@Primary
public class SmsNotificationService implements NotificationService { ... }

两条底线:覆盖类放在自己的包里,生成的 UserService 保持出厂状态;覆盖点登记到团队文档(见文末 review 清单),否则半年后没人知道行为被谁改过。

接口扩展点:让生成代码调用你

反向场景:想在生成的流程里挂业务钩子(用户注册后发券、实体保存后同步审计)。JHipster/Spring 提供的正规扩展点优先用:

  • Spring 事件:生成的 UserService.save 会发布 AuditEvent 等;用户注册后发券监听 Spring Security 的认证成功事件或自定义事件,零侵入;
  • Bean 生命周期钩子@Async 的监听器把旁路逻辑挪出主事务;
  • MapStruct/Lifecycle 注解@PrePersist 放在自己的Listener类,通过 entity listener 挂上,不改生成实体。
@Component
public class WelcomeCouponListener {

    @Async
    @TransactionalEventListener
    public void onUserRegistered(UserRegisteredEvent event) {
        couponService.grantWelcomeCoupon(event.userId());
    }
}

必须改生成文件时:标记纪律

总有一小撮文件绕不开(SecurityConfiguration 加路由规则、application.yml 加配置)。约定:

  1. 改动前先确认”真的不能在 biz 层做吗”;
  2. 改动处用统一注释包裹,便于升级时 grep:
// >>> CUSTOM-BEGIN: 订单回调白名单(升级时保留)
.requestMatchers("/api/callback/order/**").permitAll()
// <<< CUSTOM-END
  1. 维护一份 GENERATED-OVERRIDES.md 登记所有手改点:文件、原因、升级时的处置方式。我们 8→9 升级时这份清单 23 行,逐行搬运一小时收工——没有它就是考古。

目录纪律与 review 清单

每次 PR 自检,五分钟能做完:

#检查项通过标准
1diff 是否触碰生成文件未触碰,或带 CUSTOM 标记且已登记
2依赖方向biz → 生成区,无反向 import
3实体结构变更是否同步事实源.jhipster/*.json / JDL 已更新并重新生成
4覆盖 Bean 是否必要且唯一组合方案被否决的理由成立,@Primary 只有一处
5新前端代码位置features/ 目录,lazy route

CI 上可以加一个守卫脚本:对生成文件路径的 diff 若不含 CUSTOM-BEGIN 标记则fail。规则很粗糙,但足够拦住手滑。

小结

  • “不改生成代码”的本质是保住可再生性,让升级永远便宜。
  • 三级组织形态:新包 → Maven module → 前端 feature 目录,按业务体量递进。
  • 组合优先于修改;必须覆盖时用 @Primary/条件装配且落在自己包里;钩子用 Spring 事件。
  • 绕不开的手改必须 CUSTOM 标记 + 登记,review 清单五分钟守住纪律。

系列导航

← 设计模式 023:状态(State) 目录 算法 024:图:拓扑排序(Topological sort) →
← 返回文章列表