CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
NestJS 实战入门/课时/Service 层
RawBlame
课时目录12 节

NestJS 实战入门

  1. 01NestJS 是什么
  2. 02项目创建与结构
  3. 03Module 与依赖注入
  4. 04Controller 与路由
  5. 05Service 层
  6. 06Pipe 与校验
  7. 07Guard 与认证
  8. 08ConfigModule
  9. 09数据库接入入门
  10. 10异常与过滤器
  11. 11测试
  12. 12综合练习

Service 层

@Injectable、业务逻辑分离、单元测试友好。

30 分钟

Service 层

这一课你将能做到

  1. 把题库 CRUD 与筛选、作答判分等业务规则全部放进 @Injectable Service,Controller 保持薄
  2. 在缺失资源、参数越界、数据冲突时抛语义化异常(404 / 400 / 409)
  3. 用 Test.createTestingModule 不启动 HTTP 就能验证 Service 行为

为什么要学

Service 放「业务规则与编排」:404、下标越界、重复题干、判分、正确率。Controller 只翻译 HTTP,Repository 只碰持久化。

分层最大回报是可测试性:业务不依赖请求对象和数据库连接,单测几秒跑完,与 Spring @Service 同一思路。

先建立图像

Controller   取参、状态码、转调
Service      规则、编排、未来的事务边界
Repository   持久化(数据库课换 ORM)
应该放在 Service不应该放在 Service
业务规则(下标范围、查重)req / res 等 HTTP 对象
抛语义异常路由与状态码拼装
调用其他 ServiceJWT 解析(Guard 的职责)

跟着做

步骤 1:实现完整的 QuestionsService

替换 src/questions/questions.service.ts:

import {
  BadRequestException,
  ConflictException,
  Injectable,
  NotFoundException,
} from "@nestjs/common";
import { CreateQuestionDto } from "./dto/create-question.dto";
import { UpdateQuestionDto } from "./dto/update-question.dto";

export type Difficulty = "easy" | "medium" | "hard";

export interface Question {
  id: number; stem: string; options: string[];
  answerIndex: number; difficulty: Difficulty; tags: string[];
}

export interface SearchQuery {
  difficulty?: Difficulty; tag?: string; page: number;
}

@Injectable()
export class QuestionsService {
  private items: Question[] = [{
    id: 1,
    stem: "TypeScript 中,用来描述对象形状的类型是?",
    options: ["interface", "class", "enum", "namespace"],
    answerIndex: 0, difficulty: "easy", tags: ["typescript"],
  }];
  private nextId = 2;

  search({ difficulty, tag, page }: SearchQuery) {
    const size = 10;
    const data = this.items.filter(
      (q) =>
        (!difficulty || q.difficulty === difficulty) &&
        (!tag || q.tags.includes(tag)),
    );
    const start = (page - 1) * size;
    return { total: data.length, page, data: data.slice(start, start + size) };
  }

  findAll(): Question[] {
    return this.items;
  }

  findOne(id: number): Question {
    const question = this.items.find((q) => q.id === id);
    if (!question) {
      throw new NotFoundException(`题目 ${id} 不存在`);
    }
    return question;
  }

  create(dto: CreateQuestionDto): Question {
    this.assertAnswerIndex(dto.options.length, dto.answerIndex);
    if (this.items.some((q) => q.stem === dto.stem)) {
      // 题干重复属于资源冲突,409 比 400 贴切
      throw new ConflictException("相同题干的题目已存在");
    }
    const question: Question = { id: this.nextId++, ...dto, tags: dto.tags ?? [] };
    this.items.push(question);
    return question;
  }

  update(id: number, dto: UpdateQuestionDto): Question {
    const question = this.findOne(id);
    const options = dto.options ?? question.options;
    const answerIndex = dto.answerIndex ?? question.answerIndex;
    this.assertAnswerIndex(options.length, answerIndex);
    // JSON 往返会自动剔除值为 undefined(PATCH 未传)的字段
    Object.assign(question, JSON.parse(JSON.stringify(dto)), { options, answerIndex });
    return question;
  }

  remove(id: number): void {
    this.findOne(id); // 不存在直接抛 404
    this.items = this.items.filter((q) => q.id !== id);
  }

  private assertAnswerIndex(optionCount: number, answerIndex: number) {
    if (answerIndex < 0 || answerIndex >= optionCount) {
      throw new BadRequestException(
        `answerIndex 必须在 0 到 ${optionCount - 1} 之间`,
      );
    }
  }
}

步骤 2:升级 SubmissionsService 判分逻辑

替换 src/submissions/submissions.service.ts:

import { BadRequestException, Injectable } from "@nestjs/common";
import { QuestionsService } from "../questions/questions.service";

export interface Submission {
  id: number; questionId: number; userId: number;
  selectedIndex: number; correct: boolean;
}

@Injectable()
export class SubmissionsService {
  private records: Submission[] = [];
  private nextId = 1;

  constructor(private readonly questions: QuestionsService) {}

  submit(questionId: number, userId: number, selectedIndex: number): Submission {
    const question = this.questions.findOne(questionId); // 不存在抛 404
    if (selectedIndex < 0 || selectedIndex >= question.options.length) {
      throw new BadRequestException("selectedIndex 超出选项范围");
    }
    const record: Submission = {
      id: this.nextId++, questionId, userId, selectedIndex, correct: selectedIndex === question.answerIndex,
    };
    this.records.push(record);
    return record;
  }

