Andriy Shyrokoryadov

.Net developer, data scientist

Что такое stream и сериализация в C# - вопрос №32 на собеседование C# / .NET

Текст к видео "Что такое stream и сериализация в C#" на канале YouTube

Обсуждая различные вопросы связанные с языком программирования C# мы часто упоминаем объекты различных классов и переменные, то есть данные с которыми работает наше приложение. Обработка данных это постоянный процесс – данные считываются, изменяются, записываются, а затем процесс повторяется. Запись и чтение данных могут касаться совершенно различных носителей: базы данных, сетевые услуги, файлы. В двух словах так работает большинство приложений. В данном видео я хотел бы рассмотреть вопрос, чем конкретно являются данные и какие возможности работы с ними, предоставляет нам язык C#.

Итак, данные. Что это такое? Любые данные представляются собой набор байтов расположенных в определенном порядке. Данные между различными системами передаются в блоках определенного размера. Данный блок на английском языке называется chunk. Когда речь идет о взаимодействии между системами, то имеют в виду взаимодействие нашего приложения с другими приложениями (например в микросервисной архитектуре), чтение или запись данных из или в базу данных, чтение, запись или создание файлов. В каждом из этих случае мы работаем с наборами байтов сгруппированными с блоки, которые передаются в определенном направлении. В языке C# есть тип byte, которые представляет собой байт информации и мы можем любые данные которые есть у нас в приложении привести к типу byte[], то есть к массиву байтов, и далее переслать или записать этот массив в определенное место. Однако это решение не очень удобное. Для таких задач, в языке C# уже предусмотрено определенное решение. Это решение называется потоком данных или на английском языке Stream. Не стоит путать поток данных с потоком приложения, который мы рассматривали в видео на тему многопоточности. Хоть на русском языке эти два понятия звучат почти одинаково, на английском языке это 2 совершенно разных понятия: Stream и Thread. Сегодня мы рассмотрим то, что на английском языке называется Stream.

Давайте представим как может выглядеть наш код для записи определенного текста в файл. Текст будет находится в объекте типа StringBuilder. О данном классе у меня на канале есть видео – ссылка будет в верхнем правом углу. В том видео дано объяснение почему класс StringBuilder предпочтительнее использовать по сравнение с переменной типа string. Наш порядок действий для записи текста в объекте типа StringBuilder в файл будет следующий:

  • после добавления всего текста в объект класса StringBuilder, переводим данный объект в строку;
  • полученную строку переводим в байты;
  • байты записываем в новый файл или добавляем в существующий файл.

Как Вы уже догадались в этой схеме, где то должно быть место для потока данных. Действительно, поток данных это блок данных, который «перетекает» из источника данных к потребителю данных. В нашем случае источником данных будет строковая переменная переведенная в массив байтов, а потребителем данных будет файл. Данная концепция в коде отражается в классе Stream, который находится в пространстве имён System.IO. IO обозначает input / output, то есть ввод / вывод. Класс Stream предоставляет набор общих методов для взаимодействия с потоком байтов независимо от того, что из себя представляют источники и потребители данных. То есть одинаковый набор методов используется когда Вы записываете файл, передаете некоторую информацию по сети или отправляете что-то на распечатку на принтер.

Класс Stream является абстрактным классом. Об абстрактных классах на канале уже есть видео, ссылка будет в верхнем правом углу. Если концепция абстрактных классов Вам не знакома, просмотрите видео на эту тему, прежде чем двигаться дальше в вопросе потоков данных. Соответственно, чтобы воспользоваться классом Stream мы должны будет использовать один из наследников данного класса или написать свою имплементацию класса абстрактного класса Stream. Второе событие скорей всего не наступит в Вашей профессиональной жизни и вероятность этого события стремится к нулю. Со 100% уверенностью можно сказать что Вы будет пользоваться готовыми имплементациями класса Stream и данные имплементации Вы будет выбирать в зависимости от типов источника и потребителя данных. Ниже я хотел бы представить некоторые общие методы для всех потоков, но прежде чем я это сделаю я хотел бы привести пример потока из реальной жизни, чтобы мое последующее объяснение было более понятно.

Давайте представим себе какой-нибудь аудиоплеер или сервис потокового аудио, например Spotify. В этой ситуации музыка, которая играет в аудиоплеере или в сервисе Spotify является потоком, потоком байтов в определённой последовательности.

  • Если мы нажимаем кнопку Play, то можно сказать, что мы начинаем чтение потока данных, т.е. другими словами мы вызываем метод Read() на объекте класса Stream.
  • Когда играет музыка мы можем перенести ползунок в любую из доступных позиций, в начало музыкальной композиции или в конец, т.е. мы можем перемещаться по потоку в любом направлении, а сточки зрения класса Stream мы вызываем метод Seek().
  • Если бы в нашем плеере была возможности записи аудио и мы захотели что либо добавить к играющей композиции, то скорей всего мы бы воспользовались бы кнопкой Record на плеере; в объекте класса Stream в данной ситуации вызывается метод Write().
  • Некоторые плееры позволяют нам удалять аудиокомпозиции, в мире потоков такое действие называется Flush, т.е. обновление данных на текущий момент, предоставление этих данных и очистка буфера потока.
  • Естественно каждое приложение, будь то аудиоплеер или приложение Spotify можно закрыть. Это применимо и к объекту класса Stream. Вызывая Close метод мы закрываем поток.

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

