Blog (9)
Komentarze (73)
Recenzje (0)

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

@pat.wasiewiczRegex 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.

Szanowna Użytkowniczko! Szanowny Użytkowniku!
×
Aby dalej móc dostarczać coraz lepsze materiały redakcyjne i udostępniać coraz lepsze usługi, potrzebujemy zgody na dopasowanie treści marketingowych do Twojego zachowania. Twoje dane są u nas bezpieczne, a zgodę możesz wycofać w każdej chwili na podstronie polityka prywatności.

Kliknij "PRZECHODZĘ DO SERWISU" lub na symbol "X" w górnym rogu tej planszy, jeżeli zgadzasz się na przetwarzanie przez Wirtualną Polskę i naszych Zaufanych Partnerów Twoich danych osobowych, zbieranych w ramach korzystania przez Ciebie z usług, portali i serwisów internetowych Wirtualnej Polski (w tym danych zapisywanych w plikach cookies) w celach marketingowych realizowanych na zlecenie naszych Zaufanych Partnerów. Jeśli nie zgadzasz się na przetwarzanie Twoich danych osobowych skorzystaj z ustawień w polityce prywatności. Zgoda jest dobrowolna i możesz ją w dowolnym momencie wycofać zmieniając ustawienia w polityce prywatności (w której znajdziesz odpowiedzi na wszystkie pytania związane z przetwarzaniem Twoich danych osobowych).

Od 25 maja 2018 roku obowiązuje Rozporządzenie Parlamentu Europejskiego i Rady (UE) 2016/679 (określane jako "RODO"). W związku z tym chcielibyśmy poinformować o przetwarzaniu Twoich danych oraz zasadach, na jakich odbywa się to po dniu 25 maja 2018 roku.

Kto będzie administratorem Twoich danych?

Administratorami Twoich danych będzie Wirtualna Polska Media Spółka Akcyjna z siedzibą w Warszawie, oraz pozostałe spółki z grupy Wirtualna Polska, jak również nasi Zaufani Partnerzy, z którymi stale współpracujemy. Szczegółowe informacje dotyczące administratorów znajdują się w polityce prywatności.

O jakich danych mówimy?

Chodzi o dane osobowe, które są zbierane w ramach korzystania przez Ciebie z naszych usług, portali i serwisów internetowych udostępnianych przez Wirtualną Polskę, w tym zapisywanych w plikach cookies, które są instalowane na naszych stronach przez Wirtualną Polskę oraz naszych Zaufanych Partnerów.

Dlaczego chcemy przetwarzać Twoje dane?

Przetwarzamy je dostarczać coraz lepsze materiały redakcyjne, dopasować ich tematykę do Twoich zainteresowań, tworzyć portale i serwisy internetowe, z których będziesz korzystać z przyjemnością, zapewniać większe bezpieczeństwo usług, udoskonalać nasze usługi i maksymalnie dopasować je do Twoich zainteresowań, pokazywać reklamy dopasowane do Twoich potrzeb. Szczegółowe informacje dotyczące celów przetwarzania Twoich danych znajdują się w polityce prywatności.

Komu możemy przekazać dane?

Twoje dane możemy przekazywać podmiotom przetwarzającym je na nasze zlecenie oraz podmiotom uprawnionym do uzyskania danych na podstawie obowiązującego prawa – oczywiście tylko, gdy wystąpią z żądaniem w oparciu o stosowną podstawę prawną.

Jakie masz prawa w stosunku do Twoich danych?

Masz prawo żądania dostępu, sprostowania, usunięcia lub ograniczenia przetwarzania danych. Możesz wycofać zgodę na przetwarzanie, zgłosić sprzeciw oraz skorzystać z innych praw wymienionych szczegółowo w polityce prywatności.

Jakie są podstawy prawne przetwarzania Twoich danych?

Podstawą prawną przetwarzania Twoich danych w celu świadczenia usług jest niezbędność do wykonania umów o ich świadczenie (tymi umowami są zazwyczaj regulaminy). Podstawą prawną przetwarzania danych w celu pomiarów statystycznych i marketingu własnego administratorów jest tzw. uzasadniony interes administratora. Przetwarzanie Twoich danych w celach marketingowych realizowanych przez Wirtualną Polskę na zlecenie Zaufanych Partnerów i bezpośrednio przez Zaufanych Partnerów będzie odbywać się na podstawie Twojej dobrowolnej zgody.