Это третья и последняя часть серии из трех статей о Twirl — веб-приложении, которое сокращает URL-адреса для каждого пользователя. Twirl развернут на Heroku и доступен здесь: https://oteetwirl.herokuapp.com/

В первой части я дал обзор структуры проекта Twirl и рассказал о компоненте управления пользователями приложения. Во второй части я подробно рассказал, как должны храниться пароли в базах данных, и показал, как мы храним пароли в Twirl.

В этой части я обсуждаю логику сокращения ссылок, используемую в Twirl.

Цели дизайна

У Twirl есть три цели создания коротких ссылок. Во-первых, ссылки должны быть короткими. Это очевидно: основная цель приложения — генерировать короткие ссылки.

Во-вторых, короткие ссылки, генерируемые системой, должны быть уникальными. Другими словами, никакие две короткие ссылки не должны быть идентичны друг другу. Уникальность коротких ссылок гарантирует, что у нас есть однозначное сопоставление коротких ссылок с длинными (или оригинальными) ссылками. Если система создает одну и ту же короткую ссылку для двух или более URL-адресов, это приведет к двусмысленности и неопределенности: данная короткая ссылка будет перенаправлять на одну из многих альтернативных длинных ссылок.

В-третьих, короткие ссылки должны быть неугадываемыми. Другими словами, система должна генерировать короткие ссылки, которые кажутся случайными, что делает почти невозможным для внешнего наблюдателя предсказать ссылки, генерируемые системой. Это гарантирует, что никакая третья сторона не сможет предсказать будущие (или прошлые) ссылки, сгенерированные системой, и изменить количество их доступа.

Кроме того, Twirl гарантирует, что несколько запросов на сокращение одного и того же URL-адреса приведут к одной и той же короткой ссылке, если эти запросы сделаны одним и тем же пользователем. Однако, если один и тот же URL будет сокращен разными пользователями, они получат уникальные (и разные) короткие ссылки. Это, опять же, помогает следить за количеством обращений к конкретной ссылке, созданной конкретным пользователем.

Создание коротких ссылок

Для создания коротких ссылок система создает уникальную строку (например, 4i811) и добавляет ее к определенному пути, а именно /l/. Таким образом, каждая короткая ссылка состоит из следующих частей: {domain}/l/{unique_string}.

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

Уникальная строка (формирующая путь каждой короткой ссылки) состоит из двух частей. Чтобы гарантировать, что строку невозможно угадать, первая часть является псевдослучайной строкой. , созданный с использованием метода randomBytes модуля crypto Node.js. Этот метод генерирует криптографически стойкие псевдослучайные данные. Он принимает параметр size, указывающий количество байтов псевдослучайных данных для генерации, и возвращает объект Buffer, представляющий псевдослучайные данные. Буферы предназначены для обработки и представления необработанных двоичных данных в Node.js.

Чтобы прочитать содержимое объекта буфера, мы можем использовать встроенный метод toString для преобразования его в строковый тип данных. По умолчанию toString использует схему преобразования двоичного кода в текст UTF-8. Это нежелательно, так как результирующая строка может содержать любое количество специальных символов, не поддерживаемых в URL-адресе. (Схема UTF-8 может кодировать до 1 112 064 символов, в то время как допустимый URL-адрес должен содержать только буквенно-цифровые символы вместе с несколькими зарезервированными и незарезервированными символами).

Вместо того, чтобы полагаться на UTF-8, мы используем кодировку Base64 для преобразования объекта Buffer, возвращаемого методом randomBytes. Base64 кодирует буквенно-цифровые символы (A - Z, a- z, 0- 9) вместе с несколькими другими специальными символами (такими как +, -, / и =, в зависимости от конкретной реализации Base64). Итак, чтобы соответствовать стандартам допустимого URL-адреса, нам нужно удалить эти специальные символы.

Кроме того, чтобы результирующая ссылка была короткой, мы сводим размер этих псевдослучайных данных к минимуму: мы передаем 2 в качестве параметра методу randomBytes, гарантируя, что размер псевдослучайных данных данные составляют всего 2 байта.

import { randomBytes } from "crypto";
let shortLink = randomBytes(2).toString("base64");
shortLink = shortLink.replace(/[\-+\/=]/g, "");

