В докладе поговорим про концепцию io.Reader/io.Writer, для чего они нужны, как их правильно реализовывать и какие в связи с этим существуют подводные камни, а также про построение pipelines на базе стандартных и кастомных реализаций io.Reader/io.Writer.



Стас Афанасьев. Juno. Pipelines на базе io.Reader/io.Writer. Часть 1

Баг «на доверии»


Ещё один нюанс: в этой реализации есть «багуля». Этот баг подтверждён разработчиками (я им об этом написал). Может, кто-то знает, что это за «багуля»? Она на слайде – это предпоследняя строка:



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

Я написал в issue: давайте что-нибудь делать, давайте поправим! И тут вскрылся пласт проблем… Во-первых, мне сказали, что если добавить эту проверку сейчас сюда, эту проверку придётся добавить везде, а этих мест десяток. Если мы хотим это переложить на сторону клиента, то нужно определить ряд правил, по которым клиент будет валидировать данные (а их тоже может быть пяток-другой). Получается, всё это нужно скопировать.

Согласен, что это не оптимально. Тогда давайте придём к какому-нибудь консистентному варианту! Почему у нас одна реализация стандартной библиотеки не доверяет ничему, а другие доверяют абсолютно всему?

В общем, пока я писал своё гражданское мнение, обдумывал его, мы закрыли issue с комментариями – «Мы ничего делать не будем. До свидания»! Меня выставили каким-то дурачком… Вежливо, конечно, не придерёшься.

В общем, мы сейчас имеем проблему. Она заключается в том, что не понятно, кто должен валидировать данные завёрнутого Reader’а. То ли клиент, то ли мы всецело доверяем контракту… Есть у нас одно решение! Если останется время, я о нём расскажу.

Давайте переходить к следующему кейсу.

TeeReader


Мы рассмотрели пример, как оборачивать данные Reader’а. Следующий пример пайпов – это данные Reader перегнать в Writer. Тут имеют место две ситуации.

Первая ситуация. Нам нужно вычитывать данные из Reader’а, каким-то образом скопировать их в Writer (прозрачно) и работать с этим, как с Reader’ом. Для этого есть реализация TeeReader. Она представлена в верхнем сниппете реализации:



Работает подобно команде Tee в Unix’е. Думаю, многие из вас об этом слышали
Обратите внимание, эта реализация проверяет количество байт, которые она вычитывает из обёрнутого Reader’а. Видите условия во второй строке? Потому что, когда пишешь такую реализацию, интуитивно понятно: в случае прихода отрицательного числа ты получишь panic. И это ещё одно место, где мы доверяем обёрнутому Reader’у! Напоминаю, всё это стандартные библиотеки.

Давайте перейдём к кейсу, к примеру того, как это использовать. Что мы будем делать на нижнем сниппете? Будем скачивать файл robot.txt скачивать с сайта golang.org при помощи стандартного http-клиента.

Как известно, http-клиент возвращает нам структуру response, у которой поле Body является реализацией интерфейса Reader. Следует уточнить, сказав, что это реализация интерфейса ReadCloser. Но ReadCloser – это всего лишь интерфейс, собранный из Reader и Closer. То есть это Reader, который можно, в общем-то, закрыть.

На этом примере (на нижнем сниппете) мы собираем TeeReader, который будет вычитывать данные из этого Body и записывать их в файл. Создание файла сегодня, к сожалению, осталось за кулисами, потому что всё не помещалось. Но, опять-таки, если вы посмотрите дендрограмму, тип file реализует интерфейс Writer, то есть в него можем писать. Это очевидно.

Собрали наш TeeReader и вычитываем при помощи ReadAll. Всё работает, как и ожидалось: мы вычитываем получаемые Body, записываем его в файл и видим его в Assad out.

Beginner way


Вторая ситуация. Нам нужно просто вычитать данные из Reader и записать их в Writer. Решение очевидно…

Когда я только начинал работать с Go, такие задачи решал, как на слайде:



Я лоцировал буфер, заполнял его данными из Reader’а и заполненный slice передавал в Writer. Всё просто.

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

