Blog (9)
Komentarze (73)
Recenzje (0)
@pat.wasiewiczRegex i duży plik - jak ugryźć to w javie?

Regex i duży plik - jak ugryźć to w javie?

17.06.2014 00:09

Wstęp

Dla jasności - mówimy o pliku rozmiaru co najmniej 1 GB. Załóżymy, że mamy sobie taki plik, wyglądający o tak:

abbbbbbbbbbbb....bbbbbbbbc

"b" powtarzamy, aż plik osiągnie 1 GB. Chcielibyśmy znaleźć liczbę wystąpień wzorca "ab*c". Oczywista oczywistość - wczytanie całego pliku do pamięci raczej nie jest dobrym pomysłem. Żarówką, który najszybciej mi zapaliła się (i najszybciej zgasła) była próba własnoręcznego zaimplementowania parsera wyrażeń regularnych. Oczywiście - to porywanie się z motyką na słońce (jak na mojego skill-a), dlatego zrezygnowałem. Postanowiłem ugryźć to z innej strony.

Zadanie to miałem zdefiniowane na studiach. Jak się okazało, treść troszkę była inna: nie trzeba było obsługiwać regexów, a tylko wyszukiwanie wystąpienia zadanego tekstu (którego długość była ograniczona liczbą stałą) - co jest oczywiście dużo łatwiejsze. Ale że już wziąłem się za wyrażenia regularne...

Nowa nadzieja

Do głowy przyszedł mi nowy pomysł (przy wsparciu kolegów ze stackoverflow-a :‑) ): w klasie [code=java]Pattern[/code] występuje wdzięczna metoda o nazwie [code=java]matcher[/code] Jako argument, przyjmuje ona implementację interfejsu [code=java]CharSequence[/code]

Hmm.. a jakby tak zaimplementować ten interfejs? Głównym problemem jaki się nasuwa to dostęp do dowolnego znaku w pliku. Zwłaszcza, ze może być różne kodowanie. Chwilka.. a co, jeśli byśmy nie wczytywali pojedynczych znaków, tylko pewien bufor określonego rozmiaru, który przechowuje też znaki w otoczeniu pożądanej przez matcher litery? To ma sens! Przecież jest wielce prawdopodobne, że regex może chcieć informacje o sąsiadujących znakach (niestety, moze się też zdarzyć, że będziemy skakać po całym pliku, tego nie unikniemy).

Implementacja

Pomysł wygląda następująco: Podzielimy plik na stało-wymiarowe "okna". Implementujemy interfejs CharSequence. Gdy zostanie wywołana metoda charAt, obliczmy w którym "oknie" znak się znajduje a następnie ladujemy okienko do pamięci. Oczywiście, najpierw sprawdzamy, czy dana ramka nie jest już załadowana.

Nasze okno w pliku będzie reprezentować klasa:

public class CharFrame{

    private final long frameSize;

    private final long fileOffset;

    private final long charSize;

    private final long charOffset;

    public CharFrame(long frameSize, long fileOffset, long charSize, long charOffset) {
        this.frameSize = frameSize;
        this.fileOffset = fileOffset;
        this.charSize = charSize;
        this.charOffset = charOffset;
    }

    public long getFrameSize(){
        return  this.frameSize;
    }

    public long getFileOffset(){
        return this.fileOffset;
    }

    public long getCharOffset(){
        return this.charOffset;
    }

    public long getCharSize(){
        return this.charSize;
    }
}

W tej prostej klasie przechowywać będzie informacje o pojedynczej ramce: jej położenia (offset) w pliku (względem bajtów) oraz wielkość w bajtach. Analogiczne dane - ale względem znaków - też będziemy przechowywać.

Przygotujmy teraz pomocniczy interfejs:

public interface Mappable extends Closeable {
    ByteBuffer map(FileChannel.MapMode mapMode, long offset, long size) throws MappingBufferException;
    long size() throws CalculatingSizeException;
}

