Сериализация данных в вебе: как избежать скрытых подводных камней

Сериализация кажется простой операцией — превратить объект в байты или строку для передачи или хранения. Но когда начинаешь вникать в детали, особенно в контексте современных распределённых систем, обнаруживаются десятки нюансов, способных превратить тривиальную задачу в источник критических ошибок. Вот реальная история из практики: микросервис на Node.js генерировал Excel-отчёты через RabbitMQ, используя стандартный JSON. Работало стабильно, пока не появились заказы с суммами в триллионах — числа стали «округлёнными» в экспорте. Расследование показало, что сериализация BigInt через JSON.stringify() теряла точность. Это не теоретическая проблема — стандарт JSON не поддерживает 64-битные целые, но во фронтенд-фреймворках и ORM эта особенность часто остаётся за кадром.

Типизированные данные и потеря информации

Возьмём простой пример с датами. Большинство API передают их как строки ISO 8601:

javascript
// Node.js + Express
app.get('/event', (req, res) => {
  const event = { 
    title: "Release", 
    date: new Date('2024-03-15') 
  };
  res.json(event); // { title: "Release", date: "2024-03-15T00:00:00.000Z" }
});

На клиенте:

typescript
fetch('/event').then(res => res.json()).then(data => {
  console.log(data.date instanceof Date); // false - это строка!
});

Чем это опасно? Любые операции с датой потребуют ручного парсинга, что легко забыть. Решение — явная сериализация с преобразованием типов:

javascript
import { stringify, parse } from 'serializr';

class Event {
  @serializable(date()) date: Date;
  // ...
}

const event = deserialize(Event, rawData); // Происходит преобразование типов

Для пресечения подобных проблем в других экосистемах:

  • Python (FastAPI): используйте jsonable_encoder с кастомными сериализаторами для datetime
  • Java (Jackson): аннотации @JsonSerialize(using = LocalDateTimeSerializer.class)

Рекурсивные структуры и циклические ссылки

Рекурсивные отношения в ORM-моделях (например, User ↔ Order) при нативной сериализации приводят к:

text
TypeError: Converting circular structure to JSON

Решение не в подавлении ошибок через JSON.stringify(obj, null, 2), а в проектировании Data Transfer Objects (DTO). Пример для NestJS:

typescript
class UserDTO {
  @Exclude() private readonly _orders: Order[];

  constructor(user: User) {
    this.id = user.id;
    this._orders = user.orders;
  }

  @Expose()
  get recentOrders() {
    return this._orders.slice(0, 3).map(o => ({ id: o.id, total: o.total }));
  }
}

Но длина такого кода растёт пропорционально сложности моделей. Альтернатива — библиотеки типа class-transformer с декораторами @Type(() => Order) и стратегиями исключений.

Сериализация метаданных системы

Распространённая антипаттерн — смешение доменных объектов с техническими данными. Рассмотрим пример:

javascript
// Плохо: сериализуется весь экземпляр класса со служебными методами
class User {
  constructor(public id: number, public name: string) {}
  save() { /* ... */ }
}

const u = new User(1, "Alex");
localStorage.setItem('user', JSON.stringify(u)); // Включает save: undefined

Правильный подход — отделение DTO от бизнес-логики через интерфейсы:

typescript
interface UserDTO {
  id: number;
  name: string;
}

class User implements UserDTO {
  constructor(public id: number, public name: string) {}
  
  toDTO(): UserDTO {
    return { id: this.id, name: this.name };
  }
}

Бинарные протоколы и производительность

Когда JSON становится узким местом (особенно для IoT или игровых backend), стоит рассмотреть бинарные форматы. Сравнительная таблица для Node.js:

МетрикаJSONMessagePackProtobuf
Размер данных100%~50%~30%
Скорость сериал.1x0.8x3x
Поддержка типовБазовыеРасширенныеСхемы
СовместимостьУниверсальнаКроссязык.Требует .proto

Пример сериализации в Protobuf:

protobuf
// schema.proto
message User {
  uint64 id = 1;
  string name = 2;
  repeated string tags = 3;
}
javascript
const user = { id: 1n, name: "Alice", tags: ["admin"] };
const buffer = User.encode(user).finish(); // Поддерживает BigInt

Неявная десериализация и уязвимости

Сериализация недоверенных данных — классический источник уязвимостей. В Python:

python
import pickle

# Опасно: позволяет выполнение произвольного кода
data = pickle.loads(untrusted_input) 

Решение — использовать безопасные форматы (JSON, CBOR) с валидацией схемы:

python
from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str = Field(max_length=100)

user = User.parse_raw(json_data) # Валидация типов и значений

Для Node.js - библиотеки типа zod или class-validator.

Рекомендации по проектированию

  1. Схемы прежде формата: начните с определения точной структуры данных через Protocol Buffers, JSON Schema или TypeScript-интерфейсы.
  2. Слои преобразования: изолируйте сериализацию/десериализацию в отдельных модулях (DTO-трансформеры).
  3. Профилирование: замеряйте не только объём данных, но и потребление CPU при сериализации больших массивов.
  4. Контроль версий: при изменении формата используйте семантическое версионирование API и миграционные скрипты.

Нивелярировать сложность сериализации — значит готовиться к проблемам в продакшене. Современные инструменты вроде GraphQL (со строгой типизацией) или gRPC (бинарный RPC) решают часть вопросов, но полное понимание процесса преобразования данных остаётся критически важным навыком.