/* * Copyright 2014-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.springframework.xd.extension.process; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.Lifecycle; import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.core.task.TaskExecutor; import org.springframework.integration.ip.tcp.serializer.AbstractByteArraySerializer; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** * Creates a process to run a shell command and communicate with it using String payloads over stdin and stdout. * * @author David Turanski * @author Gary Russell */ public class ShellCommandProcessor implements Lifecycle, InitializingBean { private volatile boolean running = false; private final ProcessBuilder processBuilder; private volatile Process process; private volatile InputStream stdout; private volatile OutputStream stdin; private boolean redirectErrorStream; private final Map<String, String> environment = new ConcurrentHashMap<>(); private volatile String workingDirectory; private volatile String charset = "UTF-8"; private final AbstractByteArraySerializer serializer; private final static Logger log = LoggerFactory.getLogger(ShellCommandProcessor.class); private final TaskExecutor taskExecutor = new SimpleAsyncTaskExecutor(); private final String command; private final Object lifecycleLock = new Object(); /** * Creates a process to invoke a shell command to send and receive messages from the processes using the process's stdin and stdout. * * @param serializer an {@link org.springframework.integration.ip.tcp.serializer.AbstractByteArraySerializer} to delimit messages * @param command the shell command with command line arguments as separate strings */ public ShellCommandProcessor(AbstractByteArraySerializer serializer, String command) { Assert.hasLength(command, "A shell command is required"); Assert.notNull(serializer, "'serializer' cannot be null"); this.command = command; ShellWordsParser shellWordsParser = new ShellWordsParser(); List<String> commandPlusArgs = shellWordsParser.parse(command); Assert.notEmpty(commandPlusArgs, "The shell command is invalid: '" + command + "'"); this.serializer = serializer; processBuilder = new ProcessBuilder(commandPlusArgs); } /** * Start the process. */ @Override public void start() { synchronized (lifecycleLock) { if (!isRunning()) { if (log.isDebugEnabled()) { log.debug("starting process. Command = [" + command + "]"); } try { process = processBuilder.start(); } catch (IOException e) { log.error(e.getMessage(), e); throw new RuntimeException(e.getMessage(), e); } if (!processBuilder.redirectErrorStream()) { monitorErrorStream(); } monitorProcess(); stdout = process.getInputStream(); stdin = process.getOutputStream(); running = true; if (log.isDebugEnabled()) { log.debug("process started. Command = [" + command + "]"); } } } } /** * Receive data from the process. * @return any available data from stdout */ public synchronized String receive() { Assert.isTrue(isRunning(), "Shell process is not started."); String data; try { byte[] buffer = this.serializer.deserialize(this.stdout); data = new String(buffer, this.charset); } catch (IOException e) { throw new RuntimeException(e.getMessage(), e); } return data.trim(); } /** * Send data as a String to stdin. * @param data the data */ public synchronized void send(String data) { Assert.isTrue(isRunning(), "Shell process is not started."); try { this.serializer.serialize(data.getBytes(this.charset), this.stdin); this.stdin.flush(); } catch (IOException e) { log.error(e.getMessage(), e); throw new RuntimeException(e.getMessage(), e); } } /** * Send and receive data in request/response fashion. * @param data the input * @return the output */ public synchronized String sendAndReceive(String data) { Assert.isTrue(isRunning(), "Shell process is not started"); send(data); return receive(); } /** * Stop the process and close streams. */ @Override public void stop() { synchronized (lifecycleLock) { if (isRunning()) { process.destroy(); running = false; } } } @Override public boolean isRunning() { return running; } /** * Set to true to redirect stderr to stdout. * @param redirectErrorStream */ public void setRedirectErrorStream(boolean redirectErrorStream) { this.redirectErrorStream = redirectErrorStream; } /** * A map containing environment variables to add to the process environment. * @param environment */ public void setEnvironment(Map<String, String> environment) { this.environment.putAll(environment); } /** * Set the process working directory * @param workingDirectory the file path */ public void setWorkingDirectory(String workingDirectory) { this.workingDirectory = workingDirectory; } /** * Set the charset name for String encoding. Default is UTF-8 * @param charset the charset name */ public void setCharset(String charset) { this.charset = charset;//NOSONAR } @Override public void afterPropertiesSet() throws Exception { processBuilder.redirectErrorStream(redirectErrorStream); if (StringUtils.hasLength(workingDirectory)) { processBuilder.directory(new File(workingDirectory)); } if (!CollectionUtils.isEmpty(environment)) { processBuilder.environment().putAll(environment); } } /** * Runs a thread that waits for the Process result. */ private void monitorProcess() { taskExecutor.execute(new Runnable() { @Override public void run() { Process process = ShellCommandProcessor.this.process; if (process == null) { if (log.isDebugEnabled()) { log.debug("Process destroyed before starting process monitor"); } return; } int result; try { if (log.isDebugEnabled()) { log.debug("Monitoring process '" + command + "'"); } result = process.waitFor(); if (log.isInfoEnabled()) { log.info("Process '" + command + "' terminated with value " + result); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("Interrupted - stopping adapter", e); stop(); } finally { process.destroy(); } } }); } /** * Runs a thread that reads stderr */ private void monitorErrorStream() { Process process = this.process; if (process == null) { if (log.isDebugEnabled()) { log.debug("Process destroyed before starting stderr reader"); } return; } final BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream())); taskExecutor.execute(new Runnable() { @Override public void run() { String statusMessage; if (log.isDebugEnabled()) { log.debug("Reading stderr"); } try { while ((statusMessage = errorReader.readLine()) != null) { log.error(statusMessage); } } catch (IOException e) { if (log.isDebugEnabled()) { log.debug("Exception on process error reader", e); } } finally { try { errorReader.close(); } catch (IOException e) { if (log.isDebugEnabled()) { log.debug("Exception while closing stderr", e); } } } } }); } }