#10: Design by contract: инварианты, Archunit
Юбилейный десятый выпуск, записанный в студии. Design by contract из «Программиста-прагматика»: предусловия, постусловия и инварианты через тройки Хоара, почему предусловия — не валидация пользовательского ввода, и спор с авторами о том, может ли TDD заменить контракты. Затем — практика: контракты в джавадоке, тесты в структуре given/when/then и ArchUnit — библиотека для тестирования архитектуры, которой Александр зафиксировал инвариант стартап-хуков прямо в build time. Полезняшка — 37signals.
Главное
- Design by contract — это предусловия, постусловия и инварианты, формально — тройки Хоара: выполнено предусловие P → команда C исполнима → гарантировано постусловие Q.
- Инвариант — условие, соблюдающееся всю жизнь класса: классика — баланс банковского аккаунта не бывает отрицательным.
- Предусловия — про коммуникацию классов и команд между собой, а не про валидацию пользовательского ввода: пользователи о контракте ничего не знают.
- Авторы считают, что TDD тестирует только happy path и потому не заменяет контракты; Александр не согласен: разбиение входных данных на классы эквивалентности покрывает и нарушения контракта.
- В Java встроенных контрактов нет: описывайте их в джавадоке больших публичных классов — DbC прежде всего техника мышления; ассерты в коде громоздки и отключаются флагом.
- Структура тестов given/when/then (комментариями) помогает увидеть покрытые предусловия и постусловия — и тиммейты перенимают подход сами, без просьб: лучший знак, что он работает.
- ArchUnit цементирует архитектуру юнит-тестами: зависимости между пакетами, слоистая архитектура, аннотации, наследование — выразительный DSL плюс вся сила рефлексии.
- Кейс: инвариант «число стартап-хуков равно ожидаемому» проверяется ArchUnit в build time — нарушенный контракт ломает сборку с информативным сообщением, а не падает где-то в рантайме.
Ссылки
Расшифровка
[00:20] Здарова! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Это десятый выпуск подкаста «Тысяча фичей». В честь юбилейного выпуска я решил записаться в студии — если вам понравится студийная запись, обязательно напишите об этом в комментариях к выпуску в Telegram-канале. Сегодня мы поговорим о таком подходе, как дизайн по контракту, который я подглядел в книжке «Программист-прагматик». Потом познакомимся с отличным инструментом из Java-мира, который помогает соблюдать некоторые части контракта. В конце, как обычно, полезняшка. Поехали!
[01:02] Иногда, когда людям нужно договориться, они заключают контракт — это помогает коммуницировать и синхронизировать ожидания. Программам контракт тоже иногда нужен: им тоже надо помогать коммуницировать друг с другом. Самый простой пример — OpenAPI-спецификация, про которую я говорил в предыдущих выпусках; но это всё-таки спецификация, и выражение «design by contract» к ней, как правило, не применяют. Как выглядит канонический подход — сейчас разберёмся. В главе «Design by Contract» авторы приводят набор понятий: pre-conditions, post-conditions и invariants — по-русски предусловия, постусловия и инварианты. Про дизайн по контракту я знаю не понаслышке: я изучал теорию, когда писал диплом про тестирование (про него, возможно, расскажу в других выпусках). Итак, что такое пред- и постусловия? Строго, почти математически, они определяются в логике Хоара — тройками Хоара. Тройка выглядит так: P — предусловие, C — команда (выражение, метод, что угодно), Q — постусловие. Мы утверждаем: если выполнено предусловие P, команда C будет исполнена; если P не выполнено — C исполнена быть не может; если C исполнена — гарантируется соблюдение постусловия Q. Важно: если предусловие не было выполнено, соблюдение постусловия не гарантируется. Представьте: есть условия, которые обязательно нужны для выполнения команды; выполнены — команда стопроцентно выполняется; выполнилась — стопроцентно соблюдаются постусловия. Это и есть контракт — но не полный: в него можно дописать инварианты. Это, по сути, такие же условия, но соблюдающиеся на протяжении всей жизни программы или класса. Что может соблюдаться всю жизнь класса? Возьмём класс банковского аккаунта: его инвариант — значение всегда не меньше нуля, отрицательного количества денег не бывает. Самый классический пример инварианта класса.
[03:57] Стоит отметить — и авторы это подчёркивают: предусловия не должны использоваться для валидации пользовательского ввода. Это условия, которыми обмениваются команды и классы между собой, а не пользователи: пользователи про предусловия ничего не знают, это уже другая часть, не дизайн по контракту. Авторы показывают, как пред- и постусловия реализованы в Clojure: куски кода, которые исполняются до и после выполнения метода и чекаются; если эти чеки-ассерты не проходят, программа падает. Плюс-минус так они реализованы везде — другого поведения у них быть не может. В Elixir есть guard’ы — «защитные» конструкции, в которых тоже описываются условия. А в Java by design ничего связанного с контрактами — в том смысле design by contract, который я дал, — пока нет. Но это не значит, что нельзя дизайнить программы по контракту на Java, Kotlin или любом другом языке. Как это делаю я — расскажу чуть позже.
[05:19] Когда речь заходит о предусловиях и постусловиях, вспоминается тестирование: мы же пишем тесты — и вроде как тестируем, что контракт соблюдается. Авторы говорят: test-driven development не может быть заменой design by contract, и наоборот — это пересекающиеся, но не идентичные множества. У меня вопрос: почему качественное тестирование не может заменить design by contract? Авторы аргументируют: если вы пишете в TDD, то, скорее всего, тестируете только happy path — то, что ожидаете от программы, когда всё хорошо, — и не тестируете граничные случаи, когда контракт не соблюдается. Вот тут — один из немногих случаев, когда я с авторами не согласен. Я не понимаю, как TDD связано с тестированием только happy path. Когда я пишу тесты — вперёд или после, — я пишу все возможные входные случаи. Точнее, я пытаюсь разбить входные данные на классы эквивалентности. Что это значит: если в коде есть if — при x меньше 10 кидаем exception, при x больше 10 считаем математику, — то классов эквивалентности относительно этого if два: x < 10 и x > 10. Их я и тестирую: беру любое число меньше 10 и любое больше 10. Нет смысла тестировать с 11, 12, 13 и 100 — это один класс эквивалентности, исполнится один и тот же код; тестов нужно минимальное количество. Я, конечно, не сажусь с мыслью «сейчас разобью входные данные на классы эквивалентности» — звучит слишком математично, — но мыслю именно так. И несоблюдение контракта — например, отрицательный x, которого по контракту быть не должно, — я тоже покрою тестом. Не понимаю, почему я должен тестировать только happy path. Так что тестирование вполне может быть заменой design by contract — за неимением лучшего: если в языке есть инструменты для пред-, постусловий и инвариантов — конечно, используем их, а если нет — остаются, по сути, тесты.
[08:28] Если контрактов нет в языке, может помочь сторонний тулинг. В Java, по-моему, есть инструмент (естественно, широко не используемый — иначе я бы знал название), где контракт определяется в джавадоке, а затем анализатор разбирает эти комментарии и фейлится, если контракт где-то не соблюдён. Важно: проверка будет исполняться в рантайме — какой-то код до метода, какой-то после, какой-то чекает инварианты. То есть результирующий продакшн-код — не тот, на который мы смотрим: это кодогенерация, и, по-моему, не самая лучшая идея — потому в Java это особо никто не использует. Но контракты в комментариях можно писать и без инструмента, который их парсит: просто опишите контракт интерфейса, класса, метода в джавадоке. И тут важно — авторы делают на этом акцент: если у вас нет тулинга, помогающего соблюдать контракт в рантайме, это не значит, что design by contract не для вас. Это прежде всего техника дизайна, подход к мышлению: мы начинаем осознавать, что на самом деле пишем. Написать в джавадоке «этот класс ожидает такие-то входные данные и гарантирует, что вернёт класс, который ведёт себя вот так» — контракт не будет чекаться в рантайме, но при достаточном количестве тестов человек, знакомящийся с кодом, сразу поймёт, как с классом работать. Хороший подход — описывать так хотя бы большие, публичные, важные инфраструктурные классы. Можно ещё вставлять ассерты: набор ассертов на параметры в начале метода — проверка precondition, набор перед return — postcondition. Но на практике это выглядит громоздко и нелепо: во-первых, ассерты отключаются флагом; во-вторых, ассертов везде не напишешься — если в коде много return’ов, что, перед каждым кучу ассертов городить? Я входные параметры иногда проверяю, но не ассертами: проверка на null — и кидаю exception. Ассерты, по опыту, используются редко.
[12:19] Читая главу про design by contract, я вспомнил, что пишу тесты немного нестандартным способом: постоянно делю тест на три (или два — зависит от типа) блока. Стандартный тест — скажем, считаю квадрат числа: пишу комментарий // given — given number, например 10; пустая строка; // when — вызываю math.square от given number, кладу в result; и // then — assert that result equals 100. Тест разбит на три больших блока. Да, этот пример тривиальный, его можно не разбивать — согласен. Но в плюс-минус серьёзном коде разбиение на given/when/then именно с комментариями даёт тестам структуру и помогает мыслить. Написал пять тестов, все разбиты так — пробежался глазами по всем given: собираю в голове картину множества входных данных, которые уже тестирую, и понимаю, какое множество докрутить, чтобы затронуть все классы эквивалентности. Хочу понять, какие результаты — постусловия — уже протестированы: смотрю только на then-блоки — ага, тестирую exception, когда нет файла; что создаётся директория; сам контент файла. Какие методы тестируются — смотрю на when. То есть я могу не парсить весь тест, а смотреть только на тестирование предусловий, постусловий или вызовов. В некоторых случаях техника может выглядеть как карго-культ: в примере с квадратом числа — шесть строчек, из которых три исполняются, а три — комментарии, и можно было одной строкой. Но я так пишу, потому что мне так легче думается: на автомате — сел за тест, // given, enter — «ага, начинаю писать тест». Структура не мешает воспринимать тесты, громоздкости нет. На ревью я, естественно, никогда не скажу «давай будем писать given, when, then» — это моя личная инициатива и мой подход к моему коду; просить, тем более заставлять — бред. Но я начал замечать, что мои тиммейты начинают писать тесты в той же парадигме. Несколько людей, поработав со мной, сами стали писать given/when/then — хотя я не просил и не говорил. Видимо, людям нравится такой подход, когда они его видят. И это самый главный знак, что что-то действительно полезно и работает: этому не нужно учить, не нужно просить — посмотрели и начали делать. По-моему, суперкруто. Я, конечно, подвязал эту мысль к design by contract, хотя это достаточно разные вещи — просто подход к написанию тестов. Но всё-таки.
[16:27] Возвращаемся к design by contract — и к тому, как это делать в Java. Java — богатая экосистема, в которой много всего, просто мы об этом редко говорим или нам это обычно не нужно. Я говорю об инструменте — точнее, библиотеке — под названием ArchUnit. Это библиотека для тестирования архитектуры приложения, как можно догадаться из названия. При чём тут design by contract — расскажу чуть позже, а сначала — что можно тестировать. Например, что классы из разных пакетов не зависят друг от друга: создаём правило, в котором на прекрасном DSL пишем, что ни один класс из пакета controllers не импортирует ни одного класса из пакета repository (у нас слоёная архитектура, и контроллеры не должны знать про репозитории). Или: ни один класс из services не знает ни про один класс из controllers. Эти правила пишутся в обычных юнит-тестах — библиотека просто предоставляет выразительный API: как я проговорил, так код и выглядит. И этим мы зацементировали архитектуру: если неокрепший программист придёт и решит «о, у контроллера есть нужное поле — заинжекчу-ка я себе контроллер в сервис», такой трюк не пройдёт и даже не дойдёт до ревью: CI-пайплайн упадёт. Помимо зависимостей между пакетами можно чекать наследование; можно требовать, чтобы все сервисы были аннотированы @Service — это уже скорее стиль, и подобное можно проверить и чекстайлом с кастомным правилом, но в ArchUnit такое правило пишется проще и точно быстрее. А вот наследование статически проверить уже не так легко — наследники могут появиться в рантайме, загруженные откуда-то; здесь сила ArchUnit в том, что он вовсю использует Reflection API: можно тестировать то, что доступно в рантайме и недоступно статическому анализу в компайл-тайме. Есть проверки наличия и отсутствия аннотаций. Есть специальный DSL для слоёной (layered) архитектуры: определяем уровень контроллеров таким-то пакетом, уровень сервисов — таким, уровень persistence — таким, и задаём отношения: контроллеры не импортируются ни одним уровнем, сервисы используются только из контроллеров, persistence — только из сервисов. Очень выразительно — буквально шесть строчек кода через готовый Library API, где уже написано много правил для самых распространённых случаев. И если подразобраться в архитектуре самой библиотеки — она достаточно неплохая: хороший пример расширяемой выразительной библиотеки (было бы странно, будь у тулзы для тестирования архитектуры архитектурка так себе). Её можно расширять собственными правилами, импортировать подмножество классов для проверки, и у вас вся сила рефлексии под рукой.
[22:15] Собственно, я ArchUnit для этого и использовал — стандартного DSL мне не хватило. Как я с ним познакомился и при чём здесь design by contract. Не вдаваясь в детали: я сделал у себя в коде такой контракт — на старте приложения нужно исполнять стартап-логику, и каждый её кусочек определяется небольшим классом с одним методом типа onStartup, реализующим определённый интерфейс. Список бинов этого типа инжектится в один класс, который их исполняет. Чтобы быть уверенным, что список не пустой и в нём именно те сущности, которые я определил, я добавил прямо в конструктор число — сколько хуков мы ожидаем. Так я гарантирую, что новый инстанс точно попал в контекст, будет проверен и стопроцентно исполнен. (Честно говоря, я не уверен, что, например, в Micronaut стартап-логика, определённая «штатным» способом, обязательно заинжектится и выполнится, — думаю, и в Spring подобные проблемы иногда встречаются. Чтобы это обойти, я и сделал отдельный класс: он инжектит список всех, кого нужно исполнить, проверяет, что размер списка равен этому числу — инварианту, — и только после этого вызывает у бинов onStartup.) А в чём контракт для разработчика? Добавляешь новый бин, наследуешь его от нужного типа, аннотируешь @Singleton — и обязан заинкрементировать число в вызывающем классе: было 9 инстансов — стало 10, поменяй девятку на десятку. Что будет, если контракт не соблюсти? Новый класс заинжектится, проверка «size == 9» при десяти элементах упадёт — в рантайме, и, скорее всего, не в тестах, а где-то там. Это нарушение контракта, но не fail fast: precondition был нарушен в момент создания нового бина, а падение случилось в рантайме. Хотелось зафорсить разработчика обновлять число прямо тогда, когда он создал класс (в идеале — компайл-проверкой, чтобы IDEA моргала, но это уже, наверное, слишком загонно). Для этого я и выбрал ArchUnit — посоветовал коллега: «твоё правило хорошо бы зафиксировать ArchUnit’ом». Библиотека расширяемая — можно написать буквально любое правило: под руками reflection API и обычный Java-код. Я написал такое: импортирую в тесте все классы-наследники интерфейса стартап-хука, аннотированные @Singleton (то есть являющиеся бинами этого типа); иду в класс, который их исполняет, беру значение поля в конструкторе — и проверяю, что оно равно количеству наследников. То есть инвариант проверяется в build time, а в рантайме сам класс уже проверяет, что размер заинжекченного списка равен этому числу. Теперь разработчик физически не может добавить новый стартап-хук, забыв увеличить число: сборка упадёт, и фреймворк очень информативно скажет — «у меня проверка: количество инстансов этого класса должно быть равно вот тому числу в том классе; иди, пожалуйста, увеличь». По сути, это и есть контракт, проверяемый в build time, — без него код даже не собирается. В этом плане ArchUnit может помочь в дизайне по контракту: контракты можно определять не только комментариями, но и такими build-time-проверками. Так что если у вас в коде есть неявные соглашения, которым все должны неявно следовать, — мне кажется, самое время сделать их явными и посмотреть на ArchUnit. Мою проблему он решил, понравился — а в том, как он написан, можно подчерпнуть идеи и для своих библиотек.
[28:18] На этом десятый, юбилейный выпуск подошёл к концу. Полезняшка в конце — веб-сайт под названием 37signals. Во время масштабных сокращений в больших технологических компаниях, мне кажется, стоит обратить внимание на небольшие компании — и это как раз тот случай. 37signals — это и компания, и каталог из 37 идей и мыслей, которые могут вам понравиться; по крайней мере, многие из них импонируют мне. Кстати, фреймворк Ruby on Rails сделали ребята из этой компании. Не забывайте писать отзывы, предлагать темы в Telegram-канале «Тысячи фичей» и делиться подкастом с другими. Давайте прокачивать себя и людей вокруг. Ну а на этом всё. Услышимся!