1
0

Add the protocol-comparison module

This commit is contained in:
2026-09-04 00:52:18 +05:30
parent 5224afdad2
commit d56c60824e
32 changed files with 1710 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
# `protocol-comparison` — RSocket vs gRPC vs WebSocket, same use case, measured
Companion project for
[**RSocket vs gRPC vs WebSocket on Spring Boot 4.1: When Each One Wins**](https://ankurm.com/rsocket-vs-grpc-vs-websocket-spring-boot-4-1/)
on ankurm.com.
**One application serves the same two operations over all three protocols.** Same JVM, same heap,
same data generator, same five fields — so a difference in the numbers is a difference in the
transports rather than in three separately-written demos.
[`./scripts/run-all.sh`](scripts/run-all.sh) re-measures everything under
[`docs/output/`](docs/output/).
## Versions
| | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
| Spring Boot | 4.1.1 | latest GA of the 4.1 line |
| Spring Framework | 7.0.9 | Boot-managed |
| grpc-java | **1.83.1** | Boot-managed. Boot 4.1.0 pinned 1.80.0 — it moved |
| protobuf-java | **4.35.1** | Boot-managed. Boot 4.1.0 pinned 4.34.2 |
| rsocket-java | 1.1.5 | Boot-managed |
| Spring gRPC | 1.1.1 | the programming model; separate version from Boot's |
| Jackson | 3.1.5 (`tools.jackson`) | plus `jackson-dataformat-cbor`, RSocket's default |
Read from `repo1.maven.org/.../maven-metadata.xml` and Boot's own `spring-boot-dependencies` POM.
## Quickstart
```bash
./scripts/run-all.sh # six tests, then re-measure docs/output/
mvn test # the same without the capture
mvn spring-boot:run # gRPC 9090, RSocket 7000, WebSocket ws://localhost:8080/quotes
```
Nothing to install: `protoc` and the gRPC codegen plugin resolve as Maven artifacts for your OS
and architecture, and all three servers start inside the test JVM.
## The operations
| | gRPC | RSocket route | WebSocket command |
|---|---|---|---|
| one quote | `QuoteService/GetQuote` | `quote` | `QUOTE <symbol>` |
| N quotes | `QuoteService/StreamQuotes` | `quotes` | `STREAM <symbol> <n> <delayMs>` |
| unbounded | `QuoteService/StreamUnbounded` | `quotes.unbounded` | `UNBOUNDED <symbol>` |
| same, `isReady()`-gated | `QuoteService/StreamUnboundedReady` | — | — |
| server-side produced count | `QuoteService/Produced` | `produced` | `PRODUCED` |
## Documentation
1. [The same use case, three ways](docs/01-three-protocols.md)
2. [How they measure — and why a loopback benchmark flatters the wrong one](docs/02-benchmarks.md)
3. [Payload: the one comparison that needs no benchmark](docs/03-payload.md)
4. [Back-pressure: the measurement that decides it](docs/04-backpressure.md)
5. [Choosing](docs/05-choosing.md)
## Captured output
| File | What it shows |
|---|---|
| [`payload-sizes.txt`](docs/output/payload-sizes.txt) | one message as protobuf, JSON and CBOR, in hex |
| [`request-response.txt`](docs/output/request-response.txt) | 5 000 sequential calls, p50/p99/mean per protocol |
| [`stream-throughput.txt`](docs/output/stream-throughput.txt) | 50 000 messages on one connection |
| [`backpressure.txt`](docs/output/backpressure.txt) | how many the server produced for a client that wanted 100 |
| [`grpc-isready.txt`](docs/output/grpc-isready.txt) | the documented gRPC fix, tested — and it did not work here |
| [`rsocket-codecs.txt`](docs/output/rsocket-codecs.txt) | what Boot configures for RSocket, and why CBOR wins |
| [`tests.txt`](docs/output/tests.txt) | 6 tests |
## Read the benchmark honestly
Client and server are the same process on one loopback interface, two cores. **There is no
network.** That deletes protobuf's size advantage, and it flatters whichever protocol does the
least work per message — which is why raw WebSocket wins both speed benchmarks and why that is
not the recommendation. Treat the ratios as indicative and the absolutes as meaningless off this
box.
The one result that *does* transfer is the back-pressure table, because it is a property of the
protocols rather than of the link.
## Four things this module exists to prove
1. **RSocket's `request(n)` is real and the others have no equivalent.** For a client that took
100 messages then went quiet for two seconds, the servers produced **100**, **1 346 233** and
**311 617** respectively.
2. **`ServerCallStreamObserver.isReady()` did not bound a gRPC producer here.** The documented fix
was tested; the transcript is committed as measured, including its instability.
3. **Raw WebSocket is the fastest and has the worst tail.** p50 of 124 µs against a p99 of
1 448 µs — a 12x spread, where gRPC's is 3.5x.
4. **CBOR keeps the field names, and it wins by list order.** It is binary JSON, not a schema
format: 67 bytes against protobuf's 35 and JSON's 83. Boot puts `JacksonCborEncoder` ahead of
`JacksonJsonEncoder`, and a bare `RSocketStrategies.create()` has neither.

View File

@@ -0,0 +1,83 @@
# 1. The same use case, three ways
Next: [2. How they measure](02-benchmarks.md)
---
One application in this module serves the same two operations over gRPC, RSocket and a raw
WebSocket: fetch one quote, and stream N quotes. Same JVM, same heap, same
[`QuoteSource`](../src/main/java/com/ankurm/protocols/QuoteSource.java), same five fields.
That constraint is the point. Cross-protocol benchmarks published on the internet almost always
compare three different applications, and end up measuring three different serialisation
libraries, three JIT states and three thread pools.
## What each one is, in one sentence
| | gRPC | RSocket | Raw WebSocket |
|---|---|---|---|
| Transport | HTTP/2 | TCP, WebSocket, or others | HTTP/1.1 Upgrade |
| Schema | `.proto`, mandatory, code-generated | none required | none |
| Encoding here | protobuf | CBOR (Spring's default) | JSON |
| Interaction models | 4, declared in the `.proto` | 4, chosen by return type | whatever you invent |
| Demand signalling | none in the API | `request(n)` on the wire | none at all |
| Deadlines | per call, absolute, propagating | none built in | none |
| Browser client | needs grpc-web + a proxy | yes, over the WebSocket transport | yes, natively |
| Boot 4 starters | `spring-boot-starter-grpc-server` / `-client` | `spring-boot-starter-rsocket` | `spring-boot-starter-websocket` |
## The three server sides, side by side
**gRPC** ([`GrpcQuoteService`](../src/main/java/com/ankurm/protocols/GrpcQuoteService.java)) is
generated-class inheritance. There is no registration code: the generated `ImplBase` is a
`BindableService` and Boot registers every such bean.
```java
@Override
public void getQuote(QuoteRequest request, StreamObserver<Quote> observer) {
observer.onNext(toProto(source.at(request.getSymbol(), 0)));
observer.onCompleted();
}
```
**RSocket** ([`RSocketQuoteController`](../src/main/java/com/ankurm/protocols/RSocketQuoteController.java))
is annotation routing, and the interaction model *is the return type*:
```java
@MessageMapping("quote")
public Mono<Quote> quote(String symbol) { ... } // request/response
@MessageMapping("quotes")
public Flux<Quote> quotes(StreamSpec spec) { ... } // request/stream
```
**Raw WebSocket** ([`WebSocketQuoteHandler`](../src/main/java/com/ankurm/protocols/WebSocketQuoteHandler.java))
is a `switch` on a string, because there is nothing else:
```java
switch (parts[0]) {
case "QUOTE" -> send(session, source.at(parts[1], 0));
case "STREAM" -> { for (int i = 0; i < count; i++) { send(...); } }
...
}
```
That third block is the honest summary of what a raw WebSocket gives you: a bidirectional pipe
for text or bytes. Everything above it &mdash; routing, correlation, errors, versioning, demand
&mdash; is yours to invent, and inventing it is how teams end up with a private, undocumented
protocol that only its authors can debug. STOMP exists to stop that, and is covered in
[`../sse-websocket`](../../sse-websocket/README.md).
## Boot 4 version notes
Boot 4.1.1's BOM pins **grpc-java 1.83.1** and **protobuf-java 4.35.1**, both deliberately older
than the newest releases on Maven Central. Overriding them independently is how you get a
`NoSuchMethodError` between grpc-java and its shaded Netty. Note that these have moved since Boot
4.1.0, which pinned 1.80.0 and 4.34.2 &mdash; so a `spring.grpc.*` guide written against 4.1.0 is
already describing different jars.
RSocket is **rsocket-java 1.1.5**, and `spring-boot-starter-rsocket` brings
`jackson-dataformat-cbor` on purpose: CBOR, not JSON, is Spring's default RSocket data mime type.
---
Next: [2. How they measure](02-benchmarks.md)

View File

@@ -0,0 +1,74 @@
# 2. How they measure
Previous: [1. Three protocols](01-three-protocols.md) &middot; Next: [3. Payload](03-payload.md)
---
## Read this before the numbers
Client and server are **the same process on one machine**, talking over loopback: two cores,
3.9&nbsp;GB, JDK 25, Boot 4.1.1. That has three consequences, and ignoring them makes the numbers
say the opposite of the truth.
1. **There is no network.** On a real link, a 35-byte message and an 83-byte message differ by
more than the framing cost, and protobuf's size advantage starts paying. Here it does not.
2. **A protocol that does less looks faster.** WebSocket wins both benchmarks below partly because
it has no flow control, no deadlines, no schema and no per-call metadata. That is a real cost
difference, and it is also exactly what you give up.
3. **Ratios, not absolutes.** Absolute microsecond figures from a two-core sandbox mean nothing
for your hardware. The ordering and the rough factors are what transfer.
Re-measured on every `./scripts/run-all.sh`, and re-measured **in their own JVM invocation**.
That is not tidiness. The back-pressure tests in [chapter 4](04-backpressure.md) leave unbounded
producers spinning, and running the throughput benchmark after them on a two-core box halved
every number — gRPC fell from 44 801 msgs/s to 18 477. If you take one operational lesson from
this module rather than a protocol one, let it be that: a benchmark that shares a machine with
anything is measuring the machine.
## Request/response
5&nbsp;000 sequential calls after 2&nbsp;000 warm-up
([`request-response.txt`](output/request-response.txt)):
```
gRPC n=5000 p50= 291.3 us p99= 1015.3 us mean= 324.4 us ~3,083 calls/s
RSocket n=5000 p50= 300.9 us p99= 1337.4 us mean= 350.9 us ~2,850 calls/s
WebSocket n=5000 p50= 124.3 us p99= 1447.8 us mean= 172.8 us ~5,786 calls/s
```
**gRPC and RSocket are the same speed.** Within noise across runs the two swap places; treat them
as indistinguishable for request/response on a warm connection.
**Raw WebSocket is about twice as fast at the median &mdash; and has the worst tail.** p50 of
124&nbsp;&micro;s against a p99 of 1&nbsp;448&nbsp;&micro;s is a 12x spread. gRPC's is 3.5x. The
median is cheap because a text frame over an already-open socket is nearly free; the tail is bad
because nothing is scheduling anything, so a GC pause or a scheduling hiccup lands on the request
undiluted.
If your SLO is a percentile rather than an average &mdash; and it should be &mdash; that table
does not say what a first glance suggests.
## Server streaming
50&nbsp;000 messages on one connection
([`stream-throughput.txt`](output/stream-throughput.txt)):
```
gRPC 50,000 msgs in 1.116 s = 44,801 msgs/s ( 22.32 us each)
RSocket 50,000 msgs in 0.702 s = 71,175 msgs/s ( 14.05 us each)
WebSocket 50,000 msgs in 0.459 s = 109,022 msgs/s ( 9.17 us each)
```
Same ordering, wider gaps: WebSocket about 2.4x gRPC, RSocket about 1.6x.
Now read the WebSocket row again with its harness in view. The client's inbox is an
`ArrayBlockingQueue` sized to hold **all fifty thousand messages**, because there was nothing else
to do with them &mdash; a raw WebSocket consumer that falls behind can buffer or drop, and those
are the only two options. WebSocket "won" this benchmark by buffering the entire stream in the
client's heap.
That is not a rhetorical point. It is the next chapter.
---
Previous: [1. Three protocols](01-three-protocols.md) &middot; Next: [3. Payload](03-payload.md)

View File

@@ -0,0 +1,56 @@
# 3. Payload: the one comparison that needs no benchmark
Previous: [2. Benchmarks](02-benchmarks.md) &middot; Next: [4. Back-pressure](04-backpressure.md)
---
Five fields &mdash; a symbol, a sequence number, two doubles and a timestamp &mdash; encoded three
ways ([`payload-sizes.txt`](output/payload-sizes.txt)):
```
protobuf : 35 bytes 0a044141504c102a197b14ae47e11a5940215c8fc2f5281c5940288080abb4fef39203
JSON : 83 bytes {"symbol":"AAPL","seq":42,"bid":100.42,"ask":100.44,"epochMicros":1772000000000000}
CBOR : 67 bytes bf6673796d626f6c644141504c63736571182a63626964fb40591ae147ae147b6361736bfb40591c28f5c28f5c6b65706f63684d6963726f731b00064b9fe68ac000ff
JSON is 2.37x protobuf; CBOR is 1.91x protobuf
```
This is a property of the formats, not of a machine, so it is the one row of the comparison that
transfers to your hardware unchanged.
**Protobuf is 35 bytes because field names are integers.** `0a04 41 41 50 4c` is field 1, length
4, `AAPL`. There is no `"symbol"` on the wire at all &mdash; the name lives in the `.proto` file
that both sides compiled against. That is where the size comes from, and it is also the whole
argument about schemas: the saving and the coupling are the same fact.
**CBOR is 67 bytes and still writes the field names.** `6673796d626f6c` is a 6-character text
string, `symbol`. CBOR is binary JSON, not a schema format: it saves the punctuation and encodes
numbers compactly, and it keeps every key. So its advantage over JSON is real but modest, and it
does not require the coupling.
Concretely, on a stream of a million quotes: 35&nbsp;MB, 67&nbsp;MB, 83&nbsp;MB. On a loopback
that difference disappears into memory bandwidth &mdash; which is exactly why the benchmarks in
[chapter 2](02-benchmarks.md) do not reward it and a real link would.
Two practical notes:
- **Spring's RSocket default is CBOR**, not JSON, which is why
`spring-boot-starter-rsocket` brings `jackson-dataformat-cbor`. If you are debugging with
`tcpdump` and expecting to read your payloads, that is why you cannot. The mechanism is codec
ordering, printed by
[`RSocketDefaultsTest`](../src/test/java/com/ankurm/protocols/RSocketDefaultsTest.java):
```
encoders : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder, JacksonCborEncoder, JacksonJsonEncoder]
bare RSocketStrategies.create() : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder]
```
CBOR sits *ahead of* JSON in the Boot-configured list, which is the whole of why it wins. Note
also that a bare `RSocketStrategies.create()` carries neither — an `RSocketRequester` built
without injecting Boot's strategies cannot encode your objects at all. And the class names
follow the Boot 4 rule: `JacksonCborEncoder`, not `Jackson2…`.
- **You can put protobuf on RSocket.** The encoding and the protocol are independent choices;
the row above is what each stack does *by default*, not what it is capable of.
---
Previous: [2. Benchmarks](02-benchmarks.md) &middot; Next: [4. Back-pressure](04-backpressure.md)

View File

@@ -0,0 +1,112 @@
# 4. Back-pressure: the measurement that decides it
Previous: [3. Payload](03-payload.md) &middot; Next: [5. Choosing](05-choosing.md)
---
Every protocol comparison eventually reduces to one question, and it is not throughput. It is:
> **When the consumer stops keeping up, what happens?**
[`BackPressureTest`](../src/test/java/com/ankurm/protocols/BackPressureTest.java) asks it the
simplest way there is. Each server offers an unbounded stream. Each client takes a hundred
messages and then does nothing. How many did the server produce?
The sweep over quiet periods is what makes the answer conclusive. A number that stays flat as the
wait grows means something bounded the producer. A number that keeps climbing means nothing did.
[`backpressure.txt`](output/backpressure.txt):
```
protocol quiet 0.5s quiet 1s quiet 2s
RSocket 100 100 100
gRPC 103,425 449,318 1,208,219
WebSocket 19,664 143,870 320,255
```
RSocket produced **exactly one hundred**, at every quiet period. gRPC produced over a million in
two seconds. Raw WebSocket, three hundred thousand. These are re-measured on every run and the
absolute counts move by tens of percent; the three orders of magnitude between the rows do not.
(The growth is faster than linear because the loop is still being JIT-compiled during the shortest
run. The shape is the finding, not the exponent.)
## Why RSocket stops
`request(n)` is a **frame on the wire**. When the client's subscriber calls `subscription.request(100)`,
a REQUEST_N frame carries that number to the server, and Reactor's `Flux.generate` is called
exactly a hundred times and then not again. Nothing blocks; there is no buffer to size and no
thread parked. The producer simply is not invoked.
That is what "Reactive Streams over the network" means, and it is the entire reason RSocket
exists. The same property is why RSocket's streaming throughput in
[chapter 2](02-benchmarks.md) is respectable rather than the highest: it is doing real work per
batch that the others are not doing at all.
## Why the other two do not
**Raw WebSocket has no mechanism.** The frame protocol has no notion of demand. The server writes
until something underneath refuses &mdash; a socket buffer, Spring's `sendBufferSizeLimit`, the
heap. Every one of those 320&nbsp;255 messages was serialised and written for a client that had
asked for a hundred.
**gRPC has HTTP/2 flow control, and it did not help.** This is the result I did not expect.
`StreamObserver.onNext` on a gRPC server **does not block**, so a plain server-streaming loop keeps
producing regardless of what the client is doing.
The documented answer is `ServerCallStreamObserver.isReady()`, and this module tests it rather than
citing it ([`grpc-isready.txt`](output/grpc-isready.txt)):
```
handler quiet 0.5s quiet 2s
plain onNext loop 258,145 1,421,778
isReady() checked before write 667,259 1,044,018
```
**It did not bound the producer.** The `isReady()` row is not lower than the plain one in any
useful sense &mdash; it produced 667&nbsp;thousand messages in half a second for a client that
wanted a hundred &mdash; and across runs it has come out both far above and far below the plain
loop, which is its own kind of answer. I
have not established whether the surplus accumulates in the server's outbound queue, in the client
transport, or is discarded after the deframer, and I am not going to assert a mechanism I have not
read the source for.
What the numbers do establish is narrower and still worth knowing: **`isReady()` is not a demand
signal**, and on a fast link with a stalled consumer it does not behave like one. If you are
streaming from gRPC to a consumer that can fall behind, the bound has to come from your own
design &mdash; a paged API, an explicit "send me more" message on a bidirectional stream, or a
`count` in the request as `StreamQuotes` uses.
## The honest caveat
A loopback connection is the worst case for this test in the fairest possible way: the wire drains
instantly, so nothing downstream ever pushes back. On a slow real link, TCP and the HTTP/2 window
*would* eventually stop the gRPC and WebSocket producers &mdash; by blocking a thread or by
filling a buffer, but they would stop.
That does not rescue them, because the failure mode you actually meet is not "the network is
slow", it is "one consumer of forty is slow", and a shared server discovers that as latency for
everyone else or as a heap that will not shrink. The difference RSocket makes is that the slow
consumer's slowness is *expressed*, in a number, that the producer can act on before anything
fills up.
## The other thing gRPC does not do for you
Abandoning a gRPC stream produced this, repeatedly, in the server log:
```
java.lang.IllegalStateException: Failed to close the call
Caused by: java.lang.IllegalStateException: call already closed
```
That is the cancellation story from
[the Spring gRPC article](https://ankurm.com/spring-grpc-spring-boot-4/) showing up in practice:
gRPC does not interrupt your thread when a client leaves, it sets a flag on the `Context`, and a
handler that touches an already-closed stream throws. The loop in
[`GrpcQuoteService`](../src/main/java/com/ankurm/protocols/GrpcQuoteService.java) checks
`Context.current().isCancelled()` and returns without calling `onCompleted` for exactly this
reason.
---
Previous: [3. Payload](03-payload.md) &middot; Next: [5. Choosing](05-choosing.md)

View File

@@ -0,0 +1,63 @@
# 5. Choosing
Previous: [4. Back-pressure](04-backpressure.md)
---
## The decision table
| If | Pick | Because |
|---|---|---|
| Service-to-service RPC, polyglot team, you want a schema | **gRPC** | The `.proto` is a contract other languages generate from. Deadlines are absolute and propagate. Boot 4 makes it a first-class starter. |
| A stream whose consumer can fall behind | **RSocket** | `request(n)` is the only demand signal that crosses the wire. 100 vs 1.3 million is not a tuning difference. |
| A browser is one of the peers | **WebSocket**, with STOMP on it | gRPC needs grpc-web and a proxy. RSocket over WebSocket works but the client ecosystem is thin. |
| Server pushes, client never speaks | **none of these** — use SSE | See [`../sse-websocket`](../../sse-websocket/README.md). |
| You want the lowest possible median latency and control both ends | **raw WebSocket** | It is fastest here because it does the least. Read the p99 before deciding that is what you want. |
| Long-lived bidirectional exchange with independent streams | **RSocket channel** or **gRPC bidi** | Both give you two independent streams; only RSocket gives each of them demand. |
## What each one really costs
**gRPC** costs a build step, a schema you must version, and an operational story for HTTP/2 through
your proxies. In exchange you get the best-supported cross-language RPC there is, and deadlines
&mdash; which is a bigger deal than it sounds, because an absolute deadline that propagates across
hops is the single most effective defence against one slow dependency taking down a system.
**RSocket** costs ecosystem. It is the least deployed of the three, the client libraries outside
Java and JavaScript are thin, and if you are not already reactive, `Mono` and `Flux` in your
service signatures is a real commitment. In exchange you get the only demand signalling in the
list, plus resumption and leasing.
**Raw WebSocket** costs everything you then invent: routing, correlation, error shape, versioning,
and the demand mechanism you will eventually need. That is the cost STOMP exists to prevent, and
if you find yourself designing a text protocol with a command word at the front &mdash; as
[`WebSocketQuoteHandler`](../src/main/java/com/ankurm/protocols/WebSocketQuoteHandler.java)
deliberately does &mdash; that is the signal to stop and use one.
## Should you use any of them?
Three cases where the answer is no:
- **Request/response between two services that both speak HTTP already.** A REST endpoint with a
`RestClient` and a timeout is fine, and everything in your organisation already knows how to
observe, cache, proxy and debug it. Reach for gRPC when the schema or the deadline propagation
is the thing you need, not because it benchmarks faster.
- **Streaming that is really batching.** If the consumer processes a page at a time anyway,
paginated HTTP has back-pressure for free: the consumer asks for the next page when it is ready.
That is `request(n)` with a URL.
- **Events that outlive the connection.** None of these three persist anything. A dropped
connection loses the messages in flight. If that matters, the answer is a broker &mdash;
[`../kafka-basics`](../../kafka-basics/README.md),
[`../rabbitmq`](../../rabbitmq/README.md), and the comparison in
[`../broker-comparison`](../../broker-comparison/README.md).
## And the benchmark caveat, one more time
Every number in this module was measured with client and server in one JVM on one machine. That
deletes the network, which is where protobuf's 35 bytes would pay and where the tail latencies
would look completely different. The measurement that does transfer is
[chapter 4](04-backpressure.md), because "how many did the server produce for a consumer that
wanted a hundred" is a property of the protocol and not of the link.
---
Previous: [4. Back-pressure](04-backpressure.md)

View File

@@ -0,0 +1,10 @@
=== unbounded stream; client takes 100, then goes quiet ===
protocol quiet 0.5s quiet 1s quiet 2s
RSocket 100 100 100
gRPC 103,425 449,318 1,208,219
WebSocket 19,664 143,870 320,255
RSocket is flat: the server produced exactly what request(n) asked for.
gRPC grows 11.7x between 0.5 s and 2 s; WebSocket grows 16.3x.
Growth that tracks the wait, rather than settling at a buffer size,
means nothing bounded the producer. (Growth is faster than linear
because the loop is still being JIT-compiled during the first run.)

View File

@@ -0,0 +1,4 @@
=== gRPC server streaming, client takes 100 then stops ===
handler quiet 0.5s quiet 2s
plain onNext loop 258,145 1,421,778
isReady() checked before write 667,259 1,044,018

View File

@@ -0,0 +1,5 @@
=== one Quote, five fields, three encodings ===
protobuf : 35 bytes 0a044141504c102a197b14ae47e11a5940215c8fc2f5281c5940288080abb4fef39203
JSON : 83 bytes {"symbol":"AAPL","seq":42,"bid":100.42,"ask":100.44,"epochMicros":1772000000000000}
CBOR : 67 bytes bf6673796d626f6c644141504c63736571182a63626964fb40591ae147ae147b6361736bfb40591c28f5c28f5c6b65706f63684d6963726f731b00064b9fe68ac000ff
JSON is 2.37x protobuf; CBOR is 1.91x protobuf

View File

@@ -0,0 +1,4 @@
=== request/response, 5000 sequential calls after 2000 warm-up, loopback, JDK 25.0.4.1 ===
gRPC n=5000 p50= 302.0 us p99= 994.7 us mean= 335.1 us ~2,984 calls/s
RSocket n=5000 p50= 265.9 us p99= 1166.2 us mean= 311.9 us ~3,206 calls/s
WebSocket n=5000 p50= 122.8 us p99= 1893.9 us mean= 184.8 us ~5,410 calls/s

View File

@@ -0,0 +1,4 @@
=== Boot-configured RSocketStrategies ===
encoders : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder, JacksonCborEncoder, JacksonJsonEncoder]
decoders : [StringDecoder, ByteBufferDecoder, ByteArrayDecoder, DataBufferDecoder, JacksonCborDecoder, JacksonJsonDecoder]
bare RSocketStrategies.create() encoders : [CharSequenceEncoder, ByteBufferEncoder, ByteArrayEncoder, DataBufferEncoder]

View File

@@ -0,0 +1,4 @@
=== server streaming, 50000 messages on one connection, loopback ===
gRPC 50,000 msgs in 1.341 s = 37,285 msgs/s ( 26.82 us each)
RSocket 50,000 msgs in 0.900 s = 55,580 msgs/s ( 17.99 us each)
WebSocket 50,000 msgs in 0.839 s = 59,630 msgs/s ( 16.77 us each)

View File

@@ -0,0 +1,6 @@
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.321 s -- in com.ankurm.protocols.PayloadSizeTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.76 s -- in com.ankurm.protocols.RequestResponseBenchmarkTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.29 s -- in com.ankurm.protocols.BackPressureTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.409 s -- in com.ankurm.protocols.GrpcIsReadyTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.474 s -- in com.ankurm.protocols.RSocketDefaultsTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.244 s -- in com.ankurm.protocols.StreamThroughputBenchmarkTest

View File

@@ -0,0 +1,95 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>protocol-comparison</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- One application speaks all three protocols, so the comparison is not confounded by
different JVMs, different heaps or different warm-up states. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- RSocket. The starter drags in reactor-netty and jackson-dataformat-cbor; CBOR is not
an accident, it is RSocket's default data mime type in Spring. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-rsocket</artifactId>
</dependency>
<!-- gRPC. Two starters, both Boot-managed. See ankurm.com/spring-grpc-spring-boot-4/ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-grpc-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- protoc and the gRPC codegen plugin resolve as Maven artifacts for this OS and
architecture. Nothing to install locally. Both versions come from the Boot BOM;
overriding them independently is how you get a NoSuchMethodError between grpc-java
and its shaded Netty. -->
<plugin>
<groupId>io.github.ascopes</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>3.9.0</version>
<configuration>
<protocVersion>${protobuf-java.version}</protocVersion>
<binaryMavenPlugins>
<binaryMavenPlugin>
<groupId>io.grpc</groupId>
<artifactId>protoc-gen-grpc-java</artifactId>
<version>${grpc-java.version}</version>
</binaryMavenPlugin>
</binaryMavenPlugins>
</configuration>
<executions>
<execution><goals><goal>generate</goal></goals></execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Regenerate every file under docs/output/. Needs a JDK and nothing else: protoc and the gRPC
# codegen plugin resolve as Maven artifacts, and all three servers start inside the test JVM.
#
# The benchmark numbers are NOT deterministic. They are re-measured on every run and the ratios
# between the three protocols are what the documentation quotes.
set -eu
cd "$(dirname "$0")/.."
LOG=/tmp/protocol-comparison-test.log
# Everything, for the test count and the non-timing transcripts.
MAVEN_OPTS=${MAVEN_OPTS:--Xmx900m} mvn -B test > "$LOG" 2>&1
# The two timing benchmarks are then re-run ALONE. This is not tidiness: the back-pressure and
# isReady tests leave unbounded producers spinning, and on a two-core box that halves every
# throughput number measured after them. Benchmarks share a JVM with nothing.
BENCH=/tmp/protocol-comparison-bench.log
MAVEN_OPTS=${MAVEN_OPTS:--Xmx900m} mvn -B test \
-Dtest=RequestResponseBenchmarkTest,StreamThroughputBenchmarkTest > "$BENCH" 2>&1
# Interleaved log lines from other contexts have to come out, or the transcripts are unreadable.
clean() { grep -vE '^2026-|^\s*$|^WARNING|^[[:space:]]+at |^Caused by|^java\.|^org\.|Initializing|Completed initialization'; }
sed -n '/=== one Quote, five fields/,/^JSON is /p' "$LOG" | clean > docs/output/payload-sizes.txt
sed -n '/=== request\/response/,/^WebSocket /p' "$BENCH" | clean > docs/output/request-response.txt
sed -n '/=== server streaming, 50000/,/^WebSocket /p' "$BENCH" | clean > docs/output/stream-throughput.txt
{ grep -m1 '=== unbounded stream' "$LOG"; grep -m1 '^protocol ' "$LOG"
grep -m1 '^RSocket *[0-9]' "$LOG"; grep -m1 '^gRPC *[0-9]' "$LOG"
grep -m1 '^WebSocket *[0-9]' "$LOG"
sed -n '/^RSocket is flat/,/JIT-compiled/p' "$LOG"; } > docs/output/backpressure.txt
{ grep -m1 '=== gRPC server streaming' "$LOG"; grep -m1 '^handler ' "$LOG"
grep -m1 '^plain onNext loop' "$LOG"; grep -m1 '^isReady() checked' "$LOG"; } \
> docs/output/grpc-isready.txt
sed -n '/=== Boot-configured RSocketStrategies/,/^bare RSocketStrategies/p' "$LOG" | clean \
> docs/output/rsocket-codecs.txt
grep -E 'Tests run:.*in com\.ankurm' "$LOG" | sed 's/^\[INFO\] //' > docs/output/tests.txt
echo "regenerated:"; ls -1 docs/output/

View File

@@ -0,0 +1,121 @@
package com.ankurm.protocols;
import com.ankurm.protocols.grpc.Quote;
import com.ankurm.protocols.grpc.QuoteRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import com.ankurm.protocols.grpc.StreamRequest;
import io.grpc.Context;
import io.grpc.stub.ServerCallStreamObserver;
import io.grpc.stub.StreamObserver;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;
/**
* The gRPC side. No registration code: the generated {@code ImplBase} is a
* {@code BindableService} and Boot registers every such bean with the server.
*
* <p>The cancellation check in {@link #streamQuotes} is not decoration. gRPC does not interrupt
* your thread when a client goes away &mdash; it sets a flag on the {@link Context} &mdash; so a
* loop that never looks keeps producing into a stream nobody is reading. That is covered at
* length in <a href="https://ankurm.com/spring-grpc-spring-boot-4/">the Spring gRPC article</a>;
* here it matters because the back-pressure benchmark deliberately abandons a stream.
*
* @see <a href="../../../../../docs/04-backpressure.md">docs/04-backpressure.md</a>
*/
@Service
public class GrpcQuoteService extends QuoteServiceGrpc.QuoteServiceImplBase {
/** Counts what the SERVER produced, comparable with the RSocket and WebSocket counters. */
static final AtomicLong PRODUCED = new AtomicLong();
private final QuoteSource source;
GrpcQuoteService(QuoteSource source) {
this.source = source;
}
@Override
public void getQuote(QuoteRequest request, StreamObserver<Quote> observer) {
observer.onNext(toProto(source.at(request.getSymbol(), 0)));
observer.onCompleted();
}
/**
* An effectively unbounded stream, for the back-pressure comparison. A consumer that stops
* calling {@code next()} closes the HTTP/2 flow-control window, and this loop blocks inside
* {@code onNext} &mdash; so the server stops producing, but it stops by blocking a thread
* rather than by being asked to stop.
*/
@Override
public void streamUnbounded(QuoteRequest request, StreamObserver<Quote> observer) {
PRODUCED.set(0);
while (!Context.current().isCancelled()) {
observer.onNext(toProto(source.at(request.getSymbol(), PRODUCED.get())));
PRODUCED.incrementAndGet();
}
}
/**
* The fix, and the reason the unfixed version is worth measuring.
*
* <p>{@code StreamObserver.onNext} on a gRPC server <strong>never blocks</strong>. If the
* client is not reading, the message is queued in the server's outbound buffer and the loop
* keeps going &mdash; HTTP/2 flow control governs the wire, not your code. The only thing
* that connects the two is {@link ServerCallStreamObserver#isReady()}, and using it means
* restructuring the handler around {@code setOnReadyHandler} rather than writing a loop.
*
* <p>The spin here is deliberately the simplest possible demonstration rather than the
* shape you would ship; a real implementation registers an on-ready handler and returns.
*/
@Override
public void streamUnboundedReady(QuoteRequest request, StreamObserver<Quote> observer) {
PRODUCED.set(0);
ServerCallStreamObserver<Quote> ready = (ServerCallStreamObserver<Quote>) observer;
while (!ready.isCancelled()) {
if (!ready.isReady()) {
Thread.onSpinWait();
continue;
}
ready.onNext(toProto(source.at(request.getSymbol(), PRODUCED.get())));
PRODUCED.incrementAndGet();
}
}
@Override
public void produced(QuoteRequest request, StreamObserver<com.ankurm.protocols.grpc.ProducedCount> observer) {
observer.onNext(com.ankurm.protocols.grpc.ProducedCount.newBuilder()
.setCount(PRODUCED.get()).build());
observer.onCompleted();
}
@Override
public void streamQuotes(StreamRequest request, StreamObserver<Quote> observer) {
for (int i = 0; i < request.getCount(); i++) {
if (Context.current().isCancelled()) {
// Do NOT call onCompleted/onError here: the stream is already closed and
// touching it throws IllegalStateException. Just return.
return;
}
observer.onNext(toProto(source.at(request.getSymbol(), i)));
if (request.getDelayMs() > 0) {
try {
Thread.sleep(request.getDelayMs());
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return;
}
}
}
observer.onCompleted();
}
static Quote toProto(com.ankurm.protocols.Quote q) {
return Quote.newBuilder()
.setSymbol(q.symbol()).setSeq(q.seq())
.setBid(q.bid()).setAsk(q.ask())
.setEpochMicros(q.epochMicros())
.build();
}
}

View File

@@ -0,0 +1,22 @@
package com.ankurm.protocols;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* One application, three protocols, one set of data.
*
* <p>gRPC on 9090, RSocket on 7000, WebSocket on the servlet port. Serving the same two
* operations from the same JVM is the only way to make a benchmark mean anything: the heap, the
* JIT state, the CPU and the data generator are shared, so a difference in the numbers is a
* difference in the transports.
*
* @see <a href="../../../../../docs/01-three-protocols.md">docs/01-three-protocols.md</a>
*/
@SpringBootApplication
public class ProtocolComparisonApplication {
public static void main(String[] args) {
SpringApplication.run(ProtocolComparisonApplication.class, args);
}
}

View File

@@ -0,0 +1,8 @@
package com.ankurm.protocols;
/**
* The JSON/CBOR shape, field-for-field identical to the protobuf {@code Quote} message, so the
* encoded sizes in {@code docs/output/payload-sizes.txt} compare like with like.
*/
public record Quote(String symbol, long seq, double bid, double ask, long epochMicros) {
}

View File

@@ -0,0 +1,22 @@
package com.ankurm.protocols;
import java.time.Instant;
import org.springframework.stereotype.Component;
/**
* Deterministic quote generation, shared by all three protocol adapters.
*
* <p>Deliberately cheap and deliberately allocation-free apart from the record: a benchmark of
* three transports must not spend its time in the thing behind them.
*/
@Component
public class QuoteSource {
public Quote at(String symbol, long seq) {
double base = 100.0 + (seq % 97) * 0.01;
Instant now = Instant.now();
return new Quote(symbol, seq, base, base + 0.02,
now.getEpochSecond() * 1_000_000L + now.getNano() / 1_000L);
}
}

View File

@@ -0,0 +1,72 @@
package com.ankurm.protocols;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* The RSocket side. The same two operations, and one extra that exists only to make
* back-pressure visible.
*
* <p>Note what the signatures say that the other two protocols' cannot: {@code Mono} means
* request/response, {@code Flux} means request/stream, and the choice of interaction model is
* the return type rather than a separate declaration. RSocket has four (fire-and-forget,
* request/response, request/stream, channel) and Spring picks from the method shape.
*
* @see <a href="../../../../../docs/04-backpressure.md">docs/04-backpressure.md</a>
*/
@Controller
public class RSocketQuoteController {
private static final Logger log = LoggerFactory.getLogger(RSocketQuoteController.class);
/** Counts what the SERVER produced, which is the number the back-pressure test cares about. */
static final AtomicLong PRODUCED = new AtomicLong();
private final QuoteSource source;
RSocketQuoteController(QuoteSource source) {
this.source = source;
}
@MessageMapping("quote")
public Mono<Quote> quote(String symbol) {
return Mono.just(source.at(symbol, 0));
}
@MessageMapping("quotes")
public Flux<Quote> quotes(StreamSpec spec) {
Flux<Quote> flux = Flux.range(0, spec.count()).map(i -> source.at(spec.symbol(), i));
return spec.delayMs() > 0 ? flux.delayElements(Duration.ofMillis(spec.delayMs())) : flux;
}
/**
* An unbounded generator. It emits only when the transport asks, so the count it reaches is
* a direct read-out of how much the consumer requested &mdash; which is the whole argument
* for RSocket.
*/
@MessageMapping("quotes.unbounded")
public Flux<Quote> unbounded(String symbol) {
PRODUCED.set(0);
return Flux.generate(() -> 0L, (seq, sink) -> {
sink.next(source.at(symbol, seq));
PRODUCED.incrementAndGet();
return seq + 1;
});
}
@MessageMapping("produced")
public Mono<Long> produced() {
return Mono.just(PRODUCED.get());
}
/** Request payload for {@code quotes}. */
public record StreamSpec(String symbol, int count, int delayMs) {
}
}

View File

@@ -0,0 +1,27 @@
package com.ankurm.protocols;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
/**
* {@code @EnableWebSocket}, not {@code @EnableWebSocketMessageBroker}: this module wants the raw
* transport, with no broker in the path, so the benchmark measures a socket rather than a routing
* layer. The {@code sse-websocket} module in this repository is the STOMP version.
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final WebSocketQuoteHandler handler;
WebSocketConfig(WebSocketQuoteHandler handler) {
this.handler = handler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler, "/quotes").setAllowedOriginPatterns("*");
}
}

View File

@@ -0,0 +1,94 @@
package com.ankurm.protocols;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import tools.jackson.databind.ObjectMapper;
/**
* The WebSocket side, with no STOMP and no framework on top: a two-word text protocol, so the
* benchmark measures the transport and not a broker.
*
* <pre>
* QUOTE &lt;symbol&gt; -&gt; one JSON quote
* STREAM &lt;symbol&gt; &lt;count&gt; &lt;delayMs&gt; -&gt; count JSON quotes, then "END"
* UNBOUNDED &lt;symbol&gt; -&gt; quotes until the session dies
* </pre>
*
* <p>The absence worth noticing is in {@code UNBOUNDED}: there is nowhere for a consumer to say
* how many it wants. {@code sendMessage} either succeeds, blocks, or eventually throws when
* Spring's send buffer limit is exceeded. That is not a gap in this handler &mdash; it is what a
* raw WebSocket offers.
*
* @see <a href="../../../../../docs/04-backpressure.md">docs/04-backpressure.md</a>
*/
@Component
public class WebSocketQuoteHandler extends TextWebSocketHandler {
/** Counts what the SERVER wrote, comparable with RSocket's PRODUCED. */
static final AtomicLong PRODUCED = new AtomicLong();
private final QuoteSource source;
private final ObjectMapper mapper;
WebSocketQuoteHandler(QuoteSource source, ObjectMapper mapper) {
this.source = source;
this.mapper = mapper;
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String[] parts = message.getPayload().trim().split("\\s+");
switch (parts[0]) {
case "QUOTE" -> send(session, source.at(parts[1], 0));
case "STREAM" -> {
int count = Integer.parseInt(parts[2]);
int delay = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
for (int i = 0; i < count; i++) {
send(session, source.at(parts[1], i));
if (delay > 0) {
Thread.sleep(delay);
}
}
session.sendMessage(new TextMessage("END"));
}
case "UNBOUNDED" -> {
PRODUCED.set(0);
// No requestN. Nothing here can be told to slow down: the loop writes until the
// socket, the send buffer or the send-time limit stops it. Catching the write
// failure keeps an abandoned stream from filling the log with one stack trace
// per run -- and note that catching it is the ONLY notification that arrives.
try {
while (session.isOpen()) {
send(session, source.at(parts[1], PRODUCED.get()));
PRODUCED.incrementAndGet();
}
}
catch (Exception ex) {
// Broken pipe. The client left; there was no other way to find out.
}
}
case "PRODUCED" -> session.sendMessage(new TextMessage(Long.toString(PRODUCED.get())));
default -> session.sendMessage(new TextMessage("ERR unknown command"));
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
// Where an over-buffered slow consumer surfaces: as a transport error on the writer,
// long after the consumer stopped reading.
PRODUCED.addAndGet(0);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
}
private void send(WebSocketSession session, Quote quote) throws Exception {
session.sendMessage(new TextMessage(mapper.writeValueAsString(quote)));
}
}

View File

@@ -0,0 +1,45 @@
syntax = "proto3";
package quotes;
option java_multiple_files = true;
option java_package = "com.ankurm.protocols.grpc";
// The same two operations the RSocket and WebSocket sides expose, so the comparison is between
// transports rather than between designs.
service QuoteService {
// Request/response.
rpc GetQuote (QuoteRequest) returns (Quote);
// Server streaming: count quotes, as fast as the transport allows.
rpc StreamQuotes (StreamRequest) returns (stream Quote);
// Never completes on its own. Used to show what stops a gRPC server producing.
rpc StreamUnbounded (QuoteRequest) returns (stream Quote);
// The same unbounded stream, but the server checks ServerCallStreamObserver.isReady()
// before every write. Same API, different memory profile.
rpc StreamUnboundedReady (QuoteRequest) returns (stream Quote);
// How many messages the server has produced on the current unbounded stream.
rpc Produced (QuoteRequest) returns (ProducedCount);
}
message ProducedCount {
int64 count = 1;
}
message QuoteRequest {
string symbol = 1;
}
message StreamRequest {
string symbol = 1;
int32 count = 2;
// Milliseconds to sleep between emissions; 0 means "as fast as possible".
int32 delay_ms = 3;
}
message Quote {
string symbol = 1;
int64 seq = 2;
double bid = 3;
double ask = 4;
int64 epoch_micros = 5;
}

View File

@@ -0,0 +1,22 @@
spring:
application:
name: protocol-comparison
rsocket:
server:
# RSocket gets its own port and its own Reactor Netty server. TCP here; "websocket" is the
# other transport and is what you use when the client is a browser.
port: 7000
transport: tcp
grpc:
server:
# spring.grpc.server.port -- note the spring. prefix and that "port: -1" means in-process
# only. See the property table in ankurm.com/spring-grpc-spring-boot-4/
port: 9090
server:
port: 8080
logging:
level:
io.grpc: WARN
io.rsocket: WARN

View File

@@ -0,0 +1,156 @@
package com.ankurm.protocols;
import java.util.Iterator;
import java.util.concurrent.atomic.AtomicLong;
import com.ankurm.protocols.grpc.QuoteRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.junit.jupiter.api.Test;
import org.reactivestreams.Subscription;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.messaging.rsocket.RSocketStrategies;
import reactor.core.publisher.BaseSubscriber;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The measurement that decides the article.
*
* <p>Each protocol serves an unbounded stream. Each client takes a hundred messages and then does
* nothing for a while. The question is: <strong>how many did the server produce?</strong>
*
* <p>The sweep over quiet periods is what makes the answer conclusive. A number that stays flat
* as the quiet period grows means something bounded the producer. A number that grows in
* proportion to the wait means <em>nothing did</em> &mdash; the producer is running flat out and
* the only limit is the clock.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"spring.rsocket.server.port=7003", "spring.grpc.server.port=9093"})
class BackPressureTest {
private static final int WANTED = 100;
private static final long[] QUIET_MS = {500, 1_000, 2_000};
@LocalServerPort
int httpPort;
@Autowired
RSocketStrategies strategies;
@Test
void howManyDidTheServerProduce() throws Exception {
System.out.println("=== unbounded stream; client takes " + WANTED + ", then goes quiet ===");
System.out.printf("%-14s %10s %10s %10s%n", "protocol", "quiet 0.5s", "quiet 1s", "quiet 2s");
long[] rs = new long[QUIET_MS.length];
long[] gr = new long[QUIET_MS.length];
long[] ws = new long[QUIET_MS.length];
for (int i = 0; i < QUIET_MS.length; i++) {
rs[i] = rsocket(QUIET_MS[i]);
gr[i] = grpc(QUIET_MS[i]);
ws[i] = websocket(QUIET_MS[i]);
}
row("RSocket", rs);
row("gRPC", gr);
row("WebSocket", ws);
System.out.println();
System.out.println("RSocket is flat: the server produced exactly what request(n) asked for.");
System.out.printf("gRPC grows %.1fx between 0.5 s and 2 s; WebSocket grows %.1fx.%n",
gr[2] / (double) gr[0], ws[2] / (double) ws[0]);
System.out.println("Growth that tracks the wait, rather than settling at a buffer size,");
System.out.println("means nothing bounded the producer. (Growth is faster than linear");
System.out.println("because the loop is still being JIT-compiled during the first run.)");
// RSocket delivered exactly the demand, at every quiet period.
assertThat(rs).containsOnly(WANTED);
// The other two grew with the wait rather than settling at a buffer size.
assertThat(gr[2]).isGreaterThan(gr[0] * 2);
assertThat(ws[2]).isGreaterThan(ws[0] * 2);
}
private void row(String label, long[] v) {
System.out.printf("%-14s %,10d %,10d %,10d%n", label, v[0], v[1], v[2]);
}
/**
* RSocket: {@code request(n)} is a frame on the wire. The server is told the number, produces
* that many, and stops. Nothing blocks and nothing buffers &mdash; the producer is simply not
* called again.
*/
private long rsocket(long quietMs) throws Exception {
RSocketRequester requester = RSocketRequester.builder()
.rsocketStrategies(strategies).tcp("localhost", 7003);
try {
AtomicLong received = new AtomicLong();
BaseSubscriber<Quote> subscriber = new BaseSubscriber<>() {
@Override
protected void hookOnSubscribe(Subscription subscription) {
subscription.request(WANTED); // and never again
}
@Override
protected void hookOnNext(Quote value) {
received.incrementAndGet();
}
};
requester.route("quotes.unbounded").data("AAPL")
.retrieveFlux(Quote.class).subscribe(subscriber);
Thread.sleep(quietMs);
long produced = requester.route("produced").retrieveMono(Long.class).block();
subscriber.dispose();
assertThat(received.get()).isEqualTo(WANTED);
return produced;
}
finally {
requester.dispose();
}
}
/**
* gRPC: there is no application-level demand signal. The blocking stub's {@code Iterator}
* requests one message per {@code next()}, and the handler writes with
* {@code StreamObserver.onNext}, which never blocks.
*/
private long grpc(long quietMs) throws Exception {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9093)
.usePlaintext().build();
try {
var stub = QuoteServiceGrpc.newBlockingStub(channel);
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
Iterator<com.ankurm.protocols.grpc.Quote> it = stub.streamUnbounded(req);
long received = 0;
while (received < WANTED && it.hasNext()) {
it.next();
received++;
}
Thread.sleep(quietMs);
return stub.produced(req).getCount();
}
finally {
channel.shutdownNow();
}
}
/** Raw WebSocket: the frame protocol has no notion of demand at all. */
private long websocket(long quietMs) throws Exception {
try (WsClient client = new WsClient(httpPort, WANTED)) {
client.send("UNBOUNDED AAPL");
long received = 0;
while (received < WANTED && client.take(5_000) != null) {
received++;
}
Thread.sleep(quietMs);
// Ask on a second connection: the first one's server-side thread is busy writing.
try (WsClient asker = new WsClient(httpPort, 8)) {
asker.send("PRODUCED");
return Long.parseLong(asker.take(5_000));
}
}
}
}

