Developing a RSocket Server with Spring Boot: From Zero to AWS EC2 deployment
by Henry Brown
In this article I would like to show you how simple it is to create a RSocket server with Spring Boot and deploy the server to the world on an AWS EC2 instance. While I assume that if you are reading this, you already have at least a basic idea of what RSocket is, I want to give you the one-liner from the website in case you don’t.
-
RSocket is a binary protocol for use on byte stream transports such as TCP, WebSockets, and Aeron.
-
RSocket provides a protocol for Reactive Streams semantics between client-server, and server-server communication.
Okay, so that was 2 lines. But the gist of it is, that RSocket is a great way to have your services communicate with one
another in a manner that is often much more efficient than a protocol such as http
.
What makes this even better, is that if you have experience using Spring MVC then you will feel right at home. So let’s get started.
As with most things Spring Boot, this project begins over at: Spring Initializr.
The main dependency we need is in the Messaging section and is RSocket
itself. If you want to follow along,
you can grab the entire project or just the gradle build from my Github repository.
Defining a Controller
As a simple example, we will build a contact search application where you can find a contact based on a name, mobile number or email address. We won’t build anything to onerous, just enough to get a feeling for how simple the process of building an RSocket server is.
To start off, we need to build a controller class. This class will be responsible for accepting a request and returning the response.
To do that, we simply annotate our class with the @Controller
annotation. As with Spring MVC, we also need to define a
path at which our service can be reached. With Spring MVC, that annotation is: @RequestMapping
. For RSocket, we use the
@MessageMapping
annotation for the same purpose. This gives us a controller which should look very familiar to anyone
who has developed a Spring MVC controller:
@MessageMapping("v1.contact")
@Controller
class ContactController {
@MessageMapping("search")
fun search()
}
Now that we have the basis of a controller, we need to decide how we want our service to interact with the world.
Unlike http
which offers only a request-response model, the RSocket protocol offers 4 ways of interacting with a service:
-
fire-and-forget;
-
request-response;
-
request-stream; and
-
stream-stream.
Fire-and-Forget
This is used when you would like to send a request to the server but don’t require any response. Think of sending an event to a server where you want to let it know that something has happened but don’t really care what the server does with this information.
Request-Response
This is the interaction model used by http
. The client sends a request and gets a single response for that request. Think
of requesting some data from the server and waiting to get the response so that you can use the data.
Request-Stream
In this interaction model, the client requests some data from the server, and the server responds with multiple responses until the data is exhausted. This of requesting the weather for a location from the server, and the server continues to update you as the weather changes.
Stream-Stream
In this case, both the client and the server are exchanging information. For example, the client may be updating the server of its most recent location so that the server could send it the correct weather information based on its current location.
Searching for a contact
To make life a little interesting, let us implement the Request-Stream interaction model rather than the more common request-response model used for searching.
To do that, we will first define what our contact looks like:
data class Contact(
val id: Long? = null,
val firstName: String,
val lastName: String,
val mobileNumber: String,
val email: String,
)
Now that we have a contact, we define the data transfer object, we will use to request data from our server:
data class SearchCriteria(
val name: String? = null,
val mobile: String? = null,
val email: String? = null
)
To keep things simple, we will define a service that stores a pre-defined list of contacts and a method to search that list:
interface ContactService {
fun search(searchCriteria: SearchCriteria): Flux<Contact>
}
@Service
class ContactServiceInMemoryImpl : ContactService {
private val db: ConcurrentMap<Long, Contact> = ConcurrentHashMap()
@PostConstruct
fun populateTestData() {
db.putAll(
arrayOf(
1L to Contact(id = 1L, firstName = "Amy", lastName = "Aniston", mobileNumber = "27830000000", email = "amy@one.com"),
2L to Contact(id = 2L, firstName = "Brian", lastName = "Brown", mobileNumber = "27821111111", email = "brian.brown@two.com"),
3L to Contact(id = 3L, firstName = "Cindy", lastName = "Crawford", mobileNumber = "27813333333", email = "cc@three.com"),
4L to Contact(id = 4L, firstName = "Donald", lastName = "Drew", mobileNumber = "27804444444", email = "drew@four.co.za"),
)
)
}
override fun search(searchCriteria: SearchCriteria): Flux<Contact> = Flux.fromIterable(
db.values.filter {
with(searchCriteria) {
(name != null && it.firstName.contains(name, ignoreCase = true)) ||
(name != null && it.lastName.contains(name, ignoreCase = true)) ||
(mobile != null && it.mobileNumber.contains(mobile, ignoreCase = true)) ||
(email != null && it.email.contains(email, ignoreCase = true))
}
}
)
}
The final part of the puzzle is to update our controller to delegate the searching to our service:
@MessageMapping("v1.contact")
@Controller
class ContactController(
private val contactService: ContactService,
) {
@MessageMapping("search")
fun search(searchCriteria: SearchCriteria): Flux<Contact> = contactService.search(searchCriteria)
}
That is all there is to the coding. To turn all of this coding into a working application, there is only 1 piece of configuration
we need to give Spring Boot. Inside your application.properties
add the following configuration:
spring.rsocket.server.port=5000
This specifies the port that we will listen on for incoming requests.
Now before we deploy this application to AWS, we should at least confirm that it is working. Let’s do that in 2 ways:
- Integration tests; and
- Using a tool called rsc
Integration Testing
The complete testing code (including tests for the service) is available on Github so I will only show the controller test here:
@SpringBootTest
internal class ContactControllerTest {
lateinit var requester: RSocketRequester
@Suppress("unused") // called by JUnit
@BeforeAll
internal fun `before all`(
@Autowired builder: RSocketRequester.Builder,
@LocalRSocketServerPort port: Int,
@Autowired strategies: RSocketStrategies,
) {
requester = builder.tcp("localhost", port)
}
@Test
internal fun `should be able to find contact by name`() {
val result: List<Contact>? = requester
.route("v1.contact.search")
.data(SearchCriteria(name = "brian"))
.retrieveFlux(Contact::class.java)
.log()
.collectList()
.block()
expectThat(result)
.isNotNull()
.hasSize(1)[0]
.get { id }
.isEqualTo(2L)
}
}
Most of the heavy lifting is done by the framework itself. We can autowire in the RSocketRequester.Builder
which we need
to interact with the server. We can use the @LocalRSocketServerPort
annotation to get hold of the port the server is running on.
Once we have the RSocketRequester
we can use it to route a request to our messaging endpoint using the route
method and
specify the data payload using the data
method. Finally, we can use the collectList
method to force the streamed results into
a list to make it easier for us to check using the assertion library of our choice (Strikt in the example above).
Using rsc for interaction with our service
rsc is a very useful tool to have when building RSocket servers. It is to RSocket what curl
is to http
.
I won’t go in to too much detail here but you can test the service we have defined above by starting up the server and running the following command:
rsc --debug --stream --load src/test/resources/data/name-search.json --route v1.contact.search tcp://localhost:5000
where the name-search.json
file contains the following data:
{
"name": "brian"
}
Using the debug
flag allows you to appreciate the binary nature of the protocol but does not really demonstrate the difference
between the request-response and the request-stream interaction models since the result was only a single contact.
To better appreciate that, we can define another query that returns multiple results:
rsc --stream --load src/test/resources/data/multiple-search.json --route v1.contact.search tcp://localhost:5000 | jq
where the multiple-search.json
file contains the following data:
{
"mobile": "3",
"email": "CO.ZA"
}
Note also that I have piped the output of the request through jq to make it more readable. The output from this request should be something like:
{
"id": 1,
"firstName": "Amy",
"lastName": "Aniston",
"mobileNumber": "27830000000",
"email": "amanda@one.com"
}
{
"id": 3,
"firstName": "Cindy",
"lastName": "Crawford",
"mobileNumber": "27813333333",
"email": "cc@three.com"
}
{
"id": 4,
"firstName": "Donald",
"lastName": "Drew",
"mobileNumber": "27804444444",
"email": "drew@four.co.za"
}
Note that the results came back as 3 individual items and not as a single JSON payload (note the lack of commas between the elements).
Creating an EC2 instance for deploying to AWS
Now that we have created this game-changing application. It is time to deploy this application on AWS using EC2. For this, you will need a AWS account.
To start off, let’s build the application:
./gradlew assemble
This should build a jar file in: build/libs/
.
Head over to the AWS Management Console and log into your AWS account. Under Services
find and open the ECS
dashboard.
For this demo, we will use an On-Demand instance. On the EC2 dashboard, there should be an orange button that says Launch Instance
.
Click this button to begin the process. You should be presented with a list of AMIs to choose from.
Choose an AMI that is free tier eligible. I will choose: Amazon Linux 2 AMI (HVM), SSD Volume Type - ami-050312a64b6fd7ad9
Next, you will be asked to choose an instance type. Note that not all of them are free tier eligible.
Since I have no intentions of keeping this up and running for long, I will over-achieve and choose the t3.xlarge
.
Clicking the Review and Launch
will be enough to launch a new VM in the cloud but since I still want to do some configuration,
I will instead click: Nect: Configure Instance details
.
In Configure Instance Details
you can configure things such as the network settings and specify CPU options and what should happen on
a shutdown request. Since I am pretty happy with all the defaults here, I will just go with that and click Next: Add Storage
to move on.
Since the 8G general purpose SSD is more that I require for this demo, I will also not make any changes on Step 4: Add Storage
.
I will click “Next: Add Tags” to go to the next step.
Since this VM is not going to stick around for a long time, I will also not add any tags on Step 5: Add Tags
, opting
instead to continue by clicking on Next: Configure Security Group
.
At long last, we have the page that has caused me to click through all the menu items rather than simply launching my VM. By default, there is a single security rule defined that allows access to port 22 from anywhere. If you know which IP address you are going to be accessing this machine from, it is a good idea to change that rule to allow only access from that IP address.
Besides that, we also need to allow access for incoming traffic to be able to access our RSocket server. To do this, we must add another rule. I click the add rule and add the following Rule:
Type: Custom TCP rule
Protocol: TCP
Port Range: 5000
Source: Anywhere
Description: RSocket Server access
Once added, I click Review and Launch
. Once I am happy with all my choices, I launch it by clicking Launch
At this point, I am prompted to create a key pair with which to access this server.
If you don’t have a key-pair already, take the opportunity to create one and save it to a secure location on your machine.
If, like me, you already have one that you want to use, simply select it from the available keys.
Finally, you are ready to launch your instance.
Deploying the Application to EC2
Heading back to your EC2 dashboard, you should see the status change from Pending
to Running
. When it does you are
ready to connect to it. When you select the instance that you created, you should be able to click the connect
button
on the top of the page. This will show you a page for the commands you will need to connect to the instance.
In essence, you have to ensure that the key file you downloaded earlier has the correct permissions (chmod 400 hbrown-aws.pem
).
You can then use ssh
to connect to your instance as the example suggests, using a command similar to:
ssh -i "hbrown-aws.pem" ec2-user@ec2-13-245-77-31.af-south-1.compute.amazonaws.com
Be sure to specify the full path to your pem
file and to use the correct user name if you chose a different AMI
(for Amazon Linux 2 AMI (HVM), SSD Volume Type - ami-050312a64b6fd7ad9
, ec2-user
is correct).
You may see a message warning you about the authenticity of host and asking you if you want to continue connecting. You can select yes
.
You may then be prompted to update the packages using: sudo yum update
which you can also do. This should be very fast.
Installing a Java Runtime
The first thing we have to do to deploy and run out application is to install a Java runtime. There are many ways in which to do this, but if you are unsure, then one of the easiest ways is to run:
sudo amazon-linux-extras install java-openjdk11
This will install the Java 11 JDK onto the system.
Once this has been completed, you can verify that you have a working Java version by running:
java -version
If you see something like:
openjdk version "11.0.9" 2020-10-20 LTS
OpenJDK Runtime Environment 18.9 (build 11.0.9+11-LTS)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.9+11-LTS, mixed mode, sharing)
then you are good to go.
It is probably worth noting that you can also try: sudo yum install java-1.8.0-openjdk
if you want to install Java 8
and sudo alternatives --config java
should allow you to choose your java version in case you have installed multiple versions.
Using scp to copy our Jar file to EC2
The next step is to get our Jar file onto our VM. For this, we can use scp
. From your local machine terminal, run:
scp -i ~/.aws/hbrown-aws.pem build/libs/rsocket-contacts-demo-0.0.1.jar ec2-user@ec2-13-245-77-31.af-south-1.compute.amazonaws.com:/home/ec2-user/rsocket-contacts-demo-0.0.1.jar
where:
-
hbrown-aws.pem
is the full path to the pem file you downloaded and used to connect to via ssh -
build/libs/rsocket-contacts-demo-0.0.1.jar
is the full path to the jar file you created using./gradlew assemble
-
/home/ec2-user
is the path on the remote EC2 instance where the file should be placed in.
This should copy the file onto your remote EC2 instance. You can verify this using your ssh shell.
At this point, you should be ready to run the application using a java -jar
command.
However, there are a few extra steps that I like to take to make running and stopping my application easier.
These are:
mkdir rsocket-contacts-demo
mv rsocket-contacts-demo-0.0.1.jar rsocket-contacts-demo
cd rsocket-contacts-demo
mkdir logs
mkdir bin
touch application.properties
I create a directory and move the jar file I copied into the newly created directory. Inside this directory, I also create 2 more directories to store my application logs and startup and shutdown scripts. Although not strictly necessary in this case, I like to externalise my application configuration to make it easier to change the configuration later should I want to.
I then create the following files with this content:
vi application.properties
:
# the port the RSocket server will listen on
spring.rsocket.server.port=5000
# Logging Config
# control the level of the root logger - usually very high like error or warn at a minimum
logging.level.root=info
# set the level for the spring framework logging - usually off since we do not need to see the framework logs
logging.level.org.springframework=off
#### Our application log level
logging.level.dev.hbrown=debug
vi bin/startup.sh
#!/usr/bin/env bash
cd /home/ec2-user/rsocket-contacts-demo && nohup java -jar rsocket-contacts-demo-0.0.1.jar > logs/sysout.log 2> logs/syserr.log &
vi bin/shutdown.sh
#!/usr/bin/env bash
ps aux | grep '[r]socket-contacts-demo-0.0.1.jar' | awk '{print $2}' | xargs kill
I then make the startup and shutdown scripts executable: chmod u+x bin/startup.sh
Finally, I am ready to start up the application: ./bin/startup.sh
and ensure that everything is fine by following the logs:
less +F ./logs/sysout.log
If all has gone well, then at this point you should have a running RSocket server running on an AWS EC2 instance that is ready to process requests for the world.
To prove that, from you local machine, you can run:
rsc --stream --load src/test/resources/data/multiple-search.json --route v1.contact.search tcp://ec2-13-245-77-31.af-south-1.compute.amazonaws.com:5000 | jq
against your remote server (in case you need to, you can get your public IP or DNS from your dashboard by clicking on the instance).
this should give you the same output as it did above.
To shut the application down, run: ./bin/shutdown.sh
. To verify the application is shutdown correctly, you can re-run the rsc
command which
should now come back with an error:
Error: io.netty.channel.AbstractChannel$AnnotatedConnectException: Connection refused: ec2-13-245-77-31.af-south-1.compute.amazonaws.com/13.245.77.31:5000
So we don’t start incurring costs, we also want to shut the EC2 instance down.
To do that, we start by logging off from our ssh session: exit
Then on our EC2 dashboard we can select the instance we created and click the Instance state
drop-down on the top of the screen.
We can stop our selected instance by selecting: Stop Instance
and confirming that we want to stop it.
We should see the Instance state move from Running
to Stopping
and eventually Stopped
We can remove the instance by selecting it and then selecting Terminate Instance
under the same Instance state
menu item.
We should eventually see the status change to Terminated
and the instance will eventually be removed from our dashboard.
Conclusion
Well done on making it all the way to the end. Hopefully, I have been able to demonstrate the power of RSocket and how easy Spring Boot makes it to be able to create a RSocket Server.
Remember, you can download all the code in the proper context from my github repo: https://github.com/hgbrown/rsocket-contacts-demo and see all the action on my YouTube channel: https://youtu.be/GcBl9byna68
tags: kotlin - rsocket - aws - ec2 - testing - strikt - junit - springboot