Олег Карнауховweb developerОбсудить проект

Блог

Файлы вместо базы данных: хранилище для сайта, который правит один человек

Контент сайта лежит в JSON-файлах, а не в PostgreSQL. Разбор решения: чем платишь, что получаешь и где эта схема ломается.

Обычный ответ на вопрос «где хранить контент сайта» — база данных. Я выбрал каталог JSON-файлов и до сих пор считаю это правильным решением. Ниже — из чего складывался выбор и в какой момент он перестанет работать.

Что именно решалось

Сайт правит один человек — я. Правок в день — единицы. Объём — десятки документов. При таких числах база данных приносит не производительность, а зависимость: драйвер, который надо собрать и положить в релиз; миграции, которые надо писать и откатывать; процесс, который надо мониторить и бэкапить отдельно от файлов.

Релиз, который крутится на сервере, не тянет ни одной внешней зависимости — только собранный бандл. Добавить туда драйвер базы означало сломать это свойство ради задачи, которой нет.

Как это устроено

Один документ — один файл. Страница, кейс, пост — каждый лежит отдельно, назван своим адресом. Создать сущность значит записать файл, удалить — удалить файл. Списка, который надо держать в согласии с каталогом, не существует: каталог и есть список.

Чтение кэшируется в памяти процесса. Сайт — один процесс Node, поэтому кэш это обычный Map, а сбрасывает его только запись через тот же модуль.

app/server/content-store.tsts
export async function writePost(slug: string, post: Post) {
  if (!looksLikeSlug(slug)) throw new Error("bad slug");
  await mkdir(postsDir(), { recursive: true });

  // Что пост говорит сейчас — сохранить перед тем, как записать поверх.
  const previous = await readPostFile(slug);
  if (previous) await keepVersion("posts", slug, previous);

  const file = postFile(slug);
  const temporary = `${file}.${process.pid}.tmp`;
  await writeFile(temporary, `${JSON.stringify(post, null, 2)}\n`, "utf8");
  await rename(temporary, file);

  cache.set(`post:${slug}` as DocumentId, post);
}
Запись идёт во временный файл и потом переименовывается. rename в пределах одной файловой системы атомарен: читатель видит либо старый файл целиком, либо новый целиком, но никогда половину.

Самая важная деталь: слияние с кодом

Файл на диске накладывается на копию из репозитория, а не заменяет её. Код обрастает полями — это нормальный способ развития сайта, — и документ, сохранённый до появления поля, должен получить это поле пустым, а не остаться без него.

Списки при этом берутся целиком. Слитый список не был бы ни старым, ни новым: представьте меню, где к четырём сохранённым пунктам добавились два из кода. Поэтому меню, однажды сохранённое в панели, живёт своей жизнью — и об этом надо помнить, добавляя пункт в код.

Что теряешьЧто получаешь
Запросы и выборкиБэкап копированием каталога
Одновременная записьОткат — вернуть предыдущий файл
Поиск по контентуНоль зависимостей в релизе
Много авторовDiff читается глазами
Правая колонка перестаёт перевешивать, как только появляется второй автор или тысячи документов.

Когда схема сломается

Три условия, каждого достаточно. Появится второй редактор — две одновременные записи в один файл, и последний выигрывает молча. Понадобится поиск по контенту — обходить каталог станет дорого. Документов станет тысячи — чтение каталога на каждый запрос перестанет быть бесплатным.

Ни одно из трёх не наступило, а переехать на базу из файлов проще, чем обратно.