第二章 2.6:初始化 NestJS 服务端 —— TypeScript 后端框架体验

第二章 2.6:初始化 NestJS 服务端 ------ TypeScript 后端框架体验

课程专题 :《AI Agents 开发实践》

章节 :第二章 搭建智能体的工程底座

小节 :2.6 初始化 NestJS 服务端

适合人群:Java 开发者,已掌握 TypeScript 基础语法,了解 Monorepo 结构


本篇知识点

  1. NestJS 是什么?跟 Spring Boot 有什么对应关系
  2. 如何在 Monorepo 中创建 @autix/api NestJS 子包
  3. package.json、tsconfig.json、nest-cli.json 的完整配置
  4. CommonJS vs ESM、moduleResolution 的踩坑经验
  5. 实现 /health 和 /hello 两个接口
  6. 前后端跨包引用共享类型

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 范围",
      要么报错,要么尝试把它也编译进来(搞乱了构建结构)

所以正确做法是:

  1. contracts 先 build 一次,生成 dist/index.d.ts 类型声明文件
  2. 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() ≈ @CrossOrigin
  • app.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 应用开发。

写这个系列博客,有两个初衷:

  1. 督促自己:写不出来就是没学懂,逼自己把知识点吃透
  2. 寻找同路人:希望遇到志同道合的朋友,一起沟通、相互学习

联系方式:

  • 博客留言(欢迎交流讨论,一起成长)

系列文章导航

  • 上一篇:第二章 2.5:初始化 Next.js 客户端 ------ TypeScript 全栈的前端选择(./03-2.5 初始化 Next.js 客户端 ------ TypeScript 全栈的前端选择.md)
  • 本篇:第二章 2.6:初始化 NestJS 服务端 ------ TypeScript 后端框架体验
  • 下一篇:第二章 2.7:前后端联通 + 踩坑复盘(./05-2.7 前后端联通 + 踩坑复盘.md)
  • 回到索引 :第二章博客索引
相关推荐
xiaogai_gai1 小时前
销售出库单同步实战:旺店通→金蝶云星空的单一策略深度拆解
大数据·前端·数据库
miofly2 小时前
GitHub 今日推荐|sweety-handdraw-video:文章转手绘口播视频
javascript·开源·github
console.log('npc')2 小时前
[特殊字符] AI Agent 工作台前端实战教程 · 新手入门指南
前端·人工智能
gCode Teacher 格码致知2 小时前
前端教学2:ul标签的应用场合与经典案例
前端
const_qiu2 小时前
第 2.5 章 2.5.6:前端登录、Token 无感续签与路由守卫
java·前端·人工智能·状态模式
用户83134859306982 小时前
Vue3 事件:常用用法与开发高频坑点
前端·javascript·vue.js
weixin_446260852 小时前
ReAct‑Evolve:基于轨迹反馈的ReAct智能体自主提示演化系统
前端·react.js·前端框架
其实防守也摸鱼2 小时前
存储管理:页面置换算法学习任务(测验题要求给出置换页面顺序,缺页次数,缺页率)
android·前端·数据库·学习·oracle·自动化