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,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();
}
}