Service 层
这一课你将能做到
- 把题库 CRUD 与筛选、作答判分等业务规则全部放进
@Injectable Service,Controller 保持薄
- 在缺失资源、参数越界、数据冲突时抛语义化异常(404 / 400 / 409)
- 用
Test.createTestingModule 不启动 HTTP 就能验证 Service 行为
为什么要学
Service 放「业务规则与编排」:404、下标越界、重复题干、判分、正确率。Controller 只翻译 HTTP,Repository 只碰持久化。
分层最大回报是可测试性:业务不依赖请求对象和数据库连接,单测几秒跑完,与 Spring @Service 同一思路。
先建立图像
Controller 取参、状态码、转调
Service 规则、编排、未来的事务边界
Repository 持久化(数据库课换 ORM)
| 应该放在 Service | 不应该放在 Service |
|---|
| 业务规则(下标范围、查重) | req / res 等 HTTP 对象 |
| 抛语义异常 | 路由与状态码拼装 |
| 调用其他 Service | JWT 解析(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 传参。判分逻辑将来可被定时任务、消息消费者复用。
讲清楚要点
- 规则进 Service 才可测:HTTP 细节留边界,业务用普通方法和参数表达。
- 失败用语义异常表达:不同异常对应不同状态码,调用方才知道如何处理。
- 默认单例:数组跨请求共享;多实例部署数据不互通。
- 跨模块复用靠注入:判分规则只有一份实现。
- 事务边界落在 Service:方法内多次写操作同事务。
- 与 Spring 同构:薄 Controller、富 Service、异常下沉。
你可能会懵的地方
-
Service 和 Provider 是什么关系?
Service 是最常见的 Provider:@Injectable 且登记在 providers 即可注入。
-
NotFoundException 是不是太「HTTP」了?业务层能用吗?
自带状态码,REST 方便,本质是普通异常类;非 HTTP 宿主出口映射。
-
测试里为什么要 createTestingModule,直接 new 不行吗?
无依赖时 new 也能跑;测试容器保证装配一致,有依赖时可注入 mock。
-
题干查重怎么查?
本例精确相等;真实项目先规范化(去空白、全半角),或由唯一索引兜底。
-
一个 Service 越来越大怎么办?
按用例拆分(如 QuestionImportService);入门保持一聚合根一 Service。
错法对照
| 错法 | 问题 | 正解 |
|---|
| Controller 操作数组 / 写规则 | 难测试、难复用 | 规则沉到 Service |
| findOne 返回 undefined | Controller 要猜错误类型 | 抛 NotFoundException |
| Service 注入 @Req() | 耦合传输层 | 纯数据参数传入 |
| 重复创建返回 200/400 | 语义不准 | ConflictException(409) |
| 测试手动 new 忽略装配 | 与生产行为不一致 | 使用 TestingModule |
卡点急救
- 状态码不对:确认抛的是
@nestjs/common 异常类;裸 Error 默认 500。
- 内存数据被意外清空:热重载后内存不持久,回到种子数据正常。
- 循环依赖注入报错:互相注入会形成环;保持 Submissions 单向依赖 Questions。
面试常问
-
为什么业务逻辑放 Service 而不是 Controller?
业务不依赖 HTTP,可多入口复用、脱离 Web 容器快速单测;Controller 只是适配层。
-
NestJS 有哪些常用内置异常?
BadRequestException(400)、UnauthorizedException(401)、ForbiddenException(403)、NotFoundException(404)、ConflictException(409)等,都继承 HttpException。
-
单例 Service 上保存请求数据有什么风险?
状态被所有请求共享,会串数据;水平扩容后各实例状态独立。请求级数据应放数据库或 REQUEST 作用域 Provider。
自测三问
- 一句话划分 Controller 与 Service 的职责边界。
- 为什么单元测试可以不启动 HTTP 端口?
- 单例 Service 里的内存数组,在多进程部署时会出现什么现象?
小结一句话
Service 是可独立测试的业务核心,语义异常表达失败,Controller 保持薄。
延伸