Skip to content

J11 · 收尾与上线 ​

第 11 天 · 11 月 24 日周二 · 交付物:Docker 镜像、CI 流水线、OpenAPI 文档

最后一天:让 TaskFlow 能交给别人运维。基于 DTO 生成的 OpenAPI 文档、给编排平台用的健康探针、安全响应头和限流、精简的 Docker 镜像、 GitLab 流水线。Spring Boot 默认就提供其中一部分(Actuator、Spring Security);在 Nest 里,每块积木都要显式添加。这一天以回顾项目、 LEARNINGS.md 和 README 收尾。

目标 ​

  • 用 @nestjs/swagger 及其 CLI 插件在 /docs 上发布 OpenAPI 文档,并知道什么时候需要写 @ApiProperty、@ApiTags、@ApiBearerAuth。
  • 提供 Terminus 健康检查(数据库、内存、磁盘),分成 liveness 和 readiness。
  • 启用 helmet、CORS 和限流(@nestjs/throttler)。
  • 构建一个多阶段、非 root、使用 npm ci --omit=dev 的 Docker 镜像。
  • 编写 GitLab CI 流水线:安装、lint、测试、构建镜像。
  • 做最终回顾:重读 LEARNINGS.md,完善 README。

Spring → NestJS ​

SpringNestJS要点
springdoc-openapi@nestjs/swagger + CLI 插件插件在编译时读取类型,而不是在运行时
@Schema、@Parameter@ApiProperty、@ApiPropertyOptional、@ApiQuery有了插件,大多数 DTO 都用不着
@Tag@ApiTags('projects')在界面中给路由分组
@SecurityRequirement@ApiBearerAuth() + .addBearerAuth()Swagger UI 中的 “Authorize” 按钮
Actuator /actuator/health/liveness、/readiness@nestjs/terminus:/health/live、/health/ready控制器自己写,指标自己选
Spring Security 的默认响应头helmet()Express 中间件,需要启用
CorsConfigurationSource、@CrossOriginapp.enableCors({ origin })origin: false 时关闭
Bucket4j、API 网关@nestjs/throttler计数器在内存中,按实例统计
JRE 镜像 + jar(Jib、buildpacks)基于 node:24-alpine 的多阶段 Dockerfile没有单一的 jar:dist/ + 生产环境的 node_modules
GitLab CI 中的 mvn verifynpm ci、npm run lint、npm run build、npm run test:cov、npm run test:e2e每个 job 都从一个空容器开始

原理 ​

用 @nestjs/swagger 生成 Swagger 与 OpenAPI ​

DocumentBuilder 描述 API(标题、版本、认证方案),SwaggerModule.createDocument() 遍历控制器及其元数据,SwaggerModule.setup('docs', ...) 在 /docs 上提供界面,在 /docs-json 上提供 JSON,在 /docs-yaml 上提供 YAML。

要解决的问题:运行时 name: string 已经不存在了。没有辅助手段的话,每个 DTO 属性都得加一个 @ApiProperty(),重复写类型和约束。在 nest-cli.json 中声明的 CLI 插件会在 nest build / nest start 期间运行:它读取 TypeScript 类型、可选属性的 ?,以及(开启 classValidatorShim 时)class-validator 的装饰器,然后把 OpenAPI 元数据加到编译后的代码里。在 TaskFlow 上验证过:CreateProjectDto 生成的 name 是必填的,带有 maxLength: 100。开启 introspectComments 后,/** ... */ 注释会变成描述。

默认情况下,插件只处理 *.dto.ts 和 *.entity.ts 文件:放在其他文件里的 DTO 会被悄悄忽略。所以 TaskFlow 的分页 DTO 叫 pagination.dto.ts(/docs 上的 Swagger)。显式装饰器在这些情况下仍然有用:示例值、描述、插件不处理的文件,或者要隐藏的字段 (@ApiHideProperty(),见下文):

ts
// TaskFlow 之外的示例
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateProjectDto {
  @ApiProperty({ example: 'Website', description: '项目的显示名称' })
  @IsString()
  @IsNotEmpty()
  @MaxLength(100)
  name: string;
}

// 在插件不读取的文件中(query.ts,没有 .dto.ts 后缀):一切都要手写
export class PaginationQueryDto {
  @ApiPropertyOptional({ default: 1, minimum: 1 })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page = 1;
}

