Оптимизация и кастомизация запросов с использованием Spring Cache и Scope Bean

от автора

Привет! Меня зовут Алексей Салаев, я Java-разработчик команды Corp Digital в Росбанке. В этом посте я расскажу, как можно оптимизировать и кастомизировать запросы в Spring: опишу потенциальные проблемы, оценю возможные пути решения и проиллюстрирую всё примерами.

Представим, что у нас есть сервис по работе с документами. Это могут быть ордера, заявки, платежи или что-то другое. У каждого документа есть жизненный цикл — создание, подписание и затем исполнение (отправка в целевую систему).

С созданием всё понятно: сделали документ и положили в базу. На подписании остановимся подробней. Как оно может проходить? Представим, что у нас есть три уровня подписи: руководитель (HEAD), эксперт (EXPERT) и бухгалтер (ACCOUNTANT). И есть пользователи, каждых из которых уполномочен те или иные подписи проставлять:

  • руководителю доступны все три — HEAD, EXPERT и ACCOUNTANT;

  • эксперту — только своя, EXPERT;

  • бухгалтеру — HEAD и ACCOUNTANT.

Рассмотрим, как может выглядеть запрос подписания:

  1. Проверка прав пользователя на возможность подписания. Для этого необходимо обратиться в сервис USER, который по определенному идентификатору возвратит нам уровни подписей пользователя. С ними мы сможем проверить, уполномочен пользователь подписывать документ или нет.

  2. Сам процесс подписания. Для этого необходимо снова обратиться в сервис USER за уровнями подписей пользователя, на основе которых уже пройдет само предписание.

  3. Постобработка запроса — например, это может быть возврат в вызывающую систему для уровней других подписей, которые пользователь может проставить. Поскольку пользователь может иметь несколько уровней подписей, а проставить не все, то необходимо снова обратиться в сервис USER за уровнями подписей пользователя. Далее сервис определит, какие подписи не были проставлены, и вернет в вызывающую систему список уровней подписей, который пользователь еще может проставить.

Как это реализовано в коде? Мы получаем запрос в controller и затем идем в service в метод sign, где работает основная логика: 

  1. Вызывается метод checkPermissions , которые отвечают за проверку прав.

  2. Вызывается метод signing, который отвечает за сам процесс подписания. 

  3. Вызывается метод getActions, который отвечает за постобработку.

В каждом из этих методов в адаптере сервиса USER вызывается метод getSignatureLevels, который возвращает уровни пользователя. То есть вызов сервиса User происходит трижды.

Потенциальные проблемы такого подхода — долгое выполнение запросов и нагрузка на вызываемый сервис. Что можно с этим сделать?

  1. Остановиться на последовательном выполнении, то есть оставить всё как есть. Да, проблемы сохранятся, но если мы делаем какой-нибудь пилот, но нам куда важнее скорость разработки.

  2. Передача в параметрах метода. Делаем один запрос, при первом обращении данные сохраняются в переменную метода, после чего полученные данные передаются в сигнатуре методов везде, где потребуется. Плюс такого подхода — простота. Минус — мы смешиваем бизнес-логику и техническую часть; их лучше разделять, потому что со временем это будет всё сложнее поддерживать и дорабатывать.

  3. Локальное кеширование. Делается один запрос, при первом обращении данные сохраняются в поле класса, а при последующих обращениях подтягиваются из него. Если данные еще не сохранились, то мы снова обращаемся в сервис USER.

  4. Кеширование с использованием Spring Cache. При первом обращении данные кешируются, при последующих подтягиваются из кеша. На мой взгляд, это самый логичный подход. Его преимущества — достаточно простое подключение, легкая кастомизация и последующая работа.

О двух последних подходах я расскажу подробнее.

Кеширование с использованием Spring Cache

В Spring Cache абстракцию нам предоставляет Spring, а реализацию cache мы подключаем сами. Есть много доступных вариантов:

  • EhCache

  • JCache

  • Hazelcast

  • Infinispan

  • Couchbase

  • Redis

  • Caffeine

  • Simple

Я остановлюсь на Caffeine, так как этот вариант достаточно простой и лучше всего подходит для примера.

Spring Cache подключается легко. Мы ставим аннотацию @Cachable над тем методом, которым хотим закешировать, указываем, какое имя кеша использовать, и добавляем аннотацию @EnableCaching для поддержки кеширования.