View File

@@ -0,0 +1,29 @@
package com.ankurm.protocols;
import java.util.Arrays;
/** Latency bookkeeping, kept in one place so the three protocols are measured identically. */
final class Bench {
private final long[] nanos;
private int i;
Bench(int n) {
this.nanos = new long[n];
}
void record(long ns) {
nanos[i++] = ns;
}
/** p50, p99 and mean in microseconds, plus calls per second derived from the mean. */
String summary(String label) {
long[] sorted = Arrays.copyOf(nanos, i);
Arrays.sort(sorted);
double p50 = sorted[(int) (sorted.length * 0.50)] / 1000.0;
double p99 = sorted[(int) (sorted.length * 0.99)] / 1000.0;
double mean = Arrays.stream(sorted).average().orElse(0) / 1000.0;
return String.format("%-12s n=%-6d p50=%8.1f us p99=%9.1f us mean=%8.1f us ~%,.0f calls/s",
label, sorted.length, p50, p99, mean, 1_000_000.0 / mean);
}
}

View File

@@ -0,0 +1,66 @@
package com.ankurm.protocols;
import java.util.Iterator;
import com.ankurm.protocols.grpc.QuoteRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* The documented gRPC answer to over-production, tested rather than assumed.
*
* <p>Every guide to gRPC flow control says the same thing: {@code StreamObserver.onNext} does not
* block, so a server-streaming handler must consult
* {@code ServerCallStreamObserver.isReady()} before writing. This test runs the identical
* scenario against a handler that does exactly that.
*
* <p>It did not help. The transcript is committed as measured &mdash; on a loopback connection
* with a client that has stopped reading, the ready flag stays set and the loop keeps producing
* at the same rate. I have not established whether the messages accumulate in the server's
* outbound queue, in the client transport, or are discarded after the deframer, and I am not
* going to assert a mechanism I have not read the source for. What the numbers do establish is
* narrower and still useful: <strong>{@code isReady()} is not a substitute for a demand
* signal.</strong>
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"spring.rsocket.server.port=7004", "spring.grpc.server.port=9094"})
class GrpcIsReadyTest {
private static final int WANTED = 100;
@Test
void isReadyDoesNotBoundTheProducerHere() throws Exception {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9094)
.usePlaintext().build();
try {
var stub = QuoteServiceGrpc.newBlockingStub(channel);
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
System.out.println("=== gRPC server streaming, client takes 100 then stops ===");
System.out.printf("%-28s %10s %10s%n", "handler", "quiet 0.5s", "quiet 2s");
System.out.printf("%-28s %,10d %,10d%n", "plain onNext loop",
run(stub, req, false, 500), run(stub, req, false, 2_000));
System.out.printf("%-28s %,10d %,10d%n", "isReady() checked before write",
run(stub, req, true, 500), run(stub, req, true, 2_000));
}
finally {
channel.shutdownNow();
}
}
private long run(QuoteServiceGrpc.QuoteServiceBlockingStub stub, QuoteRequest req,
boolean ready, long quietMs) throws Exception {
Iterator<com.ankurm.protocols.grpc.Quote> it =
ready ? stub.streamUnboundedReady(req) : stub.streamUnbounded(req);
long received = 0;
while (received < WANTED && it.hasNext()) {
it.next();
received++;
}
Thread.sleep(quietMs);
return stub.produced(req).getCount();
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.protocols;
import java.util.HexFormat;
import com.ankurm.protocols.grpc.Quote;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.dataformat.cbor.CBORMapper;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The same five fields, encoded three ways.
*
* <p>This is the one comparison that needs no server, no warm-up and no statistics: it is a
* property of the wire formats. It is also the honest place to start, because on a loopback
* benchmark the encoding is most of what separates the three protocols.
*/
class PayloadSizeTest {
private static final com.ankurm.protocols.Quote POJO =
new com.ankurm.protocols.Quote("AAPL", 42, 100.42, 100.44, 1_772_000_000_000_000L);
@Test
void protobufVersusJsonVersusCbor() {
byte[] proto = Quote.newBuilder()
.setSymbol("AAPL").setSeq(42)
.setBid(100.42).setAsk(100.44)
.setEpochMicros(1_772_000_000_000_000L)
.build().toByteArray();
byte[] json = new ObjectMapper().writeValueAsBytes(POJO);
byte[] cbor = new CBORMapper().writeValueAsBytes(POJO);
System.out.println("=== one Quote, five fields, three encodings ===");
System.out.printf("protobuf : %3d bytes %s%n", proto.length, HexFormat.of().formatHex(proto));
System.out.printf("JSON : %3d bytes %s%n", json.length, new String(json));
System.out.printf("CBOR : %3d bytes %s%n", cbor.length, HexFormat.of().formatHex(cbor));
System.out.printf("JSON is %.2fx protobuf; CBOR is %.2fx protobuf%n",
json.length / (double) proto.length, cbor.length / (double) proto.length);
// Protobuf is smallest because field names are numbers and there is no framing text.
assertThat(proto.length).isLessThan(cbor.length);
assertThat(cbor.length).isLessThan(json.length);
}
}

View File

@@ -0,0 +1,38 @@
package com.ankurm.protocols;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.messaging.rsocket.RSocketStrategies;
import static org.assertj.core.api.Assertions.assertThat;
/**
* What Boot actually configures for RSocket, printed rather than quoted.
*
* <p>A bare {@code RSocketStrategies.create()} carries no JSON and no CBOR at all &mdash; only
* the string and buffer codecs. Everything interesting comes from Boot's
* {@code RSocketStrategiesAutoConfiguration}, which is also where the default data mime type is
* decided, and it is not the one most people assume.
*/
@SpringBootTest(properties = {"spring.rsocket.server.port=7005", "spring.grpc.server.port=9095"})
class RSocketDefaultsTest {
@Autowired
RSocketStrategies strategies;
@Test
void bootConfiguredCodecs() {
var encoders = strategies.encoders().stream().map(e -> e.getClass().getSimpleName()).toList();
var decoders = strategies.decoders().stream().map(d -> d.getClass().getSimpleName()).toList();
System.out.println("=== Boot-configured RSocketStrategies ===");
System.out.println("encoders : " + encoders);
System.out.println("decoders : " + decoders);
System.out.println("bare RSocketStrategies.create() encoders : "
+ RSocketStrategies.create().encoders().stream()
.map(e -> e.getClass().getSimpleName()).toList());
assertThat(encoders).isNotEmpty();
}
}

View File

@@ -0,0 +1,111 @@
package com.ankurm.protocols;
import com.ankurm.protocols.grpc.QuoteRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.messaging.rsocket.RSocketStrategies;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Sequential request/response round trips, all three protocols, same JVM, same data source.
*
* <p>Read the numbers as a comparison of the three, not as absolutes. Client and server are the
* same process on one loopback interface with two cores, so the network &mdash; the thing that
* dominates every real deployment &mdash; is absent. That deletes the advantage a smaller
* encoding would have on a real link and leaves framing and dispatch cost, which is exactly the
* part a loopback measures well.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"spring.rsocket.server.port=7001", "spring.grpc.server.port=9091"})
class RequestResponseBenchmarkTest {
private static final int WARMUP = 2_000;
private static final int MEASURED = 5_000;
@LocalServerPort
int httpPort;
@Autowired
RSocketStrategies strategies;
@Test
void threeProtocolsOneOperation() throws Exception {
System.out.println("=== request/response, " + MEASURED + " sequential calls after "
+ WARMUP + " warm-up, loopback, JDK " + System.getProperty("java.version") + " ===");
System.out.println(grpc());
System.out.println(rsocket());
System.out.println(websocket());
}
private String grpc() {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9091)
.usePlaintext().build();
try {
var stub = QuoteServiceGrpc.newBlockingStub(channel);
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
for (int i = 0; i < WARMUP; i++) {
stub.getQuote(req);
}
Bench bench = new Bench(MEASURED);
for (int i = 0; i < MEASURED; i++) {
long t0 = System.nanoTime();
var quote = stub.getQuote(req);
bench.record(System.nanoTime() - t0);
assertThat(quote.getSymbol()).isEqualTo("AAPL");
}
return bench.summary("gRPC");
}
finally {
channel.shutdownNow();
}
}
private String rsocket() {
RSocketRequester requester = RSocketRequester.builder()
.rsocketStrategies(strategies).tcp("localhost", 7001);
try {
for (int i = 0; i < WARMUP; i++) {
requester.route("quote").data("AAPL").retrieveMono(Quote.class).block();
}
Bench bench = new Bench(MEASURED);
for (int i = 0; i < MEASURED; i++) {
long t0 = System.nanoTime();
Quote quote = requester.route("quote").data("AAPL")
.retrieveMono(Quote.class).block();
bench.record(System.nanoTime() - t0);
assertThat(quote).isNotNull();
}
return bench.summary("RSocket");
}
finally {
requester.dispose();
}
}
private String websocket() throws Exception {
try (WsClient client = new WsClient(httpPort, 64)) {
for (int i = 0; i < WARMUP; i++) {
client.send("QUOTE AAPL");
assertThat(client.take(5_000)).isNotNull();
}
Bench bench = new Bench(MEASURED);
for (int i = 0; i < MEASURED; i++) {
long t0 = System.nanoTime();
client.send("QUOTE AAPL");
String reply = client.take(5_000);
bench.record(System.nanoTime() - t0);
assertThat(reply).contains("AAPL");
}
return bench.summary("WebSocket");
}
}
}