@ApiTags('projects') 把一个控制器的路由归为一组;@ApiBearerAuth() 配合 DocumentBuilder 中的 .addBearerAuth(),表明这些路由需要 JWT, 并添加 “Authorize” 按钮。装了 Swagger 之后,PartialType 等函数改从 @nestjs/swagger 导入(mapped types)。

类比失效的地方:springdoc 在运行时通过反射读取 Bean Validation 注解。这里的信息只在编译时存在:如果代码不是由 nest build / nest start 编译的 (普通的 tsc、ts-node、ts-jest),插件不会运行,文档就是空的。

用 @nestjs/terminus 做健康检查 ​

TerminusModule 提供 HealthCheckService 和一系列指标。health.check([...]) 执行这些指标并返回 { status, info, error, details };如果有一个失败, 它会抛出带着同一个对象的 ServiceUnavailableException(503)。TaskFlow 用到的指标:

指标检查内容何时失败
TypeOrmHealthIndicator.pingCheck('database', { timeout })对数据库发一条测试查询超时未响应
MemoryHealthIndicator.checkHeap('memory_heap', 字节数)已用的 V8 堆超过阈值(这里是 512 MiB)
DiskHealthIndicator.checkStorage('disk', { path, thresholdPercent })磁盘占用这里是超过 90%

两个探针,回答两个问题:

  • liveness:进程还能响应吗?失败时,编排平台会重启容器。它不能依赖任何外部资源:如果它检查数据库,PostgreSQL 一出故障就会重启所有实例, 却什么也修不好。
  • readiness:这个实例能接收流量吗?失败时,它会被从负载均衡中摘除,直到数据库恢复。

这正是 Spring Boot 的划分(/actuator/health/liveness 和 /readiness)。类比失效的地方:Actuator 根据 classpath 自动配置;这里要自己写控制器、 自己选指标。控制器用 version: VERSION_NEUTRAL 退出版本控制(是 /health,不是 /v1/health,见 API 版本), 标注了 @Public()(J8)和 @SkipThrottle()。@HealthCheck() 装饰器会添加响应头 Cache-Control: no-cache, no-store, must-revalidate,并在 Swagger 中为响应生成文档。

安全:helmet、CORS、限流 ​

helmet 是一个 Express 中间件,用来设置安全响应头:Content-Security-Policy、Strict-Transport-Security、X-Content-Type-Options: nosniff、 X-Frame-Options: SAMEORIGIN 等,并去掉 X-Powered-By。相当于 Spring Security 默认添加的那些响应头。已在浏览器中验证:在 helmet() 的默认配置下, Swagger UI 能正常显示。

CORS 只和浏览器有关:浏览器会拦截来自其他源、且没有 Access-Control-Allow-Origin 的响应。curl 和服务器之间的调用不受影响。 app.enableCors({ origin }) 接受一个源列表;origin: false 不发送任何 CORS 响应头,相当于对其他源关闭 API。

限流:ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }])(ttl 以毫秒计)定义默认限制,由 ThrottlerGuard 执行,它以 APP_GUARD 的形式 声明在 JWT guard 之前(全局 guard)。@Throttle() 为某条路由修改这个限制,@SkipThrottle() 去掉它。超过限制时, guard 抛出 ThrottlerException:429,带 Retry-After 响应头,在 TaskFlow 中响应体是全局过滤器生成的 problem+json,带 "code": "TOO_MANY_REQUESTS"。Spring 没有内置的对应物(用 Bucket4j、Resilience4j 或 API 网关)。

客户端按 IP(req.ip)识别,计数器保存在内存中,按实例统计:有三个副本时,一个客户端能发出三倍于限制的请求。多个命名限制可以共存:

ts
// TaskFlow 之外的示例:两个时间窗口,再对一条路由单独覆盖
ThrottlerModule.forRoot([
  { name: 'short', ttl: 1_000, limit: 3 },
  { name: 'long', ttl: 60_000, limit: 100 },
]);

@Throttle({ short: { limit: 1, ttl: 1_000 } })
@Post('exports')
createExport() { /* ... */ }

多阶段 Dockerfile ​

