Skip to content
This repository was archived by the owner on Aug 18, 2020. It is now read-only.

Commit cafc077

Browse files
authored
Merge pull request #113 from codeoverflow-org/feature/105-plugin-dependencies
Implement Coursier for plugin dependency resolution and fetching
2 parents 240b34e + a1975ae commit cafc077

File tree

10 files changed

+258
-31
lines changed

10 files changed

+258
-31
lines changed

bootstrap/build.sbt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ assemblyJarName in assembly := "ChatOverflow.jar"
44

55
libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "1.1.1"
66
libraryDependencies += "org.jline" % "jline-terminal-jansi" % "3.11.0" // used for terminal width
7-
fork := true
7+
fork := true
8+
9+
packageBin / includePom := false

build.sbt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ libraryDependencies += "com.fazecast" % "jSerialComm" % "[2.0.0,3.0.0)"
8989
// Socket.io
9090
libraryDependencies += "io.socket" % "socket.io-client" % "1.0.0"
9191

92+
// Coursier
93+
libraryDependencies += "io.get-coursier" %% "coursier" % "2.0.0-RC3-2"
94+
9295
// ---------------------------------------------------------------------------------------------------------------------
9396
// PLUGIN FRAMEWORK DEFINITIONS
9497
// ---------------------------------------------------------------------------------------------------------------------
@@ -135,6 +138,7 @@ Compile / packageBin := {
135138
}
136139

137140
Compile / unmanagedJars := (crossTarget.value ** "chatoverflow-gui*.jar").classpath
141+
packageBin / includePom := false
138142

139143
fork in run := true // Start ChatOverflow in it's own java process when starting it with 'sbt run'
140144

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package org.codeoverflow.chatoverflow.build
2+
3+
import sbt._
4+
import sbt.Keys._
5+
import sbt.plugins.JvmPlugin
6+
7+
/**
8+
* A sbt plugin to automatically include the dependencies of a sbt project in the jar as a pom file called "dependencies.pom".
9+
*/
10+
object PomInclusionPlugin extends AutoPlugin {
11+
12+
// Everything in autoImport will be visible to sbt project files
13+
// They can set this value to false if they don't want to include their dependencies as a pom file
14+
object autoImport {
15+
val includePom = settingKey[Boolean]("Whether to include a pom file inside the jar with all dependencies.")
16+
}
17+
import autoImport._
18+
19+
// We require to have the Compile configuration and the packageBin task to override
20+
override def requires = JvmPlugin
21+
override def trigger = allRequirements
22+
23+
// Adds our custom task before the packageBin task
24+
override val projectSettings: Seq[Def.Setting[_]] =
25+
inConfig(Compile)(Seq(
26+
Compile / packageBin := {
27+
addPomToOutput.value
28+
(Compile / packageBin).value
29+
}
30+
))
31+
32+
// Sets default values
33+
override def buildSettings: Seq[Def.Setting[_]] = inConfig(Compile)(
34+
includePom in packageBin := true
35+
)
36+
37+
// Just copies the pom resulted by makePom into the directory for compiled classes
38+
// That way the file will be included in the jar
39+
private lazy val addPomToOutput = Def.taskDyn {
40+
if ((includePom in packageBin).value) Def.task {
41+
val pomFile = (Compile / makePom).value
42+
43+
IO.copyFile(pomFile, new File((Compile / classDirectory).value, "dependencies.pom"))
44+
} else
45+
Def.task {} // if disabled, do nothing
46+
}
47+
}

src/main/scala/org/codeoverflow/chatoverflow/framework/PluginFramework.scala

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import org.codeoverflow.chatoverflow.framework.helper.PluginLoader
77
import org.codeoverflow.chatoverflow.framework.manager.PluginManagerStub
88

99
import scala.collection.mutable.ListBuffer
10+
import scala.concurrent.ExecutionContext.Implicits.global
11+
import scala.concurrent.duration._
12+
import scala.concurrent.{Await, Future}
13+
import scala.util.Success
1014

