Буферы, потоки и двоичные данные в Node.js +16

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

image

О внутренних механизмах Node


К сожалению, многие руководства и книги, посвящённые Node.js, не уделяют должного внимания внутренним механизмам этой платформы, не стремятся объяснить цель их существования. Как правило, в подобных публикациях всё сводится к рассказам о разработке веб-приложений с использованием готовых пакетов, без углубления в детали их реализации. А кое-где даже беспардонно заявляется, что читателю всё это понимать и не нужно, так как ему, скорее всего, никогда не придётся работать, скажем, с объектами класса Buffer, напрямую.

Для того, кто не планирует идти дальше использования в своих проектах готовых библиотек, такой подход, вероятно, оправдан. Тем же, в ком загадки будят любопытство, тем, кто хочет вывести собственное понимание JS на новый уровень, стоит копнуть поглубже и разобраться со множеством внутренних возможностей Node.js, таких, например, как класс Buffer.

В официальной документации по Node.js о классе Buffer можно прочитать следующее:

До появления объекта TypedArray в ECMAScript 2015 (ES6), в JavaScript не было механизма для чтения потоков двоичных данных или для выполнения других операций с ними. Класс Buffer был представлен как часть API Node.js, позволяющая взаимодействовать с потоками произвольных двоичных данных в контексте, например, TCP-потоков и операций с файловой системой.

Да уж, если вы раньше не знали тех слов, которые встречаются в этом определении, то вы, возможно, воспримите его как маловразумительную кучу программистских жаргонизмов. Попробуем немного всё это упростить, перефразировав это определение, так, чтобы мы могли работать с ним, ни на что не отвлекаясь. Из этого определения можно вынести следующее:

Класс Buffer был представлен как часть API Node.js, позволяющая работать с потоками двоичных данных.

Итак, теперь всё выглядит немного проще. Но «класс Buffer», «потоки», «двоичные данные» — тут всё ещё слишком много непростых понятий. Попытаемся с ними разобраться, начав с последнего.

Что такое двоичные данные?


Возможно, вы уже знаете о том, что компьютеры хранят и представляют данные в двоичной форме. Двоичные данные — это просто набор единиц и нулей. Например, вот пять разных наборов двоичных данных, составленных из значений «1» и «0»:

10, 01, 001, 1110, 00101011

Каждое число в двоичном значении, каждое значение «1» и «0» в наборе, называется битом (Bit, Binary digIT, двоичная цифра).

Для того чтобы работать с некими данными, компьютер должен преобразовать эти данные в их двоичное представление. Например, для того, чтобы сохранить десятичное число 12, компьютер должен преобразовать его в двоичную форму, а именно — в 1100.

Откуда компьютер знает, как производить подобные преобразования? Это — чистая математика. Это — двоичная система счисления, которую изучают в школах. Существуют правила преобразования десятичных чисел в двоичные и компьютер эти правила понимает.

Однако, числа — это не единственный тип данных, с которым мы работаем. У нас есть строки, изображения, и даже видео. Компьютер знает о том, как представлять в двоичном виде любые типы данных. Возьмём, например, строки. Как компьютер представит строку «L» в двоичном виде? Для того, чтобы сохранить строку в двоичной форме, компьютеру сначала надо преобразовать символы этой строки в числа, а затем надо конвертировать эти числа в их двоичное представление. Так, в случае с нашей строкой из одного символа, компьютеру сначала нужно преобразовать «L» в число, которое представляет этот символ. Посмотрим, как это делается в JavaScript.

Откройте консоль инструментов разработчика браузера и вставьте туда этот код:

"L".charCodeAt(0)

Теперь нажмите Enter. Что вы увидели? Число 76? Это — так называемое числовое представление, или код, или кодовая точка символа L. Но откуда компьютер знает, какое число соответствует некоему символу? Откуда ему известно, что число 76 соответствует букве L?

Наборы символов


Наборы символов — это заранее заданные правила, касающиеся соответствия символов их числовым кодам. Существует множество разновидностей таких правил. Например, весьма популярные — это Unicode и ASCII. JavaScript очень хорошо умеет работать с наборами символов Unicode. На самом деле, именно таблица символов Unicode используется в браузере для преобразования символа L в число 76, именно в ней записано соответствующее правило.

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

Кодировка символов


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

Один из наборов правил кодировки символов называется UTF-8. UTF-8 определяет правила преобразования символов в байты. Байт — это набор из восьми битов — восьми единиц и нулей. Итак, для представления кодовой точки любого символа должен быть использован набор из восьми единиц и нулей. Разберёмся с этим утверждением.

Как уже было сказано, двоичное представление десятичного числа 12 — это 1100. Итак, когда UTF-8 указывает на то, что число 12 должно быть представлено восьмибитным значением, это означает, что компьютеру нужно добавить несколько битов слева реального двоичного представления числа 12 для того, чтобы представить его в виде одного байта. В результате 12 должно быть сохранено как 00001100. А число 76 будет выглядеть как 01001100.

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