在 Java 这边,最终镜像包含一个 JRE 和一个 jar。Node 应用没有单一的产物:需要编译后的代码(dist/)和生产依赖(node_modules)。多阶段构建把这两种 需求分开:

  • build 阶段:安装全部依赖(TypeScript、Nest CLI、Swagger 插件),然后执行 nest build;
  • runtime 阶段:npm ci --omit=dev 只安装 dependencies(及其自身的依赖),再从上一阶段复制 dist/。Jest、oxlint 和 Nest CLI 不在最终镜像里。 TypeScript 却在:@nestjs/swagger 12 依赖它,TypeORM 1.1 依赖 ts-node。TaskFlow 生产环境的 node_modules 大约 169 MB。

npm ci 严格按照 package-lock.json 安装,如果 package.json 和 lockfile 不一致就会失败:它是 CI 和镜像构建用的命令,npm install 则是开发机上用的。 先复制 package.json 和 lockfile、再复制代码,只要依赖不变,Docker 就能复用依赖那一层。

三项运维设置:

  • USER node:官方镜像提供了一个非 root 的 node 用户。应用里的漏洞不会让攻击者获得容器内的 root 权限。
  • CMD ["node", "dist/main.js"](exec 形式):node 是主进程,容器停止时直接收到 SIGTERM。配合 enableShutdownHooks(),Nest 会正常关闭连接池 (生命周期)。
  • HEALTHCHECK:Docker(以及 Compose)会请求 /health/live。Kubernetes 忽略这条指令,使用自己的探针。

.dockerignore 把 node_modules、dist、覆盖率报告、测试、.git 和 .env* 文件排除在构建上下文之外:本地的密钥不会进入镜像。

GitLab CI:lint、测试、构建镜像 ​

GitLab 流水线是一系列阶段(stages);同一阶段的 job 并行运行,每个都在一个全新的容器里。所以要在 before_script 中执行 npm ci(通过 default 对所有 job 生效),并以 package-lock.json 为键缓存 .npm 目录。job 可以启动 services(这里是给端到端测试用的 PostgreSQL),也可以用 rules 限制运行条件(这里只在默认分支上构建镜像)。

学习计划中的阶段.gitlab-ci.yml 中的 job命令
install每个 job 的 before_scriptnpm ci --cache .npm --prefer-offline
lintlint-and-buildnpm run lint(oxlint)、npm run typecheck,然后 npm run build(nest build),每个分支都执行
testunit-tests、e2e-testsnpm run test:cov、npm run test:e2e
builddocker-image,默认分支docker build,然后 docker push 到 GitLab 镜像仓库

TaskFlow 案例 ​

/docs 上的 Swagger ​

插件在 CLI 配置中声明:

json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
    "plugins": [
      {
        "name": "@nestjs/swagger",
        "options": { "classValidatorShim": true, "introspectComments": true }
      }
    ]
  }
}

文档由 setupSwagger() 挂载,只有 main.ts 会调用它:

ts
export function setupSwagger(app: INestApplication): void {
  const config = new DocumentBuilder()
    .setTitle('TaskFlow API')
    .setVersion('1.0')
    .addBearerAuth()
    .build();
  SwaggerModule.setup('docs', app, () => SwaggerModule.createDocument(app, config));
}
ts
// SpringApplication.run(TaskflowApplication.class, args)
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  setupApp(app);
  setupSwagger(app);
  await app.listen(app.get(ConfigService).getOrThrow<number>('PORT'));
}
// `void` marks the promise as deliberately not awaited (no-floating-promises).
void bootstrap();
  • 给 SwaggerModule.setup() 传一个函数,会把文档的构建推迟到第一次请求 /docs 或 /docs-json 时,之后缓存在内存中。
  • /docs 在所有环境中都提供,包括生产环境。要隐藏它,就根据 NODE_ENV 有条件地调用 setupSwagger()。
  • 控制器上标注了 @ApiTags() 和 @ApiBearerAuth():
ts
@ApiTags('users')
@ApiBearerAuth()
@Controller('users')
@Roles(Role.Admin) // every route of this controller: ADMIN only, otherwise 403
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get()
  findAll() {
    return this.users.findAll();
  }
}

插件根据 class-validator 装饰器为 CreateProjectDto 生成的 schema:

json
{
  "type": "object",
  "properties": {
    "name": { "type": "string", "maxLength": 100 },
    "description": { "type": "string", "maxLength": 2000 }
  },
  "required": ["name"]
}