Interfejs umożliwia załadowanie "kawałka" pliku do pamięci (kawałek jest reprezentowany przez offset względem początku i wielkość). Metoda zwróci bajty, które reprezentują dany kawałek pliku. Zaimplementujmy ten interfejs:

public class MappableFileChannel implements Mappable {

    private final FileChannel fileChannel;

    public  MappableFileChannel(RandomAccessFile randomAccessFile){
        this.fileChannel = randomAccessFile.getChannel();
    }

    @Override
    public ByteBuffer map(FileChannel.MapMode mapMode, long offset, long size) throws MappingBufferException {
        try {
            return this.fileChannel.map(mapMode, offset, size);
        } catch (IOException e) {
            throw new MappingBufferException(e);
        }
    }

    @Override
    public long size() throws CalculatingSizeException {
        try {
            return this.fileChannel.size();
        } catch (IOException e) {
            throw new CalculatingSizeException(e);
        }
    }

    @Override
    public void close() throws IOException {
        this.fileChannel.close();
    }
}

Dodatkowe klasy wyjątków nie będę opisywał - wystarczy, że dziedziczą po Exception.

Możemy praktycznie przejść od meritum. Dodajmy sobie interfejs:

public interface TextProvider extends CharSequence, Closeable {
}

Rozpoczynamy implementację, przekazując przygotowywując potrzebne zmienne globalne:

public class LargeFileTextProvider implements TextProvider {

    // szerokość ramki
    private final int BUFF_SIZE;

    // mapowanie pliku na pamięc
    private final Mappable mappable;

    // kodowanie pliku tekstowego
    private final Charset charset;

    // długość pliku w bajtach
    private final long totalFileSize;

    // długość pliku w znakach w zadanym kodowaniu
    private final long totalCharSize;

    // lista możliwych wszystkich "okienek" w pliku poukładane w kolejności rosnącej
    // np. ramka opisująca bajty w przedziale 
    // 0-BUFF_SIZE - 1, BUFF_SIZE - 2*BUFF_SIZE - 1 itd.
    private List<CharFrame> charFrames;

    // ostatnio wczytana ramka (metadane)
    private CharFrame lastReadFrame;

    // znaki ostatnio wczytanego "okienka"
    private CharBuffer lastBuffer;
}

Przydatne będą na pewno te dwie metody:

private CharsetDecoder newDecoder(){
    return charset.newDecoder()
            .onMalformedInput(CodingErrorAction.REPORT)
            .onUnmappableCharacter(CodingErrorAction.REPORT);
}

private  CharBuffer newCharBuffer(){
    return CharBuffer.allocate(BUFF_SIZE);
}

CharsetDecoder (klasa domyślnie dostępna w javie) umożliwać będzie nam konwertowanie bajtów na znaki w zadanym kodowaniu. Druga będzie tworzyć instancję bufora dla bajtów (tak naprawdę tablicę znaków).

Kolejna metoda będzie tak naprawdę wrapperem do naszego mappable - zwróci nam bufor bajtów pewnego "kawałka" pliku:

private ByteBuffer loadFilePieceIntoMemory(long fileOffset, long size) throws MappingBufferException {
    return this.mappable.map(FileChannel.MapMode.READ_ONLY, fileOffset, size);
}

W tej chwili jesteśmy w stanie zbudować pojedynczą ramkę (instancję CharFrame) która będzie przechowywała nasze metadane:

