Преобразовать кандидатов в группы дубликатов

JavaBeginner
Практиковаться сейчас

Введение

Отпечаток-кандидат уменьшает затраты на поиск в архиве, но не доказывает идентичность содержимого. Вы завершите средство поиска дубликатов только для чтения, которое подтверждает реальные байты и сохраняет каждый относительный путь в каждой группе.

Предполагается понимание двоичного чтения и SHA-256 из предыдущей работы, а также классов, списков и исключений Java. Эта работа начинается независимо с готовым хешированием и новой копией галереи. Вы реализуете группировку; адаптеры каталога, отчёта и запуска подготовлены. Авторская оценка — 55 минут.

Подтвердить каждого кандидата реальными байтами

На этом шаге вы превратите факты-кандидаты в полные группы дубликатов. Откройте WebIDE, выберите Terminal > New Terminal и перейдите в независимую рабочую область:

cd /home/labex/project/duplicates
./run.sh Sample

Настоящий декодер PNG снова показывает одинаковые байты под разными именами и разные байты одинакового размера. При каждом запуске используется новая временная сборка; дочерние процессы компилятора/JVM используют C.UTF-8 и явно заданный UTF-8 для исходников и текста. Настройки терминала не меняются.

Прочитайте открытый адаптер и начальную реализацию:

cat src/Digester.java src/Finder.java

Finder.find(Path root, Digester candidates) должен возвращать список групп дубликатов, каждая из которых содержит все пути участников относительно root. root.relativize(path) создаёт такой относительный путь; root.resolve(relative) снова находит его для чтения. sameBytes(left, right) сравнивает содержимое, а не имена. Сохраните эти открытые точки входа; внутренние помощники и коллекции могут отличаться.

Реализация ниже использует Map для сопоставления ключа кандидата его возможным группам. Ключ сочетает размер и хеш. containsKey проверяет наличие ключа, put создаёт его начальное значение, а get получает его. Каждый ключ хранит список групп, подтверждённых байтами, поскольку разное содержимое может иметь один отпечаток-кандидат. List<List<Path>> означает список, элементами которого служат списки путей.

Для каждого файла сравните его с путём представителя каждой существующей группы в этой корзине. Настоящее совпадение добавляет новый путь; иначе создаётся новая подгруппа. placed отмечает, присоединился ли текущий путь к группе; break останавливает внутренний поиск после совпадения, чтобы путь добавился только один раз. В отчёт попадают только группы минимум из двух путей. Не останавливайтесь на двух участниках: третья копия тоже является отдельным путём для проверки.

sameBytes сначала отклоняет разную длину, затем читает оба файла. read() возвращает байт как целое число от 0 до 255, а в конце — -1. Различающиеся байты дают false; одновременное достижение конца даёт true. Оба потока закрываются через try-with-resources. Чтение по одному байту упрощает этот небольшой учебный пример; буферизованное сравнение или сравнение полных массивов также допустимы, но имеют свои затраты памяти и производительности.

Словарь before сопоставляет каждому Path исходные метаданные. before.keySet() возвращает эти ключи-пути для последней проверки изменений; buckets.values() возвращает сохранённые списки подгрупп кандидатов для построения результата. Подготовленный адаптер атрибутов записывает размер, время изменения и идентификатор файла до чтения, а затем отклоняет обнаруженные изменения. Он не блокирует и не фиксирует дерево. Во время сканирования сохраняйте входные данные стабильными; параллельные записи, символические ссылки и специальные файлы не поддерживаются. Важен каждый обычный путь, включая разные жёсткие ссылки на один файл. Идентификатор файла используется только для обнаружения изменений, а не для отбрасывания пути.

Замените src/Finder.java. Как и в предыдущей работе, here-document в кавычках записывает текст буквально; завершающий JAVA занимает отдельную строку. > заменяет только этот исходный файл.

cat > src/Finder.java <<'JAVA'
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;