阅读 /docs-json 时要检查两点,TaskFlow 都已处理好:

  • GET /v1/projects 的文档包含 search、archived、page 和 limit。后两个来自 src/common/dto/pagination.dto.ts 中的 PaginationQueryDto: 正是 .dto.ts 后缀让插件去读这个文件。如果命名为 pagination.ts,它会被悄悄忽略,page 和 limit 会从文档中消失,而校验照样生效。
  • User schema(通过 Project 的关联关系引用到)不包含 passwordHash。这个字段带两个装饰器,各管一件事:
ts
@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 254, unique: true })
  email: string;

  // Never serialized: ClassSerializerInterceptor applies @Exclude on responses.
  // @ApiHideProperty keeps it out of the OpenAPI schema as well.
  @Exclude()
  @ApiHideProperty()
  @Column({ name: 'password_hash', type: 'varchar' })
  passwordHash: string;

  @Column({ type: 'enum', enum: Role, enumName: 'user_role', default: Role.Member })
  role: Role;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;
}

@Exclude() 在运行时生效:ClassSerializerInterceptor 把这个字段从返回的 JSON 中去掉(J8)。它不影响文档,文档是插件 根据类型构建的:没有 @ApiHideProperty() 的话,passwordHash 会在 /docs 中被描述,尽管没有任何响应包含它。

用 Terminus 实现 /health:liveness 与 readiness ​

ts
// /actuator/health/liveness and /readiness. VERSION_NEUTRAL keeps them at
// /health/... instead of /v1/health/... for load balancers and Kubernetes.
@ApiTags('health')
@Public()
@SkipThrottle()
@Controller({ path: 'health', version: VERSION_NEUTRAL })
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    private readonly memory: MemoryHealthIndicator,
    private readonly disk: DiskHealthIndicator,
  ) {}

  /** Liveness: the process answers. No dependency, or a DB outage restarts every pod. */
  @Get('live')
  @HealthCheck()
  live() {
    return this.health.check([]);
  }

  /** Readiness: ready to receive traffic. */
  @Get(['', 'ready'])
  @HealthCheck()
  ready() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 1_500 }),
      () => this.memory.checkHeap('memory_heap', 512 * 1024 * 1024),
      () => this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
    ]);
  }
}

@Get(['', 'ready']) 在 /health 和 /health/ready 上提供 readiness。一切正常时的响应:

json
{
  "status": "ok",
  "info": {
    "database": { "status": "up" },
    "memory_heap": { "status": "up" },
    "disk": { "status": "up" }
  },
  "error": {},
  "details": {
    "database": { "status": "up" },
    "memory_heap": { "status": "up" },
    "disk": { "status": "up" }
  }
}

/health/live 不检查任何外部资源:{"status":"ok","info":{},"error":{},"details":{}}。

某个指标失败时,Terminus 抛出 ServiceUnavailableException,并记录 “Health Check has failed!” 及详情。在 TaskFlow 中,全局过滤器像处理其他异常一样 转换它:响应是 503 application/problem+json,各指标的详情只出现在日志中。

json
{
  "status": 503,
  "code": "SERVICE_UNAVAILABLE",
  "message": "Service Unavailable Exception",
  "path": "/health",
  "timestamp": "2026-11-24T10:41:07.215Z",
  "correlationId": "c4e1a7d2-5b3f-4e8a-9d61-0f2b7a9c3e55"
}

对编排平台来说,只有 HTTP 状态码重要。如果需要让人在响应里看到是哪个指标挂了,就让这个控制器直接返回 Terminus 的响应体。端到端测试验证了 /health (数据库 up)和 /health/live。

Dockerfile 与 Compose 中的 app 服务 ​

dockerfile
# syntax=docker/dockerfile:1

# Stage 1: install everything and compile TypeScript to dist/.
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
COPY src ./src
RUN npm run build

# Stage 2: production dependencies and the compiled code only.
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
# The official image ships a non-root "node" user.
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
  CMD wget -qO- http://127.0.0.1:3000/health/live || exit 1
CMD ["node", "dist/main.js"]
text
node_modules
dist
coverage
.env*
!.env.example
.git
test
*.log

Compose 中的 app 服务在 app profile 下构建这个镜像:

yaml
# docker compose up -d                 -> PostgreSQL + Adminer for `npm run start:dev`
# docker compose --profile queue up -d -> + Redis for the BullMQ queue (J9)
# docker compose --profile app up -d   -> + the API itself, built from the Dockerfile (J11)
services:
  db:
    image: postgres:16-alpine
    container_name: taskflow-db
    environment:
      POSTGRES_USER: taskflow
      POSTGRES_PASSWORD: taskflow
      POSTGRES_DB: taskflow
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskflow -d taskflow"]
      interval: 5s
      timeout: 3s
      retries: 10

  # Throwaway database for e2e tests: data lives in memory (tmpfs).
  db-test:
    image: postgres:16-alpine
    container_name: taskflow-db-test
    environment:
      POSTGRES_USER: taskflow
      POSTGRES_PASSWORD: taskflow
      POSTGRES_DB: taskflow_test
    ports:
      - "5433:5432"
    tmpfs:
      - /var/lib/postgresql/data

  adminer:
    image: adminer
    ports:
      - "8081:8080"
    depends_on:
      - db

  redis:
    image: redis:7-alpine
    profiles: [queue]
    ports:
      - "6379:6379"

  app:
    build: .
    profiles: [app]
    environment:
      NODE_ENV: production
      PORT: 3000
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: taskflow
      DB_PASSWORD: taskflow
      DB_NAME: taskflow
      # Empty unless exported: the app then refuses to start (validateEnv), while
      # the other services still work without it.
      JWT_SECRET: ${JWT_SECRET:-}
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:
  • JWT_SECRET: ${JWT_SECRET:-}:值来自 shell(或所在目录的 .env 文件)。缺失时为空:应用拒绝启动(validateEnv 要求至少 8 个字符),而 Compose 的其他服务照常运行。
  • DB_HOST: db:Compose 网络中的服务名,不是 localhost。
  • 带 condition: service_healthy 的 depends_on 会等到 pg_isready 有响应。
  • NODE_ENV: production:没有演示模块,JSON 日志,级别为 info。

镜像不会在启动时执行迁移:这是一个单独的步骤,TaskFlow 的 README 中有说明。migration:run 脚本依赖 ts-node 和 src/database/data-source.ts,镜像里 都没有;npm run migration:run:prod 在编译后的 DataSource 上运行 TypeORM CLI(typeorm migration:run -d dist/database/data-source.js),它会加载 dist/ 中编译后的迁移。在启动 API 之前,用同一个镜像起一个一次性容器来执行它:

bash
export JWT_SECRET=$(openssl rand -hex 32)    # 否则 app 容器会拒绝启动
docker compose --profile app build
docker compose --profile app run --rm app npm run migration:run:prod
docker compose --profile app up -d
curl http://localhost:3000/health/live

run --rm 会启动 db(通过 depends_on),用这条命令代替 CMD 执行,然后删除容器。导出的 JWT_SECRET 只给 app 容器用:docker compose up -d (数据库和 Adminer)不需要它。没有这个密钥,app 容器会因为 validateEnv 的错误立即退出 (“property JWT_SECRET has failed the following constraints: minLength, isString”,J7)。

在 TaskFlow 上验证过:docker build 成功;在空数据库上,migration:run:prod 创建了所有表,然后容器启动;它以 node 用户运行;连接 PostgreSQL 时 /health 和 /health/live 都返回 ok;生产环境下 /demo/ping 返回 404(演示模块没有加载);日志是 JSON 行。

.gitlab-ci.yml 流水线 ​

yaml
stages: [verify, build]

default:
  image: node:24-alpine
  cache:
    key:
      files: [package-lock.json]
    paths: [.npm/]
  before_script:
    - npm ci --cache .npm --prefer-offline

lint-and-build:
  stage: verify
  script:
    - npm run lint
    - npm run typecheck
    - npm run build # nest build, with the Swagger plugin, on every branch

unit-tests:
  stage: verify
  script:
    - npm run test:cov
  coverage: '/All files[^|]*\|[^|]*\s+([\d.]+)/'

e2e-tests:
  stage: verify
  services:
    - name: postgres:16-alpine
      alias: db
  variables:
    POSTGRES_USER: taskflow
    POSTGRES_PASSWORD: taskflow
    POSTGRES_DB: taskflow_test
    NODE_ENV: test
    DB_HOST: db
    DB_PORT: "5432"
    DB_USER: taskflow
    DB_PASSWORD: taskflow
    DB_NAME: taskflow_test
    JWT_SECRET: ci-only-secret-value
  script:
    - npm run test:e2e

