[Перевод] Новые фичи Java нужны не только на собеседованиях

Всем привет! На связи Михаил Поливаха, технический лидер проекта Axelix.
Java переехала на шестимесячный релизный цикл уже довольно давно. Каждые полгода мы получаем новую версию, читаем про очередные JEP, смотрим доклады с конференций. Потом возвращаемся на работу и продолжаем писать примерно тот же самый код, который писали пять лет назад.
И это, кстати, не обязательно плохо. Тащить новую фичу в production только потому, что она новая, не самая выдающаяся инженерная стратегия. Более того, многие приложения всё ещё работают на Java 17, а где-то бодро живёт и Java 11.
Но отсюда возникает закономерный вопрос:
Есть ли у новых возможностей Java применение в обычных, реальных приложениях? Не в презентации, не в игрушечном pet project, а в коде, который решает вполне конкретную задачу?
Да, есть.
В этой статье я разберу два примера из Open Source-кода Axelix:
Как мы используем
ScopedValue, чтобы передавать security context из HTTP filter-а глубоко в транспортный слой.Почему один из наших интерфейсов стал
sealed, причём не ради pattern matching или exhaustiveswitch.
На первый взгляд эти примеры вообще про разное. Один про контекст исполнения, второй про дизайн типов. Но на самом деле у них есть одна общая идея: хорошая языковая фича позволяет превратить договорённость между разработчиками в ограничение, которое обеспечивает сама платформа.
Ну что же, начнём.
Небольшой Контекст Про Axelix
Чтобы дальнейший код не выглядел как набор случайных классов, сначала буквально пара слов про архитектуру.
Axelix помогает как в дебаге проблем в рамках Spring Boot приложений, так и позволяет находить распространённые проблемы и неэффективности в них. Упрощённо система состоит из двух частей:
Axelix Master агрегирует информацию, предоставляет UI и встроенный MCP server.
Spring Boot starter-ы (+ небольшой Build Plugin) устанавливаются в наблюдаемые приложения и предоставляют Master-у необходимые данные и операции.
Когда пользователь через UI или AI Agent вызывает операцию, Master иногда должен обратиться к starter-у конкретного приложения. Например, запросить состояние environment, получить информацию о bean-ах или очистить cache.
И тут появляется задача, связанная с безопасностью: Master должен передать starter-у авторизационный токен того security context, внутри которого выполняется операция.
Токен появляется довольно высоко, внутри servlet filter-а. Нужен он гораздо ниже, в транспортном слое, где строится исходящий HTTP request.
Можно, конечно, добавить SecurityContext параметром в каждый метод между этими двумя точками. Потом ещё в пять методов. Потом в десять. А через полгода обнаружить, что половина приложения занимается увлекательным перекладыванием токена из одного аргумента в другой.
Так тоже можно, но нам хотелось найти решение получше.
Кейс Первый. Передаём Контекст Через ThreadLocal
Исторически стандартным решением подобной задачи в Java является ThreadLocal, например Spring Security на этом работал.
Идея очень простая:
private static final ThreadLocal<SecurityContext> CONTEXT = new ThreadLocal<>();
void runWithinContext(Runnable action, SecurityContext context) {
SecurityContext previous = CONTEXT.get();
try {
CONTEXT.set(context);
action.run();
} finally {
if (previous == null) {
CONTEXT.remove();
} else {
CONTEXT.set(previous);
}
}
}
Код, который выполняется ниже по call stack-у в том же потоке, может сделать CONTEXT.get() и получить нужное значение. Не надо тащить параметр через все промежуточные методы.
Ну где же тогда проблема?
ThreadLocal Даёт Нам Больше Возможностей, Чем Нужно
Первая и довольно известная проблема ThreadLocal: любой код, у которого есть ссылка на него, может вызвать не только get(), но и set() или remove(). Иными словами, ThreadLocal по сути представляет собой shared mutable state - опытные инженеры знают, что это самая настоящая торпеда в борт нашего “корабля”.
То есть вызывающий код сохранил context авторизованного пользователя, а какой-то далёкий вызываемый метод теоретически способен этот context заменить. Для некоторых задач подобная мутабельность действительно нужна. В нашем случае поток данных строго односторонний:
Filter аутентифицирует пользователя.
Filter создаёт
SecurityContext.Нижележащий код этот context только читает.
Никто ниже по стеку не должен менять identity пользователя. Такая возможность нам не просто не нужна, но она по сути представляет собой security breach! Если вдруг, предположим, какая-то сторонняя реализация SPI инетрфейса сможет нагадить в SecurityContext, то это большая проблема.
Но у ThreadLocal есть и другая проблема. Время жизни значения в ThreadLocal никак не ограничено структурой кода. Мы сами обязаны вызвать remove(), обычно в finally. Почему вообще это проблема?
Вот представьте себе, что если мы, по какой-либо причине, вообще не важно, по какой, не очистили identity пользователя ThreadLocal после исполнения http запроса, и вот мы получили новый запрос, который выполняет тот же самый Thread из пула (пока выносим за скобки virtual threads и модель thread-per-task выполнения, т.к это вообще отдельная история), и вот уже security context одного запроса потенциально приехал в другой запрос.
И вот представьте, что SecurityContext в пуле, например, имел пользователя с ролью ADMIN, которая позволяет не просто читать свойства приложения, но и позволяет смотерть сенсативные значения. По-умолчанию, Axelix имеет свою политику работы с сенсативными значениями (они настраиваются отдельными properties), и права их читать есть только у ADMIN. И оставив в нашем SecurityContext пользователя с ролью ADMIN, мы по сути дела даем кому-то (без роли ADMIN) потенциальную возможность увидеть серкеты или через UI, или через MCP сервер.
Конечно, аккуратный разработчик напишет try/finally. Он покроет код тестами. Он оставит комментарий. Но если корректность решения держится только на том, что каждый следующий разработчик обязан помнить важный комментарий, то это далеко не самая сильная гарантия.
ScopedValue. Не Хранилище, А Ограниченная Область Исполнения
В Java 25 ScopedValue стал финальным API. Он решает более узкую задачу, чем ThreadLocal: позволяет передать значение вниз по call chain на время выполнения конкретной операции.
Ключевое отличие именно в модели. ThreadLocal похож на изменяемую коробку, прикреплённую к потоку. ScopedValue описывает binding значения внутри динамической области исполнения.
Вот как мы используем его для передачи SecurityContext в Axelix Master:
private static final ScopedValue<SecurityContext> SECURITY_CONTEXT =
ScopedValue.newInstance();
public <V, T extends Exception> V callWithinSecurityContext(
ThrowingCallable<V, T> callable,
SecurityContext securityContext) throws T {
return ScopedValue
.where(SECURITY_CONTEXT, securityContext)
.call(callable::call);
}
Вызов where(...).call(...) означает примерно следующее:
Выполни этот callback так, чтобы внутри его динамического scope данный
ScopedValueбыл связан с этимSecurityContext.
Когда callback завершится, binding исчезнет автоматически. Причём неважно, завершилось выполнение нормально или вылетел exception, т.к. ScopedValue задизайнена обработывать и этот кейс тоже.
Чтение контекста выглядит так:
public Optional<SecurityContext> getSecurityContext() {
if (SECURITY_CONTEXT.isBound()) {
return Optional.of(SECURITY_CONTEXT.get());
}
return Optional.empty();
}
Обратите внимание: у самого ScopedValue нет метода set. Код внизу может прочитать binding, но не может взять и заменить его значение. Если нужно временно связать тот же ScopedValue с другим значением, создаётся новый вложенный scope. После его завершения снова становится виден предыдущий binding.
То есть нужная нам семантика уже выражена в API:
значение движется сверху вниз по кол стеку;
binding существует только внутри конкретной операции;
callee не может мутировать binding;
выход из scope гарантированно восстанавливает предыдущее состояние.
Это и есть тот случай, когда новая фича Java не просто сокращает несколько строк с finally, а делает правильную модель очевидной из самого кода.
Как Это Работает В Реальном Запросе
Теперь соберём весь flow целиком.
Для обычного запроса от UI, который у нас обрабатывает ExternalApiCookieAuthorizationFilter , мы получаем JWT из cookie, декодируем пользователя и запускаем оставшийся filter chain внутри security context:
securityContextExecutor.runWithinSecurityContext(
() -> filterChain.doFilter(request, response),
new DefaultSecurityContext(user, token)
);
Для MCP-вызова IAM flow похожий, но немного отличается, вот тут можно source код, если интересно. Сейчас я на это время тратить не будту.
Дальше внутри filter chain выполняется обычная логика приложения. Она может пройти через controller, service и другие абстракции. Им token вообще не нужен, поэтому мы не загрязняем им их API.
И уже в AbstractEndpointProber, непосредственно перед отправкой запроса к starter-у, context читается:
SecurityContext securityContext = securityContextExecutor
.getSecurityContext()
.orElseThrow(() -> new IllegalStateException(
"Security Context is expected to be bound"));
builder.header(
HttpHeaders.AUTHORIZATION,
AuthenticationSchemes.BEARER.prefix() + securityContext.token()
);
После завершения обработки запроса binding снимается, и получить token через этот ScopedValue больше нельзя. В общем, классно!
Небольшие ограничения ScopedValue
Я думаю, что будет справедливо сказать и про небольшие ограничения этого API. В целом, они для нас на данный момент вполне себе терпимые.
Binding Привязан К Потоку
ScopedValue не переезжает автоматически в произвольный CompletableFuture, executor или асинхронную servlet-обработку.
Текущий код Axelix работает потому, что проксирование запроса в starter, где читается binding и устанавливается заголовок Authorization, вызывается синхронно внутри filter chain в том же потоке. Наследование binding дочерними потоками поддерживает StructuredTaskScope, но в Java 25 это всё ещё Preview API.
Если вы положили значение в ScopedValue, а затем отправили задачу в случайный thread pool, ожидать его там нельзя.
Binding Immutable, а вот Объект Не Обязательно
ScopedValue не даёт заменить сам binding. Но если вы положили внутрь mutable-объект, его поля всё ещё можно менять обычными методами. Не то чтобы эта проблема специфичная для ScopedValue, она вообще актуальна и для ThreadLocal, и для рядя других API. Вот, например, смотрите:
ScopedValue<List<String>> VALUE = ScopedValue.newInstance();
Вот тут сам binding списка будет стабильным, тем не менее, никто не мешает вызвать VALUE.get().add(...), и тут вопрос мутабельности остаётся. Опять же, эта проблема не специфична только для ScopedValue, просто стоит об этом помнить.
Это Не Универсальная Замена Параметрам
Вообще, параметры в метод можно передать либо в явном виде, в сигнутуре метода, например. А можно передать неявно, т.е. через различного рода вот такие контексты. И с точки зрения поддерживаеемости кода, если значение является важной частью контракта метода, обычный параметр часто почти всегда лучше, потому что в таком случае контракт API чётко иден в сигнатуре.
Не будет такой ситауции, когда в интерфейсе написано вот так:
public interface EndpointProber<O> {
O invoke(HttpPayload payload);
}
а реализация под капотом на самом деле там вытворяет вот такое:
public class DefaultEndpointProber<O> implements EndpointProber<O> {
// Declared somewhere else, in another class
private static final ScopedValue<InstanceId> TARGET_INSTANCE =
ScopedValue.newInstance();
@Override
public O invoke(HttpPayload payload) {
String accessToken = TARGET_INSTANCE.orElseGet(payload.getToken());
return probe(instanceId, payload);
}
}
Код выше обманчив, потому что caller может думать, что он же передал token для вызова, а на деле приоритет отдается какому-то другому источнику токена. Опять же, это не проблема именно ScopedValue, я просто посчитал нужным это подсветить. Опытные инженеры наверняка это знают.
В общем, ScopedValue и вообще ThreadLocal особенно полезен для контекста, который нужен далеко внизу, но не имеет отношения к большинству промежуточных методов:
security context;
tracing context;
tenant;
request metadata.
Иными словами, сначала должна появиться задача one-way context propagation. Только потом ScopedValue.
Микро-Вывод
Используйте ScopedValue, когда значение должно передаваться только вниз по call chain и жить не дольше одной ограниченной операции.
Если вам нужна произвольная мутация, интеграция со старым кодом или поддержка старой Java, ThreadLocal никуда не исчезает. Просто хорошо инкапсулируйте операции set и remove.
Кейс Второй. Причём Тут sealed Интерфейсы?
Теперь перейдём к совершенно другой задаче. Или, как скоро станет понятно, не совсем другой.
Сразу хочу предупредить - решение ниже немного спорное (я пишу ниже почему), и во многом я пишу его тут с целью поделиться опытом и услышать мнение других в комментариях. Тем не менее, мы сделали именно так.
В Axelix Master есть встроенный MCP server. AI Agent может вызывать MCP tools, среди которых есть как операции чтения, так и потенциально опасные операции. Например, очистка cache. В рамках RBAC мы, очевидно, должны делать проверку прав доступа на такие операции в том числе.
Разным MCP endpoint-ам нужны разные authority. В Axelix Master, упрощённо, есть вот такая структура:
private static final Map<McpEndpoint, Authority> MAPPING;
static {
MAPPING = new HashMap<>(2);
MAPPING.put(McpEndpoints.CLEAR_ALL_CACHES, OssAuthority.CACHES_CLEAR);
MAPPING.put(McpEndpoints.CLEAR_SPECIFIC_CACHE, OssAuthority.CACHES_CLEAR);
// and so on.
}
Когда к нам приходит JSON RPC запрос на вызов MCP tool, мы парсим этот запрос, и превращаем строковое имя MCP endpoint-а, который птылись вызвать, в объект McpEndpoint. А затем используем этот объект как ключ Map и получаем требуемое authority.
Почему как ключ? Потому что это логично с точки зрения домена: для вызова такой-то операции в рамках MCP сервера, нужен такой-то authority. Иметь подобное сопоставление довольно логично. Можно ли просто сделать ключом String? Технически можно, конечно, но в рамках нашей реализации и устройства классов с этим были бы свои неудобства. Я сейчас в детали уходить не буду.
Представим себе, что McpEndpoint это интерфейс (опытный инжерен потянулся за кольтом, прикрепленным к ремню!):
public interface McpEndpoint {
String name();
}
Ну interface и interface. Что тут может пойти не так?
Потенциальный Mutable Ключ В HashMap. Мина Замедленного Действия
Почему опытный инженер потянулся за кольтом? Потому что в общем и целом McpEndpoint это инфтерфейс, и мы не знаем, как будет выглядеть реализация. А ключи в Map должны быть иммутабельные, это довольно широкоизвестный факт.
Представим, что кто-то реализовал открытый интерфейс вот так:
final class MutableMcpEndpoint implements McpEndpoint {
private String name;
MutableMcpEndpoint(String name) {
this.name = name;
}
@Override
public String name() {
return name;
}
void rename(String name) {
this.name = name;
}
@Override
public boolean equals(Object other) {
return other instanceof MutableMcpEndpoint endpoint
&& Objects.equals(name, endpoint.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
Теперь положим такой endpoint в HashMap, а затем изменим его имя:
var endpoint = new MutableMcpEndpoint("clearAllCaches");
var mapping = new HashMap<McpEndpoint, Authority>();
mapping.put(endpoint, OssAuthority.CACHES_CLEAR);
endpoint.rename("clearSomethingElse");
Authority authority = mapping.get(endpoint); // surprise!
Во время put, HashMap выбрала bucket на основании старого hashCode. После rename объект выдаёт уже другой hashCode, но физически лежит в старом bucket. Lookup может не найти ключ, который буквально находится внутри этой же Map.
И поулчается, что может произайти ситуация, при которой мы просто не обнаружим, что для данного McpEndpoint нужна какая-либо Authority для выполнения - это же тот же самый security breach!
Можно, конечно, написать в javadoc:
Все реализации
McpEndpointобязаны быть immutable и иметь стабильныеequalsиhashCode.
Но мы снова вернулись к ситуации, где корректность держится на комментарии.
sealed Нужен Не Только Для switch
Sealed classes и interfaces стали финальной возможностью языка ещё в Java 17. Обычно их объясняют на примере закрытой иерархии фигур:
sealed interface Shape permits Circle, Rectangle {
}
После этого compiler знает полный набор вариантов и может проверить exhaustive pattern matching. Это полезно, но это далеко не единственный use case.
Вообще, если подумать, то sealed тип по сути дела как функциональность языка, подразумевает, что мы не просто знаем, а имеем контроль над всеми возможными реализациями интерфейса. И под контролем можно иметь разные вещи, и в том числе, что важно для нас, enforcement неизменяемости!
McpEndpoint в Axelix выглядит так:
public sealed interface McpEndpoint permits DefaultMcpEndpoint {
String name();
}
Единственная разрешённая реализация является record:
public record DefaultMcpEndpoint(String name) implements McpEndpoint {
}
Что нам даёт такая комбинация?
Произвольный код не может добавить неизвестную реализацию
McpEndpoint.Как я уже сказал, мы контролируем все permitted implementations.
Текущая реализация является
record, а его состояние после создания переназначить нельзя.Единственный компонент имеет тип
String, который сам является immutable.Record генерирует покомпонентные
equalsиhashCode, основанные здесь на стабильном компонентеname.
Получается, что любой McpEndpoint, который сегодня может попасть в Map, имеет нужную нам семантику ключа.
И вот это важный, хотя и не самый очевидный способ использования sealed interface: мы закрываем иерархию, потому что алгоритм полагается на свойства всех её реализаций.
Здесь можно справедливо сказать, что, по сути, мы надеемся на свойство релаизации, работая с контрактом, это вообще-то нарушение SOLID. Это всё правда, тем не менее, сами sealed типы по своей природе устроены так, что мы заранее знаем множество реализаций, и делаем вещи по типу exhaustive switch-ей - что тоже наршуение SOLID вообще-то, т.к мы полагаемся на реализацию, а не работаем с абстракцией. Тут вот как раз есть пространство для продуктивной дискуссии.
А Почему Не enum?
Закономерный вопрос. Если множество MCP endpoint-ов фиксировано, почему просто не сделать так:
enum McpEndpoint {
CLEAR_ALL_CACHES("clearAllCaches"),
CLEAR_SPECIFIC_CACHE("clearSpecificCacheEntity"),
// and so on...
;
private final String toolName;
McpEndpoint(String toolName) {
this.toolName = toolName;
}
String toolName() {
return toolName;
}
}
Это вполне валидная альтернатива, хотя текущий контракт name() пришлось бы переименовать, поскольку у Enum уже есть финальный метод с таким именем. Более того, для навсегда фиксированного множества endpoint-ов enum дал бы ещё более сильную гарантию: были бы закрыты не только реализации, но и сами экземпляры.
Текущий sealed interface не мешает написать:
new DefaultMcpEndpoint("clearAllCaches");
Такой экземпляр благодаря record equality будет равен существующей константе с тем же именем и сработает как тот же ключ. sealed закрывает типы, а не множество объектов.
Почему же интерфейс всё равно может иметь смысл? Потому что enum-ы слишком негибкие, в частности, они нерасширяемые. Например, в разных дистрибутивах Axelix Master могут быть разные McpEndpoint-ы, а enum бы просто не позволил себя расширить.
Тем не менее, всегда помните, что extensibility не бесплатна. Оставляйте extension point только там, где он действительно нужен.
Кроме того, важно помнить, что sealed-модель также не гарантирует:
уникальность имён endpoint-ов;
регистрацию каждого нового endpoint-а во всех необходимых вот таких вот
Mapмаппингах;корректность самого mapping-а authority.
Добавили новую константу и забыли зарегистрировать её в одном из resolver-ов? Javac тут не спасёт. Для этого нужны другие модели данных и тесты.
В общем, конкретно для нас sealed решает одну конкретную проблему. И это нормально. От языковой фичи не стоит требовать, чтобы она заодно помыла посуду и задеплоила release.
То есть, это такой некий баланс между расширяемостью и immutability.
Что Объединяет Эти Две Фичи
Теперь давайте вернёмся к началу статьи.
ScopedValue и sealed interface выглядят совершенно разными возможностями Java:
ScopedValueработает с контекстом исполнения.sealed interfaceработает с системой типов.
Но в наших двух кейсах они решают одну и ту же инженерную задачу: сужают множество допустимых состояний программы.
В случае ScopedValue мы говорим:
Контекст можно связать со значением только на ограниченное время. Нижележащий код может его читать, но не произвольно перезаписывать.
В случае sealed interface:
Абстракция доступна всему приложению, но только контролируемые нами типы могут её реализовать.
Это хорошо ложится на общий вектор платформы, который архитекторы Java называют Integrity by Default: корректное и безопасное поведение должно быть стандартным, а опасное исключение должно требовать явного решения.
На мой взгляд, именно так и стоит оценивать новые возможности языка. Не по количеству строк, которые они позволяют убрать, и не по тому, насколько красиво они выглядят в докладе. Главный вопрос другой:
Какой инвариант моего приложения эта фича позволяет выразить и защитить?
Если ответа нет, возможно, фича вам пока не нужна. Если ответ есть, то это уже не игрушка.
Практические Рекомендации
Давайте суммируем.
Когда Смотреть В Сторону ScopedValue
Используйте ScopedValue, если:
Данные передаются только вниз по call chain.
Промежуточные методы не должны принимать их параметром.
Callee не должен заменять binding.
Lifetime данных совпадает с ограниченной операцией.
Оставляйте обычный параметр, если зависимость является важной частью контракта метода. Оставляйте ThreadLocal, если нужна мутация, legacy-интеграция или поддержка старой Java.
Когда Смотреть В Сторону sealed
Используйте sealed class или interface, если:
Абстракция должна быть видна широко.
Множество реализаций должно контролироваться автором.
Код опирается на свойства всех допустимых реализаций.
Сначала Инвариант, Потом Фича
Не надо искать место, куда бы пристроить ScopedValue, sealed class, record pattern или virtual thread только потому, что можете (как известно, мы же так никогда не делаем, ведь правда? Правда ведь…?).
Сначала найдите реальное ограничение системы:
этот context не должен утечь за границы запроса;
этот binding нельзя менять снизу;
эту иерархию нельзя расширять неизвестными типами;
этот объект обязан иметь стабильную value semantics.
И уже затем выбирайте возможность языка, которая это ограничение выражает.
Заключительное Слово
Новые возможности Java действительно используются в реальных приложениях. Но ценность появляется не тогда, когда мы заменяем старый синтаксис новым, а тогда, когда целый класс неправильных программ становится сложнее написать или вообще нельзя скомпилировать.
В Axelix ScopedValue дал security context, ограниченный lifetime и безопасное пропогирование по колл стеку ниже. А sealed interface позволил оставить доменную абстракцию публичной, но удержать семантику её реализаций под контролем, при этом разрешив создание новых McpEndpoint-ов.
Обе фичи сократили пространство для ошибки. А это, на мой взгляд, и есть одна из главных вещей, которых мы хотим от языка программирования.
Исходный код открыт, так что все приведённые примеры можно посмотреть непосредственно в репозитории Axelix.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.