第二章 2.6:初始化 NestJS 服务端 ------ TypeScript 后端框架体验
课程专题 :《AI Agents 开发实践》
章节 :第二章 搭建智能体的工程底座
小节 :2.6 初始化 NestJS 服务端
适合人群:Java 开发者,已掌握 TypeScript 基础语法,了解 Monorepo 结构
本篇知识点
- NestJS 是什么?跟 Spring Boot 有什么对应关系
- 如何在 Monorepo 中创建
@autix/apiNestJS 子包 package.json、tsconfig.json、nest-cli.json的完整配置- CommonJS vs ESM、moduleResolution 的踩坑经验
- 实现
/health和/hello两个接口 - 前后端跨包引用共享类型
1. NestJS 是什么?Java 开发者怎么看?
1.1 一句话总结
NestJS = Node.js 世界里的 Spring Boot。
它和 Spring Boot 一样,走"约定优于配置"的路线,用装饰器(Decorator)代替 XML 配置,内置依赖注入(IoC),模块划分清晰。
1.2 Java 对照表
| NestJS 概念 | Spring Boot 对照 | 说明 |
|---|---|---|
@Controller / @Get / @Post |
@RestController / @GetMapping / @PostMapping |
路由定义 |
@Module |
@Configuration |
模块/配置类 |
@Injectable |
@Service / @Component |
可注入的服务类 |
@Query / @Body |
@RequestParam / @RequestBody |
参数绑定 |
NestFactory.create() |
SpringApplication.run() |
启动入口 |
1.3 为什么选 NestJS?
Node.js 后端框架不止 Express、Koa 这些,那为什么 Autix 选择 NestJS?
| 优势 | 说明 |
|---|---|
| TypeScript 一等公民 | NestJS 是用 TypeScript 写的,类型系统贯穿全框架 |
| 装饰器驱动 | 和 Spring Boot 一样,用 @Controller 这种装饰器标注行为,代码一目了然 |
| 模块化 | 天生适合拆分大项目,一个 Module 就是一个业务模块 |
| IoC 容器 | 内置依赖注入,Service 直接 @Injectable() 就能用 |
| 生态对齐 Autix | Autix 生产项目用的就是 NestJS 11,学完直接能上生产 |
1.4 版本说明
本篇使用 NestJS 11(2025 年发布的最新稳定版),和 Autix 保持一致。
2. 创建目录和 package.json
2.1 目录结构
bash
# 从 ch02 根目录执行
mkdir -p services/api/src
最终整个 Monorepo 结构变成:
ch02/
├── packages/
│ └── contracts/ # 上一篇搭的共享包(已存在)
├── services/
│ └── api/ # 新建的 NestJS 后端服务
│ ├── src/
│ ├── package.json
│ ├── tsconfig.json
│ └── nest-cli.json
├── apps/ # 下一篇会搭 Next.js 前端
└── package.json # 根 workspace 配置(已存在)
2.2 package.json 完整代码
json
{
"name": "@autix/api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "nest start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@autix/contracts": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.8.0"
}
}
2.3 关键字段解读
| 字段 | 值 | 说明 |
|---|---|---|
name |
@autix/api |
scope 包名,和 contracts 同属 @autix 命名空间 |
private |
true |
不发布到 npm,仅 Monorepo 内部使用 |
scripts.dev |
nest start --watch |
开发模式,文件改动自动重启(类似 Spring DevTools) |
scripts.build |
nest build |
编译 TypeScript 到 dist/ |
dependencies.@autix/contracts |
workspace:* |
引用共享包,Monorepo 自动链接 |
dependencies.reflect-metadata |
^0.2.0 |
NestJS 装饰器依赖的反射元数据库 |
dependencies.rxjs |
^7.8.0 |
NestJS 内部用 RxJS 处理流(类似 Java 的 Reactor) |
2.4 ⚠️ 注意:没有 "type": "module"
很多新项目会在 package.json 里加 "type": "module" 来启用 ESM,但我们故意不加。
原因很简单:NestJS 默认用 CommonJS 。如果你强行切成 ESM,编译出来的文件会缺 .js 后缀(路径解析问题),踩过这个坑的人不在少数。
| 选择 | 说明 |
|---|---|
| NestJS + CommonJS(我们的选择) | 官方默认,社区资料最多,零额外配置 |
| NestJS + ESM | 可以用,但要处理 .js 后缀问题、tsconfig 改 module: nodenext、很多第三方库还没适配 |
Java 对照:这就像 Spring Boot 默认用 Tomcat,你可以换 Netty,但没必要跟默认配置对着干,除非你有明确需求。
3. tsconfig.json ------ 最复杂的配置文件
NestJS 的 tsconfig 是整个项目里最容易踩坑的地方。别复制粘贴就跑,理解每一行的意义。
3.1 完整代码
json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": ".",
"paths": {
"@autix/contracts": ["../../packages/contracts/dist/index.d.ts"],
"@autix/contracts/*": ["../../packages/contracts/dist/*"]
}
},
"include": ["src/**/*"]
}
3.2 逐行解读
| 配置 | 值 | 为什么这么写 |
|---|---|---|
extends |
../../tsconfig.base.json |
继承 Monorepo 根目录的共享配置,保持一致 |
module |
commonjs |
NestJS 默认 CommonJS ,用 ESM 会导致编译产物缺 .js 后缀 |
moduleResolution |
node |
覆盖基类的 bundler,CommonJS 不认识 bundler resolution,会报"找不到模块" |
outDir |
./dist |
编译输出到 dist 目录 |
rootDir |
./src |
只编译 src 下的源码,这也是 paths 不能直接指向源码的原因 |
experimentalDecorators |
true |
启用装饰器语法(@Controller、@Get 这些要用) |
emitDecoratorMetadata |
true |
生成装饰器元数据,NestJS IoC 容器需要 |
baseUrl |
. |
paths 相对路径的起点 |
paths.@autix/contracts |
../../packages/contracts/dist/index.d.ts |
映射到 contracts 编译后的类型声明文件 |
3.3 为什么 paths 指向 dist 而不是 src?
你可能会问:上一篇说 contracts 直接引源码也可以,为什么这里偏要引编译后的 .d.ts?
原因在于 rootDir 限制:
services/api/tsconfig.json
rootDir: ./src ← 只允许编译 src/ 下面的文件
如果 paths 指向 ../../packages/contracts/src/index.ts:
↑ TypeScript 会认为这个文件"超出了 rootDir 范围",
要么报错,要么尝试把它也编译进来(搞乱了构建结构)
所以正确做法是:
- contracts 先
build一次,生成dist/index.d.ts类型声明文件 - api 通过 paths 指向这个编译产物,只取类型,不重复编译源码
这就是为什么脚本里有个单独的 contracts 构建步骤。
3.4 Java 对照
这就像 Maven 的 <scope>provided</scope>:
xml
<!-- api 模块依赖 commons-dto 的编译产物 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>commons-dto</artifactId>
<version>1.0.0</version>
</dependency>
Maven 不会把 commons-dto 的源码复制过来编译,而是引用它已经编译好的 jar 包。NestJS 的 paths 指向 dist/index.d.ts 也是同理 ------ 只拿类型声明,不拿源码。
4. nest-cli.json
NestJS CLI 的配置文件,告诉 nest start 和 nest build 怎么干活。
json
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"deleteOutDir": true
}
| 字段 | 说明 |
|---|---|
collection |
使用 @nestjs/schematics 的项目模板(CLI 生成文件时用) |
sourceRoot |
源码根目录,就是 src/ |
deleteOutDir |
每次 build 前先清空 dist,避免旧文件残留 |
这个文件很简单,几乎不用改。
5. main.ts ------ 启动入口
这是整个 NestJS 应用的起点,类似 Spring Boot 的 Application.java 里的 main 方法。
文件路径 :services/api/src/main.ts
typescript
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(4001);
console.log('API server running on http://localhost:4001');
}
bootstrap();
5.1 逐行解读
| 代码 | 说明 |
|---|---|
import 'reflect-metadata' |
必须放在最前面,否则装饰器元数据不会生效,IoC 容器直接瘫痪 |
NestFactory.create(AppModule) |
启动应用,传入根 Module(类似 SpringApplication.run(App.class)) |
app.enableCors() |
允许跨域(Next.js 前端从 3000 端口调用 4001 端口需要跨域) |
app.listen(4001) |
监听 4001 端口,跟 Next.js 的 3000 不冲突 |
5.2 Java 对照
java
// Spring Boot 的启动类
@SpringBootApplication
public class ApiApplication {
public static void main(String[] args) {
SpringApplication.run(ApiApplication.class, args);
}
}
// 跨域配置(可以全局,也可以在单个 Controller 上)
@CrossOrigin(origins = "http://localhost:3000")
@RestController
public class HealthController { ... }
对照关系:
bootstrap()≈main()NestFactory.create(AppModule)≈SpringApplication.run(ApiApplication.class)app.enableCors()≈@CrossOriginapp.listen(4001)≈server.port=4001
6. app.module.ts ------ 根模块
文件路径 :services/api/src/app.module.ts
typescript
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HelloController } from './hello.controller';
@Module({
controllers: [HealthController, HelloController],
})
export class AppModule {}
6.1 解读
@Module 是 NestJS 的核心装饰器,它的作用类似 Spring Boot 的 @Configuration + @ComponentScan 的组合:
controllers数组告诉 NestJS IoC 容器:把这两个类注册成路由处理器 Bean- 未来我们还会加
providers(Service)、imports(其他 Module)、exports(对外暴露)
6.2 Java 对照
java
// Spring Boot 启动类自带 @Configuration 和 @ComponentScan
@SpringBootApplication
// 等价于:
// @Configuration ← 提供 Bean
// @ComponentScan ← 扫描 Controller / Service / Component
public class ApiApplication { ... }
// Spring 自动扫描 @RestController 注解的类
// 不需要手动列出来,只要在扫描范围内就行
差异点 :NestJS 需要在 controllers 数组里显式列出 Controller 类,而 Spring Boot 靠包扫描自动发现。
这意味着:
- NestJS Module 更显式,你一眼能看出这个模块管哪些 Controller
- 但也意味着新增 Controller 后别忘记加到 Module 里,否则路由不生效(这是新手高频踩坑)
7. health.controller.ts ------ 健康检查接口
文件路径 :services/api/src/health.controller.ts
typescript
import { Controller, Get } from '@nestjs/common';
import type { HealthResponse } from '@autix/contracts';
@Controller('health')
export class HealthController {
@Get()
getHealth(): HealthResponse {
return {
status: 'ok',
version: '0.1.0',
timestamp: new Date().toISOString(),
};
}
}
7.1 逐行解读
| 装饰器/代码 | 说明 |
|---|---|
@Controller('health') |
这个类里所有路由的前缀都是 /health,类似 Spring 的 @RequestMapping("/health") |
@Get() |
HTTP GET 方法映射,类似 Spring 的 @GetMapping |
getHealth(): HealthResponse |
返回值类型就是共享包里的接口,前后端用同一个类型 |
7.2 Java 对照代码
java
// Spring Boot 写法
@RestController
@RequestMapping("/health")
public class HealthController {
@GetMapping
public HealthResponse getHealth() {
return new HealthResponse(
"ok",
"0.1.0",
Instant.now().toString()
);
}
}
// 注意:HealthResponse 是 commons-dto 里的类
// 和前端引用的是同一个编译产物
7.3 响应示例
json
{
"status": "ok",
"version": "0.1.0",
"timestamp": "2026-10-13T08:00:00.000Z"
}
8. hello.controller.ts ------ 演示参数绑定
文件路径 :services/api/src/hello.controller.ts
typescript
import { Controller, Get, Query } from '@nestjs/common';
import { APP_NAME } from '@autix/contracts';
@Controller('hello')
export class HelloController {
@Get()
getHello(@Query('name') name: string = 'World') {
return {
message: `Hello, ${name}!`,
app: APP_NAME,
};
}
}
8.1 两个看点
看点一:@Query('name') 参数绑定
请求:GET /hello?name=Test
↓
@Query('name') name: string ← 自动绑定 URL 查询参数到方法参数
↓
name = "Test"
默认值 = 'World' 意味着用户不传 ?name=xxx 时,返回 "Hello, World!"。
看点二:引用共享包的常量 APP_NAME
typescript
// contracts 里定义的常量(来自上一篇)
export const APP_NAME = 'Autix Ch02';
// api 里直接引用
import { APP_NAME } from '@autix/contracts';
这就是 Monorepo 共享包的威力 ------ 常量只定义一次,前后端都能用。如果哪天想改应用名,只改 contracts 的 src/index.ts 就行。
8.2 Java 对照代码
java
// Spring Boot 写法
@RestController
@RequestMapping("/hello")
public class HelloController {
@GetMapping
public HelloResponse getHello(
@RequestParam(defaultValue = "World") String name
) {
return new HelloResponse(
"Hello, " + name + "!",
AppConstants.APP_NAME // ← 同样是 commons 模块的常量
);
}
}
8.3 响应示例
不传 name 参数:
GET /hello
→
{
"message": "Hello, World!",
"app": "Autix Ch02"
}
传 name 参数:
GET /hello?name=Test
→
{
"message": "Hello, Test!",
"app": "Autix Ch02"
}
9. 启动并验证
9.1 安装依赖
bash
# 从 ch02 根目录执行
bun install
Bun 会自动识别 workspace:* 依赖,把 contracts 和 api 链接起来。
9.2 先 build contracts(重要!)
api 的 tsconfig paths 指向的是 contracts 的 dist/index.d.ts,所以必须先 build 一次:
bash
bun run build --filter @autix/contracts
正常输出:
$ tsc --project .
9.3 启动 NestJS 服务
bash
bun run dev --filter @autix/api
正常输出(看到这行就说明启动成功了):
API server running on http://localhost:4001
9.4 PowerShell 测试接口
powershell
# 测试健康检查
Invoke-WebRequest -Uri http://localhost:4001/health | Select-Object -ExpandProperty Content
# 测试 hello 接口(带参数)
Invoke-WebRequest -Uri "http://localhost:4001/hello?name=Test" | Select-Object -ExpandProperty Content
# 测试 hello 接口(不带参数)
Invoke-WebRequest -Uri http://localhost:4001/hello | Select-Object -ExpandProperty Content
预期输出:
json
// /health
{"status":"ok","version":"0.1.0","timestamp":"..."}
// /hello?name=Test
{"message":"Hello, Test!","app":"Autix Ch02"}
// /hello
{"message":"Hello, World!","app":"Autix Ch02"}
10. NestJS vs Spring Boot 对照总结
读完这篇,你应该已经能感受到两者的相似性了。做一个完整的对照表:
| 维度 | NestJS | Spring Boot | 说明 |
|---|---|---|---|
| 路由处理器 | @Controller() + @Get() |
@RestController + @GetMapping |
几乎一模一样 |
| 参数绑定 | @Query() / @Body() |
@RequestParam / @RequestBody |
对应关系清晰 |
| 模块/配置 | @Module() |
@Configuration + @ComponentScan |
NestJS 显式列出 Controller/Service,Spring 自动扫描 |
| 可注入类 | @Injectable() |
@Service / @Component |
IoC 容器管理 |
| 启动入口 | NestFactory.create() + app.listen() |
SpringApplication.run() + 内嵌容器 |
概念一致 |
| 跨域 | app.enableCors() |
@CrossOrigin 或全局配置 |
都支持全局和单接口配置 |
| 环境配置 | .env + @nestjs/config |
application.yml + @Value |
形式不同,目的相同 |
| 定时任务 | @Cron() |
@Scheduled |
写法几乎一样 |
| 中间件 | @Injectable() + implements NestMiddleware |
HandlerInterceptor |
概念一致 |
| 内部依赖 | rxjs | 无直接对应 | NestJS 用 RxJS 处理流,Spring 用 Reactor(可选) |
一句话总结 :NestJS 就是 TypeScript 世界里长了 Spring Boot 脸的后端框架。Java 开发者上手 NestJS 基本是"换个语法继续写"。
11. 我们做了什么
来回顾一下本篇的产出:
11.1 新增文件一览
services/api/
├── package.json # 依赖 + 脚本
├── tsconfig.json # 继承基类 + CommonJS + 路径映射
├── nest-cli.json # NestJS CLI 配置
└── src/
├── main.ts # 启动入口(端口 4001,开启 CORS)
├── app.module.ts # 根 Module(注册 Controller)
├── health.controller.ts # GET /health 健康检查
└── hello.controller.ts # GET /hello 演示参数绑定
11.2 知识点清单
| 序号 | 知识点 |
|---|---|
| 1 | NestJS 11 作为 TypeScript 后端框架的定位,对照 Spring Boot |
| 2 | Monorepo 中子包之间的 workspace:* 依赖方式 |
| 3 | CommonJS vs ESM 选择,为什么 NestJS 选 CommonJS |
| 4 | tsconfig 的 module、moduleResolution、rootDir、paths 配置 |
| 5 | paths 指向编译产物 .d.ts 而非源码的原因 |
| 6 | 装饰器用法:@Controller、@Get、@Query、@Module |
| 7 | 共享包跨包引用(类型接口 + 常量) |
11.3 踩坑记录
| 坑 | 原因 | 解决方案 |
|---|---|---|
编译产物缺 .js 后缀 |
用了 ESM + module: nodenext 但 paths 没加 .js |
换回 CommonJS + module: commonjs |
moduleResolution: bundler 报错 |
bundler resolution 是 ESM 的,CommonJS 不认识 | 在子包 tsconfig 里覆盖成 moduleResolution: node |
| paths 指向源码报 rootDir 冲突 | TypeScript 认为超出了编译范围 | 指向 dist/index.d.ts 编译产物 |
| IoC 容器不工作 | import 'reflect-metadata' 没写或没写在最前面 |
放在 main.ts 第一行 |
| 路由访问 404 | Controller 类没加到 @Module 的 controllers 数组里 |
检查 app.module.ts |
12. 下一篇预告
第二章 2.7:前后端联通 ------ Next.js 调用 NestJS API
下一篇我们会把 Monorepo 里的 Next.js 前端(2.5 已经搭好)和这篇的 NestJS 后端真正打通。你会看到:
- Next.js 前端如何通过 CORS 从 3000 端口调用 4001 端口的 API
- 共享包的
HealthResponse接口如何同时约束前端请求和后端响应 - 整个 Monorepo 跑起来的完整流程(contracts → api → web)
关于作者
我是 Java 开发者,正在从零学习 TypeScript 和 AI 应用开发。
写这个系列博客,有两个初衷:
- 督促自己:写不出来就是没学懂,逼自己把知识点吃透
- 寻找同路人:希望遇到志同道合的朋友,一起沟通、相互学习
联系方式:
- 博客留言(欢迎交流讨论,一起成长)
系列文章导航
- 上一篇:第二章 2.5:初始化 Next.js 客户端 ------ TypeScript 全栈的前端选择(./03-2.5 初始化 Next.js 客户端 ------ TypeScript 全栈的前端选择.md)
- 本篇:第二章 2.6:初始化 NestJS 服务端 ------ TypeScript 后端框架体验
- 下一篇:第二章 2.7:前后端联通 + 踩坑复盘(./05-2.7 前后端联通 + 踩坑复盘.md)
- 回到索引 :第二章博客索引