refactor: устранить нарушения DRY, SRP, KISS

- DRY: вынести scoped() в общий ScopedKey (был дублирован в PayloadStore и SharedIndex)
- DRY: вынести чтение словарей и перечитывание файлов в ResourceLoader
  (было дублировано в NameDictionary, ToponymDictionary, SystemsConfig)
- DRY: вынести алгоритм Луна в Validators.luhnCheckDigit (Synthetic переиспользует)
- SRP: вынести константы типов ПД из RuleRegistry в PdTypes
- SRP: вынести проверку организаций из NameDictionary в OrganisationDetector
- KISS: словари оставлены статическими (неизменяемые, детерминированные)

Поведение не изменилось: 1840 тестов проходят, NodeLogsLeakTest по-прежнему
падает на тех же 55 задокументированных утечках.
This commit is contained in:
Максименко Никита Владимирович
2026-09-22 22:39:16 +03:00
parent 6b5a7ae35f
commit aa4c926a8f
19 changed files with 457 additions and 349 deletions
@@ -3,20 +3,12 @@ package ru.pdguard.detect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -40,9 +32,8 @@ import java.util.stream.Collectors;
public final class NameDictionary {
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
private static final long RECHECK_MILLIS = 1000;
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
private static final List<String> GIVEN_NAME_STEMS = ResourceLoader.lines("/names/given-names.txt", true).stream()
.map(Declension::withoutInflectedEnding)
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
@@ -51,28 +42,14 @@ public final class NameDictionary {
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
// что и для личных имён.
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream()
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
private static final AtomicReference<Path> externalFile =
new AtomicReference<>(Path.of("config/well-known.txt"));
private static final AtomicReference<Set<String>> wellKnownStems =
new AtomicReference<>(BUNDLED_WELL_KNOWN_STEMS);
private static volatile long externalTimestamp;
private static volatile long lastCheck;
private static final ResourceLoader.FileWatchState<Set<String>> WELL_KNOWN_STATE =
new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS);
/**
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
*/
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
"(?iu:" + String.join("|", load("/names/organisations.txt")) + ")\\p{L}*"
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
private static final int ORGANISATION_LOOKBEHIND = 40;
private static final Path EXTERNAL_FILE = Path.of("config/well-known.txt");
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
private static final String REGNAL_ORDINALS =
@@ -117,7 +94,13 @@ public final class NameDictionary {
* экземпляра, поэтому путь хранится в статическом поле.
*/
public static void configure(String wellKnownFile) {
externalFile.set(Path.of(wellKnownFile));
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = -1;
WELL_KNOWN_STATE.lastCheck = 0;
// Путь фиксирован в статическом поле; для тестов используется useExternalFile.
if (!"config/well-known.txt".equals(wellKnownFile)) {
useExternalFile(Path.of(wellKnownFile));
}
}
/**
@@ -149,20 +132,6 @@ public final class NameDictionary {
return false;
}
/**
* Стоит ли перед именем слово, относящее его к организации или объекту на карте.
*
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
* защиту не теряет, а улица Королёва под маску не попадает.
*/
public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
}
/**
* Содержит ли текст упоминание известного человека — из сборки или дописанных
* сверху.
@@ -176,8 +145,7 @@ public final class NameDictionary {
if (REGNAL_NAME.matcher(value.strip()).matches()) {
return true;
}
refreshIfChanged();
Set<String> stems = wellKnownStems.get();
Set<String> stems = currentWellKnownStems();
for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
@@ -191,82 +159,55 @@ public final class NameDictionary {
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
static void useExternalFile(Path path) {
externalFile.set(path);
externalTimestamp = -1;
lastCheck = 0;
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = -1;
WELL_KNOWN_STATE.lastCheck = 0;
// Перечитываем немедленно, минуя секундный троттлинг.
reloadExternal(path);
}
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
static synchronized void reloadExternal() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(externalFile.get())) {
if (wellKnownStems.get() != BUNDLED_WELL_KNOWN_STEMS) {
static synchronized void reloadExternal(Path path) {
WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis();
if (!java.nio.file.Files.isReadable(path)) {
if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) {
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
externalFile.get().toAbsolutePath());
path.toAbsolutePath());
}
wellKnownStems.set(BUNDLED_WELL_KNOWN_STEMS);
externalTimestamp = 0;
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = 0;
return;
}
try {
externalTimestamp = Files.getLastModifiedTime(externalFile.get()).toMillis();
WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis();
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : Files.readAllLines(externalFile.get(), StandardCharsets.UTF_8)) {
for (String line : java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) {
String trimmed = Declension.withoutInflectedEnding(line.trim());
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
merged.add(trimmed);
}
}
wellKnownStems.set(Set.copyOf(merged));
WELL_KNOWN_STATE.current = Set.copyOf(merged);
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных",
externalFile.get().toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
} catch (IOException e) {
path.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
} catch (java.io.IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.error("Не удалось прочитать {}, денилист не изменён", externalFile.get().toAbsolutePath(), e);
LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e);
}
}
private static void refreshIfChanged() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MILLIS) {
return;
}
lastCheck = now;
try {
if (!Files.isReadable(externalFile.get())) {
if (externalTimestamp != 0) {
reloadExternal();
}
return;
}
if (Files.getLastModifiedTime(externalFile.get()).toMillis() != externalTimestamp) {
reloadExternal();
}
} catch (IOException e) {
LOG.debug("Не удалось проверить время изменения {}", externalFile.get(), e);
}
}
/**
* Основы сортируются от длинных к коротким: в чередовании регулярного
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
* перехватывать совпадение у длинной.
*/
private static List<String> load(String resource) {
try (InputStream in = NameDictionary.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
private static Set<String> currentWellKnownStems() {
return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE,
lines -> {
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : lines) {
String trimmed = Declension.withoutInflectedEnding(line);
if (!trimmed.isEmpty()) {
merged.add(trimmed);
}
}
return Set.copyOf(merged);
},
BUNDLED_WELL_KNOWN_STEMS);
}
}