84 lines
3.6 KiB
Markdown
84 lines
3.6 KiB
Markdown
# 3. The payload, and a media type that does less than you think
|
|
|
|
Previous: [2. The SSE lifecycle](02-sse-lifecycle.md) · Next: [4. STOMP](04-stomp.md)
|
|
|
|
---
|
|
|
|
The advice you will find is: always pass a media type to `data(..)`, or your object is written
|
|
with `toString()`. Both halves of that turn out to be wrong on Boot 4.1, and the mechanism
|
|
underneath explains a real trap that the advice misses.
|
|
|
|
## What actually happens
|
|
|
|
`SseEmitter.send(Object data, MediaType mediaType)` walks the application's configured
|
|
`HttpMessageConverter` list and uses **the first converter whose `canWrite(type, mediaType)`
|
|
returns true**. The media type argument is a *filter*, not a preference. A `null` media type
|
|
filters nothing, so the first converter that can write the type at all wins.
|
|
|
|
`/diag` prints the list in order:
|
|
|
|
```
|
|
ByteArrayHttpMessageConverter
|
|
StringHttpMessageConverter
|
|
ResourceHttpMessageConverter
|
|
ResourceRegionHttpMessageConverter
|
|
AllEncompassingFormHttpMessageConverter
|
|
JacksonJsonHttpMessageConverter
|
|
Jaxb2RootElementHttpMessageConverter
|
|
```
|
|
|
|
Two consequences, both measured by
|
|
[`SseDataConversionTest`](../src/test/java/com/ankurm/ssews/SseDataConversionTest.java)
|
|
([`sse-payload-conversion.txt`](output/sse-payload-conversion.txt)):
|
|
|
|
**For a record or POJO, the media type changes nothing.** No converter ahead of Jackson claims it,
|
|
so Jackson writes it either way:
|
|
|
|
```
|
|
=== SseEmitter.event().data(record, APPLICATION_JSON) ===
|
|
{"seq":7,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
|
|
=== SseEmitter.event().data(record) [no media type] ===
|
|
{"seq":7,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
|
|
=== POJO: identical? true ===
|
|
```
|
|
|
|
**For a `String`, the media type *still* changes nothing** — and this is the part that
|
|
surprised me enough to rewrite the test. `StringHttpMessageConverter` supports `MediaType.ALL` and
|
|
sits ahead of Jackson, so it claims the write even when you explicitly ask for
|
|
`application/json`:
|
|
|
|
```
|
|
=== String payload, no media type -> {"already":"json"}
|
|
=== String payload, APPLICATION_JSON -> {"already":"json"}
|
|
```
|
|
|
|
There is no way to make `send()` JSON-quote a `String`. Good news if you are streaming
|
|
pre-rendered JSON — it goes out verbatim, not double-encoded. A trap if you assumed a
|
|
`String` field would be escaped for you: it will not be, and a payload containing a newline
|
|
becomes two `data:` lines rather than one.
|
|
|
|
## Jackson 3, and the `Instant` that proves it
|
|
|
|
`"at":"2026-09-03T10:00:00Z"` — an ISO-8601 string, not an epoch number. Boot 4.1.1 manages
|
|
Jackson **3.1.5** under `tools.jackson`, and `JacksonJsonHttpMessageConverter` is the Jackson 3
|
|
converter. The Jackson 2 sibling (`MappingJackson2HttpMessageConverter`) is still on the
|
|
classpath in the messaging stack.
|
|
|
|
**Rule for Boot 4: a `2` in a Spring class name means the *previous* Jackson.** The unnumbered
|
|
name is current, which is the opposite of the convention you would guess, and it is the same fork
|
|
that catches people in [`../kafka-basics`](../kafka-basics/README.md) and
|
|
[`../rabbitmq`](../rabbitmq/README.md). For STOMP it is
|
|
`JacksonJsonMessageConverter` you want, not `MappingJackson2MessageConverter`.
|
|
|
|
## Practical guidance
|
|
|
|
- Pass `MediaType.APPLICATION_JSON` anyway. It costs nothing, it documents intent, and it makes
|
|
the code correct against a converter list you did not configure yourself.
|
|
- Do not stream a `String` and assume it is escaped. Send the object.
|
|
- Keep events small. Every subscriber pays for one serialisation per event; see
|
|
[6. Choosing](06-choosing.md).
|
|
|
|
---
|
|
|
|
Previous: [2. The SSE lifecycle](02-sse-lifecycle.md) · Next: [4. STOMP](04-stomp.md)
|