public class Finder {
    public static boolean sameBytes(Path left, Path right) throws IOException {
        if (Files.size(left) != Files.size(right)) return false;
        try (InputStream a = Files.newInputStream(left);
             InputStream b = Files.newInputStream(right)) {
            int value;
            while ((value = a.read()) != -1) {
                if (value != b.read()) return false;
            }
            return b.read() == -1;
        }
    }
    public static List<List<Path>> find(Path root, Digester candidates) throws IOException {
        Map<String, List<List<Path>>> buckets = new LinkedHashMap<>();
        Map<Path, BasicFileAttributes> before = new LinkedHashMap<>();
        for (Path path : Inputs.files(root)) {
            BasicFileAttributes facts = Inputs.attributes(path);
            before.put(path, facts);
            String key = facts.size() + ":" + candidates.digest(path);
            Inputs.unchanged(path, facts);
            if (!buckets.containsKey(key)) buckets.put(key, new ArrayList<>());
            List<List<Path>> groups = buckets.get(key);
            boolean placed = false;
            for (List<Path> group : groups) {
                if (sameBytes(path, root.resolve(group.get(0)))) {
                    group.add(root.relativize(path));
                    placed = true;
                    break;
                }
            }
            if (!placed) {
                List<Path> group = new ArrayList<>();
                group.add(root.relativize(path));
                groups.add(group);
            }
        }
        for (Path path : before.keySet()) Inputs.unchanged(path, before.get(path));
        List<List<Path>> result = new ArrayList<>();
        for (List<List<Path>> groups : buckets.values()) {
            for (List<Path> group : groups) {
                if (group.size() > 1) result.add(group);
            }
        }
        return result;
    }
}
JAVA

Подготовленный Main вызывает тот же метод find с new Sha256(). Тесты могут передать другой Digester, возвращающий одно значение кандидата для разных файлов. Это контролируемый тест коллизии кандидатов, а не настоящая коллизия SHA-256; окончательное сравнение байтов всё равно должно разделять разное содержимое. Недостаточно просто написать метод сравнения, который никогда не вызывается.

Создайте настоящий отчёт вне сканируемого корня:

./run.sh Main data/gallery reports/groups.tsv

Ожидается Scan complete: 3 duplicate groups. Если компиляция не удалась, исправьте указанную строку src/Finder.java, сохраните и повторите запуск; прежде чем менять данные, проверьте скобки и сигнатуры методов.

Прочитать всю группу и повторить после изменения

На этом шаге вы прочитаете готовый отчёт и увидите, как он отражает текущие байты. Продолжайте в том же встроенном терминале WebIDE в /home/labex/project/duplicates.

cat reports/groups.tsv

Заголовок TSV в UTF-8 — GROUP, затем PATH. Каждая строка обозначает относительный путь; одинаковые метки группы связывают одинаковое содержимое. Сами метки не являются идентификаторами файлов, а порядок не важен. Группа красного изображения должна включать все три пути, два пустых файла образуют допустимую группу, а два двоичных файла с частично заполненным последним буфером — ещё одну. Синее изображение и одноимённые readme с разным содержимым не дубликаты. Каждый участник доступен в Explorer под duplicates/data/gallery; ни один входной файл не удаляется.

Попробуйте изменение на одноразовых копиях в work, сохранив оригиналы галереи. mkdir -p при необходимости создаёт учебный каталог. cp копирует файл по новому пути.

mkdir -p work/mini
cp data/gallery/red.png work/mini/first.png
cp data/gallery/copies/renamed.png work/mini/second.png
./run.sh Main work/mini reports/practice-before.tsv

Ожидается одна группа дубликатов. Теперь printf запишет текст в кавычках и перевод строки; > заменит только второй учебный файл. Он больше не содержит PNG, поэтому расширение не доказывает содержимое.

printf 'changed\n' > work/mini/second.png
./run.sh Main work/mini reports/practice-after.tsv

Ожидается ноль групп дубликатов. Заголовок отчёта остаётся:

cat reports/practice-after.tsv

Успешный пустой результат отличается от неудачного сканирования. Следующая команда намеренно запрашивает отчёт внутри входного дерева. Ожидайте Scan failed, код выхода 2 и отсутствие нового входного файла:

./run.sh Main data/gallery data/gallery/report.tsv

Исправьте выходной путь и заново создайте отчёт галереи:

./run.sh Main data/gallery reports/groups.tsv

Подготовленный адаптер отчёта записывает новый временный файл в том же каталоге и заменяет выходной путь после успешного сканирования. Он не открывает существующую жёсткую ссылку для записи, поэтому путь отчёта не может перезаписать исходные байты через неё. Это не гарантия сохранности при аварийном сбое. При ошибке чтения команда завершается неуспешно, а старый отчёт не подтверждает текущий запуск. Никогда не пропускайте файл молча, объявляя успех.

Итоговая проверка выполняет текущие исходники на изолированных тестовых данных и читает реальный отчёт галереи. Она проверяет полные группы, текущее содержимое, ошибки и сохранность оригиналов. Ранее правильный отчёт не оправдывает исходники, испорченные впоследствии.

Резюме

Вы сузили кандидатов по размеру и SHA-256, подтвердили реальные байты, сохранили каждый путь-дубликат и создали отчёт заново после настоящего изменения. Успешный результат с нулём групп и ошибка сканирования теперь имеют разные значения.