Sometimes we want the server-side application to notify the browser (client) of changes by sending messages to a webpage. That is not possible in traditional web applications. In web apps, the client has to initiate communication by sending a request to the server. Then wait for the response from the server. However, Server-Sent Events (SSE) is the reverse process. In Server-Sent Events (SSE) the server keeps a (unidirectional) HTTP connection open to the client which can push messages to the webpage at any moment. In this article, we demonstrate how to implement Server-Sent Events using Spring MVC SseEmitter.
Defining a use case
Let’s dive into the SSE topic by imagining a simple scenario. For this purpose, we reuse the Movie example we provided in the Getting started with Spring Data Redis with Kotlin article.
As demonstrated in that post, there’s an endpoint in which a user can get a list of movies. Now let’s assume another user (AKA subscriber) wants to be notified when a new movie is added to (database) Redis. In other words, when a movie is added by either the user himself or any other users, the system should notify all interested parties. For a better understanding, have a look at the diagram below:
Implementing Server-Sent Events (SSE) with Spring MVC
To achieve our objective, we should provide a method of subscription. That is feasible by defining a new REST endpoint. However, the endpoint is not the usual REST endpoint. Instead of returning a string, JSON, or anything else, it returns an instance of Spring MVC SSEmitter
and a JSON stream. Hence, we have to ensure to include spring-boot-starter-web
in the project dependency. For Maven projects, it looks like this:
<dependencies>
....
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
....
</dependencies>
Now we need to create a new endpoint. For our example, we create an endpoint under v1/movies/subscription
inside of MovieController.kt
,
package com.madadipouya.redis.springdata.example.controller
import com.madadipouya.redis.springdata.example.model.Movie
import com.madadipouya.redis.springdata.example.service.MovieService
import com.madadipouya.redis.springdata.example.subscription.model.Subscriber
import com.madadipouya.redis.springdata.example.subscription.service.SubscriptionService
import jakarta.validation.Valid
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.PastOrPresent
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType.APPLICATION_STREAM_JSON_VALUE
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/v1/movies")
class MovieController(val movieService: MovieService, val subscriptionService: SubscriptionService) {
// removed for brevity
@GetMapping(value = ["/subscribe"], produces = [APPLICATION_STREAM_JSON_VALUE])
private fun subscribeToMovie(): Subscriber = subscriptionService.subscribe(Subscriber())
// removed for brevity
}
If you see the subscribeToMovie
method returns an instance of Subscriber
which is essentially a wrapper on top of (Spring) SseEmitter
. Additionally, the controller produces APPLICATION_STREAM_JSON_VALUE
. The endpoint implementation is pretty simple. It calls the subscribe
method of subscriptionService
with a new instance of the subscriber
class.
package com.madadipouya.redis.springdata.example.subscription.model
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
class Subscriber : SseEmitter(Long.MAX_VALUE)
After that, we need to implement the subscriptionService
responsible for registering a subscriber and notifying subscribers when a new movie added to the Redis.
The implementation of it looks like this:
package com.madadipouya.redis.springdata.example.subscription.service
import com.madadipouya.redis.springdata.example.model.Movie
import com.madadipouya.redis.springdata.example.subscription.model.Subscriber
interface SubscriptionService {
fun subscribe(subscriber: Subscriber): Subscriber
fun notifySubscribers(movie: Movie)
}
package com.madadipouya.redis.springdata.example.subscription.service.impl
import com.madadipouya.redis.springdata.example.model.Movie
import com.madadipouya.redis.springdata.example.subscription.model.Subscriber
import com.madadipouya.redis.springdata.example.subscription.service.SubscriptionService
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.io.IOException
@Service
class DefaultSubscriptionService : SubscriptionService {
companion object {
val logger: Logger = LoggerFactory.getLogger(DefaultSubscriptionService::class.java)
val subscribers: MutableSet<Subscriber> = hashSetOf()
}
override fun subscribe(subscriber: Subscriber): Subscriber {
subscribers.add(subscriber)
return subscriber
}
override fun notifySubscribers(movie: Movie) {
try {
subscribers.forEach { subscriber ->
subscriber.send(movie)
subscriber.onError { error ->
logger.info("Seems the subscriber has already dropped out. Remove it from the list")
subscriber.completeWithError(error)
subscribers.remove(subscriber)
}
}
} catch (ioException: IOException) {
logger.warn("Failed to notify suscriber about the new Movie = {}, {}, {}", movie.name, movie.genre, movie.year)
}
}
}
Mind that to notify the subscribers, we need to hold a reference of all subscribers. For that, we need to keep a list of subscribers. That is when the subscribe
method is invoked. To notify subscribers we iterate through the list and invoke the send
method.
We could have only sent an empty response to subscribers as a signal for calling /movies
endpoint. But instead, we also send the details of the new movie to the subscriber. Additionally, if the subscriber has closed the connection, the emitter throws an exception which then we ought to remove the subscriber from the list.
Finally, we need to call notifySubscribers
somewhere in the code. For our purpose, we should call it inside of MovieService
right after adding a new movie to Redis,
package com.madadipouya.redis.springdata.example.service.impl
import com.madadipouya.redis.springdata.example.controller.MovieController
import com.madadipouya.redis.springdata.example.model.Movie
import com.madadipouya.redis.springdata.example.producer.MovieAddedProducer
import com.madadipouya.redis.springdata.example.repository.ActorRepository
import com.madadipouya.redis.springdata.example.repository.MovieRepository
import com.madadipouya.redis.springdata.example.service.MovieService
import com.madadipouya.redis.springdata.example.service.exception.MovieNotFoundException
import com.madadipouya.redis.springdata.example.subscription.service.SubscriptionService
import org.springframework.stereotype.Service
@Service
class DefaultMovieService(val movieRepository: MovieRepository, val movieAddedProducer: MovieAddedProducer, val subscriptionService: SubscriptionService) : MovieService {
// removed for brevity
override fun createMovie(movieDto: MovieController.MovieDto): Movie {
val movie = movieRepository.save(Movie(name = movieDto.name.orEmpty(), genre = movieDto.genre.orEmpty(), year = movieDto.year))
movieAddedProducer.publish(movie)
subscriptionService.notifySubscribers(movie)
return movie
}
// removed for brevity
}
Anyone whose interested can subscribe to the /subscribe
endpoint to get notified when a new movie is added to Redis.
Conclusion
In this article, we covered how to implement Server-Sent Events with Spring MVC SseEmitter. We used the movie subscription example that we covered earlier. A client can subscribe to an SSE endpoint to get notified about new movies added to the service without closing the connection.
You can access to the GitHub source code at the link below,
https://github.com/kasramp/spring-data-redis-example-kotlin
Inline/featured images credits
- Featured image by Christina Morillo from Pexels