private CharFrame readCharFrame(long fileOffset, long charOffset, CharsetDecoder decoder, CharBuffer buffer) throws MappingBufferException, CharacterCodingException {
    long remainingFileSize = min(BUFF_SIZE, this.totalFileSize - fileOffset);

    final ByteBuffer mappedBuffer = this.loadFilePieceIntoMemory(fileOffset, remainingFileSize);

    buffer.rewind();
    decoder.reset();

    // próbujemy odczytać znaki
    final CoderResult decodingResult = decoder.decode(mappedBuffer, buffer, true);

    if (decodingResult.isError()){
        decodingResult.throwException();
    }

    if (decodingResult.isMalformed()){
        // prawdopodobnie ramka "ucięła" znak. Może to wystąpić, jeśli takowy
        // jest zapisany w postaci dwóch bajtów. Nasza ramka zawierać pierwszy bajt
        // ale niekonieczne ten drugi. W takim przypadku, bierzemy tyle bajtów, ile udało
        // się nam odczytać (ramka nie ma rozmiar bufora, tylko już mniejszy)
        remainingFileSize = (long) mappedBuffer.position();
    }
    // parametry:  rozmiar ramki, pozycja w pliku  (w bajtach),  
   //                     ilość odczytanych znaków, pozycja w pliku względem znaków 
    return new CharFrame(remainingFileSize, fileOffset , buffer.position(), charOffset );
}

Nie zostało nam nic innego, jak przygotować ramki dla całego pliku:

private void prepareFrames() throws MappingBufferException, CharacterCodingException {

    final CharsetDecoder decoder = this.newDecoder();
    final CharBuffer buffer = this.newCharBuffer();

    long fileOffset = 0, charOffset = 0;

    while (fileOffset < this.totalFileSize){

        CharFrame charFrame = this.readCharFrame(fileOffset, charOffset, decoder, buffer);

        charOffset += charFrame.getCharSize();
        fileOffset += charFrame.getFrameSize();

        this.charFrames.add(charFrame);
    }
}

Teraz możemy zbudować już konstruktor:

public LargeFileTextProvider(Mappable mappable, Charset charset, int bufferSize) throws CalculatingSizeException, MappingBufferException, CharacterCodingException {
    if (bufferSize < 1024){
        throw new IllegalArgumentException("Buffer size cannot be lower than 1024 bytes.");
    }

    if (mappable == null){
        throw new IllegalArgumentException("Mappable cannot be null.");
    }

    this.mappable = mappable;
    this.charset = charset;
    this.BUFF_SIZE = bufferSize;

    this.charFrames = new ArrayList<CharFrame>();

    this.totalFileSize = this.mappable.size();

    this.prepareFrames();

    CharFrame lastFrame = this.charFrames.get(this.charFrames.size() - 1);
    this.totalCharSize = lastFrame.getCharOffset() + lastFrame.getCharSize();
}

W nim ustawiane są zmienne prywatne i obliczane ramki dla całego pliku.

Możemy zabrać się za implementację interfejsu CharSequence. Pierwsza potrzebna metoda będzie stwierdzała, czy dla zadanego znaku na pozycji i musimy załadować "okno" z pliku, czy też mozemy użyć ostatnio załadowany:

private boolean needFrameChange(int i){
    if (this.lastReadFrame == null){
        return true;
    }

    return i < this.lastReadFrame.getCharOffset()
            || this.lastReadFrame.getCharOffset() 
                  + this.lastReadFrame.getCharSize() < i + 1;

}

Teraz metoda, która załaduja dla znaku dla zadanej pozycji odpowiednią ramkę (tzn. jej zawartość w bajtach):

private void loadFrameForChar(int i) throws MappingBufferException {
    int frameIndex = 0;

    CharFrame charFrame = this.charFrames.get(frameIndex);

    // TODO binary search
    while (charFrame.getCharOffset() + charFrame.getCharSize() < i + 1){
       frameIndex += 1;
       charFrame = this.charFrames.get(frameIndex);
    }
    // znaleźlimy pożądaną ramkę
    this.lastReadFrame = charFrame;

    
    final CharsetDecoder decoder = this.newDecoder();
    final CharBuffer buffer = this.newCharBuffer();

    // bierzemy jej wielkość w bajtach
    long remaining = min(this.totalFileSize - this.lastReadFrame.fileOffset, BUFF_SIZE);
    // ładujemy bajty z pliku do pamięci
    ByteBuffer byteBuffer = this.loadFilePieceIntoMemory(this.lastReadFrame.getFileOffset(), remaining);

    // odczytujemy znaki
    decoder.decode(byteBuffer, buffer, true);
    // zawartość (znaki) załadowanej ramki
    this.lastBuffer = buffer;
}

