Статьи

Методы дроссельной заслонки с Spring AOP и ограничителем скорости Guava

Внешние службы или API могут иметь ограничения на использование или просто не могут обрабатывать множество запросов без сбоев. В этом посте объясняется, как создать аспект на основе Spring Framework, который можно использовать для регулирования любых вызовов рекомендуемых методов с помощью ограничителя скорости Guava. Следующая реализация требует Java 8, Spring AOP и Guava.

Давайте начнем с аннотации, которая используется для рекомендации любого вызова метода с поддержкой Spring AOP.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
  
    /**
     * @return rate limit in queries per second
     */
    int value();
  
    /**
     * @return rate limiter identifier (optional)
     */
    String key() default "";
  
}

Аннотация определяет две вещи: ограничение скорости, как в запросах (или разрешениях) в секунду, и необязательный ключ для определения ограничителя скорости. Несколько методов могут использовать один и тот же ограничитель скорости, если ключи равны. Например, когда API вызывается с разными параметрами из разных методов, желаемое общее количество запросов в секунду не будет превышать.

Следующая вещь — фактический аспект регулирования, который реализован как компонент Spring Framework. Это довольно просто использовать аспект в любом контексте, с или без Spring Framework.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Aspect
@Component
public class RateLimiterAspect {
  
    public interface KeyFactory {
        String createKey(JoinPoint jp, RateLimit limit);
    }
  
    private static final Logger LOGGER = LoggerFactory.getLogger(RateLimiterAspect.class);
  
    private static final KeyFactory DEFAULT_KEY_FACTORY = (jp, limit) -> JoinPointToStringHelper.toString(jp);
     
    private final ConcurrentHashMap<String, RateLimiter> limiters;
    private final KeyFactory keyFactory;
  
    @Autowired
    public RateLimiterAspect(Optional<KeyFactory> keyFactory) {
        this.limiters = new ConcurrentHashMap<>();
        this.keyFactory = keyFactory.orElse(DEFAULT_KEY_FACTORY);
    }
  
    @Before("@annotation(limit)")
    public void rateLimit(JoinPoint jp, RateLimit limit) {
        String key = createKey(jp, limit);
        RateLimiter limiter = limiters.computeIfAbsent(key, createLimiter(limit));
        double delay = limiter.acquire();
        LOGGER.debug("Acquired rate limit permission ({} qps) for {} in {} seconds", limiter.getRate(), key, delay);
    }
  
    private Function<String, RateLimiter> createLimiter(RateLimit limit) {
        return name -> RateLimiter.create(limit.value());
    }
  
    private String createKey(JoinPoint jp, RateLimit limit) {
        return Optional.ofNullable(Strings.emptyToNull(limit.key()))
                .orElseGet(() -> keyFactory.createKey(jp, limit));
    }
}

Класс определяет дополнительный интерфейс и реализацию по умолчанию для фабрики ключей, которая используется, если аннотация не предоставляет явный ключ для ограничителя скорости. Фабрика ключей может использовать точку соединения (в основном вызов метода) и предоставленную аннотацию для создания подходящего ключа для ограничителя скорости. Аспект также использует одновременную хэш-карту для хранения экземпляров ограничителя скорости. Аспект определен как одноэлементный, но метод rateLimit может быть вызван из нескольких потоков, поэтому одновременная хэш-карта гарантирует, что мы выделяем только один ограничитель скорости для уникального ключа. Внедрение в конструктор в этом аспекте использует опциональную поддержку внедрения в Spring Framework. Если в контексте не определен bean-компонент KeyFactory, используется фабрика ключей по умолчанию.

Класс аннотируется @Aspect и @Component, так что Spring понимает, что аспект определен, и включает рекомендацию @Before. @ Перед рекомендацией содержится только один pointcut, который требует аннотации RateLimit и привязывает его к параметру limit метода. Реализация регулирования довольно проста. Сначала создается ключ для ограничителя скорости. Затем ключ используется для поиска или создания ограничителя, и, наконец, ограничитель приобретается для разрешения.

В создании ключа ограничителя скорости есть небольшая ошибка. Ключ, определенный аннотацией, преобразуется в необязательный, но необязательный метод orElse не может использоваться из-за соображений производительности. Опциональный метод orElse принимает значение, которое нам необходимо создать в любом случае, когда необязательный orElse присутствует, а когда его нет. Другой метод orElseGet с другой стороны, использует поставщика, который позволяет ленивую оценку значения только тогда, когда необязательный параметр отсутствует. createKey фабрики createKey может быть дорогой операцией, поэтому используется версия поставщика.

Concurrent hashmap содержит удобный метод computeIfAbsent который атомарно находит или создает значение на основе ключа и определенной функции. Это позволяет просто и кратко лениво инициализировать значения карты. Ограничители скорости создаются по требованию и гарантированно имеют только один экземпляр на уникальный ключ ограничителя.

Реализация фабрики ключей по умолчанию использует вспомогательный метод из JoinPointToStringHelper, который преобразует точку соединения в текстовое представление.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class JoinPointToStringHelper {
  
    public static String toString(JoinPoint jp) {
        StringBuilder sb = new StringBuilder();
        appendType(sb, getType(jp));
        Signature signature = jp.getSignature();
        if (signature instanceof MethodSignature) {
            MethodSignature ms = (MethodSignature) signature;
            sb.append("#");
            sb.append(ms.getMethod().getName());
            sb.append("(");
            appendTypes(sb, ms.getMethod().getParameterTypes());
            sb.append(")");
        }
        return sb.toString();
    }
  
    private static Class<?> getType(JoinPoint jp) {
        return Optional.ofNullable(jp.getSourceLocation())
                .map(SourceLocation::getWithinType)
                .orElse(jp.getSignature().getDeclaringType());
    }
  
    private static void appendTypes(StringBuilder sb, Class<?>[] types) {
        for (int size = types.length, i = 0; i < size; i++) {
            appendType(sb, types[i]);
            if (i < size - 1) {
                sb.append(",");
            }
        }
    }
  
    private static void appendType(StringBuilder sb, Class<?> type) {
        if (type.isArray()) {
            appendType(sb, type.getComponentType());
            sb.append("[]");
        } else {
            sb.append(type.getName());
        }
    }
}

Наконец, регулирование можно применить к любому методу Spring, просто добавив аннотацию @RateLimit.

01
02
03
04
05
06
07
08
09
10
11
@Service
public class MyService {
  
    ...
  
    @RateLimit(5)
    public String callExternalApi() {
        return restTemplate.getForEntity(url, String.class).getBody();
    }
  
}

Можно задаться вопросом, хорошо ли масштабируется это решение? Нет, это действительно не так. Ограничитель скорости в Guava блокирует текущий поток, поэтому при наличии пакета асинхронных вызовов против удушенного сервиса многие потоки будут заблокированы, что может привести к исчерпанию свободных потоков. Другая проблема возникает, если службы реплицируются в нескольких приложениях или экземплярах JVM. Глобальная синхронизация скорости ограничителя отсутствует. Эта реализация отлично работает для одного приложения, живущего в одной JVM, с приличной нагрузкой на методы регулирования

Дальнейшее чтение: