Bạn đã biết fetch, TypeScript và các trạng thái UI. Giờ lần ngược một request: trình duyệt → route HTTP → kiểm tra dữ liệu → service → database → response. Qua mỗi chặng, tự xây một API danh sách bài đọc song ngữ; không chép ứng dụng production hay thông tin đăng nhập. Đây là nhánh học backend song song, không thay thế kế hoạch frontend 30 ngày. Có thể ôn Networking, TypeScript, Security và Testing.
Bản đồ học và từ vựng. Bắt đầu bằng GET /api/books chỉ đọc, sau đó nối module Nest, contract kiểm tra lúc chạy, lưu trữ quan hệ, route ghi có quyền, test và MySQL Docker local. Mỗi chặng mở rộng cùng một API danh sách sách để tập, không phải Books endpoint của portfolio. Cần biết fetch, Promise, TypeScript cơ bản, terminal và Git. Route gồm method và URL; contract định nghĩa input được kiểm tra và output công khai; provider là đối tượng Nest có thể inject; migration là thay đổi schema DB đã được xem lại; transaction gom các lệnh ghi để cùng thành công hoặc cùng hủy. Chỉ sang chặng mới khi bạn dự đoán được cả kết quả đúng lẫn lỗi ở chặng hiện tại.
Thứ tự học. Tám ranh giới là request UI, byte trên mạng, route, middleware, guard, pipe, service/DB và response công khai. Chương 1 lần theo chúng; xem bản đồ chặng bên dưới trước khi thêm lưu trữ hay kiểm thử. Bài tập không sửa dự án portfolio và chưa phải dịch vụ production. Chỉ học TiDB, upload và vận hành sau phần cốt lõi.
- Chương 1 đọc luồng HTTP, chương 2 tạo GET Nest chỉ lưu in-memory với tiền tố
/api. Chương 3 thay module và import contract Zod không có side effect; vẫn dùng main.ts chương 2. - Chương 4 là lab SQL độc lập. Chương 5 tạo MySQL Compose dùng một lần cùng schema Prisma riêng còn trống; không âm thầm thay service lưu in-memory hoặc dùng lại bảng tạo tay ở chương 4.
- Chương 6 giải thích auth nhưng file policy chạy độc lập. Chương 7 tạo app test in-memory riêng: cùng quy tắc input chương 3 (kể cả tối đa 200), không dùng chung controller hay JWT production. Chương 8 đóng gói app in-memory chương 2/3 và chỉ tham chiếu lab DB chương 5 ở phần riêng.
Đã có trong source main của dự án portfolio: monorepo TypeScript dùng Node 22/pnpm; web Next.js/React; NestJS với module, controller, service, guard, Zod pipe, PrismaService, schema/migration Prisma; Zod contracts dùng chung; unit test Vitest và E2E HTTP bằng Supertest. main.ts thiết lập tiền tố /api, CORS và đọc cookie. Compose local khai báo MySQL 8.4 cùng database test tạm riêng.
Có dependency khác với đã vận hành: @prisma/adapter-mariadb là driver adapter của Prisma 7 được PrismaService dùng cho MySQL connector; tên gói không có nghĩa production chạy MariaDB. @nestjs/swagger, @nestjs/passport, @nestjs/throttler, argon2 và cloudinary nằm trong dependencies; muốn biết có dùng thế nào phải xem riêng source Swagger, guard/throttling và media storage. Có cài gói Nest testing nhưng test dùng Vitest, không phải Jest. Ở đây request được kiểm tra bằng Zod contracts, không mặc định dùng DTO class-validator.
Mục tiêu trong cấu hình, không phải xác nhận đang chạy: README và render.yaml mô tả web trên Vercel, API Docker trên Render, database TiDB Cloud Serverless và media Cloudinary. Khi phát triển local, database là Docker MySQL 8.4; media có nhánh lưu tạm trong bộ nhớ khi chưa cấu hình Cloudinary. Chỉ đọc repo không thể xác nhận các dịch vụ ngoài đã được cấp phát hay deploy. Tài liệu kế hoạch còn có tính năng dự kiến; đừng xem một dòng kế hoạch hay một package đã cài là endpoint đã phát hành.
Lệnh fetch trên trình duyệt không phải phần triển khai server. Học method/path, query/body, mã trạng thái, JSON, request độc lập, I/O bất đồng bộ, ranh giới xử lý lỗi và cấu hình tiến trình. Lần theo GET /api/health: controller chỉ trả lời sau khi kiểm tra database; mã 200 là một phần hợp đồng, không chỉ là fetch thành công. Đừng đặt bí mật vào source hay bundle phía trình duyệt.
- HTTP là hợp đồng trao đổi: method/path chọn thao tác, header mô tả định dạng và cookie, body chứa JSON khi ghi. GET chỉ đọc; POST có thể tạo mới và không mặc nhiên an toàn khi retry.
- Đọc response theo status + header + body: 200 trả dữ liệu, 201 báo tạo mới, 204 không có body, 400 input sai, 401 chưa xác minh danh tính, 403 thiếu quyền, 404 không tìm thấy, 500 lỗi bất ngờ. Muốn trả 503 khi DB hỏng phải viết handler, không tự có.
- CORS quyết định JS trên trình duyệt có được đọc response khác origin, không phải phân quyền server. Preflight OPTIONS hỏi method/header; fetch kèm cookie cần origin cụ thể và bật credentials, không dùng wildcard. curl bỏ qua CORS của browser nhưng vẫn chịu auth server.
| Mã | Ranh giới / ý nghĩa |
|---|---|
| 200 / 201 / 204 | Đọc / tạo mới / không có body |
| 400 / 401 / 403 | Input sai / chưa xác thực / thiếu quyền |
| 404 / 500 / 503 | Không thấy dữ liệu / lỗi bất ngờ / dependency không sẵn sàng nếu tự ánh xạ |
UI fetch (method / URL / header / body nếu có)
| DNS + TLS + byte HTTP
v
Route Nest -> middleware (request id, cookie)
-> guard (danh tính, quyền) -> pipe (input)
-> controller -> service -> DB nếu có
-> interceptor / exception filter -> JSON công khai
-> browser kiểm status + CORS + bodyĐọc như trace ở frontend. fetch vẫn resolve ra Response khi server trả 400; chỉ lỗi mạng mới reject Promise. Xem response.ok và status trước khi đọc body có kiểu. Response 204 không có JSON. GET không nên sửa dữ liệu, nhưng POST gọi lại có thể tạo bản ghi trùng: cần idempotency key nếu có retry. URL/query thường đi vào log nên không đưa mật khẩu vào đó. Ranh giới API khác ranh giới trình duyệt: curl không chịu CORS nhưng vẫn phải qua xác thực của server.
Lập lịch trong Node. Một process xử lý nhiều socket đang chờ vì I/O hoàn tất qua callback của event loop; await db.query() nhường lượt thực thi JS. Hai lần await nối tiếp không tự chạy song song, còn vòng lặp đồng bộ hoặc parse JSON khổng lồ có thể chặn client khác. Giới hạn kích thước body ở đầu vào; xử lý Promise bị reject tập trung và log mã request thay vì trả stack trace. TypeScript phía server giúp người viết mã, không tự kiểm byte HTTP do người dùng gửi.
- Quan sát request. Trong DevTools, ghi riêng method, path, Origin, Content-Type, status và response body. GET cùng origin không cần CORS; gọi khác origin kèm cookie cần origin được cho phép chính xác. OPTIONS preflight chỉ cho phép thử gửi request thật, chưa cấp quyền API.
- Kiểm điều đã gửi. Cookie được trình duyệt lưu chưa chắc đi theo request. Xem header mạng nhưng không chép giá trị cookie vào log. Locale sai là input (400), thiếu phiên là danh tính (401), thiếu role là quyền (403), không có sách là tra cứu (404).
- Tách lỗi hệ thống. Database chậm/hỏng không đồng nghĩa danh sách rỗng. Trả thông báo công khai an toàn, giữ chẩn đoán nội bộ và chỉ ánh xạ 503 nếu hợp đồng API của bạn quy định.
- Thử I/O đồng thời. Tạo route đợi một timer ngắn. Gửi hai GET gần nhau rồi so thời gian tổng với một lần đợi và tổng hai lần đợi; Node có thể nhận request khác trong lúc chờ I/O. Điều này không chứng minh chạy đa lõi.
- Đối chiếu tác vụ CPU. Thay timer bằng vòng lặp đồng bộ rồi xem request kia bị chặn. Tiếp theo cho Promise reject và so lỗi công khai an toàn với log nội bộ. Giới hạn kích thước request và timeout downstream; đóng tab không mặc nhiên hủy truy vấn DB.
- Đọc kết quả đo. Tách độ trễ mạng, service và database trước khi kết luận Node chậm. Đây là thí nghiệm trên app mẫu, không phải số đo dịch vụ portfolio.
- Response 204 không có JSON body: đừng gọi response.json(). Với 400, fetch resolve thành Response; ánh xạ mã lỗi thành thông báo UI và giữ status để gỡ lỗi.
- Mất kết nối khi POST không cho biết lệnh ghi đã commit chưa. Khác với retry GET, thao tác ghi cho phép retry cần khóa idempotency; app in-memory mẫu chưa cài cơ chế ấy.
apps/api/src/main.ts cấu hình /api, đọc cookie và CORS có credentials theo điều kiện; module health giữ route kiểm tra sức khỏe. Đây là mã đã viết trong source, không phải bằng chứng đang chạy production.
# Chỉ chạy sau khi đã tạo app Nest mẫu ở chương 2.
curl -i 'http://127.0.0.1:3000/api/books?locale=vi' -H 'Accept: application/json'
# Mong đợi: HTTP/1.1 200 và JSON; đổi locale thành xx để nhận 400 sau chương 3.Sau khi chạy chương 2, GET /api/books?locale=vi trả HTTP 200 với [ {"id":1,"title":"Sách mẫu"} ]; trước chương 3, locale=xx vẫn chọn nhánh tiếng Anh. Sau khi nối pipe ở chương 3, locale=xx trả HTTP 400 và VALIDATION_FAILED.
- Browser lỗi nhưng curl chạy: có thể preflight/credentials lệch. Xem OPTIONS, origin cho phép và cookie; sửa CORS server, không bỏ qua phân quyền.
- Mọi request bị treo: CPU đồng bộ có thể chặn Node. Kiểm thời gian/event loop; chuyển việc nặng khỏi request và giới hạn body.
- Vì sao curl chạy mà fetch trên browser lỗi? curl không thực thi CORS của browser. Kiểm OPTIONS/origin/credentials; cả hai vẫn phải qua auth server.
- fetch có reject khi HTTP 400 không? Không: nó resolve thành Response. Kiểm response.ok/status trước khi parse; chỉ lỗi mạng mới reject.
- Ai chịu trách nhiệm khi DB chậm? Không phải nhánh “không có sách”. Cho service báo lỗi/hết hạn, log mã request nội bộ và trả lỗi an toàn qua handler viết rõ. Ánh xạ 503 là hợp đồng của bài tập, không phải mặc định của Nest.
- Vẽ tám ranh giới của GET; ghi nơi xử lý status, CORS và lỗi DB. Giải thích vì sao preflight qua không cấp quyền cho request thật.
- Chạy GET in-memory chương 2 với
locale=vi, xem header gửi/nhận bằng curl và dự đoánfetchcó resolve khi HTTP 400 không. Sau chương 3 gửilocale=xxđể xem lỗi. - Đạt khi chứng minh được 200 và 400 ở đúng chặng, phân biệt lỗi mạng với lỗi HTTP và giải thích vì sao mất DB không tự động thành 503.
Bắt đầu ở main.ts, lần theo imports của AppModule, rồi từ controller đến service được inject. Controller chuyển HTTP thành thao tác; service giữ luật nghiệp vụ; module đăng ký provider. Middleware chạy trước route, guard quyết định quyền vào, pipe kiểm tra/chuyển dữ liệu, filter định dạng lỗi và interceptor bao quanh xử lý. Đừng nhét truy vấn database thẳng vào controller.
- Khi khởi động, AppModule import BooksModule; module đăng ký controller và provider. Nest giải token ở constructor, tạo BooksService trước khi nhận request. Ranh giới import/export quyết định module nào inject được provider.
- Provider mặc định là singleton dùng chung: đừng cất user hiện tại trong thuộc tính. Request scope cấp instance theo request và có thể lan lên dependency; giữ DB client dùng chung. Khởi tạo/đóng tài nguyên ngoài bằng lifecycle hook và xử lý shutdown rõ ràng.
- Thứ tự request là middleware -> guard -> interceptor đi vào -> pipe tham số -> handler/service -> interceptor đi ra; filter định dạng lỗi thoát ra ngoài. Guard có thể trả 401 trước khi pipe có cơ hội trả 400.
main.ts: NestFactory.create(AppModule)
| import BooksModule
v
BooksModule: đăng ký BooksService + BooksController
| inject constructor (BooksService dùng chung)
v
GET /api/books: middleware -> guard -> interceptor vào
-> pipe -> controller -> service -> interceptor ra
-> response (hoặc exception filter khi lỗi)Scope và phạm vi sử dụng. Khai báo BooksService một lần trong BooksModule; module khác muốn dùng phải import BooksModule và provider được export. Controller tạo mới không có nghĩa service tạo mới. Chỉ dùng request scope khi thật sự có trạng thái theo request; thông thường hãy truyền context tường minh vào service dùng chung.
Nhánh lỗi. Guard từ chối sẽ bỏ qua pipe tham số và controller. Pipe báo lỗi thì service chưa chạy; interceptor có thể ghi nhận exception từ service và filter ánh xạ thành lỗi công khai. @Get() mặc định trả 200, @Post() là 201 trừ khi đổi rõ ràng; không che lỗi provider bằng response 200.
- Khởi tạo lúc bootstrap. Nest đọc imports của AppModule, đăng ký BooksService trong BooksModule rồi inject vào constructor controller. Singleton mặc định phục vụ nhiều request nên không giữ user hiện tại trong thuộc tính. Export/import quyết định provider nhìn thấy giữa các module.
- Lần theo request. Middleware có thể gắn mã request; guard có thể chặn trước pipe; pipe chuyển locale; controller gọi service. Interceptor có thể bọc kết quả, còn exception filter định dạng lỗi. Chỉ dùng request scope nếu truyền context tường minh không đủ, vì nó tăng số instance.
Lần theo một lỗi. Route không tồn tại không vào controller và trở thành 404. Nếu guard ném lỗi, pipe và service không chạy. Nếu pipe ném BadRequestException, controller không chạy; exception filter có thể đổi thành lỗi công khai ổn định. Nếu Promise của service reject, interceptor bọc handler có thể thấy lỗi trước khi filter ghi response. Middleware và pipe toàn cục áp dụng rộng, nên đừng gắn schema Zod toàn cục giả định mọi route nhận cùng một body. Nest xử lý binding toàn cục/controller/route theo thứ tự tài liệu. Vẽ call stack lúc interceptor đi vào và lúc quay ra; nhầm hai chiều dễ khiến chỉ đo request thành công hoặc trả response mới biến đổi một phần.
- Guard ném lỗi trước pipe body nên người chưa xác thực gửi input sai nhận 401, không phải 400. Editor hợp lệ mới vào pipe; input lỗi dừng trước service. Chương 7 kiểm thứ tự này qua HTTP.
- Khi thêm DB sau này, giữ một provider dùng chung cho pool. Service request scope tự tạo PrismaClient theo từng request sẽ nhân kết nối.
apps/api/src/main.ts khởi động module gốc; apps/api/src/app.module.ts nhập các module tính năng, còn apps/api/src/prisma/prisma.service.ts giữ vòng đời client dùng chung. Đây là bằng chứng cấu trúc mã, không chứng minh có instance đang chạy.
Ví dụ chạy được, chặng A: Trong thư mục trống dùng Node 22, chạy npx @nestjs/cli@11 new reading-api --package-manager npm, vào app, thay toàn bộ src/app.module.ts bằng file dưới và src/main.ts bằng file kế. Chạy npm run start:dev. Ví dụ chỉ lưu in-memory, chưa có DB hay đăng nhập; khởi động lại sẽ mất dữ liệu. Tiền tố /api được cài ở main.ts, không nằm trong controller.
import { Controller, Get, Injectable, Module, Query } from '@nestjs/common';
@Injectable()
class BooksService {
list(locale: string) {
return [{ id: 1, title: locale === 'vi' ? 'Sách mẫu' : 'Sample book' }];
}
}
@Controller('books')
class BooksController {
constructor(private readonly books: BooksService) {}
@Get() list(@Query('locale') locale = 'en') {
return this.books.list(locale);
}
}
@Module({ controllers: [BooksController], providers: [BooksService] })
class BooksModule {}
@Module({ imports: [BooksModule] })
export class AppModule {}Thay toàn bộ src/main.ts bằng file sau. Giữ cấu hình decorator của CLI; tiền tố toàn cục làm URL chương 1 khớp route.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();npm run start:dev báo cổng local; curl -i http://127.0.0.1:3000/api/books?locale=vi trả HTTP 200 và [ {"id":1,"title":"Sách mẫu"} ]. /books không có /api trả 404. Khởi động lại chỉ có fixture in-memory ban đầu, không phải dữ liệu lưu bền.
- Nest không tìm được BooksService: thiếu đăng ký provider hoặc import/export. Xem providers của BooksModule và module imports; chỉ đăng ký/export dependency cần thiết.
- GET trả 404: kiểm tiền tố /api và đường dẫn @Controller trước khi sửa nghiệp vụ.
- Vì sao không new BooksService() trong controller? Cách đó bỏ qua DI Nest, override và vòng đời dependency không hoạt động đúng. Hãy đăng ký/inject provider.
- Khi nào dùng provider request scope? Chỉ khi cần trạng thái riêng cho request; singleton kèm context tường minh tránh nhân pool kết nối DB.
- Tách module một file thành service, controller và module; chỉ export provider khi module khác thật sự cần. Giữ tiền tố
/apitrongmain.ts. - Thêm
GET /api/books/:id, kiểm id dạng số và némNotFoundExceptionkhi không thấy sách; ghi lại lỗi nào xảy ra trước service. - Đạt khi id đúng chỉ trả trường công khai, id vắng trả 404, id sai trả 400, và bạn chỉ ra controller, nơi đăng ký provider, vòng đời DI, thứ tự request.
Modules, Providers, vòng đời request, scope.
Khai báo type TypeScript không còn lúc chạy nên không chặn được JSON sai từ mạng. Zod 4 kiểm dữ liệu chưa rõ kiểu ở ranh giới API, trả dữ liệu hợp lệ đã có type hoặc lỗi. Bài tập chỉ dùng chung contract input/output công khai, không chia sẻ thông tin đăng nhập hay nguyên hàng DB.
- Khi biên dịch và khi chạy.
type BookInput = { title: string }giúp IDE, nhưng request chứa{title: 42}vẫn đến server. GọisafeParse(raw)rồi đưadatađã kiểm vào service, không dùng body thô. - Trước và sau khi parse.
z.input<typeof schema>mô tả đầu vào trước biến đổi;z.output<typeof schema>(cũng làz.infer) mô tả kết quả. Chẳng hạn,trim()chuẩn hóa tên sách trước khi lưu, cònmin(1)từ chối chuỗi chỉ có khoảng trắng.
- Query GET. Nest tìm route rồi pipe kiểm locale chưa đáng tin. Không truyền thì lấy
en;?locale=hoặcxxtrả 400 trước khi controller hay service chạy. - Body POST. Sau guard xác thực/phân quyền, pipe kiểm body rồi mới chuyển dữ liệu hợp lệ vào controller. Frontend có thể báo lỗi sớm bằng cùng contract, nhưng API vẫn phải kiểm lại vì người gửi có thể bỏ qua UI.
- Trường lạ.
z.objectmặc định loại key dư;z.strictObjecttừ chối. Bài tập chọn input nghiêm ngặt để{title, locale, isAdmin}bị báo lỗi, không âm thầm làm thay đổi ý người gửi. - Output công khai. Tạo DTO riêng chỉ gồm trường được phép, rồi kiểm với
publicBookSchema. Trải nguyên record DB vào JSON có thể lộ cột nhạy cảm thêm sau này.
- Dùng chung không phải phân quyền. Package chung giúp UI và API thống nhất tên trường, nhưng kiểm tại trình duyệt chỉ cải thiện UX. Không đưa Prisma model, hash và dữ liệu server vào contract công khai; kiểm quyền và ràng buộc DB riêng.
- Đổi contract có kế hoạch. Trường mới bắt buộc có thể làm hỏng client cũ; trường tùy chọn thường dễ tương thích hơn. Đổi phiên bản phía sử dụng hoặc tạo giai đoạn chuyển tiếp, đừng giả định UI và server cập nhật đồng thời.
- Các trường hợp đầu vào.
z.enum(["en", "vi"])từ chối locale lạ.LocalePipechỉ mặc địnhenkhi thiếu query; query rỗng vẫn là giá trị có mặt và bị từ chối.z.string().trim().min(1).max(200)từ chối tên sách trống hoặc quá dài. - Nối các chặng. Chương 3 chỉ gắn pipe cho query GET. Chương 7 dựng app test POST riêng, cùng quy tắc title/locale và guard chạy trước pipe body; không import module chương 3. Trong app thật, import một schema vào route và test để tránh hai bộ quy tắc lệch nhau.
- Hợp đồng lỗi an toàn. Bài tập trả HTTP 400 với
{code: "VALIDATION_FAILED", field: "locale"}khi query sai, hoặc{code: "VALIDATION_FAILED", field: "title"}trong test POST lỗi.fieldlà một đường dẫn an toàn, không phải danh sách hay toàn bộ lỗi Zod; không trả lại payload, lỗi DB hoặc stack trace. - Thử từng ranh giới. So sánh locale vắng (
en),vi, locale rỗng/lạ (400), title rỗng (400) và POST có key dư (400). Mô tả output thành công riêng với lỗi input; sự cố server cần phản hồi lỗi an toàn riêng.
packages/contracts/src/ chứa contract Zod theo nghiệp vụ; apps/api/src/common/pipes/zod-validation.pipe.ts dùng safeParse và ánh xạ lỗi thành Nest 400. Controller phải chủ động gắn pipe; cài Zod không tự nối route và không khẳng định mọi response đều được kiểm.
Chặng B. Chạy npm install zod. Lưu khối sau thành src/books.contract.ts; file định nghĩa không có side effect để lúc app import không tự in log. Thay toàn bộ src/app.module.ts từ chương 2 bằng file nối pipe bên dưới, giữ src/main.ts với tiền tố /api. Muốn thấy kết quả trim riêng, lưu script ngắn tiếp theo thành src/contract-demo.ts rồi chạy npx tsx src/contract-demo.ts sau npm install -D tsx.
import { z } from 'zod';
export const localeSchema = z.enum(['en', 'vi']);
export const createBookSchema = z.strictObject({
title: z.string().trim().min(1).max(200),
locale: localeSchema,
});
export const publicBookSchema = createBookSchema.extend({ id: z.number().int().positive() });
export type CreateBook = z.output<typeof createBookSchema>;
export type PublicBook = z.output<typeof publicBookSchema>;
src/contract-demo.ts import schema thuần; chỉ khi bạn tự chạy script mới in log, không phải lúc server import books.contract.ts.
import { z } from 'zod';
import { createBookSchema } from './books.contract';
const result = createBookSchema.safeParse({ title: ' Sample ', locale: 'en' });
if (result.success) console.log(result.data.title);
else console.log(z.flattenError(result.error).fieldErrors);Thay toàn bộ src/app.module.ts của chương 2 bằng file này; giữ src/main.ts chương 2. Đây là chặng GET trong bộ nhớ với kiểm locale đầy đủ, không phải mảnh controller dán vào module khác.
import { BadRequestException, Controller, Get, Injectable, Module, PipeTransform, Query } from '@nestjs/common';
import { localeSchema } from './books.contract';
@Injectable()
class BooksService {
list(locale: 'en' | 'vi') {
return [{ id: 1, title: locale === 'vi' ? 'Sách mẫu' : 'Sample book' }];
}
}
class LocalePipe implements PipeTransform<unknown, 'en' | 'vi'> {
transform(value: unknown): 'en' | 'vi' {
const result = localeSchema.safeParse(value ?? 'en');
if (!result.success) throw new BadRequestException({ code: 'VALIDATION_FAILED', field: 'locale' });
return result.data;
}
}
@Controller('books')
class BooksController {
constructor(private readonly books: BooksService) {}
@Get() list(@Query('locale', new LocalePipe()) locale: 'en' | 'vi') {
return this.books.list(locale);
}
}
@Module({ controllers: [BooksController], providers: [BooksService] })
class BooksModule {}
@Module({ imports: [BooksModule] })
export class AppModule {}Chạy src/contract-demo.ts in Sample sau khi trim. Khi Nest import contract sẽ không in gì; pipe lấy en nếu thiếu locale, nhận vi và từ chối xx trước controller.
Script độc lập in Sample, còn khởi động API không in dòng ấy. curl -i .../api/books?locale=xx trả HTTP 400 với mã VALIDATION_FAILED; vi hợp lệ trả 200 và sách mẫu tiếng Việt. Chỉ khai báo type TypeScript sẽ không từ chối hay trim input lúc chạy.
- Ép kiểu biên dịch được nhưng JSON sai lọt qua: TypeScript không chạy lúc runtime. Kiểm safeParse ở biên HTTP; từ chối field sai trong pipe.
- Bất ngờ nhận 500 hay thiếu locale: kiểm Zod được ánh xạ thành 400 an toàn và pipe chỉ mặc định cho giá trị vắng, không nuốt giá trị sai.
- Type PublicBook có giấu passwordHash trong JSON không? Không. Chọn/ánh xạ trường công khai và kiểm output trước khi serialize; type bị xóa lúc chạy.
- Vì sao dùng strictObject cho input? Nó từ chối key lạ thay vì âm thầm nhận dữ liệu do client gửi; kiểm cả output đã parse, không chỉ success.
- Chạy app GET hoàn chỉnh chương 3 với locale vắng,
vi, rỗng vàxx; ghi đúng status và trường lỗi an toàn. Không gắn schema body chung cho mọi route. - Dùng
createBookSchemariêng để thử title toàn khoảng trắng, số thay chuỗi và trườngpasswordHashdư; soz.inputgốc vớiz.outputđã parse. - Đạt khi query chặn
xxbằng 400 chỉ sau khi nối pipe, JSON công khai không lộ trường nội bộ và nhận ra POST chỉ cho editor là chặng sau, chưa có trong app GET này.
Zod cơ bản, object, Nest pipes.
Dùng MySQL 8.4 local để học bảng, khóa chính/ngoại, ràng buộc duy nhất, JOIN, index, transaction và an toàn migration. Thiết kế Book, Tag và bảng nối; phân biệt unique constraint với index phục vụ đọc. Tự viết SQL trước, xem kế hoạch truy vấn rồi so với Prisma. Tách database test dùng một lần khỏi dữ liệu dev.
- Một hàng sách có khóa chính INT; VARCHAR giới hạn nhãn ngắn, TEXT giữ văn bản dài, DATETIME không mang múi giờ, NOT NULL bắt buộc có giá trị. Bảng nối book_tags giữ hai khóa ngoại và khóa chính ghép. Chuẩn hóa tránh lặp tên tag trên từng sách nhưng cần JOIN khi đọc.
- Index tăng tốc một số truy vấn nhưng tốn ghi và dung lượng. Index ghép theo thứ tự (locale, created_at, id) hỗ trợ lọc locale và sắp xếp ổn định; EXPLAIN ước lượng kế hoạch, không hứa bảng một hàng luôn dùng index.
- Autocommit lưu từng lệnh riêng. Transaction gom các lệnh ghi thành một khối nguyên tử; khi lỗi phải ROLLBACK trên cùng kết nối. InnoDB mặc định REPEATABLE READ; snapshot và khóa có quy tắc khác nhau. Deadlock cần retry có giới hạn toàn transaction lặp an toàn, không chỉ lệnh INSERT cuối.
Thiết kế trước khi truy vấn. Mỗi sách có khóa chính ổn định; tên sách người nhập không phải khóa duy nhất. Tên tag có thể unique ở bảng tag, còn cặp khóa chính của book_tags ngăn nối cùng một cặp hai lần. Khóa ngoại chặn liên kết tới sách/tag không tồn tại. Bảng nối biểu diễn quan hệ nhiều-nhiều thay vì nhét chuỗi ID cách nhau bằng dấu phẩy trong một cột. Chuẩn hóa tránh sửa tên tag ở mọi hàng sách; JOIN dựng lại góc nhìn cần đọc. Chọn độ dài VARCHAR và NOT NULL để ghi quy tắc dữ liệu; DATETIME không mang múi giờ, nên service phải quy ước UTC rõ ràng.
Lý luận về index. B-tree ghép (locale, created_at, id) lọc theo cột trái locale và hỗ trợ phân trang có thứ tự; nếu đổi chiều sắp xếp từng cột có thể cần index khác. Xem EXPLAIN với key, rows, Extra trên dữ liệu đủ lớn: quét bảng một hàng là bình thường, index không mặc định nhanh hơn với điều kiện lọc yếu. Index tốn đĩa và làm ghi chậm hơn. Phân trang offset phải đi qua hàng bị bỏ qua; danh sách lớn nên dùng (created_at,id) làm cursor với khóa phụ để phá hòa. JOIN có thể dùng khóa chính của bảng nối theo book_id và index khóa ngoại theo tag_id; đọc plan thay vì đoán.
Transaction khi có nhiều người ghi. Tính nguyên tử nghĩa là cả sách lẫn quan hệ cùng commit hoặc cùng rollback. InnoDB mặc định thường dùng REPEATABLE READ; đọc thông thường trong transaction thấy ảnh chụp nhất quán, còn đọc có khóa và lệnh ghi theo quy tắc khác. Hai tác vụ khóa hàng theo thứ tự trái nhau có thể deadlock; bắt lỗi DB rồi retry toàn transaction có tính lặp an toàn với giới hạn, không retry mỗi câu lệnh cuối. Transaction nên ngắn, không chờ tương tác người dùng bên trong. Mức cô lập đánh đổi bảo đảm nhất quán với khả năng đồng thời; kiểm đúng engine trước khi suy diễn sang TiDB.
- Transaction chỉ gom lệnh thêm hàng cha và hàng liên kết khi dùng cùng kết nối. Khóa ngoại lỗi thì ROLLBACK trước COMMIT; SELECT lại thay vì chỉ xem exception là bằng chứng.
- Index giúp điều kiện lọc/sắp xếp phù hợp nhưng tăng chi phí ghi. Xem EXPLAIN với dữ liệu đại diện và đo độ trễ trước khi thêm index chỉ để đáp ứng truy vấn một hàng.
docker-compose.yml của portfolio main ghim MySQL 8.4 cho local; apps/api/prisma/schema.prisma khai báo provider MySQL. Hai điều này không có nghĩa production chạy MySQL local. TiDB là mục tiêu được mô tả, phải kiểm riêng.
Thực hành SQL local. Chỉ kết nối vào MySQL 8.4 dùng riêng cho bài tập rồi chạy DDL đầy đủ dưới đây; không chạy trên volume có sẵn hay production. Ví dụ dùng cột SQL snake_case; chương 5 sẽ ánh xạ sang Prisma. ORDER BY created_at DESC, id DESC ổn định thứ tự khi trùng thời gian; LIMIT 20 OFFSET 20 dễ hiểu nhưng chậm ở trang sâu và có thể lệch khi có hàng mới. Với dữ liệu lớn hoặc thay đổi liên tục, dùng cursor dựa trên (created_at,id) cùng thứ tự và index tương ứng. Lọc locale trước khi phân trang. Dữ liệu không đáng tin phải đưa vào câu SQL dưới dạng tham số bind, không ghép chuỗi từ request.
CREATE TABLE books (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, slug VARCHAR(120) NOT NULL UNIQUE, locale VARCHAR(2) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX books_locale_time_id (locale, created_at, id)) ENGINE=InnoDB;
CREATE TABLE tags (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(60) NOT NULL UNIQUE) ENGINE=InnoDB;
CREATE TABLE book_tags (book_id INT NOT NULL, tag_id INT NOT NULL, PRIMARY KEY (book_id, tag_id), FOREIGN KEY (book_id) REFERENCES books(id), FOREIGN KEY (tag_id) REFERENCES tags(id)) ENGINE=InnoDB;
INSERT INTO books (title, slug, locale) VALUES ('Sample book', 'sample-book-en', 'en');
INSERT INTO tags (name) VALUES ('backend');
INSERT INTO book_tags (book_id, tag_id) VALUES (1, 1);
EXPLAIN SELECT id, title FROM books WHERE locale='en' ORDER BY created_at DESC, id DESC LIMIT 20;
SELECT b.title, t.name FROM books b JOIN book_tags bt ON bt.book_id=b.id JOIN tags t ON t.id=bt.tag_id;Trong schema SQL lab mới, lệnh thêm tạo sách id 1, tag id 1 và một liên kết; JOIN trả Sample book | backend. EXPLAIN hiển thị plan có possible/key/rows (index được chọn và số hàng ước lượng thay đổi theo dữ liệu); bảng một hàng quét toàn bảng vẫn bình thường. Lab Prisma chương 5 dùng schema sạch riêng, không dùng bảng vừa tự tạo.
- INSERT vướng khóa ngoại: sách/tag được tham chiếu không tồn tại. SELECT kiểm khóa; tạo hàng cha hợp lệ trong lab trước khi nối.
- EXPLAIN quét bảng nhỏ: chưa chắc index sai. So plan trên dữ liệu đại diện; đo độ trễ trước khi đổi thứ tự index.
- Vì sao service kiểm trùng vẫn bị race? Hai request có thể cùng đọc “chưa có”; unique constraint bảo vệ quy tắc nguyên tử trong DB.
- Vì sao sắp thêm id sau created_at? Thời gian có thể trùng. Khóa phụ duy nhất giúp phân trang ổn định, kết hợp index phù hợp.
- Trên schema local dùng một lần còn trống, chạy đúng DDL và dữ liệu mẫu, rồi nối sách với tag. Giải thích vì sao liên kết tới sách không có bị từ chối và tag unique không thêm trùng được.
- So EXPLAIN trước/sau khi thêm đủ hàng mẫu; xem key và hàng ước lượng, không coi quét bảng một hàng là lỗi hiệu năng. Dùng transaction cho lệnh nối cố ý sai và kiểm rollback.
- Đạt khi JOIN ra
Sample book | backend, khóa chặn dữ liệu trùng/mồ côi và bạn giải thích được tiền tố index ghép, khóa phụ cursor và giới hạn retry deadlock.
MySQL 8.4 index, EXPLAIN, mức cô lập InnoDB, deadlock.
- Mô hình trước. Prisma schema mô tả quan hệ Book, Tag và BookTag tường minh, khóa và index. Schema nói điều mong muốn; migration đã duyệt chứa SQL đổi bảng. Client sinh ra cung cấp lời gọi có kiểu, không tự sửa schema. Lưu SQL migration trong version control.
- Dùng chung kết nối. Một Prisma service mỗi process quản lý pool driver; tạo client từng request có thể làm cạn kết nối DB. MySQL connector dùng gói tên adapter-mariadb không có nghĩa production là MariaDB.
- Đối chiếu truy vấn SQL. Sau khi sinh client Book mẫu, inject một Prisma service dùng chung.
findMany({ where: { locale: "vi" }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: 20, select: { id: true, title: true } })tương ứng WHERE, ORDER BY, LIMIT và chỉ lấy cột cần thiết. Đây là đoạn minh họa, không phải chương trình độc lập hay model Book của lil.p. - Lấy quan hệ có chủ đích.
include: { tags: true }đọc dữ liệu liên quan; đừng truy vấn tag trong vòng lặp từng sách vì tạo 1+N lượt DB. Xem truy vấn sinh ra và ánh xạ DTO công khai trước khi trả JSON. - Phân trang và ghi an toàn.
skip: 20là phân trang offset; danh sách sâu nên dùng cursor khớp thứ tự duy nhất.$transaction(async tx => { ... })chỉ gom được các lệnh khi mọi thao tác con dùngtx, không dùng client bên ngoài.
| Thao tác Prisma | Liên hệ SQL và cách dùng an toàn |
|---|---|
select: { id: true, title: true } | Chỉ lấy cột đã nêu; ánh xạ sang DTO công khai. Có thể chọn trường của quan hệ lồng nhau. |
include: { tags: true } | Nạp dữ liệu quan hệ bên cạnh các trường thường của model; tránh lộ toàn bộ và xem số truy vấn. |
Đổi schema. Trong database phát triển dùng tạm, migrate dev --create-only sinh SQL để kiểm tra và dùng shadow database tạm để phát lại lịch sử, phát hiện lệch. Xem các câu lệnh có thể xóa dữ liệu trước khi chạy migrate dev. Không dùng bảng SQL tạo tay từ chương 4.
Luồng lúc chạy. prisma generate sinh client có kiểu nhưng không tạo bảng. Khi chạy, PrismaClient và pool dùng chung truy vấn schema mẫu; select giới hạn trường trước response công khai. Khi ghi nhiều bảng, mọi thao tác phải dùng client của transaction: ném lỗi sẽ hủy cả nhóm. Tạo client từng request làm tăng số kết nối.
schema.prisma (Book / Tag / BookTag)
| migrate dev --create-only
v
shadow DB: chạy lại migrations + phát hiện drift
| đọc SQL ở prisma/migrations/
v
migrate dev -> schema reading local dùng tạm
| prisma generate
v
client được sinh -> PrismaMariaDb -> pool driver -> SQLNhất quán giữa các lớp. Lab SQL tạo bảng bằng tay; lab Prisma dùng schema dùng thử khác còn trống để lịch sử migration làm nguồn đúng. schema.prisma đặt tên trường logic, @map đặt tên cột vật lý và @@map đặt tên bảng. Relation của client là API đi qua các model; phải đọc SQL migration để chắc khóa ngoại và index thật sự tồn tại. Adapter nối Prisma với driver giao thức MySQL; nó không phải một DB khác, tên của nó không hàm ý MariaDB production. Một provider Nest giữ client và đóng lúc tắt; client theo request tạo quá nhiều pool.
Chi phí truy vấn và xử lý lỗi. select giảm cột lấy về và tránh lộ dữ liệu; include tải quan hệ, nhưng phải xem số câu SQL và tránh vòng lặp N+1. Ghi lồng hoặc $transaction gom các thay đổi liên quan; mọi truy vấn trong transaction tương tác đều phải dùng tham số tx. findMany sắp theo khóa không duy nhất có thể lặp/bỏ hàng khi có ghi đồng thời; thêm id để phá hòa. Prisma báo cả lỗi trùng unique lẫn lỗi kết nối: đổi xung đột dự kiến thành HTTP an toàn ở service, để lỗi bất ngờ cho hệ log tập trung.
Kết nối và vòng đời. Source apps/api/prisma/schema.prisma dùng provider = "mysql" và generator prisma-client có đường dẫn output. apps/api/prisma.config.ts đọc URL datasource cho CLI migrations; apps/api/src/prisma/prisma.service.ts tạo một PrismaClient dùng chung với new PrismaMariaDb(config.databaseUrl), kết nối trong onModuleInit và ngắt trong onModuleDestroy. Tên package adapter không khẳng định server là MariaDB: Compose local dùng MySQL 8.4, còn README/render mô tả TiDB Cloud là mục tiêu triển khai. Driver quản lý pool kết nối; tạo PrismaClient mỗi request làm số pool nhân lên và có thể cạn kết nối. Hãy kiểm tra mặc định/giới hạn pool ở đúng môi trường; cấu hình TLS trong URL cũng không chứng minh dịch vụ đã chạy.
Chuẩn bị Compose mẫu trước khi học Prisma. Lưu khối tiếp theo thành compose.reading.yml trong reading-api. Chỉ cấp TOY_DB_PASSWORD qua môi trường local riêng; không log hoặc commit. Compose nội suy lúc khởi động, còn $$ để container DB tự lấy biến trong healthcheck. Xác nhận đúng project/file và cổng loopback, rồi chạy docker compose -f compose.reading.yml config --quiet. Bước tiếp theo chỉ xóa container cũ của đúng dự án mẫu này trước khi khởi tạo tmpfs sạch. Healthcheck đăng nhập bằng reading và chạy SELECT 1, không chỉ ping TCP.
services:
db:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${TOY_DB_PASSWORD:?set_a_local_disposable_password}
MYSQL_DATABASE: reading
MYSQL_USER: reading
MYSQL_PASSWORD: ${TOY_DB_PASSWORD:?set_a_local_disposable_password}
ports:
- '127.0.0.1:3308:3306'
tmpfs:
- /var/lib/mysql
healthcheck:
test: ['CMD-SHELL', 'MYSQL_PWD="$$MYSQL_PASSWORD" mysql -h 127.0.0.1 -u "$$MYSQL_USER" "$$MYSQL_DATABASE" -e "SELECT 1" >/dev/null']
interval: 5s
retries: 20
start_period: 30sLab Prisma sạch, tách chương 4. Nếu trước đó đã bật đúng dự án Compose mẫu này, chạy docker compose -f compose.reading.yml down trước, rồi docker compose -f compose.reading.yml up -d db và đợi healthy. Chỉ up -d db sẽ dùng lại container đang chạy, không làm sạch tmpfs. down chỉ xóa container/mạng của dự án mẫu đã chọn; tuyệt đối không chạy trên dự án hay volume không do bạn quản lý. Không trộn DDL chương 4 với migration Prisma trên cùng schema. Trong reading-api cài prisma@7, @prisma/client@7, @prisma/adapter-mariadb@7, dotenv, dev tsx; chạy npx prisma init --datasource-provider mysql và thay prisma/schema.prisma, prisma.config.ts được sinh bằng các file đầy đủ bên dưới. slug unique giúp seed cập nhật thay vì thêm trùng.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "mysql"
}
model Book {
id Int @id @default(autoincrement())
title String @db.VarChar(200)
slug String @unique @db.VarChar(120)
locale String @db.VarChar(2)
createdAt DateTime @default(now()) @map("created_at")
tags BookTag[]
@@index([locale, createdAt, id])
@@map("books")
}
model Tag {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(60)
books BookTag[]
@@map("tags")
}
model BookTag {
bookId Int @map("book_id")
tagId Int @map("tag_id")
book Book @relation(fields: [bookId], references: [id])
tag Tag @relation(fields: [tagId], references: [id])
@@id([bookId, tagId])
@@map("book_tags")
}Hai URL local, hai mức quyền. Trong .env không theo Git chỉ dành cho Compose dùng một lần này, đặt DATABASE_URL=mysql://reading:<url-encoded-local-password>@127.0.0.1:3308/reading cho seed/runtime và MIGRATION_DATABASE_URL=mysql://root:<url-encoded-local-root-password>@127.0.0.1:3308/reading cho Prisma CLI. Tự thay placeholder ở máy mình; không commit hay hiển thị giá trị. Compose mẫu dùng cùng mật khẩu local cho hai user, tuyệt đối không thay bằng credential production.
User reading do Compose tạo truy vấn được schema nhưng thường không được tạo shadow database tạm của Prisma Migrate; migrate dev cần quyền CREATE DATABASE/DROP DATABASE cho DB tạm ấy. Root chỉ dùng cho MySQL thử nghiệm cô lập này, seed và server vẫn dùng reading. 127.0.0.1:3308 là cổng host; trong mạng Compose dùng db:3306. Mã hóa URL ký tự mật khẩu (@, :, /, #). import 'dotenv/config' tải .env riêng; shell export được ưu tiên.
Chạy migration local. npx prisma migrate dev --name reading --create-only viết SQL nhưng chưa áp dụng; đọc prisma/migrations/*/migration.sql, sau đó chạy npx prisma migrate dev mới áp. Tiếp tục với npx prisma generate, npx prisma db seed, npx tsx src/prisma-lab.ts. Prisma 7 không tự seed; chạy lại seed/query để xác nhận một hàng. Chỉ làm trên DB local dùng một lần. Môi trường thật cần credential migration phân quyền riêng và quy trình duyệt, không dùng root của bài tập.
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations', seed: 'tsx prisma/seed.ts' },
datasource: { url: process.env.MIGRATION_DATABASE_URL },
});Lưu seed có thể chạy lại này thành prisma/seed.ts. slug unique là mã công khai ổn định của sách mẫu; upsert tạo lần đầu, cập nhật khi chạy lại. Script không in credential.
import 'dotenv/config';
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
import { PrismaClient } from '../src/generated/prisma/client';
async function main() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('Set a disposable local DATABASE_URL');
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(url) });
try {
const book = await prisma.book.upsert({
where: { slug: 'sample-book-en' },
update: { title: 'Sample book', locale: 'en' },
create: { slug: 'sample-book-en', title: 'Sample book', locale: 'en' },
});
console.log({ slug: book.slug, title: book.title });
} finally {
await prisma.$disconnect();
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });Lưu script truy vấn chỉ đọc sau thành src/prisma-lab.ts, rồi chạy sau seed. Nó dùng cùng adapter Prisma 7 như source nhưng không thêm hàng thứ hai.
import 'dotenv/config';
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
import { PrismaClient } from './generated/prisma/client';
async function main() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('Set a disposable local DATABASE_URL');
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(databaseUrl) });
try {
const books = await prisma.book.findMany({
where: { locale: 'en' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 20,
select: { id: true, title: true },
});
console.log(books);
} finally {
await prisma.$disconnect();
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });Lab rollback. Sau bước generate và seed, lưu script đầy đủ bên dưới thành src/rollback-lab.ts. Chỉ chạy với DB local dùng một lần. Script ghi sách, tag và hàng bảng nối qua tx, chủ động hủy, rồi truy vấn lại sách và tag mới ngoài transaction. Cả hai đều phải vắng mặt; không chạy trên volume có sẵn.
import 'dotenv/config';
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
import { PrismaClient } from './generated/prisma/client';
async function main() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('Set a disposable local DATABASE_URL');
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(url) });
try {
let aborted = false;
try {
await prisma.$transaction(async tx => {
const book = await tx.book.create({ data: { slug: 'rollback-book-en', title: 'Temporary', locale: 'en' } });
const tag = await tx.tag.upsert({ where: { name: 'rollback-test' }, update: {}, create: { name: 'rollback-test' } });
await tx.bookTag.create({ data: { bookId: book.id, tagId: tag.id } });
throw new Error('LAB_ROLLBACK');
});
} catch (error) {
if (!(error instanceof Error) || error.message !== 'LAB_ROLLBACK') throw error;
aborted = true;
}
const partialBook = await prisma.book.findUnique({ where: { slug: 'rollback-book-en' } });
const partialTag = await prisma.tag.findUnique({ where: { name: 'rollback-test' } });
console.log({ aborted, partialBook: partialBook !== null, partialTag: partialTag !== null });
} finally {
await prisma.$disconnect();
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });- Đã chạy trong lab local cô lập (Node 22, Prisma 7.10.1, MySQL 8.4 Compose).
migrate dev --create-onlytạo SQL; sau khi đọc,migrate deváp migration Book/Tag/BookTag vàgeneratetạo client có type. Đây là các bước đã chạy trong lab, không xác nhận dịch vụ đang hoạt động. - Đầu ra đã ghi nhận. Cả hai lần
db seedin{ slug: 'sample-book-en', title: 'Sample book' }; query in[ { id: 1, title: 'Sample book' } ]trên schema mới. ID tự tăng có thể khác ở lần chạy khác. - Rollback đã ghi nhận. Script trên in
{ aborted: true, partialBook: false, partialTag: false }. Nếu một trong hai phép kiểm partial làtrue, có thể lệnh ghi thoát transaction hoặc dữ liệu cũ trùng với fixture.
- P1001: không kết nối được DB local đã chọn. Kiểm cổng host và trạng thái Compose, không in credential; sửa URL/cổng.
- P3014/lỗi shadow DB: tài khoản migration thiếu quyền hoặc chưa đặt MIGRATION_DATABASE_URL. Kiểm riêng URL migration local dùng một lần; không mở rộng quyền user reading.
- Schema lệch hoặc client cũ: không xóa volume có sẵn. So lịch sử migration với SQL; generate lại client sau khi sửa schema chỉ trên DB tạm.
- Vì sao prisma generate không tạo bảng? Nó sinh mã client TypeScript; SQL đã duyệt được áp riêng qua migrate dev trên schema dùng tạm.
- Vì sao một PrismaClient vẫn có thể cạn kết nối? Mỗi process có pool driver hữu hạn; nhiều replica nhân số kết nối. Kiểm giới hạn driver và mức đồng thời thật.
- Tạo schema mẫu còn trống tách khỏi SQL viết tay, thay file Prisma init, xem SQL migration và xác nhận generate không tự tạo bảng. Không chấp nhận yêu cầu reset drift với DB có sẵn.
- Chạy seed hai lần trên DB Compose mẫu; chỉ đọc trường công khai, thêm sách thứ hai có slug khác và đối chiếu Prisma findMany với SQL tương đương.
- Đạt khi seed không nhân đôi sách đầu, rollback không còn quan hệ ghi dở, và bạn nêu được bước nào đổi schema, sinh client, giữ pool driver dùng chung.
Prisma 7 MySQL, migration, quyền shadow database, pool.
Có danh tính chưa có nghĩa có quyền. Khi đăng ký, băm mật khẩu bằng thuật toán chậm có salt như Argon2id rồi chỉ lưu hash. Đăng nhập kiểm tra hash, trả cùng một lỗi an toàn cho tài khoản không tồn tại và mật khẩu sai, sau đó cấp JWT access/refresh có thời hạn. JWT là dữ liệu được ký chứ không mã hóa: không đặt bí mật trong payload; mỗi request được bảo vệ phải kiểm tra chữ ký, hạn và điều kiện issuer/audience. Cookie HttpOnly ngăn JavaScript đọc giá trị nhưng trình duyệt vẫn tự gửi. Khi access token hết hạn, client dùng endpoint refresh; logout xóa cookie, còn muốn vô hiệu hóa token đã cấp cần cơ chế riêng. Guard kiểm tra vai trò editor cho route ghi sau khi auth xác lập danh tính. 401 là không có danh tính hợp lệ, 403 là có danh tính nhưng thiếu quyền. Cả hai không nên làm lộ tài khoản có tồn tại hay không.
- Đăng ký băm mật khẩu bằng thuật toán chậm có salt; đăng nhập kiểm hash rồi server phát access token ngắn hạn và refresh token dài hơn. Cookie HttpOnly hạn chế JS đọc, nhưng browser vẫn gửi cookie tùy Secure, SameSite, domain và path.
- CORS kiểm ai được đọc response khác origin trên browser. CSRF là lệnh ghi không mong muốn đi kèm cookie tự đính: kiểm Origin và cân nhắc SameSite/CSRF token, đồng thời xác thực và kiểm quyền trên từng đối tượng. Origin được cho phép không chứng minh danh tính user.
| Request đến POST có bảo vệ | Ranh giới và phản hồi |
|---|---|
| Chưa xác thực, kể cả body sai | Guard chặn trước pipe → 401 |
| Đã xác thực, vai trò reader | Guard quyền từ chối ghi → 403 |
| Editor có quyền, body sai | Pipe từ chối input → 400 {code, field} |
| Editor có quyền, body đúng | Controller tạo sách công khai → 201 |
đăng ký -> hash có salt -> lưu hash
| đăng nhập so mật khẩu
v
access JWT ngắn + refresh JWT xoay vòng (cookie HttpOnly)
| browser gửi cookie -> middleware đọc
v
guard JWT: chữ ký / hạn / danh tính
| guard role: editor? -> pipe body -> service
+-- thiếu danh tính -> 401
+-- reader -> 403
+-- editor + input sai -> 400
+-- editor + input đúng -> 201Vòng đời token. JWT có chữ ký nhưng payload không được mã hóa: không đưa mật khẩu hay bí mật vào đó. Cần kiểm chữ ký, thuật toán được phép, issuer/audience nếu có cấu hình, hạn dùng và loại token; chỉ decode chuỗi không chứng minh gì. Access token sống ngắn để giảm rủi ro, refresh token phải được xoay vòng hoặc thu hồi theo chính sách server; logout vừa vô hiệu hóa trạng thái refresh vừa xóa cookie với thuộc tính tương ứng. HttpOnly ngăn JS thường đọc cookie chứ không ngăn trình duyệt gửi. Secure giới hạn HTTPS (cách xử lý local tùy trình duyệt); SameSite ảnh hưởng gửi khác site. Mọi route có quyền vẫn phải kiểm cookie ở server.
Đường ghi dùng cookie. Với POST nhạy cảm, server kiểm token đã ký và danh tính, rồi role/quyền sở hữu bản ghi, cuối cùng mới parse body. Riêng request ghi kèm cookie cần chính sách Origin/SameSite/CSRF phù hợp; chỉ bật CORS không chặn form gửi từ site khác. Trả lỗi đăng nhập chung để tránh dò tài khoản và giới hạn số lần thử. x-test-role ở chương 7 chỉ tồn tại trong module test cô lập.
Bằng chứng source, không gán nhầm cho app mẫu: apps/api/src/modules/auth/auth.service.ts kiểm tra Argon2 hash và cấp token; auth.controller.ts có login/refresh/logout; auth-cookies.util.ts đặt cookie access và refresh với path khác nhau, HttpOnly, Secure trong production và SameSite tùy chế độ cross-site. JwtAuthGuard bỏ qua route có @Public(); RolesGuard và OriginGuard xử lý vai trò và origin khi ghi. AppModule đăng ký guard toàn cục. AllExceptionsFilter định dạng lỗi server; điều này không chứng minh mọi lỗi đều thành 503. App sách tập luyện có thể chọn editor/reader để test: đây là vai trò bài tập, không phải vai trò của portfolio (gồm admin và user).
Bài tập policy độc lập, không phải đăng nhập production. Lưu ví dụ TypeScript bên dưới thành src/policy.ts, chạy npx tsx src/policy.ts sau npm install -D tsx. File này không được import vào module chương 2/3. Ở chương 7, nếu muốn kiểm policy này, chỉ thay guard đọc header giả trong module test cô lập, tuyệt đối không thay guard route thật bằng role do client gửi. Ví dụ dùng exception Nest mà CLI đã cài, không có JWT, cookie hay credential thật. Editor in true; thiếu role và reader ném exception 401/403 khi Nest xử lý qua HTTP. Chỉ suite E2E mới chứng minh status HTTP. Auth thật phải xác minh token server cấp và lấy role từ nguồn đáng tin.
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
type Role = 'reader' | 'editor';
export function mayCreateBook(role: Role | undefined): true {
if (!role) throw new UnauthorizedException();
if (role !== 'editor') throw new ForbiddenException();
return true;
}
console.log(mayCreateBook('editor'));Policy TypeScript tách biệt in true với editor; thiếu role ném UnauthorizedException (Nest trả HTTP 401), reader ném ForbiddenException (403). Trong app HTTP giả của chương 7, editor hợp lệ mới tới bước kiểm body (sai 400) hoặc tạo (đúng 201). Policy riêng này không phải đăng nhập JWT thật.
- Đăng nhập được nhưng browser không gửi cookie: kiểm domain/path/Secure/SameSite và chế độ credentials; chỉnh thuộc tính cookie theo origin/HTTPS.
- Token hợp lệ nhưng 403: danh tính đã qua, role hoặc quyền bản ghi chưa đạt. Kiểm nguồn quyền đáng tin; không bỏ qua guard.
- HttpOnly có chặn CSRF không? Không: nó ngăn JS đọc cookie, không ngăn browser tự gửi. Kết hợp Origin/SameSite/CSRF phù hợp với phân quyền server.
- JWT ký hợp lệ có cho phép mọi sách không? Không: kiểm hạn/loại token, role hiện tại và quyền trên bản ghi ở từng thao tác nhạy cảm.
- Vẽ đăng ký → hash → login → cookie access/refresh → guard → role → pipe → service → logout. Ghi rõ nơi kiểm chữ ký, hạn, CSRF và quyền sở hữu bản ghi.
- Dùng policy role độc lập chỉ làm ví dụ local; thử thiếu role và reader mà không xem header request là xác thực thật. Nói rõ editor gửi body sai đến bước 400 thế nào.
- Đạt khi ma trận 401/403/400/201 khớp chương 7, không có nội dung mật khẩu/token trong response hay log, và bạn giải thích được vì sao HttpOnly không loại CSRF.
Nest authentication, OWASP lưu mật khẩu, OWASP CSRF.
- Unit (Vitest). Gọi hàm hoặc service thuần với dependency giả. Test chạy nhanh, ổn định nhưng không đi qua decorator HTTP hay thứ tự guard.
- Tích hợp DB. Dùng schema riêng có thể bỏ để kiểm constraint và rollback trên MySQL thật. Không trỏ helper test vào DB đang phục vụ người dùng.
- HTTP (Supertest).
@nestjs/testingdựng app Nest (không bắt buộc Jest). Request đi qua middleware, guard, pipe, controller và filter; kiểm status, header và body công khai. Sourceapps/api/test/helpers/create-app.tsdùngAppModule, cookie parser vàDATABASE_URL_TESTnếu có; suite mẫu bên dưới dùng guard giả riêng.
Chọn đúng biên test cho điều muốn chứng minh: unit test Zod không kiểm được thứ tự guard HTTP; Supertest thì có. Guard đọc header giả kiểm ma trận 401/403/400/201 nhưng không chứng minh JWT hay tích hợp DB.
- Test contract thuần kiểm parse và trim; test service với repository giả kiểm quy tắc nghiệp vụ. Cả hai không chứng minh route decorator, JSON trả ra hay thứ tự phân quyền.
- Test DB thật kiểm constraint và transaction; Supertest trên app Nest đã init kiểm biên HTTP. Guard giả không chứng minh JWT, cờ cookie hoặc migration: cần test riêng cho từng điều đó.
- Mỗi suite DB dùng schema local riêng có thể bỏ và quyền riêng; từ chối đích không phải local. Seed dữ liệu xác định trước test và dọn state giữa các test.
- Đóng app Nest và pool Prisma khi dọn test. Các suite chạy song song trên một DB có thể đè nhau: tách schema hoặc chạy tuần tự, không thêm sleep để che race.
- Unit test schema thử input đúng, thiếu, sai kiểu và key dư rồi kiểm giá trị đã parse. Unit test service inject DB giả và xác nhận lỗi ngăn lệnh ghi tiếp theo.
- Supertest dựng Nest với
/apivà kiểm status cùng JSON: unit test thuần không bắt được thiếu prefix. Vai trò editor từ header chỉ tồn tại trong module test này; tuyệt đối không dùng header đó để cấp quyền ngoài môi trường test. - Để test xác thực thật, thay guard giả bằng luồng cookie/JWT rồi thử token thiếu, hết hạn, sai quyền mà không ghi giá trị token vào log.
- Tạo schema chỉ cho test trên MySQL dùng một lần, áp migration đã xem và seed dữ liệu xác định. Kiểm rollback phải truy vấn sau transaction lỗi: xác nhận sách và hàng bảng nối đều vắng, không chỉ chờ Promise reject.
- Đóng app/client rồi chạy suite lần nữa. Nếu worker song song làm kết quả đổi, tách schema từng worker hoặc chạy suite DB tuần tự. Helper E2E trong source chỉ là bằng chứng thiết kế, không cho phép dùng DB dự án.
Portfolio apps/api/vitest.config.ts cấu hình SWC decorator; apps/api/test/ có helper E2E và logic làm sạch DB test. Source có test, nhưng guard giả trong bài này không thể chứng minh cookie/JWT của portfolio.
- Dependency. Trong app Nest CLI cài
zod; càivitest,supertest,@types/supertest,@nestjs/testing,unplugin-swc,@swc/corelàm dev dependency. Giữtsconfig.jsonCLI vớiexperimentalDecorators: truevàemitDecoratorMetadata: true. - File và phạm vi. Lưu config kế tiếp thành
vitest.config.ts, suite đầy đủ thànhtest/books.e2e.test.ts, rồi chạynpx vitest run test/books.e2e.test.ts. Đây là app in-memory riêng, không phải module chương 3 hay JWT thật; dùng lại quy tắc title 1–200 ký tự và locale. Không dùng header cấp vai trò trong route triển khai.
Lưu cấu hình đầy đủ này thành vitest.config.ts. Plugin SWC biên dịch decorator kiểu Nest và sinh metadata cho decorator, khớp thiết lập compiler của Nest CLI; tsconfig.json vẫn cần experimentalDecorators: true và emitDecoratorMetadata: true.
import swc from 'unplugin-swc';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [swc.vite({
jsc: {
parser: { syntax: 'typescript', decorators: true },
transform: { legacyDecorator: true, decoratorMetadata: true },
target: 'es2022',
},
})],
test: { environment: 'node', include: ['test/**/*.test.ts'] },
});import { afterEach, beforeEach, expect, it } from 'vitest';
import request from 'supertest';
import { BadRequestException, CanActivate, Controller, ExecutionContext, ForbiddenException, Get, INestApplication, Module, Post, Body, PipeTransform, UnauthorizedException, UseGuards } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { z } from 'zod';
const input = z.strictObject({ title: z.string().trim().min(1).max(200), locale: z.enum(['en', 'vi']) });
const books: { id: number; title: string; locale: 'en' | 'vi' }[] = [];
class BookInputPipe implements PipeTransform<unknown, z.output<typeof input>> {
transform(raw: unknown) {
const result = input.safeParse(raw);
if (!result.success) throw new BadRequestException({ code: 'VALIDATION_FAILED', field: 'title' });
return result.data;
}
}
class TestEditorGuard implements CanActivate {
canActivate(context: ExecutionContext) {
const role = context.switchToHttp().getRequest().headers['x-test-role'];
if (!role) throw new UnauthorizedException();
if (role !== 'editor') throw new ForbiddenException();
return true;
}
}
@Controller('books')
class TestBooksController {
@Get() list() { return books; }
@Post() @UseGuards(TestEditorGuard) create(@Body(new BookInputPipe()) data: z.output<typeof input>) {
const book = { id: books.length + 1, ...data };
books.push(book);
return book;
}
}
@Module({ controllers: [TestBooksController] })
class TestBooksModule {}
let app: INestApplication;
beforeEach(async () => {
books.length = 0;
const moduleRef = await Test.createTestingModule({ imports: [TestBooksModule] }).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api');
await app.init();
});
afterEach(async () => { await app.close(); });
it('anonymous is 401 even with invalid body', async () => {
await request(app.getHttpServer()).post('/api/books').send({ title: '' }).expect(401);
});
it('reader is 403', async () => {
await request(app.getHttpServer()).post('/api/books').set('x-test-role', 'reader').send({ title: 'A', locale: 'en' }).expect(403);
});
it('editor with invalid body is 400', async () => {
await request(app.getHttpServer()).post('/api/books').set('x-test-role', 'editor').send({ title: '', locale: 'vi' }).expect(400).expect(({ body }) => {
expect(body).toMatchObject({ code: 'VALIDATION_FAILED', field: 'title' });
});
});
it('editor with valid body creates a public book', async () => {
const response = await request(app.getHttpServer()).post('/api/books').set('x-test-role', 'editor').send({ title: 'Sách', locale: 'vi' }).expect(201);
expect(response.body).toEqual({ id: 1, title: 'Sách', locale: 'vi' });
await request(app.getHttpServer()).get('/api/books').expect(200, [response.body]);
});npx vitest run test/books.e2e.test.ts báo một file qua với bốn test qua (thời gian tùy máy): body sai của người chưa xác thực 401, reader 403, body sai của editor 400 với {code:"VALIDATION_FAILED",field:"title"}, editor gửi đúng 201 rồi GET 200. Khi truyền object vào BadRequestException, Nest trả object đó làm body lỗi; HTTP status vẫn là 400. Kiểm các trường lỗi một cách tường minh. Nếu người chưa xác thực nhận 400 tức validation chạy trước guard, sai thứ tự dự kiến.
- POST body sai của người chưa xác thực trả 400: validation chạy trước guard mong đợi. Kiểm chỗ gắn/thứ tự guard; thiếu danh tính phải trả 401 trước khi kiểm body.
- E2E chập chờn: hàng dùng chung hoặc app/pool chưa đóng gây rò trạng thái. Chỉ reset fixture tạm và đóng Nest app/client sau test.
- Vì sao unit test pass mà HTTP trả 403? Unit test bỏ qua guard Nest. Supertest đi qua route/guard và kiểm status thật.
- Supertest mô phỏng CORS của browser không? Không: nó gửi HTTP request, không áp chính sách browser. Kiểm CORS riêng với Origin/preflight.
- Lưu
vitest.config.tsvàtest/books.e2e.test.tsđúng đường dẫn, xác nhận cấu hình decorator và chạy suite hai lần để phát hiện rò trạng thái in-memory. - Ghi status/body cho người chưa xác thực, reader, editor body sai, editor body đúng; kiểm GET không lộ trường riêng. Sau đó thiết kế riêng test rollback trên DB mẫu, không lặng lẽ chạy vào DB thật.
- Đạt khi bốn test HTTP qua hai lần, app đóng sạch và bạn nêu được một điều về auth/DB mà suite guard giả không chứng minh.
Vitest, Supertest, Nest testing.
Image là mẫu hệ thống tệp có phiên bản, gồm các layer; container là một instance đang chạy với lớp ghi tạm. Build lại image không tự thay container đang chạy; xóa container khác với xóa volume lưu bền. Chọn base image phù hợp và build lại khi sửa lỗi; khi cần tái lập bản phát hành, không dựa vào tag có thể tự thay đổi. Image mẫu chỉ chứa Nest API in-memory, không nối database production.
Dockerfile nhiều chặng tốn thêm công và dung lượng lúc build nhưng chỉ chép sản phẩm biên dịch cùng dependency cần khi chạy sang chặng runtime. Image xuất ra gọn hơn, bớt công cụ có thể bị khai thác; đổi lại artifact và native dependency phải phù hợp hệ điều hành/kiến trúc lúc chạy. Copy manifest trước source để tận dụng cache; không đưa .env, test hay bản build local vào build context. User không phải root giảm thiệt hại, không thay việc kiểm input hoặc hạn chế mạng.
- Mạng. Compose mô tả service mẫu local và mạng chung. Host gọi MySQL qua cổng loopback đã publish; container trên cùng mạng gọi qua
db:3306. - Sẵn sàng. Healthcheck
SELECT 1có đăng nhập chỉ chứng minh user mẫu truy vấn được DB.depends_on: condition: service_healthycó thể hoãn service phụ thuộc, không bảo đảm về sau luôn khỏe. Áp migration đã xem riêng. - Liveness khác readiness. Route readiness của API cần đi qua dependency bắt buộc, còn liveness kiểm tiến trình có sống không.
compose uphay tiến trình đang chạy đều chưa chứng minh request xử lý được.
| Chặng / phép kiểm | Điều chứng minh |
|---|---|
| Build image | Nest biên dịch và file runtime được đóng gói |
| Health DB | User mẫu kết nối và chạy SELECT 1 |
| Kiểm HTTP | API in-memory trả một request; chưa kiểm lưu bền |
source + lockfile
| tầng build: npm ci -> biên dịch -> dist/main.js
v
image runtime: dependency chạy + dist -> API không dùng root
DB Compose chương 5 -> SELECT 1 có xác thực -> healthy
| migration local đã đọc ở chương 5
v
image API in-memory (docker run riêng) -> thử /api/books- Vì sao cần build config.
vitest.config.tsở gốc có thể làm rộng thư mục gốc chung của TypeScript, khiến output thànhdist/src/main.js.tsconfig.build.jsonchương 8 loại file config và thư mục test; Nest CLI mặc định đọc file này khinest buildnếu nó tồn tại. - Kiểm file build mới. Chỉ dọn
distcủa app mẫu trước khi build; kiểmdist/main.jsthực sự tồn tại thay vì tin file cũ. Image chứa API in-memory, không chứa kết nối DB hay chương trình chạy migration.
Kiểm đúng biên. Healthcheck Compose chương 5 thử user DB của app, chưa kiểm tiến trình HTTP của image chạy riêng. Readiness probe của API thật cần route HTTP đi qua dependency bắt buộc, còn liveness chỉ kiểm tiến trình. depends_on: condition: service_healthy trì hoãn khởi động chứ không bảo đảm luôn khỏe. Restart loop không thay bước migration/release có thứ tự.
- Bốn phép kiểm chứng minh bốn việc: build image biên dịch được, bật container chạy process, DB healthy chứng minh user mẫu truy vấn được, HTTP probe chứng minh API in-memory phản hồi. Không phép nào nối image in-memory với DB Compose tách biệt.
- API lưu bền sẽ generate client Prisma lúc build, áp migration đã duyệt trong bước release riêng, rồi bật route readiness kiểm DB. Image in-memory ở đây chưa làm vậy; nhiều replica không được tranh nhau migration lúc khởi động.
- Giữ .env riêng ngoài build context nhờ .dockerignore; ARG lúc build không phải kho secret. USER node giảm quyền nhưng không thay kiểm input hoặc giới hạn mạng.
- Không gọi được container. Publish cổng ánh xạ loopback host sang cổng container, nhưng app chỉ bind 127.0.0.1 bên trong container thì không truy cập được qua ánh xạ ấy. Kiểm địa chỉ lắng nghe, giả định EXPOSE/CMD rồi thử cổng host; không mở API mẫu ra giao diện công cộng. Tên service Compose chỉ phân giải giữa các container cùng mạng, không dùng từ shell trên host.
- Cấu hình khác với bằng chứng chạy.
docker buildkiểm image local, còndocker compose config --quietkiểm cú pháp/nội suy mà không in cấu hình đã thay biến có thể chứa credential local.docker compose psbáo health của DB mẫu, không chứng minh API lưu ở dịch vụ ngoài đã sẵn sàng. Chỉ dọn dự án dev theocompose.reading.ymlvà đúng tên project; không dùng cờ xóa volume để che lỗi lab.
docker-compose.yml của portfolio định nghĩa MySQL 8.4 local và profile test tmpfs; apps/api/Dockerfile build API và render.yaml mô tả môi trường mục tiêu. Đây là cấu hình source, không chứng minh dịch vụ production đang chạy.
File compose.reading.yml dùng tạm và healthcheck MySQL có xác thực đã được tạo ở chương 5. Chỉ dùng lại lab đó để xem docker compose -f compose.reading.yml ps; không tạo DB thứ hai ở đây. Dockerfile bên dưới đóng gói app Nest lưu in-memory từ chương 2–3, nên không ngầm có URL database hoặc mạng Compose.
Ba file ở gốc, đúng thứ tự. Trong app Nest CLI mẫu, trước hết thay tsconfig.build.json do CLI tạo bằng khối JSON tiếp theo (Nest CLI mặc định đọc file này khi nest build). Lưu hai khối sau lần lượt thành Dockerfile và .dockerignore. Giữ package-lock.json do CLI tạo. Chỉ sau khi đủ ba file mới chạy các lệnh build bên dưới; CMD của runtime cần dist/main.js.
File 1 — tsconfig.build.json tại gốc (thay file do CLI tạo). Nest CLI mặc định dùng file này khi nest build; các mục exclude giữ file test khỏi đầu ra.
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "vitest.config.ts"]
}File 2 — Dockerfile tại gốc. Tầng build biên dịch source; tầng runtime chứa dependency cần chạy và khởi động dist/main.js bằng user không phải root.
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && chown -R node:node /app
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]File 3 — .dockerignore tại gốc. Loại dependency, output, lịch sử Git và file môi trường local khỏi build context Docker.
node_modules
.env
.env.*
dist
.git
coverageBuild và thử sau khi lưu đủ ba file. Chỉ trong app mẫu, dọn dist cũ, chạy npm run build rồi kiểm dist/main.js tồn tại; nếu không, sửa cấu hình build thay vì dùng đường dẫn cũ. Chạy docker build -t reading-api:local . và docker image inspect reading-api:local. Khởi động riêng API in-memory bằng docker run --rm -p 127.0.0.1:3000:3000 reading-api:local, sau đó curl -i http://127.0.0.1:3000/api/books?locale=vi. Container này không kết nối tới DB Compose chương 5.
Kết quả kỳ vọng, không phải xác nhận dịch vụ đang chạy: npm run build sau khi dọn đầu ra tạo dist/main.js với config build đã nêu; docker build tạo image reading-api:local. docker compose -f compose.reading.yml ps chỉ báo db mẫu healthy sau khi SELECT 1 có xác thực qua. Chạy riêng image in-memory và gọi /api/books?locale=vi dự kiến nhận 200 cùng sách mẫu VI. Chỉ build thành công không chứng minh DB hoặc API đã sẵn sàng.
- ECONNREFUSED khi container vừa lên: DB có thể chưa khởi tạo xong. Kiểm health có xác thực; chờ healthy thay vì chạy migration lặp tùy tiện.
- Host không phân giải db hoặc trùng cổng: DNS Compose chỉ cho service cùng mạng. Host dùng cổng loopback; đổi cổng trống trong file mẫu của mình.
- Vì sao copy manifest trước source? Chỉ đổi source có thể dùng lại cache cài dependency; xem các layer khi build lại thay vì cho rằng cache luôn trúng.
- docker compose up có chứng minh đã sẵn sàng không? Không: bật container khác với truy vấn DB có xác thực; đợi healthy và kiểm API route riêng.
- Xác nhận CLI biên dịch ra
dist/main.js, docker build chỉ app mẫu và xem image đã gắn tag mà không đưa.envlocal vào build context. - Chỉ chạy DB Compose mẫu với healthcheck SELECT 1 có xác thực; chờ healthy và đối chiếu địa chỉ host với container. Muốn curl route thì chạy image in-memory riêng.
- Đạt khi build thành công, Compose báo healthy, curl API in-memory chạy riêng trả 200, không chạm volume có sẵn, DB production hoặc deploy thật.
Docker multistage, Compose startup order, Docker secrets.
Học xong chương 1–8 rồi mới bắt đầu. Bản mở rộng Nest/TypeScript dùng lại /api/books, tiền tố /api, cổng 3000, hợp đồng Zod, kiểm thử quyền trước khi kiểm dữ liệu, Vitest/Supertest và Docker nhiều giai đoạn chạy bằng tài khoản không phải root. Chương 8 đóng gói app lưu trong bộ nhớ, còn lab Prisma/MySQL tách riêng; bản này cũng lưu sách trong bộ nhớ, KHÔNG phải tích hợp Prisma đã kiểm chứng. docs/examples/backend-next/ là bản tiếp nối có thể chạy, không phải tính năng portfolio đã triển khai. Sao chép vào /tmp, dùng Node 22 và npm ci; không hướng lab tới Redis/MySQL đang phục vụ người dùng.
Map trong một process rất nhanh nhưng mỗi bản sao ứng dụng có một bản dữ liệu riêng. Redis là kho key-value qua mạng: các bản sao cùng dùng cache, đổi lại mỗi lượt đọc có thêm độ trễ mạng và nguy cơ lỗi. Với cache-aside, BooksService.list() hỏi Redis trước; khi không thấy key thì đọc kho dữ liệu gốc rồi nạp cache với hạn dùng ngắn. Cache hit giảm lượt đọc kho gốc, không phải ghi dữ liệu bền vững hay thay thế chỉ mục MySQL.
Hãy xác định ai chịu trách nhiệm ghi trước khi chọn mô hình. Cache-aside giao cho ứng dụng việc nạp và xóa key. Read-through giao việc nạp khi miss cho một lớp cache; write-through ghi cache và kho gốc trong cùng luồng, còn write-behind trì hoãn ghi bền vững nên có thể mất dữ liệu đã báo thành công. Fixture chương 9 chỉ dùng cache-aside; các mô hình khác chỉ để so sánh.
| Mô hình | Luồng đọc/ghi và đánh đổi |
|---|---|
| Cache-aside | Ứng dụng đọc cache rồi kho gốc; sau ghi phải xóa key. Đơn giản nhưng ứng dụng chịu trách nhiệm làm mới. |
| Read-through | Lớp cache tự tải khi miss; cần loader truy cập kho gốc. |
| Write-through | Ghi đồng bộ cache và kho gốc; ghi chậm hơn, cần xử lý lỗi chỉ thành công một phía. |
| Write-behind | Cache xác nhận trước, kho gốc được ghi sau; có cửa sổ mất dữ liệu nếu thiếu bộ đệm bền vững. |
Key của fixture books:list:v1:locale:en gồm loại tài nguyên, phiên bản biểu diễn và ngôn ngữ; vi có key riêng. Trong dịch vụ thực tế, khi giải mã JSON cần kiểm lại DTO: JSON hỏng, đổi schema hay nhầm tenant không được lọt ra response. Không cache access token, bí mật, dữ liệu cá nhân không tách phạm vi hay quyết định cấp quyền dưới một key dùng chung. Nếu cache dữ liệu riêng từng người, phải phân tách tenant/người dùng/quyền và cân nhắc có nên cache hay không.
SET ... EX 30 giới hạn thời gian dữ liệu cũ; khi ghi, app xóa key của ngôn ngữ cũ và mới. Trong tải lớn, nên cộng một ít thời gian ngẫu nhiên vào TTL để tránh hàng nghìn key cùng hết hạn. Cấu hình maxmemory và maxmemory-policy rõ ràng: LRU ưu tiên giữ key truy cập gần đây, LFU giữ key thường dùng, còn noeviction từ chối ghi khi hết chỗ. Redis chỉ làm cache có thể loại key; Redis của BullMQ không thể tùy tiện loại trạng thái job. Nên tách instance cache và queue, hoặc cô lập dung lượng cẩn thận và đặt queue theo chính sách không loại key.
T0 GET A: miss -> đọc sách cũ -------------------------> SET giá trị cũ
T1 PATCH B: ghi sách mới -> DEL key danh sách
T2 GET A: SET giá trị cũ sau DEL -> cũ đến khi TTL hết (30 giây)
Khắc phục: key theo phiên bản / kiểm phiên bản trước SET / khóa có kiểm soát.inFlight cục bộ gộp các lượt miss đồng thời chỉ trong MỘT process API, không gộp giữa các replica. Khóa phân tán giảm stampede liên replica nhưng phải xử lý lease hết hạn và chủ khóa chết; stale-while-revalidate ưu tiên sẵn sàng nhưng chấp nhận dữ liệu cũ. TTL cộng thời gian ngẫu nhiên trải đều lúc hết hạn; nạp trước chỉ hữu ích với key biết là nhiều người đọc. Không cách nào tự xóa được tranh chấp nạp dữ liệu cũ ở sơ đồ. Fixture minh họa xóa cache và giới hạn lỗi, không hứa nhất quán tuyệt đối. Khi Redis dừng, client không giữ lệnh offline và tự nối lại với backoff có mức trần, list() đọc từ kho memory; hệ thống thật cần timeout hữu hạn và theo dõi tải lên kho gốc.
Đã đối chiếu lil.p origin/main, chỉ đọc: apps/web/src/modules/admin-core/actions/revalidate-public.action.ts dùng Next.js updateTag để làm mới dữ liệu trang, không phải Redis cache. Không thấy Redis cache provider trong source/dependency API đã kiểm. Xóa tag của Next.js tác động lên cache dữ liệu Next, không tự làm mới cache Nest hay thay TTL Redis. Redis trong bài là phần học thêm, không phải mô tả production.
Thêm src/redis-cache.service.ts (node-redis với disableOfflineQueue:true, timeout kết nối 500 ms, GET, SET EX 30, DEL, PING); sửa src/books.service.ts để list(locale) đọc/nạp cache và save() xóa key của cả ngôn ngữ cũ/mới. Đăng ký provider trong src/app.module.ts, thêm redis vào package.json và lockfile. Kho memory của chương 8 vẫn nằm trong BooksModule; chưa thêm truy vấn Prisma. Có thể chọn cache-manager với Redis store khi cần decorator/interceptor chung, nhưng service viết rõ giúp nhìn thấy thiết kế key, fallback và xóa cache. Client tự nối lại với backoff tối đa 500 ms; lệnh Redis có giới hạn 400 ms để GET không treo khi mạng lỗi.
// src/books.service.ts (trích đoạn bên trong list(locale))
const key = `books:list:v1:locale:${locale}`;
const value = await this.cache.get(key);
if (value) return { source: "cache", books: JSON.parse(value) as PublicBook[] };
const books = [...this.store.books.values()].filter(book => book.locale === locale);
await this.cache.put(key, JSON.stringify(books)); // SET EX 30
return { source: "store", books };
// Xem hàm đầy đủ để biết cách fallback và gộp các lượt miss.
// save() xóa key của cả ngôn ngữ cũ và mới.Đã quan sát trong lab local dùng một lần: GET ?locale=en lần đầu trả source:"store", lần hai source:"cache". POST { "title":"DDD", "locale":"en" } kèm x-test-role: editor trả sách id 3 (201); PATCH id 3 thành DDD revised trả 200, GET sau đó có source:"store". Khi dừng Redis, liveness vẫn 200, readiness thành 503 và GET trả 200 từ kho memory dưới 3 giây; đây không phải thử lỗi Prisma/MySQL. Trên cùng process Nest, dừng Redis khiến POST/PATCH trả 503 trong 0.001480/0.002207 giây (dữ liệu vẫn đã ghi vào memory); bật lại chính container đó, readiness trở về 200, GET lần lượt lấy từ store rồi cache với title đã sửa. README fixture ghi rõ lệnh và cổng 4318 dùng khi thử.
- GET đầu đã trúng cache? Bài chạy trước còn key; dùng
redis-cli DEL books:list:v1:locale:entrên Redis thử nghiệm rồi thử lại. - Ghi xong mà danh sách không đổi? Kiểm key theo phiên bản và xóa cả hai ngôn ngữ cũ/mới khi chuyển locale.
- Redis dừng làm GET bị treo? Kiểm cấu hình hàng đợi offline, reconnect và timeout; chỉ fallback khi kho gốc còn truy cập được. Nếu Redis đã lên mà readiness vẫn 503, kiểm tra cache client có đang tự nối lại với backoff giới hạn, thay vì đóng hẳn.
- Redis đầy bộ nhớ? Xem
INFO memory, TTL và chính sách loại key; không để trạng thái BullMQ trong cache tự loại key. - Dữ liệu sai ngôn ngữ? Đưa locale vào key, kiểm JSON lưu trữ và đổi phiên bản biểu diễn khi DTO đổi.
- Vì sao không cache mọi thứ? Key dùng chung có thể lộ dữ liệu riêng; dữ liệu ít trúng cache hoặc đổi liên tục tốn công làm mới hơn lợi ích. Đo đạc và bắt đầu với lượt đọc công khai nhiều truy cập.
- Khi miss thì sao? Một process gộp các lượt đọc đang chạy, lấy dữ liệu từ kho memory rồi lưu 30 giây; replica khác vẫn có thể tranh chấp và fixture chưa kết nối MySQL.
- Xóa key sau ghi có bảo đảm luôn mới? Không: lượt đọc đang chạy có thể nạp lại dữ liệu cũ sau DEL. Khi cần chặt chẽ, kiểm phiên bản hoặc dùng key theo phiên bản; TTL chỉ giới hạn thời gian cũ.
- Redis chết thì sao? Lab GET đọc kho gốc còn readiness trả 503; ở môi trường thật chỉ nên fallback nếu database chịu được tải tăng.
- Làm theo
docs/examples/backend-next/README.mdđể chạy bản sao và Redis thử nghiệm. GET/api/books?locale=enhai lần: thấystorerồicache;TTL books:list:v1:locale:enlà số từ 1 đến 30. - POST sách 3
DDD, PATCH thànhDDD revised, rồi GET: title đã đổi vàsource:"store"; GET lần nữa thànhsource:"cache". Không dùng id từ lần chạy cũ. - Chỉ dừng container Redis dùng cho lab. GET kết thúc dưới 3 giây với
source:"store";/api/health/readytrả 503 còn/api/health/livetrả 200. Không dừng container dùng chung hay production. Bật lại đúng container đó mà không khởi động lại Nest; ready phải về 200, hai GET liên tiếp trả store rồi cache với title mới nhất.
Redis cache-aside, Redis EXPIRE, Chính sách loại key, node-redis, Next.js updateTag.
Producer ghi job vào BullMQ/Redis; worker nhận rồi thực hiện. POST không phải chờ dịch vụ email hay xử lý ảnh, nhưng xác nhận đưa vào queue KHÔNG chứng minh email đã gửi. BullModule.forRoot tạo kết nối, registerQueue đặt tên book-emails, @InjectQueue dành cho producer, còn @Processor/WorkerHost xử lý job. Trong fixture, processor chạy cùng process API để dễ học; triển khai thật nên tách vòng đời worker để mở rộng và dừng độc lập.
Job thường đi từ waiting → active → completed; khi lỗi có thể qua delayed/backoff rồi quay lại waiting, hoặc hết lượt và thành failed. Concurrency giới hạn số việc xử lý song song; rate limit bảo vệ dịch vụ phía sau, không bảo vệ bước ghi database. Job có lịch trì hoãn chỉ được chạy từ thời điểm đã định; lịch lặp cần chính sách nhận diện và dọn dẹp riêng. Chúng khác với retry khi lỗi. Fixture dùng hai lượt thử, nghỉ cố định 250 ms và concurrency 2, chưa cấu hình rate limit hay lịch lặp.
POST /api/books -> BooksService.save() -> kho memory -> Queue.add(jobId=notify-3-1)
|
Redis waiting -> WorkerHost giữ khóa -> active -> completed
| lỗi -> delayed(250ms) -> active -> failed (2 lượt)
MySQL + outbox (CHƯA có): BEGIN -> ghi sách + hàng outbox -> COMMIT
dispatcher -> Queue.add -> worker -> tác động chống lặp| Vấn đề | Cách xử lý và giới hạn |
|---|---|
| Thử lại/nghỉ giữa lượt | Lỗi tạm thời được thử lại; dữ liệu sai phải bị chặn trước khi tạo job. |
| ID job | notify-<sách>-<phiên bản> chặn tạo trùng một lần sửa; job đã hoàn thành còn lưu sẽ chặn tái dùng ID đó. |
| Giữ lịch sử | removeOnComplete:false giữ bằng chứng nhưng tốn RAM Redis; hệ thống thật cần giới hạn số lượng/thời gian. |
| Bảo đảm thực thi | Xử lý ít nhất một lần có thể tạo tác động hai lần; không thể hứa email ngoài hệ thống chỉ gửi đúng một lần. |
Worker có thể chết sau khi gửi thông báo nhưng trước khi đánh dấu job hoàn thành; khóa hết hạn và job được chạy lại. Job ID của BullMQ giúp tránh tạo hai job giống nhau, không ngăn tác động ngoài hệ thống lặp lại. Với email, dùng mã thông điệp ổn định nếu nhà cung cấp hỗ trợ idempotency, hoặc ghi nhận trạng thái/claim trong kho dữ liệu gốc bằng thao tác nguyên tử; với ảnh, dùng object key xác định và thay thế an toàn. Queue không bảo đảm tác động ngoài Redis xảy ra đúng một lần.
Fixture ghi sách vào memory RỒI mới tạo job. Nếu Redis lỗi giữa hai bước, sách đã tồn tại nhưng POST trả 503 nhanh, không treo; người dùng retry HTTP mù quáng có thể ghi lại. Với Prisma/MySQL, ghi sách và hàng outbox trong CÙNG transaction để khép khoảng hở; dispatcher thử đưa job vào queue, worker chống tác động trùng. Outbox thực tế cần trạng thái, cơ chế lease và xử lý lại, chưa được triển khai ở đây. Khi dừng, ngừng nhận job mới, chờ tác vụ đang chạy trong khoảng giới hạn rồi đóng kết nối BullMQ; theo dõi số job stalled, waiting, delayed, failed và lý do lỗi. Bull Board chỉ là dashboard tùy chọn, không thay cảnh báo hay kiểm soát truy cập.
Đã đối chiếu lil.p origin/main, chỉ đọc: không thấy BullMQ queue, processor hay kết nối Redis cho job trong package/source API đã kiểm. Route liên hệ và media không chứng minh có worker email/ảnh. Queue và thông báo ở chương này chỉ nằm trong docs/examples/backend-next/; không gửi email thật.
Từ chương 8: thêm BullModule.forRoot({connection}), BullModule.registerQueue({name:"book-emails"}) trong src/app.module.ts và đăng ký NotificationProcessor. Trong src/books.service.ts, inject @InjectQueue("book-emails"), tạo job từ save() với ID sách và số lần sửa tăng dần. Thêm src/notification.processor.ts kế thừa WorkerHost, src/inspect.ts để xem trạng thái job, dependency @nestjs/bullmq/bullmq và lockfile. Zod và route HTTP không đổi. Worker tách process, outbox có transaction và gửi thông báo thật vẫn là việc học tiếp. Producer dùng kết nối riêng, không xếp lệnh offline và giới hạn enqueue 900 ms; worker vẫn dùng maxRetriesPerRequest:null để chờ kết nối lại. Timeout không chứng minh Redis chưa nhận job: đối soát theo business key trước khi thử lại.
// src/books.service.ts (trích đoạn)
await queue.add("notify", { bookId, revision, title: book.title, requestId }, {
jobId: `notify-${bookId}-${revision}`, attempts: 2,
backoff: { type: "fixed", delay: 250 },
removeOnComplete: false, removeOnFail: false,
});
// src/notification.processor.ts: @Processor("book-emails") kế thừa WorkerHost.Quan sát trên bản chạy thử sạch: POST sách 3 DDD → HTTP 201, id 3 và notify-3-1 completed. PATCH sách 3 DDD revised → HTTP 200, id 3 và notify-3-2 completed. Số phiên bản trong jobId cho phép tạo job cho lần sửa thứ hai; nếu dùng notify-3 với removeOnComplete:false, lần tạo thứ hai bị bỏ qua khi job đầu còn lưu. POST sách 4 FAIL_DEMO → HTTP 201, id 4; notify-4-1 failed sau 2 lượt với lý do forced teaching failure. Không gửi email và chưa có dead-letter queue do ứng dụng quản lý. Trong lần thử mất Redis của rev2, POST và PATCH đều trả 503 trong 0.001480/0.002207 giây; bật lại cùng container, POST khác trả 201. Kho memory này không có bảo đảm giao dịch cùng job.
- Waiting mãi không hết? Kiểm đăng ký worker và kết nối Redis; processor fixture chạy cùng API, worker thật cần process và quy trình dừng riêng.
- Job completed nhưng không có email? Processor chỉ ghi
would notify; chỉ nối dịch vụ thật sau khi có quyền và mô hình trạng thái gửi. - PATCH không tạo job mới? Kiểm jobId và job completed còn lưu; dùng ID theo phiên bản sửa và chống lặp tác động ngoài hệ thống.
- Job failed lặp lại? Xem
failedReason,attemptsMade; không thử mãi dữ liệu sai vĩnh viễn; thiết kế cảnh báo, replay hoặc DLQ riêng. - API trả 503 sau khi ghi sách? Tạo job lỗi sau khi ghi memory; đối soát theo business key rồi thiết kế outbox transaction khi chuyển sang MySQL. Đừng tự động thử lại khi enqueue hết giờ: Redis có thể đã nhận job trước thời điểm timeout.
- Vì sao 201 không bảo đảm thông báo? POST chỉ xác nhận ghi job; worker vẫn có thể lỗi hoặc chết. Công khai trạng thái gửi riêng và đo tỉ lệ lỗi.
- BullMQ có bảo đảm chỉ xử lý một lần? Không: khóa có thể hết hạn sau tác động ngoài rồi job chạy lại. Dùng business ID ổn định để chống tác động trùng.
- Vì sao dùng outbox thay ghi DB rồi Queue.add? Hai hệ thống không commit chung được; hàng outbox nằm cùng transaction MySQL với sách và dispatcher có thể thử lại.
- Theo dõi gì? Số waiting/delayed/failed, tuổi job lâu nhất, số lượt thử và lý do lỗi; giới hạn giữ job để bảo vệ RAM Redis, đặt ngưỡng cảnh báo theo tải thực tế.
- Dùng lệnh README trên bản sao sạch, POST
DDDrồi PATCH id 3 thànhDDD revised. Xem trạng thái bằngREDIS_PORT=6387 node dist/inspect.js notify-3-1 notify-3-2. Cả hai job phải completed; nếu không, tìm nguyên nhân, không kết luận đã gửi email. - POST
FAIL_DEMOthành sách 4; chờ qua 250 ms rồi chạyREDIS_PORT=6387 node dist/inspect.js notify-4-1. Điều kiện đạt làfailed, attemptsMade=2, reason=forced teaching failure(tổng số job có thể khác nếu Redis đã chạy lab trước). Sau đó theo lệnh README hoặc chạysh test/redis-outage.shchỉ với container Redis thử nghiệm: POST và PATCH đều phải trả 503 dưới hai giây; bật lại đúng container đó và xác nhận readiness hồi phục. - Vẽ ghi sách → outbox → dispatcher → queue → worker chống lặp và chỉ rõ bước nào cùng transaction. Đây là điều kiện đạt về thiết kế, không phải tuyên bố fixture đã chạy outbox.
Nest BullMQ, Job BullMQ, Job ID, Retry và backoff, Job stalled, Giới hạn lưu job.
Log có cấu trúc là một sự kiện với các trường dữ liệu; metric tổng hợp số đo qua nhiều request; trace nối các span giữa các thành phần. Ví dụ GET /api/books?locale=en: log ghi {requestId:"local-health", statusCode:200, source:"store"}, bộ đếm tăng http_requests_total{route:"/api/books",status:"200"}, còn các span thể hiện HTTP handler → Redis GET → đọc kho gốc. Request ID giúp tìm log, không phải trace ID; một log đơn lẻ cũng không tạo được phân bố độ trễ. Fixture chỉ ghi log Pino; metric và trace ở đây là thiết kế minh họa, CHƯA có output thật.
Dùng debug cho điều tra nhiều chi tiết, info cho sự kiện bình thường, warn khi suy giảm nhưng còn xử lý được và error khi lỗi cần can thiệp. Ghi mẫu route, status, thời gian, request/job ID và loại lỗi; không ghi toàn bộ body, header, mật khẩu, cookie, bearer token hay nguyên đối tượng lỗi từ bên ngoài. Không dùng ID có quá nhiều giá trị làm nhãn metric; hãy để trong log hoặc trace được lấy mẫu. Thời hạn lưu, tỉ lệ lấy mẫu, quyền truy cập và chính sách xóa cũng quan trọng như khâu ghi log.
browser --x-request-id--> Nest/Pino --requestId trong job.data--> log worker BullMQ
| | |
log HTTP cảnh báo Redis/cache jobId, requestId
probe: /api/health/live = process còn sống; /api/health/ready = nối được Redis
chưa chạy: W3C traceparent -> HTTP span -> worker span -> exporter OTLP -> collector| Tín hiệu/probe | Câu hỏi được trả lời |
|---|---|
| Log request Pino | Request nào lỗi, status và thời gian bao nhiêu? Phải che thông tin đăng nhập. |
| Metric (chưa chạy) | Bao nhiêu phần trăm request lỗi? Tổng hợp nhãn ít giá trị theo thời gian. |
| Trace (chưa chạy) | Span phía sau nào tốn thời gian? Cần truyền context và exporter. |
| Live / ready / startup | Khởi động lại process chết / ngừng đưa traffic / chờ khởi tạo chậm. |
Cấu hình nestjs-pino trong src/app.module.ts che req.headers.authorization, req.headers.cookie và res.headers["set-cookie"]. genReqId chỉ nhận ID đầu vào dài 1–64 ký tự hợp lệ khi đến từ loopback trong fixture; request khác nhận UUID mới để tránh giả mạo chuỗi log. Ở production phải xác định proxy nào đáng tin và kiểm mã; không tin chuỗi bất kỳ do client gửi. POST đưa request.id vào job.data.requestId để NotificationProcessor ghi cùng mã, dù job chạy sau response. Cấu hình che của Pino không bảo vệ nếu bạn ghi bí mật dưới một trường khác.
Liveness chỉ hỏi process còn chạy nên handler không phụ thuộc Redis. Readiness hỏi instance có nên nhận traffic: fixture PING Redis với giới hạn probe 500 ms và trả HTTP 503 khi lỗi. Startup probe cho quá trình khởi tạo chậm thêm thời gian trước khi áp liveness; đó là cấu hình của hệ điều phối, không phải endpoint khác trong fixture. Readiness production phải phản ánh dependency cần thiết và chế độ suy giảm được chọn: lab vẫn trả GET từ memory khi ready=503. Đừng khởi động lại mọi process khỏe chỉ vì dependency mất kết nối ngắn hạn. Cache client tự nối lại với backoff có mức trần; PING và lệnh đọc được giới hạn thời gian, nên readiness có thể về 200 khi Redis phục hồi mà không cần khởi động lại Nest.
OpenTelemetry tạo span, truyền context bằng header W3C traceparent và xuất qua OTLP đến collector/backend. Worker cần đưa context qua job data hoặc qua lớp instrumentation phù hợp; chỉ có request ID chưa đủ truyền trace. Phác thảo nối tối thiểu, CHƯA chạy: khởi tạo NodeSDK trước khi import app Nest, cấu hình getNodeAutoInstrumentations() cùng OTLP trace exporter, rồi đóng SDK lúc tắt. Với Sentry, cần khởi tạo tích hợp Nest, lọc/che dữ liệu nhạy cảm trong lỗi và thử gửi trong môi trường dùng một lần; fixture chưa cài hay chạy SDK, exporter, collector hoặc Sentry DSN.
// Chỉ là phác thảo, fixture CHƯA cài/chạy.
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_ENDPOINT }),
instrumentations: [getNodeAutoInstrumentations()],
});
await sdk.start(); // trước khi import entrypoint Nest
// Truyền trace context qua queue một cách tường minh.Đã đối chiếu lil.p origin/main, chỉ đọc: apps/api/src/common/middleware/request-id.middleware.ts nhận lại x-request-id đầu vào hoặc tạo UUID rồi đưa vào response; apps/api/src/modules/health/health.controller.ts kiểm MySQL bằng SELECT 1. Đó không phải chính sách chỉ tin loopback hay kiểm Redis readiness của lab. Không thấy Pino, OpenTelemetry, Sentry được nối vào source/dependency API đã kiểm; đây chỉ là nội dung học.
Từ chương 8, thêm nestjs-pino, pino, pino-http và lockfile; cấu hình LoggerModule.forRoot({pinoHttp:{redact, genReqId}}) trong src/app.module.ts, dùng Logger qua app.useLogger trong src/main.ts. Thêm HealthController cho /api/health/live và /api/health/ready tại đó; đưa request ID từ controller qua BooksService.save() đến NotificationProcessor. src/redis-cache.service.ts cấp hàm PING. Chỉ Pino và các probe này đã chạy local.
// src/app.module.ts (trích đoạn; xem hàm đầy đủ trong fixture)
redact: { paths: ["req.headers.authorization", "req.headers.cookie",
"res.headers[\"set-cookie\"]"], censor: "[REDACTED]" },
genReqId(request, response) {
const incoming = request.headers["x-request-id"];
const remote = request.socket.remoteAddress;
const local = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
const id = local && typeof incoming === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(incoming) ? incoming : randomUUID();
response.setHeader("x-request-id", id);
return id;
}Quan sát ở lab local dùng một lần: GET /api/health/ready với x-request-id: local-health trả 200 và phản hồi local-health; Pino ghi log JSON dùng cùng ID. Header authorization và cookie trong log chỉ hiện [REDACTED]. POST DDD chuyển request ID đến log worker của notify-3-1 completed. Sau khi dừng Redis, /api/health/live trả 200, /api/health/ready 503 và GET sách 200 từ kho gốc, mỗi request dưới 3 giây. Chưa quan sát metric, trace phân tán, sự kiện Sentry hay probe production. Trong lần chạy rev2, readiness về 200 trên cùng process sau khi bật lại Redis.
- Request ID khác giữa API và worker? Kiểm
request.idđã vào job.data và worker ghi job.data.requestId; context AsyncLocalStorage của Pino không tự qua Redis. - Log lộ bí mật? Che các đường dẫn header đã biết và kiểm payload log tự viết; che header không tự che mọi object.
- Ready 503 nhưng live 200? Đúng khi Redis lỗi; kiểm PING/kết nối và quyết định ngừng chuyển traffic, không khởi động lại process còn chạy. Nếu Redis đã chạy lại mà ready vẫn 503, kiểm tra backoff và trạng thái cache client; fixture phải hồi phục mà không cần khởi động lại API.
- Không có trace ở worker? Kiểm truyền
traceparentqua job và cấu hình exporter/collector; fixture chưa có nên ID trong log không phải trace. - Nhãn metric có quá nhiều giá trị? Bỏ requestId/jobId khỏi nhãn, giữ trong log và span lấy mẫu; tổng hợp theo route/status.
- Gỡ một request chậm ra sao? Tìm request ID trong log đã che dữ liệu, xem thời gian và kết quả, rồi dùng trace được lấy mẫu để xác định span chậm; metric cho biết lỗi có lan rộng không. Fixture chỉ cung cấp log.
- Request ID có phải trace ID? Không. Nó là mã liên kết log của ứng dụng; W3C trace ID nằm trong span context được truyền qua dịch vụ và cần exporter để xem.
- Vì sao ready 503 mà GET vẫn được? Ready thể hiện chính sách về dependency, không nói mọi route đã hỏng; GET fallback được nhưng ghi có queue nên cần tách khỏi traffic.
- Cần che dữ liệu nào? Thông tin đăng nhập, authorization/cookie, PII và toàn bộ request body. Đường dẫn che của Pino là lớp bảo vệ thêm; trước hết thiết kế log không thu dữ liệu nhạy cảm.
- Khi Redis chạy, GET
/api/health/readyvớix-request-id: local-healthvà header authorization/cookie giả. Kiểm HTTP 200 và ID phản hồi; trong JSON Pino phải có cùng ID và[REDACTED], không có giá trị header giả. - Chỉ dừng Redis của lab: GET live=200, ready=503 (
status:"unready"), books=200 (source:"store") dưới 3 giây mỗi request. Phân biệt process còn sống và instance chưa sẵn sàng. Bật lại cùng container thử nghiệm, xác nhận ready=200 mà không khởi động lại API, rồi GET hai lần để thấy store và cache. - Trên giấy, phác thảo log, metric với nhãn ít giá trị và trace HTTP/Redis/worker cho một request. Ghi rõ metric/trace chưa chạy; không nói Sentry đã cấu hình.
Logger Nest, nestjs-pino, Che dữ liệu Pino, Health checks Nest, OpenTelemetry JS, W3C Trace Context, Sentry NestJS.
Tích hợp liên tục (CI) kiểm từng thay đổi và cung cấp kết quả có thể lặp lại cho người review. Continuous delivery chuẩn bị artifact để phát hành sau khi được duyệt riêng; continuous deployment tự phát hành mọi thay đổi đạt điều kiện. CI xanh không phải quyền deploy. Workflow của fixture chỉ chạy khi có pull request; không phát hành image, không truy cập database hosted hay deploy. npm run lint chạy ESLint, npm run typecheck chạy tsc --noEmit, npm test chạy Vitest/Supertest, npm run build biên dịch Nest. docker build kiểm image nhiều giai đoạn chạy không phải root có thể build.
Workflow gồm điều kiện chạy (on), job, các bước có thứ tự và service container tùy chọn. GitHub lấy đúng commit, dùng matrix Node 22, cache gói npm, chạy npm ci theo lockfile, kiểm lint/kiểu/test/build rồi build Docker. permissions: contents: read hạn chế quyền token; pin action bằng SHA giảm nguy cơ bị đổi mã, nhưng vẫn phải review lockfile. concurrency có thể hủy lượt kiểm PR đã cũ. Không in bí mật hay chạy mã PR chưa tin cậy cùng thông tin production. Bảo vệ môi trường và phê duyệt nằm ở workflow phát hành riêng, không phải gate PR này.
PR -> checkout(pin SHA) -> npm ci -> ESLint -> tsc --noEmit -> Vitest/Supertest
-> nest build -> dist/main.js -> Docker build
DỪNG: chỉ review, không migrate/deploy
SAU khi duyệt riêng (chưa chạy): image -> migration mở rộng -> phát hành -> thu gọn sau| Giai đoạn | Chứng minh điều gì và không chứng minh điều gì |
|---|---|
| ESLint + TypeScript | Quy tắc tĩnh và kiểu dữ liệu; không nói Redis lúc chạy còn khỏe. |
| Vitest/Supertest + Redis | Route, quyền trước Zod và cache trong lab; không đo hiệu năng hosted. |
| MySQL service | Có sẵn cho e2e Prisma về sau; fixture dùng memory và KHÔNG truy vấn MySQL. |
| Docker image | Build được image; không xác minh phát hành, migration, môi trường hay readiness production. |
Thay schema có thể làm phiên bản ứng dụng cũ đang chạy bị lỗi. Ví dụ expand/contract với Prisma: migration A thêm Book.subtitle cho phép null; phát hành mã đọc được cả null và giá trị mới rồi điền dữ liệu cũ theo lô. Phát hành mã ghi mới, kiểm metric, chỉ trong migration B được duyệt riêng sau đó mới bắt buộc non-null hoặc bỏ cột cũ khi các instance cũ đã thoát. Tạo và review SQL trong môi trường phát triển bằng prisma migrate dev; khi phát hành đã duyệt, chạy prisma migrate deploy đúng một lần cho môi trường mục tiêu trước khi chạy mã cần schema mới. Fixture không chứa migration hay nối database.
Nếu migration lỗi, dừng phát hành và tìm nguyên nhân; không đảo ngược mù quáng một migration làm mất dữ liệu. Rolling thay từng instance nên schema phải tương thích cả phiên bản cũ/mới. Blue-green chuyển traffic giữa hai nhóm đầy đủ và chỉ quay lại được khi dữ liệu/schema còn tương thích. Canary đưa một phần nhỏ traffic sang mã mới, chỉ tăng dần nếu sức khỏe và tỉ lệ lỗi đạt yêu cầu. Quay lại mã ứng dụng không tự quay lại dữ liệu database; khôi phục hay sửa database production cần duyệt riêng và kế hoạch phục hồi đã thử.
Đã đối chiếu lil.p origin/main, chỉ đọc: .github/workflows/ci.yml cấu hình format/lint/kiểm tra kiểu, Vitest, build và e2e MySQL 8.4 có điều kiện; apps/api/docker-entrypoint.sh gọi prisma migrate deploy trước khi khởi động API. Đây là cấu hình, không chứng minh lượt deploy cụ thể đã thành công. Workflow Redis/BullMQ/Pino trong chương chỉ thuộc fixture học tập; chưa thử migration production, phát hành Render/Vercel, kết nối TiDB hay readiness hosted.
So với app memory ở chương 8, docs/examples/backend-next/package.json bổ sung các lệnh chạy được: eslint ., tsc --noEmit, vitest run và nest build. Thêm eslint.config.mjs, tsconfig.json, tsconfig.build.json, vitest.config.ts, test/books.e2e.test.ts, package-lock.json, Dockerfile nhiều giai đoạn chạy không phải root và .github/workflows/books-ci.yml BÊN TRONG fixture. Muốn chạy trong repo học tập độc lập thì chuyển workflow vào .github/workflows/ ở root; nằm dưới docs/examples/ nên nó KHÔNG kích hoạt GitHub Actions của content repo. MySQL service là ví dụ chuẩn bị cho phần học tiếp; e2e dùng memory KHÔNG truy vấn nó.
YAML đầy đủ: `docs/examples/backend-next/.github/workflows/books-ci.yml` (chép nguyên vào root repo Nest lab riêng). Action pin SHA; không có job deploy.
name: Books review gate (toy repository only)
on: [pull_request]
permissions:
contents: read
concurrency:
group: books-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
node: ["22"]
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
options: >-
--health-cmd="redis-cli ping" --health-interval=2s
--health-timeout=3s --health-retries=15
mysql:
image: mysql:8.4
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: reading_test
ports: ["3307:3306"]
options: >-
--tmpfs /var/lib/mysql
--health-cmd="mysqladmin ping -h 127.0.0.1"
--health-interval=2s --health-timeout=5s --health-retries=40
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci --no-audit --no-fund
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
- run: test -f dist/main.js
- run: docker build -t reading-api:ci .Quan sát local trên bản sao dùng một lần (Node 22.21.1): npm ci, ESLint, tsc --noEmit và nest build đạt; có dist/main.js. Vitest/Supertest qua 2/2 test e2e; build Docker nhiều giai đoạn thành công và image chạy bằng user node. Bản workflow GitHub Actions của fixture, tích hợp MySQL service, migration Prisma và mọi lượt phát hành/deploy đều CHƯA chạy. Check PR #66 sẽ được báo riêng sau khi đẩy bản sửa này, không suy ra từ kết quả local.
npm cibáo lockfile không khớp? Tạo lại lockfile đã review trong fixture, commit cùng package.json rồi chạy lại; không đổi CI sang cài gói không khóa phiên bản.- E2E không nối Redis? Chờ service khỏe, kiểm host/port (job dùng localhost với cổng đã map) và tránh key Redis còn sót giữa các test.
- MySQL báo khỏe mà test không truy vấn? Đúng với fixture memory; thêm Prisma service và migration thật trước khi nói có kiểm thử database.
- Build xong thiếu
dist/main.js? Kiểm đường dẫn xuất của Nest CLI và tsconfig exclude; build image không sửa được entrypoint biên dịch sai. - Migration lỗi khi phát hành? Dừng triển khai, kiểm SQL, khóa và lịch sử; không chạy down migration làm mất dữ liệu khi chưa có kế hoạch phục hồi được duyệt riêng.
- Delivery khác deployment thế nào? Delivery giữ artifact đã kiểm ở trạng thái sẵn phát hành với bước duyệt; deployment tự phát hành thay đổi đạt gate. CI xanh không tự cho phép cả hai.
- Vì sao migration trước khi khởi động app? Mã mới có thể cần cột mới, nhưng mã cũ phải còn chạy trên schema giai đoạn mở rộng. Chạy migrate deploy đã duyệt đúng một lần và dừng rollout nếu lỗi.
- Docker build có chứng minh phát hành an toàn? Không. Nó chỉ xác nhận tạo image; cấu hình môi trường, mạng, probe, migration dữ liệu và traffic thật vẫn chưa được kiểm.
- Quay lại thế nào nếu canary lỗi? Chuyển traffic về mã cũ còn tương thích và kiểm dữ liệu; không mặc định down migration an toàn. Phục hồi database production cần duyệt riêng.
- Trên bản sao mới, chạy
npm ci --no-audit --no-fund,npm run lint,npm run typecheck,npm test,npm run build,test -f dist/main.jskhi Redis thử nghiệm đang chạy. Cần mọi lệnh exit 0 và Vitest đạt 2/2 e2e, không phải script giảnode --check. - Build
docker build -t reading-api:ci ., xemdocker image inspect reading-api:ci --format "{{.Config.User}}": phải lànode. YAML nằm trong docs để học, không được CI của content repo thực thi. - Review kế hoạch expand/contract cho
subtitlecho phép null: nêu migration A, bước điền dữ liệu và migration B, ai sẽ chạyprisma migrate deploysau khi duyệt, vì sao quay lại mã không đồng nghĩa quay lại DB. Đây là bài thiết kế trên giấy, fixture KHÔNG chạy migration.
GitHub Actions Node CI, Service container, Cú pháp workflow, Prisma migrate deploy, Docker nhiều giai đoạn, Kiểm thử Nest.
TiDB tương thích MySQL (mục tiêu cấu hình, chưa xác minh đang chạy). TiDB nói giao thức gần với MySQL, nên Prisma MySQL connector có thể dùng, nhưng không bảo đảm mọi hành vi khóa InnoDB, mức cô lập hay DDL đều giống. README và render.yaml của portfolio mô tả TiDB Cloud là mục tiêu production, prisma.config.ts ghi TLS qua sslaccept=strict; file trong repo không chứng minh đã có cluster cloud hoạt động. DB từ xa còn có độ trễ mạng, TLS, giới hạn kết nối và khác biệt migration. Học sau SQL local: so cùng một truy vấn sách và transaction trên môi trường TiDB dùng một lần chỉ sau khi được duyệt riêng, không dùng dữ liệu production. Tự hỏi: vì sao SQL qua local nhưng lỗi ở cloud? Có thể do cú pháp hỗ trợ khác, mạng/auth/TLS, cạn pool hay hành vi transaction. TiDB với Prisma và tương thích.
Tải tệp và Cloudinary (đã có triển khai cùng nhánh dự phòng). apps/api/src/modules/media/admin-media.controller.ts dùng Multer lưu tạm trong bộ nhớ và giới hạn kích thước; media-storage.ts chỉ chọn Cloudinary khi có cấu hình, nếu không dùng MemoryMediaStorage. Chỉ cài dependency chưa đủ khẳng định nhánh cloud đang chạy. Luồng upload thật phải xác thực/phân quyền trước khi nhận byte, chặn kích thước, kiểm cả MIME và chữ ký file, làm sạch/đổi tên object key, không tin tên file phía client. Giữ file trong bộ nhớ tốn bộ nhớ theo số upload đồng thời; streaming hoặc upload ký trực tiếp giảm tải API nhưng phức tạp hơn. Cloudinary có thể biến đổi/đưa ảnh tới người dùng; nhánh in-memory mất dữ liệu sau khi khởi động lại. Tập với ảnh giả nhỏ và storage mock, thử file quá lớn/sai loại bị từ chối mà không lưu; không dùng ảnh riêng tư. Nest upload, Cloudinary Node.
Swagger/OpenAPI (đã nối, chỉ ngoài production). apps/api/src/common/swagger/setup-swagger.ts được main.ts gọi khi không phải production; chỉ thấy dependency @nestjs/swagger chưa đủ kết luận. OpenAPI mô tả đường dẫn, schema request/response và mã lỗi để người đọc khám phá hay sinh client. Nó là tài liệu, không tự kiểm tra request lúc chạy: Zod schema đổi nhưng metadata OpenAPI có thể vẫn cũ. Hãy tự so contract POST với mô tả 400/401/403, dùng credential giả trong ví dụ và không mặc định /api/docs mở ở production. Nest OpenAPI, đặc tả OpenAPI.
Cache, log và làm mới (các lớp nằm ở source khác nhau). Cache trình duyệt/CDN giảm số lượt đọc nhưng có thể trả dữ liệu cũ sau khi chỉnh nội dung công khai; không cache response cá nhân có auth dưới chế độ public. Ở portfolio, apps/web/src/modules/admin-core/actions/revalidate-public.action.ts làm mất hiệu lực cache tag công khai sau chỉnh sửa admin thành công; phía API có LoggingInterceptor và RequestIdMiddleware để lần theo request và thời gian xử lý. Request ID giúp nối log, không phải secret; cần che cookie, token, mật khẩu và dữ liệu cá nhân. Phân biệt memoization theo request của React, cache public của Next và query cache client: xóa một lớp không tự xóa hai lớp kia. Thử cache danh sách sách công khai, sửa tiêu đề, quan sát bản cũ, xóa đúng key rồi kiểm tra lượt đọc tiếp; nội dung có auth giữ no-store. Next caching, Nest interceptor.
Triển khai và sao lưu (chỉ học, không thực hiện). README, render.yaml, vercel.json và Dockerfile API mô tả web Vercel → API Render → DB TiDB Cloud, cùng media Cloudinary; cấu hình chưa chứng minh hệ thống đang chạy. Luồng triển khai xây image cố định, cấp cấu hình lúc chạy, áp dụng migration đã review, kiểm tra readiness rồi mới chuyển traffic. Đổi schema cần tính tương thích giữa tiến trình cũ/mới khi chúng cùng tồn tại. Backup cần thời hạn lưu, nơi lưu được bảo vệ và buổi diễn tập khôi phục trên DB biệt lập; có file backup mà chưa thử restore chưa thể gọi là kế hoạch phục hồi. Git revert không đồng nghĩa rollback database: đảo schema/data có thể xóa nội dung và luôn phải được duyệt riêng. Hãy vẽ phụ thuộc và một tình huống lỗi (DB timeout, media hỏng hoặc cache cũ), nêu cách phát hiện/quyết định hoàn tác mà không deploy, đọc credential hay thay đổi production. Render Docker, Vercel deploy, MySQL backup.
Sau đó mới tìm hiểu kiến trúc production: TiDB Cloud tương thích MySQL đến đâu, TLS và giới hạn kết nối; đường đi từ Vercel web tới Render API; tải tệp lên Cloudinary và nhánh lưu tạm trong bộ nhớ khi dev; log có cấu trúc, request ID, sự kiện audit, theo dõi hệ thống, giới hạn tần suất và làm mới cache. Đây là ràng buộc đáng học, không phải điều kiện để viết CRUD API local đầu tiên. Đọc thêm DevOps và Monitoring.
Bài tổng kết: Trình bày API nhỏ từ đầu đến cuối: web fetch với loading/error, kiểm tra dữ liệu đầu vào/ra dùng chung, Nest route và service, truy vấn MySQL có index qua Prisma, route ghi có phân quyền, unit/E2E test chạy đạt. Giải thích một lỗi ở mỗi ranh giới và ghi rõ phần nào chưa deploy hay xác minh. Không cần thông tin đăng nhập production hoặc rollback.
Nền tảng và HTTP: Node.js async I/O, NestJS controllers, NestJS providers, NestJS pipes.
Hợp đồng dữ liệu và lưu trữ: Zod cơ bản, tài liệu MySQL 8.4, Prisma 7 MySQL connector, hướng dẫn kết nối TiDB.
Kiểm thử và vận hành: Vitest, Supertest, NestJS testing, Docker Compose, Render Docker, Cloudinary Node uploads.