Чтобы убедиться, что каждая короткая ссылка уникальна, мы полагаемся на счетчики. Обратите внимание, что randomBytes генерирует псевдослучайные данные; он не гарантирует никакой уникальности, а просто служит для того, чтобы скрыть результирующую короткую ссылку. Иными словами, нет никакой гарантии, что данные, сгенерированные randomBytes, не будут дублироваться. Таким образом, чтобы каждая короткая ссылка была уникальной, к псевдослучайной строке добавляется счетчик.

Поскольку состояние системы поддерживается базой данных, мы сохраняем текущее значение счетчика в определенной таблице (а именно, counters) в базе данных. Значение счетчика увеличивается на единицу каждый раз, когда генерируется короткая ссылка.

Необходимость атомарности при создании счетчиков

Если несколько серверов обращаются к одной и той же базе данных и пытаются изменить одни и те же данные, это может привести к дублированию или несогласованности изменяемых данных. В данном контексте считайте, что система обрабатывает каждый запрос на создание короткой ссылки, делая два отдельных запроса к базе данных: сначала для обновления счетчика в базе данных, а затем для считывания значения счетчика, обновленного таким образом. Теперь, если получены два одновременных запроса (при условии, что сервер может обрабатывать несколько потоков), сервер выполнит два набора этих запросов одновременно. В идеале транзакции обновления и чтения должны выполняться последовательно, т.е. один раз для каждого запроса. Но поскольку запросы обрабатываются параллельно, два запроса на обновление (по одному для каждого из двух запросов) могут быть обработаны первыми, до двух запросов на чтение. Это приведет к тому, что две короткие ссылки будут использовать один и тот же счетчик.

Возьмем пример. Пусть текущее значение счетчика ( c) равно 1. R1(c) указывает, что поток 1 считывает значение счетчика c. W1(c, c + 1) указывает, что поток 1 записывает значение счетчика, увеличивая его на единицу. Теперь это однопоточная последовательность выполнения:

W1(c, c + 1); // value of c is increased to 2
X = R1(c); // value of X is 1

Ожидаемая двухпоточная последовательность выполнения (с начальным значением c равным 1):

W1(c, c + 1); // value of c is incrased to 2
X = R1(c); // value of X is 2
W2(c, c + 1); // value of c is increased to 3
Y = R2(c); // value of Y is 3

Однако, поскольку несколько потоков выполняются параллельно, также может произойти следующее:

W1(c, c + 1); // value of c is incrased to 2
W2(c, c + 1); // value of c is incrased to 3
X = R1(c); 
Y = R2(c); //the values of both X and Y are 3

Это снизит гарантию уникальности системы. Если система получает несколько одновременных запросов, будет (по крайней мере) теоретически возможно, что две короткие ссылки (сгенерированные для двух разных URL-адресов) будут идентичными, поскольку они могут полагаться на одно и то же значение счетчика и одни и те же псевдослучайные данные.

Чтобы избежать этого и всегда гарантировать уникальность коротких ссылок, мы используем атомарные транзакции для обновления и чтения значений счетчика из базы данных. Атомарная транзакция — это «неразделимая и несокращаемая серия операций с базой данных, так что либо все происходит, либо ничего не происходит». Приведенный выше пример (с участием двух потоков) не работает, потому что между временем, когда вы обновляете значение, и временем, когда вы читаете его значение, само значение изменяется (другой транзакцией). Этого можно избежать, если мы используем атомарные транзакции: это гарантирует неделимость операций чтения и записи и тем самым гарантирует, что мы читаем то, что пишем.

Атомарность может быть достигнута, если мы используем предложение RETURNING в запросе для обновления значения счетчика. Предложение RETURNING позволяет нам ' получать данные из измененных строк "во время манипулирования ими"'. Другими словами, он свертывает операцию SELECT в запрос на изменение базы данных. Это гарантирует, что как операция обновления, так и операция чтения в базе данных завершится неудачно или успешно, и что мы будем считывать данные, обновляемые каждой транзакцией.

UPDATE counters SET value=value+1 WHERE id='link_counter' RETURNING value

