Skip to content

Commit 240d025

Browse files
committed
WIP
1 parent 8d99dca commit 240d025

File tree

19 files changed

+361
-31
lines changed

19 files changed

+361
-31
lines changed

README.adoc

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@ Theoretically, a classloader can be configured for Kotlin scripting.
1313

1414
Unfortunately, this doesn't actually work because the implementation uses its own classloader.
1515

16-
## The solution
17-
The workaround is to unpack the jars used by the scripts.
16+
## The solution (for Kotlin 2.0.21)
17+
The current workaround is to unpack the kotlin jars used by the scripts inside the fat jar
18+
and to copy the jars used by the scripts to the local file system and using URLClassLoader to load the classes.
19+
20+
As a result: you shouldn't combine unpacking and copying jars in the same project (see COPY_MIX_FAIL in the log messages).
21+
22+
Later, we should check, if the classloader issue is fixed in the future.
1823

1924
## Motivation
2025
Kotlin scripts in my application for evaluations etc. are a powerful option.

application/src/main/kotlin/de/micromata/kotlinscripting/MyScriptingHost.kt renamed to application/src/main/kotlin/de/micromata/kotlinscripting/CustomScriptingHost.kt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,28 @@ private val log = KotlinLogging.logger {}
1212
* Otherwise, BasicJvmScriptingHost() can be used directly.
1313
*/
1414
class CustomScriptingHost(
15-
// private val customClassLoader: ClassLoader
15+
private val customClassLoader: ClassLoader? = null
1616
) : BasicJvmScriptingHost() {
1717

1818
override fun eval(
1919
script: SourceCode,
2020
compilationConfiguration: ScriptCompilationConfiguration,
2121
evaluationConfiguration: ScriptEvaluationConfiguration?
2222
): ResultWithDiagnostics<EvaluationResult> {
23-
//val originalClassLoader = Thread.currentThread().contextClassLoader
23+
val originalClassLoader = Thread.currentThread().contextClassLoader
2424
return try {
2525
ThreadLocalStorage.threadLocal.set(Constants.THREADLOCAL_TEST)
2626
// Trying to set the custom ClassLoader here.
27-
// Thread.currentThread().contextClassLoader = customClassLoader
28-
// log.info { "CustomScriptingHost: Setting custom ClassLoader: $customClassLoader" }
27+
if (customClassLoader != null) {
28+
Thread.currentThread().contextClassLoader = customClassLoader
29+
log.info { "CustomScriptingHost: Setting custom ClassLoader: $customClassLoader" }
30+
}
2931
super.eval(script, compilationConfiguration, evaluationConfiguration)
3032
} finally {
3133
// Resetting the original ClassLoader:
32-
// Thread.currentThread().contextClassLoader = originalClassLoader
34+
if (customClassLoader != null) {
35+
Thread.currentThread().contextClassLoader = originalClassLoader
36+
}
3337
}
3438
}
3539
}

