I have an ssh manager to execute (bash) scripts on a server. It contains a commandWithContinousRead(String command, Consumer<String> consumer)
. Whenever an echo is called in the bash script it is consumed by the consumer. I want to extend this with Spring Boot and an HTTP call. When a client sends a request, the server streams the data when it's ready from a bash script and the client can print it out.
I know Server-Sent Events, however, I feel like that is mostly for events and usually uses multiple resources on an API.
Additionally, I tried searching for streaming topics, but had no success. I did find StreamingResponseBody
from Spring, but it collects all the data and then sends it all at once.
I used Postman for testing, maybe it cannot handle streaming? However, how do I test this?
Example:
#/bin/bash
# Scriptname: stream-this.sh
echo "Starting line"
sleep 4
echo "Middle line"
sleep 4
echo "End line"
Request with commandWithContinousRead
, but prints everything at once after eight seconds.
@RequestMapping(value = "/stream-this", method = RequestMethod.POST,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ???? streamScript() {
StreamingResponseBody stream = out -> {
sshManager.commandWithContinousRead("bash /scripts/stream-this.sh", echo -> {
try {
byte[] bytes = echo.getBytes(StandardCharsets.UTF_8);
out.write(bytes);
System.out.println(echo);
} catch (IOException e) {
e.printStackTrace();
}
});
};
return new ResponseEntity<>(stream, HttpStatus.OK);
}
Implementation of commandWithContinousRead
function.
public void commandWithContinousRead(String command, Consumer<String> consumer) {
SSHClient client = buildClient();
try (Session session = client.startSession()) {
Session.Command cmd = session.exec(command);
BufferedReader br = new BufferedReader(new InputStreamReader(cmd.getInputStream(), StandardCharsets.UTF_8));
String line;
while ((line = br.readLine()) != null) {
consumer.accept(line);
}
br.close();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
client.disconnect();
} catch (IOException e) {
e.printStackTrace();
}
}
}
CodePudding user response:
Now that you have posted the commandWithContinuousRead
method, everything looks correct. Also, you've just now stated that you're testing with Postman, and that's definitely a problem -- postman doesn't support streaming responses
https://github.com/postmanlabs/postman-app-support/issues/5040
It's always a good idea to programmatically unit and integration test your code. A simple unit test doesn't even need to use Spring, or a real SSH connection (run the bash script local to the test). The unit test would just be testing the logic of your Consumer and would let you know that the reading of the output, and the bash script itself aren't blocking. Ideally, you would use junit, but here's a simple test class that I put together that shows what I mean.
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.IOException;
import java.lang.Process;
import java.nio.charset.StandardCharsets;
import java.util.function.Consumer;
public class Test {
// This would be a @Test instead of a main
public static void main(String... args) {
commandWithContinousRead("bash stream-this.sh", echo -> {
byte[] bytes = echo.getBytes(StandardCharsets.UTF_8);
// assert statements go here
System.out.println("In main -- " echo);
});
}
public static void commandWithContinousRead(String command, Consumer<String> consumer) {
try {
Process process = Runtime.getRuntime().exec(command);
BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = br.readLine()) != null) {
consumer.accept(line);
}
br.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
An integration test would actually setup Spring, and would go through the endpoint, thereby testing in the same manner that the client/browser would. Commonly, this is done using @WebMvcTest
and mockMvc
async. You could choose to either mock the SSH client, or to have a server setup explicitly so your actual SSH client can connect to it. (The second option would expose/eliminate issues related to the ssh connection). This kind of test would expose issues with the spring setup/streaming response. You would need to set an artificial timeout on your mock mvc after say, 5 seconds and using a new mock mvc, after 9 seconds That would allow you to see that after 5 seconds, you've received the first echo, and after 9, you have the whole expected response. A good starting point for you would be to look at https://www.tabnine.com/code/java/methods/org.springframework.test.web.servlet.result.RequestResultMatchers/asyncStarted
Having passed those two levels of tests, then you would begin to suspect the client, which in this case, is Postman. If possible, try to use the actual browser(s) or clients that will be running your code. It may turn out that streaming might not be an option for you.
Please post the implementation of commandWithContinousRead
It could be a fundamental problem where the script that is echoing and sleeping is running on the same thread as the code that is supposed to read the echo and print them out. I.e., you're blocking while you wait for the bash script itself to run which would explain the 8 second delay before getting any output. Also, what type does commandWithContinousRead
return? Depending, on how you're "reading" the echos in that method, you could be blocking there too. It's hard to say with 100% certainty without seeing the code for commandWithContinousRead
.
Your return type will be a ResponseEntity<StreamingResponseBody>
(to fill in the ????)
CodePudding user response:
Okay, I came up with a solution that worked. As Pickled Brain mentioned, the main problem was Postman not working with streaming. Also, I went back to try SSE in a single call and I did by running the bash script in another thread. Additionally, I created an SSE client in Nodejs for testing purposes and it worked flawlessly.
Function to run the script, and place it in another thread.
private SseEmitter runScript() {
SseEmitter emitter = new SseEmitter(-1L); // -1L = no timeout
ExecutorService sseMvcExecutor = Executors.newSingleThreadExecutor();
sseMvcExecutor.execute(() -> {
try {
shellManager.commandWithContinousRead("bash scriptname"), s -> {
SseEmitter.SseEventBuilder event = SseEmitter.event().name("message").data(s);
try {
emitter.send(event);
System.out.println(s);
} catch (IOException e) {
e.printStackTrace();
}
});
emitter.send(SseEmitter.event().name("close").data(""));
} catch (IOException e) {
e.printStackTrace();
}
emitter.complete();
});
return emitter;
}
SSE Client:
const EventSource = require('eventsource'); // npm install eventsource
const url = 'yoururl';
var es = new EventSource(url);
es.onopen = function(ev) {
console.log("OPEN");
console.log(ev);
};
es.onmessage = function(ev) {
console.log("MESSAGE");
console.log(ev.data);
};
es.addEventListener('close', function() {
es.close();
console.log('closing!');
});
es.onerror = function(ev) {
console.log("ERROR");
console.log(ev);
es.close();
};
process.on('SIGINT', () => {
es.close();
console.log(es.CLOSED);
});