mirror of
https://github.com/slhaf/Partner.git
synced 2026-05-12 16:53:04 +08:00
refactor(watch): support configurable directory watch depth
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
package work.slhaf.partner.api.common.support;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.nio.file.StandardWatchEventKinds.*;
|
||||
|
||||
@Slf4j
|
||||
public class DirectoryWatchSupport implements Closeable {
|
||||
|
||||
private final Context ctx;
|
||||
private final Map<WatchEvent.Kind<?>, EventHandler> handlers = new HashMap<>();
|
||||
private final ExecutorService executor;
|
||||
private final int watchDepth;
|
||||
private final InitLoader initLoader;
|
||||
|
||||
public DirectoryWatchSupport(Context ctx, ExecutorService executor, int watchDepth, InitLoader initLoader) {
|
||||
if (watchDepth < -1) {
|
||||
throw new IllegalArgumentException("watchDepth must be -1 or greater: " + watchDepth);
|
||||
}
|
||||
this.ctx = ctx;
|
||||
this.executor = executor;
|
||||
this.watchDepth = watchDepth;
|
||||
this.initLoader = initLoader;
|
||||
}
|
||||
|
||||
public DirectoryWatchSupport onCreate(EventHandler handler) {
|
||||
ctx.kinds().add(ENTRY_CREATE);
|
||||
handlers.put(ENTRY_CREATE, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DirectoryWatchSupport onModify(EventHandler handler) {
|
||||
ctx.kinds().add(ENTRY_MODIFY);
|
||||
handlers.put(ENTRY_MODIFY, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DirectoryWatchSupport onDelete(EventHandler handler) {
|
||||
ctx.kinds().add(ENTRY_DELETE);
|
||||
handlers.put(ENTRY_DELETE, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DirectoryWatchSupport onOverflow(EventHandler handler) {
|
||||
ctx.kinds().add(OVERFLOW);
|
||||
handlers.put(OVERFLOW, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
registerPath();
|
||||
if (initLoader != null) {
|
||||
initLoader.load();
|
||||
}
|
||||
executor.execute(buildWatchTask());
|
||||
}
|
||||
|
||||
public Context context() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public boolean isWatching(Path dir) {
|
||||
return ctx.watchKeys().values().stream().anyMatch(dir::equals);
|
||||
}
|
||||
|
||||
public void registerDirectory(Path dir) throws IOException {
|
||||
registerDirectoryTree(dir);
|
||||
}
|
||||
|
||||
private void registerDirectoryInternal(Path dir) throws IOException {
|
||||
if (!java.nio.file.Files.isDirectory(dir) || isWatching(dir)) {
|
||||
return;
|
||||
}
|
||||
WatchEvent.Kind<?>[] kindsArray = ctx.kinds().toArray(WatchEvent.Kind[]::new);
|
||||
WatchKey key = dir.register(ctx.watchService(), kindsArray);
|
||||
ctx.watchKeys().put(key, dir);
|
||||
}
|
||||
|
||||
private void registerPath() {
|
||||
try {
|
||||
registerDirectoryTree(ctx.root());
|
||||
} catch (IOException e) {
|
||||
log.error("监听目录注册失败: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void registerDirectoryTree(Path dir) throws IOException {
|
||||
if (!Files.isDirectory(dir) || !isWithinDepth(dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
registerDirectoryInternal(dir);
|
||||
if (!shouldTraverseChildren(dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Stream<Path> walk = Files.list(dir).filter(Files::isDirectory)) {
|
||||
for (Path child : walk.toList()) {
|
||||
registerDirectoryTree(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWithinDepth(Path dir) {
|
||||
if (watchDepth == -1) {
|
||||
return true;
|
||||
}
|
||||
return depthOf(dir) <= watchDepth;
|
||||
}
|
||||
|
||||
private boolean shouldTraverseChildren(Path dir) {
|
||||
return watchDepth == -1 || depthOf(dir) < watchDepth;
|
||||
}
|
||||
|
||||
private int depthOf(Path dir) {
|
||||
Path normalizedRoot = ctx.root().toAbsolutePath().normalize();
|
||||
Path normalizedDir = dir.toAbsolutePath().normalize();
|
||||
if (normalizedDir.equals(normalizedRoot)) {
|
||||
return 0;
|
||||
}
|
||||
if (!normalizedDir.startsWith(normalizedRoot)) {
|
||||
throw new IllegalArgumentException("Directory is outside watched root: " + dir);
|
||||
}
|
||||
return normalizedRoot.relativize(normalizedDir).getNameCount();
|
||||
}
|
||||
|
||||
private Runnable buildWatchTask() {
|
||||
return () -> {
|
||||
String rootStr = ctx.root().toString();
|
||||
log.info("行动程序目录监听器已启动,监听目录: {}", rootStr);
|
||||
while (true) {
|
||||
WatchKey key = null;
|
||||
try {
|
||||
key = ctx.watchService().take();
|
||||
List<WatchEvent<?>> events = key.pollEvents();
|
||||
for (WatchEvent<?> event : events) {
|
||||
WatchEvent.Kind<?> kind = event.kind();
|
||||
Object context = event.context();
|
||||
log.debug("文件目录监听事件: {} - {} - {}", rootStr, kind.name(), context);
|
||||
Path thisDir = (Path) key.watchable();
|
||||
Path resolvedContext = context instanceof Path path ? thisDir.resolve(path) : null;
|
||||
if (kind == ENTRY_CREATE && resolvedContext != null && Files.isDirectory(resolvedContext)) {
|
||||
try {
|
||||
registerDirectoryTree(resolvedContext);
|
||||
} catch (IOException e) {
|
||||
log.error("监听目录注册失败: {}", resolvedContext, e);
|
||||
}
|
||||
}
|
||||
EventHandler handler = handlers.get(kind);
|
||||
if (handler == null) {
|
||||
continue;
|
||||
}
|
||||
handler.handle(thisDir, resolvedContext);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
log.info("监听线程被中断,准备退出...");
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
} catch (ClosedWatchServiceException e) {
|
||||
log.info("WatchService 已关闭,监听线程退出。");
|
||||
break;
|
||||
} finally {
|
||||
if (key != null) {
|
||||
boolean valid = key.reset();
|
||||
if (!valid) {
|
||||
log.info("WatchKey 已失效,停止监听该目录: {}", key.watchable());
|
||||
ctx.watchKeys().remove(key);
|
||||
if (key.watchable().equals(ctx.root())) {
|
||||
try {
|
||||
Files.createDirectories(ctx.root());
|
||||
registerPath();
|
||||
if (initLoader != null) {
|
||||
initLoader.load();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("重建根目录并重新注册监听失败: {}", ctx.root(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
ctx.watchService().close();
|
||||
ctx.watchKeys().clear();
|
||||
}
|
||||
|
||||
public interface EventHandler {
|
||||
void handle(Path thisDir, Path context);
|
||||
}
|
||||
|
||||
public interface InitLoader {
|
||||
void load();
|
||||
}
|
||||
|
||||
public record Context(Path root, WatchService watchService, Map<WatchKey, Path> watchKeys,
|
||||
Set<WatchEvent.Kind<?>> kinds) {
|
||||
public Context(Path root) throws IOException {
|
||||
this(root, FileSystems.getDefault().newWatchService(), new HashMap<>(), new LinkedHashSet<>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package work.slhaf.partner.api.common.support;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
class DirectoryWatchSupportTest {
|
||||
|
||||
@Test
|
||||
void testWatchDepthRejectsInvalidValue(@TempDir Path tempDir) throws IOException {
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
work.slhaf.partner.api.common.support.DirectoryWatchSupport.Context context = new work.slhaf.partner.api.common.support.DirectoryWatchSupport.Context(tempDir);
|
||||
Assertions.assertThrows(IllegalArgumentException.class,
|
||||
() -> new work.slhaf.partner.api.common.support.DirectoryWatchSupport(context, executor, -2, null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWatchDepthZeroOnlyWatchesRoot(@TempDir Path tempDir) throws Exception {
|
||||
Path childDir = Files.createDirectories(tempDir.resolve("child"));
|
||||
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
WatchHarness harness = createWatchSupport(tempDir, executor, 0)) {
|
||||
harness.watchSupport().start();
|
||||
|
||||
Files.writeString(tempDir.resolve("root.txt"), "root");
|
||||
waitForCondition(() -> harness.events().contains("root.txt"), 2000);
|
||||
|
||||
Files.writeString(childDir.resolve("child.txt"), "child");
|
||||
Thread.sleep(300);
|
||||
|
||||
Assertions.assertTrue(harness.events().contains("root.txt"));
|
||||
Assertions.assertFalse(harness.events().contains("child/child.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWatchDepthOneWatchesDirectChildrenOnly(@TempDir Path tempDir) throws Exception {
|
||||
Path childDir = Files.createDirectories(tempDir.resolve("child"));
|
||||
Path grandChildDir = Files.createDirectories(childDir.resolve("grandchild"));
|
||||
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
WatchHarness harness = createWatchSupport(tempDir, executor, 1)) {
|
||||
harness.watchSupport().start();
|
||||
|
||||
Files.writeString(childDir.resolve("child.txt"), "child");
|
||||
waitForCondition(() -> harness.events().contains("child/child.txt"), 2000);
|
||||
|
||||
Files.writeString(grandChildDir.resolve("deep.txt"), "deep");
|
||||
Thread.sleep(300);
|
||||
|
||||
Assertions.assertTrue(harness.events().contains("child/child.txt"));
|
||||
Assertions.assertFalse(harness.events().contains("child/grandchild/deep.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWatchDepthNegativeOneWatchesAllDescendants(@TempDir Path tempDir) throws Exception {
|
||||
Path grandChildDir = Files.createDirectories(tempDir.resolve("child").resolve("grandchild"));
|
||||
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
WatchHarness harness = createWatchSupport(tempDir, executor, -1)) {
|
||||
harness.watchSupport().start();
|
||||
|
||||
Files.writeString(grandChildDir.resolve("deep.txt"), "deep");
|
||||
waitForCondition(() -> harness.events().contains("child/grandchild/deep.txt"), 2000);
|
||||
|
||||
Assertions.assertTrue(harness.events().contains("child/grandchild/deep.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRegistersNewDirectoriesUpToConfiguredDepth(@TempDir Path tempDir) throws Exception {
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
WatchHarness harness = createWatchSupport(tempDir, executor, 1)) {
|
||||
harness.watchSupport().start();
|
||||
|
||||
Path childDir = Files.createDirectories(tempDir.resolve("child"));
|
||||
waitForCondition(() -> harness.watchSupport().isWatching(childDir), 2000);
|
||||
|
||||
Files.writeString(childDir.resolve("child.txt"), "child");
|
||||
waitForCondition(() -> harness.events().contains("child/child.txt"), 2000);
|
||||
|
||||
Assertions.assertTrue(harness.events().contains("child/child.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReRegistersRootAfterRecreate(@TempDir Path tempDir) throws Exception {
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
WatchHarness harness = createWatchSupport(tempDir, executor, 0)) {
|
||||
harness.watchSupport().start();
|
||||
|
||||
deleteDirectory(tempDir);
|
||||
waitForCondition(() -> !harness.watchSupport().isWatching(tempDir), 2000);
|
||||
Files.createDirectories(tempDir);
|
||||
|
||||
waitForCondition(() -> harness.watchSupport().isWatching(tempDir), 2000);
|
||||
|
||||
Files.writeString(tempDir.resolve("recreated.txt"), "ok");
|
||||
waitForCondition(() -> harness.events().contains("recreated.txt"), 3000);
|
||||
|
||||
Assertions.assertTrue(harness.events().contains("recreated.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
private WatchHarness createWatchSupport(Path root, ExecutorService executor, int watchDepth) throws IOException {
|
||||
work.slhaf.partner.api.common.support.DirectoryWatchSupport watchSupport = new work.slhaf.partner.api.common.support.DirectoryWatchSupport(new work.slhaf.partner.api.common.support.DirectoryWatchSupport.Context(root), executor, watchDepth, null);
|
||||
List<String> events = new CopyOnWriteArrayList<>();
|
||||
watchSupport.onCreate((thisDir, context) -> record(root, context, events));
|
||||
watchSupport.onModify((thisDir, context) -> record(root, context, events));
|
||||
return new WatchHarness(watchSupport, events);
|
||||
}
|
||||
|
||||
private void record(Path root, Path context, List<String> events) {
|
||||
if (context == null || Files.isDirectory(context)) {
|
||||
return;
|
||||
}
|
||||
events.add(root.relativize(context).toString().replace('\\', '/'));
|
||||
}
|
||||
|
||||
private void waitForCondition(BooleanSupplier supplier, long timeoutMs) throws InterruptedException {
|
||||
long start = System.currentTimeMillis();
|
||||
while (!supplier.getAsBoolean()) {
|
||||
if (System.currentTimeMillis() - start > timeoutMs) {
|
||||
break;
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteDirectory(Path dir) throws IOException {
|
||||
if (!Files.exists(dir)) {
|
||||
return;
|
||||
}
|
||||
try (var stream = Files.walk(dir)) {
|
||||
stream.sorted(Comparator.reverseOrder()).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private record WatchHarness(work.slhaf.partner.api.common.support.DirectoryWatchSupport watchSupport,
|
||||
List<String> events) implements AutoCloseable {
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
watchSupport.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user