Перейти к содержанию

Задача для WebAssembly и контракт анализатора

WebAssembly позволяет подключить к браузерному приложению модуль, собранный из другого языка. Но само наличие такого модуля не делает страницу быстрее и не отменяет JavaScript. В этой серии создадим анализатор «Мера»: пользователь выберет текстовый файл, а приложение покажет объём, количество строк и распределение их длин. Интерфейс и работа с файлом останутся в браузерном JavaScript, вычислительная часть появится в Rust.

Начнём с договора результата. Нам нужны основы JavaScript, HTML и готовность познакомиться с новым синтаксисом. Мы не будем строить собственный браузер, полноценный CSV-парсер или универсальную систему обработки естественного языка. Небольшая задача поможет увидеть границу между модулем и страницей, а затем осмысленно сравнить реализации.

Что относится к вычислению

Функция анализатора принимает содержимое и возвращает статистику. Она не открывает диалог выбора файла, не обновляет DOM и не отправляет результат на сервер. Благодаря этому одинаковое вычисление можно вызвать из основного потока, из Worker или из запасной JavaScript-реализации.

Внешний результат зададим как объект JavaScript:

{
  bytes: 9,
  scalars: 6,
  lines: 2,
  nonEmptyLines: 2,
  maxLineScalars: 3
}

Это ожидаемая статистика строки "кот\nhi". Три русские буквы занимают шесть байт UTF-8, перевод строки — один, две латинские буквы — ещё два. Unicode-скалярных значений здесь шесть: три буквы, перевод строки и две буквы. В первой строке три скалярных значения, поэтому максимальная длина равна трём.

Термин «скалярное значение» важен. Его нельзя молча заменить «видимым символом». Буква с комбинируемым знаком или последовательность emoji может содержать несколько значений, но восприниматься как один знак. Наш анализатор измеряет техническую структуру текста, а не ширину надписи на экране.

Договор строк и пустого файла

За разделитель принимаем LF, то есть \n. Для подсчёта длины отдельной строки удаляем один завершающий CR: это позволяет корректно рассматривать распространённый CRLF. При этом исходное количество байт и скалярных значений учитывает оба знака. Мы не нормализуем файл незаметно перед общей статистикой.

Пустой файл имеет ноль строк. Непустой файл с завершающим LF содержит дополнительную пустую строку. Например, "a\n" имеет две строки, одна из которых пустая. Можно выбрать другое соглашение, но оно должно быть одинаковым для Rust, JavaScript и показанных примеров. Иначе сравнение движков обнаружит разницу договоров, а не ошибку оптимизации.

Непустой строкой считаем строку, в которой после удаления пробелов ASCII и табуляций осталось что-то ещё. Неразрывный пробел не относится к этой маленькой договорённости. Мы намеренно не используем различающиеся наборы Unicode-пробелов разных языков программирования для скрытой очистки текста.

Запишем несколько контрольных случаев как предметную таблицу:

Текст Строк Непустых строк Максимальная длина
пустая строка 0 0 0
a 1 1 1
a\n 2 1 1
a\r\nb 2 2 1
пробел и табуляция 1 0 2

Таблица описывает ожидаемые значения. Мы не запускаем примеры и не выдаём её за отчёт измерения. Перед публикацией кода эти случаи нужно будет отдельно воспроизвести в выбранной среде.

Начальная JavaScript-реализация

Пока Wasm не нужен. Сохраним полную функцию в web/fallback.mjs; позднее она станет запасным движком. Входом будет корректная строка Unicode, полученная после строгого декодирования UTF-8-файла:

export function analyzeText(text) {
  const rows = text === "" ? [] : text.split("\n").map(row =>
    row.endsWith("\r") ? row.slice(0, -1) : row);
  const lengths = rows.map(row => [...row].length);
  return {
    bytes: new TextEncoder().encode(text).length,
    scalars: [...text].length,
    lines: rows.length,
    nonEmptyLines: rows.filter(row => /[^ \t]/u.test(row)).length,
    maxLineScalars: lengths.reduce((max, value) => Math.max(max, value), 0)
  };
}

Обход [...text] использует итерацию по кодовым точкам и отличается от text.length, который считает кодовые единицы UTF-16. Однако создание массивов означает выделение памяти. Пока пример сделан для ясности, а не назван оптимальным. При работе с большими файлами это решение нужно учитывать при измерении общей стоимости.

Функция возвращает объект с пятью определёнными полями. Интерфейс не должен зависеть от внутренних типов Rust или адресов памяти. Если впоследствии заменим движок, пользователь продолжит видеть тот же смысл результата. Такой договор полезнее преждевременного обсуждения десятков низкоуровневых экспортов.

Граница данных и ограничение объёма

Для учебного приложения зададим максимальный объём входного файла восемь MiB. Это ограничивает объём чтения, копирования и выделения памяти. Сам лимит не гарантирует одинаковое быстродействие на телефоне и настольном компьютере; он просто делает границу задачи явной. Приложение сообщает об отказе до чтения слишком большого файла.

Файл должен быть корректным UTF-8. В следующем уроке увидим, почему Rust-строка имеет такой договор, а позже добавим строгий TextDecoder и управляемые ошибки. Если принимать любую последовательность байт как текст с заменой повреждённых символов, статистика уже относится к исправленному содержимому, а не к исходному файлу.

WebAssembly не предоставляет автоматического доступа к любым локальным файлам пользователя. Браузерная часть получает только выбранный файл и передаёт его содержимое модулю. Открытие диска, сеть и интерфейс остаются отдельными возможностями приложения. Это границы архитектуры, а не дополнительный запрет на обычное вычисление.

Одна задача не требует переноса всего приложения

Рассмотрим альтернативу: переписать на Rust форму, выбор файла и все подписи. Тогда простой анализатор быстро превратится в изучение браузерных привязок и нового набора компонентов. Такое расширение не помогает ответить на исходный вопрос о вычислительном модуле. Нам полезнее сохранить знакомую оболочку JavaScript и отдельно изучить границу чистой функции.

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

Перед добавлением нового показателя сначала сформулируйте его единицу и смысл. Например, «средняя длина» может учитывать пустые строки или только непустые. Выбор нужно записать раньше кода: именно он определяет, какие числа будут считаться правильными.

Мы определили результат до выбора инструмента: одинаковые поля, явные правила строк, корректный UTF-8 и ограниченный вход. Далее перенесём вычисление в Rust, сохранив этот договор. По смыслу такая работа близка к обработке файлов и медиаресурсов, но здесь данные остаются внутри браузерного приложения.

Оглавление серии