Наконец, если делается запрос на сокращение ссылки, система сначала проверяет, существует ли ссылка в базе данных для того же пользователя. Если да, он извлекает соответствующую короткую ссылку из базы данных вместо создания новой короткой ссылки.

Схема базы данных

В базе есть две таблицы, связанные с сокращением ссылок. Во-первых, таблица counters, в которой хранится счетчик, на основе которого генерируются короткие ссылки (как было сказано выше).

Во-вторых, таблица mamed links, в которой хранится список всех коротких ссылок, сгенерированных системой. Эта таблица содержит следующие столбцы:

  • user_id: это внешний ключ, который ссылается на идентификатор пользователя (из таблицы users), сгенерировавшего каждую короткую ссылку.
  • original_link: Сохраняет соответствующую исходную (длинную) ссылку каждой короткой ссылки, созданной пользователем.
  • short_link: Сохраняет каждую короткую ссылку, сгенерированную системой.
  • enabled: поддерживает, включена или отключена каждая короткая ссылка
  • accessed_count: Сохраняет количество запросов на расширение каждой короткой ссылки
  • created_at: Сохраняет метку времени при создании каждой короткой ссылки.
  • updated_at: сохраняет метку времени при обновлении каждой короткой ссылки (в настоящее время система не поддерживает никаких функций для обновления ссылок)

Эта таблица зависит от таблицы users, поскольку столбец user_id этой таблицы имеет ограничение внешнего ключа для таблицы users.

Расширение коротких ссылок

При запросе короткой ссылки, сгенерированной системой, система извлечет соответствующую длинную ссылку из базы данных. Если короткая ссылка не существует в базе данных, отображается сообщение об ошибке. Обратите внимание, что для получения соответствующей длинной ссылки данной короткой ссылки система не проверяет, вошел ли пользователь (делающий запрос) в систему или нет.

Другие особенности

Таблица links отслеживает, сколько раз каждая длинная ссылка была извлечена из базы данных. Это позволяет приложению отображать первые 50 коротких ссылок (с точки зрения количества посещений) каждого пользователя. Используя метод setInterval, интерфейсный JavaScript периодически делает запрос к пути /analytics. Каждый раз, когда поступает запрос на этот путь, система выбирает первые пятьдесят коротких ссылок этого пользователя, используя следующий запрос:

await pool.query(
`SELECT original_link, short_link, accessed_count, enabled FROM links
WHERE user_id=$1
ORDER BY accessed_count DESC, created_at DESC limit 50;`,
[userID]
);

Система также позволяет пользователю включать или отключать короткие ссылки. Когда пользователь делает запрос к пути /disable/:id, где id — уникальная строка короткой ссылки, система изменяет значение столбца enabled таблицы links для этой короткой ссылки с true на false. И наоборот, он изменяет соответствующее значение столбца enabled с false на true, когда делается запрос к пути /enable/:id.

Интерфейсный JavaScript генерирует запросы на отключение/включение, когда пользователь нажимает кнопку переключения для данной ссылки.

Будущие улучшения

Вот некоторые улучшения, которые можно внести в систему сокращения ссылок:

  • Псевдоним: предоставление пользователям возможности настраивать свои короткие ссылки с помощью настраиваемых псевдонимов.
  • Разбивка на страницы: добавление разбиения на страницы для отображения всех ссылок, созданных пользователем, вместо отображения только 50 самых популярных ссылок.
  • Добавление возможности поиска: предоставление пользователям возможности искать по сокращенным ими ссылкам.
  • Страница 404: добавление страницы 404 для ссылок, отключенных пользователем.
  • Кэширование. Включение кэширования ограниченного числа коротких ссылок путем сохранения их в памяти.
  • Улучшение производительности. Чтобы уменьшить количество запросов к базе данных для обработки каждого запроса на сокращение ссылки, система может увеличивать уникальный счетчик (поддерживаемый базой данных) на n значений. , что позволяет ему создавать n коротких ссылок без запроса к базе данных.
  • Предоставление интерфейса API: разрешение сторонним приложениям использовать службу этого приложения; в настоящее время запросы на сокращение ссылок должны выполняться браузером после того, как пользователь войдет в систему (поскольку мы просматриваем заголовки файлов cookie для аутентификации пользователя).

Первоначально опубликовано на https://otee.dev 20 декабря 2021 г.