  listByUser(userId: number): Submission[] {
    return this.records.filter((r) => r.userId === userId);
  }

  stats(userId: number) {
    const mine = this.listByUser(userId);
    const correct = mine.filter((r) => r.correct).length;
    return { total: mine.length, correct, accuracy: mine.length ? Math.round((correct / mine.length) * 100) : 0 };
  }
}

步骤 3:确认 Controller 只剩转调

方法体内不再出现数组操作:

@Get(":id")
findOne(@Param("id") id: string) {
  return this.questions.findOne(Number(id));
}

步骤 4:用 curl 验证三类失败

curl -i http://localhost:3000/questions/999
curl -i -X POST http://localhost:3000/questions -H "Content-Type: application/json" -d "{\"stem\":\"x\",\"options\":[\"A\"],\"answerIndex\":5}"
curl -i -X POST http://localhost:3000/questions -H "Content-Type: application/json" -d "{\"stem\":\"TypeScript 中,用来描述对象形状的类型是?\",\"options\":[\"A\"],\"answerIndex\":0}"

依次得 404 / 400 / 409,Controller 没有一行错误处理。

步骤 5:写一个最小 Service 单测

新建 src/questions/questions.service.spec.ts:

import { Test } from "@nestjs/testing";
import { NotFoundException } from "@nestjs/common";
import { QuestionsService } from "./questions.service";

describe("QuestionsService", () => {
  let service: QuestionsService;

  beforeEach(async () => {
    service = (
      await Test.createTestingModule({ providers: [QuestionsService] }).compile()
    ).get(QuestionsService);
  });

  it("findOne(1) 返回种子题", () =>
    expect(service.findOne(1).stem).toContain("TypeScript"));

  it("findOne(999) 抛 NotFoundException", () => {
    expect(() => service.findOne(999)).toThrow(NotFoundException);
  });
});

执行 npm test -- questions.service,两例通过,不启动端口。

步骤 6:立下「Service 不碰传输层」的规矩

评审清单加一条:Service 不得出现 Request / Response;用户 id 由 Guard 解析、Controller 传参。判分逻辑将来可被定时任务、消息消费者复用。

讲清楚要点

  1. 规则进 Service 才可测:HTTP 细节留边界,业务用普通方法和参数表达。
  2. 失败用语义异常表达:不同异常对应不同状态码,调用方才知道如何处理。
  3. 默认单例:数组跨请求共享;多实例部署数据不互通。
  4. 跨模块复用靠注入:判分规则只有一份实现。
  5. 事务边界落在 Service:方法内多次写操作同事务。
  6. 与 Spring 同构:薄 Controller、富 Service、异常下沉。

你可能会懵的地方

  • Service 和 Provider 是什么关系? Service 是最常见的 Provider:@Injectable 且登记在 providers 即可注入。

  • NotFoundException 是不是太「HTTP」了?业务层能用吗? 自带状态码,REST 方便,本质是普通异常类;非 HTTP 宿主出口映射。

  • 测试里为什么要 createTestingModule,直接 new 不行吗? 无依赖时 new 也能跑;测试容器保证装配一致,有依赖时可注入 mock。

  • 题干查重怎么查? 本例精确相等;真实项目先规范化(去空白、全半角),或由唯一索引兜底。

  • 一个 Service 越来越大怎么办? 按用例拆分(如 QuestionImportService);入门保持一聚合根一 Service。

错法对照

错法问题正解
Controller 操作数组 / 写规则难测试、难复用规则沉到 Service
findOne 返回 undefinedController 要猜错误类型抛 NotFoundException
Service 注入 @Req()耦合传输层纯数据参数传入
重复创建返回 200/400语义不准ConflictException(409)
测试手动 new 忽略装配与生产行为不一致使用 TestingModule

卡点急救

  1. 状态码不对:确认抛的是 @nestjs/common 异常类;裸 Error 默认 500。
  2. 内存数据被意外清空:热重载后内存不持久,回到种子数据正常。
  3. 循环依赖注入报错:互相注入会形成环;保持 Submissions 单向依赖 Questions。

面试常问

  • 为什么业务逻辑放 Service 而不是 Controller? 业务不依赖 HTTP,可多入口复用、脱离 Web 容器快速单测;Controller 只是适配层。

  • NestJS 有哪些常用内置异常? BadRequestException(400)、UnauthorizedException(401)、ForbiddenException(403)、NotFoundException(404)、ConflictException(409)等,都继承 HttpException。

  • 单例 Service 上保存请求数据有什么风险? 状态被所有请求共享,会串数据;水平扩容后各实例状态独立。请求级数据应放数据库或 REQUEST 作用域 Provider。

自测三问

  1. 一句话划分 Controller 与 Service 的职责边界。
  2. 为什么单元测试可以不启动 HTTP 端口?
  3. 单例 Service 里的内存数组,在多进程部署时会出现什么现象?

小结一句话

Service 是可独立测试的业务核心,语义异常表达失败,Controller 保持薄。

延伸

  • Providers(官方 Provider 文档)
  • Exception filters(下一课前置)
  • 下一课预告:学习 Pipe 与校验,用 class-validator 把非法请求挡在业务方法之外。
加载本节练习…
← 上一课Controller 与路由下一课 →Pipe 与校验