Второй момент – это то, что данный путь не является оптимальным. Тут довольно шаблонный код, который написан до нас.

Для этого существует специальное семейство хелперов в стандартной библиотеке – это Copy, CopyN и CopyBuffer.

io.Copy. WriterTo и ReaderFrom


io.Copy в принципе делает то, что было на предыдущем слайде: он аллоцирует буфер по дефолту в 32 Кб и пишет данные из Reader в Writer (сигнатура этого Copy представлена на верхнем сниппете):



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

  • WriterTo;
  • ReadFrom.

Гипотетическая ситуация. Ваш Reader работает с буфером памяти. Он уже его прелоцировал, пишет, что-то вычитывает оттуда, то есть место под него уже прелоцировано. Вы хотите этот Reader вычитать где-то извне.

Мы уже посмотрели, как это происходит: создаётся, алоцируется буфер, который передаётся в метод Read; Reader, который работает с памятью, уже из прелоцированного кусочка перекидывает… Но это уже не оптимально – место прелоцировано. Зачем делать ещё раз?



Где-то 5-6 лет назад (есть ссылка на change list) сделали два интерфейса: WriteTo и ReadFrom, которые реализуются по месту. Reader реализует WriteTo, а Writer – ReadFrom. Получается, Reader, имея уже прелоцированный slice с данными, может избежать дополнительной локации и принять методы Write To Writer и передать доступный внутри буфер.

Так работает реализация bytes.Buffer и bufio. И если вы в очередной раз посмотрите на дендрограмму, то увидите, что эти два интерфейса не очень-то популярны. Они как раз реализованы у тех типов, которые работают с внутренним буфером – там, где память уже прелоцирована. Это не поможет вам избежать элокации всякий раз, а только в том случае, когда вы уже работаете с прелоцированным куском.

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

Реализация CopyBuffer


На этом сниппете представлена реализация хелпера copyBuffer. Этот не экспортируемый copyBuffer используется под капотом у io.Copy, CopyN и CopyBuffer.

И здесь имеется небольшой нюанс, о котором стоит сказать. CopyN был недавно оптимизирован – отвязан от этой логики. Это как раз та оптимизация, о которой я говорил ранее: прежде чем создать дополнительный буфер в 32 Кб, происходит проверка – может, источник данных реализует интерфейс WriterTo, и этот дополнительный буфер не нужен?

Если этого не происходит, мы проверяем: а может, Writer реализует ReaderFrom, чтобы их соединить без этого посредника? Если и этого не происходит, остаётся последняя надежда: может быть, нам передали какой-то прелоцированный буфер, который мы могли бы использовать?



Так примерно и работает io.Copy.

Есть одна issue, которая полу-proposal, полубаг – непонятно что. Она висит уже полтора года. Звучит она так: CopyBuffer семантически неправильный.

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

Когда вы вызываете copyBuffer в надежде избежать дополнительной локации, передаёте туда какой-то прелоцированный slice byte, работает следующая логика: если у Reader или Writer реализованы интерфейсы WriterTo и ReaderFrom, то нет никакой гарантии, что вам удастся избежать этой локации. Это приняли как proposal и обещали подумать об этом в Go 2.0. Пока это просто нужно знать.

Работа с io.Pipe. PipeReader и pipeWriter


Ещё один кейс: вам нужно данные из Writer’а каким-то образом получить в Reader’е. Довольно жизненный кейс.

Представьте, что у вас уже есть какие-то данные, они реализуют интерфейс Reader – всё с этим понятно. У вас есть потребность эти данные сжать, «позипать» и отправить в S3. В чём нюанс?..
Кто работал с типом gzip в пакете compess, знает, что сам gzip’ер – всего лишь прокси: он принимает в себя данные, реализует интерфейс Writer, он эти данные впишет, что-то с ними сделает, а затем должен куда-то их сбросить. На конструкторе он принимает реализацию интерфейса Writer.

Соответственно, здесь нам нужен какой-то промежуточный Writer, куда мы закинем уже сжатые данные, которые архивированы на первом этапе. Следующий наш ход – залить эти данные на S3. И стандартный клиент AWS’а принимает в качестве источника данных реализацию интерфейса io.Reader.



