Phần 1 · Chương 1.4
Bộ khung biết đi
Bạn sẽ tạo ra: Hai service bộ khung chạy được — có health check, request ID, log JSON có cấu trúc · khoảng 90 phút, bao gồm bài tập
Tài liệu gốc: SRS — Đặc tả yêu cầu phần mềm · SAD — Tài liệu kiến trúc phần mềm (tiếng Anh)
Ba chương đầu của Phần 1 xây toàn những thứ người dùng không bao giờ nhìn thấy: một workspace, bốn store, một bản giao kèo. Hôm nay có thứ biết trả lời. Chúng ta dựng dậy hai trong sáu service của Relay — API service và gateway — dưới dạng một bộ khung biết đi: không logic nghiệp vụ, không kết nối store, không có gì để đem đi demo. Thứ chúng có là tất cả những gì đội vận hành sẽ cần vào ngày tồi tệ nhất: một health check, một request ID trên mọi phản hồi, và những dòng log có cấu trúc để một lệnh grep kể trọn câu chuyện của một request. Dựng bộ khung trước, thêm chi tiết sau — vì cấy hệ thần kinh vào một cơ thể đã trưởng thành là một ca đại phẫu.
Vì sao là hai service này trước
Góc nhìn service của bản SAD gọi tên sáu service triển khai được, và đánh dấu đúng hai trong số đó là Phase 1. Cặp đôi ấy không hề ngẫu nhiên — nó là phép phân công lao động trung tâm của cả kiến trúc, được quyết từ những bản ADR ta đã đọc ở 0.5 và trích nguyên văn từ chính bảng trách nhiệm §4.1. API service "owns all REST semantics" — nắm trọn ngữ nghĩa REST — và là "the only service that writes to PostgreSQL — a deliberate single-writer discipline" (service duy nhất ghi vào PostgreSQL — một kỷ luật một-người-ghi có chủ đích, ADR-04). Gateway "terminates WebSockets" — tiếp nhận WebSocket — và chuyển các lệnh ghi qua HTTP nội bộ: "the gateway never writes to the database" (gateway không bao giờ ghi vào database, ADR-05).
Observability từ dòng đầu tiên
Dòng kế hoạch của loạt bài trao cho chương này nửa còn lại: health check, request ID, log có cấu trúc. Mỗi thứ là một yêu cầu, không phải một thói quen.
Request ID là EIR-API-05, trích đủ vì mệnh đề nào cũng có việc của nó:
"Every response shall include a unique X-Request-Id header, referenced in
all error responses and in the request log" — mọi phản hồi phải mang một
header X-Request-Id duy nhất, được nhắc lại trong mọi phản hồi lỗi và trong
log của request. Tài liệu ấn định tính duy nhất nhưng không ấn định định
dạng — chúng ta quyết crypto.randomUUID() và ghi lại.
Log có cấu trúc là NFR-OBS-01: "All services shall emit structured JSON
logs including request ID, tenant ID, and correlation ID." Đọc câu ấy một
cách trung thực thì hai trong ba trường chưa thể tồn tại — chưa có tenant nào
cho đến các đường dữ liệu của Phần 2, và chưa có gì để correlate khi chưa có
hơn một chặng nhảy (OpenTelemetry đến cùng NFR-OBS-02, về sau). Hôm nay chúng
ta log request_id thật, và ghi nhận hai trường kia là những khoản hoãn có
địa chỉ đến rõ ràng. Làm giả chúng còn tệ hơn là thiếu chúng.
Vì sao cần tất cả những thứ này trước cả khi có logic để quan sát? NFR-OBS-06: "Any customer-reported issue shall be traceable from a request ID to complete logs and traces within 5 minutes" — mọi sự cố khách hàng báo phải truy vết được từ một request ID đến trọn bộ log và trace trong vòng 5 phút. Lời hứa ấy đắt khủng khiếp nếu trang bị lại về sau và gần như miễn phí nếu bắt đầu từ ngày đầu — miễn là nó bắt đầu từ ngày đầu, tức là hôm nay.
Bản thân endpoint health là quyết định của chúng ta, có ghi lại:
GET /healthz, đúng cách viết mà các healthcheck compose của 1.2 đã dùng,
trả về { status: "ok", service, uptime_s }. Cổng cũng vậy: 4000 cho API
service, 4001 cho gateway, mỗi bên ghi đè được bằng PORT (3000 không thuộc
về thứ gì trong repo này — đó là nơi trang hướng dẫn bạn đang đọc đang sống).
flowchart TB
api["API service ✓ ĐANG ĐỨNG<br/>/healthz · X-Request-Id · log JSON<br/>(nắm REST; writer duy nhất của Postgres — ADR-04)"]
gw["Gateway service ✓ ĐANG ĐỨNG<br/>/healthz + bảng công bố protocol<br/>(tiếp nhận WebSocket; không bao giờ ghi DB — ADR-05)"]
whk["Webhook dispatcher<br/>(Phần 3 →)"]
ing["Analytics ingester<br/>(Phần 5 →)"]
mws["Media worker<br/>(Phần 4 →)"]
dash["Dashboard<br/>(Phần 5 →)"]
api ~~~ gw
whk ~~~ ing
mws ~~~ dashMột mái nhà cho đồ nghề chung
Cả hai service cần đúng ba món: logger, con dấu request ID, và phần dây điện health/404.
{
"name": "@relay/service-kit",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
}
}{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"erasableSyntaxOnly": true
},
"include": ["src"]
}Một dòng mới trong bản tsconfig quen thuộc: erasableSyntaxOnly bắt compiler
từ chối mọi cú pháp TypeScript không thể chỉ-xóa-đi mà để lại JavaScript hợp
lệ (enum, namespace). Giữ ý nghĩ đó lại — nó thuộc về một câu chuyện ta sẽ kể
nốt bên dưới. Tsconfig của hai service chính là file này, giống hệt từng
byte; chúng ta sẽ không in nó thêm hai lần.
import { randomUUID } from "node:crypto";
import { createServer, type Server } from "node:http";
// The operational plumbing every Relay service shares — ONE home, because
// the second copy is where drift starts (chapter 1.4's TRAP; 1.1's lesson
// applied to behavior instead of configuration).
//
// Logs are structured JSON, one object per line (NFR-OBS-01): request_id is
// real from day one; tenant_id and trace/correlation ids are recorded
// deferrals — they join when Part 2's data paths and NFR-OBS-02's tracing
// make them mean something.
export type LogSink = (line: string) => void;
const stdoutSink: LogSink = (line) => {
process.stdout.write(line + "\n");
};
export interface Logger {
log(level: "info" | "error", msg: string, fields?: Record<string, unknown>): void;
}
/** One JSON object per line; the sink is injectable so tests can assert log
* structure instead of scraping stdout — observability you can't test rots. */
export function createLogger(service: string, sink: LogSink = stdoutSink): Logger {
return {
log(level, msg, fields = {}) {
sink(
JSON.stringify({
time: new Date().toISOString(),
level,
service,
msg,
...fields,
}),
);
},
};
}
/** EIR-API-05 fixes uniqueness; the UUID format is chapter 1.4's decision. */
export function newRequestId(): string {
return randomUUID();
}
export interface ServeOptions {
service: string;
/** Extra fields merged into the /healthz payload. */
health: () => Record<string, unknown>;
logger?: Logger;
}
/** Build (but do not start) a service's HTTP server: every response carries
* X-Request-Id (EIR-API-05), every request logs exactly one structured line
* carrying the same id (NFR-OBS-06's grep-ability starts here), GET /healthz
* answers with the service's health payload, and unknown routes get the
* EIR-API-04 error shape. The docs_url host is a placeholder until the docs
* site exists — constitution V's reachable-page promise lands with it. */
export function serve(options: ServeOptions): Server {
const { service, health } = options;
const logger = options.logger ?? createLogger(service);
return createServer((req, res) => {
const requestId = newRequestId();
const path = req.url ?? "/";
res.setHeader("X-Request-Id", requestId);
res.setHeader("content-type", "application/json");
let status: number;
let body: unknown;
if (req.method === "GET" && path === "/healthz") {
status = 200;
body = { status: "ok", service, ...health() };
} else {
status = 404;
body = {
code: "not_found",
message: `no route for ${req.method ?? "?"} ${path}`,
docs_url: "https://relay.example/docs/errors/not_found",
};
}
res.statusCode = status;
res.end(JSON.stringify(body));
logger.log("info", "request", {
request_id: requestId,
method: req.method,
path,
status,
});
});
}Ba chi tiết xứng đáng với số dòng của chúng. Cái sink tiêm được từ ngoài vào,
vì phần log không test được sẽ mục ruỗng trong im lặng — các bài test của
chúng ta sẽ parse từng dòng log, không nheo mắt nhìn stdout. Thân của 404 là
khuôn lỗi EIR-API-04 — code, message, docs_url — kèm hai quyết định có
ghi lại: mã not_found sống cùng các service cho đến khi một chương API sở
hữu sổ mã REST riêng, và host của docs_url là chỗ giữ tạm cho đến khi trang
tài liệu ra đời để biến lời hứa trang-tra-cứu-được của constitution V thành
thật. Và serve dựng server nhưng không khởi động nó — chính khoảng tách ấy
cho phép các bài test khởi động trên một cổng vu vơ.
Hai service
Có bộ đồ nghề rồi, mỗi service chỉ còn vỏn vẹn một trang:
{
"name": "@relay/api",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@relay/protocol": "workspace:*",
"@relay/service-kit": "workspace:*"
},
"devDependencies": {
"tsx": "^4.23.1"
}
}import { createLogger, serve, type Logger } from "@relay/service-kit";
// The API service — SAD §4.1: owns all REST semantics and is the ONLY
// service that writes to PostgreSQL (ADR-04). At walking-skeleton stage it
// is deliberately empty: health, request ids, structured logs. The muscles —
// tenancy, channels, the message write path — grow onto this in Part 2, and
// its REST error envelope is already asserted (in the test beside this file)
// to match @relay/protocol's error payload shape: one error shape, one home.
export function createServer(logger?: Logger) {
return serve({
service: "api",
health: () => ({ uptime_s: Math.round(process.uptime()) }),
...(logger ? { logger } : {}),
});
}
if (import.meta.main) {
const port = Number(process.env.PORT ?? 4000);
const logger = createLogger("api");
createServer().listen(port, () => {
logger.log("info", "listening", { port });
});
}{
"name": "@relay/gateway",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@relay/protocol": "workspace:*",
"@relay/service-kit": "workspace:*"
},
"devDependencies": {
"tsx": "^4.23.1"
}
}import { CLOSE_CODES, frameSchema } from "@relay/protocol";
import { createLogger, serve, type Logger } from "@relay/service-kit";
// The gateway — SAD §4.1: terminates WebSockets and never writes to the
// database (ADR-05). At walking-skeleton stage no sockets exist yet; instead
// the gateway DECLARES the wire vocabulary it will speak, computed from
// @relay/protocol — never hardcoded, so the advertisement cannot drift from
// the contract. Sessions, JWT verification, and real frames arrive in Part 2.
const frames = frameSchema.options.map((option) => option.shape.type.value);
const closeCodes = Object.keys(CLOSE_CODES).map(Number);
export function createServer(logger?: Logger) {
return serve({
service: "gateway",
health: () => ({
uptime_s: Math.round(process.uptime()),
protocol: { frames, close_codes: closeCodes },
}),
...(logger ? { logger } : {}),
});
}
if (import.meta.main) {
const port = Number(process.env.PORT ?? 4001);
const logger = createLogger("gateway");
createServer().listen(port, () => {
logger.log("info", "listening", { port });
});
}Gateway làm đúng một việc vượt ra ngoài health: payload health của nó công
bố bộ từ vựng protocol mà nó sẽ nói — từng tên frame, từng close code, tính
ra lúc khởi động từ chính các export của @relay/protocol. Không một chuỗi
nào được gõ tay, nên bảng công bố không thể lệch khỏi bản giao kèo. 1.3 đã
hứa bộ khung sẽ không nói thứ tiếng nào ngoài package protocol; một bộ khung
rỗng chưa thể trò chuyện, nhưng nó có thể bày bộ từ vựng của mình ra tủ kính.
Dựng nó dậy
Hai cửa sổ terminal — công cụ trung thực ở quy mô này; một trình quản lý tiến trình là cỗ máy ta chưa cần:
pnpm --filter @relay/api devpnpm --filter @relay/gateway devGiờ tra khảo bộ khung:
curl -i localhost:4000/healthz
curl -s localhost:4001/healthz
curl -i localhost:4000/no-such-routeBa thứ đáng nhìn tận mắt. Header X-Request-Id trên mọi phản hồi — gọi hai
lần, nhận hai UUID khác nhau. Khối protocol của gateway điểm danh đủ mười
frame và bốn close code. Và trong terminal của mỗi service, đúng một dòng log
JSON cho mỗi request, với request_id khớp với header bạn vừa nhận — cặp đôi
ấy là NFR-OBS-06 thu nhỏ: cầm một id từ bất cứ đâu, grep tìm ra trọn câu
chuyện của request đó.
flowchart LR
req["curl /healthz"]
svc["service<br/>đóng dấu một UUID mới"]
header["header phản hồi<br/>X-Request-Id: 639c…e9a"]
logline["dòng log (stdout, JSON)<br/>{ …, request_id: 639c…e9a, status: 200 }"]
grep["grep 639c…e9a *.log<br/>→ trọn câu chuyện của một request<br/>(NFR-OBS-06: truy vết trong vài phút)"]
req --> svc
svc --> header
svc --> logline
header --> grep
logline --> grepNhững bài test canh chừng người gác
Các bộ test khởi động mỗi service trên cổng 0 — cổng vu vơ do hệ điều hành cấp — nên test không bao giờ va chạm với service đang chạy hay với nhau:
import { describe, expect, it } from "vitest";
import { createLogger, newRequestId } from "./index.js";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
describe("structured logger (NFR-OBS-01)", () => {
it("emits one valid JSON object per line with the required fields", () => {
const lines: string[] = [];
const logger = createLogger("test-svc", (line) => lines.push(line));
logger.log("info", "hello", { request_id: "r-1" });
expect(lines).toHaveLength(1);
const parsed = JSON.parse(lines[0]!) as Record<string, unknown>;
expect(parsed).toMatchObject({
level: "info",
service: "test-svc",
msg: "hello",
request_id: "r-1",
});
expect(Number.isNaN(Date.parse(parsed.time as string))).toBe(false);
});
it("keeps levels and extra fields intact through the sink", () => {
const lines: string[] = [];
const logger = createLogger("test-svc", (line) => lines.push(line));
logger.log("error", "boom", { status: 500 });
const parsed = JSON.parse(lines[0]!) as Record<string, unknown>;
expect(parsed.level).toBe("error");
expect(parsed.status).toBe(500);
});
});
describe("request ids (EIR-API-05)", () => {
it("are UUID-shaped and unique", () => {
const a = newRequestId();
const b = newRequestId();
expect(a).toMatch(UUID_RE);
expect(b).toMatch(UUID_RE);
expect(a).not.toBe(b);
});
});import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { errorFrameSchema } from "@relay/protocol";
import { describe, expect, it } from "vitest";
import { createLogger } from "@relay/service-kit";
import { createServer } from "./main.js";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const silent = createLogger("api", () => {});
function listen(server: Server): Promise<number> {
return new Promise((resolve) =>
server.listen(0, () => resolve((server.address() as AddressInfo).port)),
);
}
describe("api skeleton", () => {
it("answers /healthz with its shape and a fresh request id per response", async () => {
const server = createServer(silent);
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).toMatchObject({ status: "ok", service: "api" });
expect(typeof body.uptime_s).toBe("number");
const id1 = res.headers.get("x-request-id");
const id2 = (await fetch(`http://127.0.0.1:${port}/healthz`)).headers.get(
"x-request-id",
);
expect(id1).toMatch(UUID_RE);
expect(id2).toMatch(UUID_RE);
expect(id1).not.toBe(id2);
} finally {
server.close();
}
});
it("shapes its 404 exactly like the protocol's error payload (EIR-API-04)", async () => {
const server = createServer(silent);
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/no-such-route`);
expect(res.status).toBe(404);
const body: unknown = await res.json();
// One error shape, one home: the REST envelope must parse against the
// wire contract's error payload schema — alignment by construction.
const parsed = errorFrameSchema.shape.payload.safeParse(body);
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.code).toBe("not_found");
} finally {
server.close();
}
});
it("logs exactly one structured line per request, carrying the response's id", async () => {
const lines: string[] = [];
const server = createServer(createLogger("api", (line) => lines.push(line)));
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
expect(lines).toHaveLength(1);
const entry = JSON.parse(lines[0]!) as Record<string, unknown>;
expect(entry).toMatchObject({
service: "api",
msg: "request",
path: "/healthz",
status: 200,
});
expect(entry.request_id).toBe(res.headers.get("x-request-id"));
} finally {
server.close();
}
});
});import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { CLOSE_CODES, frameSchema } from "@relay/protocol";
import { describe, expect, it } from "vitest";
import { createLogger } from "@relay/service-kit";
import { createServer } from "./main.js";
const silent = createLogger("gateway", () => {});
function listen(server: Server): Promise<number> {
return new Promise((resolve) =>
server.listen(0, () => resolve((server.address() as AddressInfo).port)),
);
}
describe("gateway skeleton", () => {
it("advertises exactly the vocabulary @relay/protocol exports", async () => {
const server = createServer(silent);
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
status: string;
service: string;
protocol: { frames: string[]; close_codes: number[] };
};
expect(body.status).toBe("ok");
expect(body.service).toBe("gateway");
// Computed from the package on both sides of this assertion — but one
// side travelled over HTTP: the advertisement matches the contract.
const expectedFrames = frameSchema.options.map((o) => o.shape.type.value);
expect(body.protocol.frames).toEqual(expectedFrames);
expect(body.protocol.frames).toContain("connection.ack");
expect(body.protocol.frames).toHaveLength(10);
expect(body.protocol.close_codes).toEqual(
Object.keys(CLOSE_CODES).map(Number),
);
} finally {
server.close();
}
});
it("carries a request id and answers unknown routes with the shared 404 shape", async () => {
const server = createServer(silent);
const port = await listen(server);
try {
const res = await fetch(`http://127.0.0.1:${port}/socket-someday`);
expect(res.status).toBe(404);
expect(res.headers.get("x-request-id")).toBeTruthy();
const body = (await res.json()) as Record<string, unknown>;
expect(body.code).toBe("not_found");
expect(typeof body.docs_url).toBe("string");
} finally {
server.close();
}
});
});Để ý bài test thứ hai của API service: cái 404 REST của nó phải parse được
bằng schema payload lỗi của package protocol. Một phép khẳng định ấy chính
là toàn bộ chính sách một-khuôn-lỗi, chạy được bằng máy — phong thư lỗi REST
và frame lỗi WebSocket không bao giờ có thể lặng lẽ tách đôi, vì có một bài
test import bên này và đút cho nó bên kia. Cả hai service đều tiêu thụ
@relay/protocol ngay hôm nay, đúng như 1.3 đã nói.
Bước qua cửa ải:
pnpm install
pnpm lint
pnpm typecheck
pnpm testBốn mươi bài test. Không Docker, không cổng cố định, và là chương thứ tư liên tiếp cửa ải không cần gì ngoài Node.
flowchart LR
ch1["1.1 workspace<br/>part1-ch1"]
ch2["1.2 hạ tầng<br/>part1-ch2"]
ch3["1.3 protocol<br/>part1-ch3"]
ch4["1.4 bộ khung<br/>part1-ch4"]
done["Phần 1 ✓<br/>Phần 2 thêm chi tiết:<br/>session, gửi tin, thứ tự"]
ch1 --> ch2 --> ch3 --> ch4 --> doneĐến lượt bạn
Bài tập chính là công trình: tự tạo cả ba thành viên theo chương này, tự tay gõ phần kit. Rồi chọc vào bộ khung ở những chỗ nó biết dạy:
- Khởi động cả hai service, giết gateway, rồi curl API service — nó vẫn trả lời, bình thản. Hai tiến trình hỏng độc lập với nhau chính là toàn bộ lý do "sáu service" là một kiến trúc khả dĩ; bạn vừa quan sát phiên bản nhỏ nhất của điều đó.
- Bắn một tá request lẫn lộn vào cả hai service, nhặt một
X-Request-Idtừ một phản hồi, và grep cả hai terminal. Một dòng, một service, trọn câu chuyện — đó là NFR-OBS-06 tập dượt trên một hệ thống to bốn file. - Thêm tạm một route
/versionvào API service và để ý: bài test 404 vẫn xanh nhưng cái route thì không ai test. Cảm nhận khoảng hở ấy: mỗi route bạn thêm từ nay về sau đều nợ bộ test một bài. Xóa nó đi (hoặc test nó) trước khi đi tiếp.
Nếu bạn kẹt, tag của chương đang giữ sẵn đáp án: part1-ch4.
Những điều đọng lại
Nếu không đọc gì khác trong chương này, hãy giữ lấy những điều sau:
- Bộ khung dựng trước, chi tiết thêm sau — đường nối API/gateway là dòng kẻ chịu lực nhất của kiến trúc, và nó tồn tại (kèm test) trước khi bất kỳ logic nào kịp làm nhòe nó.
- Observability bắt đầu từ dòng đầu tiên: request ID trên mọi phản hồi (EIR-API-05), một dòng log JSON có cấu trúc cho mỗi request (NFR-OBS-01), và những khoản hoãn trung thực cho các trường chưa thể tồn tại — vì lời hứa 5 phút của NFR-OBS-06 rẻ bây giờ và là đại phẫu về sau.
- Đồ nghề vận hành có đúng một mái nhà — bản sao thứ hai là nơi trôi dạt bắt đầu, và đồ nghề trôi dạt trong im lặng cho tới đúng cái sự cố cần đến nó.
- Dependency là quyết định có câu chuyện: trò lột kiểu của nền tảng đâm vào tường resolver ở cú import liên-package đầu tiên; tsx là công cụ nhỏ nhất chữa được, cầm lên một cách chủ đích và ghim chặt.
- Một khuôn lỗi, một mái nhà, máy kiểm chứng — cái 404 REST parse được bằng schema payload lỗi của protocol, nên hai bộ từ vựng thất bại không thể tách đôi.