Всем привет, меня зовут Дмитрий, и сегодня я расскажу о том, как производственная необходимость заставила меня стать контрибутором для фреймворка Micronaut. Наверняка многие о нём слышали. Если вкратце, то это легковесная альтернатива Spring Boot, где основной упор сделан не на рефлексию, а на предварительную компиляцию всех нужных зависимостей. Более подробное знакомство можно начать с официальной документации.
Фреймворк Micronaut используется в нескольких внутренних проектах Яндекса и зарекомендовал себя достаточно хорошо. Так чего же нам не хватало? Могу сказать сразу: из коробки фреймворк поддерживает, в принципе, все фичи, которые теоретически могут понадобиться программисту для разработки бэкендов. Однако есть редкие кейсы, которые из коробки не поддерживаются. Один из них — когда работать нужно не по HTTP, а с расширением HTTP. Например, с дополнительными методами. Таких случаев на самом деле гораздо больше, чем может показаться. Более того, часть таких протоколов является стандартами:
- Webdav — расширение для доступа к ресурсам. Помимо стандартных методов, HTTP требует поддержки дополнительных методов типа LOCK, PROPPATCH и т. д.
- Caldav — расширение Webdav для работы с событиями календарного типа. Данный протокол с большой долей вероятности есть в приложениях на вашем смартфоне: для синхронизации календарей, встреч и т. д.
И этим список не исчерпывается. Если вы заглянете в реестр HTTP-методов, то увидите, что HTTP-методов, лишь описанных стандартами RFC, на данный момент 39. А сколько ещё случаев, когда имеется самописный протокол поверх HTTP. Так что поддержка нестандартных HTTP-методов довольно распространена. Также часто бывает, что фреймворк, который вы используете, не поддерживает такие методы. Вот дискуссия на Stack Overflow для ExpressJS. А вот pull request на гитхабе для Tornado. Ну и поскольку Micronaut часто позиционируется как легковесная альтернатива Spring — то вот та же проблема для Spring.
Неудивительно, что, когда в одном из проектов нам понадобилась поддержка протокола, расширяющего HTTP в плане методов, мы столкнулись с той же самой проблемой для Micronaut, который используем для этого проекта уже длительное время. Оказалось, что заставить Micronaut обрабатывать нестандартные методы довольно сложно.
Почему? Потому что если вы заглянете в определение HTTP-методов в Micronaut на данный момент, то обнаружите, что они заданы с помощью Enum, а не класса, как это сделано, например, в Netty (я не случайно упоминаю именно Netty, впоследствии это всплывёт ещё не раз). Что ещё хуже — весь матчинг обращений к серверу производится с фильтрацией именно по enum, а не по строковому названию метода. Это означает, что, если вам нужен нестандартный HTTP-метод, его надо прописывать в Enum, а это на самом деле не такое уж хорошее решение проблемы. Во-первых, это потребует коммита в репозиторий каждый раз, как вам потребуется новый метод. Во-вторых, методы HTTP по умолчанию не стандартизованы и их список нигде не зафиксирован, поэтому предусмотреть все возможные ситуации нереально. Нужно заставить Micronaut каким-то образом обрабатывать методы, которые заранее не были предусмотрены разработчиками.
Решение первое: в лоб
Первое и самое очевидное решение заключалось в том, чтобы не трогать Micronaut вообще и ничего в нём не переписывать. Зачем, ведь можно поставить перед Micronaut nginx, как мы и сделали, оттолкнувшись от примера:
http {
upstream other_PROPPATCH {
server ...;
}
upstream other_REPORT {
server ...;
}
server {
location /service {
proxy_method POST;
proxy_pass http://other_$request_method;
}
}
}
В чём смысл? Мы можем заставить nginx для нестандартных методов обращаться к нужной нам прокси, при этом использовать возможность nginx по изменению метода: т. е. будем обращаться через метод POST, а его Micronaut обрабатывать умеет.
Чем плохо? Начнём с того, что мы фактически делаем все запросы с точки зрения Micronaut неидемпотентными. Не стоит забывать, что для нестандартных методов такое разделение тоже есть. Например, REPORT идемпотентен, тогда как PROPPATCH — нет. В результате фреймворк не знает о типе запроса, да и программист, просматривающий код этих обработчиков, тоже просто так это не определит. Однако дело даже не в этом. У нас уже есть набор тестов, которые автоматизированно проверяют приложение на соответствие нужному протоколу. Для того чтобы в проекте эти тесты работали вот с таким решением, нужно выбрать один из двух вариантов действий:
- Поднять образ nginx с нужными настройками, помимо самого приложения, чтобы тесты обращались к nginx, а не к самому Micronaut. Хотя инфраструктура Яндекса, безусловно, позволяет поднимать дополнительные компоненты, в данном случае это выглядит как overengineering чисто для тестов.
- Переписать тесты, чтобы они не тестировали нужный протокол, а обращались к путям, к которым переадресовывает nginx. То есть фактически мы тестируем не протокол, а кишки его конкретной костыльной реализации.
Оба варианта не особо красивы, поэтому возникла идея: почему бы всё-таки не поправить Micronaut для нужной цели, тем более наверняка такая правка пригодится не только нам. То есть хотелось что-то вроде этого:
@CustomMethod("PROPFIND")
public String process(
// Provide here HttpRequest or something else, as standard micronaut methods
) {
}
И я бодро взялся за эту задачу, а что же получилось в итоге?
Решение второе: давайте всё перепишем!
На самом деле это куда проще, чем кажется на первый взгляд. Коммит просто меняет HttpMethod с enum на класс. Далее создали статические методы (прежде всего valueOf) внутри класса, которые вызывались для enum. И IDEA на пару с Gradle удостоверились, что ничего не сломалось.
Сложнее всего здесь оказалось с DefaultUriRouter, поскольку он исходил из того, что набор фиксированный, и создавал массив списков путей для возможных методов. От этого пришлось отказаться для новой реализации. Но в целом всё оказалось достаточно просто. Обратите внимание, что пришлось добавить 240 строк и удалить 116.
Проблема в том, что это мажорное изменение. Да, на практике в обычном проекте, использующем Micronaut, вы — скорее всего — не используете напрямую в коде HttpMethod, а если и используете, то вряд ли обращаетесь там к ordinal методу и прочим специфическим методам enum. Однако это всё равно не делает допустимым такое изменение в версии 1.x, особенно учитывая тот факт, что всё это затеяно ради поддержки довольно редкого кейса. Вот для 2.x это нормальная правка, но до 2.x ещё надо дожить. Поэтому пришлось написать ещё кода…
Решение третье: действуем эволюционно
Собственно, можете посмотреть соответствующий pull request для версии 1.3. Как видно, пришлось написать примерно в пять раз больше кода, чем для мажорного изменения, и это не случайно. Здесь я хочу воздать хвалу дефолтным методам в интерфейсах, введённым в восьмой Java. Для таких рефакторингов, которые не ломают обратную совместимость, эта вещь незаменима, и я не представляю, как бы проводил эти правки для Java до восьмой версии (хотя, как ни странно, мажорное изменение вполне можно было сделать и до восьмой).
Базово правки отталкивались от того, что в интерфейсе HttpRequest имелся метод getMethod, по которому и проводились фильтрации. Возвращал он, как несложно догадаться, enum. Поэтому в интерфейс сначала добавили дефолтный метод getHttpMethodName, который по умолчанию и возвращает название значения enum. Затем нашли, где использовался исходный метод в матчинге путей, и там это заменили вызовами нового метода. А вот затем в реализациях интерфейса для сервера Netty метод интерфейса переопределили для использования реального значения метода HTTP.
Здесь содержался один подводный камень, который можно заметить в дискуссии, и он касается декларативных клиентов Micronaut. Они используют преобразование названия значения enum в экземпляр класса HttpMethod для Netty. Если посмотреть в документацию для метода valueOf в данном классе, то можно заметить, что для стандартных методов будет возвращаться кэшированное значение, а для нестандартных — каждый раз новый экземпляр класса. То есть если у вас highload и вы миллион раз обратитесь к серверу с нестандартным HTTP-методом, вы попутно создадите миллион новых объектов. Конечно, современные GC должны с таким справляться, но всё-таки не хочется создавать дополнительные объекты просто так. Тогда возникла идея воспользоваться ConcurrentHashMap.computeIfAbsent для кэширования, но и здесь всё не так просто: всё дело в дефекте для Java 8, который будет приводить к блокировке потокв даже для случая, когда никакой записи не производится. В итоге приняли промежуточное решение:
- Для стандартных методов используем кэширование экземпляров, которое предоставляет сам Netty (собственно, как оно было и раньше).
- Для нестандартных методов пускай создаются новые экземпляры. Тот, кто выбирает нестандартные методы, должен позаботиться о том, чтобы сборщик мусора сумел переварить создание объектов (мы, к примеру, используем Shenandoah).
Выводы
Что можно сказать в итоге?
- Известная кривая стоимости исправления ошибки на разных этапах разработки ПО здесь проявилась весьма ярко. Конкретно здесь речь идёт о просчёте на самом раннем этапе разработки Micronaut, когда для методов HTTP было решено использовать enum. Трудно сказать, чем обосновано это решение, учитывая, что Micronaut крутится на Netty, где для того же самого используется класс. По сути, поддерживать класс вместо enum изначально не стоило бы дополнительных трудозатрат. Именно поэтому оказалось проще сделать мажорное изменение в данном плане, чем исправлять это с поддержкой обратной совместимости.
- Известная ахиллесова пята проектов open source (впрочем, такое можно наблюдать и в промышленных проектах с закрытым кодом) — у них нет проектной документации. При этом у Micronaut очень хорошая документация на самом деле: какие есть варианты его использования и тому подобное. Однако здесь речь идёт о документировании того, как принимались проектные решения. В итоге программисту со стороны достаточно сложно включиться в разработку проекта, даже если требуется внести небольшое улучшение.
- Не забывайте учитывать тот факт, используется ли тот или иной open source проект в highload и многопоточных средах. Здесь это пришлось учесть даже для небольшого улучшения.
P. S.
Пока эта статья готовилась к публикации, pull request был принят в ветку мастера Micronaut и выйдет в версии 1.3.
Автор: Шевченко Дмитрий