From 6f7cc82267d2970df762434c4ebae8e05f1bffa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D1=83=D0=BA=D1=88=D0=B8=D0=BD=20=D0=98=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD?= Date: Tue, 12 May 2026 17:31:40 +0200 Subject: [PATCH] Fast-path copyToTempDirectory via ClassLoader.getResourceAsStream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copyToTempDirectory currently always takes the "extract the entire host JAR" path when the requesting class lives inside a JAR. The unzip step is O(N) in the total entry count of that JAR. On a fat / Spring Boot uberjar (~100k entries, several hundred MB) this adds 10-20 seconds of pure I/O to startup, every time the host application boots — even though only a single resource (e.g. mac_arm/libsodium.dylib) is needed. This adds a fast path: when ClassLoader.getResource returns a non-null, non-directory resource, stream it to a temp file in O(1) and return. The legacy extraction path remains as the fallback for nested-jar layouts where getResource returns null (e.g. Spring Boot loader's BOOT-INF/lib/.jar!/...) and for directory resources, which the legacy path handles via copyDirectory. Behaviour preserved: - Output shape is unchanged: a single file in a fresh temp directory whose absolute path is returned. - Permissions: Files.copy preserves read/write defaults; the existing writePerms/readPerms/execPerms collections are only consulted inside the legacy unzip path, where they continue to apply. - Directory resources are explicitly excluded from the fast path (URL ends with "/" or file:// URL points to a directory), so the existing loadWholeFolders behaviour is unchanged. Measured impact (macOS arm64 / APFS / Java 25, SodiumJava ctor wall-time): Classpath | Before | After Minimal (3 jars) | 552 ms | ~80 ms Spring Boot / Clojure uberjar (138k / 321 MB) | 19,000 ms | ~80 ms Tests: - All 16 existing tests continue to pass. - 2 new parametrised tests cover the fast path for both "test1.txt" and "/test1.txt" relative paths. --- .../goterl/resourceloader/ResourceLoader.java | 80 +++++++++++++++++++ .../resourceloader/ResourceLoaderTest.java | 20 +++++ 2 files changed, 100 insertions(+) diff --git a/src/main/java/com/goterl/resourceloader/ResourceLoader.java b/src/main/java/com/goterl/resourceloader/ResourceLoader.java index 30be667..6e73840 100644 --- a/src/main/java/com/goterl/resourceloader/ResourceLoader.java +++ b/src/main/java/com/goterl/resourceloader/ResourceLoader.java @@ -63,6 +63,22 @@ public class ResourceLoader { * @throws URISyntaxException If cannot find the resource file. */ public File copyToTempDirectory(String relativePath, Class outsideClass) throws IOException, URISyntaxException { + // Fast path: when the resource is a single file reachable through + // the standard ClassLoader API we can read it in O(1) without + // unpacking the host JAR. Unpacking the whole JAR (legacy path + // below) is O(N) in the total entry count of that JAR and on a fat + // / Spring Boot uberjar (~100k entries, several hundred MB) it adds + // 10-20 seconds of pure I/O to startup. The legacy path is + // preserved as a fallback for: + // - directory resources (handled via copyDirectory in the legacy + // path; we explicitly skip them here), + // - nested-jar layouts where getResource returns null + // (e.g. Spring Boot loader's BOOT-INF/lib/.jar!/...). + File fastPathResult = tryFastPath(relativePath, outsideClass); + if (fastPathResult != null) { + return fastPathResult; + } + // Create a "main" temporary directory in which // everything can be thrown in. File mainTempDir = createMainTempDirectory(); @@ -88,6 +104,70 @@ public File copyToTempDirectory(String relativePath, Class outsideClass) throws return getFileFromFileSystem(relativePath, mainTempDir); } + /** + * Attempts to satisfy the resource request via the standard + * {@link ClassLoader#getResource} / {@link ClassLoader#getResourceAsStream} + * APIs, which are O(1) in the resource directory. Returns the extracted + * file on success, or {@code null} when the resource is a directory, is + * not reachable through the classloader, or otherwise needs the legacy + * JAR-extraction path. + */ + private File tryFastPath(String relativePath, Class outsideClass) throws IOException { + if (relativePath == null || relativePath.endsWith("/")) { + return null; + } + String classpathName = relativePath.startsWith("/") + ? relativePath.substring(1) + : relativePath; + if (classpathName.isEmpty()) { + return null; + } + ClassLoader cl = (outsideClass != null) + ? outsideClass.getClassLoader() + : Thread.currentThread().getContextClassLoader(); + if (cl == null) { + cl = ClassLoader.getSystemClassLoader(); + } + if (cl == null) { + return null; + } + URL resource = cl.getResource(classpathName); + if (resource == null) { + return null; + } + // Skip the fast path when the resource is a directory: directory + // entries on most classloaders yield a synthetic listing stream + // which is not a file we can sensibly copy. + String urlString = resource.toString(); + if (urlString.endsWith("/")) { + return null; + } + String protocol = resource.getProtocol(); + if ("file".equals(protocol)) { + try { + if (new File(resource.toURI()).isDirectory()) { + return null; + } + } catch (URISyntaxException e) { + return null; + } + } + try (InputStream is = resource.openStream()) { + if (is == null) { + return null; + } + File fastTempDir = createMainTempDirectory(); + fastTempDir.mkdirs(); + int slash = classpathName.lastIndexOf('/'); + String filename = (slash >= 0) + ? classpathName.substring(slash + 1) + : classpathName; + File out = new File(fastTempDir, filename); + Files.copy(is, out.toPath(), StandardCopyOption.REPLACE_EXISTING); + return out; + } + } + public File extractFromWithinAJarFile(URL jarPath, File mainTempDir, String relativePath) throws IOException, URISyntaxException { if (jarPath == null) { diff --git a/src/test/java/com/goterl/resourceloader/ResourceLoaderTest.java b/src/test/java/com/goterl/resourceloader/ResourceLoaderTest.java index ca600b4..31df7f3 100644 --- a/src/test/java/com/goterl/resourceloader/ResourceLoaderTest.java +++ b/src/test/java/com/goterl/resourceloader/ResourceLoaderTest.java @@ -122,6 +122,26 @@ public void nestedExtractTest(String url, String path) throws NoSuchMethodExcept assertThat(file.exists()).isTrue(); } + @DataProvider(name = "fastPathTestData") + public static Object[][] fastPathTestData() { + return new Object[][] { + {"test1.txt"}, + {"/test1.txt"} + }; + } + + @Test(dataProvider = "fastPathTestData") + public void copyToTempDirectoryFastPathExtractsClasspathResource(String relativePath) throws IOException, URISyntaxException { + ResourceLoader loader = new ResourceLoader(); + File file = loader.copyToTempDirectory(relativePath, ResourceLoaderTest.class); + + assertThat(file).isNotNull(); + assertThat(file.exists()).isTrue(); + assertThat(file.isFile()).isTrue(); + assertThat(file.getName()).isEqualTo("test1.txt"); + assertThat(file.length()).isGreaterThan(0); + } + private static boolean delete(String path) { File filePath = new File(path); String[] list = filePath.list();