Add the protocol-comparison module

This commit is contained in:
2026-09-04 01:03:48 +05:30
parent 5224afdad2
commit d56c60824e
32 changed files with 1710 additions and 0 deletions
@@ -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();
}
}
@@ -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);
}
}
@@ -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) {
}
@@ -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);
}
}
@@ -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) {
}
}
@@ -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("*");
}
}
@@ -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)));
}
}
@@ -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;
}
@@ -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
@@ -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));
}
}
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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();
}
}