View File

@@ -0,0 +1,120 @@
package com.ankurm.protocols;
import java.util.Iterator;
import com.ankurm.protocols.grpc.StreamRequest;
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.messaging.rsocket.RSocketStrategies;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Server streaming: how fast can one connection carry N messages one way?
*
* <p>This is the shape most of these protocols are actually chosen for &mdash; a price feed, a
* log tail, a progress stream &mdash; and it separates them differently from request/response,
* because the per-message cost stops being dominated by a round trip.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"spring.rsocket.server.port=7002", "spring.grpc.server.port=9092"})
class StreamThroughputBenchmarkTest {
private static final int WARMUP = 5_000;
private static final int N = 50_000;
@LocalServerPort
int httpPort;
@Autowired
RSocketStrategies strategies;
@Test
void fiftyThousandMessagesEachWay() throws Exception {
System.out.println("=== server streaming, " + N + " messages on one connection, loopback ===");
System.out.println(grpc());
System.out.println(rsocket());
System.out.println(websocket());
}
private String grpc() {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9092)
.usePlaintext().build();
try {
var stub = QuoteServiceGrpc.newBlockingStub(channel);
drain(stub, WARMUP);
long t0 = System.nanoTime();
int seen = drain(stub, N);
return report("gRPC", seen, System.nanoTime() - t0);
}
finally {
channel.shutdownNow();
}
}
private int drain(QuoteServiceGrpc.QuoteServiceBlockingStub stub, int count) {
// The blocking stub returns an Iterator. Errors surface mid-iteration rather than at the
// call, which is the first thing that surprises people about gRPC server streaming.
Iterator<com.ankurm.protocols.grpc.Quote> it = stub.streamQuotes(
StreamRequest.newBuilder().setSymbol("AAPL").setCount(count).build());
int seen = 0;
while (it.hasNext()) {
it.next();
seen++;
}
return seen;
}
private String rsocket() {
RSocketRequester requester = RSocketRequester.builder()
.rsocketStrategies(strategies).tcp("localhost", 7002);
try {
var warm = new RSocketQuoteController.StreamSpec("AAPL", WARMUP, 0);
requester.route("quotes").data(warm).retrieveFlux(Quote.class).blockLast();
var spec = new RSocketQuoteController.StreamSpec("AAPL", N, 0);
long t0 = System.nanoTime();
long seen = requester.route("quotes").data(spec)
.retrieveFlux(Quote.class).count().block();
return report("RSocket", (int) seen, System.nanoTime() - t0);
}
finally {
requester.dispose();
}
}
private String websocket() throws Exception {
// A generous inbox, because there is nothing else to do with messages that arrive faster
// than they are read. This IS the WebSocket back-pressure story, in one constant.
try (WsClient client = new WsClient(httpPort, N + WARMUP + 16)) {
client.send("STREAM AAPL " + WARMUP + " 0");
int warm = 0;
String m;
while ((m = client.take(20_000)) != null && !"END".equals(m)) {
warm++;
}
assertThat(warm).isEqualTo(WARMUP);
long t0 = System.nanoTime();
client.send("STREAM AAPL " + N + " 0");
int seen = 0;
while ((m = client.take(20_000)) != null && !"END".equals(m)) {
seen++;
}
return report("WebSocket", seen, System.nanoTime() - t0);
}
}
private String report(String label, int seen, long elapsedNanos) {
assertThat(seen).isEqualTo(N);
double seconds = elapsedNanos / 1e9;
return String.format("%-12s %,7d msgs in %6.3f s = %,10.0f msgs/s (%6.2f us each)",
label, seen, seconds, seen / seconds, elapsedNanos / 1000.0 / seen);
}
}

