Журнал

Должны ли аналитики и техписатели читать код, и в каком количестве нужна документация

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

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

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

  1. Разработчик сам пишет документацию в общих словах по ключевым алгоритмам и в рамках выполнения тикета обновляет рабочую документацию в Wiki. Рабочкой пользуются все внутри команды, там написано разрабами для разрабов, но и другим членам команды понятно.
  2. Когда надо сдавать официальные доки, техписатель из рабочки создает документацию по всем ГОСТам, написанную для заказчика и его специалистов. Если что-то непонятно — читает код. Если что-то совсем не понятно — спрашивает разрабов.

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

По поводу степени подробности документирования: тут важна золотая середина. Мало подробностей или ноль — документация бесполезна и никому не нужна (однако немного документации все-таки лучше, чем ноль). Много подробностей — много времени, чтобы ее постоянно актуализировать (или она устаревает), и ее тяжело хорошо структурировать. Мы исходили обычно из потребности. Т.е. есть какая-то база, дока на которую должна быть обязательна: краткое описание функции сервиса, перечень его контрактов, взаимодействие со внешними системами, крупными мазками схема с архитектурой (в мире ведь не только микросервисы с рестами бывают), конфигурация (если там что-то не очевидно). Кроме базы есть еще нюансы, нетривиальности и т.п., которые нужно описывать с бизнесовой или функциональной точки зрения, чтобы не только понимать, почему в коде наверчена нетривиальная шляпа, но и понять, когда ее можно убрать. И бывают сложные реализации, когда по какой-то причине не получилось сделать просто — это тоже описываем. На всякую мелочевку по реализации ответят код и юнит-тесты. А все остальное скорее всего очевидно и вряд ли кому-то нужно.

Я думаю, что документация должна быть частью процесса, но не единой сущностью с кодом. Все эти javadoc, doxygen и т.п. хороши для библиотек, когда тебе по-быренькому надо посмотреть что-нибудь и лень читать нормальную документацию. В обычном коде комментарии нужны только для неочевидной шляпы (и это реально редкий кейс). Документация именно кода скорее вредна — это будет либо капитан очевидность, либо суррогат нормальной документации. Если код нормально написан, то он самоописательный (самодокументирующийся).

СсылкаКомментировать

Выбор библиотеки ассертов для проекта на Kotlin

В одном из старых проектов в кучу были навалены ассерты из JUnit, kotlin.test и AssertJ. Это было не единственной его проблемой: его вообще писали как письмо Дяди Федора, а времени остановиться и привести к единому виду не было. И вот это время пришло.

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

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

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

Немного бэкграунда

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

Когда на старой работе начали писать на Kotlin, через какое-то время даже делали свою библиотечку, имитирующую поведение скаловских матчеров, но сейчас она имеет мало смысла после появления Kotest (хотя в ней лучше было реализовано сравнение сложных структур, с рекурсивным выводом более подробного сообщения).

Требования

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

  1. Бесшовная интеграция с Kotlin и IntelliJ Idea. Scala-библиотеки по этому принципу отпадают — извращаться с настройкой двух рантаймов нет желания. Несмотря на это, ScalaTest будет присутствовать в сравнении как отправная точка, просто потому что с ним много работал. Под интеграцией с IntelliJ я подразумеваю возможность клика на <Click to see difference>, чтобы увидеть сравнение реального значения и ожидаемого. Эта фича, вообще говоря, работает в кишках IntelliJ Idea — но ведь разработчики Kotlin-библиотек наверно про нее все-таки слышали и могут решить эту проблему, да?
  2. Возможность быстро понять проблему. Чтобы было не 1 != 2 и стектрейс, а нормальное сообщение, содержащее в идеале название переменной и разделение на "expected" и "actual". Чтобы для коллекций было сообщение не просто "два множества на 100 элементов не равны, вот тебе оба в строковом представлении, ищи разницу сам", а подробности, например "... эти элементы должны быть, а их нет, а вот эти не должны, но они есть". Можно конечно везде описания самому писать, но зачем тогда мне библиотека? Как выяснилось чуть позже, название переменной — это была влажная мечта, и при недолгих раздумьях будет очевидно, что это не так-то просто сделать.
  3. Адекватность записи. assertEquals(expected, actual) — Йоды стиль читать сложно мне, вкусовщина однако это большая. Кроме того, я не хочу задумываться о тонких нюансах библиотеки — в идеале должен быть ограниченный набор ключевых слов/конструкций, и чтобы не надо было вспоминать особенности из серии "это массив, а не коллекция, поэтому для него нужен особый метод" или помнить, что строка не contains, а includes. Другими словами — это одновременно читаемость и очевидность как при чтении, так и при написании тестов.
  4. Наличие проверки содержания подстроки. Что-то вроде assertThat("Friendship").contains("end").
  5. Проверка исключений. В качестве контр-примера приведу JUnit4, в котором исключение ловится либо в аннотацию, либо в переменную типа ExpectedException с аннотацией @Rule.
  6. Сравнение коллекций и содержание элемента(ов) в них.
  7. Поддержка отрицаний для всего вышеперечисленного.
  8. Проверка типов. Если ошибка будет выявлена компилятором — то это гораздо круче, чем если она будет выявлена при запуске теста. Как минимум, типизация не должна мешать: если мы знаем тип ожидаемого значения, то тип реального значения, возвращенного generic-функцией, должен быть выведен. Контр-пример: assertThat(generic<Boolean>(input)).isEqualTo(true). <Boolean> тут лишний. Третий вариант заключается в игнорировании типов при вызове ассерта.
  9. Сравнение сложных структур, например двух словарей с вложенными контейнерами. И даже если в них вложен массив примитивов. Все же знают про неконсистентность их сравнения? Так вот ассерты — это последнее место, где я хочу об этом задумываться, даже если это отличается от поведения в рантайме. Для сложных структур по-хорошему должен быть рекурсивный обход дерева объектов с полезной информацией, а не тупо вызов equals. Кстати в той недо-библиотеке на старой работе так и было сделано.

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

Конкурсанты

Перед написанием этой статьи я думал, что достаточно будет сравнить штук 5 библиотек, но оказалось, что этих библиотек — пруд пруди.

