Server-Sent Events with Spring MVC SseEmitter

Server-Sent Events with Spring MVC SseEmitter

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:

Movie-Service-SSE

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