El método de regulación llama a M solicitudes en N segundos


137

Necesito un componente / clase que acelere la ejecución de algún método al máximo de llamadas M en N segundos (o ms o nanos, no importa).

En otras palabras, necesito asegurarme de que mi método se ejecute no más de M veces en una ventana deslizante de N segundos.

Si no conoce la clase existente, no dude en publicar sus soluciones / ideas sobre cómo implementaría esto.



3
Hay algunas excelentes respuestas a este problema en stackoverflow.com/questions/667508/…
skaffman

> Necesito asegurarme de que mi método se ejecute> no más de M veces en una> ventana deslizante de N segundos. Recientemente escribí una publicación de blog sobre cómo hacer esto en .NET. Es posible que pueda crear algo similar en Java. Mejor limitación de velocidad en .NET
Jack Leitch

La pregunta original se parece mucho al problema resuelto en esta publicación de blog: [Java Multi-Channel Asynchronous Throttler] ( cordinc.com/blog/2010/04/java-multichannel-asynchronous.html ). Para una tasa de M llamadas en N segundos, el regulador discutido en este blog garantiza que cualquier intervalo de longitud N en la línea de tiempo no contendrá más de M llamadas.
Hbf

Respuestas:


81

Usaría un búfer en anillo de marcas de tiempo con un tamaño fijo de M. Cada vez que se llama al método, verifica la entrada más antigua, y si es menos de N segundos en el pasado, ejecuta y agrega otra entrada, de lo contrario duerme Por la diferencia horaria.


44
Encantador. Justo lo que necesito. Los intentos rápidos muestran ~ 10 líneas para implementar esto y una huella de memoria mínima. Solo necesito pensar en la seguridad de los hilos y en la cola de las solicitudes entrantes.
vtrubnikov

55
Es por eso que usa DelayQueue de java.util.concurrent. Previene el problema de múltiples hilos que actúan en la misma entrada.
erickson

55
Para un caso de subprocesos múltiples, creo que el enfoque de cubo de fichas puede ser una mejor opción.
Michael Borgwardt

1
¿Sabes cómo se llama este algoritmo si tiene algún nombre?
Vlado Pandžić

80

Lo que funcionó fuera de la caja para mí fue Google Guava RateLimiter .

// Allow one request per second
private RateLimiter throttle = RateLimiter.create(1.0);

private void someMethod() {
    throttle.acquire();
    // Do something
}

19
No recomendaría esta solución ya que Guava RateLimiter bloqueará el hilo y eso agotará fácilmente el grupo de hilos.
kaviddiss

18
@kaviddiss si no quieres bloquear, usatryAquire()
slf

77
El problema con la implementación actual de RateLimiter (al menos para mí) es que no permite períodos de tiempo superiores a 1 segundo y, por lo tanto, tasas de, por ejemplo, 1 por minuto.
John B

44
@ John B Por lo que yo entiendo, puede lograr 1 solicitud por minuto con RateLimiter usando RateLimiter.create (60.0) + rateLimiter.acquire (60)
divideByZero el

2
@radiantRazor Ratelimiter.create (1.0 / 60) y adquirir () logra 1 llamada por minuto.
bizentass

30

En términos concretos, debería poder implementar esto con a DelayQueue. Inicialice la cola con M Delayedinstancias con su retraso inicialmente establecido en cero. A medida que entran solicitudes al método, takeun token, que hace que el método se bloquee hasta que se cumpla el requisito de limitación. Cuando se ha tomado adduna ficha , una nueva ficha a la cola con un retraso de N.


1
Sí, esto haría el truco. Pero no me gusta particularmente DelayQueue porque está usando (a través de PriortyQueue) un hash binario equilibrado (lo que significa muchas comparaciones offery un posible crecimiento de la matriz), y todo es un poco pesado para mí. Supongo que para otros esto podría estar perfectamente bien.
vtrubnikov

55
En realidad, en esta aplicación, dado que el nuevo elemento agregado al montón casi siempre será el elemento máximo en el montón (es decir, tiene el mayor retraso), generalmente se requiere una comparación por agregado. Además, la matriz nunca crecerá si el algoritmo se implementa correctamente, ya que un elemento se agrega solo después de tomar un elemento.
erickson

3
También encontré esto útil en los casos en que no desea que las solicitudes sucedan en grandes ráfagas manteniendo el tamaño M y el retraso N relativamente pequeño en orden de puñado de milis. p.ej. M = 5, N = 20ms proporcionaría una transferencia directa de 250 / seg de ráfaga de kepping en un tamaño de 5.
FUD

¿Se escalará a un millón de rpm y cuando se permitan solicitudes concurrentes? Necesitaría agregar un millón de elementos retrasados. Además, los casos de esquina tendrán una latencia alta: caso en el que varios subprocesos están llamando a poll () y se bloquearía cada vez.
Aditya Joshee

@AdityaJoshee No lo he comparado, pero si tengo algo de tiempo intentaré tener una idea de los gastos generales. Una cosa a tener en cuenta es que no necesita 1 millón de tokens que caducan en 1 segundo. Podría tener 100 tokens que caducan en 10 milisegundos, 10 tokens que caducan en milisegundos, etc. Esto realmente obliga a que la tasa instantánea esté más cerca de la tasa promedio, suavizando los picos, lo que puede causar copias de seguridad en el cliente, pero eso es una consecuencia natural. de limitación de velocidad. Sin embargo, 1 millón de RPM apenas suena a estrangulamiento. Si puede explicar su caso de uso, podría tener mejores ideas.
Erickson

21

Lea sobre el algoritmo de depósito de token . Básicamente, tienes un cubo con tokens. Cada vez que ejecutas el método, tomas un token. Si no hay más fichas, bloqueas hasta que obtengas una. Mientras tanto, hay algún actor externo que repone las fichas en un intervalo fijo.

No conozco una biblioteca para hacer esto (o algo similar). Puede escribir esta lógica en su código o usar AspectJ para agregar el comportamiento.


3
Gracias por su sugerencia, algo interesante. Pero no es exactamente lo que necesito. Por ejemplo, necesito limitar la ejecución a 5 llamadas por segundo. Si uso el depósito de tokens y llegan 10 solicitudes al mismo tiempo, las primeras 5 llamadas tomarán todos los tokens disponibles y se ejecutarán momentáneamente, mientras que las 5 llamadas restantes se ejecutarán en un intervalo fijo de 1/5 s. En tal situación, necesito 5 llamadas restantes para ejecutarlas en ráfaga única solo después de pases de 1 segundo.
vtrubnikov

