Java 8 Stream con elaborazione batch


95

Ho un file di grandi dimensioni che contiene un elenco di elementi.

Vorrei creare un batch di elementi, effettuare una richiesta HTTP con questo batch (tutti gli elementi sono necessari come parametri nella richiesta HTTP). Posso farlo molto facilmente con un forciclo, ma come amante di Java 8, voglio provare a scrivere questo con il framework Stream di Java 8 (e raccogliere i vantaggi dell'elaborazione lenta).

Esempio:

List<String> batch = new ArrayList<>(BATCH_SIZE);
for (int i = 0; i < data.size(); i++) {
  batch.add(data.get(i));
  if (batch.size() == BATCH_SIZE) process(batch);
}

if (batch.size() > 0) process(batch);

Voglio fare qualcosa di molto lungo lazyFileStream.group(500).map(processBatch).collect(toList())

Quale sarebbe il modo migliore per farlo?


Non riesco a capire come eseguire il raggruppamento, mi dispiace, ma le righe di Files # leggeranno pigramente il contenuto del file.
Toby

1
quindi fondamentalmente hai bisogno di un inverso di flatMap(+ un flatMap aggiuntivo per comprimere di nuovo i flussi)? Non penso che qualcosa del genere esista come metodo conveniente nella libreria standard. O dovrai trovare una libreria di terze parti o scriverne una tua basata su spliterator e / o un collector che emette un flusso di flussi
the8472

3
Forse puoi combinarlo Stream.generatecon reader::readLinee limit, ma il problema è che gli stream non vanno bene con le eccezioni. Inoltre, questo probabilmente non è parallelizzabile bene. Penso che il forciclo sia ancora l'opzione migliore.
tobias_k

Ho appena aggiunto un codice di esempio. Non credo che flatMap sia la strada da percorrere. Sospettando di dover scrivere uno Spliterator personalizzato
Andy Dang

1
Sto coniando il termine "abuso di flusso" per domande come questa.
kervin

Risposte:


13

Nota! Questa soluzione legge l'intero file prima di eseguire forEach.

Puoi farlo con jOOλ , una libreria che estende i flussi Java 8 per casi d'uso di flussi sequenziali a thread singolo:

Seq.seq(lazyFileStream)              // Seq<String>
   .zipWithIndex()                   // Seq<Tuple2<String, Long>>
   .groupBy(tuple -> tuple.v2 / 500) // Map<Long, List<String>>
   .forEach((index, batch) -> {
       process(batch);
   });

Dietro le quinte zipWithIndex()c'è solo:

static <T> Seq<Tuple2<T, Long>> zipWithIndex(Stream<T> stream) {
    final Iterator<T> it = stream.iterator();

    class ZipWithIndex implements Iterator<Tuple2<T, Long>> {
        long index;

        @Override
        public boolean hasNext() {
            return it.hasNext();
        }

        @Override
        public Tuple2<T, Long> next() {
            return tuple(it.next(), index++);
        }
    }

    return seq(new ZipWithIndex());
}

... mentre l' groupBy()API è conveniente per:

default <K> Map<K, List<T>> groupBy(Function<? super T, ? extends K> classifier) {
    return collect(Collectors.groupingBy(classifier));
}

(Dichiarazione di non responsabilità: lavoro per l'azienda dietro jOOλ)


Wow. Questo è ESATTAMENTE quello che sto cercando. Il nostro sistema normalmente elabora i flussi di dati in sequenza, quindi sarebbe una buona idea passare a Java 8.
Andy Dang

16
Si noti che questa soluzione memorizza inutilmente l'intero flusso di input nell'intermedio Map(a differenza, ad esempio, della soluzione di Ben Manes)
Tagir Valeev

124

Per completezza, ecco una soluzione Guava .

Iterators.partition(stream.iterator(), batchSize).forEachRemaining(this::process);

Nella domanda la raccolta è disponibile quindi non è necessario un flusso e può essere scritto come,

Iterables.partition(data, batchSize).forEach(this::process);

11
Lists.partitionè un'altra variazione che avrei dovuto menzionare.
Ben Manes

2
questo è pigro, vero? non chiamerà l'intero Streamin memoria prima di elaborare il batch rilevante
orirab

1
@orirab yes. È pigro tra i batch, poiché consumerà batchSizeelementi per iterazione.
Ben Manes


58

È anche possibile l'implementazione pura di Java-8:

int BATCH = 500;
IntStream.range(0, (data.size()+BATCH-1)/BATCH)
         .mapToObj(i -> data.subList(i*BATCH, Math.min(data.size(), (i+1)*BATCH)))
         .forEach(batch -> process(batch));

Nota che a differenza di JOOl può funzionare bene in parallelo (a condizione che il tuo datasia un elenco ad accesso casuale).


1
e se i tuoi dati fossero effettivamente un flusso? (diciamo le righe in un file, o anche dalla rete).
Omry Yadan

6
@OmryYadan, la domanda riguardava il ricevere input da List(vedi data.size(), data.get()nella domanda). Sto rispondendo alla domanda posta. Se hai un'altra domanda, chiedila invece (anche se penso che anche la domanda sullo stream sia già stata posta).
Tagir Valeev

1
Come elaborare i lotti in parallelo?
soup_boy

37

Soluzione Pure Java 8 :

Possiamo creare un raccoglitore personalizzato per farlo in modo elegante, che prende a batch sizee a Consumerper elaborare ogni lotto:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.function.*;
import java.util.stream.Collector;

import static java.util.Objects.requireNonNull;


/**
 * Collects elements in the stream and calls the supplied batch processor
 * after the configured batch size is reached.
 *
 * In case of a parallel stream, the batch processor may be called with
 * elements less than the batch size.
 *
 * The elements are not kept in memory, and the final result will be an
 * empty list.
 *
 * @param <T> Type of the elements being collected
 */
class BatchCollector<T> implements Collector<T, List<T>, List<T>> {

    private final int batchSize;
    private final Consumer<List<T>> batchProcessor;


    /**
     * Constructs the batch collector
     *
     * @param batchSize the batch size after which the batchProcessor should be called
     * @param batchProcessor the batch processor which accepts batches of records to process
     */
    BatchCollector(int batchSize, Consumer<List<T>> batchProcessor) {
        batchProcessor = requireNonNull(batchProcessor);

        this.batchSize = batchSize;
        this.batchProcessor = batchProcessor;
    }

    public Supplier<List<T>> supplier() {
        return ArrayList::new;
    }

    public BiConsumer<List<T>, T> accumulator() {
        return (ts, t) -> {
            ts.add(t);
            if (ts.size() >= batchSize) {
                batchProcessor.accept(ts);
                ts.clear();
            }
        };
    }

    public BinaryOperator<List<T>> combiner() {
        return (ts, ots) -> {
            // process each parallel list without checking for batch size
            // avoids adding all elements of one to another
            // can be modified if a strict batching mode is required
            batchProcessor.accept(ts);
            batchProcessor.accept(ots);
            return Collections.emptyList();
        };
    }

    public Function<List<T>, List<T>> finisher() {
        return ts -> {
            batchProcessor.accept(ts);
            return Collections.emptyList();
        };
    }

    public Set<Characteristics> characteristics() {
        return Collections.emptySet();
    }
}

Facoltativamente, creare una classe di utilità di supporto:

import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collector;

public class StreamUtils {

    /**
     * Creates a new batch collector
     * @param batchSize the batch size after which the batchProcessor should be called
     * @param batchProcessor the batch processor which accepts batches of records to process
     * @param <T> the type of elements being processed
     * @return a batch collector instance
     */
    public static <T> Collector<T, List<T>, List<T>> batchCollector(int batchSize, Consumer<List<T>> batchProcessor) {
        return new BatchCollector<T>(batchSize, batchProcessor);
    }
}

Utilizzo di esempio:

List<Integer> input = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> output = new ArrayList<>();

int batchSize = 3;
Consumer<List<Integer>> batchProcessor = xs -> output.addAll(xs);

input.stream()
     .collect(StreamUtils.batchCollector(batchSize, batchProcessor));

Ho anche pubblicato il mio codice su GitHub, se qualcuno vuole dare un'occhiata:

Collegamento a Github


1
Questa è una buona soluzione, a meno che non sia possibile inserire tutti gli elementi del flusso nella memoria. Inoltre non funzionerà su flussi infiniti: il metodo di raccolta è terminale, il che significa che invece di produrre flussi di batch attenderà fino al completamento del flusso, quindi elaborerà il risultato in batch.
Alex Ackerman

2
@AlexAckerman un flusso infinito significherà che il finisher non verrà mai chiamato, ma l'accumulatore verrà comunque chiamato, quindi gli articoli verranno comunque elaborati. Inoltre, richiede solo che la dimensione del batch degli elementi sia in memoria in qualsiasi momento.
Solubris

@Solubris, hai ragione! Colpa mia, grazie per averlo sottolineato: non cancellerò il commento per il riferimento, se qualcuno ha la stessa idea di come funziona il metodo di raccolta.
Alex Ackerman

L'elenco inviato al consumatore deve essere copiato per renderlo sicuro per le modifiche, ad esempio: batchProcessor.accept (copyOf (ts))
Solubris

19

Ho scritto uno Spliterator personalizzato per scenari come questo. Riempirà elenchi di una data dimensione dal flusso di input. Il vantaggio di questo approccio è che eseguirà un'elaborazione lenta e funzionerà con altre funzioni di flusso.

public static <T> Stream<List<T>> batches(Stream<T> stream, int batchSize) {
    return batchSize <= 0
        ? Stream.of(stream.collect(Collectors.toList()))
        : StreamSupport.stream(new BatchSpliterator<>(stream.spliterator(), batchSize), stream.isParallel());
}

private static class BatchSpliterator<E> implements Spliterator<List<E>> {

    private final Spliterator<E> base;
    private final int batchSize;

    public BatchSpliterator(Spliterator<E> base, int batchSize) {
        this.base = base;
        this.batchSize = batchSize;
    }

    @Override
    public boolean tryAdvance(Consumer<? super List<E>> action) {
        final List<E> batch = new ArrayList<>(batchSize);
        for (int i=0; i < batchSize && base.tryAdvance(batch::add); i++)
            ;
        if (batch.isEmpty())
            return false;
        action.accept(batch);
        return true;
    }

    @Override
    public Spliterator<List<E>> trySplit() {
        if (base.estimateSize() <= batchSize)
            return null;
        final Spliterator<E> splitBase = this.base.trySplit();
        return splitBase == null ? null
                : new BatchSpliterator<>(splitBase, batchSize);
    }

    @Override
    public long estimateSize() {
        final double baseSize = base.estimateSize();
        return baseSize == 0 ? 0
                : (long) Math.ceil(baseSize / (double) batchSize);
    }

    @Override
    public int characteristics() {
        return base.characteristics();
    }

}

davvero utile. Se qualcuno desidera eseguire il batch su alcuni criteri personalizzati (ad esempio la dimensione della raccolta in byte), è possibile delegare il predicato personalizzato e utilizzarlo in ciclo for come condizione (imho while loop sarà più leggibile quindi)
pls

Non sono sicuro che l'implementazione sia corretta. Ad esempio, se il flusso di base è SUBSIZEDla divisione restituita da trySplitpuò avere più elementi rispetto a prima della divisione (se la divisione avviene nel mezzo del batch).
Malto

@Malt se la mia comprensione Spliteratorsè corretta, allora trySplitdovrei sempre suddividere i dati in due parti più o meno uguali in modo che il risultato non dovrebbe mai essere più grande dell'originale?
Bruce Hamilton,

@BruceHamilton Sfortunatamente, secondo i documenti le parti non possono essere più o meno uguali. Essi devono essere uguali:if this Spliterator is SUBSIZED, then estimateSize() for this spliterator before splitting must be equal to the sum of estimateSize() for this and the returned Spliterator after splitting.
Malt

Sì, questo è coerente con la mia comprensione della suddivisione di Spliterator. Tuttavia, sto facendo fatica a capire come "gli split restituiti da trySplit possono avere più elementi rispetto a prima dello split", potresti approfondire cosa intendi?
Bruce Hamilton,

13

Abbiamo avuto un problema simile da risolvere. Volevamo prendere un flusso che fosse più grande della memoria di sistema (iterando attraverso tutti gli oggetti in un database) e randomizzare l'ordine nel miglior modo possibile - abbiamo pensato che sarebbe stato ok bufferizzare 10.000 elementi e randomizzarli.

L'obiettivo era una funzione che assumeva un flusso.

Tra le soluzioni qui proposte, sembra esserci una serie di opzioni:

  • Usa varie librerie aggiuntive non Java 8
  • Inizia con qualcosa che non è un flusso, ad esempio un elenco di accesso casuale
  • Avere un flusso che può essere diviso facilmente in uno spliterator

Il nostro istinto originariamente era quello di utilizzare un raccoglitore personalizzato, ma questo significava abbandonare lo streaming. La soluzione di raccolta personalizzata sopra è molto buona e l'abbiamo quasi utilizzata.

Ecco una soluzione che imbroglia usando il fatto che Streams può darti un Iteratorche puoi usare come un portello di fuga per farti fare qualcosa in più che i flussi non supportano. Il Iteratorè tornato convertito a un flusso utilizzando un altro po 'di Java 8 StreamSupportstregoneria.

/**
 * An iterator which returns batches of items taken from another iterator
 */
public class BatchingIterator<T> implements Iterator<List<T>> {
    /**
     * Given a stream, convert it to a stream of batches no greater than the
     * batchSize.
     * @param originalStream to convert
     * @param batchSize maximum size of a batch
     * @param <T> type of items in the stream
     * @return a stream of batches taken sequentially from the original stream
     */
    public static <T> Stream<List<T>> batchedStreamOf(Stream<T> originalStream, int batchSize) {
        return asStream(new BatchingIterator<>(originalStream.iterator(), batchSize));
    }

    private static <T> Stream<T> asStream(Iterator<T> iterator) {
        return StreamSupport.stream(
            Spliterators.spliteratorUnknownSize(iterator,ORDERED),
            false);
    }

    private int batchSize;
    private List<T> currentBatch;
    private Iterator<T> sourceIterator;

    public BatchingIterator(Iterator<T> sourceIterator, int batchSize) {
        this.batchSize = batchSize;
        this.sourceIterator = sourceIterator;
    }

    @Override
    public boolean hasNext() {
        prepareNextBatch();
        return currentBatch!=null && !currentBatch.isEmpty();
    }

    @Override
    public List<T> next() {
        return currentBatch;
    }

    private void prepareNextBatch() {
        currentBatch = new ArrayList<>(batchSize);
        while (sourceIterator.hasNext() && currentBatch.size() < batchSize) {
            currentBatch.add(sourceIterator.next());
        }
    }
}

Un semplice esempio di utilizzo di questo sarebbe simile a questo:

@Test
public void getsBatches() {
    BatchingIterator.batchedStreamOf(Stream.of("A","B","C","D","E","F"), 3)
        .forEach(System.out::println);
}

Le stampe sopra

[A, B, C]
[D, E, F]

Per il nostro caso d'uso, volevamo mescolare i batch e quindi mantenerli come un flusso - sembrava così:

@Test
public void howScramblingCouldBeDone() {
    BatchingIterator.batchedStreamOf(Stream.of("A","B","C","D","E","F"), 3)
        // the lambda in the map expression sucks a bit because Collections.shuffle acts on the list, rather than returning a shuffled one
        .map(list -> {
            Collections.shuffle(list); return list; })
        .flatMap(List::stream)
        .forEach(System.out::println);
}

Questo produce qualcosa del tipo (è randomizzato, così diverso ogni volta)

A
C
B
E
D
F

La salsa segreta qui è che c'è sempre un flusso, quindi puoi operare su un flusso di batch o fare qualcosa per ogni batch e poi flatMaptornare a un flusso. Ancora meglio, tutto quanto sopra viene eseguito solo come finale forEacho collecto altre espressioni di terminazione PULL i dati attraverso il flusso.

Si scopre che si iteratortratta di un tipo speciale di operazione di terminazione su un flusso e non causa l'esecuzione e la memorizzazione dell'intero flusso! Grazie ai ragazzi di Java 8 per un design brillante!


Ed è molto positivo che tu esegua un'iterazione completa su ogni batch quando viene raccolto e persista a un List—non puoi rinviare l'iterazione degli elementi all'interno del batch perché il consumatore potrebbe voler saltare un intero batch e se non hai consumato il elementi quindi non salterebbero molto lontano. (Ho implementato uno di questi in C #, anche se è stato sostanzialmente più semplice.)
ErikE

9

Puoi anche usare RxJava :

Observable.from(data).buffer(BATCH_SIZE).forEach((batch) -> process(batch));

o

Observable.from(lazyFileStream).buffer(500).map((batch) -> process(batch)).toList();

o

Observable.from(lazyFileStream).buffer(500).map(MyClass::process).toList();

8

Potresti anche dare un'occhiata a Cyclops-React , sono l'autore di questa libreria. Implementa l'interfaccia jOOλ (e per estensione JDK 8 Streams), ma a differenza di JDK 8 Parallel Streams si concentra sulle operazioni asincrone (come il blocco potenziale delle chiamate I / O asincrone). JDK Parallel Streams, al contrario si concentra sul parallelismo dei dati per le operazioni legate alla CPU. Funziona gestendo aggregati di attività basate sul futuro sotto il cofano, ma presenta agli utenti finali un'API Stream standard estesa.

Questo codice di esempio può aiutarti a iniziare

LazyFutureStream.parallelCommonBuilder()
                .react(data)
                .grouped(BATCH_SIZE)                  
                .map(this::process)
                .run();

C'è un tutorial sul batching qui

E un tutorial più generale qui

Per utilizzare il proprio pool di thread (che probabilmente è più appropriato per bloccare l'I / O), è possibile avviare l'elaborazione con

     LazyReact reactor = new LazyReact(40);

     reactor.react(data)
            .grouped(BATCH_SIZE)                  
            .map(this::process)
            .run();

3

Esempio di Java 8 puro che funziona anche con flussi paralleli.

Come usare:

Stream<Integer> integerStream = IntStream.range(0, 45).parallel().boxed();
CsStreamUtil.processInBatch(integerStream, 10, batch -> System.out.println("Batch: " + batch));

La dichiarazione e l'implementazione del metodo:

public static <ElementType> void processInBatch(Stream<ElementType> stream, int batchSize, Consumer<Collection<ElementType>> batchProcessor)
{
    List<ElementType> newBatch = new ArrayList<>(batchSize);

    stream.forEach(element -> {
        List<ElementType> fullBatch;

        synchronized (newBatch)
        {
            if (newBatch.size() < batchSize)
            {
                newBatch.add(element);
                return;
            }
            else
            {
                fullBatch = new ArrayList<>(newBatch);
                newBatch.clear();
                newBatch.add(element);
            }
        }

        batchProcessor.accept(fullBatch);
    });

    if (newBatch.size() > 0)
        batchProcessor.accept(new ArrayList<>(newBatch));
}

2

In tutta onestà, dai un'occhiata all'elegante soluzione Vavr :

Stream.ofAll(data).grouped(BATCH_SIZE).forEach(this::process);

1

Semplice esempio usando Spliterator

    // read file into stream, try-with-resources
    try (Stream<String> stream = Files.lines(Paths.get(fileName))) {
        //skip header
        Spliterator<String> split = stream.skip(1).spliterator();
        Chunker<String> chunker = new Chunker<String>();
        while(true) {              
            boolean more = split.tryAdvance(chunker::doSomething);
            if (!more) {
                break;
            }
        }           
    } catch (IOException e) {
        e.printStackTrace();
    }
}

static class Chunker<T> {
    int ct = 0;
    public void doSomething(T line) {
        System.out.println(ct++ + " " + line.toString());
        if (ct % 100 == 0) {
            System.out.println("====================chunk=====================");               
        }           
    }       
}

La risposta di Bruce è più completa, ma stavo cercando qualcosa di veloce e sporco per elaborare un mucchio di file.


1

questa è una soluzione java pura che viene valutata pigramente.

public static <T> Stream<List<T>> partition(Stream<T> stream, int batchSize){
    List<List<T>> currentBatch = new ArrayList<List<T>>(); //just to make it mutable 
    currentBatch.add(new ArrayList<T>(batchSize));
    return Stream.concat(stream
      .sequential()                   
      .map(new Function<T, List<T>>(){
          public List<T> apply(T t){
              currentBatch.get(0).add(t);
              return currentBatch.get(0).size() == batchSize ? currentBatch.set(0,new ArrayList<>(batchSize)): null;
            }
      }), Stream.generate(()->currentBatch.get(0).isEmpty()?null:currentBatch.get(0))
                .limit(1)
    ).filter(Objects::nonNull);
}

1

Puoi usare apache.commons:

ListUtils.partition(ListOfLines, 500).stream()
                .map(partition -> processBatch(partition)
                .collect(Collectors.toList());

La parte del partizionamento viene eseguita in modo poco lento, ma dopo che l'elenco è stato partizionato, si ottengono i vantaggi di lavorare con i flussi (ad esempio, utilizzare flussi paralleli, aggiungere filtri, ecc.). Altre risposte suggerivano soluzioni più elaborate ma a volte la leggibilità e la manutenibilità sono più importanti (ea volte non lo sono :-))


Non sono sicuro di chi abbia votato negativamente, ma sarebbe bello capire perché .. Ho dato una risposta che integrava le altre risposte per le persone non in grado di usare Guava
Tal Joffe

Stai elaborando un elenco qui, non uno stream.
Drakemor

@Drakemor Sto elaborando un flusso di sottoelenchi. notare la chiamata alla funzione stream ()
Tal Joffe

Ma prima lo trasformi in un elenco di sottoelenchi, che non funzionerà correttamente per i veri dati in streaming. Ecco il riferimento alla partizione: commons.apache.org/proper/commons-collections/apidocs/org/…
Drakemor

1
TBH Non capisco completamente la tua argomentazione, ma immagino che possiamo essere d'accordo nel non essere d'accordo. Ho modificato la mia risposta per riflettere la nostra conversazione qui. Grazie per la discussione
Tal Joffe

1

Potrebbe essere fatto facilmente usando Reactor :

Flux.fromStream(fileReader.lines().onClose(() -> safeClose(fileReader)))
            .map(line -> someProcessingOfSingleLine(line))
            .buffer(BUFFER_SIZE)
            .subscribe(apiService::makeHttpRequest);

0

Con Java 8e com.google.common.collect.Listspuoi fare qualcosa come:

public class BatchProcessingUtil {
    public static <T,U> List<U> process(List<T> data, int batchSize, Function<List<T>, List<U>> processFunction) {
        List<List<T>> batches = Lists.partition(data, batchSize);
        return batches.stream()
                .map(processFunction) // Send each batch to the process function
                .flatMap(Collection::stream) // flat results to gather them in 1 stream
                .collect(Collectors.toList());
    }
}

Di seguito Tè riportato il tipo di elementi nell'elenco di input e Uil tipo di elementi nell'elenco di output

E puoi usarlo in questo modo:

List<String> userKeys = [... list of user keys]
List<Users> users = BatchProcessingUtil.process(
    userKeys,
    10, // Batch Size
    partialKeys -> service.getUsers(partialKeys)
);
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.