Хабрахабр

Когда стандарта HTTP мало. Коммитим в Micronaut

Всем привет, меня зовут Дмитрий, и сегодня я расскажу о том, как производственная необходимость заставила меня стать контрибутором для фреймворка 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, впоследствии это всплывёт ещё не раз). Почему? Это означает, что, если вам нужен нестандартный HTTP-метод, его надо прописывать в Enum, а это на самом деле не такое уж хорошее решение проблемы. Что ещё хуже — весь матчинг обращений к серверу производится с фильтрацией именно по enum, а не по строковому названию метода. Во-вторых, методы HTTP по умолчанию не стандартизованы и их список нигде не зафиксирован, поэтому предусмотреть все возможные ситуации нереально. Во-первых, это потребует коммита в репозиторий каждый раз, как вам потребуется новый метод. Нужно заставить Micronaut каким-то образом обрабатывать методы, которые заранее не были предусмотрены разработчиками.

Решение первое: в лоб

image

Зачем, ведь можно поставить перед Micronaut nginx, как мы и сделали, оттолкнувшись от примера: Первое и самое очевидное решение заключалось в том, чтобы не трогать Micronaut вообще и ничего в нём не переписывать.

http 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 на класс. На самом деле это куда проще, чем кажется на первый взгляд. И IDEA на пару с Gradle удостоверились, что ничего не сломалось. Далее создали статические методы (прежде всего valueOf) внутри класса, которые вызывались для enum.

От этого пришлось отказаться для новой реализации. Сложнее всего здесь оказалось с DefaultUriRouter, поскольку он исходил из того, что набор фиксированный, и создавал массив списков путей для возможных методов. Обратите внимание, что пришлось добавить 240 строк и удалить 116. Но в целом всё оказалось достаточно просто.

Да, на практике в обычном проекте, использующем Micronaut, вы — скорее всего — не используете напрямую в коде HttpMethod, а если и используете, то вряд ли обращаетесь там к ordinal методу и прочим специфическим методам enum. Проблема в том, что это мажорное изменение. Вот для 2.x это нормальная правка, но до 2.x ещё надо дожить. Однако это всё равно не делает допустимым такое изменение в версии 1.x, особенно учитывая тот факт, что всё это затеяно ради поддержки довольно редкого кейса. Поэтому пришлось написать ещё кода…

Решение третье: действуем эволюционно

image

3. Собственно, можете посмотреть соответствующий pull request для версии 1. Здесь я хочу воздать хвалу дефолтным методам в интерфейсах, введённым в восьмой Java. Как видно, пришлось написать примерно в пять раз больше кода, чем для мажорного изменения, и это не случайно. Для таких рефакторингов, которые не ломают обратную совместимость, эта вещь незаменима, и я не представляю, как бы проводил эти правки для Java до восьмой версии (хотя, как ни странно, мажорное изменение вполне можно было сделать и до восьмой).

Возвращал он, как несложно догадаться, enum. Базово правки отталкивались от того, что в интерфейсе HttpRequest имелся метод getMethod, по которому и проводились фильтрации. Затем нашли, где использовался исходный метод в матчинге путей, и там это заменили вызовами нового метода. Поэтому в интерфейс сначала добавили дефолтный метод getHttpMethodName, который по умолчанию и возвращает название значения enum. А вот затем в реализациях интерфейса для сервера Netty метод интерфейса переопределили для использования реального значения метода HTTP.

Они используют преобразование названия значения enum в экземпляр класса HttpMethod для Netty. Здесь содержался один подводный камень, который можно заметить в дискуссии, и он касается декларативных клиентов Micronaut. То есть если у вас highload и вы миллион раз обратитесь к серверу с нестандартным HTTP-методом, вы попутно создадите миллион новых объектов. Если посмотреть в документацию для метода valueOf в данном классе, то можно заметить, что для стандартных методов будет возвращаться кэшированное значение, а для нестандартных — каждый раз новый экземпляр класса. Тогда возникла идея воспользоваться ConcurrentHashMap.computeIfAbsent для кэширования, но и здесь всё не так просто: всё дело в дефекте для Java 8, который будет приводить к блокировке потокв даже для случая, когда никакой записи не производится. Конечно, современные GC должны с таким справляться, но всё-таки не хочется создавать дополнительные объекты просто так. В итоге приняли промежуточное решение:

  • Для стандартных методов используем кэширование экземпляров, которое предоставляет сам Netty (собственно, как оно было и раньше).
  • Для нестандартных методов пускай создаются новые экземпляры. Тот, кто выбирает нестандартные методы, должен позаботиться о том, чтобы сборщик мусора сумел переварить создание объектов (мы, к примеру, используем Shenandoah).

Выводы

image

Что можно сказать в итоге?

  • Известная кривая стоимости исправления ошибки на разных этапах разработки ПО здесь проявилась весьма ярко. Конкретно здесь речь идёт о просчёте на самом раннем этапе разработки Micronaut, когда для методов HTTP было решено использовать enum. Трудно сказать, чем обосновано это решение, учитывая, что Micronaut крутится на Netty, где для того же самого используется класс. По сути, поддерживать класс вместо enum изначально не стоило бы дополнительных трудозатрат. Именно поэтому оказалось проще сделать мажорное изменение в данном плане, чем исправлять это с поддержкой обратной совместимости.
  • Известная ахиллесова пята проектов open source (впрочем, такое можно наблюдать и в промышленных проектах с закрытым кодом) — у них нет проектной документации. При этом у Micronaut очень хорошая документация на самом деле: какие есть варианты его использования и тому подобное. Однако здесь речь идёт о документировании того, как принимались проектные решения. В итоге программисту со стороны достаточно сложно включиться в разработку проекта, даже если требуется внести небольшое улучшение.
  • Не забывайте учитывать тот факт, используется ли тот или иной open source проект в highload и многопоточных средах. Здесь это пришлось учесть даже для небольшого улучшения.

P. S.

Пока эта статья готовилась к публикации, pull request был принят в ветку мастера Micronaut и выйдет в версии 1.3.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»