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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user