docker-image:
  stage: build
  image: docker:27
  services: [docker:27-dind]
  before_script: []
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$CI_REGISTRY_IMAGE:latest" .
    - docker push --all-tags "$CI_REGISTRY_IMAGE"
  • lint-and-build:oxlint(包括 no-floating-promises)、tsc --noEmit,然后 npm run build,也就是带 Swagger 插件的 nest build,每个分支都执行。 构建错误在 merge request 阶段就会暴露,不用等到构建镜像。
  • unit-tests:低于覆盖率阈值时 npm run test:cov 失败(覆盖率);coverage 表达式从 Jest 文本报告的 All files 行中提取第一个百分比,显示在 GitLab 中。
  • e2e-tests:一个 postgres:16-alpine 服务,可通过名字 db 访问。job 的变量同时配置服务(POSTGRES_*)和应用(DB_*、JWT_SECRET、 NODE_ENV=test)。CI 中没有 .env.test 文件:一切都来自变量。
  • docker-image:Docker-in-Docker(docker:27-dind),用预定义变量 CI_REGISTRY_* 登录 GitLab 镜像仓库,打两个 tag(commit 和 latest)。 runner 必须允许 Docker-in-Docker(特权模式)。

未验证

验证期间,这条流水线没有在 GitLab runner 上运行过。它执行的命令(npm run lint、npm run typecheck、npm run build、npm run test:cov、 npm run test:e2e)在本地都能通过,镜像也能用 docker build 构建。

最终回顾:LEARNINGS.md 与 README ​

从头到尾重读 LEARNINGS.md。模板为每一天预留了“与 Spring 的区别”“遇到的陷阱”“待深入”三项(学习日志)。补全缺失的日子, 然后在文件开头总结出让你付出代价最大的五个与 Spring 的区别。把它们和常见陷阱列表对照一下。

完善 README,让新人不用找你也能上手。TaskFlow 的 README 已经涵盖启动、脚本、Redis 选项、Docker 操作顺序(密钥、构建、migration:run:prod,然后 up) 和项目结构。还缺:

  • 一张环境变量表(.env.example 中的变量,包括 PROJECTS_STORE、REDIS_URL、CORS_ORIGIN);
  • 端到端测试的前提条件(db-test、.env.test 的内容);
  • 运维用的 URL:/docs、/health、/health/live、/health/ready;
  • 每个 CI job 的作用。

每一天的内容在 TaskFlow 中的位置:

天文件章节
J1src/main.ts、docker-compose.yml、src/demo/event-loop.controller.tsJ1
J2src/projects/dto/project.dto.ts、src/projects/projects.controller.tsJ2
J3src/projects/projects.store.ts、src/projects/projects.module.ts、src/common/common.module.tsJ3
J4src/projects/entities/、src/projects/typeorm-projects.store.ts、src/database/J4
J5src/tasks/entities/task.entity.ts、src/database/migrations/J5
J6src/common/filters/、src/common/interceptors/、src/common/middleware/J6
J7src/config/、src/app.module.tsJ7
J8src/auth/、src/users/J8
J9src/tasks/events/、src/tasks/tasks.scheduler.ts、src/notifications/J9
J10src/**/*.spec.ts、test/app.e2e-spec.ts、jest.config.tsJ10
J11src/health/、src/app.setup.ts、Dockerfile、.gitlab-ci.ymlJ11

打 tag 前的最后检查:npm run lint、npm run typecheck、npm test、npm run test:e2e、npm run build,然后 git tag jour-11。

