package com.lucidworks.storm;
import groovy.util.ConfigSlurper;
import groovy.util.ConfigObject;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.GnuParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.OptionBuilder;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.log4j.Logger;
import backtype.storm.Config;
import backtype.storm.LocalCluster;
import backtype.storm.StormSubmitter;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.core.env.MapPropertySource;
/**
* Serves as a common framework for running Storm topologies. Specifically, this driver
* provides three key benefits for Storm topologies:
* <p/>
* 1) Standard configuration setup with command-line options and Config.groovy
* 2) Allows us to execute multiple Storm topologies from a single JAR file
* 3) Separates the running of a topology (a framework task) from the building
* of a topology definition (a custom task)
*/
public class StreamingApp {
private static final Logger appLog = Logger.getLogger(StreamingApp.class);
private static final Map<String, ApplicationContext> globalSpringContexts = new HashMap<String, ApplicationContext>();
public static enum ENV {
development, test, staging, production
}
/**
* Returns the global singleton to the Spring framework ApplicationContext per Storm Topology per JVM.
*/
public static final ApplicationContext spring(Map stormConf) {
String stormId = (String) stormConf.get("storm.id");
String springXml = (String) stormConf.get("springXml");
if (springXml == null) springXml = "storm-solr-spring.xml";
// one per JVM
ApplicationContext spring = null;
synchronized (StreamingApp.class) {
spring = globalSpringContexts.get(stormId);
if (spring == null) {
InputStream res = StreamingApp.class.getClassLoader().getResourceAsStream(springXml);
if (res != null) {
appLog.info("Classpath resource '" + springXml + "' FOUND by classloader: " + StreamingApp.class.getClassLoader());
} else {
appLog.warn("Classpath resource '" + springXml + "' not found by classloader: " + StreamingApp.class.getClassLoader());
}
ClassPathXmlApplicationContext ctxt = new ClassPathXmlApplicationContext(new String[]{springXml}, false /* don't refresh yet */);
// inject the Spring closure from the Storm config map into the Spring context for property resolution
if (ctxt instanceof ConfigurableApplicationContext) {
Map<String, Object> springProps = new HashMap<String, Object>();
for (Object key : stormConf.keySet()) {
if (!(key instanceof String))
continue;
Object valu = stormConf.get(key);
if (valu == null)
continue;
String propId = (String) key;
if (propId.startsWith("spring.")) {
springProps.put(propId.substring(7), valu);
}
}
if (!springProps.isEmpty())
((ConfigurableApplicationContext) ctxt).getEnvironment()
.getPropertySources().addFirst(new MapPropertySource("STORM_CONF", springProps));
}
ctxt.refresh();
spring = ctxt;
globalSpringContexts.put(stormId, spring);
}
}
return spring;
}
private static final DateFormat TIMESTAMP_FORMATTER =
DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG);
public static final String timestamp() {
return timestamp(System.currentTimeMillis());
}
public static final String timestamp(long timeMs) {
return TIMESTAMP_FORMATTER.format(new Date(timeMs));
}
public static final Throwable getRootCause(Throwable thr) {
if (thr == null)
return null;
Throwable rootCause = thr;
Throwable cause = thr;
while ((cause = cause.getCause()) != null)
rootCause = cause;
return rootCause;
}
/**
* Main entry point to the Client execution environment.
*
* @param args Command-line args parsed using Commons CLI (GnuParser)
*/
public static void main(String[] args) throws Exception {
// the first argument must be the name of the topo to execute
if (args == null || args.length < 1 || args[0].startsWith("-")) {
System.err.println("Must specify a StormTopologyFactory implementation class name!\n" +
"Example:\n\tstorm jar lw-storm.jar com.lucidworks.storm.StreamingApp StormTopologyFactoryClassName [options]");
System.exit(1);
}
String topoClassName = args[0];
if (topoClassName.indexOf(".") == -1) {
topoClassName = "com.lucidworks.storm." + topoClassName;
} else if (!topoClassName.startsWith("com.lucidworks.storm.")) {
topoClassName = "com.lucidworks.storm." + topoClassName;
}
ClassLoader cl = StreamingApp.class.getClassLoader();
Class<StormTopologyFactory> topoClass =
(Class<StormTopologyFactory>) cl.loadClass(topoClassName);
StormTopologyFactory topologyFactory = topoClass.newInstance();
// all but the first arg is treated as args to the topo
String[] actionArgs = new String[args.length - 1];
for (int i = 0; i < actionArgs.length; i++) {
actionArgs[i] = args[i + 1];
}
// run the topology through this driver
(new StreamingApp(topologyFactory, actionArgs)).run();
}
/**
* Parses the command-line arguments passed by the user.
*
* @param topo
* @param args
* @return CommandLine The Apache Commons CLI object.
*/
public static CommandLine processCommandLineArgs(StormTopologyFactory topo, String[] args) {
Options options = new Options();
options.addOption("w", "localRunSecs", true, "Number of seconds to sleep after submitting topology in development mode; default: 30");
options.addOption("e", "env", true, "Environment; default is 'development'");
options.addOption("h", "help", false, "Print this message");
options.addOption("v", "verbose", false, "Generate verbose log messages");
options.addOption("c", "config", true, "Optional path to Config.groovy");
CommandLine cli = null;
try {
cli = (new GnuParser()).parse(options, args, true);
} catch (ParseException exp) {
boolean hasHelpArg = false;
if (args != null && args.length > 0) {
for (int z = 0; z < args.length; z++) {
if ("-h".equals(args[z]) || "-help".equals(args[z])) {
hasHelpArg = true;
break;
}
}
}
if (!hasHelpArg) {
System.err.println("Failed to parse command-line arguments due to: " + exp.getMessage());
}
HelpFormatter formatter = new HelpFormatter();
formatter.printHelp(topo.getClass().getSimpleName(), options);
System.exit(1);
}
if (cli.hasOption("help")) {
HelpFormatter formatter = new HelpFormatter();
formatter.printHelp(topo.getClass().getSimpleName(), options);
System.exit(0);
}
return cli;
}
public static Option buildOption(String argName, String shortDescription) {
return buildOption(argName, shortDescription, null); // no default, required
}
@SuppressWarnings("static-access")
public static Option buildOption(String argName, String shortDescription, String defaultValue) {
if (defaultValue != null)
shortDescription += (" Default is " + defaultValue);
return OptionBuilder.hasArg().isRequired((defaultValue == null)).withDescription(shortDescription).create(argName);
}
protected CommandLine cli;
protected StormTopologyFactory topo;
protected Logger log;
protected Config stormConf;
protected List<Closeable> closeables;
protected ENV env;
public StreamingApp(StormTopologyFactory topo, String[] args) throws IOException {
this.topo = topo;
log = Logger.getLogger(topo.getClass());
cli = processCommandLineArgs(topo, args);
env = ENV.valueOf(cli.getOptionValue("env", "development"));
stormConf = initStormConfig(cli);
stormConf.setDebug(cli.hasOption("verbose"));
}
public ENV getEnv() {
return env;
}
public int parallelism(String component) {
Object val = null;
Object componentProps = (component != null) ? stormConf.get(component) : null;
if (componentProps != null && componentProps instanceof Map) {
Map map = (Map) componentProps;
val = map.get("parallelism");
} else {
val = stormConf.get(component + ".parallelism"); // flattened
}
return (val != null && val instanceof Number) ? ((Number) val).intValue() : 1;
}
public int tickRate(String component) {
Object val = null;
Object componentProps = (component != null) ? stormConf.get(component) : null;
if (componentProps != null && componentProps instanceof Map) {
Map map = (Map) componentProps;
val = map.get("tickRate");
} else {
val = stormConf.get(component + ".tickRate");
}
return (val != null && val instanceof Number) ? ((Number) val).intValue() : 0;
}
/**
* Builds and runs a StormTopology in the configured environment (development|staging|production)
*/
public void run() throws Exception {
log.info(String.format("Running %s in %s mode.", topo.getName(), env));
String topologyName = topo.getName();
if (ENV.development == env) {
int localRunSecs = Integer.parseInt(cli.getOptionValue("localRunSecs", "30"));
try {
LocalCluster cluster = new LocalCluster();
stormConf.put("topology.tick.tuple.freq.secs", 5);
cluster.submitTopology(topologyName, stormConf, topo.build(this));
log.info("Submitted " + topologyName + " to LocalCluster at " + timestamp() + " ... sleeping for " +
localRunSecs + " seconds before terminating.");
try {
Thread.sleep(localRunSecs * 1000);
} catch (InterruptedException ie) {
Thread.interrupted();
}
log.info("Killing " + topologyName);
cluster.killTopology(topologyName);
cluster.shutdown();
log.info("Shut down LocalCluster at " + timestamp());
} catch (Exception exc) {
Throwable rootCause = getRootCause(exc);
log.error("Storm topology " + topologyName + " failed due to: " + rootCause, rootCause);
throw exc;
} finally {
cleanup();
}
System.exit(0);
} else {
StormSubmitter.submitTopology(topologyName, stormConf, topo.build(this));
}
}
/**
* Provide the initialized Storm Config
*/
public Config getStormConfig() {
return stormConf;
}
/**
* Bootstrap a Storm Config object from a Groovy Config class.
*/
@SuppressWarnings("unchecked")
protected Config initStormConfig(CommandLine cl) throws IOException {
String env = cl.getOptionValue("env", "development");
File configFile = getOptionalConfigFile(cl);
stormConf = getConfig(env, configFile);
Map<String, String> otherArgs = getUnrecognizedArgs(cl.getArgs());
if (otherArgs != null && !otherArgs.isEmpty()) {
log.info("Adding overrides to Storm config: " + otherArgs);
stormConf.putAll(otherArgs);
}
return stormConf;
}
/**
* Returns a config file that's passed in as a command line arg
*
* @param cl
* @return
* @throws FileNotFoundException
*/
private File getOptionalConfigFile(CommandLine cl) throws FileNotFoundException {
File configFile = null;
String config = cl.getOptionValue("config", null);
if (config != null) {
configFile = new File(config);
if (!configFile.exists()) {
throw new FileNotFoundException(configFile.getAbsolutePath());
}
}
return configFile;
}
public static Config getConfig(String env) throws IOException {
return getConfig(env, null);
}
public static Config getConfig(String env, File config) throws IOException {
ConfigObject configObject = new ConfigSlurper(env).parse(readGroovyConfigScript(config));
Config stormConf = new Config();
Map flatten = configObject.flatten();
stormConf.putAll(flatten);
Map<String, Class> dataTypes = new HashMap<String, Class>();
dataTypes.put("topology.workers", Integer.class);
dataTypes.put("topology.acker.executors", Integer.class);
dataTypes.put("topology.message.timeout.secs", Integer.class);
dataTypes.put("topology.max.task.parallelism", Integer.class);
dataTypes.put("topology.stats.sample.rate", Double.class);
// this will convert built in properties as storm uses old school properties
for (Field field : stormConf.getClass().getFields()) {
if (Modifier.isStatic(field.getModifiers())
&& Modifier.isPublic(field.getModifiers())) {
String property = field.getName().toLowerCase().replace('_', '.');
if (property.startsWith("java.")) {
// don't mess with Java system properties here
continue;
}
Object override = flatten.get(property);
if (override != null) {
stormConf.put(property, override);
System.out.println("Overrode property '" + property + "' with value [" + override + "] from Config.groovy of type " + override.getClass().getName());
}
String system = System.getProperty(property, null);
if (system != null) {
if (dataTypes.containsKey(property)) {
Class aClass = dataTypes.get(property);
try {
Method valueOf = aClass.getMethod("valueOf", String.class);
stormConf.put(property, valueOf.invoke(aClass, system));
System.out.println("Overrode property '" + property + "' with value [" + stormConf.get(property) + "] from -D System property of type " + aClass.getName());
} catch (Exception e) {
throw new RuntimeException(e.getMessage(), e);
}
} else {
stormConf.put(property, system);
System.out.println("Overrode property '" + property + "' with String value [" + system + "] from -D System property");
}
}
}
}
return stormConf;
}
/**
* Build a Map of any unrecognized command-line args of format: --foo=bar
*/
protected Map<String, String> getUnrecognizedArgs(String[] addlArgs) {
Map<String, String> unrecogArgs = new HashMap<String, String>(20);
if (addlArgs != null) {
for (int a = 0; a < addlArgs.length; a++) {
String tmp = addlArgs[a].trim();
if (tmp.startsWith("--")) {
tmp = tmp.substring(2);
} else if (tmp.startsWith("-")) {
tmp = tmp.substring(1);
}
if (tmp.indexOf("=") != -1) {
String[] pair = tmp.split("=");
String name = pair[0].trim();
String value = pair[1].trim();
if (name.length() > 0 && value.length() > 0) {
unrecogArgs.put(name, value);
}
} else {
// is there another to read?
if (addlArgs.length > (a + 1)) {
unrecogArgs.put(tmp, addlArgs[a + 1].trim());
++a; // skip the next value in the array
} else {
log.warn("Skipped dangling command-line arg: " + addlArgs[a]);
}
}
}
}
return unrecogArgs;
}
/**
* Reads Config.groovy from the classpath.
*/
private static String readGroovyConfigScript(File file) throws IOException {
InputStream groovyConfigIn;
if (file != null) {
groovyConfigIn = new FileInputStream(file);
} else {
groovyConfigIn = StreamingApp.class.getResourceAsStream("/Config.groovy");
}
if (groovyConfigIn == null)
throw new FileNotFoundException("Missing classpath resource /Config.groovy");
StringBuilder sb = new StringBuilder();
InputStreamReader reader = null;
char[] ach = new char[1024];
int r = 0;
try {
reader = new InputStreamReader(groovyConfigIn, "UTF-8");
while ((r = reader.read(ach, 0, ach.length)) > 0)
sb.append(ach, 0, r);
} finally {
if (reader != null) {
try {
reader.close();
} catch (Exception ignore) {
}
}
}
return sb.toString();
}
/**
* Remember to close a Closeable resource after the app finishes.
*
* @param closeable
*/
public void rememberCloseable(Closeable closeable) {
if (closeables == null) {
closeables = new ArrayList<Closeable>();
}
closeables.add(closeable);
}
/**
* Get access to the parsed command-line options.
*
* @return CommandLine
*/
public CommandLine getCommandLine() {
return cli;
}
/**
* Closes all resources held by this driver.
*/
protected void cleanup() {
if (closeables != null) {
for (Closeable next : closeables) {
try {
next.close();
} catch (Exception nothingWeCanDo) {
}
}
}
}
/**
* Reads a File into a byte[].
*
* @param file
* @return byte[] The bytes in the file.
* @throws IOException
*/
public byte[] readFile(File file) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
int r = 0;
byte[] aby = new byte[256];
FileInputStream fis = null;
try {
fis = new FileInputStream(file);
while ((r = fis.read(aby)) != -1) {
bytes.write(aby, 0, r);
}
} finally {
if (fis != null) {
try {
fis.close();
} catch (Exception zzz) {
}
}
}
return bytes.toByteArray();
}
/**
* Saves bytes to a File.
*
* @param file
* @param bytes
* @throws IOException
*/
public void saveToFile(File file, byte[] bytes) throws IOException {
FileOutputStream fos = null;
try {
fos = new FileOutputStream(file);
fos.write(bytes);
fos.flush();
} finally {
if (fos != null) {
try {
fos.close();
} catch (Exception zzz) {
}
}
}
}
public Writer openWriter(String arg) throws IOException {
File outputFile = new File(cli.getOptionValue(arg));
return new OutputStreamWriter(new FileOutputStream(outputFile), "UTF-8");
}
public BufferedReader openReader(String arg) throws IOException {
BufferedReader reader = null;
String path = cli.getOptionValue(arg);
if (path != null) {
reader = new BufferedReader(new InputStreamReader(new FileInputStream(path), "UTF-8"));
rememberCloseable(reader);
}
return reader;
}
public Logger getLog() {
return log;
}
private static Set<String> findClasses(String path, String packageName) throws Exception {
Set<String> classes = new TreeSet<String>();
if (path.startsWith("file:") && path.contains("!")) {
String[] split = path.split("!");
URL jar = new URL(split[0]);
ZipInputStream zip = new ZipInputStream(jar.openStream());
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (entry.getName().endsWith(".class")) {
String className = entry.getName().replaceAll("[$].*", "")
.replaceAll("[.]class", "").replace('/', '.');
if (className.startsWith(packageName)) {
classes.add(className);
}
}
}
} else {
// when running unit tests
File dir = new File(path);
if (dir.isDirectory()) {
String packagePath = packageName.replace('.', File.separatorChar);
if (dir.getAbsolutePath().endsWith(packagePath)) {
for (File file : dir.listFiles()) {
if (file.getName().endsWith(".class")) {
String className = file.getName().replaceAll("[$].*", "")
.replaceAll("[.]class", "").replace('/', '.');
if (className.indexOf("$") == -1)
classes.add(packageName + "." + className);
}
}
}
}
}
return classes;
}
}