55
¿Qué pasa si agregas 5 fichas al cubo cada segundo (o 5 - (5 restantes) en lugar de 1 cada 1/5 de segundo?
Kevin

@Kevin no, esto todavía no me daría el efecto de 'ventana deslizante'
vtrubnikov

2
@valery sí lo haría. (Sin embargo, recuerde limitar las fichas en M)
nos

No es necesario un "actor externo". Todo se puede hacer con un solo subproceso si mantiene los metadatos sobre los tiempos de solicitud.
Marsellus Wallace

8

Si necesita un limitador de velocidad de ventana deslizante basado en Java que opere en un sistema distribuido, puede echar un vistazo al proyecto https://github.com/mokies/ratelimitj .

Una configuración respaldada por Redis, para limitar las solicitudes por IP a 50 por minuto, se vería así:

import com.lambdaworks.redis.RedisClient;
import es.moki.ratelimitj.core.LimitRule;

RedisClient client = RedisClient.create("redis://localhost");
Set<LimitRule> rules = Collections.singleton(LimitRule.of(1, TimeUnit.MINUTES, 50)); // 50 request per minute, per key
RedisRateLimit requestRateLimiter = new RedisRateLimit(client, rules);

boolean overLimit = requestRateLimiter.overLimit("ip:127.0.0.2");

Consulte https://github.com/mokies/ratelimitj/tree/master/ratelimitj-redis para obtener más detalles sobre la configuración de Redis.


5

Esto depende de la aplicación.

Imagínese el caso en el que varios subprocesos quieren un token para hacer un poco de acción a nivel mundial de la velocidad limitada a ninguna ráfaga permitido (es decir, que desea limitar a 10 acciones por cada 10 segundos, pero no desea que 10 acciones que sucedan en el primer segundo y luego permanecer 9 segundos detenidos).

El DelayedQueue tiene una desventaja: el orden en el que los subprocesos solicitan tokens podría no ser el orden en el que reciben su solicitud. Si se bloquean varios subprocesos esperando un token, no está claro cuál tomará el siguiente token disponible. Incluso podría tener hilos esperando para siempre, en mi punto de vista.

Una solución es tener un intervalo de tiempo mínimo entre dos acciones consecutivas y tomar acciones en el mismo orden en que se solicitaron.

Aquí hay una implementación:

public class LeakyBucket {
    protected float maxRate;
    protected long minTime;
    //holds time of last action (past or future!)
    protected long lastSchedAction = System.currentTimeMillis();

    public LeakyBucket(float maxRate) throws Exception {
        if(maxRate <= 0.0f) {
            throw new Exception("Invalid rate");
        }
        this.maxRate = maxRate;
        this.minTime = (long)(1000.0f / maxRate);
    }

    public void consume() throws InterruptedException {
        long curTime = System.currentTimeMillis();
        long timeLeft;

        //calculate when can we do the action
        synchronized(this) {
            timeLeft = lastSchedAction + minTime - curTime;
            if(timeLeft > 0) {
                lastSchedAction += minTime;
            }
            else {
                lastSchedAction = curTime;
            }
        }

        //If needed, wait for our time
        if(timeLeft <= 0) {
            return;
        }
        else {
            Thread.sleep(timeLeft);
        }
    }
}

¿Qué minTimesignifica aquí? ¿Qué hace? ¿Puedes explicar sobre eso?
flash

minTimees la cantidad mínima de tiempo que debe pasar después de que se consuma un token antes de que se pueda consumir el siguiente token.
Duarte Meneses

3

Aunque no es lo que pidió, ThreadPoolExecutorque está diseñado para limitar M solicitudes simultáneas en lugar de M solicitudes en N segundos, también podría ser útil.


2

He implementado un algoritmo de aceleración simple. Pruebe este enlace, http://krishnaprasadas.blogspot.in/2012/05/throttling-algorithm.html

Un breve sobre el algoritmo,

Este algoritmo utiliza la capacidad de Java Delayed Queue . Cree un objeto retrasado con el retraso esperado (aquí 1000 / M para milisegundos TimeUnit ). Ponga el mismo objeto en la cola retrasada que nos proporcionará la ventana móvil. Luego, antes de que cada llamada al método tome el objeto de la cola, la toma es una llamada de bloqueo que regresará solo después del retraso especificado, y después de la llamada al método, no olvide poner el objeto en la cola con el tiempo actualizado (aquí milisegundos actuales) .

Aquí también podemos tener múltiples objetos retrasados ​​con diferentes retrasos. Este enfoque también proporcionará un alto rendimiento.


66
Debe publicar un resumen de su algoritmo. Si su enlace desaparece, su respuesta se vuelve inútil.
jwr

Gracias, he añadido el escrito.
Krishas

1

Mi implementación a continuación puede manejar una precisión de tiempo de solicitud arbitraria, tiene una complejidad de tiempo O (1) para cada solicitud, no requiere ningún búfer adicional, por ejemplo, complejidad de espacio O (1), además no requiere subproceso de fondo para liberar el token, en su lugar los tokens se liberan según el tiempo transcurrido desde la última solicitud.

class RateLimiter {
    int limit;
    double available;
    long interval;

    long lastTimeStamp;

    RateLimiter(int limit, long interval) {
        this.limit = limit;
        this.interval = interval;

        available = 0;
        lastTimeStamp = System.currentTimeMillis();
    }

    synchronized boolean canAdd() {
        long now = System.currentTimeMillis();
        // more token are released since last request
        available += (now-lastTimeStamp)*1.0/interval*limit; 
        if (available>limit)
            available = limit;

        if (available<1)
            return false;
        else {
            available--;
            lastTimeStamp = now;
            return true;
        }
    }
}

0

Intente usar este enfoque simple:

public class SimpleThrottler {

private static final int T = 1; // min
private static final int N = 345;

private Lock lock = new ReentrantLock();
private Condition newFrame = lock.newCondition();
private volatile boolean currentFrame = true;

public SimpleThrottler() {
    handleForGate();
}

/**
 * Payload
 */
private void job() {
    try {
        Thread.sleep(Math.abs(ThreadLocalRandom.current().nextLong(12, 98)));
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.err.print(" J. ");
}

public void doJob() throws InterruptedException {
    lock.lock();
    try {

        while (true) {

            int count = 0;

            while (count < N && currentFrame) {
                job();
                count++;
            }

            newFrame.await();
            currentFrame = true;
        }

    } finally {
        lock.unlock();
    }
}

public void handleForGate() {
    Thread handler = new Thread(() -> {
        while (true) {
            try {
                Thread.sleep(1 * 900);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                currentFrame = false;

                lock.lock();
                try {
                    newFrame.signal();
                } finally {
                    lock.unlock();
                }
            }
        }
    });
    handler.start();
}

}



0

Esta es una actualización del código LeakyBucket anterior. Esto funciona para más de 1000 solicitudes por segundo.

import lombok.SneakyThrows;
import java.util.concurrent.TimeUnit;

class LeakyBucket {
  private long minTimeNano; // sec / billion
  private long sched = System.nanoTime();

  /**
   * Create a rate limiter using the leakybucket alg.
   * @param perSec the number of requests per second
   */
  public LeakyBucket(double perSec) {
    if (perSec <= 0.0) {
      throw new RuntimeException("Invalid rate " + perSec);
    }
    this.minTimeNano = (long) (1_000_000_000.0 / perSec);
  }

  @SneakyThrows public void consume() {
    long curr = System.nanoTime();
    long timeLeft;

    synchronized (this) {
      timeLeft = sched - curr + minTimeNano;
      sched += minTimeNano;
    }
    if (timeLeft <= minTimeNano) {
      return;
    }
    TimeUnit.NANOSECONDS.sleep(timeLeft);
  }
}

y la unidad de prueba de arriba:

import com.google.common.base.Stopwatch;
import org.junit.Ignore;
import org.junit.Test;

import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;

public class LeakyBucketTest {
  @Test @Ignore public void t() {
    double numberPerSec = 10000;
    LeakyBucket b = new LeakyBucket(numberPerSec);
    Stopwatch w = Stopwatch.createStarted();
    IntStream.range(0, (int) (numberPerSec * 5)).parallel().forEach(
        x -> b.consume());
    System.out.printf("%,d ms%n", w.elapsed(TimeUnit.MILLISECONDS));
  }
}

¿Qué minTimeNanosignifica aquí? ¿puedes explicar?
flash

0

Aquí hay una pequeña versión avanzada del limitador de velocidad simple

/**
 * Simple request limiter based on Thread.sleep method.
 * Create limiter instance via {@link #create(float)} and call {@link #consume()} before making any request.
 * If the limit is exceeded cosume method locks and waits for current call rate to fall down below the limit
 */
public class RequestRateLimiter {

    private long minTime;

    private long lastSchedAction;
    private double avgSpent = 0;

    ArrayList<RatePeriod> periods;


    @AllArgsConstructor
    public static class RatePeriod{

        @Getter
        private LocalTime start;

        @Getter
        private LocalTime end;

        @Getter
        private float maxRate;
    }


    /**
     * Create request limiter with maxRate - maximum number of requests per second
     * @param maxRate - maximum number of requests per second
     * @return
     */
    public static RequestRateLimiter create(float maxRate){
        return new RequestRateLimiter(Arrays.asList( new RatePeriod(LocalTime.of(0,0,0),
                LocalTime.of(23,59,59), maxRate)));
    }

    /**
     * Create request limiter with ratePeriods calendar - maximum number of requests per second in every period
     * @param ratePeriods - rate calendar
     * @return
     */
    public static RequestRateLimiter create(List<RatePeriod> ratePeriods){
        return new RequestRateLimiter(ratePeriods);
    }

    private void checkArgs(List<RatePeriod> ratePeriods){

        for (RatePeriod rp: ratePeriods ){
            if ( null == rp || rp.maxRate <= 0.0f || null == rp.start || null == rp.end )
                throw new IllegalArgumentException("list contains null or rate is less then zero or period is zero length");
        }
    }

    private float getCurrentRate(){

        LocalTime now = LocalTime.now();

        for (RatePeriod rp: periods){
            if ( now.isAfter( rp.start ) && now.isBefore( rp.end ) )
                return rp.maxRate;
        }

        return Float.MAX_VALUE;
    }



    private RequestRateLimiter(List<RatePeriod> ratePeriods){

        checkArgs(ratePeriods);
        periods = new ArrayList<>(ratePeriods.size());
        periods.addAll(ratePeriods);

        this.minTime = (long)(1000.0f / getCurrentRate());
        this.lastSchedAction = System.currentTimeMillis() - minTime;
    }

    /**
     * Call this method before making actual request.
     * Method call locks until current rate falls down below the limit
     * @throws InterruptedException
     */
    public void consume() throws InterruptedException {

        long timeLeft;

        synchronized(this) {
            long curTime = System.currentTimeMillis();

            minTime = (long)(1000.0f / getCurrentRate());
            timeLeft = lastSchedAction + minTime - curTime;

            long timeSpent = curTime - lastSchedAction + timeLeft;
            avgSpent = (avgSpent + timeSpent) / 2;

            if(timeLeft <= 0) {
                lastSchedAction = curTime;
                return;
            }

            lastSchedAction = curTime + timeLeft;
        }

        Thread.sleep(timeLeft);
    }

    public synchronized float getCuRate(){
        return (float) ( 1000d / avgSpent);
    }
}

Y pruebas unitarias

import org.junit.Assert;
import org.junit.Test;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class RequestRateLimiterTest {


    @Test(expected = IllegalArgumentException.class)
    public void checkSingleThreadZeroRate(){

        // Zero rate
        RequestRateLimiter limiter = RequestRateLimiter.create(0);
        try {
            limiter.consume();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    @Test
    public void checkSingleThreadUnlimitedRate(){

        // Unlimited
        RequestRateLimiter limiter = RequestRateLimiter.create(Float.MAX_VALUE);

        long started = System.currentTimeMillis();
        for ( int i = 0; i < 1000; i++ ){

            try {
                limiter.consume();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        long ended = System.currentTimeMillis();
        System.out.println( "Current rate:" + limiter.getCurRate() );
        Assert.assertTrue( ((ended - started) < 1000));
    }

    @Test
    public void rcheckSingleThreadRate(){

        // 3 request per minute
        RequestRateLimiter limiter = RequestRateLimiter.create(3f/60f);

        long started = System.currentTimeMillis();
        for ( int i = 0; i < 3; i++ ){

            try {
                limiter.consume();
                Thread.sleep(20000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        long ended = System.currentTimeMillis();

        System.out.println( "Current rate:" + limiter.getCurRate() );
        Assert.assertTrue( ((ended - started) >= 60000 ) & ((ended - started) < 61000));
    }



    @Test
    public void checkSingleThreadRateLimit(){

        // 100 request per second
        RequestRateLimiter limiter = RequestRateLimiter.create(100);

        long started = System.currentTimeMillis();
        for ( int i = 0; i < 1000; i++ ){

            try {
                limiter.consume();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        long ended = System.currentTimeMillis();

        System.out.println( "Current rate:" + limiter.getCurRate() );
        Assert.assertTrue( (ended - started) >= ( 10000 - 100 ));
    }

    @Test
    public void checkMultiThreadedRateLimit(){

        // 100 request per second
        RequestRateLimiter limiter = RequestRateLimiter.create(100);
        long started = System.currentTimeMillis();

        List<Future<?>> tasks = new ArrayList<>(10);
        ExecutorService exec = Executors.newFixedThreadPool(10);

        for ( int i = 0; i < 10; i++ ) {

            tasks.add( exec.submit(() -> {
                for (int i1 = 0; i1 < 100; i1++) {

                    try {
                        limiter.consume();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }) );
        }

        tasks.stream().forEach( future -> {
            try {
                future.get();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
        });

        long ended = System.currentTimeMillis();
        System.out.println( "Current rate:" + limiter.getCurRate() );
        Assert.assertTrue( (ended - started) >= ( 10000 - 100 ) );
    }

    @Test
    public void checkMultiThreaded32RateLimit(){

        // 0,2 request per second
        RequestRateLimiter limiter = RequestRateLimiter.create(0.2f);
        long started = System.currentTimeMillis();

        List<Future<?>> tasks = new ArrayList<>(8);
        ExecutorService exec = Executors.newFixedThreadPool(8);

        for ( int i = 0; i < 8; i++ ) {

            tasks.add( exec.submit(() -> {
                for (int i1 = 0; i1 < 2; i1++) {

                    try {
                        limiter.consume();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }) );
        }

        tasks.stream().forEach( future -> {
            try {
                future.get();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
        });

        long ended = System.currentTimeMillis();
        System.out.println( "Current rate:" + limiter.getCurRate() );
        Assert.assertTrue( (ended - started) >= ( 10000 - 100 ) );
    }

    @Test
    public void checkMultiThreadedRateLimitDynamicRate(){

        // 100 request per second
        RequestRateLimiter limiter = RequestRateLimiter.create(100);
        long started = System.currentTimeMillis();

        List<Future<?>> tasks = new ArrayList<>(10);
        ExecutorService exec = Executors.newFixedThreadPool(10);

        for ( int i = 0; i < 10; i++ ) {

            tasks.add( exec.submit(() -> {

                Random r = new Random();
                for (int i1 = 0; i1 < 100; i1++) {

                    try {
                        limiter.consume();
                        Thread.sleep(r.nextInt(1000));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }) );
        }

        tasks.stream().forEach( future -> {
            try {
                future.get();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
        });

        long ended = System.currentTimeMillis();
        System.out.println( "Current rate:" + limiter.getCurRate() );
        Assert.assertTrue( (ended - started) >= ( 10000 - 100 ) );
    }

}

El código es bastante simple. Simplemente cree el limitador con maxRate o con puntos y tasa. Y luego simplemente llame a consumir cada solicitud. Siempre que no se excede la tasa, el limitador regresa inmediatamente o espera un tiempo antes de regresar a la tasa de solicitud actual más baja. También tiene un método de tasa actual que devuelve el promedio móvil de la tasa actual.
Leonid Astakhov

0

Mi solución: un método util simple, puede modificarlo para crear una clase de contenedor.

public static Runnable throttle (Runnable realRunner, long delay) {
    Runnable throttleRunner = new Runnable() {
        // whether is waiting to run
        private boolean _isWaiting = false;
        // target time to run realRunner
        private long _timeToRun;
        // specified delay time to wait
        private long _delay = delay;
        // Runnable that has the real task to run
        private Runnable _realRunner = realRunner;
        @Override
        public void run() {
            // current time
            long now;
            synchronized (this) {
                // another thread is waiting, skip
                if (_isWaiting) return;
                now = System.currentTimeMillis();
                // update time to run
                // do not update it each time since
                // you do not want to postpone it unlimited
                _timeToRun = now+_delay;
                // set waiting status
                _isWaiting = true;
            }
            try {
                Thread.sleep(_timeToRun-now);

            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // clear waiting status before run
                _isWaiting = false;
                // do the real task
                _realRunner.run();
            }
        }};
    return throttleRunner;
}

Tome de JAVA Thread Debounce y Throttle

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.