What this post covers
This is the twenty-fifth post in the "Digging into Spring's Source" series. Up to Part 24 we looked at HTTP, where one request gets one response and that is the end of it. This post is U23, the last unit of Phase 5 (web) on the map in Part 1: WebSocket, where a connection stays open and either side can speak at any time, and the messaging layer that carries messages over it. We follow how a line typed by one person in a chat room ends up on the screen of everyone in that room.
There are four questions to answer.
- Where does an HTTP request turn into a WebSocket connection?
- Who turns the chunk of text the client sent (a STOMP frame) into Spring's
Messageobject, and where is it sent? - What route does the value returned by an
@MessageMappingmethod take to reach the people subscribed to/topic/..? - When server code calls
SimpMessagingTemplate.convertAndSend, which channel does that message enter?
| Item | Value |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Modules read | spring-messaging, spring-websocket |
| Classes read | ExecutorSubscribableChannel, SubProtocolWebSocketHandler, StompSubProtocolHandler, SimpAnnotationMethodMessageHandler, SimpleBrokerMessageHandler, AbstractMessageBrokerConfiguration |
Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.
This post keeps only the main thread that someone new to WebSocket and STOMP can follow. What was left out is listed by name and location at the end, under "What this post left out".
Words to know first
WebSocket is a way for a browser and a server to keep one connection open so that either side can send data at any time. With HTTP the server only answers when the client asks; with WebSocket the server can speak first. It is used for chat and live notifications.
The handshake is the procedure that opens that connection. A WebSocket connection begins as an ordinary HTTP request. The client sends a header saying "let's switch this connection to WebSocket" (Upgrade: websocket), and if the server agrees, the same connection is used as a WebSocket from then on.
STOMP is an agreement about the format of the text sent over a WebSocket. WebSocket itself only says "a chunk of text is sent"; it says nothing about what the chunk means. STOMP calls that chunk a frame and lays it out as a command on the first line (CONNECT, SUBSCRIBE, SEND, MESSAGE, ...), then name:value headers, an empty line, and a body. An agreement layered on top of WebSocket like this is called a sub-protocol.
A destination is the address written in a STOMP frame's destination header. It looks like a path, such as /topic/greetings, but it is not a URL — it is a name for "where this message should go" or "what I want to receive".
Publish/subscribe (pub/sub) means the sender does not name a recipient. It puts a message out at a destination (publish), and everyone who said in advance that they want that destination (subscribe) receives it. The part that remembers who subscribed to what and hands messages out is called a broker.
A handler method is a method of ours that Spring calls when something arrives. In HTTP it was a method annotated with @RequestMapping, and Part 22 covers how Spring finds such a method, fills in its arguments and calls it. @MessageMapping in this post is the same structure applied to messages.
1. First foundation: Message, channel, handler
The spring-messaging module knows nothing about WebSocket. At its base are three very general interfaces that say "put a message somewhere, and someone will receive and handle it".
public interface Message<T> {
/**
* Return the message payload.
*/
T getPayload();
/**
* Return message headers for the message (never {@code null} but may be empty).
*/
MessageHeaders getHeaders();
}
spring-messaging/src/main/java/org/springframework/messaging/Message.java, lines 28–40
A Message is just two things: a payload and headers (MessageHeaders). MessageHeaders is a Map<String, Object> that cannot be modified — calling put throws (MessageHeaders lines 265–268). An id and a timestamp header are added automatically when it is created (lines 136–158).
The other two have one method each to implement (MessageChannel has a single abstract send(message, timeout); the send(message) in the table below is a default method that calls it).
| Interface | Method | Meaning |
|---|---|---|
MessageChannel |
send(message) |
the slot you drop a message into |
MessageHandler |
handleMessage(message) |
the side that receives and handles it |
SubscribableChannel |
subscribe(handler), unsubscribe(handler) |
a channel that handlers can be attached to and detached from |
The sender knows only the channel; it does not know who receives the message or how many receivers there are. All the big parts in this post are MessageHandlers, and they never call each other directly — they are connected only by putting messages into channels.
This resembles the ApplicationEvent from Part 12, but it is different. With events there is one road per container (the multicaster) and listeners are chosen by the type of the event. Channels can be created in any number to separate roads, and every handler attached to a channel receives the message and then decides for itself, from the headers (mostly the destination), whether it is its job.
What happens in one send
Every channel in this post is the same class, ExecutorSubscribableChannel. send lives in its parent AbstractMessageChannel (lines 128–157) and calls preSend → sendInternal → postSend → afterSendCompletion in that order. What gets a turn before and after is the ChannelInterceptor (lines 35–95). If preSend returns null, send ends right there with false and no handler is called (lines 177–193). This is where you hook in decisions like authentication — "should this message be let in?"
sendInternal, which actually calls the handlers, is short.
@Override
public boolean sendInternal(Message<?> message, long timeout) {
for (MessageHandler handler : getSubscribers()) {
SendTask sendTask = new SendTask(message, handler);
if (this.executor != null) {
try {
this.executor.execute(sendTask);
}
catch (RejectedExecutionException ex) {
// Probably on shutdown -> run send task locally instead
sendTask.run();
}
}
else {
// No executor configured -> always run send tasks locally
sendTask.run();
}
}
return true;
}
It creates one SendTask per subscribed handler and, if the channel has an Executor (a thread pool), hands the task to it; otherwise it runs the task on the spot. The subscriber list is a CopyOnWriteArraySet (AbstractSubscribableChannel line 34).
Blue marks where interceptors get a turn. On a channel with an Executor, the handler may be called after send has already returned.
Example 1: one channel with an interceptor and handlers
I created channels on their own, with no web server, and ran them. This example was run on the released Spring Framework 7.0.9 and produced the output below (the 7.1.0-SNAPSHOT this series reads was not built and run directly). PrintingInterceptor prints one line with the thread name each time a hook is called, and BlockingInterceptor returns null from preSend when the payload is "blocked". (The real example prints Korean; the labels and payload strings are translated here.)
// An example written for this post (not Spring's code)
ExecutorSubscribableChannel direct = new ExecutorSubscribableChannel();
direct.addInterceptor(new PrintingInterceptor());
direct.subscribe(message -> log("handler A " + message.getPayload()));
direct.subscribe(message -> log("handler B " + message.getPayload()));
boolean sent = direct.send(hello);
// ...
ExecutorSubscribableChannel async = new ExecutorSubscribableChannel(pool);
async.addInterceptor(new PrintingInterceptor());
async.subscribe(message -> {
log("handler A " + message.getPayload());
handled.countDown();
});
// ...
sent = async.send(hello);
// ...
ExecutorSubscribableChannel guarded = new ExecutorSubscribableChannel();
guarded.addInterceptor(new BlockingInterceptor());
guarded.subscribe(message -> log("handler A " + message.getPayload()));
System.out.println(" send(\"pass\") returned " + guarded.send(MessageBuilder.withPayload("pass").build()));
System.out.println(" send(\"blocked\") returned " + guarded.send(MessageBuilder.withPayload("blocked").build()));
header keys of the message: [destination, id, timestamp]
trying to modify the headers: MessageHeaders is immutable
== 1) Channel without an Executor: the sending thread calls the handlers too ==
[main ] preSend payload=hello
[main ] beforeHandle
[main ] handler A hello
[main ] afterMessageHandled
[main ] beforeHandle
[main ] handler B hello
[main ] afterMessageHandled
[main ] postSend sent=true
[main ] afterSendCompletion
send() returned true
== 2) Channel with an Executor: the handler runs on another thread ==
[main ] preSend payload=hello
[main ] postSend sent=true
[main ] afterSendCompletion
send() returned true (the handler has not been called yet)
[pool-1] beforeHandle
[pool-1] handler A hello
[pool-1] afterMessageHandled
== 3) If preSend returns null the message never enters the channel ==
[main ] handler A pass
send("pass") returned true
send("blocked") returned false
In 2) I held the pool thread back until send returned, so that the output order is the same on every run. Without that, the [pool-1] lines sometimes print before the send() returned line.
Easy to get wrong
sendreturningtruedoes not mean the work is done.ExecutorSubscribableChannel.sendInternalalways returnstrue, whether or not there are handlers (the last line of the code above). With an Executor, the handler may not even have started at that point.- The interceptor hooks come in two groups.
preSend,postSendandafterSendCompletionare called on the sending thread;beforeHandleandafterMessageHandledare called, once per handler, on the thread that calls the handler. The latter two are not inChannelInterceptorbut inExecutorChannelInterceptor, which extends it (lines 38–69; they are called fromSendTask.run, lines 143–168). ChannelInterceptoralso haspreReceiveandpostReceive, but they are not called on the channels in this post. As the Javadoc says, those hooks apply only to channels you pull messages out of (PollableChannel) (lines 65–82);ExecutorSubscribableChannelonly pushes to handlers.
2. Second foundation: WebSocketHandler and the handshake
The base of the spring-websocket module is the WebSocketHandler interface (lines 35–80). Its methods follow the life of one connection.
| Method | When it is called |
|---|---|
afterConnectionEstablished(session) |
the handshake finished and the connection is open |
handleMessage(session, message) |
a WebSocket message from the other side arrived |
handleTransportError(session, exception) |
an error occurred in transport |
afterConnectionClosed(session, closeStatus) |
after the connection closed |
A WebSocketSession is one open connection (line 38). It is identified by getId(), and sendMessage(message) sends to the other side (line 130). The server can speak first precisely because it holds on to this session object.
Where an HTTP request is upgraded to WebSocket
Since the connection starts as an HTTP request, the entrance is the DispatcherServlet from Part 21. The WebSocket endpoint path (for example /ws) is registered in a WebSocketHandlerMapping (line 40, a subclass of SimpleUrlHandlerMapping), and the handler mapped to that path is a WebSocketHttpRequestHandler.
@Override
public void handleRequest(HttpServletRequest servletRequest, HttpServletResponse servletResponse)
throws ServletException, IOException {
ServerHttpRequest request = new ServletServerHttpRequest(servletRequest);
ServerHttpResponse response = new ServletServerHttpResponse(servletResponse);
HandshakeInterceptorChain chain = new HandshakeInterceptorChain(this.interceptors, this.wsHandler);
// ...
try {
// ...
Map<String, Object> attributes = new HashMap<>();
if (!chain.applyBeforeHandshake(request, response, attributes)) {
return;
}
this.handshakeHandler.doHandshake(request, response, this.wsHandler, attributes);
chain.applyAfterHandshake(request, response, null);
}
// ...
}
Three lines are the skeleton. Call beforeHandshake on the HandshakeInterceptors (stop if any returns false), hand over to HandshakeHandler.doHandshake, then call afterHandshake. The attributes map holds values that an interceptor can fill in and that can later be read with WebSocketSession.getAttributes().
The default implementation of doHandshake is in AbstractHandshakeHandler (lines 168–230). It does several checks and one delegation.
- If the HTTP method is not
GET(orCONNECT, for HTTP/2): 405 (lines 177–185). - For
GET, it checks that theUpgradeheader isWebSocket, that theConnectionheader containsUpgrade, and thatSec-WebSocket-Keyis present. Otherwise: 400 (lines 186–204). - It checks the WebSocket version and the
Origin. A disallowed origin gets 403 (lines 205–212). - From the sub-protocols the client asked for, it picks one that the handler supports (line 219, lines 297–308). This is where STOMP gets selected.
- It hands over to
RequestUpgradeStrategy.upgrade(...)(line 228).
Actually switching the connection to WebSocket is done not by Spring but by the server (Tomcat and so on). The strategy DefaultHandshakeHandler picks is JettyRequestUpgradeStrategy if Jetty's WebSocket server is on the classpath and StandardWebSocketUpgradeStrategy otherwise (line 45). Taking the latter, it calls the servlet container's ServerContainer.upgradeHttpToWebSocket(...) (line 138) and passes it a StandardWebSocketHandlerAdapter, which wraps our WebSocketHandler in the standard API (jakarta.websocket.Endpoint) (line 119). When the connection opens, the server calls that adapter's onOpen, and the adapter calls afterConnectionEstablished (StandardWebSocketHandlerAdapter lines 60–103).
Example 4 in section 6 checks this flow by actually connecting to an embedded Tomcat. Its output shows the handshake request passing through DispatcherServlet and reaching the interceptor's beforeHandshake and afterHandshake, the response status becoming 101, a sub-protocol being picked, and StandardWebSocketUpgradeStrategy being the chosen strategy.
From the green box on, it is no longer HTTP. Messages that arrive afterwards do not go through DispatcherServlet.
Easy to get wrong
- A
HandshakeInterceptorapplies to the handshake request only, once. Messages that arrive after the upgrade are not HTTP requests. The server calls the adapter'sonMessage, and the adapter callsWebSocketHandler.handleMessagedirectly (lines 110–113), so they never pass throughDispatcherServlet(in the output of example 4 in section 6, the only line that passes throughDispatcherServletis the handshake). To check something on every message, use theChannelInterceptorfrom section 1. - SockJS is not WebSocket. It is a fallback that imitates the same effect with several HTTP requests where WebSocket cannot be used. Adding
withSockJS()to an endpoint registers aSockJsHttpRequestHandlerinstead of aWebSocketHttpRequestHandler(WebMvcStompWebSocketEndpointRegistrationlines 142–169). TheWebSocketHandlerbehind it is the same. This post goes no further than that.
3. Where STOMP is layered on
What WebSocketHandler.handleMessage receives is just a chunk of text. Two classes form the bridge that reads the chunk as a STOMP frame and turns it into the Message of section 1.
SubProtocolWebSocketHandler— it is aWebSocketHandlerand aMessageHandlerat the same time (lines 70–71). It stands on the border between the two worlds.StompSubProtocolHandler— the one that actually reads and writes STOMP. The sub-protocol names it declares arev10.stomp,v11.stompandv12.stomp(lines 217–220).
SubProtocolWebSocketHandler has two methods named handleMessage.
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
WebSocketSessionHolder holder = this.sessions.get(session.getId());
if (holder != null) {
session = holder.getSession();
}
SubProtocolHandler protocolHandler = findProtocolHandler(session);
protocolHandler.handleMessageFromClient(session, message, this.clientInboundChannel);
if (holder != null) {
holder.setHasHandledMessages();
}
}
// ...
@Override
public void handleMessage(Message<?> message) throws MessagingException {
String sessionId = resolveSessionId(message);
// ...
WebSocketSession session = holder.getSession();
try {
findProtocolHandler(session).handleMessageToClient(session, message);
}
// ...
}
The first, as a WebSocketHandler, receives what the client sent; the second, as a MessageHandler, receives what should be sent to a client. Either way it finds the SubProtocolHandler that matches the sub-protocol the session chose during the handshake (lines 443–475) and passes the work on. For the second one to be called, someone has to subscribe this object to a channel — it subscribes itself to clientOutboundChannel when it starts (lines 285–293).
In the incoming direction, StompSubProtocolHandler looks like this.
public void handleMessageFromClient(WebSocketSession session,
WebSocketMessage<?> webSocketMessage, MessageChannel channel) {
// ...
messages = decoder.decode(byteBuffer);
// ...
for (Message<byte[]> message : messages) {
StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
// ...
headerAccessor.setSessionId(sessionId);
headerAccessor.setSessionAttributes(session.getAttributes());
headerAccessor.setUser(getUser(session));
// ...
sent = channelToUse.send(message);
// ...
}
}
decoder.decodecuts the bytes into STOMP frames and produces a list ofMessage<byte[]>. The frame's command and headers go inside theMessageheaders, and the body becomes the payload (StompDecoder, line 50).- It writes the session id, session attributes and user into the headers. A STOMP frame does not say who sent it; the WebSocket connection knows that. The parts after this point know nothing about
WebSocketSessionand look only at the session id in the headers. - It
sends to the channel. Herechannelis theclientInboundChannelpassed in the code above.
After sending, for CONNECT, SUBSCRIBE and UNSUBSCRIBE it also publishes an ApplicationEvent such as SessionConnectEvent (lines 340–353). You can receive these with the @EventListener from Part 12.
A STOMP command is kept in the headers in two forms: the original command (StompCommand) and a coarser kind that parts unaware of STOMP can also recognise (SimpMessageType). The mapping is written in the StompCommand enum (lines 31–47).
| STOMP command | SimpMessageType |
|---|---|
CONNECT, STOMP |
CONNECT |
SUBSCRIBE |
SUBSCRIBE |
UNSUBSCRIBE |
UNSUBSCRIBE |
SEND (client → server), MESSAGE (server → client) |
MESSAGE |
DISCONNECT |
DISCONNECT |
The outgoing direction is the reverse. handleMessageToClient turns the Message headers into STOMP headers (lines 458–515), builds the bytes with StompEncoder, and writes them out with session.sendMessage(...) (lines 517–529).
The same class receives as a WebSocketHandler and sends as a MessageHandler. The yellow boxes are the channels we look at in Section 4.
Easy to get wrong
SENDandMESSAGEare the same kind. A client'sSENDand aMESSAGEgoing out from the server are bothSimpMessageType.MESSAGE. On the way out, if there is no command or it isSEND, it is rewritten toMESSAGE(lines 578–580). That is why, in the output of the examples in section 6, a message that came in asSENDis printed with typeMESSAGE.- The
CONNECTEDframe comes from aCONNECT_ACKmade by the broker.StompSubProtocolHandlerdoes not answerCONNECTby itself. TheCONNECTmessage travels over the channel to the broker and comes back asCONNECT_ACK, which is then converted toCONNECTED(lines 561–563).
4. The three channels and the handlers attached to them
This is the centre of the post. Between the bridge of section 3 and our code there are three ExecutorSubscribableChannels.
| Channel (bean name) | What passes through | Handlers subscribed |
|---|---|---|
clientInboundChannel |
every message sent by a client | SimpAnnotationMethodMessageHandler, SimpleBrokerMessageHandler, UserDestinationMessageHandler |
brokerChannel |
messages server-side code sends to the broker | SimpleBrokerMessageHandler, UserDestinationMessageHandler |
clientOutboundChannel |
every message going out to a client | SubProtocolWebSocketHandler |
Each handler subscribes in its start() (SimpAnnotationMethodMessageHandler lines 306–312, AbstractBrokerMessageHandler lines 215–228, UserDestinationMessageHandler lines 171–178). The subscriber lists in the table were printed and confirmed by example 3 in section 6.
Yellow boxes are channels, blue boxes are handlers subscribed to a channel. The SubProtocolWebSocketHandler at the top and at the bottom is the same object.
The three handlers on clientInboundChannel receive every message. Each then looks at the beginning of the destination (the prefix) and handles only its own. We look at them one by one, assuming the usual setup (/app goes to our methods, /topic goes to the broker).
SimpAnnotationMethodMessageHandler — the one that calls @MessageMapping
One class does what RequestMappingHandlerMapping and RequestMappingHandlerAdapter did in Part 22. Its parent is AbstractMethodMessageHandler.
- Finding ahead of time. During initialisation it scans the beans in the container and, in classes annotated with
@Controller(lines 415–418), finds@MessageMappingand@SubscribeMappingmethods to build a "destination pattern → method" table (AbstractMethodMessageHandlerlines 231–268, lines 420–451). - Choosing. When a message arrives it checks whether the destination starts with a configured prefix (
/app); if not, it returns without doing anything. If so, it looks up a method with the remainder after the prefix (/hello) (lines 429–453, lines 497–516). - Calling. Argument resolvers build the method arguments from the
Message(PayloadMethodArgumentResolver, which converts the payload to the wanted type, comes last, lines 336–357), the method is called, and the return value is handed to the return value handlers (lines 549–583).
In HTTP the return value became the response body. Messaging has no "response" as such, so the return value becomes a new message that is sent somewhere. SendToMethodReturnValueHandler does that.
With @SendTo("/topic/greetings") it goes to that destination; without it, to /topic plus the incoming destination minus its prefix (the default prefix /topic is at line 71, the combining at lines 245–260). To send, it calls messagingTemplate.convertAndSend(destination, returnValue, ...) (lines 209–216), and that template is the one that sends to brokerChannel (lines 372–373).
SimpleBrokerMessageHandler — the one that remembers subscriptions and fans out
This is the simple broker built into Spring. It subscribes to both clientInboundChannel and brokerChannel, and deals only with destinations that start with its own prefix (/topic). handleMessageInternal, which receives messages, branches on the kind (lines 292–350).
- First it checks the prefix.
/app/hellois filtered out here (lines 300–302,checkDestinationPrefix, lines 314–327). - For
SUBSCRIBEit writes the subscription into the registry; forUNSUBSCRIBEit removes it (lines 342–349). - For
MESSAGEit finds the subscribers of that destination and sends to them (lines 305–308). - For
CONNECTit remembers the session and sends aCONNECT_ACKto the outbound channel (lines 309–334).
The default registry implementation is DefaultSubscriptionRegistry, and it has two stores (lines 91–93).
| Store | Shape | Use |
|---|---|---|
SessionRegistry |
session id → that session's subscriptions (subscription id, destination or pattern) | the original. Changed on SUBSCRIBE, UNSUBSCRIBE and disconnect (lines 393–414) |
DestinationCache |
destination → (session id → subscription ids) | a computed result, so that not everything is scanned for every message. Up to 1024 entries by default (lines 258–296, line 73) |
A subscription destination can also be a pattern such as /topic/*; matching is done with AntPathMatcher (lines 283–296).
The code that hands messages out to subscribers looks like this.
protected void sendMessageToSubscribers(@Nullable String destination, Message<?> message) {
MultiValueMap<String,String> subscriptions = this.subscriptionRegistry.findSubscriptions(message);
// ...
subscriptions.forEach((sessionId, subscriptionIds) -> {
for (String subscriptionId : subscriptionIds) {
SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
initHeaders(headerAccessor);
headerAccessor.setSessionId(sessionId);
headerAccessor.setSubscriptionId(subscriptionId);
headerAccessor.copyHeadersIfAbsent(message.getHeaders());
// ...
Object payload = message.getPayload();
Message<?> reply = MessageBuilder.createMessage(payload, headerAccessor.getMessageHeaders());
SessionInfo info = this.sessions.get(sessionId);
if (info != null) {
try {
info.getClientOutboundChannel().send(reply);
}
// ...
}
}
});
}
It creates a new Message for each subscription. The payload stays as it is; the headers get the receiving session id and subscription id, and the message is put on clientOutboundChannel. With 100 subscribers, 100 messages pass through the outbound channel, and the SubProtocolWebSocketHandler of section 3 looks up the WebSocketSession by the session id in the headers and sends to them one by one.
Example 2: a broker on its own, fed with messages
I created a SimpleBrokerMessageHandler directly, attached it to three channels, and instead of STOMP frames fed it hand-built Messages of the same shape (same environment as example 1; labels and payloads translated as before). A handler that only prints is attached to the outbound channel. The channels have no Executor, so everything runs in order on the main thread. Each step in the output below is one or two calls to clientInboundChannel.send(...); only 5) is new SimpMessagingTemplate(brokerChannel).convertAndSend(...).
clientInboundChannel subscribers = 1, brokerChannel subscribers = 1
== 1) SUBSCRIBE and send without CONNECT (session s0) ==
(nothing was put on the outbound channel)
== 2) Two sessions CONNECT ==
out → type=CONNECT_ACK session=s1 subscription=null destination=null payload=
out → type=CONNECT_ACK session=s2 subscription=null destination=null payload=
== 3) s1 subscribes to /topic/news, s2 to the pattern /topic/* ==
(the subscriptions are only written to the registry; no outgoing message)
== 4) A client sends to /topic/news (clientInboundChannel) ==
out → type=MESSAGE session=s1 subscription=sub-1 destination=/topic/news payload=second news
out → type=MESSAGE session=s2 subscription=sub-2 destination=/topic/news payload=second news
== 5) Server code sends with SimpMessagingTemplate (brokerChannel) ==
out → type=MESSAGE session=s2 subscription=sub-2 destination=/topic/weather payload=sunny
== 6) Send to /app/hello, which is not a broker prefix ==
(nothing was put on the outbound channel)
== 7) s1 DISCONNECTs, then another message goes to /topic/news ==
out → type=DISCONNECT_ACK session=s1 subscription=null destination=null payload=
out → type=MESSAGE session=s2 subscription=sub-2 destination=/topic/news payload=third news
In 4) one message goes out as two. In 5) only s2, which subscribed with a pattern, receives /topic/weather. In 7) s1's subscription was removed together with the DISCONNECT (lines 375–389).
Easy to get wrong
- Nothing goes to a session that never sent
CONNECT. In 1), s0's subscription was registered but it received no message. The broker creates the session info onCONNECT(lines 321–322), and when sending it silently skips sessions without that info (theif (info != null)in the code above). Step 1) of the example was put there after reading this condition in the source, to see whether it really behaves that way. It is an easy trap when you test the broker with hand-builtMessages. - If a client sends straight to
/topic/.., our methods are not involved. That is case 4). The broker also subscribes toclientInboundChannel, so aSENDto a broker prefix fans out to subscribers immediately. To check or transform it, have clients send to/app/..or add a channel interceptor. - A simple broker is enabled even if you enable none. If the configuration calls neither
enableSimpleBrokernorenableStompBrokerRelay, a simple broker with no prefix is registered (MessageBrokerRegistrylines 227–239).
The other two: user destinations and an external broker
UserDestinationMessageHandler deals with destinations that start with /user/.. (lines 203–240). It turns a destination meaning "only this user" into a real destination unique to each of that user's sessions (the default implementation appends -user plus the session id, DefaultUserDestinationResolver lines 243–247) and puts the message back on brokerChannel. After that it is ordinary subscribe and publish.
StompBrokerRelayMessageHandler is used in place of the simple broker (line 82). It does not remember subscriptions itself; it relays messages to an external STOMP broker server. It extends the same parent (AbstractBrokerMessageHandler) and subscribes to the same two channels, so the other parts do not change. This post only names it.
5. Who creates these beans — @EnableWebSocketMessageBroker
We never created the channels and handlers above ourselves. They appear when one annotation is put on a configuration class.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(DelegatingWebSocketMessageBrokerConfiguration.class)
public @interface EnableWebSocketMessageBroker {
}
As Part 9 showed, an @EnableXxx is an @Import. The configuration class it brings in has three layers.
The lower the layer, the less it knows about WebSocket. Channels and the broker are in spring-messaging; the beans that touch WebSocket are in spring-websocket.
| Layer | What it does |
|---|---|
DelegatingWebSocketMessageBrokerConfiguration |
gets every WebSocketMessageBrokerConfigurer bean in the container injected, and asks them in turn whenever a configuration hook is called (lines 43–54) |
WebSocketMessageBrokerConfigurationSupport |
subProtocolWebSocketHandler, and stompWebSocketHandlerMapping, which maps endpoint paths to that handler (lines 93–123) |
AbstractMessageBrokerConfiguration |
the three channels, the handlers of section 4, the message converter, SimpMessagingTemplate |
When the registerStompEndpoints of our WebSocketMessageBrokerConfigurer calls addEndpoint("/ws"), at that moment the StompSubProtocolHandler is registered with the SubProtocolWebSocketHandler (lines 103–110) and the WebSocketHttpRequestHandler of section 2 is mapped to that path (lines 170–187).
The method that creates a channel bean shows the class from section 1 unchanged. It creates a new ExecutorSubscribableChannel(executor), attaches the registered interceptors, and returns it — nothing more (lines 157–168).
Easy to get wrong
- The inbound and outbound channels have a thread pool;
brokerChannelhas none by default. The first two are always created with an Executor (default pool size is twice the number of CPUs,TaskExecutorRegistrationline 53), whilebrokerChanneluses an Executor only when one is configured (lines 255–256). So when an@MessageMappingmethod returns a value, the same thread goes on to the broker and finds the subscribers. You can see it in the output in section 6. - All three channels get, as their last interceptor, one that makes the headers unmodifiable. It is
ImmutableMessageChannelInterceptor(line 184, line 235, line 257). It locks the header accessor inpreSend(ImmutableMessageChannelInterceptorlines 33–44). As section 4 showed, several handlers receive the same message, so this reads as a way to keep the headers from changing once the message is in the channel. Interceptors we register come before it.
6. The journey of one message, and SimpMessagingTemplate
With all the parts seen, let us connect them from start to finish. A client sends SEND to /app/hello, and the return value of the @MessageMapping("/hello") method goes to the subscribers of /topic/greetings as MESSAGE.
The three yellow boxes are channels. The message crosses a channel three times, and its content and headers change in between.
SimpMessagingTemplate sends to brokerChannel
SimpMessagingTemplate is what you use to send a message to subscribers from outside a controller method — from a scheduled job or an HTTP controller, for example. The bean the configuration creates is this.
@Bean
public SimpMessagingTemplate brokerMessagingTemplate(
AbstractSubscribableChannel brokerChannel, AbstractSubscribableChannel clientInboundChannel,
AbstractSubscribableChannel clientOutboundChannel, CompositeMessageConverter brokerMessageConverter) {
SimpMessagingTemplate template = new SimpMessagingTemplate(brokerChannel);
MessageBrokerRegistry registry = getBrokerRegistry(clientInboundChannel, clientOutboundChannel);
String prefix = registry.getUserDestinationPrefix();
if (prefix != null) {
template.setUserDestinationPrefix(prefix);
}
template.setMessageConverter(brokerMessageConverter);
return template;
}
The channel passed to the constructor is brokerChannel. convertAndSend(destination, payload) turns the payload into a Message with the converter (AbstractMessageSendingTemplate lines 154–160), writes the destination and the kind MESSAGE into the headers, and sends it to that channel (lines 148–191). In other words it enters the diagram above directly at ⑤. The template that sent the @SendTo return value in section 4 is this very bean.
Example 3: put frames in, see frames come out
I ran it end to end (same environment as example 1), in two passes. This first example runs without a web server. With no server, the handshake of section 2 does not happen (connecting to a real server is example 4, right after this one). Instead I wrote a fake session that implements WebSocketSession and called afterConnectionEstablished and handleMessage directly — the methods the server calls after a handshake. That way the text of the frames going in and coming out can be seen as it is. For configuration, instead of @EnableWebSocketMessageBroker I directly extended AbstractMessageBrokerConfiguration, the topmost ancestor of the configuration class that annotation imports (the third layer in section 5), and registered only a SubProtocolWebSocketHandler by hand. The two upper layers the annotation brings in also create the HTTP-side beans that map endpoint paths in a handler mapping, and an example that runs without a server has no use for those. From section 3 onwards the real classes run unchanged.
// An example written for this post (not Spring's code)
// This is all the code we write: receive a message sent to /app/hello and send it back to /topic/greetings
@Controller
static class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
String greet(String name) {
log("GreetingController.greet(\"" + name + "\")");
return "안녕하세요, " + name + "!"; // "Hello, <name>!"
}
}
The configuration class calls only enableSimpleBroker("/topic") and setApplicationDestinationPrefixes("/app"). main feeds in frames like this.
// An example written for this post (not Spring's code)
SubProtocolWebSocketHandler handler = context.getBean(SubProtocolWebSocketHandler.class);
FakeSession session = new FakeSession();
handler.afterConnectionEstablished(session); // what the server calls once the handshake is done
handler.handleMessage(session, new TextMessage("CONNECT\naccept-version:1.2\nhost:localhost\n\n\0"));
printFrame(session);
// ...
handler.handleMessage(session, new TextMessage("SUBSCRIBE\nid:sub-0\ndestination:/topic/greetings\n\n\0"));
// ...
handler.handleMessage(session, new TextMessage(
"SEND\ndestination:/app/hello\ncontent-type:text/plain;charset=UTF-8\n\n남알지\0"));
printFrame(session);
// ...
SimpMessagingTemplate template = context.getBean(SimpMessagingTemplate.class);
System.out.println(" channel used by the template == brokerChannel ? " + (template.getMessageChannel() == broker));
template.convertAndSend("/topic/greetings", "공지입니다"); // "This is a notice"
printFrame(session);
Each channel has an interceptor that prints one line in preSend, and to keep the output order stable the inbound and outbound channels were reduced to one thread each. The labels below are translated, but the frames are shown exactly as the server produced them, Korean payloads included — the content-length values count their UTF-8 bytes, so translating the payloads would make the frames no longer match what the server sent. Example 4 below uses English strings throughout, and its output is shown untranslated.
clientInboundChannel subscribers: [SimpAnnotationMethodMessageHandler, SimpleBrokerMessageHandler, UserDestinationMessageHandler]
brokerChannel subscribers: [SimpleBrokerMessageHandler, UserDestinationMessageHandler]
clientOutboundChannel subscribers: [SubProtocolWebSocketHandler]
== 1) client → server: CONNECT ==
[main ] → clientInboundChannel type=CONNECT destination=null
[clientInboundChannel-1 ] → clientOutboundChannel type=CONNECT_ACK destination=null
[main ] event SessionConnectEvent
[clientOutboundChannel-1] event SessionConnectedEvent
[clientOutboundChannel-1] WebSocketSession.sendMessage(TextMessage)
server → client frame:
CONNECTED
version:1.2
heart-beat:0,0
^@
== 2) client → server: SUBSCRIBE /topic/greetings ==
[main ] → clientInboundChannel type=SUBSCRIBE destination=/topic/greetings
[main ] event SessionSubscribeEvent
== 3) client → server: SEND /app/hello ==
[main ] → clientInboundChannel type=MESSAGE destination=/app/hello
[clientInboundChannel-1 ] GreetingController.greet("남알지")
[clientInboundChannel-1 ] → brokerChannel type=MESSAGE destination=/topic/greetings
[clientInboundChannel-1 ] → clientOutboundChannel type=MESSAGE destination=/topic/greetings
[clientOutboundChannel-1] WebSocketSession.sendMessage(TextMessage)
server → client frame:
MESSAGE
destination:/topic/greetings
content-type:text/plain;charset=UTF-8
subscription:sub-0
message-id:s1-0
content-length:27
안녕하세요, 남알지!^@
== 4) server code sends with SimpMessagingTemplate.convertAndSend ==
channel used by the template == brokerChannel ? true
[main ] → brokerChannel type=MESSAGE destination=/topic/greetings
[main ] → clientOutboundChannel type=MESSAGE destination=/topic/greetings
[clientOutboundChannel-1] WebSocketSession.sendMessage(TextMessage)
server → client frame:
MESSAGE
destination:/topic/greetings
content-type:text/plain;charset=UTF-8
subscription:sub-0
message-id:s1-1
content-length:15
공지입니다^@
The context was closed.
^@ is the zero character that ends a STOMP frame, printed in a visible form. In 1), the lines from the main thread and the lines from other threads do not wait for each other, so their order can differ from run to run.
Following the thread names in 3) shows the diagram above as it is.
main(on a real server this is where one of the server's threads stands; example 4 shows it) turns the frame into aMessage, puts it onclientInboundChanneland returns.clientInboundChannel-1calls our method and, on the same thread, goes throughbrokerChannelall the way to the broker finding the subscribers and putting the message onclientOutboundChannel. As section 5 showed, that is becausebrokerChannelhas no Executor.clientOutboundChannel-1builds the frame and writes it to the session.
In 4) the main thread that called SimpMessagingTemplate starts at brokerChannel. It does not pass through clientInboundChannel.
The name in brackets is the thread that did the work. Step 4), sent through the template, skips clientInboundChannel. All of it is the real output of Example 3 above; the Korean strings are the example's own payloads ("Hello, 남알지!" and "This is an announcement").
Example 4: a round trip with a real server and a real client
This time there is no fake session. I started an embedded Tomcat 11.0.15 on a random free port with a DispatcherServlet in it, and in the same process connected with WebSocketStompClient, Spring's STOMP client for Java, then subscribed, sent and received. This example, too, was run on the released Spring Framework 7.0.9 and produced the output below (the 7.1.0-SNAPSHOT this series reads was not built and run directly). The controller is the same as in example 3 except that it returns English text ("Hello, " + name + "!").
The configuration is exactly what section 5 described: one annotation and a WebSocketMessageBrokerConfigurer.
// An example written for this post (not Spring's code)
// This time one annotation creates everything: the three channels, the handlers and the endpoint mapping
@Configuration
@EnableWebSocketMessageBroker
static class Config implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").addInterceptors(new HandshakePrinter());
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
registry.configureBrokerChannel().interceptors(new Trace("brokerChannel"));
}
// ...
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
// wrap the WebSocketHandler once to see the moments the connection opens and closes
registry.addDecoratorFactory(ConnectionPrinter::new);
}
}
To see what passes where, printing is hooked in at four places. HandshakePrinter is a HandshakeInterceptor from section 2; it prints the request's Sec-WebSocket-Protocol header and the response's status code. ConnectionPrinter is a WebSocketHandler wrapped around SubProtocolWebSocketHandler; it prints a line when afterConnectionEstablished is called. Trace prints a line in preSend on each of the three channels, as in example 3, and on the outbound channel also right before a handler is called (beforeHandle). Finally, doService of DispatcherServlet is overridden to print a line for every request that passes. main looks like this.
// An example written for this post (not Spring's code)
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(Config.class, GreetingController.class);
// print a line every time a request passes through DispatcherServlet
DispatcherServlet dispatcherServlet = new DispatcherServlet(context) {
@Override
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
// a servlet has a log(String) of its own, so call ours with the class name
StompRoundTripExample.log("DispatcherServlet.doService " + request.getMethod() + " " + request.getRequestURI());
super.doService(request, response);
}
};
Tomcat tomcat = new Tomcat();
WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient());
stompClient.setMessageConverter(new StringMessageConverter());
// ...
tomcat.setPort(0); // any free port
// to keep the output order the same on every run, the request-processing threads are reduced to one as well
tomcat.getConnector().setProperty("maxThreads", "1");
tomcat.getConnector().setProperty("minSpareThreads", "1");
Context tomcatContext = tomcat.addContext("", Files.createTempDirectory("u23-docbase").toString());
// Tomcat's WebSocket initialization, which a container normally finds by scanning jars, is done by hand here
tomcatContext.addServletContainerInitializer(new WsSci(), null);
Tomcat.addServlet(tomcatContext, "dispatcher", dispatcherServlet).setLoadOnStartup(1);
tomcatContext.addServletMappingDecoded("/", "dispatcher");
tomcat.start();
int port = tomcat.getConnector().getLocalPort();
// ...
// write the sub-protocols we want into the Sec-WebSocket-Protocol header of the handshake request
WebSocketHttpHeaders handshakeHeaders = new WebSocketHttpHeaders();
handshakeHeaders.setSecWebSocketProtocol(List.of("v12.stomp", "v11.stomp"));
session = stompClient.connectAsync("ws://localhost:" + port + "/ws", handshakeHeaders, new ClientSessionHandler())
.get(10, TimeUnit.SECONDS);
// ...
session.subscribe("/topic/greetings", new GreetingFrameHandler());
// ...
session.send("/app/hello", "namalzi");
printReceived();
// ...
template.convertAndSend("/topic/greetings", "A notice from the server");
printReceived();
WsSci is a Tomcat class (it is not Spring's, so this post does not read its source). The StandardWebSocketUpgradeStrategy of section 2 takes the server's WebSocket container from the servlet context attribute jakarta.websocket.server.ServerContainer (lines 147–154), and registering WsSci is how I had Tomcat prepare that. As in example 3 the inbound and outbound channels were reduced to one thread each, and here Tomcat's request-processing threads were reduced to one as well. What the client receives is put on a queue by the client-side callback and printed by main (the lines that start with client:).
Tomcat started on a random port
stompWebSocketHandlerMapping: WebSocketHandlerMapping /ws -> WebSocketHttpRequestHandler
handshake handler: DefaultHandshakeHandler, upgrade strategy: StandardWebSocketUpgradeStrategy
== 1) client connects: HTTP handshake, then STOMP CONNECT ==
[http-nio-auto-1-exec-1 ] DispatcherServlet.doService GET /ws
[http-nio-auto-1-exec-1 ] beforeHandshake GET /ws Upgrade=websocket Sec-WebSocket-Protocol=[v12.stomp,v11.stomp]
[http-nio-auto-1-exec-1 ] afterHandshake status=101 Upgrade=websocket Sec-WebSocket-Protocol=v12.stomp
[http-nio-auto-1-exec-1 ] afterConnectionEstablished acceptedProtocol=v12.stomp delegate=SubProtocolWebSocketHandler
[http-nio-auto-1-exec-1 ] -> clientInboundChannel type=CONNECT destination=null
[clientInboundChannel-1 ] -> clientOutboundChannel type=CONNECT_ACK destination=null
[clientOutboundChannel-1] clientOutboundChannel hands it to SubProtocolWebSocketHandler
client: connected = true
== 2) client -> server: SUBSCRIBE /topic/greetings ==
[http-nio-auto-1-exec-1 ] -> clientInboundChannel type=SUBSCRIBE destination=/topic/greetings
== 3) client -> server: SEND /app/hello ==
[http-nio-auto-1-exec-1 ] -> clientInboundChannel type=MESSAGE destination=/app/hello
[clientInboundChannel-1 ] GreetingController.greet("namalzi")
[clientInboundChannel-1 ] -> brokerChannel type=MESSAGE destination=/topic/greetings
[clientInboundChannel-1 ] -> clientOutboundChannel type=MESSAGE destination=/topic/greetings
[clientOutboundChannel-1] clientOutboundChannel hands it to SubProtocolWebSocketHandler
client: received MESSAGE destination=/topic/greetings subscription=0 payload=Hello, namalzi!
== 4) server code sends with SimpMessagingTemplate.convertAndSend ==
template channel == brokerChannel ? true
[main ] -> brokerChannel type=MESSAGE destination=/topic/greetings
[main ] -> clientOutboundChannel type=MESSAGE destination=/topic/greetings
[clientOutboundChannel-1] clientOutboundChannel hands it to SubProtocolWebSocketHandler
client: received MESSAGE destination=/topic/greetings subscription=0 payload=A notice from the server
== 5) client disconnects ==
server: afterConnectionClosed code=1000
Tomcat stopped. context active = false
I ran it seven times and got the same output, character for character, all seven times. In 5) the tracing is switched off, because I did not pin down the order of the lines printed while the connection closes. http-nio-auto-1-exec-1 is the name of the Tomcat thread that processes requests.
Here is how the output lines up with the explanations earlier in the post.
- The second and third lines at the top — the beans of sections 5 and 2. The single line
addEndpoint("/ws")mapped/ws→WebSocketHttpRequestHandlerin thestompWebSocketHandlerMappingbean (aWebSocketHandlerMapping). The handshake handler isDefaultHandshakeHandler, and the strategy it picked isStandardWebSocketUpgradeStrategy. - The first four lines of 1) — the handshake of section 2. The
GET /wsrequest passes throughDispatcherServletand reachesbeforeHandshake. The request carriesUpgrade: websocketand the two sub-protocols the client wrote (it printed as[v12.stomp,v11.stomp]becausesetSecWebSocketProtocoljoins the list with commas into a single header value,WebSocketHttpHeaderslines 153–155). The response seen inafterHandshakehas status 101 andSec-WebSocket-Protocol: v12.stomp. That matches step 4 of section 2, which goes through the requested protocols in order and picks the first one the handler supports (lines 297–308). In this runafterHandshakewas printed first andafterConnectionEstablishedwas called after it on the same thread, and the sub-protocol the session reported was alsov12.stomp. - There is only one
DispatcherServlet.doServiceline. TheSUBSCRIBEandSENDof 2) and 3) came in on the same Tomcat thread, but they went ontoclientInboundChannelwithout passing throughDispatcherServlet. This is the first item of "Easy to get wrong" in section 2, visible in output. - The rest of 1), and 3) — the bridge of section 3 and the three channels of section 4. A Tomcat thread stands where
mainstood in example 3; everything after that is the same as example 3. The Tomcat thread puts the message onclientInboundChannel,clientInboundChannel-1callsGreetingController.greetand then, on the same thread, puts messages onbrokerChannelandclientOutboundChannelin turn, andclientOutboundChannel-1hands the result toSubProtocolWebSocketHandler. TheSENDprinting as typeMESSAGE, andCONNECTcoming back asCONNECT_ACK(section 3), are the same too. - The
client:lines — the end of the round trip. The client receivedHello, namalzi!in aMESSAGEfor/topic/greetings. In 4) the message fromSimpMessagingTemplate, called bymain, skipsclientInboundChannel, starts atbrokerChanneland arrives on the same subscription (subscription=0). - 5) When the client disconnected,
afterConnectionClosedwas called on the server side with code 1000. 1000 isCloseStatus.NORMAL(line 46).
The frame text and the events such as SessionConnectEvent that example 3 showed are not printed in this example. Conversely, the handshake and the server's thread are visible only here.
Easy to get wrong
- A sender who is also subscribed receives its own message. The broker does not single out the sending session. In 3) of example 3, s1 sent the
SENDand received theMESSAGEbecause s1 had subscribed to/topic/greetings. The client in example 4 likewise received the answer to its own message on its own subscription. WebSocketStompClientdid not ask for a sub-protocol unless told to. The first time I ran example 4 I connected without handshake headers, andbeforeHandshakeprintedSec-WebSocket-Protocol=nullandafterConnectionEstablishedprinted an emptyacceptedProtocol. The rest of the round trip worked all the same. That is because, when no sub-protocol was negotiated,SubProtocolWebSocketHandleruses the default handler, or, if there is none and only one handler is registered, that one (lines 462–473). The code above, which writes two sub-protocols intoWebSocketHttpHeaders, is what I changed it to in order to see a sub-protocol being picked.- Sending to
/app/..withSimpMessagingTemplatedoes not call@MessageMapping. The template sends tobrokerChannel, andSimpAnnotationMethodMessageHandlersubscribes only toclientInboundChannel(the table in section 4). The broker drops it because the prefix does not match. - The return value of
@SubscribeMappingdoes not go through the broker. It answers only the client that made the subscription request, straight overclientOutboundChannel(SubscriptionMethodReturnValueHandlerlines 126–160; the template this handler uses is at line 146). It is used to push initial data once at the moment of subscribing.
What this post left out
A lot was left out for the sake of the main thread. Only names and locations are given here. The SockJS transports and session management (the sockjs package of spring-websocket). The TCP connections and the "system session" of the external broker relay (StompBrokerRelayMessageHandler). Heartbeats (in the simple broker, from HeartbeatTask, line 508). The user registry SimpUserRegistry and broadcasting user destinations across several servers. OrderedMessageChannelDecorator, which preserves message order, and ConcurrentWebSocketSessionDecorator, which cuts off slow clients (line 48). Handling exceptions with @MessageExceptionHandler (lines 585–609). websocket-scoped beans (lines 145–150). The selector header that puts a SpEL condition on a subscription (from DefaultSubscriptionRegistry line 168). The internals of WebSocketStompClient, the Java STOMP client that example 4 only used as its client (line 75). And @EnableWebSocket, which registers a bare WebSocketHandler without STOMP, as well as reactive WebSocket on the WebFlux side, were not covered.
Recap
| Concept | One-line summary |
|---|---|
Message |
a payload and an unmodifiable header map. One STOMP frame becomes one Message |
MessageChannel / MessageHandler |
the slot and the receiver. Parts never call each other; they are connected only by channels |
ExecutorSubscribableChannel |
gives the message to every subscribed handler — on another thread if it has an Executor |
ChannelInterceptor |
changes the message or blocks it with null in preSend. Unlike a handshake interceptor, it applies to every message |
| Handshake | one HTTP GET. WebSocketHttpRequestHandler → HandshakeHandler → the server's upgrade |
SubProtocolWebSocketHandler |
both a WebSocketHandler and a MessageHandler. The border between WebSocket and the channels |
StompSubProtocolHandler |
frame ↔ Message. On the way in it writes the session id and user into the headers |
clientInboundChannel |
everything a client sent. Three handlers receive it and split it by prefix |
SimpAnnotationMethodMessageHandler |
routes /app/.. to @MessageMapping methods. The return value goes to brokerChannel |
brokerChannel |
the road from the server side to the broker. No Executor by default |
SimpleBrokerMessageHandler |
remembers subscriptions and sends out one message per subscription |
clientOutboundChannel |
everything going out to clients. SubProtocolWebSocketHandler subscribes to it |
SimpMessagingTemplate |
for server code that sends directly. It enters at brokerChannel |
@EnableWebSocketMessageBroker |
one @Import brings in three layers of configuration classes that create all the beans above |
What to look at next
With this post, Phase 5 (web) on the map in Part 1 is finished. We started from the foundation in Part 20 and came through servlet MVC, WebFlux and now messaging.
In example 3 of this post we ran the flow with a fake session, without starting a server. The module that properly helps with running pieces of Spring without a server is spring-test. How a container is reused across tests instead of being started for each one, where JUnit and Spring meet, and MockMvc, which runs MVC without a server, are all there. That continues in Part 26: spring-test — SpringExtension, TestContext, MockMvc.
- Part 9: @Conditional, @Profile and @Import — Choosing Beans by Condition
- Part 12: Events, Environment, Resource — Three Abstractions Around the Container
- Part 21: DispatcherServlet — How One Request Becomes a Response
- Part 22: Handler Mappings and Adapters — How @RequestMapping Methods Are Found and Called
- Part 24: WebFlux — DispatcherHandler, Functional Endpoints, and WebClient