На слайде представлен pipeline – показано, как это выглядит: нам нужно перегнать данные перегнать из Reader в Writer, из Writer – в Reader. Как это сделать?

В стандартной библиотеке есть прикольная функция – io.Pipe. Она возвращает два значения: pipeReader и pipeWriter. Эта пара неразрывно связана. Представьте себе «детский телефон» на стаканчиках с верёвками: нет смысла говорить в один стаканчик, пока на другом конце никто не слушает…



Что делает этот io.Pipe? Он не будет вычитывать, пока данные никто не пишет. И наоборот, он не будет ничего писать, пока на другом конце эти данные никто не читает. Вот пример реализации:



Мы здесь будем делать то же самое. Будем вычитывать файл robot.txt, который вычитывали до этого, будем его сжимать при помощи нашего gzip и отправлять в S3.

  • На первой строке создаётся пара – pipeReader, pipeWriter. Далее мы должны запустить как минимум одну горутину, которая будет вычитывать данные с одного конца (своего рода труба). В этой горутине запускаем uploader с источником данных (источник – pipeReader).
  • На следующем этапе нам нужно сжать данные. Данные сжимаем и пишем в pipeWriter (он будет другим концом трубы), а уже запущенная горутина получает данные на другом конце трубы и вычитывает их. Когда весь этот бутерброд готов, остаётся только поджечь фитилёк…
  • Смотрите: io.Copy на последней строке пишет данные из Body в созданный нами gzip’ер (т. е. из Reader’а в Writer). Всё это отрабатывает так, как и ожидалось.

Этот пример можно решить и по-другому. Если вы используете какую-нибудь реализацию, которая реализует и Reader, и Writer. Вы будете в неё сначала записывать данные, а потом вычитывать их.
Это было наглядная демонстрация того, как работать с io.Pipe.

Другие реализации


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



Я ничего не сказал ни про MultiReader, ни про MultiWriter. А это ещё одни прикольные реализации стандартной библиотеки, которые позволяют соединять различные реализации. Например, MultiWriter пишет одновременно во все Writer’ы, а MultiReader – вычитывает Reader’ы последовательно.

Ещё одна реализация называется limio. Она позволяет задать лимитирование для вычитывания. Можно задать скорость в байтах в секунду, с которой нужно вычитывать ваш Reader.

Ещё одна интересная реализация – это просто визуализация прогресса чтения – Progress bar (от какого-то чувака). Называется ioprogress.

Зачем я всё это говорил? Что я хотел этим сказать?



  • Если вам вдруг необходимо реализовывать интерфейсы Reader и Writer, делайте это правильно. Нет пока единого решения, кто отвечает за реализацию – будем считать, что все доверяют контракту. Значит, вам нужно безукоризненно его соблюдать.
  • Если ваш случай – это работа с прелоцированным буфером, не забывайте про интерфейсы ReaderFrom и WriterTo.
  • Если вы попали в тупик и вам нужны примеры – смотрите стандартную библиотеку, там много классных реализаций, на которые можно опираться. Там есть документация.
  • Если же вам что-то совсем непонятно, то не стесняйтесь писать issues. Ребята там адекватные, отвечают быстро, очень вежливо и грамотно вам помогут.



На этом у меня всё. Спасибо, что пришли!

Вопросы


Вопрос из аудитории (В): – У меня вопрос простой, наверное. Расскажи, пожалуйста, о каких-нибудь use-кейсах из жизни: какие использовались и зачем? Ты говорил, что Reader / Writer возвращает длину, которую он прочитал. Были ли у тебя в практике случаи, когда с этим возникали проблемы; когда ты требовал прочитать (не просто ReadAll существует), а что-то не получалось?

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

В: – Это не совсем баг. Давай тогда расскажу о своём небольшом опыте. У меня была проблема с компанией Booking.com: они использовали драйвер, который я написал, и у них была проблема – что-то не доходило. Есть стандартный бинарный протокол, который мы делали; локально всё хорошо работает, у всех всё хорошо, но оказалось, что у них очень плохая сеть с дата-центром. Тогда Reader действительно возвращал не всё (плохие сетевые карты, ещё что-то).