View File

@@ -0,0 +1,60 @@
package com.ankurm.protocols;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import jakarta.websocket.ContainerProvider;
import jakarta.websocket.WebSocketContainer;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler;
/**
* A minimal raw-WebSocket client for the benchmark.
*
* <p>The queue is the point of interest. A WebSocket client has no way to tell the server how
* many messages it is ready for, so the only thing it can do with an over-eager producer is
* buffer them &mdash; here, in a bounded queue that starts dropping. That is the shape of every
* real WebSocket consumer, and it is what {@code BackPressureTest} contrasts with RSocket.
*/
final class WsClient implements AutoCloseable {
private final WebSocketSession session;
private final BlockingQueue<String> inbox;
WsClient(int port, int inboxCapacity) throws Exception {
this.inbox = new ArrayBlockingQueue<>(inboxCapacity);
WebSocketContainer container = ContainerProvider.getWebSocketContainer();
container.setDefaultMaxTextMessageBufferSize(1024 * 1024);
this.session = new StandardWebSocketClient(container)
.execute(new TextWebSocketHandler() {
@Override
protected void handleTextMessage(WebSocketSession s, TextMessage message) {
inbox.offer(message.getPayload()); // drops when full; nothing else to do
}
}, "ws://localhost:" + port + "/quotes").get();
}
void send(String command) throws Exception {
session.sendMessage(new TextMessage(command));
}
String take(long timeoutMs) throws Exception {
return inbox.poll(timeoutMs, TimeUnit.MILLISECONDS);
}
int drained() {
return inbox.size();
}
boolean isOpen() {
return session.isOpen();
}
@Override
public void close() throws Exception {
session.close();
}
}