application/src/main/kotlin/de/micromata/kotlinscripting/DemoApplication.kt

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,66 @@ class DemoApplication {
1313
@JvmStatic
1414
fun main(args: Array<String>) {
1515
runApplication<DemoApplication>(*args)
16-
runScript("Hello world") { SimpleScriptExecutor().executeScript("helloWorld.kts") }
17-
runScript("Simple script with variables") { SimpleScriptExecutor().executeScript("useContext.kts") }
18-
runScript("Simple script using business package") { SimpleScriptExecutor().executeScript("useContextAndBusiness.kts") }
19-
runScript("Simple script using business and common package", "This test will fail on fat jar, because the commons module isn't unpacked.") { SimpleScriptExecutor().executeScript("useContextAndBusinessAndCommons.kts") }
20-
runScript("ScriptExecutorWithCustomizedScriptingHost") {
21-
ScriptExecutorWithCustomizedScriptingHost().executeScript()
16+
runScript( "SIMPLE_1", "Hello world") { SimpleScriptExecutor().executeScript("helloWorld.kts") }
17+
runScript("SIMPLE_2", "Simple script with variables") { SimpleScriptExecutor().executeScript("useContext.kts") }
18+
runScript("SIMPLE_3", "Simple script using business package") { SimpleScriptExecutor().executeScript("useContextAndBusiness.kts") }
19+
runScript(
20+
"SIMPLE_4",
21+
"Simple script using business and common package (OK)",
22+
"This test will fail on fat jar, because the commons module isn't unpacked (OK)."
23+
) { SimpleScriptExecutor().executeScript("useContextAndBusinessAndIndirectCommons.kts") }
24+
runScript(
25+
"CUST_OK",
26+
"ScriptExecutorWithCustomizedScriptingHost",
27+
"Using ThreadLocal, context and execution with timeout.",
28+
) {
29+
ScriptExecutorWithCustomizedScriptingHost().executeScript("useContextAndThreadLocal.kts")
30+
}
31+
runScript(
32+
"CUST_TIMEOUT",
33+
"ScriptExecutorWithCustomizedScriptingHost: endless loop (OK)",
34+
"Timeout expected after 5 seconds! (OK)",
35+
) {
36+
ScriptExecutorWithCustomizedScriptingHost().executeScript("endlessLoop.kts")
37+
}
38+
runScript(
39+
"CLASSLOADER_FAIL",
40+
"*** ScriptExecutorWithOwnClassloader (fails for fat jar)***",
41+
"CustomCustomLoader is not used for loading commons (no log)! *** Any ideas on how to fix this?***",
42+
) {
43+
ScriptExecutorWithOwnClassloader().executeScript()
44+
}
45+
runScript(
46+
"COPY_1",
47+
"ScriptExecutorWithCopiedJars, direct commons-usage",
48+
"common package is not extracted in fat jar, but copied to temp dir for url classloader.",
49+
) {
50+
ScriptExecutorWithCopiedJars().executeScript("useContextAndCommons.kts")
51+
}
52+
runScript(
53+
"COPY_MIX_FAIL",
54+
"ScriptExecutorWithCopiedJars, indirect commons-usage (fails for fat jar****)",
55+
"common package available thru url classloader, but the mix of extracted and copied jars fails. *** Any ideas on how to fix this?***",
56+
) {
57+
ScriptExecutorWithCopiedJars().executeScript("useContextAndBusinessAndIndirectCommons.kts")
58+
}
59+
runScript(
60+
"COPY_2",
61+
"ScriptExecutorWithCopiedJars, direct commons and indirect misc-usage",
62+
"common and misc packages are not extracted in fat jar, but copied to temp dir for url classloader.",
63+
) {
64+
ScriptExecutorWithCopiedJars().executeScript("useContextAndCommonsAndIndirectMisc.kts")
2265
}
2366
System.exit(0) // Not an elegant way, but here it is enough.
2467
}
2568

26-
private fun runScript(name: String, msg: String? = null, block: () -> Unit) {
27-
log.info { "******************************************************************************" }
28-
log.info { "*** Running test $name" }
69+
private fun runScript(id: String, name: String, msg: String? = null, block: () -> Unit) {
70+
log.info { "*****************************************************************************************************" }
71+
log.info { "*** $id: Running test $name" }
2972
if (msg != null) {
3073
log.info { "*** $msg" }
3174
}
32-
log.info { "******************************************************************************" }
75+
log.info { "*****************************************************************************************************" }
3376
block()
3477
}
3578
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package de.micromata.kotlinscripting
2+
3+
import de.micromata.kotlinscripting.utils.JarExtractor
4+
import de.micromata.kotlinscripting.utils.KotlinScriptUtils
5+
import mu.KotlinLogging
6+
import java.net.URLClassLoader
7+
import java.util.concurrent.Executors
8+
import java.util.concurrent.Future
9+
import java.util.concurrent.TimeUnit
10+
import java.util.concurrent.TimeoutException
11+
import kotlin.script.experimental.api.*
12+
import kotlin.script.experimental.host.toScriptSource
13+
import kotlin.script.experimental.jvm.*
14+
15+
private val log = KotlinLogging.logger {}
16+
17+
class ScriptExecutorWithCopiedJars {
18+
private var evalException: Exception? = null
19+
20+
fun executeScript(scriptFile: String): Any? {
21+
val script = KotlinScriptUtils.loadScript(scriptFile)
22+
log.info { "Updated classpathFiles: ${JarExtractor.classpathFiles?.joinToString()}" }
23+
log.info { "Updated classpath URLs: ${JarExtractor.classpathUrls?.joinToString()}" }
24+
25+
val classLoader = if (JarExtractor.runningInFatJar) {
26+
URLClassLoader(JarExtractor.classpathUrls, Thread.currentThread().contextClassLoader).also {
27+
Thread.currentThread().contextClassLoader = it
28+
}
29+
} else {
30+
Thread.currentThread().contextClassLoader
31+
}
32+
val scriptingHost = CustomScriptingHost(classLoader)
33+
val compilationConfig = ScriptCompilationConfiguration {
34+
jvm {
35+
// dependenciesFromClassloader(classLoader = classLoader, wholeClasspath = true)
36+
if (JarExtractor.classpathFiles != null) {
37+
dependenciesFromClassloader(classLoader = classLoader, wholeClasspath = true)
38+
updateClasspath(JarExtractor.classpathFiles)
39+
} else {
40+
dependenciesFromCurrentContext(wholeClasspath = true)
41+
}
42+
}
43+
providedProperties("context" to KotlinScriptContext::class)
44+
compilerOptions.append("-nowarn")
45+
}
46+
val context = KotlinScriptContext()
47+
context.setProperty("testVariable", Constants.TEST_VAR)
48+
val evaluationConfiguration = ScriptEvaluationConfiguration {
49+
jvm {
50+
baseClassLoader(classLoader) // Without effect. ClassLoader will be overwritten by the UrlClassLoader.
51+
}
52+
providedProperties("context" to context)
53+
}
54+
val scriptSource = script.toScriptSource()
55+
val executor = Executors.newSingleThreadExecutor()
56+
var future: Future<ResultWithDiagnostics<EvaluationResult>>? = null
57+
try {
58+
future = executor.submit<ResultWithDiagnostics<EvaluationResult>> {
59+
scriptingHost.eval(scriptSource, compilationConfig, evaluationConfiguration)
60+
}
61+
val result = future.get(10, TimeUnit.SECONDS) // Timeout
62+
return KotlinScriptUtils.handleResult(result, script)
63+
} catch (ex: TimeoutException) {
64+
log.info("Script execution was cancelled due to timeout.")
65+
future?.cancel(true) // Attempt to cancel
66+
evalException = ex
67+
log.error("scripting.error.timeout")
68+
} catch (ex: Exception) {
69+
log.info("Exception on Kotlin script execution: ${ex.message}", ex)
70+
evalException = ex
71+
log.error("Exception on Kotlin script execution: ${ex.message}")
72+
} finally {
73+
executor.shutdownNow()
74+
}
75+
return null
76+
}
77+
}

application/src/main/kotlin/de/micromata/kotlinscripting/ScriptExecutorWithCustomizedScriptingHost.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ private val log = KotlinLogging.logger {}
1616
class ScriptExecutorWithCustomizedScriptingHost {
1717
private var evalException: Exception? = null
1818

19-
fun executeScript(): Any? {
20-
//val classLoader = CustomClassLoader(Thread.currentThread().contextClassLoader)
21-
val script = KotlinScriptUtils.loadScript("useContextAndThreadLocal.kts")
19+
fun executeScript(scriptFile: String): Any? {
20+
val script = KotlinScriptUtils.loadScript(scriptFile)
2221
val scriptingHost = CustomScriptingHost() // (classLoader)
2322
val compilationConfig = ScriptCompilationConfiguration {
2423
jvm {
@@ -43,7 +42,7 @@ class ScriptExecutorWithCustomizedScriptingHost {
4342
future = executor.submit<ResultWithDiagnostics<EvaluationResult>> {
4443
scriptingHost.eval(scriptSource, compilationConfig, evaluationConfiguration)
4544
}
46-
val result = future.get(10, TimeUnit.SECONDS) // Timeout
45+
val result = future.get(5, TimeUnit.SECONDS) // Timeout
4746
return KotlinScriptUtils.handleResult(result, script)
4847
} catch (ex: TimeoutException) {
4948
log.info("Script execution was cancelled due to timeout.")
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package de.micromata.kotlinscripting
2+
3+
import de.micromata.kotlinscripting.utils.KotlinScriptUtils
4+
import mu.KotlinLogging
5+
import java.util.concurrent.Executors
6+
import java.util.concurrent.Future
7+
import java.util.concurrent.TimeUnit
8+
import java.util.concurrent.TimeoutException
9+
import kotlin.script.experimental.api.*
10+
import kotlin.script.experimental.host.toScriptSource
11+
import kotlin.script.experimental.jvm.baseClassLoader
12+
import kotlin.script.experimental.jvm.dependenciesFromClassloader
13+
import kotlin.script.experimental.jvm.jvm
14+
15+
private val log = KotlinLogging.logger {}
16+
17+
/**
18+
* Doesn't work with the current Kotlin version. The classloader is not used.
19+
*
20+
* Refer e.g. kotlin.script.experimental.jvm.impl.KJvmCompiledScript and kotlin.script.experimental.jvm.BasicJvmScriptEvaluator
21+
* for further debugging.
22+
*/
23+
class ScriptExecutorWithOwnClassloader {
24+
private var evalException: Exception? = null
25+
26+
fun executeScript(): Any? {
27+
val classLoader = CustomClassLoader(Thread.currentThread().contextClassLoader)
28+
val script = KotlinScriptUtils.loadScript("useContextAndCommons.kts")
29+
val scriptingHost = CustomScriptingHost(classLoader) // (classLoader)
30+
val compilationConfig = ScriptCompilationConfiguration {
31+
jvm {
32+
dependenciesFromClassloader(classLoader = classLoader, wholeClasspath = true)
33+
}
34+
providedProperties("context" to KotlinScriptContext::class)
35+
compilerOptions.append("-nowarn")
36+
}
37+
val context = KotlinScriptContext()
38+
context.setProperty("testVariable", Constants.TEST_VAR)
39+
val evaluationConfiguration = ScriptEvaluationConfiguration {
40+
jvm {
41+
baseClassLoader(classLoader) // Without effect. ClassLoader will be overwritten by the UrlClassLoader.
42+
}
43+
providedProperties("context" to context)
44+
}
45+
val scriptSource = script.toScriptSource()
46+
val executor = Executors.newSingleThreadExecutor()
47+
var future: Future<ResultWithDiagnostics<EvaluationResult>>? = null
48+
try {
49+
future = executor.submit<ResultWithDiagnostics<EvaluationResult>> {
50+
scriptingHost.eval(scriptSource, compilationConfig, evaluationConfiguration)
51+
}
52+
val result = future.get(10, TimeUnit.SECONDS) // Timeout
53+
return KotlinScriptUtils.handleResult(result, script)
54+
} catch (ex: TimeoutException) {
55+
log.info("Script execution was cancelled due to timeout.")
56+
future?.cancel(true) // Attempt to cancel
57+
evalException = ex
58+
log.error("scripting.error.timeout")
59+
} catch (ex: Exception) {
60+
log.info("Exception on Kotlin script execution: ${ex.message}", ex)
61+
evalException = ex
62+
log.error("Exception on Kotlin script execution: ${ex.message}")
63+
} finally {
64+
executor.shutdownNow()
65+
}
66+
return null
67+
}
68+
}

application/src/main/kotlin/de/micromata/kotlinscripting/SimpleScriptExecutor.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import kotlin.script.experimental.jvm.jvm
88
import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
99

1010
class SimpleScriptExecutor {
11-
fun executeScript(file: String): Any? {
12-
val script = KotlinScriptUtils.loadScript(file)
11+
fun executeScript(scriptFile: String): Any? {
12+
val script = KotlinScriptUtils.loadScript(scriptFile)
1313
val scriptingHost = BasicJvmScriptingHost()
1414
val compilationConfig = ScriptCompilationConfiguration {
1515
jvm {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package de.micromata.kotlinscripting.utils
2+
3+
import mu.KotlinLogging
4+
import java.io.File
5+
import java.io.InputStream
6+
import java.net.URL
7+
import java.nio.file.Files
8+
import java.nio.file.Path
9+
import java.nio.file.Paths
10+
import java.util.jar.JarFile
11+
import kotlin.io.path.absolutePathString
12+
import kotlin.io.path.exists
13+
14+
private val log = KotlinLogging.logger {}
15+
16+
/**
17+
* Kotlin scripts don't run out of the box with spring boot. This workaround is needed.
18+
*/
19+
internal object JarExtractor {
20+
const val TEMP_DIR = "kotlin-scripting-extracted-jar"
21+
22+
val extractedDir = createFixedTempDirectory().toFile()
23+
24+
val runningInFatJar = JarExtractor::class.java.protectionDomain.codeSource.location.toString().startsWith("jar:")
25+
26+
/**
27+
* The classpath to be used for the script engine.
28+
* It contains the copied jars.
29+
*/
30+
var classpathFiles: List<File>? = null
31+
private set
32+
var classpathUrls: Array<URL>? = null
33+
private set
34+
35+
private val copyJars = listOf(
36+
"commons",
37+
"misc",
38+
).map { Regex("""$it-\d+(\.\d+)*\.jar${'$'}""") } // """commons-\d+(\.\d+)*\.jar$""",
39+
40+
init {
41+
log.info { "Source code location: ${JarExtractor::class.java.protectionDomain.codeSource.location}" }
42+
if (runningInFatJar) {
43+
log.info { "We're running in a fat jar: ${JarExtractor::class.java.protectionDomain.codeSource.location}" }
44+
val classpath = System.getProperty("java.class.path")
45+
val jarPath = File(classpath) // Fat JAR or classpath
46+
extract(jarPath)
47+
} else {
48+
log.info { "We aren't running in a fat jar: ${JarExtractor::class.java.protectionDomain.codeSource.location}" }
49+
}
50+
}
51+
52+
private fun extract(springBootJarFile: File) {
53+
log.info { "Detecting jar file: ${springBootJarFile.absolutePath}" }
54+
val files = mutableListOf<File>()
55+
files.add(springBootJarFile.absoluteFile) // Add the spring boot jar file itself. But the test COPY_MIX_FAIL will fail, why?
56+
JarFile(springBootJarFile).use { zip ->
57+
zip.entries().asSequence().forEach { entry ->
58+
zip.getInputStream(entry).use { input ->
59+
if (!entry.isDirectory) {
60+
val origFile = File(entry.name)
61+
if (origFile.extension == "jar" && copyJars.any { origFile.name.matches(it) }) {
62+
val jarFile = File(extractedDir, origFile.name)
63+
log.debug { "Copying jar file: ${origFile.name} -> ${jarFile.absolutePath}" }
64+
// Extract JAR file in destination directory
65+
jarFile.outputStream().use { output ->
66+
input.copyTo(output)
67+
}
68+
files.add(jarFile.absoluteFile)
69+
}
70+
}
71+
}
72+
}
73+
}
74+
classpathFiles = files
75+
classpathUrls = files.map { it.toURI().toURL() }.toTypedArray()
76+
}
77+
78+
79+
fun createFixedTempDirectory(): Path {
80+
val systemTempDir = Paths.get(System.getProperty("java.io.tmpdir"))
81+
val tempDir = systemTempDir.resolve(TEMP_DIR)
82+
if (tempDir.exists()) {
83+
log.info { "Deleting temp directory: ${tempDir.absolutePathString()}" }
84+
tempDir.toFile().deleteRecursively()
85+
}
86+
log.info { "Creating temp directory: ${tempDir.absolutePathString()}" }
87+
Files.createDirectories(tempDir)
88+
return tempDir
89+
}
90+
91+
}

0 commit comments

Comments
 (0)