Я сравнивал следующие библиотеки:

  1. ScalaTest — как опорная точка для меня.
  2. JUnit 5 — как опорная точка для сферического Java-разработчика.
  3. kotlin.test — для любителей multiplatform и официальных решений. Для наших целей — это обертка над JUnit, но есть нюансы.
  4. AssertJ — довольно популярная библиотека с богатым набором ассертов. Отпочковалась от FestAssert, на сайте которого по-японски торгуют сезамином по всем старым ссылкам на документацию.
  5. Kotest — он же KotlinTest, не путать с kotlin.test. Разработчики пишут, что их вдохновлял ScalaTest. В кишках есть даже сгенерированные функции и классы для 1-22 аргументов — в лучших традициях scala.
  6. Truth — библиотека от Гугла. По словам самих создателей, очень напоминает AssertJ.
  7. Hamсrest ­— фаворит многих автотестировщиков по мнению Яндекса. Поверх нее еще работает valid4j.
  8. Strikt — многим обязан AssertJ и по стилю тоже его напоминает.
  9. Kluent — автор пишет, что это обертка над JUnit (хотя на самом деле — над kotlin.test), по стилю похож на Kotest. Мне понравилась документация — куча примеров по категориям, никаких стен текста.
  10. Atrium — по словам создателей, черпали вдохновение из AssertJ, но потом встали на свой путь. Оригинальная особенность — локализация сообщений ассертов (на уровне импорта в maven/gradle).
  11. Expekt — черпали вдохновение из Chai.js. Проект заброшен: последний коммит — 4 года назад.
  12. AssertK — как AssertJ, только AssertK (но есть нюансы).
  13. HamKrest — как Hamсrest, только HamKrest (на самом деле от Hamcrest только название и стиль).

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

Эволюция методики оценки

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

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

Потом решил, что все-таки надо защититься от банальных ошибок, и средствами JUnit сделал валидатор, который проверяет, что все тесты, которые должны были завалиться, завалились, и что нет неизвестных тестов. Когда наткнулся на баг в ScalaTest, решил сделать две вариации: одна, где все тесты проходят, вторая — где ничего не проходит и дополнил валидатор. Внимательный читатель может спросить: а кто стрижет брадобрея и какие ассерты использованы там? Отчасти для объективности, отчасти для переносимости ассертов там вообще нет:). Заодно будет демо/аргумент для тех, кто считает, что ассерты не нужны вообще.

Затем я оказался на распутье: выносить ли или нет константы типа listOf(1,2,3)? Если да — то это упоротость какая-то, если нет — то при переписывании теста на другие ассерты обязательно ошибусь. Составив список библиотек, которые стоит проверить для претензии на полноту исследования, я плюнул и решил решить эту проблему наследованием: написал общий скелет для всех тестов и сделал интерфейс для ассертов, который нужно переопределить для каждой библиотеки. Выглядит немного страшновато, зато можно использовать как розеттский камень.

Однако есть проблема с параметризованными проверками и type erasure. Reified параметры могут быть только в inline-функциях, а их переопределять нельзя. Поэтому хорошо заиспользовать конструкции типа

assertThrows<T>{...}

в коде не получится, пришлось использовать их дополнения без reified параметра:

assertThrows(expectedClass){...}

Я честно немного поковырялся в этой проблеме и решил на нее забить. В конце концов, в kotlin.test есть похожая проблема с интерфейсом Asserter: ассерт на проверку исключения в него не включен, и является внешней функцией. Чего мне тогда выпендриваться, если у создателей языка та же проблема?:)

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

Результаты

Дальше тупо суммируем баллы по требованиям: 0 — если требование не выполнено, 0.5 — если выполнено частично, 1 — если все в целом ок. Максимум — 9 баллов.

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

Библиотека Интеграция Описание ошибки Читабельность Подстрока Исключения Коллекции Отрицания Вывод типов Сложные структуры Итого
Kotest ± ± + + + + + нет + 7.0
Kluent ± ± + + + + + нет - 6.0
AssertJ ± + ± + ± + + нет ± 6.0
Truth ± + + + - + + нет - 5.5
Strikt ± ± ± + + + + нет - 5.5
ScalaTest ± ± ± + + + + нет - 5.5
HamKrest ± - ± + + ± + да - 5.5
AssertK ± ± ± + ± + + нет - 5.0
Atrium ± ± ± + + ± + нет - 5.0
Hamсrest ± ± ± + - ± + да - 5.0
JUnit + + - ± + - ± игнор - 4.5
kotlin.test + ± - - + - - да - 3.5
Expekt ± ± - + - ± + нет - 3.5

Примечания по каждой библиотеке:

Kotest
  • Чтобы подключить только ассерты, надо немного поковыряться. На мой взгляд сходу это понять тяжело.
  • Не имеет варианта ловли исключения с явным параметром, а не reified, но это на самом деле не особо и нужно: мало кто будет заниматься такими извращениями.
  • Сложные структуры: тест с вложенными массивами не прошел. Я завел на это тикет. UPD: Через месяц его пофиксили, и мой тест прошел. Разработчик даже сделал пулл-реквест в мой репозиторий с тестами.
  • Интеграция: <Click to see difference> есть только для простых ассертов.
  • Типизация: иногда при использовании дженериков надо писать им явный тип.
  • Описание ошибок: почти идеальное, не хватило только подробностей отличия двух множеств.
Kluent
  • Можно писать как "hello".shouldBeEqualTo("hello"), так и "hello" `should be equal to` "hello". Любителям DSL понравится.
  • Интересная запись для ловли исключения:
    invoking { block() } shouldThrow expectedClass.kotlin withMessage expectedMessage
    
  • Описания ошибок в целом отличные, не нет подробностей отличия двух коллекций, что не так. Еще расстроила ошибка в формате Expected Iterable to contain none of "[1, 3]" — непонятно, что на самом деле проверяемый Iterable содержит.
  • Интеграция: <Click to see difference> есть только для простых ассертов.
  • Сложные структуры: тест с вложенными массивами не прошел.
AssertJ
  • Впечатляющее количество методов для сравнения — еще бы запомнить их... Надо знать, что списки надо сравнивать через containsExactly, множества — через hasSameElementsAs, а словари — через .usingRecursiveComparison().isEqualTo.
  • Интеграция: <Click to see difference> есть только для простых ассертов.
  • Исключения: ловится просто какое-то исключение, а не конкретное. Сообщение об ошибке, соответственно, не содержит название класса.
  • Сложные структуры: есть .usingRecursiveComparison(), который почти хорошо сравнивает. Однако ошибку хотелось бы иметь поподробнее: ассерт определяет, что значения по одному ключу не равны, но не говорит по какому. И несмотря на то, что он корректно определил, что два словаря с массивами равны, отрицание этого ассерта
    assertThat(actual)
        .usingRecursiveComparison()
        .isNotEqualTo(unexpected)
    
    сработало некорректно: для одинаковых структур не завалился тест на их неравенство.
  • Типизация: иногда при использовании дженериков надо писать им явный тип. Видимо, это ограничение DSL.