场景速查 ​

  • 要给 API 写文档,又不想给每个字段加注解 → 在 nest-cli.json 中启用 @nestjs/swagger 的 CLI 插件(Swagger)。
  • 某个字段出现在 /docs 里,但从不返回 → @ApiHideProperty()(/docs 上的 Swagger)。
  • Kubernetes 要重启卡住的 pod,但不能因为数据库故障而重启 → liveness 不依赖任何东西,readiness 检查数据库(健康检查)。
  • 其他源上的前端要调用 API → CORS_ORIGIN=https://app.example.com(安全)。
  • 登录要能抵御暴力破解 → 在路由上加 @Throttle({ default: { limit: 5, ttl: 60_000 } });在反向代理后面,配置 trust proxy 以便按客户端计数 (安全、陷阱)。
  • 想要一个不以 root 运行的轻量镜像 → 多阶段构建、npm ci --omit=dev、USER node(Dockerfile)。
  • 容器启动时数据库 schema 必须是最新的 → 在 up 之前执行 docker compose --profile app run --rm app npm run migration:run:prod (Dockerfile 与 Compose)。
  • 构建或测试失败时要阻止 merge request → 流水线的 verify 阶段(CI)。

陷阱 ​

liveness 检查数据库

PostgreSQL 一出故障,所有实例的 liveness 都会失败,编排平台会反复重启它们,却什么也修不好。数据库检查属于 readiness。

代理后面的限流

throttler 用 req.ip 识别客户端。在负载均衡器后面,这是代理的 IP:所有客户端共用同一个计数器。让 Express 信任代理:

ts
// TaskFlow 之外的示例,在 main.ts 中
// import type { NestExpressApplication } from '@nestjs/platform-express';
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: true });
app.set('trust proxy', 1); // 应用前面有一层代理
  • Swagger 插件与文件名。 只处理 *.dto.ts 和 *.entity.ts,其他文件被悄悄跳过:所以 TaskFlow 里叫 pagination.dto.ts。文件名不同的话,就手动加装饰器 (@ApiPropertyOptional())。
  • @Exclude() 不会在 Swagger 中隐藏字段。 它作用于返回的 JSON;对 OpenAPI schema,要加 @ApiHideProperty(),就像 passwordHash 那样。
  • 503 的响应体被全局过滤器改写。 Terminus 的详情只在日志里(/health)。
  • CMD ["npm", "start"]。 npm 夹在 Docker 和 Node 之间,让 SIGTERM 的传递变复杂;在 TaskFlow 中,npm start 还会执行 nest start,而镜像里没有它。 直接启动 node。
  • 容器首次启动时忘了执行迁移。 镜像不会在启动时执行迁移:在 up 之前,用一次性容器执行 npm run migration:run:prod(Dockerfile 与 Compose)。
  • 在镜像里执行 npm run migration:run。 这个脚本依赖 ts-node 和 src/,镜像里都没有;改用读取 dist/ 的 migration:run:prod。
  • .env 文件被复制进镜像。 .dockerignore 排除了 .env*;在加 COPY . . 之前先确认这一点。
  • throttler 计数器按实例统计。 要实现全局限制,需要共享存储(ThrottlerModule 的 storage 选项)。
  • 自定义 CSP。 helmet() 的默认配置不影响 Swagger UI;如果收紧了 contentSecurityPolicy,要重新打开 /docs 检查。

练习 ​

  • [ ] 安装 @nestjs/swagger,在 nest-cli.json 中声明插件,在 /docs 上提供 Swagger,并在 /docs-json 中确认 name 为必填且带 maxLength: 100。
  • [ ] 在控制器上加 @ApiTags() 和 @ApiBearerAuth(),用 “Authorize” 完成认证,然后从 Swagger UI 调用 GET /v1/projects。
  • [ ] 在 /docs-json 中确认 GET /v1/projects 的文档包含 page 和 limit,并且 passwordHash 没有出现。临时去掉 @ApiHideProperty(),观察这个字段 重新出现在 schema 中。
  • [ ] 基于 Terminus 的 /health(readiness:数据库、内存、磁盘)和 /health/live(liveness)。停掉数据库(docker compose stop db):/health 返回 503, /health/live 返回 200。
  • [ ] 启用 helmet、通过 CORS_ORIGIN 配置 CORS、启用 throttler;验证一分钟内第 6 次登录返回 429。
  • [ ] 编写多阶段 Dockerfile 和 Compose 中的 app 服务;用一次性容器执行 npm run migration:run:prod 应用迁移,运行 docker compose --profile app up -d,确认进程以 node 用户运行。不设 JWT_SECRET 再试一次,阅读报错。
  • [ ] 编写 .gitlab-ci.yml(install、lint、test、build),在 merge request 上观察流水线。
  • [ ] 最终回顾:重读 LEARNINGS.md,完善 README。
  • [ ] git tag jour-11。

延伸阅读 ​