1115
/**
1216
* The plugin framework holds all plugin types important from the jar files in the plugin folder.
@@ -62,6 +66,8 @@ class PluginFramework(pluginDirectoryPath: String) extends WithLogger {
6266
logger warn s"PluginType directory '$pluginDirectory' does not exist!"
6367
} else {
6468

69+
val futures = ListBuffer[Future[_]]()
70+
6571
// Get (new) jar file urls
6672
val jarFiles = getNewJarFiles(pluginDirectory)
6773
logger info s"Found ${jarFiles.length} new plugins."
@@ -81,22 +87,28 @@ class PluginFramework(pluginDirectoryPath: String) extends WithLogger {
8187
} else {
8288

8389
// Try to test the initiation of the plugin
84-
try {
85-
plugin.createPluginInstance(new PluginManagerStub)
86-
logger info s"Successfully tested instantiation of plugin '${plugin.getName}'"
87-
pluginTypes += plugin
88-
} catch {
89-
// Note that we catch not only exceptions, but also errors like NoSuchMethodError. Deep stuff
90-
case _: Error => logger warn s"Error while test init of plugin '${plugin.getName}'."
91-
case _: Exception => logger warn s"Exception while test init of plugin '${plugin.getName}'."
90+
futures += plugin.getDependencyFuture andThen {
91+
case Success(_) =>
92+
try {
93+
plugin.createPluginInstance(new PluginManagerStub)
94+
logger info s"Successfully tested instantiation of plugin '${plugin.getName}'"
95+
pluginTypes += plugin
96+
} catch {
97+
// Note that we catch not only exceptions, but also errors like NoSuchMethodError. Deep stuff
98+
case _: Error => logger warn s"Error while test init of plugin '${plugin.getName}'."
99+
case _: Exception => logger warn s"Exception while test init of plugin '${plugin.getName}'."
100+
}
92101
}
93102
}
94103
}
95104
}
96-
}
97105

98-
logger info s"Loaded ${pluginTypes.length} plugin types in total: " +
99-
s"${pluginTypes.map(pt => s"${pt.getName} (${pt.getAuthor})").mkString(", ")}"
106+
// If plugins aren't done within this timeout they can still fetch everything in the background, they just won't be included in this summary
107+
futures.foreach(f => Await.ready(f, 5.seconds))
108+
109+
logger info s"Loaded ${pluginTypes.length} plugin types in total: " +
110+
s"${pluginTypes.map(pt => s"${pt.getName} (${pt.getAuthor})").mkString(", ")}"
111+
}
100112
}
101113

102114
/**

src/main/scala/org/codeoverflow/chatoverflow/framework/PluginType.scala

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
package org.codeoverflow.chatoverflow.framework
22

3+
import java.io.File
4+
35
import org.codeoverflow.chatoverflow.WithLogger
46
import org.codeoverflow.chatoverflow.api.APIVersion
57
import org.codeoverflow.chatoverflow.api.plugin.{Plugin, PluginManager}
68
import org.codeoverflow.chatoverflow.framework.PluginCompatibilityState.PluginCompatibilityState
79

10+
import scala.concurrent.Future
11+
812
/**
9-
* A plugin type is a container for all information about a plugin, everything in the 'plugin.xml' and the actual class.
10-
* The plugins functionality and meta information can be accessed through this interface.
11-
*
12-
* @param name the name of the plugin, used for identifying
13-
* @param author the author of the plugin, used for identifying
14-
* @param version the version of the plugin
15-
* @param majorAPIVersion the major api version, with which the plugin was developed
16-
* @param minorAPIVersion the minor api version, with which the plugin was developed
17-
* @param pluginClass the class of the plugin, used to create instances of this plugin.
18-
* Needs to have a constructor with the signature of one PluginManager,
19-
* otherwise instances can't be created from it.
20-
*/
13+
* A plugin type is a container for all information about a plugin, everything in the 'plugin.xml' and the actual class.
14+
* The plugins functionality and meta information can be accessed through this interface.
15+
*
16+
* @param name the name of the plugin, used for identifying
17+
* @param author the author of the plugin, used for identifying
18+
* @param version the version of the plugin
19+
* @param majorAPIVersion the major api version, with which the plugin was developed
20+
* @param minorAPIVersion the minor api version, with which the plugin was developed
21+
* @param pluginClass the class of the plugin, used to create instances of this plugin.
22+
* Needs to have a constructor with the signature of one PluginManager,
23+
* otherwise instances can't be created from it.
24+
* @param pluginDependencies A future that completes when all dependencies, that the plugin has, are available and
25+
* that returns a seq of the required dependencies files in the local coursier cache.
26+
*/
2127
class PluginType(name: String, author: String, version: String, majorAPIVersion: Int, minorAPIVersion: Int,
22-
metadata: PluginMetadata, pluginClass: Class[_ <: Plugin]) extends WithLogger {
28+
metadata: PluginMetadata, pluginClass: Class[_ <: Plugin], pluginDependencies: Future[Seq[File]]) extends WithLogger {
2329

2430
private var pluginVersionState = PluginCompatibilityState.Untested
2531

@@ -126,4 +132,12 @@ class PluginType(name: String, author: String, version: String, majorAPIVersion:
126132
* @return the PluginMetadata instance of this plugin
127133
*/
128134
def getMetadata: PluginMetadata = metadata
135+
136+
/**
137+
* Returns the future that will result in a sequence of jar files that represents the dependencies
138+
* of this plugin including all sub-dependencies.
139+
*
140+
* @return the dependency future
141+
*/
142+
def getDependencyFuture: Future[Seq[File]] = pluginDependencies
129143
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package org.codeoverflow.chatoverflow.framework.helper
2+
3+
import java.io.{File, InputStream}
4+
5+
import coursier.Fetch
6+
import coursier.cache.{CacheLogger, FileCache}
7+
import coursier.core.Dependency
8+
import coursier.maven.PomParser
9+
import org.codeoverflow.chatoverflow.WithLogger
10+
11+
import scala.io.Source
12+
13+
/**
14+
* A utility object containing some common code for use with Coursier.
15+
*/
16+
object CoursierUtils extends WithLogger {
17+
18+
private object CoursierLogger extends CacheLogger {
19+
override def downloadedArtifact(url: String, success: Boolean): Unit = {
20+
logger debug (if (success)
21+
s"Successfully downloaded $url"
22+
else
23+
s"Failed to download $url")
24+
}
25+
}
26+
27+
private val cache = FileCache().noCredentials.withLogger(CoursierLogger)
28+
29+
/**
30+
* Extracts all dependencies out of the provided pom. Throws an exception if the pom is invalid.
31+
*
32+
* @param is the InputStream from which the pom is read
33+
* @return a seq of all found dependencies
34+
*/
35+
def parsePom(is: InputStream): Seq[Dependency] = {
36+
val pomFile = Source.fromInputStream(is)
37+
val parser = coursier.core.compatibility.xmlParseSax(pomFile.mkString, new PomParser)
38+
39+
parser.project match {
40+
case Right(deps) => deps.dependencies.map(_._2)
41+
case Left(errorMsg) => throw new IllegalArgumentException(s"Pom couldn't be parsed: $errorMsg")
42+
}
43+
}
44+
45+
/**
46+
* Resolves and fetches all passed dependencies and gives back a seq of all local files of these dependencies.
47+
*
48+
* @param dependencies all dependencies that you want to be fetched
49+
* @return all local files for the passed dependencies
50+
*/
51+
def fetchDependencies(dependencies: Seq[Dependency]): Seq[File] = {
52+
// IntelliJ may warn you that a implicit is missing. This is one of the many bugs in IntelliJ, the code compiles fine.
53+
Fetch()
54+
.withCache(cache)
55+
.addDependencies(dependencies: _*)
56+
.run()
57+
}
58+
}

src/main/scala/org/codeoverflow/chatoverflow/framework/helper/PluginClassLoader.scala

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,52 @@ package org.codeoverflow.chatoverflow.framework.helper
22

33
import java.net.{URL, URLClassLoader}
44

5+
import org.codeoverflow.chatoverflow.WithLogger
6+
7+
/**
8+
* This plugin class loader is used for plugin security policy checks,
9+
* to expose the addURL method to the package inorder to add all required dependencies after dependency resolution
10+
* and most importantly to isolate the plugin from the normal classpath and only access the classpath if it needs to load the ChatOverflow api.
11+
* Also if this PluginClassLoader had access to the classpath the same classes of the classpath would have
12+
* higher priority over the classes in this classloader which could be a problem if a plugin uses a newer version
13+
* of a dependency that the framework.
14+
*
15+
* @param urls Takes an array of urls an creates a simple URLClassLoader with it
16+
*/
17+
class PluginClassLoader(urls: Array[URL]) extends URLClassLoader(urls, PluginClassLoader.platformClassloader) {
18+
// Note the platform classloader in the constructor of the URLClassLoader as the parent.
19+
// That way the classloader skips the app classloader with the classpath when it is asks it's parents for classes.
20+
21+
protected[helper] override def addURL(url: URL): Unit = super.addURL(url) // just exposes this method to be package-private instead of class internal protected
22+
23+
override def loadClass(name: String, resolve: Boolean): Class[_] = {
24+
if (name.startsWith("org.codeoverflow.chatoverflow.api")) {
25+
PluginClassLoader.appClassloader.loadClass(name) // Api needs to be loaded from the classpath
26+
} else {
27+
super.loadClass(name, resolve) // non api class. load it as normal
28+
}
29+
}
30+
}
31+
532
/**
6-
* This plugin class loader does only exist for plugin security policy checks.
7-
*
8-
* @param urls Takes an array of urls an creates a simple URLClassLoader with it
9-
*/
10-
class PluginClassLoader(urls: Array[URL]) extends URLClassLoader(urls)
33+
* This companion object holds references to the app classloader (normal classloader, includes java and classpath)
34+
* and to the extension/platform classloader depending on the java version that excludes the classpath,
35+
* but still includes everything from java.
36+
*/
37+
private object PluginClassLoader extends WithLogger {
38+
val appClassloader: ClassLoader = this.getClass.getClassLoader
39+
val platformClassloader: ClassLoader = {
40+
var current = appClassloader
41+
while (current != null && !current.getClass.getName.contains("ExtClassLoader") && // ExtClassLoader is java < 9
42+
!current.getClass.getName.contains("PlatformClassLoader")) { // PlatformClassLoader is java >= 9
43+
current = current.getParent
44+
}
45+
46+
if (current != null) {
47+
current
48+
} else {
49+
logger error "Platform classloader couldn't be found. Falling back to normal app classloader."
50+
appClassloader
51+
}
52+
}
53+
}

src/main/scala/org/codeoverflow/chatoverflow/framework/helper/PluginLoader.scala

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import org.reflections.scanners.SubTypesScanner
1010
import org.reflections.util.ConfigurationBuilder
1111

1212
import scala.collection.JavaConverters._
13+
import scala.concurrent.ExecutionContext.Implicits.global
14+
import scala.concurrent.Future
15+
import scala.util.{Failure, Success}
1316
import scala.xml.{Node, SAXParseException, XML}
1417

1518
/**
@@ -80,7 +83,8 @@ class PluginLoader(private val jar: File) extends WithLogger {
8083
majorString.toInt,
8184
minorString.toInt,
8285
PluginMetadata.fromXML(p),
83-
cls
86+
cls,
87+
resolveDependencies(getString(p, "name"))
8488
))
8589
} catch {
8690
// thrown by getString
@@ -144,4 +148,29 @@ class PluginLoader(private val jar: File) extends WithLogger {
144148
None
145149
}
146150
}
151+
152+
/**
153+
* Creates a future which gets all dependencies from the included dependencies.pom, if existing, fetches them
154+
* and adds their jar files to the classloader.
155+
*
156+
* @param pluginName the name of the plugin, only used for logging
157+
* @return a future of all required jars for this plugin
158+
*/
159+
private def resolveDependencies(pluginName: String): Future[Seq[File]] = {
160+
val pomIs = classloader.getResourceAsStream("dependencies.pom")
161+
if (pomIs == null) {
162+
return Future(Seq())
163+
}
164+
165+
Future(CoursierUtils.parsePom(pomIs))
166+
.map(dependencies => dependencies.filter(_.module.name.value != "chatoverflow-api_2.12"))
167+
.map(dependencies => CoursierUtils.fetchDependencies(dependencies))
168+
.andThen {
169+
case Success(jarFiles) =>
170+
jarFiles.foreach(jar => classloader.addURL(jar.toURI.toURL))
171+
logger info s"Dependencies for the plugin $pluginName successfully resolved and fetched if missing."
172+
case Failure(exception) =>
173+
logger warn s"Couldn't resolve and fetch dependencies for the plugin in $pluginName: $exception"
174+
}
175+
}
147176
}

src/main/scala/org/codeoverflow/chatoverflow/instance/PluginInstance.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ class PluginInstance(val instanceName: String, pluginType: PluginType) extends W
9999

100100
} else {
101101

102+
if (!areDependenciesAvailable) {
103+
logger error "Dependencies have either failed to resolve and fetch or aren't done yet."
104+
return false
105+
}
106+
102107
// This is set to false if any connector (aka input/output) is not ready.
103108
var allConnectorsReady = true
104109

@@ -224,6 +229,16 @@ class PluginInstance(val instanceName: String, pluginType: PluginType) extends W
224229
}
225230
}
226231

232+
/**
233+
* Returns whether all dependencies are resolved and fetched which is required for the plugin to start.
234+
*
235+
* @return true if all dependencies are available, false if it has failed or aren't downloaded yet.
236+
*/
237+
def areDependenciesAvailable: Boolean = {
238+
val opt = pluginType.getDependencyFuture.value
239+
opt.isDefined && opt.get.isSuccess
240+
}
241+
227242
/**
228243
* Returns if the plugin is currently executed (the thread is running)
229244
*

0 commit comments

Comments
 (0)