@Cacheable(cacheNames = {"signature"}) public Set<SignatureLevel> getSignatureLevels(long userId) {       //логика }

Что представляет собой Cache? По сути, это мапа, в которой есть ключ и значение. По ключу мы добавляем данные, а затем берем значение, если они там есть. По умолчанию Spring использует генератор ключей SimpleKeyGenerator, который можно заменить на свой. Но мы остановимся на стандартном.

SimpleKeyGenerator берет сигнатуру метода и смотрит, есть ли в ней параметры:

  • Если параметров нет, он берет 0.

  • Если параметр один, он берет хеш-код этого параметра.

  • Если параметров несколько, то на основе хеш-кодов всех параметров вычисляется общее значение, которое используется в качестве ключа кеширования.

Использовать в качестве ключей данные из сигнатуры метода — это не очень хорошая практика. Со временем данные могут меняться, и туда могут добавляться поля, которые в кешировании участвовать не должны. Чтобы этого избежать, лучше указывать именно те параметры, которые мы хотим использовать в качестве ключа. В данном случае я указал userId:

@Cacheable(cacheNames = {"signature"},  key = "{#userId}") public Set<SignatureLevel> getSignatureLevels(long userId) {       //логика }

Мы запрашиваем уровни подписей пользователя по его идентификатору (userId) в сервисе USER, получаем данные и кешируем их. При последующих вызовах запрос в сервис USER выполняться не будет, а данные поступят из кеша. Вроде все просто.

Теперь давайте рассмотрим, как закешировать данные на основе еще одного параметра, которого нет в сигнатуре метода. Например, мы хотим иметь кеш в рамках одного запроса. Если параллельно придет другой запрос, то данные необходимо закешировать отдельно и не использовать кеш парольного запроса. Для этого будем использовать рассчитанный на основе userId ключ из сигнатуры метода и requestId, который может быть передан в заголовках http-запроса.

Возникает вопрос: как мы можем передать данные из заголовка запроса в качестве ключа кеширования. Можно, например, использовать ThreadLocal — он позволяет хранить данные, которые будут доступны только определенному потоку. Для этого настроим фильтр, который будет сохранять в ThreadLocal данные из заголовка http-запроса.

В качестве ключа для кеша мы можем использовать любой дополнительный параметр, поэтому в примере я назвал его Custom-ID, чтобы это показать.

@Component public class CustomFilter implements Filter {      @Override  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {    HttpServletRequest httpRequest = (HttpServletRequest) request;    try {    ThreadLocalCustom.setContext(new Context(httpRequest.getHeader("Custom-ID")));         chain.doFilter(request, response);    } finally {         ThreadLocalCustom.clearContext();    } 

Теперь осталось только указать данный параметр из ThreadLocal в качестве ключа для кеширования. Для этого можем использовать язык выражений SpEL:

@Cacheable(cacheNames = {"signature"},        key = "{#userID,T(ru.rosbank.ThreadLocalCustom).getCustomId()}") public Set<SignatureLevel> getSignatureLevels(long userId) {       //логика }

Мы рассмотрели, как можем в качестве ключа использовать любой параметр из запроса. Преимущество этого подхода: мы можем кешировать данные в рамках одного запроса, одной сессии или вообще на основе любого параметра.

Теперь представим, что у нас есть два пользователя — руководитель и исполняющий обязанности руководителя (и.о. руководителя). По бизнес-логике для и.о. руководителя мы можем кешировать, например, на одну минуту после записи. А для реального руководителя — на пять минут. Как это сделать?

Сначала нужно настроить CacheManager, который будет отвечать, сколько будут жить данные в кеше. Покажу на примере Caffeine. Создадим бин defaultCacheManager.

@Bean("defaultCacheManager") public CacheManager defaultCacheManager() {     var cacheManager = new CaffeineCacheManager("signature");     cacheManager.setCaffeine(caffeineCacheBuilder());     return cacheManager; }  public Caffeine<Object, Object> caffeineCacheBuilder() {     return Caffeine.newBuilder()             .maximumSize(5000)             .expireAfterWrite(Duration.ofSeconds(60)) //истечение после последней записи             //.expireAfterAccess(Duration.ofDays(1)) //истечение после последнего доступа }

После создания мы можем указать его в качестве cacheManager для кеширования:

@Cacheable(cacheNames = {"signature"},    cacheManager = "defaultCacheManager") public Set<SignatureLevel> getSignatureLevels(long userId) {       //логика } 

Мы, кстати, можем не указывать здесь cacheManager, если укажем Spring, чтобы он использовал бин defaultCacheManager по умолчанию, через аннотацию @Primary.

Мы настроили кеш для пользователей с одной минутой кеширования. Теперь настроим кеш для пользователей с пятью минутами после добавления в кеш. Назовем его constantLifetimeCacheManager.

@Bean("constantLifetimeCacheManager") public CacheManager constantLifetimeCacheManager() {     var cacheManager = new CaffeineCacheManager(“signatureConst”);     cacheManager.setCaffeine(constantLifetimeCacheBuilder());     return cacheManager; } // фиксированное время жизни 5 минут public Caffeine<Object, Object> constantLifetimeCacheBuilder() {     return Caffeine.newBuilder()             .initialCapacity(1000)             .maximumSize(5000)             .expireAfter(new Expiry<>() {                 @Override                 public long expireAfterCreate(Object key, Object value, long currentTime)  {                     return TimeUnit.MINUTES.toNanos(5);                 } ... 

Указываем данный CacheManager в параметрах аннотации:

@Cacheable(cacheNames = {“signature”},    cacheManager = “constantLifetimeCacheManager”) public Set<SignatureLevel> getSignatureLevels(long userId) {       //логика 

Представим, что по бизнес-логике мы, используя маску, можем определить по id пользователя, является ли он руководителем или и.о. руководителя. А как сделать, чтобы у нас по данной маске использовался либо defaultCacheManager, либо constantLifetimeCacheManager?

Самый простой вариант — через if-else. Создаем два метода, getSignatureLevels_1 и getSignatureLevels_2, с разными настройками кеширования и в зависимости от маски вызываем соответствующий метод. Но Spring предоставляет нам другую реализацию, на основе CacheResolver. Для этого нам необходимо реализовать метод Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) интерфейса CacheResolver:

public class MultipleCacheResolver implements CacheResolver {      @Qualifier("defaultCacheManager")     private final CacheManager defaultCacheManager;     @Qualifier("constantLifetimeCacheManager")     private final CacheManager constantLifetimeCacheManager;      @Override     public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {         var caches = new ArrayList<Cache>();         var userId = (long) context.getArgs()[0];         if (userId % 2 == 0) { //маска, например, четный или не четный id             caches.add(defaultCacheManager.getCache(CaffeineCacheConfig.CACHE_NAME_SIGNATURE));         } else {             caches.add(constantLifetimeCacheManager.getCache(CaffeineCacheConfig.CACHE_NAME_SIGNATURE_CONST));         }         return caches;     }

Здесь мы получаем из CacheOperationInvocationContext данные сигнатуры метода — в нашем случае это единственный параметр userId. Проверяем по маске: в примере выше смотрим, четный параметр или нечетный. В зависимости от результата проверки используется тот или иной CacheManager. 

Осталось указать данный CacheResolver в параметрах @Cacheable:

@Override @Cacheable(cacheNames = {"signature"},     key = "{#param, T(ru.rosbank.ThreadLocalCustom).getCustomId()}",  cacheResolver = "multipleCacheResolver") public Set<SignatureLevel> getSignatureLevels(long userId) {       //логика }

Перед выполнением метода getSignatureLevels вызывается CacheResolver, который определяет, какой кеш использовать, и использует его.

Мы рассмотрели, как можно кастомизировать кеш с использованием CacheResolver и SimpleKeyGenerator. Теперь я вернусь к третьему пункту списка: расскажу о подходе с локальным кешированием и, главное, о том, как можно провести в нем аналогичную (похожую) кастомизацию.

Локальное кеширование

Для начала хотелось бы уточнить, что понимается под локальным кешированием. У нас есть адаптер, который непосредственно обращается к сервису. В данном адаптере имеется коллекция, которая принадлежит классу UserImpl:

public class UserImpl implements UserApi {     private final Set<SignatureLevel> signatureLevels = new HashSet<>(); ...

В эту коллекцию сохраняется результат запроса из сервиса USER:

public class UserImpl implements UserApi   private final Set<SignatureLevel> signatureLevels  = new HashSet<>();   private boolean isCache = false;   public Set<SignatureLevel> getSignatureLevels(long userId) {       if (!isCache) {           signatureLevels.addAll(//запрос в сервис);           isCache = true;       }       return signatureLevels;     }  @Bean public UserApi getUserApi(){     return new UserImpl(); }

Вначале мы проверяем, заполнена ли коллекция. Если нет, то идем в сервис, получаем оттуда данные, добавляем в коллекцию и возвращаем результат. Если данные есть, то сразу возвращаем их из коллекции.

Вроде всё сделано хорошо… но работать не будет. Ведь Spring создаёт бины singleton. Так что когда к нам придет следующий запрос, в коллекции signatureLevels будут данные от предыдущего запроса. Как решить эту проблему? Логично сделать бин UserImpl prototype:

public class UserImpl implements UserApi {     private final Set<SignatureLevel> signatureLevels = new HashSet<>(); ...  @Bean @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE) public UserApi getUserApi()     return new UserImpl();

Но это всё равно не сработает. Вспомним, как Spring создает бины. У нас есть два бина, handler или сервис, который является singleton, и UserApi — prototype, который, в свою очередь, заинжекчен в бин singleton. При создании бина singleton будут сначала созданы все зависимые бины, а затем один раз заинжекчены. Поэтому бин UserImpl для singleton будет один и тот же.

Один из вариантов заставить всё работать — использовать @Lookup:

@Lookup private UserApi getUserApi()     return null;

Рассмотрим, как можно кастомизировать это по аналогии с кешированием. Предлагаю достаточно простой способ — с помощью пользовательской области видимости.

Пользовательская область видимости

Попробуем сделать и использовать свой кастомный Scope, для этого необходимо:

  • реализовать метод Object get(String name, ObjectFactory<?> objectFactory) интерфейса Scope. Он обязателен для реализации. Все другие методы этого интерфейса опциональны.

  • зарегистрировать новый scope:

public class CustomScope implements Scope {     @Override    public Object get(String name, ObjectFactory<?> objectFactory) {        // логика    } ...

Для регистрации нового Scope необходимо реализовать интерфейс BeanFactoryPostProcessor и в методе PostProcessBeanFactory зарегистрировать новый Scope. В примере новый бин получил название CustomScope:

@Component public class CustomBeanFactoryPostProcessor implements BeanFactoryPostProcessor    @Override    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)         beanFactory.registerScope("customScope", new CustomScope());

Рассмотрим пример реализации пользовательской области видимости. Для хранения созданных бинов будем также использовать Caffeine. Можно использовать и обычную ConcurrentHashMap, но тогда придётся реализовывать механизм удаления бинов из хранилища в зависимости от того, сколько они должны жить. При использовании Caffeine всё будет само очищаться по заданной логике, рассмотренной в предыдущем разделе. Пример реализации: 

public class CustomScope implements Scope {     private final Cache<Object, Object> defaultCacheManager;     private final Cache<Object, Object> constantLifetimeCacheManager;      @Override     public Object get(String name,  ObjectFactory<?> objectFactory) {         if (Long.parseLong(ThreadLocalCustom.getContext().getCustomId()) % 2 == 0) {             return defaultCacheManager.get(name + ThreadLocalCustom.getContext().getCustomId(), k -> objectFactory.getObject());         } else {             return constantLifetimeCacheManager.get(name + ThreadLocalCustom.getContext().getCustomId(), k -> objectFactory.getObject());         }     } ...

В примере указано два CacheManager, с которыми мы работали. В качестве ключа для кеша используем название класса и CustomId из ThreadLocal, а значением будем непосредственно сам созданный бин.

Здесь, в отличие от предыдущего примера, для определения CacheManager’а используется CustomId, потому что параметр UserId получить достаточно сложно. Это решаемый вопрос, но в нашем случае я решил не усложнять примеры лишним кодом и использовал CustomId из ThreadLocal в качестве маски.

Осталось указать, что мы используем кастомный @Scope для UserImpl, и подсказать Spring, что надо брать новый @Bean из контекста в соответствии с нашими настройками, ведь мы имеем дело с разными scope’ами для бинов:

public class UserImpl implements UserApi      private final Set<SignatureLevel> signatureLevels = new HashSet<>();     private boolean isCache = false;      public Set<SignatureLevel> getSignatureLevels(long params) {         if (!isCache) {             signatureLevels.addAll(//запрос в сервис);             isCache = true;         }         return signatureLevels;     } @Bean @Scope(scopeName = "customScope", proxyMode = ScopedProxyMode.TARGET_CLASS) public UserApi getUserApi(){     return new UserImpl();

Опишем, как работает данный механизм в целом. Когда запрос попадает в handler, для определения, какой бин использовать UserImpl, будет вызван метод get класса CustomScope. Если в методе get бин находится в кеше, он будет возвращен, если его нет в кеше, то будет создан, помещен в кеш и возвращен.

И уже в возвращенном бине UserImpl будет отрабатываться логика нахождения уровней подписи SignatureLevels.

Подведем итоги

Если вы столкнулись с проблемами, описанными в начале статьи, правильное кеширование запросов сможет вам помочь в их решении. Сделать это несложно, запросы легко поддаются кастомизации, и мы можем делать разные настройки для разных задач. Также можно использовать решение на основе пользовательской области видимости, но данный подход сложнее, в нем нужно учесть ряд важных нюансов, связанных с синхронизацией.


ссылка на оригинал статьи https://habr.com/ru/company/rosbank/blog/694768/


Комментарии

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

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