• Документы

    Перевожу Гугло-документы в обычный офис. Купил у Микрософта офисный пакет на год, не нарадуюсь. Работает очень быстро. Файлы открываются мгновенно, как-никак нативный C++ вместо Джаваскрипта.

    Конечно, офисный редактор на Джаваскрипте это целое искусство, но меня как пользователя это не должно волновать. Правда в том, что в нативном офисе можно открыть 10 документов и спокойно переключаться по ним в течение дня.

    В Гугловом офисе каждая вкладка живет в изолированном пространстве. Хром в произвольном порядке выгружает вкладки на диск. Казалось, 10 минут назад ты был в этом документе, переключаешься – и 5 секунд ждешь, пока вкладка подгрузится.

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

    Интерфейс Гугло-дравйва крайне убог и тоже тормозит. Создать в нем папку и скопировать файлы это целая история. Или ставь клиент Драйва для синхронизации с локальной папкой, но там тоже полная жесть.

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

    Неоценимый плюс Гугла состоит в шаринге и доступности таких онлайн-документов. Пошарить какую-то табличку или бриф между командой теперь проще простого. Но и у Микрософта есть какое-то там облачное хранилище, которое я еще не смотрел.

    Личные документы (особенно финансовые) следует хранить в нативном формате. Потому что порой ищешь данные за прошлый год и нужно БЫСТРО открыть 10 документов. В Хроме это просто невозможно.

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

  • Егор о поездке в Воронеж

    The meetup was really good. I would say, it was one of the best events I’ve took participation in. People were very into the topics, had a lot of questions, were aggressive and friendly enough) They paid for the entire travel, we had lunch, dinner, everything. A+ experience!

    Так-то!

  • Смерть в Фейсбуке

    Одна дура пишет в фейсбуке, что не увидела предсмертных постов ее друга. Думаю, все это слышали. Вроде как он писал, что ему плохо, что ложится в госпиталь, а Фейсбук эти посты той бабе не показал.

    Какой плохой Фейсбук.

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

    Лично у меня нет претензий к алгоритму Фейсбука.

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

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

    Предположим, тот чел не умер, а спокойно живет дольше. Общались бы они с той дурой? Поехали бы к друг другу в гости? Нет же. Зато смерть псевдо-друга, которого ты знаешь только по лайкам, отличный повод погнать на Фейсбук, выступить перед журналистами, нагнать трафика.

    Короче, некрасиво пиариться на смерти человека, к которому не имеешь отношения.

  • Встреча с Егором. Видео

    Егор выступил в клубе Глубокого Рефакторинга:

    Особенно рекомендую второй доклад:

  • Тентакли и прокси

    Значит, купил я ноду на Digital Ocean с целью поднять на ней прокси для обхода блокировок. На LinkedIn зайти, например. Кто не знает, на Маке и Линуксе это делается командой

    ssh -D 8123 -f -C -q -N user@your.ip.address
    

    Далее, чтобы гнать не весь трафик через ноду а только часть (иначе будет тормозить) в Хром ставится расширение Proxy SwitchySharp, которое позволяет менять соединение по правилам. Например, все, что матчится на linkedin.com идет через прокси, остальное – по прямому соединению.

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

    Первая же ссылка говорила за себя: в названии было что-то про аниме, группы и тентакли. Открыл – работает.

    В то же самое время я обсуждал с заказчиком свое увольнение. Отвечая на вопрос в духе “Иван, чем вы думаете заняться после выхода из проекта”, я нажал что-то левое, и ссылка из буфера обмена уходит заказчику.

    А у него в Швейцарии нет Роскомнадзора и некому защитить его от тентаклей.

    Ясное дело, удалил эту ссылку с быстротой молнии, но сих пор не знаю, видел он или нет. Заблокирован ли тот сайт в его стране? Успела ли открыться превьюшка?

    Больше никаких таких ссылочек во время общения с заказчиком.

  • Канал в Телеграме

    И раз уж пошла такая пьянка: завел канал в Телеграме. Все-таки, как бы я его ни хаял, Телеграм стал достойным способом поделиться информацией. Буду валить туда все, что пишу в блог, а заодно всякую мелочь, ради которой лень вставать.

    Вступайте: https://t.me/igrishaev_blog

  • Отвечу на вопросы

    Я отвечаю на письма читателей, и порой текст получаются столь развернутыми, что потянул бы на отдельный пост. Поэтому ввожу новую рубрику ответов в блоге. Укажите в письме, что не против публикации здесь, и ответ смогут прочесть все читатели. Конечно, я уберу личные данные (город, место работы, только имя).

    Какие вопросы мне задавать? Хотя бы на следующие темы:

    • программирование и разработка ПО,
    • функциональный подход,
    • Кложа, Емакс,
    • работа, в т.ч. удаленная,
    • книги, в т.ч. детские (у меня хорошая подборка книг для детей),
    • организация рабочего времени, “сделывание” задач,
    • переезд в другой город,
    • английский,
    • опен-сорс.

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

    Почта ivan@grishaev.me

  • First Clojure stream in English

    Last Sunday, I tried to stream some Clojure coding commenting it in English. Although I was nervous a bit and thus sounded weird, I think the result worth publishing it here:

  • Разбираемся в часовыми поясами. Инструкция по безопасной работе со временем

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

    Зоны и их преобразования – достаточно тяжелая для изучения тема. Это не проходят в институтах, об этом мало статей на популярных ресурсах. Разработчики учатся работать со временем методом проб и ошибок.

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

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

    Не устану повторять случай с коллегой, который работал в лондонском стартапе. Лондон это такой город, от которого отсчитываются часовые пояса, потому что через него проходит нулевой меридиан. Иначе говоря, локальное и общемировое времена в Лондоне одинаковы (не считая обозначения зоны, конечно).

    Пока разработка шла в Лондоне, никто и не думал ни про какие зоны, и код писали без их учета. Разработчик с зоной +3 столкнулся с тем, что не проходит ни один тест, потому что время прибито гвоздями без учета пояса. В итоге коллега проработал у них год сидя с переведенными часами. Было проще поменять зону локально, чем исправить код.

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

    В нашем проекте фронтендер решил, что он умнее системы и что нужно помочь клиенту перевести локальное время в UTC. И тем самым заложил в систему трудноуловимый баг. При отправке объекта Date через Ajax первый автоматически приводится к общемировому времени. Затем срабатывал обработчик коллеги, который тоже корректировал пояс. Получается, что мы дважды вычитали локальное время на клиенте и тем самым портили любую дату с сервера.

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

    Я заметил, что откровенно плохо обстоит со временем у фронтендеров. Это связано с тем, что время – скорее серверная часть приложения, и к тому же требует некоторой подготовки и мышления. Чаще всего ошибки случались на стороне клиентской части.

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

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

    Для работы со временем лучше сразу брать хорошую стороннюю библиотеку. Время настолько сложная вещь, что редкий язык может похвастаться качественной коробочной реализацией. В Джаваскрипте объект Date убог. В Джаве класс java.util.Date был объявлен deprecated уже с версии 1.1, то есть почти сразу. Родной datetime в Питоне приемлем только для базовых задач.

    Не нужно думать, что “мне только оберточку написать”, зато без зависимостей. Время – важно. Пусть будут зависимости, но меньше багов.

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

    (Конечно, часовые пояса идут не идеально ровно как полоски на арбузе. Еще недавно в России меняли временные зоны – объединяли и делили соседние. Так, стараниями Медведева в моей родной Чите зимой в 16 часов было уже темно как ночью. Но для простоты будем считать, что пояса распределены равномерно.)

    Поэтому фраза “созвонимся в три часа” в общемировом масштаба не значит ничего. По чьему времени в три часа? По Москве? Нью-Йорку? Очевидно, нужна особая метка чтобы обозначить, откуда это время получено. Тогда путем простых вычислений можно перевести время из одной метки в другую. Эта метка и называется временной зоной.

    Обозначение у нее может быть разное, например смещение в часах (+03:00), имя части света и города через косую черту (Europe/Berlin) или одна из аббревиатур (CET, Центральное Европейское время), но это уже тонкости стандартов кадирования. Важно то, что теперь время привязано к конкретной местности и может быть преобразовано в универсальное время UTC.

    UTC – универсальное координированное время – это современный стандарт отсчета, пришедший на смену GMT. Не будем сейчас разбирать его особенности, достаточно сказать, что на шкале поясов он играет роль нуля: именно от него отсчитываются отрицательные (западные) и положительные (восточные) зоны.

    Например, я живу в Воронеже, мой пояс третий по счету на восток, поэтому я могу написать в резюме “when calling, please consider my +3 UTC timezone” или просто “my TZ is +3”. Тогда заказчик из Лондона, у которого нулевой пояс, добавит к своему времени 3 часа и получит мое локальное время.

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

    Ваш сервер и база данных отвечают за хранение дат. Современные БД (здесь и далее – PostgreSQL) предоставляют два типа полей: это timestamp with time zone и timestamp without time zone, то есть с зоной или без. Если в первое поле записать дату с зоной, то БД сконвертирует ее в UTC, потому что так удобней. А при выводе этой даты в утилитах она будет представлена в локальном времени того пользователя, кто сейчас ее просматривает.

    Рассмотрим пример:

    create table foo (
      ts_z timestamp with time zone,
      ts timestamp without time zone
    );
    
    CREATE TABLE
    
    insert into foo (ts_z) values
      ('2017-12-13T10:00:00-05:00'::timestamp with time zone);
    
    INSERT 0 1
    
    select * from foo;
    

    Результат:

              ts_z          | ts
    ------------------------+----
     2017-12-13 18:00:00+03 |
    (1 row)
    

    Я создал таблицу с двумя полями: время с зоной и без. Потом записал в поле с временной зоной время с часовым поясом Нью-Йорка. В базе оно будет храниться как 2017-12-13 15:00:00 UTC. Но при выводе я увижу время в консоли в моем локальном времени. Все верно, мой пояс +3, дата была приведена верно.

    А в поле ts нужно писать только время по UTC, иначе будет ошибка:

    insert into foo (ts) values
      ('2017-12-13T10:00:00-05:00'::timestamp with time zone);
    
    INSERT 0 1
    
    select * from foo;
    
              ts_z          |         ts
    ------------------------+---------------------
     2017-12-13 18:00:00+03 |
                            | 2017-12-13 18:00:00
    (2 rows)
    

    Видим, что во второе поле записалось не то, что нужно: правильное время по UTC должно быть 15 часов, а не 18. Вот как нужно было сделать:

    insert into foo (ts) values
      ('2017-12-13T10:00:00-05:00'::timestamp with time zone AT TIME ZONE 'UTC');
    
    INSERT 0 1
    
    select * from foo;
    
              ts_z          |         ts
    ------------------------+---------------------
     2017-12-13 18:00:00+03 |
                            | 2017-12-13 18:00:00
                            | 2017-12-13 15:00:00
    (3 rows)
    

    Эта и другие ошибки будут рассмотрены ниже.

    В различных библиотеках и ORM при выборке подобных дат они восстанавливаются в объекты языка с заполненной часовой зоной (поля tz, zone, tzinfo и тд).

    Вариант дат без зон тоже имеет право на жизнь, и на мой взгляд он проще. В команде вы просто соглашаетесь, что все даты хранятся как локальное время по UTC. Все библиотеки имеют функции и методы для работы с таким временем, например, datetime.utcnow() в Питоне, moment.utc() в JS и тд.

    Клиент и сервер должны обмениваться друг с другом только датами в UTC. Универсальное время переводится в локальное на стороне клиента исходя из локального смещения относительно нулевого меридиана. Эту величину можно определить методом .getTimezoneOffset() объекта Date на клиенте.

    При передаче дат клиенту вы кодируете их в ISO-формат с зоной. Если зона UTC, она обозначается буквой Z. Таким образом, в текстовом представлении время, отправляемое клиенту будет выглядеть так:

    "2017-12-12T14:12:23.502Z"
    

    Слева направо: год, месяц, день, разделитель, часы, минуты, секунды, микросекунды, индикатор нулевой зоны UTC.

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

    На этом этапе нельзя не заметить, насколько убог формат JSON: он не может передавать даты и не расширяется.

    В обратную сторону с клиента на сервер даты передаются в таком же формате: ISO-строка в UTC с Z на конце в качестве зоны.

    Рассмотрим, что делать клиенту с полученной строкой. Легче всего восстановить ее в объект библиотекой moment.js:

    moment("2017-12-12T14:20:05.849Z").format()
    "2017-12-12T17:20:05+03:00"
    

    Видим, что все правильно: мой пояс +3, часы были увеличены на эту разницу, на конце индикатор +03:00.

    В сторону: сейчас кто-то встанет и начнет заливать, что moment.js уже не популярен, и что горячие парни пишут какую-то новую либу на замену ей. Мой совет – шлите лесом таких советчиков. Нам нужно проверенное решение, а не модная поделка.

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

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

    При передаче JSON-тела на сервер объект Date не нужно приводить в ISO-строку вручную. Для этого существует метод .toJSON(), который автоматически будет вызван при кодировании внешнего словаря. Например:

    var d = new Date();
    Tue Dec 12 2017 18:40:48 GMT+0300 (MSK)
    
    JSON.stringify({foo: 42, bar: d})
    "{"foo":42,"bar":"2017-12-12T15:40:48.573Z"}"
    

    Рассмотрим основные ошибки, связанные с обработкой времени и как с ними бороться. Начнем с сервера.

    • Тип поля указан timestamp with time zone, но вы передаете объект времени без зоны.

    В этом случае сервер подставит текущую зону, то есть ту, в которой расположен сервер. Если пользователь расположен далеко, например, на другом континенте, это может вызвать значительные неудобства. Скажем, пользователь из Нью-Йорка (-5 UTC) передал на сервер время "2017-12-12T14:20:00Z". Сервер расположен в Швейцарии (+1 UTC). По какой-то причине зона была отброшена, и в базу вместо "2017-12-12T23:20:00 UTC" (исходное плюс 5 часов) записалось "2017-12-12T13:20:00 UTC" (исходное минус 1 час). Ошибка в 10 часов! Представьте, что это напоминания о поездке или рассылка смс.

    • Тип поля timestamp without time zone, но в базу пишется локальное время сервера.

    Убедитесь, что для получения текущего времени вы используете функцию с utc в ее имени, например, datetime.utcnow() вместо datetime.now() и аналогично в других языках. В PostgreSQL аналогично: почти для всех вызовов временных функций нужно указывать зону UTC. Сравните:

    > select current_timestamp;
           now
    -------------------------------
     2017-12-12 19:03:07.683706+03
    
    
    > select current_timestamp at time zone 'UTC';
         timezone
    --------------------------
     2017-12-12 16:04:21.7177
    

    Перевести зону из одной в другую на уровне БД можно следующим оператором:

    select
      '2017-12-13T10:00:00-05:00'::timestamp with time zone AT TIME ZONE 'UTC'
      as right_time;
    
         right_time
    ---------------------
     2017-12-13 15:00:00
    

    Теперь клиент.

    • С сервера приходят UTC-даты в ISO, но без метки Z, например "2017-12-12T14:20:05".

    Попытка отобразить такую дату с помощью moment.js, как мы делали выше, приведет к ошибке:

    moment("2017-12-12T14:20:05").format()
    "2017-12-12T14:20:05+03:00"
    

    Вариантов тут два: либо приклеить к строке со временем нужную зону, либо явно указать библиотеке, что это время в UTC:

    moment.utc("2017-12-12T14:20:05").local().format()
    "2017-12-12T17:20:05+03:00"
    

    Метод .utc() парсит строку с учетом того, что результат будет время по UTC. Метод .local() преобразует его к локальному времени на основании смещения, которое берется из браузера. Теперь все правильно.

    • С сервера приходит его локальное время.

    Это беда, но лечится явным указанием зоны. Например, вы в Москве, а сервер в Нью-Йорке. Он отдает локальное время "2017-12-12T14:20:05". Нью-Йорк в минус пятом часовом поясе. Приклеим зону к строке и распарсим:

    moment("2017-12-12T14:20:05" + "-05:00").format()
    "2017-12-12T22:20:05+03:00"
    

    Значит, это 22:20 вечера по Москве. Проверим устно: если от НЙ до нулевого меридиана 5 часов, а потом еще 3 часа до Москвы, то в сумме 8 часов. 14 + 8 = 22 часа местного времени.

    • Сервер отдает время в виде “unix epoch”.

    Это значит в количестве секунд со дня рождения ОС Юникс – 1 января 1970 года. Такое время всегда представлено в UTC. Отобразить его пользователю можно так:

    moment.unix(1513107997).format()
    "2017-12-12T22:46:37+03:00"
    

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

    Обобщу вышесказанное.

    Научиться работать со временем не сложно. Главное – не оправдывать лень и не дожидаться момента, когда съедут все даты вечером в пятницу. Время – важно.

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

    Храните даты на сервере либо с временной зоной UTC, либо без нее, но подразумевая это.

    Клиент и сервер должны пересылать даты только в UTC с нулевой зоной Z.

    Локальное время выводится на клиенте из UTC относительно локального смещения пользователя.

    При кодировании дат в JSON последний сам позаботится часовом поясе. Вы не должны что-то прибавлять или вычитать.

    Маленький бонус: рассмотрим как быть, если формировать локальное время нужно не на клиенте, а на сервере. До сих пор я вел разговор в том ключе, что у нас Single page application и все рендериться на клиенте. Однако, чисто серверные приложения никуда не делись, и такая задача тоже может возникнуть.

    Представим, что даты хранятся в UTC, но вот пришел HTTP-запрос от какого-то пользователя, и нужно показать даты в его локальном формате. Как быть?

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

    Но страна вовсе не означает время. Так, пользователь Воронежа может сидеть через прокси или Тор с выходной нодой где-то в Индии, но при это его локальное время не имеет отношения к этой стране.

    Узнать локальное смещение можно через специальный метод объекта Date:

    (new Date()).getTimezoneOffset()
    -180
    

    Оно выражено в минутах и имеет знак противоположный часовому поясу. Если у меня зона +3, то смещение будет -180 минут.

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

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

    Рассмотрим код на Питоне:

    from datetime import datetime, timedelta
    
    utc = datetime.utcnow()
    loc = utc - timedelta(minutes=-180)
    
    utc # datetime.datetime(2017, 12, 13, 13, 57, 18, 108606)
    loc # datetime.datetime(2017, 12, 13, 16, 57, 18, 108606)
    

    Локальное время на 3 часа больше, теперь эту дату можно показывать пользователю.

    Это был пример явной манипуляции с часовыми поясами. Выше я писал, что этого следует избегать. Однако, здесь явно присутствует понимание того, что мы делаем. Простой вычет будет эффективней и проще использования навороченной библиотеки с часовыми поясами.

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

    Награда для тех, кто прочел до конца:

  • Пробный стрим про Кложу и Биткоин

    Вчера стримил Кложу. Писал библиотеку-обертку для популярных Биткоин-кошельков: Bitcoin.Core и Electrum. Неплохо пролучилось для первого раза, на мой взгляд. Надо попробовать стрим на английском. Код на Гитхабе.

Страница 39 из 75