Своя СУБД на TS · урок 11: бинарный протокол и сервер
Кому это. За десять уроков мы построили целый движок: страницы на диске, буферный пул, кучу, B+Tree, парсер SQL, планировщик и исполнитель, журнал и транзакции, MVCC, блокировки, vacuum. Но говорить с движком умеет только встроенный REPL, в том же процессе. Снаружи к нашей СУБД не подключиться.
Зачем. Это capstone воркшопа: выставляем движок в сеть. Поднимем TCP-сервер, придумаем своё кадрирование сообщений, реализуем simple query protocol в духе Postgres, напишем серверную сессию (держит транзакцию соединения) и клиент-библиотеку. В конце клиент откроет сокет, пошлёт SELECT, получит типизированные строки, а BEGIN/COMMIT заработают по сети. И поскольку байтовый контракт у всех пяти языков воркшопа общий, клиент на TypeScript сможет поговорить с сервером на Go.
Важно про объём. Этот урок целиком аддитивный: мы пишем новый пакет src/wire/, ничего в движке не трогая. Шесть файлов: кадрирование, кодеки сообщений, сессия, сервер, клиент и barrel-экспорт. Сервер опирается на готовый движок (db.execute из урока 6, db.begin из урока 9) и его ошибки. Главные новые навыки тут не про БД, а про сеть: сборка кадров из рваного TCP-потока, кодеки big-endian, состояние сессии, сериализация запросов на клиенте.
Где мы в движке
Последний слой, он же вершина стека:
┌────────────────────────────────────────────────┐
│ Бинарный протокол + TCP-сервер │ урок 11 ← мы здесь
├────────────────────────────────────────────────┤
│ Исполнитель (Volcano) + EXPLAIN │ урок 6
│ Планировщик, binder, каталог │ урок 6
│ Парсер SQL │ урок 5
├────────────────────────────────────────────────┤
│ Vacuum (сборка мёртвых версий) │ урок 10
│ Блокировки (2PL), конкуренция │ урок 9
│ MVCC (видимость, версии кортежа) │ урок 8
│ Менеджер транзакций, WAL, recovery │ уроки 7, 8
├────────────────────────────────────────────────┤
│ B+Tree · Куча · Буферный пул · DiskManager │ уроки 1-4
└────────────────────────────────────────────────┘
Протокол не лезет внутрь движка: он сидит сверху, переводит байты сети в вызовы Database и обратно. Каждое соединение получает свою сессию и свою транзакцию, а изоляцию между соединениями уже обеспечивают снимки из урока 9.
Идея протокола
Простой протокол запросов это диалог из сообщений с типами. Все числа в нём big-endian, сетевой порядок байт. Это сознательный контраст с диском: на странице из урока 1 мы писали little-endian (родной для процессора), а на проводе пишем big-endian (традиция сетевых протоколов, как у Postgres). Один и тот же u32, разный порядок байт, разные методы Buffer.
Диалог строго запрос-ответ. Клиент шлёт сообщение Query с SQL-строкой. Сервер отвечает последовательностью: для SELECT это RowDescription (имена и типы колонок), потом DataRow на каждую строку, потом CommandComplete с тегом (SELECT 3). Любую последовательность сервер закрывает сообщением ReadyForQuery со статусом транзакции (I вне транзакции, T внутри, E транзакция упала). По нему клиент понимает, что ответ дочитан и можно слать следующий запрос. Ошибка это ErrorResponse с кодом и текстом, а потом всё равно ReadyForQuery.
Значения в DataRow идут текстом: int это десятичная строка, bool это t/f, text как есть, NULL это маркер длины минус один. Клиент разбирает текст обратно в типизированное значение по коду типа из RowDescription. Это упрощение Postgres (там бывает и бинарный формат), но его достаточно для полного цикла и оно избавляет от кодеков чисел на проводе.
Кадрирование
Начнём снизу: с разбиения потока на сообщения. Каждый кадр это байт типа сообщения (ASCII), затем u32 длины (big-endian, включает свои 4 байта, но не байт типа), затем payload. Заведи src/wire/framing.ts.
// src/wire/framing.ts
/** Один разобранный кадр: тип сообщения и его payload. */
export interface Frame {
readonly type: number;
readonly payload: Buffer;
}
/** Размер заголовка кадра: байт типа плюс u32 длины. */
const FRAME_HEADER_SIZE = 5;
/** Собрать кадр: байт типа, длина (4 плюс payload), payload. */
export function encodeFrame(type: number, payload: Buffer): Buffer {
const frame = Buffer.alloc(FRAME_HEADER_SIZE + payload.length);
frame.writeUInt8(type, 0);
frame.writeUInt32BE(4 + payload.length, 1);
payload.copy(frame, FRAME_HEADER_SIZE);
return frame;
}
export class FrameReader {
private buffer: Buffer = Buffer.alloc(0);
/** Добавить чанк и вернуть все кадры, собравшиеся целиком. */
push(chunk: Buffer): Frame[] {
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
const frames: Frame[] = [];
while (this.buffer.length >= FRAME_HEADER_SIZE) {
const length = this.buffer.readUInt32BE(1); // включает 4 байта длины
const total = 1 + length; // плюс байт типа
if (this.buffer.length < total) {
break; // кадр ещё не дочитан
}
const type = this.buffer.readUInt8(0);
const payload = this.buffer.subarray(FRAME_HEADER_SIZE, total);
frames.push({ type, payload });
this.buffer = this.buffer.subarray(total);
}
return frames;
}
}
FrameReader это самая важная часть и одновременно та, про которую легко забыть. TCP даёт поток байт, а не сообщения: один socket.on('data') может принести половину кадра, полтора кадра или три кадра сразу. push копит байты в буфере и отрезает ровно те кадры, что собрались целиком, оставляя хвост на потом. Цикл читает длину, проверяет, дочитан ли кадр, и если да, вырезает его. Без этого приёмник рано или поздно прочитает мусор: на медленной сети или большом ответе фрагментация неизбежна.
Сообщения
Теперь кодеки payload-ов. Они симметричны: на каждый encode есть decode. Заведи src/wire/messages.ts. Сначала типы сообщений и таблицы кодов.
// src/wire/messages.ts
import type { ColumnType } from '~/schema.ts';
import type { Value } from '~/tuple.ts';
/** Байт типа сообщения (ASCII). */
export const MessageType = {
query: 0x51, // 'Q'
terminate: 0x58, // 'X'
rowDescription: 0x54, // 'T'
dataRow: 0x44, // 'D'
commandComplete: 0x43, // 'C'
errorResponse: 0x45, // 'E'
readyForQuery: 0x5a, // 'Z'
} as const;
/** Код типа колонки в RowDescription. */
const TYPE_CODE: Record<ColumnType, number> = { int: 0, text: 1, bool: 2 };
const CODE_TYPE: readonly ColumnType[] = ['int', 'text', 'bool'];
/** Статус транзакции в ReadyForQuery. */
export type TxnStatus = 'I' | 'T' | 'E';
/** Поле результата: имя колонки и её тип. */
export interface Field {
readonly name: string;
readonly type: ColumnType;
}
/** Сообщение клиент в сервер. */
export type ClientMessage =
| { readonly kind: 'query'; readonly sql: string }
| { readonly kind: 'terminate' };
/** Сообщение сервер в клиент (так его строит сервер: DataRow несёт типизированные Value). */
export type ServerMessage =
| { readonly kind: 'rowDescription'; readonly fields: readonly Field[] }
| { readonly kind: 'dataRow'; readonly values: readonly Value[] }
| { readonly kind: 'commandComplete'; readonly tag: string }
| { readonly kind: 'errorResponse'; readonly code: string; readonly message: string }
| { readonly kind: 'readyForQuery'; readonly status: TxnStatus };
/** Сырые ячейки DataRow на проводе: текст или NULL. Тип отдаёт RowDescription. */
export type RawRow = readonly (string | null)[];
/**
* Серверное сообщение после разбора на клиенте. Отличается от ServerMessage только
* DataRow: значения приходят сырым текстом (RawRow), а не Value, и клиент парсит их по
* типам из RowDescription (decodeValueText). Так тип честно отражает, что байты ещё текст.
*/
export type DecodedServerMessage =
| Exclude<ServerMessage, { readonly kind: 'dataRow' }>
| { readonly kind: 'dataRow'; readonly values: RawRow };
Тонкий, но важный тип это DecodedServerMessage. Сервер строит DataRow из типизированных Value (он знает типы), а клиент получает сырой текст и ещё не разобрал его: типы он узнает только из пришедшего раньше RowDescription. Поэтому разобранный DataRow несёт RawRow (массив строк или null), и тип это честно отражает: байты на проводе это текст, пока клиент не применит decodeValueText. Дальше диспетчеры: тип кадра и payload по сообщению, и обратно.
// src/wire/messages.ts (продолжение: диспетчеры серверных сообщений)
/** Байт типа для серверного сообщения. */
export function serverMessageType(message: ServerMessage): number {
switch (message.kind) {
case 'rowDescription':
return MessageType.rowDescription;
case 'dataRow':
return MessageType.dataRow;
case 'commandComplete':
return MessageType.commandComplete;
case 'errorResponse':
return MessageType.errorResponse;
case 'readyForQuery':
return MessageType.readyForQuery;
}
}
/** Закодировать payload серверного сообщения. */
export function encodeServerPayload(message: ServerMessage): Buffer {
switch (message.kind) {
case 'rowDescription':
return encodeRowDescription(message.fields);
case 'dataRow':
return encodeDataRow(message.values);
case 'commandComplete':
return Buffer.from(message.tag, 'utf8');
case 'errorResponse':
return encodeErrorResponse(message.code, message.message);
case 'readyForQuery':
return Buffer.from([message.status.codePointAt(0) ?? 0]);
}
}
/** Разобрать серверное сообщение из типа кадра и payload (DataRow остаётся сырым текстом). */
export function decodeServerMessage(type: number, payload: Buffer): DecodedServerMessage {
switch (type) {
case MessageType.rowDescription:
return { kind: 'rowDescription', fields: decodeRowDescription(payload) };
case MessageType.dataRow:
return { kind: 'dataRow', values: decodeDataRow(payload) };
case MessageType.commandComplete:
return { kind: 'commandComplete', tag: payload.toString('utf8') };
case MessageType.errorResponse:
return { kind: 'errorResponse', ...decodeErrorResponse(payload) };
case MessageType.readyForQuery:
return { kind: 'readyForQuery', status: statusFromByte(payload) };
default:
throw new RangeError(`неизвестный тип серверного сообщения 0x${type.toString(16)}`);
}
}
Клиентские сообщения проще: их всего два. Query несёт SQL-строку с завершающим нулевым байтом (как в Postgres), Terminate пуст.
// src/wire/messages.ts (продолжение: клиентские сообщения)
/** Байт типа для клиентского сообщения. */
export function clientMessageType(message: ClientMessage): number {
return message.kind === 'query' ? MessageType.query : MessageType.terminate;
}
/** Закодировать payload клиентского сообщения. */
export function encodeClientPayload(message: ClientMessage): Buffer {
if (message.kind === 'terminate') {
return Buffer.alloc(0);
}
const sql = Buffer.from(message.sql, 'utf8');
return Buffer.concat([sql, Buffer.from([0])]); // SQL плюс завершающий NUL
}
/** Разобрать клиентское сообщение из типа кадра и payload. */
export function decodeClientMessage(type: number, payload: Buffer): ClientMessage {
switch (type) {
case MessageType.query: {
const end = payload.length > 0 && payload.at(-1) === 0 ? -1 : undefined;
return { kind: 'query', sql: payload.subarray(0, end).toString('utf8') };
}
case MessageType.terminate:
return { kind: 'terminate' };
default:
throw new RangeError(`неизвестный тип клиентского сообщения 0x${type.toString(16)}`);
}
}
Теперь значения. На проводе они текстовые, и кодек симметричен: encodeValueText превращает Value в байты (или null-маркер), decodeValueText читает текст обратно по типу колонки.
// src/wire/messages.ts (продолжение: текстовые значения)
/** Текстовое представление значения для DataRow (null отдельным маркером длины). */
export function encodeValueText(value: Value): Buffer | null {
if (value === null) {
return null;
}
if (typeof value === 'bigint') {
return Buffer.from(value.toString(10), 'utf8');
}
if (typeof value === 'boolean') {
return Buffer.from(value ? 't' : 'f', 'utf8');
}
return Buffer.from(value, 'utf8');
}
/** Разобрать текстовое значение колонки по её типу. */
export function decodeValueText(type: ColumnType, text: string): Value {
switch (type) {
case 'int':
return BigInt(text);
case 'bool':
return text === 't';
case 'text':
return text;
}
}
Остались помощники для составных сообщений. RowDescription это число полей и на каждое имя (длина плюс байты) и код типа. DataRow это число колонок и на каждую длина (int32, минус один значит NULL) плюс байты. ErrorResponse это код и текст. Все читают и пишут симметрично, поэтому привожу их вместе.
// src/wire/messages.ts (продолжение: кодеки составных payload-ов)
function encodeRowDescription(fields: readonly Field[]): Buffer {
const head = Buffer.alloc(2);
head.writeUInt16BE(fields.length, 0);
const chunks: Buffer[] = [head];
for (const field of fields) {
const name = Buffer.from(field.name, 'utf8');
const nameLength = Buffer.alloc(2);
nameLength.writeUInt16BE(name.length, 0);
chunks.push(nameLength, name, Buffer.from([TYPE_CODE[field.type]]));
}
return Buffer.concat(chunks);
}
function decodeRowDescription(payload: Buffer): Field[] {
const count = payload.readUInt16BE(0);
const fields: Field[] = [];
let cursor = 2;
for (let index = 0; index < count; index++) {
const nameLength = payload.readUInt16BE(cursor);
cursor += 2;
const name = payload.toString('utf8', cursor, cursor + nameLength);
cursor += nameLength;
const code = payload.readUInt8(cursor);
cursor += 1;
const type = CODE_TYPE[code];
if (type === undefined) {
throw new RangeError(`неизвестный код типа колонки ${code}`);
}
fields.push({ name, type });
}
return fields;
}
function encodeDataRow(values: readonly Value[]): Buffer {
const head = Buffer.alloc(2);
head.writeUInt16BE(values.length, 0);
const chunks: Buffer[] = [head];
for (const value of values) {
const bytes = encodeValueText(value);
const length = Buffer.alloc(4);
if (bytes === null) {
length.writeInt32BE(-1, 0); // NULL это длина минус один, без байт данных
chunks.push(length);
} else {
length.writeInt32BE(bytes.length, 0);
chunks.push(length, bytes);
}
}
return Buffer.concat(chunks);
}
/** Сырые колонки DataRow: текст или null. Тип отдаёт RowDescription, см. decodeValueText. */
function decodeDataRow(payload: Buffer): RawRow {
const count = payload.readUInt16BE(0);
const values: (string | null)[] = [];
let cursor = 2;
for (let index = 0; index < count; index++) {
const length = payload.readInt32BE(cursor);
cursor += 4;
if (length === -1) {
values.push(null);
continue;
}
values.push(payload.toString('utf8', cursor, cursor + length));
cursor += length;
}
return values;
}
function encodeErrorResponse(code: string, message: string): Buffer {
const codeBytes = Buffer.from(code, 'ascii');
const head = Buffer.alloc(2);
head.writeUInt16BE(codeBytes.length, 0);
return Buffer.concat([head, codeBytes, Buffer.from(message, 'utf8')]);
}
function decodeErrorResponse(payload: Buffer): { code: string; message: string } {
const codeLength = payload.readUInt16BE(0);
const code = payload.toString('ascii', 2, 2 + codeLength);
const message = payload.toString('utf8', 2 + codeLength);
return { code, message };
}
function statusFromByte(payload: Buffer): TxnStatus {
const status = String.fromCodePoint(payload.readUInt8(0));
if (status !== 'I' && status !== 'T' && status !== 'E') {
throw new RangeError(`неизвестный статус транзакции "${status}"`);
}
return status;
}
Много кода, но он весь однообразный: записал число big-endian, записал байты, на чтении прошёл курсором ровно в том же порядке. Главное правило кодека: читай в точности так, как писал, поле за полем.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.