СА: – Но если он возвращал не всё, то он не должен был возвращать признак конца (окончания), и клиент должен был прийти ещё раз. По контракту, который описан, Reader не должен… Скажем так, Reader, конечно, сам решает, когда он хочет приходить, когда не хочет, однако, если он хочет вычитать всё, то должен ждать EOF’а.

В: – Но это именно из-за соединения. Именно такая проблема возникала в стандартном пакете net.

СА: – И он возвращал EOF?

В: – Он не всё возвращал – просто не всё вычитывал. Я ему говорю: «Прочитай следующие 20 байт». Он читает. И у меня вычитывает не всё.

СА: – Гипотетически это возможно, потому что это всего лишь интерфейс, который описывает протокол коммуникации. Надо смотреть и конкретно разбирать кейс. Здесь я вам могут только ответить, что клиент, по идее, должен был прийти ещё раз, если он не получил всё, что хотел. Вы просили у него slice из 20 байт, он вычитал вам 15, но не пришёл EOF – надо бы ещё раз сходить…

В: – Для такой ситуации есть io.ReadFull. Он специально создан, чтобы вычитывать слайс до конца.

СА: – Да. Про ReadFull я ничего не сказал.

В: – Это вполне нормальная ситуация, когда Read заполняет не весь slice. К этому нужно быть готовым.

СА: – Это вполне ожидаемый кейс!

В: – Спасибо за доклад – было интересно. Я использую Reader’ы в маленьком простеньком прокси, который читает http и пишет в другую сторону. Я использую Close Reader, чтобы решить одну проблему – всё время закрывать то, что прочитал. Нужно ли мне слепо доверять контракту? Вы говорили о том, что там могут быть проблемы. Или добавить дополнительные проверки? Теоретически возможно, что на этом участке что-то придёт не полностью. Нужно ли мне эти дополнительные проверки делать и не верить контракту?

СА: – Я бы так сказал: если ваше приложение терпимо к этим ошибкам (например, если вы всецело доверяете контракту), то, возможно, нет. Но если вы не хотели бы получить у себя «панику» (как я показывал на негативном чтении в byte.Buffer), то я бы всё-таки проверял.
Но это – “up to you”.Что я могу вам порекомендовать? Думаю, просто взвесьте все за и против. Что будет, если вдруг вы получите отрицательное количество байт?

В: – Спасибо за доклад. К сожалению, я в Go ничего не знаю. Если «паника» произошла, есть ли какой-то способ это перехватить и вывести информацию о том, что, где, как и задебажить, чтобы в пятницу вечером избежать проблем?

СА: – Есть. Механизм Recover позволяет «панику» выловить и вывести её, не падая, условно говоря.



В: – Как ваши рекомендации по использованию реализаций Writer и Reader согласуются с ошибками, которые возвращаются при реализации веб-сокетов. Конкретный пример не приведу, но всегда ли там end of file используется? Насколько я помню, сообщение завершается какими-то другими значениями…

СА: – Вопрос хороший, потому что мне просто нечего на него ответить. Надо смотреть! Если не приходит EOF, то клиент, если хочет всё получить, должен ходить ещё раз.

В: – Насколько длинный pipe получалось собрать? Есть ли какие-то внутренние убеждения, что пайп больше пяти участников собирать не стоит, или с ветвлениями? Насколько длинное дерево получилось выстроить из этих пайпов (Read, Write)?

СА: – На моей практике примерно пять последовательных call’ов – это оптимально, потому что дальше сложнее дебажить, держать в голове, что и куда вытекает. Довольно ветвистая структура получается. Но я бы сказал где-то 5-7 максимум.

В: – 5-7 – это в каком кейсе?

СА: – Это чтение, например, каких-то данных. Вам нужно залогировать, причём то, что вы логируете, нужно обрезать. Залогировали – дальше вы эти данные вычитали – вам нужно отправить это ещё в какой-тол storage (ну, гипотетически). В любой storage, которые реализует интерфейс Writer. При таком пайпе 5-6 шагов происходит, притом что на одном из шагов он ещё ветвится куда-то в сторону, а вы продолжаете работать с Reader’ом.

