DSL is quite a popular technique to help a developer to define program or business logic in a more readable and concise way compared to using the general-purpose language features. There are two types of DSLs: internal and external. Internal (or embedded) DSLs exploit host language features to build a fluent library API that makes certain concepts more readable in the host language itself. External DSLs call for a specifically designed language that is not bound to host language and usually requires a separately developed DSL parser.
With the help of Groovy, you can create both DSL types with ease. In this recipe, we will define an internal DSL for executing remote SSH commands.
We are going to use the JSch
library (http://www.jcraft.com/jsch/), which is used by many other Java libraries that require SSH connectivity.
The following Gradle script (see the Integrating Groovy into the build process using Gradle recipe in Chapter 2, Using Groovy Ecosystem) will help us to build the source code of this recipe:
apply plugin: 'groovy' repositories { mavenCentral() } dependencies { compile localGroovy() compile 'ch.qos.logback:logback-classic:1.+' compile 'org.slf4j:slf4j-api:1.7.4' compile 'commons-io:commons-io:1.4' compile 'com.jcraft:jsch:0.1.49' testCompile 'junit:junit:4.+' }
For more information on Gradle, you can take a look into the Integrating Groovy into the build process using Gradle recipe in Chapter 2, Using Groovy Ecosystem.
Let's start by creating Groovy classes needed to define elements of our DSL:
class CommandOutput { int exitStatus String output Throwable exception CommandOutput(int exitStatus, String output) { this.exitStatus = exitStatus this.output = output } CommandOutput(int exitStatus, String output, Throwable exception) { this.exitStatus = exitStatus this.output = output this.exception = exception } }
import java.util.regex.Pattern class RemoteSessionData { static final int DEFAULT_SSH_PORT = 22 static final Pattern SSH_URL = ~/^(([^:@]+)(:([^@]+))?@)?([^:]+)(:(d+))?$/ String host = null int port = DEFAULT_SSH_PORT String username = null String password = null def setUrl(String url) { def matcher = SSH_URL.matcher(url) if (matcher.matches()) { host = matcher.group(5) port = matcher.group(7).toInteger() username = matcher.group(2) password = matcher.group(4) } else { throw new RuntimeException("Unknown URL format: $url") } } }
connect
, disconnect
, reconnect
, and exec
:import org.apache.commons.io.output.CloseShieldOutputStream import org.apache.commons.io.output.TeeOutputStream import com.jcraft.jsch.Channel import com.jcraft.jsch.ChannelExec import com.jcraft.jsch.JSch import com.jcraft.jsch.JSchException import com.jcraft.jsch.Session class RemoteSession extends RemoteSessionData { private Session session = null private final JSch jsch = null RemoteSession(JSch jsch) { this.jsch = jsch } def connect() { if (session == null || !session.connected) { disconnect() if (host == null) { throw new RuntimeException('Host is required.') } if (username == null) { throw new RuntimeException('Username is required.') } if (password == null) { throw new RuntimeException('Password is required.') } session = jsch.getSession(username, host, port) session.password = password println ">>> Connecting to $host" session.connect() } } def disconnect() { if (session?.connected) { try { session.disconnect() } catch (Exception e) { } finally { println "<<< Disconnected from $host" } } } def reconnect() { disconnect() connect() } CommandOutput exec(String cmd) { connect() catchExceptions { awaitTermination(executeCommand(cmd)) } } private ChannelData executeCommand(String cmd) { println "> $cmd" def channel = session.openChannel('exec') def savedOutput = new ByteArrayOutputStream() def systemOutput = new CloseShieldOutputStream(System.out) def output = new TeeOutputStream(savedOutput, systemOutput) channel.command = cmd channel.outputStream = output channel.extOutputStream = output channel.setPty(true) channel.connect() new ChannelData(channel: channel, output: savedOutput) } class ChannelData { ByteArrayOutputStream output Channel channel } private CommandOutput awaitTermination( ChannelData channelData) { Channel channel = channelData.channel try { def thread = null thread = new Thread() { void run() { while (!channel.isClosed()) { if (thread == null) { return } try { sleep(1000) } catch (Exception e) { // ignored } } } } thread.start() thread.join(0) if (thread.isAlive()) { thread = null return failWithTimeout() } else { int ec = channel.exitStatus return new CommandOutput( ec, channelData.output.toString() ) } } finally { channel.disconnect() } } private CommandOutput catchExceptions(Closure cl) { try { return cl() } catch (JSchException e) { return failWithException(e) } } private CommandOutput failWithTimeout() { println 'Session timeout!' new CommandOutput(-1, 'Session timeout!') } private CommandOutput failWithException(Throwable e) { println "Caught exception: ${e.message}" new CommandOutput(-1, e.message, e) } }
engine
class:import com.jcraft.jsch.JSch class SshDslEngine { private final JSch jsch private RemoteSession delegate SshDslEngine() { JSch.setConfig('HashKnownHosts', 'yes') JSch.setConfig('StrictHostKeyChecking', 'no') this.jsch = new JSch() } def remoteSession(Closure cl) { if (cl != null) { delegate = new RemoteSession(jsch) cl.delegate = delegate cl.resolveStrategy = Closure.DELEGATE_FIRST cl() if (delegate?.session?.connected) { try { delegate.session.disconnect() } catch (Exception e) { } } } } }
new SshDslEngine().remoteSession { url = 'root:secret123@localhost:3223' exec 'yum --assumeyes install groovy' exec 'groovy -e "println 'Hello, Remote!'"' }
In the previous code example, we construct a DSL engine object and call the remoteSession
method to which we pass a closure with our DSL code. The snippet, after connecting to a remote server, installs Groovy through the Yum package manager and runs a simple Groovy script through the command line, which just prints the message: Hello, Remote
!.
The principal stratagems employed by Groovy for the definition of a DSL are the closure delegates. A closure delegate is basically an object that is dynamically queried for methods/fields from within the closure's code. By default, delegate equals to the object that contains the closure; for example, enclosing a class or surrounding a closure.
As you can notice in the remoteSession
method, the closure input parameter is given a delegate object (cl.delegate = delegate
) that represents the RemoteSession
implementation. Also, the closure's resolveStrategy
is set to DELEGATE_FIRST
; this means the closure's code will first call a method from the given RemoteSession
instance, and only after that will it call methods from other available contexts. This is the reason why, in the DSL usage example, the closure that we pass to the remoteSession
method has access to setUrl
and exec
methods.
The remote connection is automatically started upon the first command execution; but the connection logic can be controlled explicitly since it is defined by our DSL. Additionally, normal Groovy code can be added around methods of the RemoteSession
class, like the following code snippet:
new SshDslEngine().remoteSession { url = 'root:secret123@localhost:3223' connect() if (exec('rpm -qa | grep groovy').exitStatus != 0) { exec 'yum --assumeyes install groovy' } disconnect() connect() def result = exec 'groovy -e "println 'Hello, Remote!'"' if (!result.contains('Hello')) { throw new RuntimeException('Command failed!') } disconnect() }
JSch
library, which can be found at http://www.jcraft.com/jsch/.