Привет! Меня зовут Алексей Салаев, я Java-разработчик команды Corp Digital в Росбанке. В этом посте я расскажу, как можно оптимизировать и кастомизировать запросы в Spring: опишу потенциальные проблемы, оценю возможные пути решения и проиллюстрирую всё примерами. Представим, что у нас есть сервис по работе с документами. Это могут быть ордера, заявки, платежи или что-то другое. У каждого документа есть жизненный цикл — создание, подписание и затем исполнение (отправка в целевую систему). С созданием всё понятно: сделали документ и положили в базу. На подписании остановимся подробней. Как оно может проходить? Представим, что у нас есть три уровня подписи: руководитель (HEAD), эксперт (EXPERT) и бухгалтер (ACCOUNTANT). И есть пользователи, каждых из которых уполномочен те или иные подписи проставлять: руководителю доступны все три — HEAD, EXPERT и ACCOUNTANT; эксперту — только своя, EXPERT; бухгалтеру — HEAD и ACCOUNTANT. Рассмотрим, как может выглядеть запрос подписания: Проверка прав пользователя на возможность подписания. Для этого необходимо обратиться в сервис USER, который по определенному идентификатору возвратит нам уровни подписей пользователя. С ними мы сможем проверить, уполномочен пользователь подписывать документ или нет. Сам процесс подписания. Для этого необходимо снова обратиться в сервис USER за уровнями подписей пользователя, на основе которых уже пройдет само предписание. Постобработка запроса — например, это может быть возврат в вызывающую систему для уровней других подписей, которые пользователь может проставить. Поскольку пользователь может иметь несколько уровней подписей, а проставить не все, то необходимо снова обратиться в сервис USER за уровнями подписей пользователя. Далее сервис определит, какие подписи не были проставлены, и вернет в вызывающую систему список уровней подписей, который пользователь еще может проставить. Как это реализовано в коде? Мы получаем запрос в controller и затем идем в service в метод sign, где работает основная логика:  Вызывается метод checkPermissions , которые отвечают за проверку прав. Вызывается метод signing, который отвечает за сам процесс подписания.  Вызывается метод getActions, который отвечает за постобработку. В каждом из этих методов в адаптере сервиса USER вызывается метод getSignatureLevels, который возвращает уровни пользователя. То есть вызов сервиса User происходит трижды. Потенциальные проблемы такого подхода — долгое выполнение запросов и нагрузка на вызываемый сервис. Что можно с этим сделать? Остановиться на последовательном выполнении, то есть оставить всё как есть. Да, проблемы сохранятся, но если мы делаем какой-нибудь пилот, но нам куда важнее скорость разработки. Передача в параметрах метода. Делаем один запрос, при первом обращении данные сохраняются в переменную метода, после чего полученные данные передаются в сигнатуре методов везде, где потребуется. Плюс такого подхода — простота. Минус — мы смешиваем бизнес-логику и техническую часть; их лучше разделять, потому что со временем это будет всё сложнее поддерживать и дорабатывать. Локальное кеширование. Делается один запрос, при первом обращении данные сохраняются в поле класса, а при последующих обращениях подтягиваются из него. Если данные еще не сохранились, то мы снова обращаемся в сервис USER. Кеширование с использованием Spring Cache. При первом обращении данные кешируются, при последующих подтягиваются из кеша. На мой взгляд, это самый логичный подход. Его преимущества — достаточно простое подключение, легкая кастомизация и последующая работа. О двух последних подходах я расскажу подробнее. В Spring Cache абстракцию нам предоставляет Spring, а реализацию cache мы подключаем сами. Есть много доступных вариантов: EhCache JCache Hazelcast Infinispan Couchbase Redis Caffeine Simple Я остановлюсь на Caffeine, так как этот вариант достаточно простой и лучше всего подходит для примера. Spring Cache подключается легко. Мы ставим аннотацию @Cachable над тем методом, которым хотим закешировать, указываем, какое имя кеша использовать, и добавляем аннотацию @EnableCaching для поддержки кеширования. Что представляет собой Cache? По сути, это мапа, в которой есть ключ и значение. По ключу мы добавляем данные, а затем берем значение, если они там есть. По умолчанию Spring использует генератор ключей SimpleKeyGenerator, который можно заменить на свой. Но мы остановимся на стандартном. SimpleKeyGenerator берет сигнатуру метода и смотрит, есть ли в ней параметры: Если параметров нет, он берет 0. Если параметр один, он берет хеш-код этого параметра. Если параметров несколько, то на основе хеш-кодов всех параметров вычисляется общее значение, которое используется в качестве ключа кеширования. Использовать в качестве ключей данные из сигнатуры метода — это не очень хорошая практика. Со временем данные могут меняться, и туда могут добавляться поля, которые в кешировании участвовать не должны. Чтобы этого избежать, лучше указывать именно те параметры, которые мы хотим использовать в качестве ключа. В данном случае я указал userId: Мы запрашиваем уровни подписей пользователя по его идентификатору (userId) в сервисе USER, получаем данные и кешируем их. При последующих вызовах запрос в сервис USER выполняться не будет, а данные поступят из кеша. Вроде все просто. Теперь давайте рассмотрим, как закешировать данные на основе еще одного параметра, которого нет в сигнатуре метода. Например, мы хотим иметь кеш в рамках одного запроса. Если параллельно придет другой запрос, то данные необходимо закешировать отдельно и не использовать кеш парольного запроса. Для этого будем использовать рассчитанный на основе userId ключ из сигнатуры метода и requestId, который может быть передан в заголовках http-запроса. Возникает вопрос: как мы можем передать данные из заголовка запроса в качестве ключа кеширования. Можно, например, использовать ThreadLocal — он позволяет хранить данные, которые будут доступны только определенному потоку. Для этого настроим фильтр, который будет сохранять в ThreadLocal данные из заголовка http-запроса. В качестве ключа для кеша мы можем использовать любой дополнительный параметр, поэтому в примере я назвал его Custom-ID, чтобы это показать. Теперь осталось только указать данный параметр из ThreadLocal в качестве ключа для кеширования. Для этого можем использовать язык выражений SpEL: Мы рассмотрели, как можем в качестве ключа использовать любой параметр из запроса. Преимущество этого подхода: мы можем кешировать данные в рамках одного запроса, одной сессии или вообще на основе любого параметра. Теперь представим, что у нас есть два пользователя — руководитель и исполняющий обязанности руководителя (и.о. руководителя). По бизнес-логике для и.о. руководителя мы можем кешировать, например, на одну минуту после записи. А для реального руководителя — на пять минут. Как это сделать? Сначала нужно настроить CacheManager, который будет отвечать, сколько будут жить данные в кеше. Покажу на примере Caffeine. Создадим бин defaultCacheManager. После создания мы можем указать его в качестве cacheManager для кеширования: Мы, кстати, можем не указывать здесь cacheManager, если укажем Spring, чтобы он использовал бин defaultCacheManager по умолчанию, через аннотацию @Primary. Мы настроили кеш для пользователей с одной минутой кеширования. Теперь настроим кеш для пользователей с пятью минутами после добавления в кеш. Назовем его constantLifetimeCacheManager. Указываем данный CacheManager в параметрах аннотации: Представим, что по бизнес-логике мы, используя маску, можем определить по id пользователя, является ли он руководителем или и.о. руководителя. А как сделать, чтобы у нас по данной маске использовался либо defaultCacheManager, либо constantLifetimeCacheManager? Самый простой вариант — через if-else. Создаем два метода, getSignatureLevels_1 и getSignatureLevels_2, с разными настройками кеширования и в зависимости от маски вызываем соответствующий метод. Но Spring предоставляет нам другую реализацию, на основе CacheResolver. Для этого нам необходимо реализовать метод Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) интерфейса CacheResolver: Здесь мы получаем из CacheOperationInvocationContext данные сигнатуры метода — в нашем случае это единственный параметр userId. Проверяем по маске: в примере выше смотрим, четный параметр или нечетный. В зависимости от результата проверки используется тот или иной CacheManager.  Осталось указать данный CacheResolver в параметрах @Cacheable: Перед выполнением метода getSignatureLevels вызывается CacheResolver, который определяет, какой кеш использовать, и использует его. Мы рассмотрели, как можно кастомизировать кеш с использованием CacheResolver и SimpleKeyGenerator. Теперь я вернусь к третьему пункту списка: расскажу о подходе с локальным кешированием и, главное, о том, как можно провести в нем аналогичную (похожую) кастомизацию. Для начала хотелось бы уточнить, что понимается под локальным кешированием. У нас есть адаптер, который непосредственно обращается к сервису. В данном адаптере имеется коллекция, которая принадлежит классу UserImpl: В эту коллекцию сохраняется результат запроса из сервиса USER: Вначале мы проверяем, заполнена ли коллекция. Если нет, то идем в сервис, получаем оттуда данные, добавляем в коллекцию и возвращаем результат. Если данные есть, то сразу возвращаем их из коллекции. Вроде всё сделано хорошо… но работать не будет. Ведь Spring создаёт бины singleton. Так что когда к нам придет следующий запрос, в коллекции signatureLevels будут данные от предыдущего запроса. Как решить эту проблему? Логично сделать бин UserImpl prototype: Но это всё равно не сработает. Вспомним, как Spring создает бины. У нас есть два бина, handler или сервис, который является singleton, и UserApi — prototype, который, в свою очередь, заинжекчен в бин singleton. При создании бина singleton будут сначала созданы все зависимые бины, а затем один раз заинжекчены. Поэтому бин UserImpl для singleton будет один и тот же. Один из вариантов заставить всё работать — использовать @Lookup: Рассмотрим, как можно кастомизировать это по аналогии с кешированием. Предлагаю достаточно простой способ — с помощью пользовательской области видимости. Попробуем сделать и использовать свой кастомный Scope, для этого необходимо: реализовать метод Object get(String name, ObjectFactory<?> objectFactory) интерфейса Scope. Он обязателен для реализации. Все другие методы этого интерфейса опциональны. зарегистрировать новый scope: Для регистрации нового Scope необходимо реализовать интерфейс BeanFactoryPostProcessor и в методе PostProcessBeanFactory зарегистрировать новый Scope. В примере новый бин получил название CustomScope: Рассмотрим пример реализации пользовательской области видимости. Для хранения созданных бинов будем также использовать Caffeine. Можно использовать и обычную ConcurrentHashMap, но тогда придётся реализовывать механизм удаления бинов из хранилища в зависимости от того, сколько они должны жить. При использовании Caffeine всё будет само очищаться по заданной логике, рассмотренной в предыдущем разделе. Пример реализации:  В примере указано два CacheManager, с которыми мы работали. В качестве ключа для кеша используем название класса и CustomId из ThreadLocal, а значением будем непосредственно сам созданный бин. Здесь, в отличие от предыдущего примера, для определения CacheManager’а используется CustomId, потому что параметр UserId получить достаточно сложно. Это решаемый вопрос, но в нашем случае я решил не усложнять примеры лишним кодом и использовал CustomId из ThreadLocal в качестве маски. Осталось указать, что мы используем кастомный @Scope для UserImpl, и подсказать Spring, что надо брать новый @Bean из контекста в соответствии с нашими настройками, ведь мы имеем дело с разными scope’ами для бинов: Опишем, как работает данный механизм в целом. Когда запрос попадает в handler, для определения, какой бин использовать UserImpl, будет вызван метод get класса CustomScope. Если в методе get бин находится в кеше, он будет возвращен, если его нет в кеше, то будет создан, помещен в кеш и возвращен. И уже в возвращенном бине UserImpl будет отрабатываться логика нахождения уровней подписи SignatureLevels. Если вы столкнулись с проблемами, описанными в начале статьи, правильное кеширование запросов сможет вам помочь в их решении. Сделать это несложно, запросы легко поддаются кастомизации, и мы можем делать разные настройки для разных задач. Также можно использовать решение на основе пользовательской области видимости, но данный подход сложнее, в нем нужно учесть ряд важных нюансов, связанных с синхронизацией.  Java разработчик