В: – По Beginner way у вас был интересный слайд. Можете указать ещё 2-3 интересных момента, которые были, но сейчас их точно лучше не делать, а делать теперь по-другому?

СА: – Тем слайдом я хотел показать именно то, как делать не нужно касаемо вычитывания Reader’а. Сходу в голову мне ничего не приходит что-то вроде Beginner way… Это, наверное, основная ошибка, основной паттерн, которого следует избегать при работе с Reader’ами.
Ведущий: – Я бы добавил от себя, что начинающему очень важно прочитать всю документацию пакета io, на все интерфейсы, которые там есть, и понять их. Потому что на самом деле их там очень много, а вы часто начинаете делать что-то своё, хотя там это уже есть и правильно реализовано («правильно» – с учётом всех особенностей).
Вопрос ведущего: – Как дальше жить-то?

СА: – Хороший вопрос! Я обещал рассказать, если у нас останется время. По итогу обсуждения бага в LimitedReader появилось такое решение: сделать в некотором смысле Reader-«презерватив», который защищает от внешних угроз, оборачивает какой-то Reader, которому вы не доверяете – не пускать всякую заразу внутрь своей системы.

И в этом Reader’е вы реализуете все проверки, которые «нельзя» делать: например, негативное чтение, эксперименты с количеством байт (скажем, вы послали slice из 10 байтов, а вам вернулось 15 – как на это реагировать?)… В этом Reader’е и можно реализовать набор таких проверок. Я сказал: «Может, давайте добавим в стандартную библиотеку, потому что это было бы полезно использовать всем»?

Мне был дан ответ, что смысла в этом вроде как нет – это простая штука, которую можно реализовать самостоятельно. Всё. Живём дальше. Доверяем контракту, ребята. Но я бы не доверял.



В: – Когда мы работаем с Reader’ами, Writer’ами и есть возможность нарваться на gzip-«бомбу»… Насколько мы доверяем потоком ReadAll и WriteAll? Или всё-таки реализовать буферное чтение и работать только с буфером?

СА: – Сам ReadAll использует под капотом всего лишь bytes.Buffer. Когда вы хотите использовать ту или иную штуку, вам желательно влезть и посмотреть, как эти «кишочки» реализованы. Опять же, зависит от ваших требований: если вы нетерпимы к таким ошибкам, которые я показал, нужно посмотреть, проверяется ли то, что приходит из обёрнутого Reader’а. Если не проверяется – использовать, например, bufio (там всё это проверяется). Либо делать то, что я сейчас рассказал: некий прокси-Reader, который будет по вашему списку требований проверять эти данные и – либо возвращать их клиенту, либо возвращать клиенту.




Немного рекламы :)


Спасибо, что остаётесь с нами. Вам нравятся наши статьи? Хотите видеть больше интересных материалов? Поддержите нас, оформив заказ или порекомендовав знакомым, облачные VPS для разработчиков от $4.99, уникальный аналог entry-level серверов, который был придуман нами для Вас: Вся правда о VPS (KVM) E5-2697 v3 (6 Cores) 10GB DDR4 480GB SSD 1Gbps от $19 или как правильно делить сервер? (доступны варианты с RAID1 и RAID10, до 24 ядер и до 40GB DDR4).

Dell R730xd в 2 раза дешевле в дата-центре Equinix Tier IV в Амстердаме? Только у нас 2 х Intel TetraDeca-Core Xeon 2x E5-2697v3 2.6GHz 14C 64GB DDR4 4x960GB SSD 1Gbps 100 ТВ от $199 в Нидерландах! Dell R420 — 2x E5-2430 2.2Ghz 6C 128GB DDR3 2x960GB SSD 1Gbps 100TB — от $99! Читайте о том Как построить инфраструктуру корп. класса c применением серверов Dell R730xd Е5-2650 v4 стоимостью 9000 евро за копейки?