Skip to content

Commit 240d025

Browse files
committedNov 27, 2024
WIP
1 parent 8d99dca commit 240d025

File tree

19 files changed

+361
-31
lines changed

19 files changed

+361
-31
lines changed
 

‎README.adoc

+7-2
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 ‎application/src/main/kotlin/de/micromata/kotlinscripting/CustomScriptingHost.kt

+9-5
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

+53-10
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
}
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

+3-4
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.")
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

+2-2
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 {
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+
}

‎application/src/main/kotlin/de/micromata/kotlinscripting/utils/KotlinScriptUtils.kt

+14-5
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,26 @@ internal object KotlinScriptUtils {
6060
line2?.let { log(severity, it) }
6161
}
6262
val returnValue = extractResult(result)
63+
if (returnValue is ResultValue.Error) {
64+
logReport(result)
65+
log.error { "Script result with error: ${returnValue.error}" }
66+
return returnValue.error
67+
}
6368
if (result is ResultWithDiagnostics.Success) {
64-
println("Script result with success: ${returnValue}")
69+
log.info { "Script result with success: ${returnValue}" }
6570
} else {
66-
println("*** Script result: ${result.valueOrNull()}")
67-
result.reports.forEach {
68-
println("Script report: ${it.message}")
69-
}
71+
log.error { "Script result: ${result.valueOrNull()}" }
72+
logReport(result)
7073
}
7174
return returnValue
7275
}
7376

77+
private fun logReport(result: ResultWithDiagnostics<EvaluationResult>) {
78+
result.reports.forEach {
79+
log.error { "Script report: ${it.message}" }
80+
}
81+
}
82+
7483
internal fun loadScript(filename: String): String {
7584
return object {}.javaClass.classLoader.getResource(filename)?.readText()
7685
?: throw IllegalArgumentException("Script not found: $filename")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
while (true) {
2+
// do nothing
3+
}
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
import de.micromata.kotlinscripting.business.Foo
22

33
// Foo.useCommons() uses the commons package.
4-
Foo.bar() + ", " + Foo.useCommons()
4+
Foo.callCommons()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import de.micromata.kotlinscripting.commons.GetMessage
2+
3+
// Foo.useCommons() uses the commons package.
4+
GetMessage.getMessage()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import de.micromata.kotlinscripting.commons.GetMessage
2+
3+
// Foo.useCommons() uses the commons package.
4+
GetMessage.getMiscMessage()

‎business/src/main/kotlin/de/micromata/kotlinscripting/business/Foo.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ object Foo {
77
return "A warm welcome from business package."
88
}
99

10-
fun useCommons(): String {
11-
return GetMessage.getMessage()
10+
fun callCommons(): String {
11+
return "business is calling commons: " + GetMessage.getMessage()
1212
}
1313
}

‎commons/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ plugins {
66
val kotlinVersion: String by rootProject.extra
77

88
dependencies {
9+
implementation(project(":misc"))
910
implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
1011
}

‎commons/src/main/kotlin/de/micromata/kotlinscripting/commons/GetMessage.kt

+4
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@ object GetMessage {
44
fun getMessage(): String {
55
return "Hello world from commons package!"
66
}
7+
8+
fun getMiscMessage(): String {
9+
return "commons package is calling misc package: " + de.micromata.kotlinscripting.misc.Misc.getMessage()
10+
}
711
}

‎misc/build.gradle.kts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
plugins {
2+
id("java-library")
3+
kotlin("jvm") version "2.0.21"
4+
}
5+
6+
val kotlinVersion: String by rootProject.extra
7+
8+
dependencies {
9+
implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package de.micromata.kotlinscripting.misc
2+
3+
object Misc {
4+
fun getMessage(): String {
5+
return "Misc package speaking!"
6+
}
7+
}

‎settings.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ rootProject.name = "kotlinscripting-parent"
33
include("application")
44
include("business")
55
include("commons")
6+
include("misc")

0 commit comments

Comments
 (0)
Please sign in to comment.