Если вам интересны тонкости кодировок символов, взгляните на этот материал, в котором всё это подробно раскрывается.

Теперь мы понимаем — что такое двоичные данные, но что такое потоки двоичных данных, которые мы упоминали выше?

Поток


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

Если вспомнить некоторые вещи из определения буфера, а именно, то, что там упоминаются «потоки двоичных данных… в контексте… файловой системы», можно понять, что речь идёт о перемещениях двоичных данных файлов, например, при чтении этих файлов для последующей работы с их содержимым. Скажем, мы читаем текст из file1.txt, преобразуем его и сохраняем в файл file2.txt.

А причём тут буфер? Как он помогает работать с двоичными данными, пребывающими в форме потока?

Буфер


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

Обычно перемещение данных производят для того, чтобы, как минимум, их прочитать и сделать доступными для возможной последующей обработки. Скажем, для того, чтобы принять на основе данных какое-то решение. Скорость обработки данных компьютером ограничена, поэтому можно говорить о неких рамках, представляющих минимальное и максимальное количество данных, которое какой-то процесс может обработать за некий промежуток времени. Итак, если скорость поступления данных больше, чем скорость, с которой они потребляются, избыточным данным нужно где-то подождать своей очереди на обработку.

С другой стороны, если система способна обрабатывать данные быстрее, чем они поступают, то некоему количеству данных, прибывших раньше, чем может быть начат очередной сеанс обработки некоего пакета данных, нужно подождать прихода ещё некоторого количества данных, прежде чем все они будут отправлены на обработку.

Эта «зона ожидания» и есть буфер! Физическим представлением буфера может являться пространство в оперативной памяти, где данные, при работе с потоком, временно накапливаются, ждут своей очереди, и в итоге отправляются на обработку.

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

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

В любом случае речь идёт о некоем «зале ожидания». Буфер в Node.js играет ту же роль. Node.js не может контролировать скорость поступления данных или время их прибытия. Он лишь может принимать решения о том, чтобы отправить на обработку данные, которые уже прибыли. Если время отправки данных на обработку ещё не пришло, Node.js поместит их в буфер — в «зону ожидания».

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

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

Если проигрыватель завершил воспроизведение данных, поступивших ранее, а буфер пока ещё не заполнен, надпись «буферизация» появится снова, система будет ждать, когда наберётся необходимое ей количество данных. Собственно говоря, в Node работа с буферами выглядит примерно так.

Из исходного определения буфера можно увидеть, что когда данные находятся в буфере, мы можем с ними работать. Что можно сделать с необработанными двоичными данными?

Работа с буферами


Реализация буфера в Node.js даёт нам массу вариантов работы с данными. Кроме того, можно создавать буферы самостоятельно, задавая их характеристики. Итак, помимо того буфера, который Node.js создаст автоматически в процессе передачи данных, можно создать собственный буфер и манипулировать им. Существуют разные способы создания буферов. Взглянем на некоторые из них.

// Создадим пустой буфер размера 10. 
// Этот буфер может вместить в себя только 10 байт.
const buf1 = Buffer.alloc(10);
// Создадим буфер с неким содержимым.
const buf2 = Buffer.from("hello buffer");

После создания буфера с ним можно начинать работать.

// Посмотрим на содержимое буфера
buf1.toJSON()
// { type: 'Buffer', data: [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] }
// Это будет в пустом буфере
buf2.toJSON()
// { type: 'Buffer',
     data: [ 
       104, 101, 108, 108, 111, 32, 98, 117, 102, 102, 101, 114 
     ] 
   }
// Метод toJSON() представляет данные как кодовые точки символов Unicode
// Узнаем размер буфера
buf1.length // 10
buf2.length // 12. Назначено автоматически, основываясь на размере данных, помещённых в буфер.
// Запись в буфер
buf1.write("Buffer really rocks!") 

// Декодирование содержимого буфера
buf1.toString() // 'Buffer rea'
//выглядит странно, но так как буферу buf1 при создании был назначен размер 10, он может вместить только 10 символов

Итоги


Теперь, когда вы понимаете, что такое «буфер», «поток» и «двоичные данные», вы можете открыть документацию по буферам и осмысленно поэкспериментировать со всем тем, о чём там идёт речь.
Кроме того, для того, чтобы увидеть, как с буферами работают на практике, почитайте исходный код библиотеки zlib.js. Это — одна из библиотек ядра Node.js. Посмотрите на то, как в этой библиотеке буферы используются для взаимодействия с потоками двоичных данных. Тут работа ведётся с файлами, представляющими собой gzip-архивы.

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

Уважаемые читатели! Как вы думаете, на какие базовые вещи, касающиеся Node.js, стоит обратить внимание начинающим разработчикам?

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (1):

  1. Andrew_Dewy
    /#10670448

    Очень не хватало такого подробного объяснения на русском. Спасибо)