Кроме методов, объекты класса Stream имеют ряд свойств которые доступны в каждой из конкретных имплементации данного абстрактного класса. Возвращаясь к примеру с аудиоплеером – у нас есть доступ к информации на каком месте музыкального произведения мы сейчас находимся и какова длина музыкального произведения. Обо эти свойства доступны в классе Stream и называются они соответственно Position и Length. Также мы всегда можем проверить можем ли прочитать данный поток, записать его или перемещаться по данному потоку. Данную проверку можно осуществить с помощью свойств CanRead, CanWrite, CanSeek соответственно.

Как уже было сказано ранее класс Stream является абстрактным. В среде .Net определенно несколько имплементации данного абстрактного класса. Я не могу Вам сказать точное число имплементаций, потому что в своей практике я время от времени сталкиваюсь с новыми имплементациями и уже наверное сбился со счета. Давайте рассмотрим основные наиболее часто встречающиеся и используемые имплементации класса Stream.

  • FileStream – это имплементация класса Stream рассчитанная на работу с файлами;
  • MemoryStream – это имплементация класса Stream, в которой хранилищем данных потока является память компьютера;
  • BufferedStream – это декоратор других потоков, который предоставляется функциональность создания буфера для потоков, которые он декорирует.
  • NetwrokStream – данный тип потока представляет собой тип для взаимодействия с данными передаваемыми по сети;
  • CryptoStream – этот тип потока объединяет в себе существующим поток с криптографическими трансформациями данных в существующем потоке.

Следующие несколько классов не являются имплементациями класса Stream однако облегчают работу с потоками

  • BinaryReader / BinaryWriter – данные классы позволяют прочитать из потока или записать в поток дискретные типы данных.
  • StreamReader / StreamWriter – это классы для работы с чтением / записью символьных данных, то есть текста. По умолчанию оба этих класса работают с кодировкой Unicode, однако эту настройку можно изменить. Данные классы наследуют классы TextReader / TextWriter соответственно.

Одним из вопросов на собеседовании, который может быть связан с потоками данных это вопрос о сериализаци и десериализации. Каждый объект в любом приложении имеет состояние, это состояние является значением всего свойств и полей объекта. Сериализацией называется процесс записи состояния объекта (с возможной последующей передачей) в поток. Десериализация это процесс обратный сериализации – из данных, которые находятся в потоке, мы можем восстановить состояние объекта и использовать этот объект в другом месте. Например, мы можем передать некий объект по сети. Для этого мы сериализуем данный объект и передаем его в поток, а на другом компьютере мы считываем этот поток и реконструируем этот объект. Таким образом объект с определенным состоянием был передан по сети. Сериализация и десериализация служат не только лишь для того, чтобы передавать файлы по сети. Данные 2 процесса используются везде, где можно использовать потоки. Например можно сказать что файл Microsoft Word с определенным текстом представляет собой некий объект с определённым состоянием. Запись этого файла на жесткий диск компьютера представляет собой сериализацию текста и запись его в файл с расширением docx.

При сериализации и десериализации необходимо обратить внимание на несколько моментов. Первое это то какой формат сериализации используется: бинарный, SOAP или XML. И второе, как данные форматы обрабатывают приватные поля и свойства. В случае использования бинарного формата и формата SOAP приватные поля и свойства будут сериализованы, однако для формата XML данные поля будут потеряны. Чтобы этого избежать при использовании формата XML необходимо обеспечить доступ к этим приватным полям через публичные свойства.

Каждый из вышеуказанных форматов представлен в языке C# определенным классом:

  • BinaryFormatter
  • SoapFormatter
  • XmlSerializer

Выбор формата зависит от того, как вы планируете использовать данные после сериализации. BinaryFormatter сериализует данные в компактную двоичную форму. С другой стороны 2 оставшихся форматера сериализуют объекты в универсальную форму, в которой даные объекты могут быть легко использованы в других операционных системах, приложениях и языках программирования.

Процесс сериализации поддается конфигурации. Данная конфигурация осуществляется путем применения атрибутов на свойства и поля, которые мы хотели бы сериализовать или, наоборот, проигнорировать при сериализации. Об атрибутах на моем канале снято отдельное видео – ссылка будет в правом верхнем углу. Каждый форматер имеет свой набор атрибутов, однако смысл их подобный: сериализация или игнорирование определенного поля или свойства объекта, а также можно указывать имя под которым данное свойство или поле будет сериализовано.