Spring ↔ NestJS 对照表
本页收录计划中的完整对照表,按主题分类,并补充了每天章节里给出的对照。概念名链接到讲解它的章节小节; “TaskFlow 中的位置”一列给出能看到它实际运行的文件(路径相对于 examples/ 目录)。每张表下面有一个简短列表, 指出和 Spring 的类比在哪里不再成立:Java 开发者的大部分错误就出在这些地方。
Web:控制器、请求、响应
| 概念 | Spring | NestJS | TaskFlow 中的位置 |
|---|---|---|---|
| REST 控制器 | @RestController | @Controller('projects') | src/projects/projects.controller.ts |
| HTTP 映射 | @GetMapping、@PostMapping | @Get()、@Post() | src/projects/projects.controller.ts、src/tasks/tasks.controller.ts |
| 请求体 | @RequestBody | @Body() | src/projects/projects.controller.ts |
| 路径参数 | @PathVariable | @Param('id') | src/projects/projects.controller.ts(配合 ParseUUIDPipe) |
| 查询参数 | @RequestParam | @Query() | src/projects/dto/project.dto.ts(ProjectQueryDto)、src/tasks/tasks.controller.ts(@Query('status')) |
| 请求头 | @RequestHeader | @Headers() | 没有用 @Headers():x-correlation-id 请求头在 src/common/correlation-id.ts 中读取 |
| 状态码 | ResponseEntity、@ResponseStatus | 返回对象 + @HttpCode()(避免用 @Res()) | src/projects/projects.controller.ts(DELETE → 204) |
| DTO + 校验 | Bean Validation(@Valid、@NotNull) | class-validator + ValidationPipe | src/projects/dto/project.dto.ts、src/app.module.ts(APP_PIPE) |
| 序列化 | Jackson | class-transformer(@Exclude、@Expose) | src/users/entities/user.entity.ts(@Exclude)、src/app.module.ts(ClassSerializerInterceptor) |
类比失效的地方:
- 没有
@Valid,但也没有隐式校验。 全局ValidationPipe会校验每个以 DTO 类为类型的参数。光有 TypeScript 类型,运行时什么都不检查;基本类型的参数(@Param('id') id: string)也不会被校验:需要专门的管道,比如ParseUUIDPipe(校验)。 - 查询字符串里只有字符串。
@Type(() => Number)负责转换数字,?archived=false则必须显式写@Transform, 因为字符串"false"在 JavaScript 里是真值(分页)。 - 序列化不是自动的。 Jackson 作用于所有响应;
@Exclude只有在ClassSerializerInterceptor已启用、并且返回值 是该类的实例时才生效。复制出来的对象({ ...user })丢了元数据,字段也就不再被排除 (原样返回实体的陷阱)。 @Res()不是ResponseEntity。 它让处理函数进入手动模式:拦截器和序列化都不再生效 (陷阱)。
注入与模块
| 概念 | Spring | NestJS | TaskFlow 中的位置 |
|---|---|---|---|
| 启动 | SpringApplication.run | NestFactory.create | src/main.ts |
| Service bean | @Service、@Component | @Injectable() | src/projects/projects.service.ts |
| 注入 | 构造函数或 @Autowired | 构造函数注入(TS 类型 + 元数据) | src/tasks/tasks.service.ts(按类型、@InjectRepository(Task)、@Inject(CLOCK)) |
| 容器 / 上下文 | ApplicationContext、component scan | @Module({ providers, controllers, imports, exports }) | src/users/users.module.ts、src/app.module.ts |
| Bean 作用域 | singleton、request | Scope.DEFAULT(单例)、Scope.REQUEST、Scope.TRANSIENT | TaskFlow 的所有 provider 都是单例(默认作用域) |
| 工厂创建的 bean | @Configuration + @Bean | useFactory | src/projects/projects.module.ts(PROJECTS_STORE provider) |
| 条件 bean | @Profile、@ConditionalOnProperty | 根据配置选择的 provider、ConditionalModule | src/projects/projects.module.ts、src/notifications/notifications.module.ts |
| 限定符 | @Qualifier("nom") | 自定义 token:@Inject(PROJECTS_STORE) | src/projects/projects.store.ts、src/common/common.module.ts(CLOCK) |
| 可配置模块 | 自动配置 + application.yml | 动态模块 forRoot() / forRootAsync() | src/database/database.module.ts、src/auth/auth.module.ts |
类比失效的地方:
- 没有 component scan。 没列在
providers里的 provider 就不存在,没导出的 provider 对其他模块不可见。错误出现在 启动时(“Nest can't resolve dependencies of …”),而不是编译时(模块)。 - 类型在运行时消失。 接口不能当注入键:需要一个 token。泛型同样被擦除:
Repository<Task>变成Repository, 所以要用@InjectRepository(Task)(差异)。 - 没有作用域代理。 Spring 通过代理把
requestbean 注入单例。在 Nest 里,一个REQUESTprovider 会让所有依赖它的 provider 都变成请求作用域,一直传到控制器:整条链在每个请求时都重新创建(作用域)。 - 循环依赖要付出代价。
forwardRef()是有的,但模块之间出现循环,首先说明拆分需要重新考虑 (陷阱)。
数据
| 概念 | Spring | NestJS | TaskFlow 中的位置 |
|---|---|---|---|
| Repository | JpaRepository<T, ID> | TypeORM 的 Repository<T>、@InjectRepository(T) | src/projects/typeorm-projects.store.ts、src/tasks/tasks.service.ts |
| 派生查询 | findByNameContaining(...) | find({ where: { name: ILike(...) } }) 或 QueryBuilder | src/projects/typeorm-projects.store.ts(findPage) |
| 分页 | Page<T>、Pageable | findAndCount + skip / take | src/projects/typeorm-projects.store.ts、src/common/dto/pagination.dto.ts |
| 实体 | @Entity、@Id、@Column | @Entity、@PrimaryGeneratedColumn、@Column | src/projects/entities/project.entity.ts |
| 关系 | @OneToMany、@ManyToOne | 同名的 TypeORM 装饰器 | src/tasks/entities/task.entity.ts、src/projects/entities/project-member.entity.ts |
| 事务 | @Transactional | DataSource.transaction() 或 QueryRunner(没有原生的声明式写法) | src/projects/typeorm-projects.store.ts(create、archive) |
| 迁移 | Flyway、Liquibase | TypeORM 迁移(CLI) | src/database/migrations/、src/database/data-source.ts |
| 自动同步表结构 | spring.jpa.hibernate.ddl-auto | synchronize: true(仅限开发) | src/database/postgres-options.ts(synchronize: false) |
| 开发数据 | data.sql、CommandLineRunner | 种子数据脚本 | src/database/seed.ts |
类比失效的地方:
@Transactional没有原生的声明式对应物。 Spring 把事务绑定到当前线程。Node 在单个线程上处理所有请求: 所以事务是一个回调,每个需要参与事务的查询都要通过收到的manager执行。注入的 repository 则在事务之外执行 (事务)。有第三方库借助AsyncLocalStorage重新做出了装饰器; TaskFlow 只用 TypeORM 自带的 API。- 没有脏检查。 修改已加载的实体不会在事务结束时触发任何写入:必须调用
save()或update()。 - 没有透明的懒加载。 没有请求的关系(
relations、join)就是undefined,也没有LazyInitializationException来提醒你(加载)。 - 列类型要写出来。 对于
description: string | null,编译器只会输出Object作为类型:TypeORM 从中推断不出 任何东西,所以 TaskFlow 的实体里显式写了type: 'text'、type: 'varchar'(元数据陷阱)。
请求处理管道
| 概念 | Spring | NestJS | TaskFlow 中的位置 |
|---|---|---|---|
| 执行顺序 | Servlet 过滤器 → DispatcherServlet → 拦截器 → 控制器 | Middleware → Guards → Interceptors → Pipes → Handler → Interceptors → Exception Filters | src/app.module.ts |
| 异常处理 | @ControllerAdvice + @ExceptionHandler | 带 @Catch() 的 ExceptionFilter,通过 APP_FILTER 注册 | src/common/filters/all-exceptions.filter.ts |
| HTTP 异常 | ResponseStatusException | HttpException、NotFoundException 等 | src/projects/projects.service.ts、src/auth/auth.service.ts(ConflictException) |
| Servlet 过滤器 | Filter | 中间件 | src/common/middleware/correlation-id.middleware.ts |
| 拦截器 | HandlerInterceptor / AOP @Around | NestInterceptor(RxJS) | src/common/interceptors/logging.interceptor.ts |
| 参数转换与校验 | Converter、@InitBinder | Pipe(ParseIntPipe、ParseUUIDPipe) | src/tasks/tasks.controller.ts(ParseUUIDPipe、ParseEnumPipe) |
类比失效的地方:
- 拦截器只包裹处理函数。 Spring 的
@Around可以作用于任意 bean 方法;Nest 拦截器只作用于路由。service 不会被代理。 - 中间件不知道处理函数是谁。 和 Servlet
Filter一样,它看到的是原始请求,不知道哪个控制器会响应。要根据 路由或其元数据做决定,请用 guard 或拦截器(执行顺序)。 - 拦截器操作的是
Observable。 处理函数之后的代码要在next.handle()上用 RxJS 操作符(tap、map、catchError)来写,而不是写在proceed()之后。
配置与生命周期
| 概念 | Spring | NestJS | TaskFlow 中的位置 |
|---|---|---|---|
| 配置 | application.yml、@Value、@ConfigurationProperties | @nestjs/config(ConfigModule、ConfigService) | src/app.module.ts、src/config/ |
| 类型化属性 | @ConfigurationProperties | registerAs + 类型化配置 | src/config/configuration.ts |
| 启动时校验 | 属性类上的 @Validated | validate 选项(class-validator 或 Joi) | src/config/env.validation.ts |
| 多环境 | spring.profiles.active | NODE_ENV + .env.* 文件 | src/app.module.ts(envFilePath)、.env.example |
| 日志 | SLF4J + Logback | Nest 的 Logger、nestjs-pino | src/app.module.ts(LoggerModule) |
| 生命周期 | @PostConstruct、@PreDestroy | OnModuleInit、OnModuleDestroy(+ enableShutdownHooks()) | src/app.setup.ts(enableShutdownHooks()) |
类比失效的地方:
- 一切都是字符串。 环境变量是字符串;转换和校验都要显式完成(
validateEnv)。给属性写上类型 (PORT: number = 3000),否则隐式转换不会发生(陷阱)。 NODE_ENV不是 Spring profile。 不会自动合并任何文件,也不会按 profile 激活任何 bean:TaskFlow 通过envFilePath选择.env.${NODE_ENV}文件,并且只接受development、test和production。- 优雅停机要手动开启。 Spring Boot 会注册 JVM 关闭钩子;Nest 只有在调用
app.enableShutdownHooks()之后, 收到信号时才会调用OnModuleDestroy和OnApplicationShutdown。
安全
| 概念 | Spring | NestJS | TaskFlow 中的位置 |
|---|---|---|---|
| 授权 | Spring Security、@PreAuthorize | Guard(CanActivate)、@UseGuards、自定义 @Roles | src/auth/guards.ts、src/common/decorators/auth.decorators.ts |
| 安全过滤链 | SecurityFilterChain | 全局 guard(APP_GUARD) | src/app.module.ts |
| JWT 认证 | Spring Security 的 JWT resource server | @nestjs/passport + passport-jwt、@nestjs/jwt | src/auth/jwt.strategy.ts、src/auth/auth.module.ts |
| 公开路由 | permitAll() | 由全局 guard 读取的 @Public() 装饰器 | src/common/decorators/auth.decorators.ts、src/auth/auth.controller.ts |
| 当前用户 | SecurityContextHolder、@AuthenticationPrincipal | request.user、@CurrentUser() | src/common/decorators/auth.decorators.ts |
| 密码哈希 | PasswordEncoder | argon2(或 bcrypt) | src/auth/auth.service.ts |
| 响应头、CORS、限流 | Spring Security 的 headers()、cors();没有标准的限流方案 | helmet()、enableCors()、@nestjs/throttler | src/app.setup.ts、src/auth/auth.controller.ts(@Throttle) |
类比失效的地方:
- 没有内置的安全框架。 你要自己组装 Passport 策略、guard 和装饰器。只有注册了全局 guard,才谈得上“默认安全”, TaskFlow 就是用
JwtAuthGuard这样做的(全局 guard)。 - guard 保护的是路由,不是 service 方法。 没有可以用在
@Service上的@PreAuthorize对应物:业务访问规则 (是否为项目成员)写在 service 里(成员)。 - 角色来自 token。 角色变更要到下次登录才生效(TaskFlow 的 token 有效期 15 分钟)。
异步、事件、定时任务
| 概念 | Spring | NestJS | TaskFlow 中的位置 |
|---|---|---|---|
| 异步 | @Async、CompletableFuture、WebFlux | async/await、Promise、RxJS | 随处可见;事件循环见 src/demo/event-loop.controller.ts |
| 事件 | ApplicationEventPublisher、@EventListener | @nestjs/event-emitter(EventEmitter2、@OnEvent) | src/tasks/tasks.service.ts(emit)、src/notifications/notifications.listener.ts |
| 定时任务 | @Scheduled | @nestjs/schedule(@Cron、@Interval) | src/tasks/tasks.scheduler.ts |
| 队列任务 | Spring Batch、JMS | @nestjs/bullmq(Redis) | src/notifications/notifications.queue.ts |
类比失效的地方:
async不会创建线程。@Async在另一个线程上执行方法;async函数在唯一的 JavaScript 线程上运行,每遇到 一个await就让出控制权。一段繁重的计算会阻塞所有请求(事件循环)。- 监听器的错误不会向上传递。 使用
@OnEvent(..., { async: true })时,异常永远到不了发出事件的代码:要在监听器 里捕获并记录日志(异步错误)。也没有@TransactionalEventListener: TaskFlow 在保存之后才发出事件。 - 定时任务在每个实例上都会运行,就像没有加锁的
@Scheduled(Spring 那边用 ShedLock 加锁)。有多个副本时, 改用队列或锁(定时任务)。 - BullMQ 是基于 Redis 的任务队列(重试、退避、延迟),更接近带重新投递的 JMS 队列,而不是 Spring Batch, 后者把处理过程拆分成步骤和批次。
测试
| 概念 | Spring | NestJS | TaskFlow 中的位置 |
|---|---|---|---|
| 测试 | JUnit、Mockito、MockMvc、@SpringBootTest | Jest、Test.createTestingModule、supertest | src/**/*.spec.ts、test/app.e2e-spec.ts |
| Mock | Mockito(mock()、when()) | jest.fn()、jest.Mocked<T> | src/tasks/tasks.service.spec.ts |
| Mock repository | 用在 JpaRepository 上的 @MockitoBean(原 @MockBean) | getRepositoryToken(Entity) + useValue | src/tasks/tasks.service.spec.ts |
| 应用测试 | @SpringBootTest + MockMvc | Test.createTestingModule + supertest | test/app.e2e-spec.ts |
| 真实数据库 | @DataJpaTest、Testcontainers | 连接真实数据库的集成测试(Testcontainers 或 Compose 服务) | docker-compose.yml 中的 db-test 服务 |
| 覆盖率 | JaCoCo | jest --coverage + coverageThreshold | jest.config.ts |
类比失效的地方:
- 测试模块不扫描任何东西。
Test.createTestingModule只包含列出来的 provider:比 Spring 上下文更快,也更明确。 - 不启动 Nest 的测试要自己加载
reflect-metadata(陷阱)。 - supertest 发出的是真实的 HTTP 请求,打到应用的服务器上:更接近
@SpringBootTest(webEnvironment = RANDOM_PORT), 而不是模拟 servlet 层的 MockMvc。 - 通过
APP_*声明的全局 provider 在端到端测试中同样生效,而写在main.ts里的app.useGlobalPipes()不会, 因为测试不执行main.ts(端到端测试)。
工具、文档与生产
| 概念 | Spring | NestJS | TaskFlow 中的位置 |
|---|---|---|---|
| 构建 / 依赖 | Maven、Gradle | npm 或 pnpm、package.json | package.json、package-lock.json |
| Lombok | @Getter、@Builder | 不需要(TS,公有属性) | 实体和 DTO 使用公有属性;构造函数里使用参数属性 |
| 项目生成器 | Spring Initializr | Nest CLI(nest new、nest g) | nest-cli.json |
| 热重载 | Spring Boot DevTools | nest start --watch | package.json(npm run start:dev) |
| Actuator | /actuator/health | @nestjs/terminus | src/health/health.controller.ts |
| OpenAPI | springdoc-openapi | @nestjs/swagger | src/app.setup.ts(setupSwagger)、nest-cli.json(插件) |
| Docker 镜像 | Jib、spring-boot:build-image | 多阶段 Dockerfile | Dockerfile |
类比失效的地方:
- 没有构建生命周期。 Maven 会依次执行各个阶段;这里每个工具都有自己的脚本(
npm run build、npm test、npm run lint)和自己的配置(tsconfig.json、jest.config.ts、nest-cli.json、.oxlintrc.json)。 - Lombok 没有用武之地。 TypeScript 类直接暴露公有属性,不需要 getter,
constructor(private readonly service: ProjectsService) {}一行就声明并赋值了字段。 - Swagger 需要一个编译插件。 springdoc 在运行时通过反射读取类型;那时 TypeScript 类型已经不存在了,所以要在
nest-cli.json里声明@nestjs/swagger插件(Swagger)。它只读取*.dto.ts和*.entity.ts文件,也不理会@Exclude():从不返回的字段要用@ApiHideProperty()从文档里隐藏 (/docs 上的 Swagger)。 - Terminus 只做健康检查。 没有对应 Actuator 的
info、env或metrics端点。