Truth
  • Подробные сообщения об ошибках, иногда даже многословные.
  • Исключения: не поддерживаются, пишут, что надо использовать assertThrows из JUnit5. Интересно, а если ассерты не через JUnit запускают, то что?
  • Читаемость: кроме прикола с исключением, странное название для метода, проверяющего наличие всех элементов в коллекции: containsAtLeastElementsIn. Но я думаю на общем фоне это незначительно, благо тут можно для сравнения коллекций не задумываясь писать assertThat(actual).isEqualTo(expected).
  • Интеграция: <Click to see difference> только для примитивного ассерта.
  • Сложные структуры: тест с вложенными массивами не прошел.
  • Типизация: тип не выводится из ожидаемого значения, пришлось писать явно.
  • Веселый стектрейс с сокращалками ссылок "для повышения читаемости":
    expected: 1
    but was : 2
      at asserts.truth.TruthAsserts.simpleAssert(TruthAsserts.kt:10)
      at common.FailedAssertsTestBase.simple assert should have descriptive message(FailedAssertsTestBase.kt:20)
      at [[Reflective call: 4 frames collapsed (https://goo.gl/aH3UyP)]].(:0)
      at [[Testing framework: 27 frames collapsed (https://goo.gl/aH3UyP)]].(:0)
      at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)
      at [[Testing framework: 9 frames collapsed (https://goo.gl/aH3UyP)]].(:0)
      at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)
      at [[Testing framework: 9 frames collapsed (https://goo.gl/aH3UyP)]].(:0)
      at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)
      at [[Testing framework: 17 frames collapsed (https://goo.gl/aH3UyP)]].(:0)
      at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.processAllTestClasses(JUnitPlatformTestClassProcessor.java:99)
      ...
    
Strikt
  • Не имеет варианта ловли исключения с явным параметром, а не reified, но это на самом деле не особо и нужно: мало кто будет заниматься такими извращениями.
  • Отрицание для содержания подстроки в строке выглядит неконсистентно: expectThat(haystack).not().contains(needle), хотя для коллекций есть нормальный expectThat(collection).doesNotContain(items).
  • Читаемость: для массивов надо использовать contentEquals. Та же проблема с отрицанием: expectThat(actual).not().contentEquals(unexpected). Более того, надо еще думать о типе, потому что для Array<T> Strikt почему-то не смог определить нужный ассерт сам. Для списков — containsExactly, для множеств — containsExactlyInAnyOrder.
  • Типизация: иногда при использовании дженериков надо писать им явный тип. Более того, для массивов нужно еще с вариацией правильно тип подобрать. Посмотрите на этот кусочек кода:
    val actual: Array<String> = arrayOf("1")
    val expected: Array<String> = arrayOf("2")
    expectThat(actual).contentEquals(expected)
    
    Он не скомпилируется, потому что компилятор не сможет определить перегрузку для contentEquals. Это происходит потому, что нужный contentEquals определен с ковариантным типом:
    infix fun <T> Assertion.Builder<Array<out T>>.contentEquals(other: Array<out T>)
    
    Из-за этого надо писать
    val actual: Array<out String> = arrayOf("1")
    val expected: Array<String> = arrayOf("2")
    expectThat(actual).contentEquals(expected)
    
  • Интеграция: нет <Click to see difference>.
  • Описание ошибки: нет подробностей для словаря и массивов, а целом довольно подробно.
  • Сложные структуры: тест с вложенными массивами не прошел.
ScalaTest
  • Интеграция: при сравнении коллекций нельзя открыть сравнение.
  • Описание ошибки: в коллекциях написано, что просто не равны. Для словаря тоже подробностей нет.
  • Читабельность: надо помнить об особенностях DSL при отрицании и contains, отличии contains и include, а также необходимости theSameElementsAs.
  • Сложные структуры: тест с вложенными массивами не прошел, на это есть тикет.
  • Типизация: тип не выводится из ожидаемого значения, пришлось писать явно.
Hamkrest
  • Проект, судя по тикетам, в полузаброшенном состоянии. Вдобавок документация весьма жиденькая — пришлось читать исходный код библиотеки, чтобы угадать название нужного матчера.
  • Ожидал, что достаточно сменить импорты Hamcrest, но не тут-то было: довольно многое тут по-другому.
  • Запись ловли исключений — зубодробительная:
    assertThat( {
        block()
    }, throws(has(RuntimeException::message, equalTo(expectedMessage))))
    
  • Коллекции: нет проверки наличия нескольких элементов. Пулл-реквест висит 3,5 года. Написал так: assertThat(collection, allOf(items.map { hasElement(it) })).
  • Поддержки массивов нет.
  • Сложные структуры: тест с вложенными массивами не прошел.
  • Интеграция: нет <Click to see difference>.
  • Описание ошибки — как-то ни о чем:
    expected: a value that not contains 1 or contains 3
    but contains 1 or contains 3
    
AssertK
  • Как можно догадаться из названия — почти все совпадает с AssertJ. Однако синтаксис иногда немного отличается (нет некоторых методов, некоторые методы называются по-другому).
  • Читаемость: Если в AssertJ написано assertThat(collection).containsAll(items), то в AssertK та же конструкция сработает неправильно, потому что в нем containsAll принимает vararg. Понятно, что цель на containsAll(1,2,3), но продумать альтернативный вариант стоило бы. В некоторых других библиотеках есть похожая проблема, но в них она вызывает ошибку компиляции, а тут — нет. Причем разработчикам проблема известна — это один из первых тикетов. Вдобавок, нужно отличать containsOnly и containsExactly.
  • Интеграция: нет <Click to see difference>.
  • Исключения: ловится просто какое-то исключение, а не конкретное, потом его тип надо отдельно проверять.
  • Сложные структуры: аналога .usingRecursiveComparison() нет.
  • Типизация: иногда при использовании дженериков надо писать им явный тип.
  • Описания ошибок — подробности есть (хоть и не везде), но местами странные:
    expected to contain exactly:<[3, 4, 5]> but was:<[1, 2, 3]>
     at index:0 unexpected:<1>
     at index:1 unexpected:<2>
     at index:1 expected:<4>
     at index:2 expected:<5>
    
    Вот почему тут на первый индекс два сообщения?
Atrium
  • Поставляется в двух вариантах стиля: fluent и infix. Я ожидал отличий вида assertThat(x).isEqualTo(y) против x shouldBe y, но нет, это expect(x).toBe(y) против expect(x) toBe y. На мой взгляд весьма сомнительная разница, с учетом того, что инфиксный метод можно вызвать без "инфиксности". Однако для инфиксной записи иногда нужно использовать объект-заполнитель o: expect(x) contains o atLeast 1 butAtMost 2 value "hello". Вроде объяснено, зачем так, но выглядит странно. Хотя в среднем по больнице мне нравится infix-ассерты (вертолеты из-за скаловского прошлого), для Atrium я писал во fluent-стиле.
  • Читабельность: странные отрицания: notToBe, но containsNot. Но это не критично. Пришлось думать, как сделать проверку наличия нескольких элементов в коллекции: contains принимает vararg, а containsElementsOf не может вывести тип, сделал тупой каст. Понятно, что цель на contains(1,2,3), но продумать альтернативный вариант стоило бы. Отрицание наличия нескольких элементов записывается как expect(collection).containsNot.elementsOf(items).
  • Поддержки работы с массивами нет, рекомендуют преобразовывать через toList.
  • Не имеет варианта ловли исключения с явным параметром, а не reified, но это на самом деле не особо и нужно: мало кто будет заниматься такими извращениями.
  • Сложные структуры: тест с вложенными массивами не прошел.
  • Интеграция: нет <Click to see difference>.
  • Описание ошибки: местами нет подробностей (при сравнении словарей, например), местами описание довольно запутанное:
    expected that subject: [4, 2, 1]        (java.util.Arrays.ArrayList <938196491>)
    ◆ does not contain:
      ⚬ an entry which is: 1        (kotlin.Int <211381230>)
        ✘ ▶ number of such entries: 1
            ◾ is: 0        (kotlin.Int <1934798916>)
        ✔ ▶ has at least one element: true
            ◾ is: true
    
  • Типизация: иногда при использовании дженериков надо писать им явный тип.
Hamcrest
  • Читабельность: странный синтаксис для отрицаний (либо
assertThat(actual, `is`(not(unexpected)))

либо

assertThat(actual, not(unexpected))

Надо знать нюанс containsString vs contains vs hasItem vs hasItems. Пришлось думать, как сделать проверку наличия нескольких элементов в коллекции: hasItems принимает vararg, а Set<T> без знания T просто так не преобразуешь в массив. Понятно, что цель на hasItems(1,2,3), но продумать альтернативный вариант стоило бы. Получилось в итоге

assertThat(collection, allOf(items.map { hasItem(it) }))

С отрицанием еще веселее:

assertThat(collection, not(anyOf(items.map { hasItem(it) })))
  • В продолжение этой вакханалии с hasItems, я поставил ± в графу "коллекции", потому что лучше б не было ассертов, чем такие.
  • Исключения: отдельной проверки нет.
  • Интеграция: нет <Click to see difference>.
  • Описание ошибки: для коллекций нет подробностей.
  • Сложные структуры: тест с вложенными массивами не прошел.
JUnit
  • Читабельность: Йода-стиль assertEquals(expected, actual), надо помнить нюансы и отличия методов: что массивы надо сравнивать через assertArrayEquals, коллекции через assertIterableEquals и т.п.
  • Описание ошибок: для тех случаев, когда у JUnit все-таки были методы, оно было вполне нормальным.
  • Подстрока: через assertLinesMatch(listOf(".*$needle.*"), listOf(haystack)) конечно можно, но выглядит это не очень.
  • Отрицания: нет отрицания для assertLinesMatch, что логично, нет отрицания для assertIterableEquals.
  • Коллекции: нет проверки содержания элемента, assertIterableEquals для Map и Set не подходит совсем, потому что ему важен порядок.
  • Сложные структуры: тупо нет.
kotlin.test
  • Очень бедно. Вроде как это должна быть обертка над JUnit, но методов там еще меньше. Очевидно, что это расплата за кроссплатформенность.
  • Проблемы те же, что и у JUnit, и плюс к этому:
  • Нет проверки подстроки.
  • Нет даже намека на сравнения коллекций в лице assertIterableEquals, нет сравнения массивов.
  • Типизация: JUnit'у пофиг на типы в assertEquals, а kotlin.test ругнулся, что не может вывести тип.
  • Описание ошибок: не по чему оценивать.
Expekt
  • Можно писать в двух стилях expect(x).equal(y) и x.should.equal(y), причем второй вариант не инфиксный. Разница тут ничтожна, выбрал второй.
  • Читабельность: contains(item) против should.have.elements(items) и should.contain.elements(items). Причем есть приватный метод containsAll. Пришлось думать, как сделать проверку наличия нескольких элементов в коллекции: should.have.elements принимает vararg. Понятно, что цель на should.have.elements(1,2,3), но продумать альтернативный вариант стоило бы. Для отрицания нужно еще вспомнить про any: .should.not.contain.any.elements.
  • Типизация: тип не выводится из ожидаемого значения, пришлось писать явно.
  • Поддержки исключений нет.
  • Поддержки массивов нет.
  • Сложные структуры: тест с вложенными массивами не прошел.
  • Описание ошибки: просто разный текст для разных ассертов без подробностей.
  • Интеграция: нет <Click to see difference>.

Заключение

Лично мне из всего этого разнообразия понравились Kotest, Kluent и AssertJ. В целом я в очередной раз опечалился тому, как фигово работать с массивами в Kotlin и весьма удивился, что нигде кроме AssertJ нет нормального рекурсивного сравнения словарей и коллекций (да и там отрицание этого не работает). До написания статьи я думал, что в библиотеках ассертов эти моменты должны быть продуманы.

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

Опросы:

  • А какая библиотека больше всего нравится вам?
  • Какой библиотекой пользуетесь?
СсылкаКомментировать

Как найти что-то из своего во ВКонтакте и немного grep-магии

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

Disclaimer: Длительное чтение историй своих переписок 10-летней давности может привести к необратимым повреждениям психики и к потере кучи времени. Повторять описанное стоит с максимальными мерами предосторожности.

Кому не интересно нудное описание, что есть и чего нет в архиве — листайте до заголовка Ищем информацию

Скачиваем архив с данными

С этим все почти просто. Заходим по ссылке, запрашиваем архив. Есть нюанс: для скачивания понадобится привязка к мобильному устройству и/или к мобильному телефону, так что если вы до сих пор злостный анон, ВК захочет вас послать. Не забудьте в настройках поставить все галочки — по умолчанию они не все установлены.

Мой архив был готов через пару часов после запроса.

Смотрим, что внутри

Первое, что бросается в глаза — очень маленький размер архива, всего 13 мегабайт. Я в ВК 14 лет и в моей жизни был период, когда "вся жизнь там". Очень сомнительно звучит, что "это самый полный архив".

Внутри лежит поганый HTML. С одной стороны ­— удобно для обывателя, но с другой — я ожидал выгрузку в машиночитаемом формате. Те же операторы связи закинут список ваших звонков в CSV. Или телега, которая дает выбор — либо красивую HTML-ку, либо JSON (правда, для индивидуальных чатов — только HTML). Ну ладно, это не самое страшное.

Вишенкой к формату идет кодировка — конечно же, это всеми любимая CP1251. Ну, хоть не KOI8-R, и на том спасибо.

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

Что в итоге есть?

Информация о профиле

Которую и так видно на главной странице. Всякие вузы, интересы, даты рождения, отношения к алкоголю и т.п.

Список друзей

Для каждого друга покажут: текущее имя или DELETED, ссылку на страницу и дату добавления в друзья (текстом). При этом примерно до июня 2009 года текст даты будет "очень давно".

Подарки

Картинка, ссылка на профиль подарившего, дата.

Подписки

Тупо список пабликов с ссылкой на них. Иногда с пометкой "Администратор" или "Владелец".

Черный список

В формате списка друзей, только вместо "очень давно" — "Дата добавления неизвестна". И DELETED может быть не только ссылкой, но и просто текстом. Спасибо, ВК, теперь я знаю, что у меня в черном списке есть

DELETED
Дата добавления неизвестна

Сообщения

Разбиты по диалогам. Только текстовая информация. Даже картинки указаны голой ссылой, а не в <img> теге. Даты, разумеется, только текстом. Часть контента просто заменяется текстом, например, сообщение может содержать только "пересланное сообщение" без ссылки — и решай ребус, что же ты там такого переслал. Особенно если это чат с DELETED. Похожая ситуация с "1 прикреплённое сообщение" и "Запись на стене".

Вы, 17 мая 2018 в 16:39:27
2 прикреплённых сообщения

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

Стена

Примерно в том же формате, что и сообщения, но еще есть ссылка на оригинал. Репосты раскрываются, а не показываются дурацкосй строкой вроде "Запись на стене". Однако "Аудиозапись" и "Граффити" — без ссылки. Посты от других людей на стене тоже есть. И даже есть ссылка на комментарии к записи, которые в этом же архиве.

Лайки

5 категорий: Фото, Видео, Стена, Заметки, Товары. В каждой — список ссылок. Тут хотя бы логично, хотя насколько я знаю, даты лайков ВК тоже знает.

Фотографии

Альбомы из профиля и вдобавок фотографии со страницы, со стены, сохраненные и из "фото со мной". Для альбомов есть даты сохранения и обновления. Фотки в таком же формате, как и стена, со ссылкой на комменты. Но зато сама фотка вставлена <img> тегом.

Видео

Загруженные и добавленные. Как фотки (есть превьюшка), есть еще длительность и количество просмотров. Но прямой ссылки на скачивание нет.

Музыка

Список композиций с именем и длительность, без ссылок.

Документы

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

Товары

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

Истории

Aka "сториз". Не умею таких делать, и тут у меня тоже пусто.

Приложения

Список текущих приложений с названием, ссылкой, правами доступа в формате "кучей через запятую" и датой в формате "Дата последнего запуска неизвестна".

Закладки

Нет, не те. Страницы, записи, видео, товары, статьи, ссылки, подкасты. Везде "Данных нет".

История изменений

Для имени, почты и телефонов. У меня там пусто, но вот немного печалит, что мне суют всяких DELETED, когда ВК все это про них знает. Привязки к телефонам по классике: "Привязан номер телефона 1234547". Ишь чего, машиночитаемый формат захотел!

Платежи

Все, чего у меня нет: карты, история платежей, голоса, голоса внутри приложений, подписки, история переводов.

Реклама

Это уже поинтереснее: всего этого не посмотришь на сайте. Ретаргетинг:

Вы попали в 3271 группу ретаргетинга
Кабинет пользователя

Что бы это ни значило.

Интересы
Бизнес
Пользовательский интерес

ИТ (компьютеры и софт)
Пользовательский интерес

Образование
Пользовательский интерес

iPad
Системный сегмент

Firefox
Системный сегмент

Windows 10
Системный сегмент

Android (бюджетные)
Системный сегмент

Друзья именинников — всех, 3 дня
Системный сегмент

Друзья именинников — всех, 7 днeй
Системный сегмент

Друзья именинников — женщин, 3 дня
Сторонний сегмент

Друзья именинников — женщин, 7 дней
Сторонний сегмент

Образование
Пользовательский интерес

Наука и техника
Пользовательский интерес

Общество
Пользовательский интерес

Интересы к "Windows 10" и "Android (бюджетные)" даже натолкнули на мысль, что меня ломанули. Но в целом нормальный список, понимаешь, почему тебе суют рекламу IT-конференций.

В "Кабинетах" и "Часто посещаемых местах" закономерно пусто, но про последние не очень ясно, что они делают в рекламе.

Прочее

"Обращения в Поддержку": только в новую, в старую — нет. Блокировки: когда блокировали вход в страницу и почему. "Сеансы авторизации": только недавние, с именем, User-Agent и IP. "Импортированные контакты": из почты, телефона, Twitter, FB, G+ и ОК — там у меня пусто. "Верификация": для тех, у кого галочка рядом с именем.

Комментарии

Одной большой кучей. Текст, ссылка на оригинал и дата. Комментарии могут быть:

  • к публикациям на вашей стене.
  • к публикациям на чужих стенах, в т.ч. в пабликах. Причем даже если владелец стены их скрыл.
  • комментариями от имени сообщества, если писал от его имени.
  • ВНЕЗАПНО публикациями в пабликах — когда пишешь на стену паблика от своего имени. Почему ВК считает это комментарием — непонятно.

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

Немного удручает, что нет никакой информации о том, что комментируешь, только ссылка. Особенно прикольно, когда есть id стены, профиль удален, из текста непонятно, кому ты это писал — и решай ребус, что это за чел. Иногда помогает гугление (причем гугл может не помнить, а яндекс выдаст результат, но, опять же, не для всех).

Чего в архиве точно нет

Если вы надеялись, что в архиве будет суперполное досье на вас, то у меня для вас печальные новости: ВК явно хранит данных о вас гораздо больше, чем присылает в архиве. Я приведу список того, чего точно нет, но я наверняка о чем-то забыл. А чего-то вообще не знаю.

Обсуждений в группах

Имеются в виду те, которые отдельно от стены.

Постов от имени сообщества

Если вы админ сообщества и на стену публиковали записи от имени сообщества, то их не будет. Не важно, подписывались ли вы своим именем или нет. Забавно с учетом того, что комментарии от имени сообщества в архиве есть.

Постов на чужих стенах

С учетом того, что записи на стенах пабликов есть ­— очень странно.

Некоторых комментариев

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

Обращений в старую поддержку и баг-репортов

Отчетливо помню, как парочку багов заводил. Баг-репорты, видимо, сгинули вместе со старым баг-трекером, как и обращения в ТП. Второе может быть объяснено тем, что обращение осуществлялось через обсуждения в специальном паблике.

Мнений

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

Закладок людей

Да, в ВК можно добавлять людей в закладки. У меня там 25 человек, которые не в друзьях. И этого в архиве нет, хотя другие закладки есть.

Немного про безопасность

Все ссылки на фото (даже которые в личке грузили!) являются публично доступными. СЕСУРИТИ. Документы хоть закрыты — но за это ВК уже нагибали. Так что если вы слали нюдсы в личных сообщениях, то не такие уж они и приватные. Хотя вряд ли это вас смутит, если вы шлете дикпики через интернет.

Ищем информацию

Текст

Наконец-то можно приступить к поиску иголки в стоге сена. Начнем с поиска текста. Самый очевидный вариант — это grep, но если вы попробуете что-то поискать, то вас будет ждать облом. Потому что кодировка не та. Чтобы это исправить, поможет iconv, который умеет конвертировать данные между разными кодировками:

grep -Rnia "$(iconv -t CP1251 <<<'привет')" . | iconv -f CP1251

Не забудьте флаг a, который говорит grep, что мы работаем с бинарным форматом, иначе он будет спамить текстом Binary file matches.

Однако и с такой командой есть проблема: несмотря на флаг -i, который нам говорит игнорировать регистр, grep ничего не знает о нашей кодировке и не знает, что 'п' и 'П' — это одна буква. Поэтому спереди надо дописать LC_CTYPE=ru_RU.CP1251.

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

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

grep -Raioh 'https://vk.com/wall[^?]*' ../Archive/comments | sort | uniq

Довольно много комментариев будут на вашей стене, лучше отфильтровать по своему id. Повторюсь, что есть скрытые комментарии. Судя по всему, они появились следующим образом: пользователь закрыл комменты к своей стене. По крайней мере, API ВК пишет Access to post comments denied.

Картинки

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

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

Включаем grep-магию:

grep -RiohPa "attachment__link' href='\K.*?(?=')" ../Archive/messages | grep 'userapi' | xargs -P 10 -n 1 curl -sO

Что тут происходит? Мы занимаемся запрещенными темными искусствами — парсим HTML с помощью регулярных выражений :) И не простых, а перловых (-P), да еще с positive look-ahead ((?=)) и zero-width look-behind (\K). Флаг -o говорит нам о том, что нас интересует только совпадение, т.е. сама ссылка, которая находится после href в одинарных кавычках. Подробнее про это можно почитать на Unix StackExchange.

Потом выбираем ссылки только на картинки, которые хранятся на серверах ВК. А дальше с помощью xargs в 10 потоков скачиваем все через curl. Подробнее про связку xargs с curl. Клево, но будут потенциальные проблемы, если что-то не получится загрузить. Тогда было бы мудрее записать все в файл, убрав дубликаты (у меня штук 30 было), и потом сверять список скачанного и список из файла.

Если нужны картинки из фоток, то можно немного поменять первую команду:

grep -RiohPa "img src=\"\K.*?(?=\")" ../Archive/photos/

Ссылки

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

grep -Raioh 'https\?://[^"<'\'']*' ../Archive | grep -v -e 'vk.com/wall' -e 'www.w3.org' -e 'vk.com/id' | sort | uniq > all_links.txt

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

Можно посмотреть топ доменов ваших ссылок:

cat all_links.txt | grep -ioP '://\K[^/]*' | sort | uniq -c | sort -n

Но предварительно лучше разобраться с поддоменами, чтобы были более четкие данные. Я особо не парился и писал ручками обрезку домена третьего уровня через sed:

cat all_links.txt | grep -ioP '://\K[^/]*' | sed 's/.*.userapi.com/userapi.com/g' | sed 's/.*.joyreactor.cc/joyreactor.cc/g' | sort | uniq -c | sort -n

Заключение

Я на самом деле хотел только картинки посмотреть. А потом одно посмотрел, другое посмотрел, да и затянуло. Разумеется, в обычных условия я не позволи бы себе столько времени потратить на такую чушь — не повторяйте мою ошибку :). Но, признаюсь, чтение некоторых старых переписок навеяло... воспоминания. Эмоции. Пищу для размышлений и рефлексии. Так что, может быть, эти часы копания в прошлом были потрачены не совсем зря.

СсылкаКомментировать

Впечатления от CSS, или как я галерею со смешнявками делал

Disclaimer: Я не фронтендер. И вообще не особо разбираюсь во всем этом. Наверняка специалисты закидают меня субстанциями за то, что сделал что-то криво и вообще "вы не разобрались". Все впечатления сильно субъективные и зависят от моего круга общения.

Предыстория

Какие-то потуги в сайтостроении у меня были еще в школе, в далеких 2000-х. Статичные сайтики, табличная верстка (sic!), инлайн-стили. Картинка на заднем фоне была тогда вау-эффектом, взаимодействие с пользователем — на грани фантастики, javascript был очень дозированно, jQuery не то что не устарел — его тогда не существовало, а отсупы go <br> <br> <br> <br>.

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

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

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

CSS

Когда хотел немного поменять разметку стандартной темы ­— особо и вариантов не было, но благо базовые знания были и разбирался со всем быстро. Потом захотелось немного красивостей навроде плашек с тегами ­­— нашел пару примеров, на их основе делал: немного псевдоэлементов c font-awesome — и тоже получилось. Приятным сюрпризом оказалась легкая возможность менять стили для печати страницы, но тут уже не все так гладко: когда сегодня рефакторил стили, выяснил, что есть еще нерешенные проблемы.

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

На волне эйфории от создания статического сайта я решил, что буду делать все на чистом CSS. Ну, а что — игры на чистом CSS делают, вот даже есть бродилка от первого лица. Позже выяснилось, что бродилка в себе до фига javascript содержит и ни фига она не pure css, но на хабре была в районе 2014-2015 года была тема про игры на чистом CSS, и там, насколько помню, все было честно. В любом случае на момент старта у меня в голове была картинка, что CSS очень мощный и с ним можно сделать почти все:

Первое, что заметно — очень много материалов. Хорошие доки на Mozilla CDN, если там не понятно — гугл выплюнет 300 тыщ блогпостов, где все написано как для даунов. Становится понятно, откуда так много "вайти в айти" в этой области, хотя скорее тут положительная обратная связь и другие факторы тоже играют. Тем не менее, порог вхождения ощутимо низкий.

Однако это касается простых вещей. Почти все материалы, которые мне попадались — "практические": сделай так и все. Нет глубокого объяснения, почему это работает так, а не иначе. Нет фундамента, цельной картины, разъяснения внутренних механизмов.

При этом в самом CSS очень много нюансов. Эти нюансы подскажет браузер, но иногда доходит до абсурда: все готово, осталось чуть-чуть поправить, и тут тебе надо поменять несколько свойств у родителя и детей. Или вообще все выбросить и переписать на flexbox, потому что "там проще". Например, много свойств меняют свое значение или не выполняются, если ты значение display поменяешь.

Этими нюансами CSS мне напоминает химию. Ты не можешь оследить все взаимодействия на уровне атомов, поэтому тебе очень много вещей надо просто запомнить. Даже хороший химик не всегда тебе может объяснить, почему реакция происходит именно так — он просто знает, что она так проходит, есть экспериментальные данные. И он это это запомнил, как и особенности кучи других реакций.

Но CSS ведь не наука о сложной природе, а абстракция, созданная человеком. Однако тут получается, что людям проще и быстрее попробовать, чем реально изучить фундаментально. Как в машинном обучении:

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

Тем не менее, я получил большое удовлетворение, когда в итоге у меня получилось сделать галерею на чистом CSS с ленивой подгрузкой изображений. Результаты можно посмотреть тут, а исходники — в developer-tools или тут. Но не могу сказать, что я на 100% уверен, что делает каждая строчка CSS, и что будет, если ее убрать. Особенно с учетом того, что некоторые завязаны друг на друга и меняют свое поведение.

Но были и печальные моменты. Знаете, что было самое сложное? Сделать резиновый квадрат. Да-да, можно делать игры, можно сделать галерею на чистом CSS, будучи нубом как я, но резиновый квадрат это уже сложно. Хотя, по идее, именно ради этого CSS и должен был развиваться, разве нет? Я задал вопрос на StackOverflow, спросил у нескольких фронтендеров — никто не знает, как это решить. Есть конечно нюансы, но это хороший пример того, что в CSS не все в порядке. Подробнее можете прочитать в вопросе. Особенно меня позабавил чувак, который предложил jQuery для решения этой проблемы ("верните мой 2007", как говорится).

Знатно у меня пригорело от того, что простые вещи делаются сложнее "сложных". У меня реально существенно больше времени ушло на верстку превьюшек (хотя казалось бы, чего там сложного — накидал квадратиков, да картинки в них), чем на элементы управления (стрелки, переход на следующую картинку и т.п.). Я думал, все эти смешнявки про то, как тяжело добиться нужного форматирования в CSS — это приколы из серии "ты забыл точку с запятой и компилятор тебе выдал 1000 ошибок, ну ты и нуб", а это оказалось суровой реальностью.

Итого

А итог получается смешанный. С одной стороны — на CSS можно сделать много всего, но сложно. Или нельзя, и надо добавить чуть-чуть JS. Или генерации на сервере. А потом удивляемся, почему страницы там медленно грузятся и статичные read-only страницы не отображаются нормально или крашат браузер. Реальная история: с моего старого планшета почти невозможно читать какие-нибудь фэндом-вики на *.fandom.com — постоянные вылеты при скролле. Кто-то скажет "купи новый планшет", но черт побери, там сложность контента как на википедии — простыня текста да пара картинок. С учетом всего этого многие выбирают простой путь и генерят все фреймворками на JS. И вообще, какой смысл от стилей, если все равно сервак нужен? Можно тогда джаваскриптом получать viewport размеры и на сервере по пикселям все генерить? И не удивлюсь, что где-то так и есть.

СсылкаКомментировать

Как убить на мелкий скрипт кучу времени или история одного пулл-реквеста

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

Понадобилось мне дублировать информацию из markdown-заметок в телеграм-канал. Казалось быть, что тут рассусоливать — Ctrl+C и Ctrl+V в помощь. Однако выяснился маленький нюанс: markdown в телеге не совсем полноценный и ссылки в таком формате [text](http://example.com) клиент не поддерживает. Ладно, подумал я, попробуем что-то с этим сделать.

Пытаюсь победить врукопашную

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

Может, получится копированием из браузера или текстового редактора? Снова облом и еще один баг в телеге: при использовании буфера обмена форматирование сообщения теряется. Я заинтересовался, почему же так произошло.

В этом нам поможет утилита xclip, которая позволяет инспектировать содержимое буфера обмена. Для сообщения телеги она показывает такие доступные форматы:

text/plain
UTF8_STRING
STRING
TEXT
application/x-td-field-text
application/x-td-field-tags

Сравните это с теми, что предоставляет Firefox:

text/html
text/_moz_htmlcontext
text/_moz_htmlinfo
UTF8_STRING
COMPOUND_TEXT
TEXT
STRING
text/plain;charset=utf-8
text/plain
text/x-moz-url-priv

или какой-нибудь Libre Office Writer:

application/x-openoffice-embed-source-xml;windows_formatname="Star Embed Source (XML)"
text/rtf
text/richtext
text/html
text/plain;charset=utf-16
application/x-openoffice-objectdescriptor-xml;windows_formatname="Star Object Descriptor (XML)";classname="8BC6B165-B1B2-4EDD-aa47-dae2ee689dd6";typename="LibreOffice 6.0 Text Document";viewaspect="1";width="16999";height="2995";posx="0";posy="0"
text/plain;charset=utf-8
UTF8_STRING
STRING
application/x-libreoffice-internal-id-5387

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

В любой непонятной ситуации пиши скрипт

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

Ок, нам нужен скрипт, который прочитает файлик и отправить его сообщением в телегу. Какие библиотеки для этого есть? На PyPI 31 страница с результатами по запросу "telegram". Выбрал Telethon — на хабре статьи с ним есть, и вообще, стильно, модно, молодежно, асинхронно, чистый питон, MTProto и так далее.

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

from telethon.sync import TelegramClient
import socks

api_id = 11111
api_hash = '...'
proxy=(socks.SOCKS4, '127.0.0.1', 9050)

with TelegramClient('anon', api_id, api_hash, proxy=proxy, timeout=60) as client:
	print(client.get_me().stringify())

Запускаем скрипт... и он не работает из-за того, что не может подключиться к телеге. Конечно же, я не HACKERMAN, чтобы он с первого раза заработал. Наверно, проблема с прокси.

We need to do deeper

Ок, проверим, работает ли Tor, который исполняет роль прокси:

$ curl -XGET httpbin.org/ip --socks5 localhost:9050

Получаем ответ с адресом, который не мой — все ок. Значит, проблема в том, как работает прокси в Telethon. Какую библиотеку он для этого использует? PySocks. Отлично, с этой библиотекой работал давным-давно. Там есть простенький скрипт, которым в том числе можно проверить работу прокси. Запускаем и получаем...

Traceback (most recent call last):
  File "test.py", line 110, in <module>
    print("HTTP: " + opener.open("http://httpbin.org/ip").read().decode())
  File "/usr/lib/python3.6/urllib/request.py", line 526, in open
    response = self._open(req, data)
  File "/usr/lib/python3.6/urllib/request.py", line 544, in _open
    '_open', req)
  File "/usr/lib/python3.6/urllib/request.py", line 504, in _call_chain
    result = func(*args)
  File "test.py", line 94, in http_open
    return self.do_open(build, req)
  File "/usr/lib/python3.6/urllib/request.py", line 1318, in do_open
    encode_chunked=req.has_header('Transfer-encoding'))
  File "/usr/lib/python3.6/http/client.py", line 1254, in request
    self._send_request(method, url, body, headers, encode_chunked)
  File "/usr/lib/python3.6/http/client.py", line 1300, in _send_request
    self.endheaders(body, encode_chunked=encode_chunked)
  File "/usr/lib/python3.6/http/client.py", line 1249, in endheaders
    self._send_output(message_body, encode_chunked=encode_chunked)
  File "/usr/lib/python3.6/http/client.py", line 1036, in _send_output
    self.send(msg)
  File "/usr/lib/python3.6/http/client.py", line 974, in send
    self.connect()
  File "test.py", line 55, in connect
    ((socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),))
  File "/usr/lib/python3/dist-packages/socks.py", line 200, in create_connection
    proxy_username, proxy_password)
  File "/usr/lib/python3/dist-packages/socks.py", line 322, in set_proxy
    username.encode() if username else None,
AttributeError: 'int' object has no attribute 'encode'

Ах ты ж... Какого черта? Я ведь сам туда хэндлер для https добавлял, должно работать! Что могло там поменяться за... 6 лет? Какой я старый :(

15-20 минут на клонирование репы и принт-деббагинг приводят к ответу, что проблема в неправильном порядке аргументов — в основной библиотеке поменяли аргументы, а во вспомогательном скрипте забыли поправить. Хорошо хоть пулл-реквеcт на фикс этой проблемы есть, и скрипт оттуда работает. Мораль проста: если список аргументов длинный, лучше использовать именованные параметры.

Ок, с этим разобрались, значит, проблема не в прокси. В чем же дело? И тут мне в голову приходит гениальнейшая идея про то, что может стоит включить дебаг-логи в Telethon? И одновременно осознание, какой же я даун, если не додумался до этого раньше. Пробуем с дебаг-логами:

DEBUG:telethon.network.mtprotosender:Connection attempt 2...
WARNING:telethon.network.mtprotosender:Attempt 2 at connecting failed: ProxyConnectionError: Error connecting to SOCKS4 proxy 127.0.0.1:9050: [Errno 115] Operation now in progress

О чем же говорит нам эта ошибка? По номеру находим код EINPROGRESS, а про него читаем уже маны ядра по функции connect. Если общими словами, то проблема заключается в том, что у нас неблокирующая операция (в нашем случае установка соединения) не успела выполнится мгновенно и намекает нам, что она все еще выполняется. Похоже на правду: Tor не очень быстр. Не ожидал конечно, что ради 10-строчного скрипта на питоне, который в наши времена любой школотрон пишет за 5 минут с закрытыми глазами, мне придется опуститься до уровня системых вызовов и вспоминать особенности работы с сокетами...

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

Пора уже делать что-то полезное

Дальше стоит уже более обозримая задача: пофиксить это хоть как-нибудь. Лезть в дебри работы с сокетами — уж очень специфичное занятие, тем более, что реагирует мейнтенер не очень быстро. Тот же баг от автора Telethon висит уже больше года без ответа и привета. Опытный читатель здесь может сказать: бросай ты уже эту библиотеку, возьми другую. И, наверно, будет прав. Но мы не ищем легких путей: попробуем сделать что-нибудь с самим Telethon.

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

- s.setblocking(False)
+ s.settimeout(timeout)

Грязно, некрасиво, неасинхронно, но работает. Получаю большое удовольствие от того, какой я молодец и смог пофиксить проблему, и создаю PR.

Возвращаемся назад

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

Как нормально передать параметры api_key и api_hash, которые нельзя отозвать/поменять, но можно заабузить? Посмотрел, как это делают в примерах Telethon — через переменные окружения. Неплохо, но где хранить тогда эти переменные окружения? В конфиге, чтобы подгружать их? А что если кто-то получит доступ к конфигу?

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

А зачем ему энтерпрайз левел секурити?)

...

... подумал я и впихнул чтение через keyring. Не plain-text, ничего разворачивать не надо, даже ставить не надо. Для моих целей действительно сойдет.

Заключение

Я надеюсь, что мне удалось на примере еще раз показать, что оценка времени на работу — непростое занятие, что для программиста иметь кругозор — хорошо, и что надо знать, когда стоит остановиться. Скрипт разросся до 50 строк, из которых полезны только 10, остальное — парсинг параметров, импорты да отступы. А пулл-реквест в итоге приняли на следующий день после небольшой дискуссии.

Надеюсь, что статья вас хотя бы позабавила, а если вы узнали что-то новое, то вообще шикарно. Спасибо за внимание!

Опрос: оправдана ли трата нескольких часов на этот скрипт?

  • Да
  • Зависит, от ситуации, но скорее да
  • Зависит, от ситуации, но скорее нет
  • Точно нет
СсылкаКомментировать