Mając te dwie metody, możemy zaimplementować charAt:

@Override
public char charAt(int i) {

    if (i < 0){
        throw  new ArrayIndexOutOfBoundsException();
    }

    if (i > this.length() - 1){
        throw new ArrayIndexOutOfBoundsException();
    }

    if (this.needFrameChange(i)){
        try {
            this.loadFrameForChar(i);
        } catch (MappingBufferException e) {
            throw new RuntimeException(e);
        }
    }

    assert(i >= this.lastReadFrame.charOffset);

    try{
        return this.lastBuffer.get((int) (i - this.lastReadFrame.charOffset));
    }
    catch(IndexOutOfBoundsException e){
        e.printStackTrace();
        throw new IndexOutOfBoundsException();
    }
}

Zaimplementowanie subSequence to już czysta formalność. Zrobiłem to najprostszą drogą (matcher nie korzysta z subSequence, więc nie troszczyłem się bardzo o wydajność):

@Override
    public CharSequence subSequence(int i, int i2) {

        if (i > i2){
            throw new IllegalArgumentException();
        }

        if (i < 0){
            throw new ArrayIndexOutOfBoundsException("Start index is too low.");
        }

        if (i >= this.totalCharSize){
            throw new ArrayIndexOutOfBoundsException("Start index is too high.");
        }

        if (i2 > this.totalCharSize){
            throw  new ArrayIndexOutOfBoundsException("End index is too high.");
        }

        StringBuffer buffer = new StringBuffer();

        while (i < i2){
            buffer.append(this.charAt(i));
        }

        return buffer;
    }

I to.. by było na tyle! Zostało kilka drobnych metod (jak np. close) których nie będe pokazywał - są one trywialne, a wpis i tak się już rozrósł :)

Przykładowe użycie:

TextProvider textProvider = new LargeFileTextProvider(new MappableFileChannel(new RandomAccessFile("plik.txt", "r")), Charset.forName("UTF-8"));
Pattern regex = Pattern.compile("ab*c");
Matcher matcher = regex.matcher(textProvider);

Przygotowałem również GUI, który wygląda mniej więcej tak:

Przykładowe GUI
Przykładowe GUI

Przykładowe testy, które program przechodzi:

Kilka testów jednostkowych
Kilka testów jednostkowych

Przykładowy test:


@Test public void returningChar_WithShortData_ReturnsProperResult() throws MappingBufferException, CalculatingSizeException, CharacterCodingException {
        // arrange
        String text = "sample";
        Charset charset = Charset.forName("UTF-8");
        byte[] bytes = this.getTextAsBytes(text, charset);

        Mappable mappable = mock(Mappable.class);

        doReturn(MappedByteBuffer.wrap(bytes))
               .doReturn(MappedByteBuffer.wrap(bytes))
               .when(mappable)
               .map(any(FileChannel.MapMode.class), anyLong(), anyLong());

        doReturn((long)bytes.length -1).when(mappable).size();

        TextProvider textProvider = new LargeFileTextProvider(mappable, charset);

        //act
        char result = textProvider.charAt(2);

        // assert
        assertEquals('m', result);
    }

Za pewne są miejsca do optymalizacji, być może są ciekawsze sposoby rozwiązania tego problemu, ale nie o to tu chodziło - program miał spełnić swoje zadanie, zaimplementowanie przeze mnie, a myślę że to rozwiązanie spełnia te kryteria :)

Podsumowanie

Wspomniany przykład z początku: program wyszukał mi wzorzec w czasie ~ 4 sekund.

Wybrane dla Ciebie
Komentarze (3)