diff --git a/.github/scripts/run-performance-matrix.sh b/.github/scripts/run-performance-matrix.sh
new file mode 100755
index 0000000..5902204
--- /dev/null
+++ b/.github/scripts/run-performance-matrix.sh
@@ -0,0 +1,274 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT_DIR"
+
+MATRIX_DIR="rpc-consumer/target/performance-matrix"
+SNAPSHOT_DIR="rpc-consumer/target/rpc-performance"
+LOG_DIR="$MATRIX_DIR/logs"
+RPC_PORT_START=19090
+RPC_PORT_END=19120
+BASE_PAYLOAD_BYTES="1024"
+NACOS_ADDRESS="127.0.0.1:8848"
+
+rm -rf "$MATRIX_DIR"
+mkdir -p "$MATRIX_DIR" "$LOG_DIR"
+
+if ! command -v tc >/dev/null 2>&1; then
+ echo "Linux tc is required for network emulation." >&2
+ exit 1
+fi
+
+cleanup() {
+ sudo tc qdisc del dev lo root 2>/dev/null || true
+ docker rm -f rpc-perf-nacos >/dev/null 2>&1 || true
+}
+trap cleanup EXIT
+
+# Resolve and compile everything before tc is applied so dependency downloads never pollute network scenarios.
+# JaCoCo is intentionally disabled in this performance job; coverage remains in the unit-test job.
+mvn -B -ntp test-compile \
+ -pl rpc-consumer -am \
+ -DskipTests \
+ -Djacoco.skip=true \
+ -Drpc.registry=local
+
+scenario_index=0
+scenario_failures=0
+
+run_scenario() {
+ local category="$1"
+ local scenario="$2"
+ local registry="$3"
+ local protocol="$4"
+ local serializer="$5"
+ local network="$6"
+ local payload_bytes="$7"
+ local warmup="$8"
+ local sequential="$9"
+ local concurrent="${10}"
+ local concurrency="${11}"
+ local request_timeout_ms="${12}"
+ local call_timeout_ms="${13}"
+ local require_all_success="${14}"
+ local registry_address="${15:-$NACOS_ADDRESS}"
+
+ scenario_index=$((scenario_index + 1))
+ local rpc_port=$((RPC_PORT_START + scenario_index))
+ if (( rpc_port > RPC_PORT_END )); then
+ echo "Scenario count exceeded reserved tc RPC port range ${RPC_PORT_START}-${RPC_PORT_END}." >&2
+ exit 1
+ fi
+
+ rm -rf "$SNAPSHOT_DIR"
+
+ local output log status
+ output=$(printf "%s/%02d-%s.md" "$MATRIX_DIR" "$scenario_index" "$scenario")
+ log="$LOG_DIR/$scenario.log"
+
+ echo "::group::Performance scenario: $scenario (RPC port $rpc_port)"
+ set +e
+ mvn -B -ntp test \
+ -pl rpc-consumer -am \
+ -Dtest=RpcPerformanceSnapshotTest \
+ -Dsurefire.failIfNoSpecifiedTests=false \
+ -Djacoco.skip=true \
+ -Dlogback.configurationFile="$ROOT_DIR/rpc-benchmark/src/main/resources/logback.xml" \
+ -Drpc.registry="$registry" \
+ -Drpc.registry-address="$registry_address" \
+ -Drpc.protocol="$protocol" \
+ -Drpc.serializer="$serializer" \
+ -Drpc.request-timeout-ms="$request_timeout_ms" \
+ -Drpc.perf.call-timeout-ms="$call_timeout_ms" \
+ -Drpc.perf.server-port="$rpc_port" \
+ -Drpc.perf.payload-bytes="$payload_bytes" \
+ -Drpc.perf.require-all-success="$require_all_success" \
+ -Drpc.perf.warmup="$warmup" \
+ -Drpc.perf.sequential-requests="$sequential" \
+ -Drpc.perf.concurrent-requests="$concurrent" \
+ -Drpc.perf.concurrency="$concurrency" \
+ >"$log" 2>&1
+ status=$?
+ set -e
+ echo "::endgroup::"
+
+ local summary="$SNAPSHOT_DIR/summary.md"
+ if [[ "$status" -eq 0 && ! -s "$summary" ]]; then
+ echo "Scenario $scenario completed without producing $summary" >&2
+ status=2
+ fi
+
+ {
+ echo "### \`$scenario\`"
+ echo
+ echo "- Status: $([[ "$status" -eq 0 ]] && echo 'PASS' || echo 'FAIL')"
+ echo "- Matrix dimension: \`$category\`"
+ echo "- Registry / Protocol / Serializer: \`$registry / $protocol / $serializer\`"
+ echo "- Request payload: \`${payload_bytes} bytes\`"
+ echo "- RPC port: \`$rpc_port\`"
+ echo "- Network profile: \`$network\`"
+ echo "- Strict success requirement: \`$require_all_success\`"
+ echo
+
+ if [[ "$status" -eq 0 ]]; then
+ sed \
+ -e '1{/^# RPC CI Performance Snapshot$/d;}' \
+ -e '/^> Observational snapshot only\./d' \
+ -e '/^Raw attempt samples are available/d' \
+ -e 's/^## Framework metrics$/#### Framework metrics/' \
+ "$summary"
+ else
+ echo '> Scenario failed. The tail of its Maven log is included below so unsupported or broken modes remain visible.'
+ echo
+ echo '```text'
+ tail -n 60 "$log"
+ echo '```'
+ fi
+ } > "$output"
+
+ if [[ "$status" -ne 0 ]]; then
+ scenario_failures=$((scenario_failures + 1))
+ echo "Scenario $scenario FAILED; continuing so the rest of the matrix still runs." >&2
+ else
+ echo "Scenario $scenario passed."
+ fi
+}
+
+start_nacos() {
+ docker rm -f rpc-perf-nacos >/dev/null 2>&1 || true
+ docker run -d --name rpc-perf-nacos \
+ -e MODE=standalone \
+ -e NACOS_AUTH_ENABLE=false \
+ -e JVM_XMS=256m \
+ -e JVM_XMX=256m \
+ -e JVM_XMN=128m \
+ -p 8848:8848 \
+ -p 9848:9848 \
+ -p 9849:9849 \
+ nacos/nacos-server:v2.5.4 >/dev/null
+
+ for _ in $(seq 1 60); do
+ if curl -fsS "http://127.0.0.1:8848/nacos/v1/console/health/readiness" >/dev/null; then
+ echo "Nacos is ready."
+ return
+ fi
+ sleep 2
+ done
+
+ docker logs rpc-perf-nacos || true
+ echo "Nacos did not become ready in time." >&2
+ exit 1
+}
+
+reset_network() {
+ sudo tc qdisc del dev lo root 2>/dev/null || true
+}
+
+shape_rpc_ports() {
+ reset_network
+
+ # Every scenario gets a unique RPC endpoint in a reserved local port range. This prevents a previous Nacos
+ # ephemeral instance shutdown from racing a new JVM that re-registers the exact same ip:port identity.
+ # Only this reserved RPC data-plane range is shaped; Nacos 8848/9848/9849 stays untouched.
+ sudo tc qdisc add dev lo root handle 1: prio bands 3
+ sudo tc qdisc add dev lo parent 1:1 handle 10: netem "$@"
+
+ local port priority=1
+ for port in $(seq $((RPC_PORT_START + 1)) "$RPC_PORT_END"); do
+ sudo tc filter add dev lo protocol ip parent 1:0 prio "$priority" u32 \
+ match ip sport "$port" 0xffff flowid 1:1
+ priority=$((priority + 1))
+ sudo tc filter add dev lo protocol ip parent 1:0 prio "$priority" u32 \
+ match ip dport "$port" 0xffff flowid 1:1
+ priority=$((priority + 1))
+ done
+}
+
+apply_lan_profile() {
+ shape_rpc_ports delay 1ms 200us distribution normal rate 1gbit
+}
+
+start_nacos
+
+# Production-like baseline: gRPC + Nacos + Protobuf + synthetic LAN conditions.
+# On loopback, request and response packets are both shaped, so the configured one-way delay roughly doubles
+# into added request/response RTT before framework processing time is included.
+apply_lan_profile
+run_scenario "baseline" "baseline-grpc-protobuf-nacos-lan" \
+ "nacos" "grpc" "protobuf" "tc LAN: 1ms +/-0.2ms one-way, ~2ms added RTT, 1gbit" \
+ "$BASE_PAYLOAD_BYTES" 20 40 120 8 8000 10000 true
+
+# Payload matrix: keep the production baseline stack and LAN network profile fixed.
+for payload in 64 16384 262144; do
+ run_scenario "payload" "payload-${payload}b" \
+ "nacos" "grpc" "protobuf" "tc LAN: 1ms +/-0.2ms one-way, ~2ms added RTT, 1gbit" \
+ "$payload" 10 20 60 4 10000 12000 true
+done
+
+# Serializer matrix: gRPC + Nacos + LAN remain fixed; Protobuf is represented by the baseline.
+for serializer in kryo java json; do
+ run_scenario "serializer" "serializer-${serializer}" \
+ "nacos" "grpc" "$serializer" "tc LAN: 1ms +/-0.2ms one-way, ~2ms added RTT, 1gbit" \
+ "$BASE_PAYLOAD_BYTES" 10 20 60 4 8000 10000 true
+done
+
+# Registry matrix: Nacos is represented by the baseline; Local is retained only as a comparison control.
+run_scenario "registry" "registry-local-control" \
+ "local" "grpc" "protobuf" "tc LAN: 1ms +/-0.2ms one-way, ~2ms added RTT, 1gbit" \
+ "$BASE_PAYLOAD_BYTES" 10 20 60 4 8000 10000 true
+
+# Network matrix: gRPC + Nacos + Protobuf + 1 KiB payload remain fixed.
+shape_rpc_ports delay 3ms 1ms distribution normal rate 500mbit
+run_scenario "network" "network-cross-az" \
+ "nacos" "grpc" "protobuf" "tc cross-AZ/private: 3ms +/-1ms one-way, ~6ms added RTT, 500mbit" \
+ "$BASE_PAYLOAD_BYTES" 10 20 60 4 10000 12000 true
+
+shape_rpc_ports delay 25ms 5ms distribution normal loss 0.05% rate 100mbit
+run_scenario "network" "network-public-internet" \
+ "nacos" "grpc" "protobuf" "tc public internet: 25ms +/-5ms one-way, ~50ms added RTT, 0.05% loss, 100mbit" \
+ "$BASE_PAYLOAD_BYTES" 10 20 60 4 12000 14000 false
+
+shape_rpc_ports delay 60ms 10ms distribution normal loss 0.1% rate 50mbit
+run_scenario "network" "network-cross-region" \
+ "nacos" "grpc" "protobuf" "tc cross-region: 60ms +/-10ms one-way, ~120ms added RTT, 0.1% loss, 50mbit" \
+ "$BASE_PAYLOAD_BYTES" 8 16 48 4 15000 17000 false
+
+shape_rpc_ports delay 120ms 40ms distribution normal loss 1% rate 5mbit
+run_scenario "network" "network-weak-mobile" \
+ "nacos" "grpc" "protobuf" "tc weak/mobile: 120ms +/-40ms one-way, ~240ms added RTT, 1% loss, 5mbit" \
+ "$BASE_PAYLOAD_BYTES" 6 12 36 3 20000 22000 false
+
+# Protocol matrix runs last so a currently broken protocol cannot hide the baseline/network/serializer data.
+apply_lan_profile
+for protocol in netty http http2; do
+ run_scenario "protocol" "protocol-${protocol}" \
+ "nacos" "$protocol" "protobuf" "tc LAN: 1ms +/-0.2ms one-way, ~2ms added RTT, 1gbit" \
+ "$BASE_PAYLOAD_BYTES" 10 20 60 4 10000 12000 true
+done
+
+reset_network
+
+MATRIX_FILE="$MATRIX_DIR/matrix.md"
+{
+ echo "## CI Matrix — $(date -u '+%Y-%m-%d %H:%M:%S UTC') — \`${GITHUB_SHA:-local}\`"
+ echo
+ echo "- Runner: \`${RUNNER_OS:-local} ${RUNNER_ARCH:-unknown}\`"
+ echo "- Java: \`$(java -version 2>&1 | head -n 1 | tr -d '"')\`"
+ echo "- CPU visible: \`$(nproc)\`"
+ echo "- Baseline: \`gRPC + Nacos + Protobuf + 1 KiB + tc LAN\`"
+ echo "- Strategy: orthogonal matrix around the production-like baseline; Local Registry is a control only"
+ echo "- Network simulation: Linux \`tc netem\` on \`lo\`, filtered to reserved RPC ports \`$((RPC_PORT_START + 1))-$RPC_PORT_END\` only"
+ echo "- Nacos isolation: every scenario gets a unique RPC ip:port instance identity"
+ echo "- Coverage instrumentation: disabled for performance scenarios"
+ echo "- Scenario failures: \`$scenario_failures\`"
+ echo
+ cat "$MATRIX_DIR"/[0-9][0-9]-*.md
+} > "$MATRIX_FILE"
+
+cat "$MATRIX_FILE"
+
+if [[ "$scenario_failures" -ne 0 ]]; then
+ echo "$scenario_failures performance scenario(s) failed; matrix data was still generated for diagnosis." >&2
+ exit 1
+fi
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7cb1e5b..aa27e0c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,18 +20,15 @@ jobs:
name: Build
runs-on: ubuntu-24.04
timeout-minutes: 10
-
steps:
- name: Checkout code
uses: actions/checkout@v7
-
- name: Set up JDK 17
uses: actions/setup-java@v6
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'
-
- name: Build all modules
run: mvn -B -ntp clean package -DskipTests -Drpc.registry=local
@@ -39,24 +36,20 @@ jobs:
name: Unit Tests & Coverage
runs-on: ubuntu-24.04
timeout-minutes: 10
-
steps:
- name: Checkout code
uses: actions/checkout@v7
-
- name: Set up JDK 17
uses: actions/setup-java@v6
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'
-
- name: Run core, transport and starter unit tests
run: >-
mvn -B -ntp test
-pl rpc-core,rpc-transport-netty,rpc-spring-boot-starter -am
-Drpc.registry=local
-
- name: Upload unit test and coverage reports
if: always()
uses: actions/upload-artifact@v7
@@ -72,20 +65,15 @@ jobs:
name: RPC Integration
runs-on: ubuntu-24.04
timeout-minutes: 10
- outputs:
- performance_markdown: ${{ steps.performance-record.outputs.markdown }}
-
steps:
- name: Checkout code
uses: actions/checkout@v7
-
- name: Set up JDK 17
uses: actions/setup-java@v6
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'
-
- name: Run local-registry Netty end-to-end test
run: >-
mvn -B -ntp test
@@ -94,41 +82,6 @@ jobs:
-Dsurefire.failIfNoSpecifiedTests=false
-Drpc.registry=local
-Drpc.protocol=netty
-
- # Performance data is observational only. Hosted Runner variance makes absolute
- # latency/QPS unsuitable as a merge threshold. PR CI validates the snapshot;
- # successful main CI runs persist the result into docs/PERFORMANCE.md.
- - name: Run RPC performance snapshot
- run: >-
- mvn -B -ntp test
- -pl rpc-consumer -am
- -Dtest=RpcPerformanceSnapshotTest
- -Dsurefire.failIfNoSpecifiedTests=false
- -Drpc.registry=local
- -Drpc.protocol=netty
- -Drpc.serializer=kryo
- -Drpc.perf.warmup=100
- -Drpc.perf.sequential-requests=200
- -Drpc.perf.concurrent-requests=1000
- -Drpc.perf.concurrency=16
-
- - name: Prepare performance record
- id: performance-record
- shell: bash
- run: |
- summary='rpc-consumer/target/rpc-performance/summary.md'
- test -f "$summary"
- {
- echo 'markdown< Observational snapshot only\./d' \
- -e '/^Raw per-request samples are available/d' \
- -e 's/^## Framework metrics$/### Framework metrics/' \
- "$summary"
- echo 'RPC_PERFORMANCE_EOF'
- } >> "$GITHUB_OUTPUT"
-
- name: Upload integration test reports
if: always()
uses: actions/upload-artifact@v7
@@ -138,66 +91,104 @@ jobs:
if-no-files-found: warn
path: '**/target/surefire-reports/'
+ performance-matrix:
+ name: RPC Performance Matrix
+ runs-on: ubuntu-24.04
+ timeout-minutes: 35
+ outputs:
+ performance_markdown: ${{ steps.performance-record.outputs.markdown }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+ - name: Set up JDK 17
+ uses: actions/setup-java@v6
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: 'maven'
+ - name: Verify tc and Docker
+ shell: bash
+ run: |
+ command -v tc
+ sudo tc qdisc show dev lo
+ docker --version
+ - name: Run production-baseline performance matrix
+ shell: bash
+ run: bash .github/scripts/run-performance-matrix.sh
+ - name: Prepare performance record
+ id: performance-record
+ if: always()
+ shell: bash
+ run: |
+ matrix='rpc-consumer/target/performance-matrix/matrix.md'
+ if [[ ! -s "$matrix" ]]; then
+ echo 'Performance matrix file was not produced.' >&2
+ exit 1
+ fi
+ delimiter="RPC_PERFORMANCE_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}"
+ {
+ echo "markdown<<${delimiter}"
+ cat "$matrix"
+ echo "${delimiter}"
+ } >> "$GITHUB_OUTPUT"
+
+ ci-gate:
+ name: CI Gate
+ if: always()
+ needs: [ build, unit-tests, rpc-integration, performance-matrix ]
+ runs-on: ubuntu-24.04
+ timeout-minutes: 2
+ steps:
+ - name: Verify required checks
+ shell: bash
+ run: |
+ test "${{ needs.build.result }}" = "success"
+ test "${{ needs.unit-tests.result }}" = "success"
+ test "${{ needs.rpc-integration.result }}" = "success"
+ test "${{ needs.performance-matrix.result }}" = "success"
+
record-performance:
name: Record Performance
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
- needs.rpc-integration.result == 'success'
- needs: [ rpc-integration ]
+ needs.ci-gate.result == 'success' &&
+ needs.performance-matrix.result == 'success'
+ needs: [ ci-gate, performance-matrix ]
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: write
-
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
-
- - name: Append performance snapshot
+ - name: Append performance matrix
shell: bash
env:
- PERFORMANCE_MARKDOWN: ${{ needs.rpc-integration.outputs.performance_markdown }}
+ PERFORMANCE_MARKDOWN: ${{ needs.performance-matrix.outputs.performance_markdown }}
run: |
test -n "$PERFORMANCE_MARKDOWN"
+ git pull --ff-only origin main
mkdir -p docs
test -f docs/PERFORMANCE.md
-
- short_sha="${GITHUB_SHA:0:7}"
- recorded_at="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
-
{
echo
echo '---'
echo
- echo "## CI Snapshot — ${recorded_at} — \`${short_sha}\`"
- echo
printf '%s\n' "$PERFORMANCE_MARKDOWN"
} >> docs/PERFORMANCE.md
-
- name: Commit performance history
shell: bash
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add docs/PERFORMANCE.md
- git commit -m 'docs: record RPC performance snapshot [skip ci]'
+ if git diff --cached --quiet; then
+ echo "No performance history change."
+ exit 0
+ fi
+ git commit -m 'docs: record RPC performance matrix [skip ci]'
git push origin HEAD:main
-
- ci-gate:
- name: CI Gate
- if: always()
- needs: [ build, unit-tests, rpc-integration ]
- runs-on: ubuntu-24.04
- timeout-minutes: 2
-
- steps:
- - name: Verify required checks
- shell: bash
- run: |
- test "${{ needs.build.result }}" = "success"
- test "${{ needs.unit-tests.result }}" = "success"
- test "${{ needs.rpc-integration.result }}" = "success"
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index 765253c..32421e4 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -1,33 +1,109 @@
# RPC Performance History
-该文档由 CI 持续维护,用于记录 Java-RPC 真实端到端调用链路的性能快照。
+该文档由 CI 持续维护,用于记录 Java-RPC 真实端到端调用链路在不同协议、序列化器、注册中心、payload 和网络条件下的性能数据。
-> 这些数据来自 GitHub Hosted Runner,适合观察趋势和发现明显回归,不作为绝对性能承诺或硬性合并门槛。测试固定使用 Local Registry + Netty + Kryo,并保持固定 warmup、请求量和并发度以提高不同运行之间的可比性。
+> 数据来自 GitHub Hosted Runner,适合比较实现差异、观察趋势和发现明显回归,不作为绝对性能承诺或硬性合并门槛。CI 使用以生产常见组合为中心的正交矩阵:一次只改变一个主要变量,避免全笛卡尔积既昂贵又难解释。
-## 测试配置
+## CI 性能矩阵
-- Warmup:100 次 RPC
-- 顺序阶段:200 次 RPC,并发度 1
-- 并发阶段:1000 次 RPC,并发度 16
-- Registry:`local`
-- Protocol:`netty`
-- Serializer:`kryo`
-- 记录指标:吞吐、Avg、P50、P95、P99、Max,以及 Client / Server 内置 metrics
+### 生产基线
+
+默认基线不再使用 Local Registry 或裸 loopback,而是:
+
+- Protocol: `grpc`
+- Registry: `nacos`(CI 启动真实 `nacos/nacos-server:v2.5.4`)
+- Serializer: `protobuf`
+- Request payload: `1 KiB`
+- Network: Linux `tc netem` 模拟内网:`delay 1ms ± 0.2ms` + `rate 1gbit`
+
+`tc` 只过滤固定 RPC 数据端口 `19090`,不会把 Nacos 的 `8848/9848/9849` 控制面流量一起塑形,因此 RPC 数据面的网络差异不会被注册中心心跳/发现请求混入。
+
+由于请求和响应都会经过 loopback 的 egress qdisc,文档中的 `tc delay` 可理解为单向注入值,RPC 请求/响应增加的 RTT 通常约为其两倍,再叠加框架处理时间。例如 `delay 25ms` 的场景大致增加约 `50ms` RTT。这里的网络档位是稳定、可复现的合成条件,不声称精确代表某个云厂商或运营商 SLA。
+
+### 协议
+
+固定 `Nacos + Protobuf + 1 KiB + tc LAN`:
+
+- `grpc`(baseline)
+- `netty`
+- `http`
+- `http2`
+
+### 序列化
+
+固定 `gRPC + Nacos + 1 KiB + tc LAN`:
+
+- `protobuf`(baseline)
+- `kryo`
+- `java`
+- `json`
+
+### 注册中心
+
+固定 `gRPC + Protobuf + 1 KiB + tc LAN`:
+
+- `nacos`(baseline,真实 Docker)
+- `local`(仅作为 control,不再作为默认基线)
+
+### Payload
+
+固定 `gRPC + Nacos + Protobuf + tc LAN`:
+
+- `64 B`
+- `1 KiB`(baseline)
+- `16 KiB`
+- `256 KiB`
+
+这样可以区分协议/序列化固定开销与数据量扩大后的拷贝、编码和带宽成本。
+
+### 网络
+
+固定 `gRPC + Nacos + Protobuf + 1 KiB`,只塑形 RPC 端口:
+
+- 内网 LAN(baseline):`delay 1ms ± 0.2ms`,约 `2ms` added RTT,`1gbit`
+- 跨 AZ / 私网:`delay 3ms ± 1ms`,约 `6ms` added RTT,`500mbit`
+- 普通公网:`delay 25ms ± 5ms`,约 `50ms` added RTT,`loss 0.05%`,`100mbit`
+- 跨地域公网:`delay 60ms ± 10ms`,约 `120ms` added RTT,`loss 0.1%`,`50mbit`
+- 弱网 / 移动网络:`delay 120ms ± 40ms`,约 `240ms` added RTT,`loss 1%`,`5mbit`
+
+公网、跨地域和弱网场景允许出现真实 RPC 失败,不再以“必须 100% 成功”作为采样前提;报告会继续跑完整个阶段并记录成功率、failed、timeout、成功吞吐与成功请求的 P95/P99。至少需要保留成功样本,否则该场景仍判定失败。
+
+## 测量约束
+
+性能 job 与覆盖率 job 分离:性能矩阵显式关闭 JaCoCo,避免 coverage instrumentation 污染延迟与吞吐。性能 JVM 使用低日志配置,避免逐请求 INFO 输出成为热路径瓶颈;普通单元测试和覆盖率仍按原 CI 配置执行。
+
+矩阵采用“围绕生产基线一次只改变一个主要变量”的策略。某个协议如果失败,脚本不会立即停止整个矩阵,而是把该场景标记为 FAIL 后继续运行剩余场景,最后再统一让 job 失败。这样一个坏模式不会遮住其他协议、序列化器、Nacos 或网络档位的数据。
+
+## 记录指标
+
+每个场景都会记录:
+
+- 尝试请求数、成功数、失败数、成功率
+- Attempt throughput 与 successful throughput
+- 成功 RPC 的 Avg / P50 / P95 / P99 / Max latency
+- Client / Server 的 total、success、failed、timeout、active
+- Client / Server 内置 metric 的平均与最大耗时
+- Registry / Protocol / Serializer / Payload / Network profile
+- Java / CPU / Runner 信息
+
+PR CI 会执行完整矩阵作为验证;只有 `main` CI 全绿后才自动把矩阵结果追加到本文档。
## 历史记录
-### PR #11 CI 基线
+### PR #11 CI 单一基线(矩阵启用前)
- Commit: `281d526ebe6ccd0e970453fc9094b85bf08cfe96`
- Java: `17.0.20.1`
- CPU visible to JVM: `4`
+- Registry / Protocol / Serializer: `local / netty / kryo`
+- Network: loopback
| Phase | Requests | Concurrency | Throughput req/s | Avg ms | P50 ms | P95 ms | P99 ms | Max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| sequential | 200 | 1 | 482.033 | 2.066 | 1.535 | 4.578 | 6.195 | 9.340 |
| concurrent | 1000 | 16 | 1537.520 | 10.250 | 9.376 | 17.876 | 22.806 | 27.916 |
-#### Framework metrics
+Framework metrics
| Phase | Side | Total | Success | Failed | Timeout | Active | Metric avg ms | Metric max ms |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
@@ -36,12 +112,12 @@
| concurrent | CLIENT | 1000 | 1000 | 0 | 0 | 0 | 9.692 | 25.216 |
| concurrent | SERVER | 1000 | 1000 | 0 | 0 | 0 | 0.661 | 12.092 |
+
+
---
## CI Snapshot — 2026-09-14 09:14:08 UTC — `c525edb`
-
-
- Commit: `c525edb58daffb37267b544e5928f0fb4db5ab49`
- Protocol: `netty`
- Serializer: `kryo`
@@ -62,4 +138,3 @@
| sequential | SERVER | 200 | 200 | 0 | 0 | 0 | 0.190 | 2.493 |
| concurrent | CLIENT | 1000 | 1000 | 0 | 0 | 0 | 8.364 | 20.758 |
| concurrent | SERVER | 1000 | 1000 | 0 | 0 | 0 | 0.791 | 5.687 |
-
diff --git a/rpc-benchmark/src/main/resources/logback.xml b/rpc-benchmark/src/main/resources/logback.xml
index 2a299a7..07546d6 100644
--- a/rpc-benchmark/src/main/resources/logback.xml
+++ b/rpc-benchmark/src/main/resources/logback.xml
@@ -1,14 +1,12 @@
-
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
-
-
- %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
-
-
+
diff --git a/rpc-consumer/src/test/java/com/xiaoyu/rpc/consumer/RpcPerformanceSnapshotTest.java b/rpc-consumer/src/test/java/com/xiaoyu/rpc/consumer/RpcPerformanceSnapshotTest.java
index c838576..9514ac1 100644
--- a/rpc-consumer/src/test/java/com/xiaoyu/rpc/consumer/RpcPerformanceSnapshotTest.java
+++ b/rpc-consumer/src/test/java/com/xiaoyu/rpc/consumer/RpcPerformanceSnapshotTest.java
@@ -2,6 +2,7 @@
import com.google.protobuf.ByteString;
import com.xiaoyu.rpc.api.HelloService;
+import com.xiaoyu.rpc.common.extension.ExtensionLoader;
import com.xiaoyu.rpc.common.serialization.Serializer;
import com.xiaoyu.rpc.common.serialization.SerializerCode;
import com.xiaoyu.rpc.common.vo.RpcRequest;
@@ -9,22 +10,23 @@
import com.xiaoyu.rpc.core.config.RpcConfig;
import com.xiaoyu.rpc.core.observability.RpcMetricSide;
import com.xiaoyu.rpc.core.observability.RpcMetrics;
+import com.xiaoyu.rpc.core.registry.ServiceDiscovery;
import com.xiaoyu.rpc.core.server.RpcServer;
import org.junit.jupiter.api.Test;
import java.io.IOException;
+import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
-import java.util.ArrayList;
import java.util.Arrays;
-import java.util.List;
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -35,7 +37,7 @@
* CI 中的轻量端到端性能快照。
*
* 它的目标是生成可比较的观测数据,而不是用 GitHub Hosted Runner 的绝对性能值作为合并门槛。
- * JMH 仍然负责更严格的微基准;这里关注真实 RPC 主链路的延迟、吞吐和框架内置 metrics 是否一致。
+ * JMH 仍然负责更严格的微基准;这里关注真实 RPC 主链路的延迟、吞吐、失败率和框架内置 metrics 是否一致。
*/
public class RpcPerformanceSnapshotTest {
@@ -43,6 +45,11 @@ public class RpcPerformanceSnapshotTest {
private static final int SEQUENTIAL_REQUESTS = Integer.getInteger("rpc.perf.sequential-requests", 200);
private static final int CONCURRENT_REQUESTS = Integer.getInteger("rpc.perf.concurrent-requests", 1000);
private static final int CONCURRENCY = Integer.getInteger("rpc.perf.concurrency", 16);
+ private static final int PAYLOAD_BYTES = Math.max(1, Integer.getInteger("rpc.perf.payload-bytes", 1024));
+ private static final int FIXED_SERVER_PORT = Integer.getInteger("rpc.perf.server-port", 0);
+ private static final int CALL_TIMEOUT_MS = Math.max(1, Integer.getInteger("rpc.perf.call-timeout-ms", 6000));
+ private static final boolean REQUIRE_ALL_SUCCESS = Boolean.parseBoolean(
+ System.getProperty("rpc.perf.require-all-success", "true"));
private static final Path OUTPUT_DIR = Path.of("target", "rpc-performance");
public static class HelloServiceImpl implements HelloService {
@@ -54,10 +61,10 @@ public String sayHello(String name) {
@Test
void generatePerformanceSnapshot() throws Exception {
- String registry = System.getProperty("rpc.registry", "local");
- String protocol = System.getProperty("rpc.protocol", "netty");
- String serializerName = System.getProperty("rpc.serializer", "kryo");
- int port = findFreePort();
+ String registry = System.getProperty("rpc.registry", "nacos");
+ String protocol = System.getProperty("rpc.protocol", "grpc");
+ String serializerName = System.getProperty("rpc.serializer", "protobuf");
+ int port = FIXED_SERVER_PORT > 0 ? FIXED_SERVER_PORT : findFreePort();
System.setProperty("rpc.registry", registry);
System.setProperty("rpc.server-host", "127.0.0.1");
@@ -71,15 +78,16 @@ void generatePerformanceSnapshot() throws Exception {
server = new RpcServer();
server.register(HelloService.class, new HelloServiceImpl());
server.start();
+ // Use the same SPI discovery instance as RpcClient, not a direct-address shortcut.
+ // Registration acknowledgement does not imply subscriber cache visibility.
+ awaitDiscovery(registry, port);
Serializer serializer = SerializerCode.getSerializerByCode(RpcConfig.getInstance().getSerializerCode());
try (RpcClient client = new RpcClient()) {
warmUp(client, serializer);
-
PhaseResult sequential = runSequential(client, serializer);
PhaseResult concurrent = runConcurrent(client, serializer);
-
- writeArtifacts(registry, protocol, serializerName, sequential, concurrent);
+ writeArtifacts(registry, protocol, serializerName, port, sequential, concurrent);
}
} finally {
if (server != null) {
@@ -95,23 +103,59 @@ void generatePerformanceSnapshot() throws Exception {
}
}
+ private static void awaitDiscovery(String registry, int port) throws Exception {
+ ServiceDiscovery discovery = ExtensionLoader.getExtensionLoader(ServiceDiscovery.class).getExtension(registry);
+ long started = System.nanoTime();
+ long deadline = started + TimeUnit.SECONDS.toNanos(30);
+ Exception lastError = null;
+ InetSocketAddress expected = new InetSocketAddress("127.0.0.1", port);
+ while (System.nanoTime() < deadline) {
+ try {
+ InetSocketAddress actual = discovery.lookupService(HelloService.class.getName());
+ if (expected.equals(actual)) {
+ System.out.printf(Locale.ROOT, "Discovery ready for %s in %.3f ms (excluded from RPC timing)%n",
+ expected, nanosToMillis(System.nanoTime() - started));
+ return;
+ }
+ lastError = new IllegalStateException("Expected current instance " + expected + ", discovered " + actual);
+ } catch (Exception error) {
+ lastError = error;
+ }
+ Thread.sleep(200);
+ }
+ throw new IllegalStateException("Consumer did not discover current provider within 30 seconds: " + expected, lastError);
+ }
+
private static void warmUp(RpcClient client, Serializer serializer) throws Exception {
for (int i = 0; i < WARMUP_REQUESTS; i++) {
- invokeOnce(client, serializer, "Warmup-" + i);
+ try {
+ invokeOnce(client, serializer, requestName("Warmup", i));
+ } catch (Exception e) {
+ if (REQUIRE_ALL_SUCCESS) {
+ throw e;
+ }
+ }
}
+ waitForMetricsToSettle();
}
private static PhaseResult runSequential(RpcClient client, Serializer serializer) throws Exception {
RpcMetrics.getInstance().reset();
long[] samples = new long[SEQUENTIAL_REQUESTS];
+ int successfulRequests = 0;
long phaseStart = System.nanoTime();
-
for (int i = 0; i < SEQUENTIAL_REQUESTS; i++) {
- samples[i] = invokeOnce(client, serializer, "Sequential-" + i);
+ try {
+ samples[i] = invokeOnce(client, serializer, requestName("Sequential", i));
+ successfulRequests++;
+ } catch (Exception e) {
+ if (REQUIRE_ALL_SUCCESS) {
+ throw e;
+ }
+ }
}
-
long elapsedNanos = System.nanoTime() - phaseStart;
- return createPhaseResult("sequential", SEQUENTIAL_REQUESTS, 1, elapsedNanos, samples);
+ return createPhaseResult("sequential", SEQUENTIAL_REQUESTS, successfulRequests, 1, elapsedNanos, samples);
}
private static PhaseResult runConcurrent(RpcClient client, Serializer serializer) throws Exception {
@@ -120,35 +164,39 @@ private static PhaseResult runConcurrent(RpcClient client, Serializer serializer
ExecutorService executor = Executors.newFixedThreadPool(CONCURRENCY);
CountDownLatch startGate = new CountDownLatch(1);
CountDownLatch doneGate = new CountDownLatch(CONCURRENT_REQUESTS);
- AtomicReference failure = new AtomicReference<>();
-
+ AtomicInteger successfulRequests = new AtomicInteger();
+ AtomicReference firstFailure = new AtomicReference<>();
+ AtomicReference invalidResponse = new AtomicReference<>();
try {
for (int i = 0; i < CONCURRENT_REQUESTS; i++) {
final int requestIndex = i;
executor.execute(() -> {
try {
startGate.await();
- samples[requestIndex] = invokeOnce(client, serializer, "Concurrent-" + requestIndex);
- } catch (Throwable t) {
- failure.compareAndSet(null, t);
+ samples[requestIndex] = invokeOnce(client, serializer, requestName("Concurrent", requestIndex));
+ successfulRequests.incrementAndGet();
+ } catch (AssertionError error) {
+ invalidResponse.compareAndSet(null, error);
+ } catch (Exception error) {
+ firstFailure.compareAndSet(null, error);
} finally {
doneGate.countDown();
}
});
}
-
long phaseStart = System.nanoTime();
startGate.countDown();
- boolean completed = doneGate.await(45, TimeUnit.SECONDS);
+ boolean completed = doneGate.await(90, TimeUnit.SECONDS);
long elapsedNanos = System.nanoTime() - phaseStart;
-
- assertTrue(completed, "Concurrent performance phase should finish within 45 seconds");
- Throwable error = failure.get();
- if (error != null) {
- fail("Concurrent RPC performance phase failed", error);
+ assertTrue(completed, "Concurrent performance phase should finish within 90 seconds");
+ if (invalidResponse.get() != null) {
+ fail("Data corruption is never an allowed weak-network failure", invalidResponse.get());
}
-
- return createPhaseResult("concurrent", CONCURRENT_REQUESTS, CONCURRENCY, elapsedNanos, samples);
+ if (REQUIRE_ALL_SUCCESS && firstFailure.get() != null) {
+ fail("Concurrent RPC performance phase failed", firstFailure.get());
+ }
+ return createPhaseResult("concurrent", CONCURRENT_REQUESTS, successfulRequests.get(),
+ CONCURRENCY, elapsedNanos, samples);
} finally {
executor.shutdownNow();
executor.awaitTermination(5, TimeUnit.SECONDS);
@@ -157,102 +205,120 @@ private static PhaseResult runConcurrent(RpcClient client, Serializer serializer
private static long invokeOnce(RpcClient client, Serializer serializer, String name) throws Exception {
long startNanos = System.nanoTime();
- String result = (String) client
- .sendRequest(buildRequest(name, serializer), String.class)
- .get(5, TimeUnit.SECONDS);
+ String result = (String) client.sendRequest(buildRequest(name, serializer), String.class)
+ .get(CALL_TIMEOUT_MS, TimeUnit.MILLISECONDS);
long elapsedNanos = System.nanoTime() - startNanos;
-
- if (result == null || !result.contains(name)) {
- throw new AssertionError("Unexpected RPC result for " + name + ": " + result);
+ if (!("Hello, " + name + "! (from Netty Server)").equals(result)) {
+ throw new AssertionError("Unexpected RPC result for payload marker: " + name.substring(0, Math.min(32, name.length())));
}
return elapsedNanos;
}
- private static PhaseResult createPhaseResult(String name, int requests, int concurrency,
- long elapsedNanos, long[] samples) {
+ private static PhaseResult createPhaseResult(String name, int requests, int successfulRequests, int concurrency,
+ long elapsedNanos, long[] samples) throws InterruptedException {
+ waitForMetricsToSettle();
RpcMetrics.Snapshot clientMetrics = RpcMetrics.getInstance().snapshot(RpcMetricSide.CLIENT);
RpcMetrics.Snapshot serverMetrics = RpcMetrics.getInstance().snapshot(RpcMetricSide.SERVER);
-
- assertMetrics("client", requests, clientMetrics);
- assertMetrics("server", requests, serverMetrics);
-
- long[] sorted = samples.clone();
- Arrays.sort(sorted);
+ assertClientMetrics(requests, successfulRequests, clientMetrics);
+ assertServerMetrics(requests, successfulRequests, serverMetrics);
+ long[] successfulSamples = Arrays.stream(samples).filter(sample -> sample > 0L).toArray();
+ Arrays.sort(successfulSamples);
long sum = 0L;
- for (long sample : samples) {
+ for (long sample : successfulSamples) {
sum += sample;
}
+ double seconds = elapsedNanos / 1_000_000_000D;
+ double attemptedThroughput = seconds <= 0D ? 0D : requests / seconds;
+ double successfulThroughput = seconds <= 0D ? 0D : successfulRequests / seconds;
+ double successRatePct = requests == 0 ? 100D : successfulRequests * 100D / requests;
+ double averageMillis = successfulRequests == 0 ? 0D : nanosToMillis(sum / (double) successfulRequests);
+ double minMillis = successfulRequests == 0 ? 0D : nanosToMillis(successfulSamples[0]);
+ double p50Millis = successfulRequests == 0 ? 0D : nanosToMillis(percentile(successfulSamples, 0.50D));
+ double p95Millis = successfulRequests == 0 ? 0D : nanosToMillis(percentile(successfulSamples, 0.95D));
+ double p99Millis = successfulRequests == 0 ? 0D : nanosToMillis(percentile(successfulSamples, 0.99D));
+ double maxMillis = successfulRequests == 0 ? 0D : nanosToMillis(successfulSamples[successfulSamples.length - 1]);
+ if (REQUIRE_ALL_SUCCESS) {
+ assertEquals(requests, successfulRequests, name + " successful request count mismatch");
+ } else {
+ assertTrue(successfulRequests > 0, name + " should retain at least one successful RPC sample");
+ }
+ return new PhaseResult(name, requests, successfulRequests, requests - successfulRequests,
+ concurrency, elapsedNanos, attemptedThroughput, successfulThroughput, successRatePct,
+ minMillis, averageMillis, p50Millis, p95Millis, p99Millis, maxMillis,
+ clientMetrics, serverMetrics, samples.clone());
+ }
- double elapsedSeconds = elapsedNanos / 1_000_000_000D;
- double throughput = elapsedSeconds <= 0D ? 0D : requests / elapsedSeconds;
- double averageMillis = requests == 0 ? 0D : nanosToMillis(sum / (double) requests);
-
- return new PhaseResult(
- name,
- requests,
- concurrency,
- elapsedNanos,
- throughput,
- nanosToMillis(sorted[0]),
- averageMillis,
- nanosToMillis(percentile(sorted, 0.50D)),
- nanosToMillis(percentile(sorted, 0.95D)),
- nanosToMillis(percentile(sorted, 0.99D)),
- nanosToMillis(sorted[sorted.length - 1]),
- clientMetrics,
- serverMetrics,
- samples.clone());
+ private static void assertClientMetrics(int expectedRequests, int successfulRequests, RpcMetrics.Snapshot snapshot) {
+ assertEquals(expectedRequests, snapshot.totalRequests(), "client total requests mismatch");
+ assertEquals(0, snapshot.activeRequests(), "client active requests should return to zero");
+ assertEquals(successfulRequests, snapshot.successRequests(), "client success requests mismatch");
+ assertEquals(expectedRequests - successfulRequests, snapshot.failedRequests(), "client failed requests mismatch");
+ if (REQUIRE_ALL_SUCCESS) {
+ assertEquals(0, snapshot.timeoutRequests(), "client timeout requests should be zero");
+ }
}
- private static void assertMetrics(String side, int expectedRequests, RpcMetrics.Snapshot snapshot) {
- assertEquals(expectedRequests, snapshot.totalRequests(), side + " total requests mismatch");
- assertEquals(expectedRequests, snapshot.successRequests(), side + " success requests mismatch");
- assertEquals(0, snapshot.failedRequests(), side + " failed requests should be zero");
- assertEquals(0, snapshot.timeoutRequests(), side + " timeout requests should be zero");
- assertEquals(0, snapshot.activeRequests(), side + " active requests should return to zero");
+ private static void assertServerMetrics(int expectedRequests, int successfulRequests, RpcMetrics.Snapshot snapshot) {
+ assertEquals(0, snapshot.activeRequests(), "server active requests should return to zero");
+ if (REQUIRE_ALL_SUCCESS) {
+ assertEquals(expectedRequests, snapshot.totalRequests(), "server total requests mismatch");
+ assertEquals(successfulRequests, snapshot.successRequests(), "server success requests mismatch");
+ assertEquals(0, snapshot.failedRequests(), "server failed requests should be zero");
+ assertEquals(0, snapshot.timeoutRequests(), "server timeout requests should be zero");
+ }
+ // In lossy profiles a request from a previous phase can arrive after client timeout.
+ // Server metrics describe the observation interval, not client completion counts.
+ }
+
+ private static void waitForMetricsToSettle() throws InterruptedException {
+ for (int i = 0; i < 100; i++) {
+ RpcMetrics.Snapshot client = RpcMetrics.getInstance().snapshot(RpcMetricSide.CLIENT);
+ RpcMetrics.Snapshot server = RpcMetrics.getInstance().snapshot(RpcMetricSide.SERVER);
+ if (client.activeRequests() == 0 && server.activeRequests() == 0) {
+ return;
+ }
+ Thread.sleep(20L);
+ }
}
private static long percentile(long[] sorted, double percentile) {
int rank = (int) Math.ceil(percentile * sorted.length);
- int index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
- return sorted[index];
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))];
}
private static double nanosToMillis(double nanos) {
return nanos / 1_000_000D;
}
+ private static String requestName(String phase, int index) {
+ String prefix = phase + '-' + index + '|';
+ return prefix + "x".repeat(Math.max(0, PAYLOAD_BYTES - prefix.length()));
+ }
+
private static RpcRequest buildRequest(String name, Serializer serializer) {
- return RpcRequest.newBuilder()
- .setInterfaceName(HelloService.class.getName())
- .setMethodName("sayHello")
- .addParamTypes(String.class.getName())
- .addParameters(ByteString.copyFrom(serializer.serialize(name)))
- .build();
+ return RpcRequest.newBuilder().setInterfaceName(HelloService.class.getName()).setMethodName("sayHello")
+ .addParamTypes(String.class.getName()).addParameters(ByteString.copyFrom(serializer.serialize(name))).build();
}
private static int findFreePort() throws IOException {
- try (java.net.ServerSocket socket = new java.net.ServerSocket(0, 1,
- java.net.InetAddress.getByName("127.0.0.1"))) {
+ try (java.net.ServerSocket socket = new java.net.ServerSocket(0, 1, java.net.InetAddress.getByName("127.0.0.1"))) {
return socket.getLocalPort();
}
}
- private static void writeArtifacts(String registry, String protocol, String serializer,
+ private static void writeArtifacts(String registry, String protocol, String serializer, int serverPort,
PhaseResult sequential, PhaseResult concurrent) throws IOException {
Files.createDirectories(OUTPUT_DIR);
Files.writeString(OUTPUT_DIR.resolve("performance.json"),
- toJson(registry, protocol, serializer, sequential, concurrent), StandardCharsets.UTF_8);
+ toJson(registry, protocol, serializer, serverPort, sequential, concurrent), StandardCharsets.UTF_8);
Files.writeString(OUTPUT_DIR.resolve("summary.md"),
- toMarkdown(registry, protocol, serializer, sequential, concurrent), StandardCharsets.UTF_8);
- Files.writeString(OUTPUT_DIR.resolve("latency-samples.csv"),
- toCsv(sequential, concurrent), StandardCharsets.UTF_8);
+ toMarkdown(registry, protocol, serializer, serverPort, sequential, concurrent), StandardCharsets.UTF_8);
+ Files.writeString(OUTPUT_DIR.resolve("latency-samples.csv"), toCsv(sequential, concurrent), StandardCharsets.UTF_8);
}
- private static String toJson(String registry, String protocol, String serializer,
+ private static String toJson(String registry, String protocol, String serializer, int serverPort,
PhaseResult sequential, PhaseResult concurrent) {
- StringBuilder out = new StringBuilder();
- out.append("{\n");
+ StringBuilder out = new StringBuilder("{\n");
field(out, "generatedAt", Instant.now().toString(), true, 1);
field(out, "gitSha", envOrDefault("GITHUB_SHA", "local"), true, 1);
out.append(" \"environment\": {\n");
@@ -260,21 +326,22 @@ private static String toJson(String registry, String protocol, String serializer
field(out, "os", System.getProperty("os.name") + " " + System.getProperty("os.arch"), true, 2);
numberField(out, "availableProcessors", Runtime.getRuntime().availableProcessors(), true, 2);
numberField(out, "maxHeapMb", Runtime.getRuntime().maxMemory() / (1024D * 1024D), false, 2);
- out.append(" },\n");
- out.append(" \"config\": {\n");
+ out.append(" },\n \"config\": {\n");
field(out, "registry", registry, true, 2);
field(out, "protocol", protocol, true, 2);
field(out, "serializer", serializer, true, 2);
+ numberField(out, "serverPort", serverPort, true, 2);
+ numberField(out, "payloadBytes", PAYLOAD_BYTES, true, 2);
+ numberField(out, "callTimeoutMs", CALL_TIMEOUT_MS, true, 2);
+ booleanField(out, "requireAllSuccess", REQUIRE_ALL_SUCCESS, true, 2);
numberField(out, "warmupRequests", WARMUP_REQUESTS, true, 2);
numberField(out, "sequentialRequests", SEQUENTIAL_REQUESTS, true, 2);
numberField(out, "concurrentRequests", CONCURRENT_REQUESTS, true, 2);
numberField(out, "concurrency", CONCURRENCY, false, 2);
- out.append(" },\n");
- out.append(" \"phases\": [\n");
+ out.append(" },\n \"phases\": [\n");
appendPhaseJson(out, sequential, true);
appendPhaseJson(out, concurrent, false);
- out.append(" ]\n");
- out.append("}\n");
+ out.append(" ]\n}\n");
return out.toString();
}
@@ -282,24 +349,25 @@ private static void appendPhaseJson(StringBuilder out, PhaseResult phase, boolea
out.append(" {\n");
field(out, "name", phase.name(), true, 3);
numberField(out, "requests", phase.requests(), true, 3);
+ numberField(out, "successfulRequests", phase.successfulRequests(), true, 3);
+ numberField(out, "failedRequests", phase.failedRequests(), true, 3);
+ numberField(out, "successRatePct", phase.successRatePct(), true, 3);
numberField(out, "concurrency", phase.concurrency(), true, 3);
numberField(out, "durationMs", nanosToMillis(phase.elapsedNanos()), true, 3);
- numberField(out, "throughputRps", phase.throughputRps(), true, 3);
- out.append(" \"latencyMs\": {\n");
+ numberField(out, "attemptedThroughputRps", phase.attemptedThroughputRps(), true, 3);
+ numberField(out, "successfulThroughputRps", phase.successfulThroughputRps(), true, 3);
+ out.append(" \"successfulLatencyMs\": {\n");
numberField(out, "min", phase.minMillis(), true, 4);
numberField(out, "average", phase.averageMillis(), true, 4);
numberField(out, "p50", phase.p50Millis(), true, 4);
numberField(out, "p95", phase.p95Millis(), true, 4);
numberField(out, "p99", phase.p99Millis(), true, 4);
numberField(out, "max", phase.maxMillis(), false, 4);
- out.append(" },\n");
- out.append(" \"clientMetrics\": ");
+ out.append(" },\n \"clientMetrics\": ");
appendMetricsJson(out, phase.clientMetrics());
- out.append(",\n");
- out.append(" \"serverMetrics\": ");
+ out.append(",\n \"serverMetrics\": ");
appendMetricsJson(out, phase.serverMetrics());
- out.append("\n }");
- out.append(comma ? ",\n" : "\n");
+ out.append("\n }").append(comma ? ",\n" : "\n");
}
private static void appendMetricsJson(StringBuilder out, RpcMetrics.Snapshot snapshot) {
@@ -309,23 +377,24 @@ private static void appendMetricsJson(StringBuilder out, RpcMetrics.Snapshot sna
.append(",\"timeout\":").append(snapshot.timeoutRequests())
.append(",\"active\":").append(snapshot.activeRequests())
.append(",\"averageLatencyMs\":").append(format(snapshot.averageLatencyMillis()))
- .append(",\"maxLatencyMs\":").append(format(snapshot.maxLatencyMillis()))
- .append('}');
+ .append(",\"maxLatencyMs\":").append(format(snapshot.maxLatencyMillis())).append('}');
}
- private static String toMarkdown(String registry, String protocol, String serializer,
+ private static String toMarkdown(String registry, String protocol, String serializer, int serverPort,
PhaseResult sequential, PhaseResult concurrent) {
- StringBuilder out = new StringBuilder();
- out.append("# RPC CI Performance Snapshot\n\n");
+ StringBuilder out = new StringBuilder("# RPC CI Performance Snapshot\n\n");
out.append("> Observational snapshot only. GitHub Hosted Runner performance varies; these values are not merge thresholds.\n\n");
out.append("- Commit: `").append(envOrDefault("GITHUB_SHA", "local")).append("`\n");
out.append("- Protocol: `").append(protocol).append("`\n");
out.append("- Serializer: `").append(serializer).append("`\n");
out.append("- Registry: `").append(registry).append("`\n");
+ out.append("- RPC port: `").append(serverPort).append("`\n");
+ out.append("- Request payload: `").append(PAYLOAD_BYTES).append(" bytes`\n");
+ out.append("- Require all requests to succeed: `").append(REQUIRE_ALL_SUCCESS).append("`\n");
out.append("- Java: `").append(System.getProperty("java.version")).append("`\n");
out.append("- CPU visible to JVM: `").append(Runtime.getRuntime().availableProcessors()).append("`\n\n");
- out.append("| Phase | Requests | Concurrency | Throughput req/s | Avg ms | P50 ms | P95 ms | P99 ms | Max ms |\n");
- out.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n");
+ out.append("| Phase | Attempts | Success | Failed | Success % | Concurrency | Attempt req/s | Success req/s | Avg ms | P50 ms | P95 ms | P99 ms | Max ms |\n");
+ out.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n");
appendPhaseMarkdown(out, sequential);
appendPhaseMarkdown(out, concurrent);
out.append("\n## Framework metrics\n\n");
@@ -335,63 +404,58 @@ private static String toMarkdown(String registry, String protocol, String serial
appendMetricsMarkdown(out, sequential, "SERVER", sequential.serverMetrics());
appendMetricsMarkdown(out, concurrent, "CLIENT", concurrent.clientMetrics());
appendMetricsMarkdown(out, concurrent, "SERVER", concurrent.serverMetrics());
- out.append("\nRaw per-request samples are available in `latency-samples.csv`; machine-readable totals are in `performance.json`.\n");
+ out.append("\nLatency percentiles include successful RPCs only. Low sample counts do not establish a reliable tail-latency SLA.\n");
+ out.append("Nacos visibility polling and warmup are excluded from timing. In lossy profiles, server observation intervals may include late requests.\n");
return out.toString();
}
private static void appendPhaseMarkdown(StringBuilder out, PhaseResult phase) {
- out.append("| ").append(phase.name())
- .append(" | ").append(phase.requests())
- .append(" | ").append(phase.concurrency())
- .append(" | ").append(format(phase.throughputRps()))
- .append(" | ").append(format(phase.averageMillis()))
- .append(" | ").append(format(phase.p50Millis()))
- .append(" | ").append(format(phase.p95Millis()))
- .append(" | ").append(format(phase.p99Millis()))
- .append(" | ").append(format(phase.maxMillis()))
- .append(" |\n");
+ out.append("| ").append(phase.name()).append(" | ").append(phase.requests())
+ .append(" | ").append(phase.successfulRequests()).append(" | ").append(phase.failedRequests())
+ .append(" | ").append(format(phase.successRatePct())).append(" | ").append(phase.concurrency())
+ .append(" | ").append(format(phase.attemptedThroughputRps())).append(" | ").append(format(phase.successfulThroughputRps()))
+ .append(" | ").append(format(phase.averageMillis())).append(" | ").append(format(phase.p50Millis()))
+ .append(" | ").append(format(phase.p95Millis())).append(" | ").append(format(phase.p99Millis()))
+ .append(" | ").append(format(phase.maxMillis())).append(" |\n");
}
- private static void appendMetricsMarkdown(StringBuilder out, PhaseResult phase, String side,
- RpcMetrics.Snapshot snapshot) {
- out.append("| ").append(phase.name())
- .append(" | ").append(side)
- .append(" | ").append(snapshot.totalRequests())
- .append(" | ").append(snapshot.successRequests())
- .append(" | ").append(snapshot.failedRequests())
- .append(" | ").append(snapshot.timeoutRequests())
- .append(" | ").append(snapshot.activeRequests())
- .append(" | ").append(format(snapshot.averageLatencyMillis()))
- .append(" | ").append(format(snapshot.maxLatencyMillis()))
- .append(" |\n");
+ private static void appendMetricsMarkdown(StringBuilder out, PhaseResult phase, String side, RpcMetrics.Snapshot snapshot) {
+ out.append("| ").append(phase.name()).append(" | ").append(side)
+ .append(" | ").append(snapshot.totalRequests()).append(" | ").append(snapshot.successRequests())
+ .append(" | ").append(snapshot.failedRequests()).append(" | ").append(snapshot.timeoutRequests())
+ .append(" | ").append(snapshot.activeRequests()).append(" | ").append(format(snapshot.averageLatencyMillis()))
+ .append(" | ").append(format(snapshot.maxLatencyMillis())).append(" |\n");
}
private static String toCsv(PhaseResult... phases) {
- StringBuilder out = new StringBuilder("phase,index,latency_ms\n");
+ StringBuilder out = new StringBuilder("phase,index,status,latency_ms\n");
for (PhaseResult phase : phases) {
long[] samples = phase.samplesNanos();
for (int i = 0; i < samples.length; i++) {
- out.append(phase.name()).append(',')
- .append(i).append(',')
- .append(format(nanosToMillis(samples[i])))
- .append('\n');
+ boolean success = samples[i] > 0L;
+ out.append(phase.name()).append(',').append(i).append(',').append(success ? "success" : "failure").append(',');
+ if (success) {
+ out.append(format(nanosToMillis(samples[i])));
+ }
+ out.append('\n');
}
}
return out.toString();
}
private static void field(StringBuilder out, String name, String value, boolean comma, int indent) {
- out.append(" ".repeat(indent))
- .append('\"').append(name).append("\": \"")
- .append(escapeJson(value)).append('\"')
- .append(comma ? ",\n" : "\n");
+ out.append(" ".repeat(indent)).append('\"').append(name).append("\": \"")
+ .append(escapeJson(value)).append('\"').append(comma ? ",\n" : "\n");
}
private static void numberField(StringBuilder out, String name, double value, boolean comma, int indent) {
- out.append(" ".repeat(indent))
- .append('\"').append(name).append("\": ")
- .append(format(value))
- .append(comma ? ",\n" : "\n");
+ out.append(" ".repeat(indent)).append('\"').append(name).append("\": ")
+ .append(format(value)).append(comma ? ",\n" : "\n");
+ }
+
+ private static void booleanField(StringBuilder out, String name, boolean value, boolean comma, int indent) {
+ out.append(" ".repeat(indent)).append('\"').append(name).append("\": ")
+ .append(value).append(comma ? ",\n" : "\n");
}
private static String escapeJson(String value) {
@@ -407,20 +471,10 @@ private static String envOrDefault(String name, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
- private record PhaseResult(
- String name,
- int requests,
- int concurrency,
- long elapsedNanos,
- double throughputRps,
- double minMillis,
- double averageMillis,
- double p50Millis,
- double p95Millis,
- double p99Millis,
- double maxMillis,
- RpcMetrics.Snapshot clientMetrics,
- RpcMetrics.Snapshot serverMetrics,
- long[] samplesNanos) {
+ private record PhaseResult(String name, int requests, int successfulRequests, int failedRequests,
+ int concurrency, long elapsedNanos, double attemptedThroughputRps, double successfulThroughputRps,
+ double successRatePct, double minMillis, double averageMillis, double p50Millis, double p95Millis,
+ double p99Millis, double maxMillis, RpcMetrics.Snapshot clientMetrics,
+ RpcMetrics.Snapshot serverMetrics, long[] samplesNanos) {
}
}
diff --git a/rpc-core/src/main/java/com/xiaoyu/rpc/core/registry/LocalRegistry.java b/rpc-core/src/main/java/com/xiaoyu/rpc/core/registry/LocalRegistry.java
index 3185946..58110b1 100644
--- a/rpc-core/src/main/java/com/xiaoyu/rpc/core/registry/LocalRegistry.java
+++ b/rpc-core/src/main/java/com/xiaoyu/rpc/core/registry/LocalRegistry.java
@@ -1,28 +1,32 @@
package com.xiaoyu.rpc.core.registry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import java.net.InetSocketAddress;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class LocalRegistry implements ServiceRegistry, ServiceDiscovery {
+ private static final Logger log = LoggerFactory.getLogger(LocalRegistry.class);
private static final Map SERVICES = new ConcurrentHashMap<>();
@Override
public void registerService(String serviceName, InetSocketAddress inetSocketAddress) {
SERVICES.put(serviceName, inetSocketAddress);
- System.out.println("LocalRegistry: Registered " + serviceName + " at " + inetSocketAddress);
+ log.info("LocalRegistry registered {} at {}", serviceName, inetSocketAddress);
}
@Override
public InetSocketAddress lookupService(String serviceName) {
- System.out.println("LocalRegistry: Looking up " + serviceName);
+ log.debug("LocalRegistry lookup: {}", serviceName);
return SERVICES.get(serviceName);
}
@Override
public void clearRegistry() {
SERVICES.clear();
- System.out.println("LocalRegistry: Cleared all services.");
+ log.info("LocalRegistry cleared all services");
}
}
diff --git a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/client/RpcStreamResponseHandler.java b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/client/RpcStreamResponseHandler.java
new file mode 100644
index 0000000..5229308
--- /dev/null
+++ b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/client/RpcStreamResponseHandler.java
@@ -0,0 +1,58 @@
+package com.xiaoyu.rpc.core.client;
+
+import com.xiaoyu.rpc.common.vo.RpcResponse;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http2.Http2ResetFrame;
+import io.netty.util.ReferenceCountUtil;
+
+import java.nio.channels.ClosedChannelException;
+
+/**
+ * One handler per HTTP/2 stream. Never install the connection-scoped pending-request
+ * handler on a child channel: closing that stream must not fail sibling requests.
+ */
+public final class RpcStreamResponseHandler extends SimpleChannelInboundHandler {
+ private final NettyRpcClientHandler connectionHandler;
+ private final String requestId;
+
+ public RpcStreamResponseHandler(NettyRpcClientHandler connectionHandler, String requestId) {
+ this.connectionHandler = connectionHandler;
+ this.requestId = requestId;
+ }
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext ctx, RpcResponse response) throws Exception {
+ if (!requestId.equals(response.getRequestId())) {
+ exceptionCaught(ctx, new IllegalStateException("Response requestId does not match its HTTP/2 stream"));
+ return;
+ }
+ // Invoke only the response-routing operation, not connection lifecycle callbacks.
+ connectionHandler.channelRead(ctx, response);
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ connectionHandler.failRequest(requestId, new ClosedChannelException());
+ super.channelInactive(ctx);
+ }
+
+ @Override
+ public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception {
+ if (event instanceof Http2ResetFrame reset) {
+ try {
+ exceptionCaught(ctx, new IllegalStateException("HTTP/2 stream reset: " + reset.errorCode()));
+ } finally {
+ ReferenceCountUtil.release(event);
+ }
+ } else {
+ super.userEventTriggered(ctx, event);
+ }
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ connectionHandler.failRequest(requestId, cause);
+ ctx.close();
+ }
+}
diff --git a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcClientResponseHandler.java b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcClientResponseHandler.java
index 6fd54e0..cab3170 100644
--- a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcClientResponseHandler.java
+++ b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcClientResponseHandler.java
@@ -2,75 +2,80 @@
import com.xiaoyu.rpc.common.vo.RpcResponse;
import com.xiaoyu.rpc.core.client.NettyRpcClientHandler;
-import io.netty.buffer.ByteBuf;
+import com.xiaoyu.rpc.core.config.RpcConfig;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.http2.Http2DataFrame;
import io.netty.handler.codec.http2.Http2Frame;
import io.netty.handler.codec.http2.Http2Headers;
import io.netty.handler.codec.http2.Http2HeadersFrame;
-/**
- * 将 gRPC/HTTP2 帧转换为内部 RpcResponse,并交给通用客户端处理器完成 requestId 关联。
- */
+/** Decode a unary response and wait for successful gRPC trailers before completing the RPC. */
class GrpcClientResponseHandler extends SimpleChannelInboundHandler {
-
private final NettyRpcClientHandler clientHandler;
private final String requestId;
+ private final GrpcMessageAccumulator accumulator;
+ private RpcResponse response;
+ private boolean finished;
GrpcClientResponseHandler(NettyRpcClientHandler clientHandler, String requestId) {
this.clientHandler = clientHandler;
this.requestId = requestId;
+ this.accumulator = new GrpcMessageAccumulator(RpcConfig.getInstance().getMaxMessageSize());
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, Http2Frame frame) throws Exception {
- if (frame instanceof Http2DataFrame) {
- Http2DataFrame dataFrame = (Http2DataFrame) frame;
- ByteBuf content = dataFrame.content();
- if (content.readableBytes() < 5) {
- clientHandler.failRequest(requestId, new IllegalStateException("Invalid gRPC frame: missing 5-byte prefix"));
- return;
+ if (finished) {
+ return;
+ }
+ if (frame instanceof Http2DataFrame data) {
+ byte[] payload = accumulator.append(ctx.alloc(), data.content());
+ if (payload != null) {
+ response = RpcResponse.parseFrom(payload);
}
-
- byte compressedFlag = content.readByte();
- if (compressedFlag != 0) {
- clientHandler.failRequest(requestId, new UnsupportedOperationException("Compressed gRPC payload is not supported"));
- return;
+ if (data.isEndStream()) {
+ throw new CorruptedFrameException("gRPC response ended without status trailers");
}
-
- int length = content.readInt();
- if (content.readableBytes() < length) {
- clientHandler.failRequest(requestId, new IllegalStateException("Invalid gRPC frame: payload length mismatch"));
- return;
+ } else if (frame instanceof Http2HeadersFrame headersFrame) {
+ Http2Headers headers = headersFrame.headers();
+ if (headers.status() != null && !"200".contentEquals(headers.status())) {
+ throw new CorruptedFrameException("Unexpected gRPC HTTP status: " + headers.status());
}
-
- ByteBuf slice = content.readSlice(length);
- RpcResponse response;
- if (slice.nioBufferCount() > 0) {
- response = RpcResponse.parseFrom(slice.nioBuffer());
- } else {
- byte[] bytes = new byte[length];
- slice.readBytes(bytes);
- response = RpcResponse.parseFrom(bytes);
+ if (headersFrame.isEndStream()) {
+ CharSequence status = headers.get("grpc-status");
+ if (status == null || !"0".contentEquals(status)) {
+ throw new CorruptedFrameException("gRPC status=" + status + ", message=" + headers.get("grpc-message"));
+ }
+ accumulator.requireComplete();
+ if (response == null || !requestId.equals(response.getRequestId())) {
+ throw new CorruptedFrameException("Missing response or mismatched gRPC requestId");
+ }
+ finished = true;
+ ctx.fireChannelRead(response);
}
- ctx.fireChannelRead(response);
- return;
}
+ }
- if (frame instanceof Http2HeadersFrame) {
- Http2Headers headers = ((Http2HeadersFrame) frame).headers();
- CharSequence grpcStatus = headers.get("grpc-status");
- if (grpcStatus != null && !"0".contentEquals(grpcStatus)) {
- CharSequence grpcMessage = headers.get("grpc-message");
- String message = grpcMessage == null ? "unknown grpc error" : grpcMessage.toString();
- clientHandler.failRequest(requestId, new RuntimeException("gRPC error status=" + grpcStatus + ", message=" + message));
- }
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ accumulator.close();
+ if (!finished) {
+ clientHandler.failRequest(requestId, new CorruptedFrameException("gRPC stream closed before successful trailers"));
}
+ super.channelInactive(ctx);
+ }
+
+ @Override
+ public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
+ accumulator.close();
+ super.handlerRemoved(ctx);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ accumulator.close();
clientHandler.failRequest(requestId, cause);
ctx.close();
}
diff --git a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcMessageAccumulator.java b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcMessageAccumulator.java
new file mode 100644
index 0000000..35fb690
--- /dev/null
+++ b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcMessageAccumulator.java
@@ -0,0 +1,72 @@
+package com.xiaoyu.rpc.core.protocol.grpc;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.ByteBufAllocator;
+import io.netty.handler.codec.CorruptedFrameException;
+import io.netty.handler.codec.TooLongFrameException;
+
+/** Bounded, unary gRPC deframer. HTTP/2 DATA boundaries are not message boundaries. */
+final class GrpcMessageAccumulator implements AutoCloseable {
+ private final int maxMessageSize;
+ private ByteBuf buffer;
+ private boolean complete;
+
+ GrpcMessageAccumulator(int maxMessageSize) {
+ if (maxMessageSize < 1 || maxMessageSize > Integer.MAX_VALUE - 5) {
+ throw new IllegalArgumentException("Invalid gRPC maximum message size");
+ }
+ this.maxMessageSize = maxMessageSize;
+ }
+
+ byte[] append(ByteBufAllocator allocator, ByteBuf fragment) {
+ int size = fragment.readableBytes();
+ if (size == 0) {
+ return null;
+ }
+ if (complete) {
+ throw new CorruptedFrameException("Multiple messages on a unary gRPC stream");
+ }
+ if (buffer == null) {
+ buffer = allocator.buffer(Math.min(256, maxMessageSize + 5), maxMessageSize + 5);
+ }
+ if (size > maxMessageSize + 5 - buffer.readableBytes()) {
+ throw new TooLongFrameException("gRPC message exceeds configured maximum");
+ }
+ buffer.writeBytes(fragment, fragment.readerIndex(), size);
+ if (buffer.readableBytes() < 5) {
+ return null;
+ }
+ if (buffer.getUnsignedByte(0) != 0) {
+ throw new CorruptedFrameException("Compressed gRPC messages are not supported");
+ }
+ int length = buffer.getInt(1);
+ if (length < 0 || length > maxMessageSize) {
+ throw new TooLongFrameException("Invalid gRPC message length: " + length);
+ }
+ if (buffer.readableBytes() > length + 5) {
+ throw new CorruptedFrameException("Extra bytes after unary gRPC message");
+ }
+ if (buffer.readableBytes() < length + 5) {
+ return null;
+ }
+ byte[] payload = new byte[length];
+ buffer.getBytes(5, payload);
+ complete = true;
+ close();
+ return payload;
+ }
+
+ void requireComplete() {
+ if (!complete) {
+ throw new CorruptedFrameException("gRPC stream ended with a missing or truncated message");
+ }
+ }
+
+ @Override
+ public void close() {
+ if (buffer != null) {
+ buffer.release();
+ buffer = null;
+ }
+ }
+}
diff --git a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcProtocol.java b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcProtocol.java
index 3271df3..135d827 100644
--- a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcProtocol.java
+++ b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcProtocol.java
@@ -2,7 +2,9 @@
import com.xiaoyu.rpc.common.vo.RpcRequest;
import com.xiaoyu.rpc.core.client.NettyRpcClientHandler;
+import com.xiaoyu.rpc.core.client.RpcStreamResponseHandler;
import com.xiaoyu.rpc.core.protocol.Protocol;
+import com.xiaoyu.rpc.core.protocol.http2.Http2ClientConnectionReadyHandler;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
@@ -33,17 +35,12 @@ public String getName() {
@Override
public void config(ChannelPipeline pipeline, boolean isServer, ChannelHandler serverHandler) {
if (isServer) {
- // 先接入 HTTP/2 帧编解码,处理握手并产出 Frame
pipeline.addLast(Http2FrameCodecBuilder.forServer().build());
-
- // 再通过 MultiplexHandler 为每个 Stream 创建子 Channel
pipeline.addLast(new Http2MultiplexHandler(new ChannelInitializer() {
@Override
- protected void initChannel(Channel ch) throws Exception {
+ protected void initChannel(Channel ch) {
ChannelPipeline p = ch.pipeline();
- // 在子 Channel 中添加 gRPC 适配器
p.addLast(new GrpcServerHandler(serverHandler));
- // 添加业务处理器 (复用现有的 NettyRpcHandler)
p.addLast(serverHandler);
}
}));
@@ -53,10 +50,10 @@ protected void initChannel(Channel ch) throws Exception {
.autoAckPingFrame(true)
.initialSettings(Http2Settings.defaultSettings().maxHeaderListSize(8192))
.build());
+ Http2ClientConnectionReadyHandler.install(pipeline);
pipeline.addLast(new Http2MultiplexHandler(new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
- // 连接级残留帧统一释放,避免引用计数对象泄漏
ReferenceCountUtil.release(msg);
}
}));
@@ -64,7 +61,17 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) {
}
@Override
- public void sendRequest(Channel channel, RpcRequest request, NettyRpcClientHandler clientHandler) throws Exception {
+ public void sendRequest(Channel channel, RpcRequest request, NettyRpcClientHandler clientHandler) {
+ Http2ClientConnectionReadyHandler.readinessFuture(channel).whenComplete((ignored, readinessError) -> {
+ if (readinessError != null) {
+ clientHandler.failRequest(request.getRequestId(), readinessError);
+ return;
+ }
+ openStreamAndSend(channel, request, clientHandler);
+ });
+ }
+
+ private void openStreamAndSend(Channel channel, RpcRequest request, NettyRpcClientHandler clientHandler) {
Http2StreamChannelBootstrap streamBootstrap = new Http2StreamChannelBootstrap(channel);
streamBootstrap.open().addListener(openFuture -> {
if (!openFuture.isSuccess()) {
@@ -74,11 +81,11 @@ public void sendRequest(Channel channel, RpcRequest request, NettyRpcClientHandl
Http2StreamChannel streamChannel = (Http2StreamChannel) openFuture.getNow();
streamChannel.pipeline().addLast(new GrpcClientResponseHandler(clientHandler, request.getRequestId()));
- streamChannel.pipeline().addLast(clientHandler);
+ streamChannel.pipeline().addLast(new RpcStreamResponseHandler(clientHandler, request.getRequestId()));
byte[] payload = request.toByteArray();
io.netty.buffer.ByteBuf body = streamChannel.alloc().buffer(payload.length + 5);
- body.writeByte(0); // compressed-flag
+ body.writeByte(0);
body.writeInt(payload.length);
body.writeBytes(payload);
@@ -88,9 +95,7 @@ public void sendRequest(Channel channel, RpcRequest request, NettyRpcClientHandl
.scheme("http")
.set(HttpHeaderNames.CONTENT_TYPE, "application/grpc")
.set(HttpHeaderNames.TE, "trailers");
-
- if (channel.remoteAddress() instanceof InetSocketAddress) {
- InetSocketAddress remote = (InetSocketAddress) channel.remoteAddress();
+ if (channel.remoteAddress() instanceof InetSocketAddress remote) {
headers.authority(remote.getHostString() + ":" + remote.getPort());
}
@@ -98,6 +103,7 @@ public void sendRequest(Channel channel, RpcRequest request, NettyRpcClientHandl
streamChannel.writeAndFlush(new DefaultHttp2DataFrame(body, true)).addListener(writeFuture -> {
if (!writeFuture.isSuccess()) {
clientHandler.failRequest(request.getRequestId(), writeFuture.cause());
+ streamChannel.close();
}
});
});
diff --git a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcServerHandler.java b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcServerHandler.java
index 2468985..8f4d638 100644
--- a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcServerHandler.java
+++ b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcServerHandler.java
@@ -2,111 +2,101 @@
import com.xiaoyu.rpc.common.vo.RpcRequest;
import com.xiaoyu.rpc.common.vo.RpcResponse;
+import com.xiaoyu.rpc.core.config.RpcConfig;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelDuplexHandler;
+import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelPromise;
+import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.http.HttpHeaderNames;
-import io.netty.handler.codec.http2.*;
+import io.netty.handler.codec.http2.DefaultHttp2DataFrame;
+import io.netty.handler.codec.http2.DefaultHttp2Headers;
+import io.netty.handler.codec.http2.DefaultHttp2HeadersFrame;
+import io.netty.handler.codec.http2.Http2DataFrame;
+import io.netty.handler.codec.http2.Http2Frame;
+import io.netty.handler.codec.http2.Http2Headers;
+import io.netty.handler.codec.http2.Http2HeadersFrame;
import io.netty.util.ReferenceCountUtil;
-import lombok.extern.slf4j.Slf4j;
+import io.netty.util.concurrent.PromiseCombiner;
-@Slf4j
+/** One instance per unary gRPC stream, including its bounded fragmented-message state. */
public class GrpcServerHandler extends ChannelDuplexHandler {
+ private final GrpcMessageAccumulator accumulator;
+ private RpcRequest request;
+ private boolean dispatched;
- // 透传的业务处理器(例如 NettyRpcHandler)
- private final io.netty.channel.ChannelHandler busineesHandler;
-
- public GrpcServerHandler(io.netty.channel.ChannelHandler busineesHandler) {
- this.busineesHandler = busineesHandler;
+ public GrpcServerHandler(ChannelHandler businessHandler) {
+ // The business handler follows this adapter in the child pipeline.
+ accumulator = new GrpcMessageAccumulator(RpcConfig.getInstance().getMaxMessageSize());
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
- if (msg instanceof Http2Frame) {
- try {
- // 只在这里处理 gRPC 对应的 HTTP/2 Frame,转换成内部 RpcRequest
- processFrame(ctx, (Http2Frame) msg);
- } finally {
- ReferenceCountUtil.release(msg);
- }
- } else {
+ if (!(msg instanceof Http2Frame)) {
ctx.fireChannelRead(msg);
+ return;
}
- }
-
- private void processFrame(ChannelHandlerContext ctx, Http2Frame frame) throws Exception {
- if (frame instanceof Http2HeadersFrame) {
- Http2HeadersFrame headersFrame = (Http2HeadersFrame) frame;
- Http2Headers headers = headersFrame.headers();
- CharSequence contentType = headers.get(HttpHeaderNames.CONTENT_TYPE);
- if (contentType != null && contentType.toString().startsWith("application/grpc")) {
- // Initial gRPC header received
+ try {
+ if (msg instanceof Http2DataFrame data) {
+ byte[] payload = accumulator.append(ctx.alloc(), data.content());
+ if (payload != null) {
+ request = RpcRequest.parseFrom(payload);
+ }
+ if (data.isEndStream()) {
+ dispatch(ctx);
+ }
+ } else if (msg instanceof Http2HeadersFrame headers && headers.isEndStream()) {
+ dispatch(ctx);
}
+ } finally {
+ ReferenceCountUtil.release(msg);
}
+ }
- if (frame instanceof Http2DataFrame) {
- Http2DataFrame dataFrame = (Http2DataFrame) frame;
- ByteBuf content = dataFrame.content();
-
- // gRPC 数据帧固定前缀:1 字节压缩标记 + 4 字节消息长度
- if (content.readableBytes() < 5)
- return;
-
- content.readByte(); // Compressed-Flag
- int length = content.readInt();
-
- if (content.readableBytes() < length) {
- // 当前帧数据不足,回退读指针等待后续数据(简化处理,生产环境建议引入缓冲聚合)
- content.resetReaderIndex();
- return;
- }
-
- // 尽量避免中间大数组拷贝:先切片,再按底层存储类型选择解析路径
- ByteBuf slice = content.readSlice(length);
-
- RpcRequest rpcRequest;
- if (slice.nioBufferCount() > 0) {
- // 直接走 NIO Buffer 解析,少一次复制
- rpcRequest = RpcRequest.parseFrom(slice.nioBuffer());
- } else {
- // 兜底路径:内存布局不支持 NIO Buffer 时退回字节数组解析
- byte[] bytes = new byte[length];
- slice.readBytes(bytes);
- rpcRequest = RpcRequest.parseFrom(bytes);
- }
-
- ctx.fireChannelRead(rpcRequest);
+ private void dispatch(ChannelHandlerContext ctx) {
+ accumulator.requireComplete();
+ if (dispatched || request == null) {
+ throw new CorruptedFrameException("Invalid unary gRPC request");
}
+ dispatched = true;
+ ctx.fireChannelRead(request);
}
@Override
- public void write(ChannelHandlerContext ctx, Object msg, io.netty.channel.ChannelPromise promise) throws Exception {
- if (msg instanceof RpcResponse) {
- RpcResponse response = (RpcResponse) msg;
- try {
- byte[] bytes = response.toByteArray();
- // gRPC 响应体同样要补上 5 字节前缀(压缩位 + 长度)
- ByteBuf out = ctx.alloc().buffer();
- out.writeByte(0);
- out.writeInt(bytes.length);
- out.writeBytes(bytes);
+ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
+ if (!(msg instanceof RpcResponse response)) {
+ super.write(ctx, msg, promise);
+ return;
+ }
+ byte[] bytes = response.toByteArray();
+ ByteBuf body = ctx.alloc().buffer(bytes.length + 5);
+ body.writeByte(0).writeInt(bytes.length).writeBytes(bytes);
+ Http2Headers headers = new DefaultHttp2Headers().status("200")
+ .set(HttpHeaderNames.CONTENT_TYPE, "application/grpc");
+ Http2Headers trailers = new DefaultHttp2Headers().set("grpc-status", "0");
+ PromiseCombiner writes = new PromiseCombiner(ctx.executor());
+ writes.add(ctx.write(new DefaultHttp2HeadersFrame(headers, false)));
+ writes.add(ctx.write(new DefaultHttp2DataFrame(body, false)));
+ writes.add(ctx.write(new DefaultHttp2HeadersFrame(trailers, true)));
+ writes.finish(promise);
+ }
- Http2Headers headers = new DefaultHttp2Headers().status("200")
- .set(HttpHeaderNames.CONTENT_TYPE, "application/grpc");
- ctx.write(new DefaultHttp2HeadersFrame(headers));
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ accumulator.close();
+ super.channelInactive(ctx);
+ }
- ctx.write(new DefaultHttp2DataFrame(out, false));
+ @Override
+ public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
+ accumulator.close();
+ super.handlerRemoved(ctx);
+ }
- Http2Headers trailers = new DefaultHttp2Headers()
- .set("grpc-status", "0")
- .set("grpc-message", "");
- ctx.writeAndFlush(new DefaultHttp2HeadersFrame(trailers, true), promise);
- } catch (Exception e) {
- log.error("Failed to write gRPC response", e);
- promise.setFailure(e);
- }
- return;
- }
- super.write(ctx, msg, promise);
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ accumulator.close();
+ ctx.close();
}
}
diff --git a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/http2/Http2ClientConnectionReadyHandler.java b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/http2/Http2ClientConnectionReadyHandler.java
new file mode 100644
index 0000000..69b79c9
--- /dev/null
+++ b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/http2/Http2ClientConnectionReadyHandler.java
@@ -0,0 +1,72 @@
+package com.xiaoyu.rpc.core.protocol.http2;
+
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.ChannelPipeline;
+import io.netty.handler.codec.http2.Http2ConnectionPrefaceAndSettingsFrameWrittenEvent;
+import io.netty.util.AttributeKey;
+
+import java.nio.channels.ClosedChannelException;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Gates client-created HTTP/2 streams until Netty has written the connection preface and initial SETTINGS frame.
+ *
+ * Netty explicitly requires clients not to write HTTP/2 application data before
+ * {@link Http2ConnectionPrefaceAndSettingsFrameWrittenEvent} has been processed. Without this gate a request can race
+ * channel activation and put a HEADERS frame on the wire before the HTTP/2 client preface.
+ */
+public final class Http2ClientConnectionReadyHandler extends ChannelInboundHandlerAdapter {
+
+ private static final AttributeKey> READY_FUTURE =
+ AttributeKey.valueOf(Http2ClientConnectionReadyHandler.class, "readyFuture");
+
+ private final CompletableFuture readyFuture;
+
+ private Http2ClientConnectionReadyHandler(CompletableFuture readyFuture) {
+ this.readyFuture = readyFuture;
+ }
+
+ public static void install(ChannelPipeline pipeline) {
+ CompletableFuture readyFuture = new CompletableFuture<>();
+ pipeline.channel().attr(READY_FUTURE).set(readyFuture);
+ pipeline.addLast(new Http2ClientConnectionReadyHandler(readyFuture));
+ }
+
+ public static CompletableFuture readinessFuture(Channel channel) {
+ CompletableFuture readyFuture = channel.attr(READY_FUTURE).get();
+ if (readyFuture != null) {
+ return readyFuture;
+ }
+
+ CompletableFuture failed = new CompletableFuture<>();
+ failed.completeExceptionally(new IllegalStateException(
+ "HTTP/2 client readiness handler is not installed on channel " + channel));
+ return failed;
+ }
+
+ @Override
+ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
+ if (evt instanceof Http2ConnectionPrefaceAndSettingsFrameWrittenEvent) {
+ readyFuture.complete(null);
+ }
+ ctx.fireUserEventTriggered(evt);
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) {
+ if (!readyFuture.isDone()) {
+ readyFuture.completeExceptionally(new ClosedChannelException());
+ }
+ ctx.fireChannelInactive();
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ if (!readyFuture.isDone()) {
+ readyFuture.completeExceptionally(cause);
+ }
+ ctx.fireExceptionCaught(cause);
+ }
+}
diff --git a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/http2/Http2Protocol.java b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/http2/Http2Protocol.java
index 80f509a..a49ba32 100644
--- a/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/http2/Http2Protocol.java
+++ b/rpc-transport-netty/src/main/java/com/xiaoyu/rpc/core/protocol/http2/Http2Protocol.java
@@ -4,16 +4,25 @@
import com.xiaoyu.rpc.common.serialization.SerializerCode;
import com.xiaoyu.rpc.common.vo.RpcRequest;
import com.xiaoyu.rpc.common.vo.RpcResponse;
+import com.xiaoyu.rpc.core.client.RpcStreamResponseHandler;
import com.xiaoyu.rpc.core.config.RpcConfig;
+import com.xiaoyu.rpc.core.protocol.Protocol;
+import com.xiaoyu.rpc.core.protocol.http.HttpRpcDecoder;
+import com.xiaoyu.rpc.core.protocol.http.HttpRpcEncoder;
+import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
-import io.netty.handler.codec.http2.*;
+import io.netty.handler.codec.http.HttpObjectAggregator;
+import io.netty.handler.codec.http2.Http2FrameCodec;
+import io.netty.handler.codec.http2.Http2FrameCodecBuilder;
+import io.netty.handler.codec.http2.Http2MultiplexHandler;
+import io.netty.handler.codec.http2.Http2Settings;
+import io.netty.handler.codec.http2.Http2StreamChannel;
+import io.netty.handler.codec.http2.Http2StreamChannelBootstrap;
+import io.netty.handler.codec.http2.Http2StreamFrameToHttpObjectCodec;
import io.netty.util.ReferenceCountUtil;
-import com.xiaoyu.rpc.core.protocol.http.HttpRpcDecoder;
-import com.xiaoyu.rpc.core.protocol.http.HttpRpcEncoder;
-import com.xiaoyu.rpc.core.protocol.Protocol;
public class Http2Protocol implements Protocol {
@@ -29,84 +38,71 @@ public void config(ChannelPipeline pipeline, boolean isServer, io.netty.channel.
if (isServer) {
Http2FrameCodec frameCodec = Http2FrameCodecBuilder.forServer()
- .autoAckSettingsFrame(true) // 自动确认设置帧
- .autoAckPingFrame(true) // 自动确认ping帧
+ .autoAckSettingsFrame(true)
+ .autoAckPingFrame(true)
.build();
-
- // 使用新的构造方式,指定子通道的处理器
Http2MultiplexHandler multiplexHandler = new Http2MultiplexHandler(
new ChannelInitializer() {
@Override
protected void initChannel(Http2StreamChannel ch) {
- // 在子通道(Stream)中注入转换层
ch.pipeline().addLast(new Http2StreamFrameToHttpObjectCodec(true));
- ch.pipeline().addLast(new io.netty.handler.codec.http.HttpObjectAggregator(512 * 1024));
+ ch.pipeline().addLast(new HttpObjectAggregator(512 * 1024));
ch.pipeline().addLast(new HttpRpcDecoder(serializer, RpcRequest.class));
ch.pipeline().addLast(new HttpRpcEncoder(serializer));
-
if (serverHandler != null) {
ch.pipeline().addLast(serverHandler);
}
}
});
pipeline.addLast(frameCodec, multiplexHandler);
-
} else {
- // 客户端核心配置
Http2FrameCodec frameCodec = Http2FrameCodecBuilder.forClient()
- // 强制自动处理一些基础帧,防止它们掉到 TailContext
.autoAckSettingsFrame(true)
.autoAckPingFrame(true)
.initialSettings(Http2Settings.defaultSettings().maxHeaderListSize(8192))
.build();
-
pipeline.addLast(frameCodec);
- // MultiplexHandler 必须紧跟其后
+ Http2ClientConnectionReadyHandler.install(pipeline);
pipeline.addLast(new Http2MultiplexHandler(new ChannelInboundHandlerAdapter() {
-
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
- // 如果还有残留的设置帧传到这里,说明 FrameCodec 没拦截住
- // 这里直接释放,避免引用计数对象泄漏
ReferenceCountUtil.release(msg);
}
-
- @Override
- public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
- // 处理用户事件,如Http2SettingsAckFrame等
- ctx.fireUserEventTriggered(evt);
- }
}));
}
}
@Override
- public void sendRequest(io.netty.channel.Channel channel, RpcRequest request,
- com.xiaoyu.rpc.core.client.NettyRpcClientHandler clientHandler) throws Exception {
- RpcConfig rpcConfig = RpcConfig.getInstance();
- Serializer serializer = SerializerCode.getSerializerByCode(rpcConfig.getSerializerCode());
-
- // HTTP/2 每个请求走独立 Stream,底层 TCP 连接仍然复用同一个 Channel
- io.netty.handler.codec.http2.Http2StreamChannelBootstrap streamBootstrap = new io.netty.handler.codec.http2.Http2StreamChannelBootstrap(
- channel);
+ public void sendRequest(Channel channel, RpcRequest request,
+ com.xiaoyu.rpc.core.client.NettyRpcClientHandler clientHandler) {
+ Serializer serializer = SerializerCode.getSerializerByCode(RpcConfig.getInstance().getSerializerCode());
+ Http2ClientConnectionReadyHandler.readinessFuture(channel).whenComplete((ignored, readinessError) -> {
+ if (readinessError != null) {
+ clientHandler.failRequest(request.getRequestId(), readinessError);
+ return;
+ }
+ openStreamAndSend(channel, request, serializer, clientHandler);
+ });
+ }
+ private void openStreamAndSend(Channel channel, RpcRequest request, Serializer serializer,
+ com.xiaoyu.rpc.core.client.NettyRpcClientHandler clientHandler) {
+ Http2StreamChannelBootstrap streamBootstrap = new Http2StreamChannelBootstrap(channel);
streamBootstrap.open().addListener(f -> {
if (!f.isSuccess()) {
clientHandler.failRequest(request.getRequestId(), f.cause());
return;
}
-
Http2StreamChannel streamChannel = (Http2StreamChannel) f.getNow();
- // 每个 Stream 都有独立 pipeline,避免多请求之间相互干扰
streamChannel.pipeline().addLast(new Http2StreamFrameToHttpObjectCodec(false));
- streamChannel.pipeline().addLast(new io.netty.handler.codec.http.HttpObjectAggregator(512 * 1024));
+ streamChannel.pipeline().addLast(new HttpObjectAggregator(512 * 1024));
streamChannel.pipeline().addLast(new HttpRpcEncoder(serializer));
streamChannel.pipeline().addLast(new HttpRpcDecoder(serializer, RpcResponse.class));
- streamChannel.pipeline().addLast(clientHandler);
-
+ streamChannel.pipeline().addLast(new RpcStreamResponseHandler(clientHandler, request.getRequestId()));
streamChannel.writeAndFlush(request).addListener(writeFuture -> {
if (!writeFuture.isSuccess()) {
clientHandler.failRequest(request.getRequestId(), writeFuture.cause());
+ streamChannel.close();
}
});
});
diff --git a/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/client/RpcStreamResponseHandlerTest.java b/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/client/RpcStreamResponseHandlerTest.java
new file mode 100644
index 0000000..d1956f0
--- /dev/null
+++ b/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/client/RpcStreamResponseHandlerTest.java
@@ -0,0 +1,78 @@
+package com.xiaoyu.rpc.core.client;
+
+import com.xiaoyu.rpc.common.vo.RpcResponse;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.http2.DefaultHttp2ResetFrame;
+import io.netty.handler.codec.http2.Http2Error;
+import org.junit.jupiter.api.Test;
+import java.util.concurrent.CompletableFuture;
+import static org.junit.jupiter.api.Assertions.*;
+
+class RpcStreamResponseHandlerTest {
+ @Test
+ void completedStreamDoesNotFailSiblingOrNextRequest() {
+ NettyRpcClientHandler shared = new NettyRpcClientHandler();
+ EmbeddedChannel parent = new EmbeddedChannel(shared);
+ EmbeddedChannel first = new EmbeddedChannel(new RpcStreamResponseHandler(shared, "a"));
+ EmbeddedChannel second = new EmbeddedChannel(new RpcStreamResponseHandler(shared, "b"));
+ try {
+ CompletableFuture a = new CompletableFuture<>();
+ CompletableFuture b = new CompletableFuture<>();
+ shared.addFuture("a", a);
+ shared.addFuture("b", b);
+ first.writeInbound(RpcResponse.newBuilder().setRequestId("a").build());
+ first.close();
+ assertFalse(a.isCompletedExceptionally());
+ assertTrue(a.isDone());
+ assertFalse(b.isDone());
+ second.writeInbound(RpcResponse.newBuilder().setRequestId("b").build());
+ assertTrue(b.isDone());
+ assertFalse(b.isCompletedExceptionally());
+ assertEquals(0, shared.pendingRequestCount());
+ } finally {
+ first.finishAndReleaseAll();
+ second.finishAndReleaseAll();
+ parent.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void resetFailsOnlyItsStreamAndParentCloseStillFailsAll() {
+ NettyRpcClientHandler shared = new NettyRpcClientHandler();
+ EmbeddedChannel parent = new EmbeddedChannel(shared);
+ EmbeddedChannel child = new EmbeddedChannel(new RpcStreamResponseHandler(shared, "a"));
+ try {
+ CompletableFuture a = new CompletableFuture<>();
+ CompletableFuture b = new CompletableFuture<>();
+ shared.addFuture("a", a);
+ shared.addFuture("b", b);
+ child.pipeline().fireUserEventTriggered(new DefaultHttp2ResetFrame(Http2Error.CANCEL));
+ assertTrue(a.isCompletedExceptionally());
+ assertFalse(b.isDone());
+ parent.close();
+ assertTrue(b.isCompletedExceptionally());
+ assertEquals(0, shared.pendingRequestCount());
+ } finally {
+ child.finishAndReleaseAll();
+ parent.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void mismatchedResponseCannotCompleteAnotherStream() {
+ NettyRpcClientHandler shared = new NettyRpcClientHandler();
+ EmbeddedChannel channel = new EmbeddedChannel(new RpcStreamResponseHandler(shared, "a"));
+ try {
+ CompletableFuture a = new CompletableFuture<>();
+ CompletableFuture b = new CompletableFuture<>();
+ shared.addFuture("a", a);
+ shared.addFuture("b", b);
+ channel.writeInbound(RpcResponse.newBuilder().setRequestId("b").build());
+ assertTrue(a.isCompletedExceptionally());
+ assertFalse(b.isDone());
+ } finally {
+ channel.finishAndReleaseAll();
+ shared.failAll(new IllegalStateException("test cleanup"));
+ }
+ }
+}
diff --git a/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcClientResponseHandlerTest.java b/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcClientResponseHandlerTest.java
index ca0808f..2d53df5 100644
--- a/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcClientResponseHandlerTest.java
+++ b/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcClientResponseHandlerTest.java
@@ -3,17 +3,20 @@
import com.google.protobuf.ByteString;
import com.xiaoyu.rpc.common.vo.RpcResponse;
import com.xiaoyu.rpc.core.client.NettyRpcClientHandler;
+import com.xiaoyu.rpc.core.client.RpcStreamResponseHandler;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.http2.DefaultHttp2DataFrame;
+import io.netty.handler.codec.http2.DefaultHttp2Headers;
import io.netty.handler.codec.http2.DefaultHttp2HeadersFrame;
-import io.netty.handler.codec.http2.Http2Headers;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
@@ -21,57 +24,74 @@
class GrpcClientResponseHandlerTest {
@Test
- @DisplayName("应把 DataFrame 解码为 RpcResponse 并完成 future")
+ @DisplayName("完整消息加成功状态尾帧才完成 future")
void testDecodeDataFrameToRpcResponse() throws Exception {
NettyRpcClientHandler clientHandler = new NettyRpcClientHandler();
- EmbeddedChannel channel = new EmbeddedChannel(
- new GrpcClientResponseHandler(clientHandler, "req-1"),
- clientHandler);
-
+ EmbeddedChannel channel = channel(clientHandler, "req-1");
CompletableFuture future = new CompletableFuture<>();
clientHandler.addFuture("req-1", future);
-
- RpcResponse response = RpcResponse.newBuilder()
- .setRequestId("req-1")
- .setMessage("Success")
- .setData(ByteString.copyFromUtf8("ok"))
- .build();
-
- byte[] payload = response.toByteArray();
- ByteBuf buf = Unpooled.buffer();
- buf.writeByte(0);
- buf.writeInt(payload.length);
- buf.writeBytes(payload);
-
- channel.writeInbound(new DefaultHttp2DataFrame(buf, true));
-
- assertTrue(future.isDone(), "Future should be completed");
- Object result = future.get();
- assertInstanceOf(RpcResponse.class, result);
- assertEquals("req-1", ((RpcResponse) result).getRequestId());
- assertEquals("Success", ((RpcResponse) result).getMessage());
+ RpcResponse response = response("req-1");
+ try {
+ channel.writeInbound(new DefaultHttp2HeadersFrame(new DefaultHttp2Headers()
+ .status("200").set("content-type", "application/grpc"), false));
+ channel.writeInbound(new DefaultHttp2DataFrame(framed(response), false));
+ assertFalse(future.isDone(), "A DATA frame does not establish gRPC success");
+ channel.writeInbound(new DefaultHttp2HeadersFrame(new DefaultHttp2Headers()
+ .set("grpc-status", "0"), true));
+ assertEquals(response, future.get(1, TimeUnit.SECONDS));
+ } finally {
+ channel.finishAndReleaseAll();
+ }
}
@Test
@DisplayName("收到 grpc-status 非 0 时应异常完成 future")
void testFailFutureOnGrpcErrorStatus() {
NettyRpcClientHandler clientHandler = new NettyRpcClientHandler();
- EmbeddedChannel channel = new EmbeddedChannel(
- new GrpcClientResponseHandler(clientHandler, "req-2"),
- clientHandler);
-
+ EmbeddedChannel channel = channel(clientHandler, "req-2");
CompletableFuture future = new CompletableFuture<>();
clientHandler.addFuture("req-2", future);
+ try {
+ channel.writeInbound(new DefaultHttp2HeadersFrame(new DefaultHttp2Headers()
+ .status("200").set("grpc-status", "13").set("grpc-message", "internal"), true));
+ assertTrue(future.isCompletedExceptionally());
+ ExecutionException error = assertThrows(ExecutionException.class,
+ () -> future.get(1, TimeUnit.SECONDS));
+ assertTrue(error.getCause().getMessage().contains("gRPC status=13"));
+ } finally {
+ channel.finishAndReleaseAll();
+ }
+ }
- Http2Headers trailers = new io.netty.handler.codec.http2.DefaultHttp2Headers()
- .set("grpc-status", "13")
- .set("grpc-message", "internal");
- channel.writeInbound(new DefaultHttp2HeadersFrame(trailers, true));
+ @Test
+ @DisplayName("DATA 直接结束且缺少状态尾帧时必须失败")
+ void testMissingTrailersCannotBeSuccess() {
+ NettyRpcClientHandler clientHandler = new NettyRpcClientHandler();
+ EmbeddedChannel channel = channel(clientHandler, "req-3");
+ CompletableFuture future = new CompletableFuture<>();
+ clientHandler.addFuture("req-3", future);
+ try {
+ channel.writeInbound(new DefaultHttp2DataFrame(framed(response("req-3")), true));
+ ExecutionException error = assertThrows(ExecutionException.class,
+ () -> future.get(1, TimeUnit.SECONDS));
+ assertInstanceOf(CorruptedFrameException.class, error.getCause());
+ } finally {
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ private static EmbeddedChannel channel(NettyRpcClientHandler handler, String requestId) {
+ return new EmbeddedChannel(new GrpcClientResponseHandler(handler, requestId),
+ new RpcStreamResponseHandler(handler, requestId));
+ }
+
+ private static RpcResponse response(String requestId) {
+ return RpcResponse.newBuilder().setRequestId(requestId).setMessage("Success")
+ .setData(ByteString.copyFromUtf8("ok")).build();
+ }
- assertTrue(future.isCompletedExceptionally(), "Future should be completed exceptionally");
- ExecutionException ex = assertThrows(ExecutionException.class, future::get);
- String message = ex.getCause().getMessage();
- assertNotNull(message, "Error message should not be null");
- assertTrue(message.toLowerCase().contains("grpc"), "Error message should include grpc details");
+ private static ByteBuf framed(RpcResponse response) {
+ byte[] payload = response.toByteArray();
+ return Unpooled.buffer(payload.length + 5).writeByte(0).writeInt(payload.length).writeBytes(payload);
}
}
diff --git a/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcFramingTest.java b/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcFramingTest.java
new file mode 100644
index 0000000..e71a87b
--- /dev/null
+++ b/rpc-transport-netty/src/test/java/com/xiaoyu/rpc/core/protocol/grpc/GrpcFramingTest.java
@@ -0,0 +1,113 @@
+package com.xiaoyu.rpc.core.protocol.grpc;
+
+import com.google.protobuf.ByteString;
+import com.xiaoyu.rpc.common.vo.RpcRequest;
+import com.xiaoyu.rpc.common.vo.RpcResponse;
+import com.xiaoyu.rpc.core.client.NettyRpcClientHandler;
+import com.xiaoyu.rpc.core.client.RpcStreamResponseHandler;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.buffer.UnpooledByteBufAllocator;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.CorruptedFrameException;
+import io.netty.handler.codec.TooLongFrameException;
+import io.netty.handler.codec.http2.DefaultHttp2DataFrame;
+import io.netty.handler.codec.http2.DefaultHttp2Headers;
+import io.netty.handler.codec.http2.DefaultHttp2HeadersFrame;
+import org.junit.jupiter.api.Test;
+import java.util.concurrent.CompletableFuture;
+import static org.junit.jupiter.api.Assertions.*;
+
+class GrpcFramingTest {
+ @Test
+ void serverAccumulatesSplitPrefixAndLargeMessage() {
+ RpcRequest request = RpcRequest.newBuilder().setRequestId("large")
+ .addParameters(ByteString.copyFrom(new byte[256 * 1024])).build();
+ ByteBuf wire = framed(request.toByteArray());
+ EmbeddedChannel server = new EmbeddedChannel(new GrpcServerHandler(null));
+ try {
+ server.writeInbound(new DefaultHttp2DataFrame(wire.readRetainedSlice(2), false));
+ assertNull(server.readInbound());
+ while (wire.readableBytes() > 8192) {
+ server.writeInbound(new DefaultHttp2DataFrame(wire.readRetainedSlice(8192), false));
+ assertNull(server.readInbound());
+ }
+ server.writeInbound(new DefaultHttp2DataFrame(wire.readRetainedSlice(wire.readableBytes()), true));
+ assertEquals(request, server.readInbound());
+ assertNull(server.readInbound());
+ } finally {
+ wire.release();
+ server.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void clientAccumulatesLargeResponseAndWaitsForTrailers() {
+ NettyRpcClientHandler shared = new NettyRpcClientHandler();
+ CompletableFuture future = new CompletableFuture<>();
+ shared.addFuture("large", future);
+ RpcResponse response = RpcResponse.newBuilder().setRequestId("large")
+ .setData(ByteString.copyFrom(new byte[256 * 1024])).build();
+ EmbeddedChannel channel = new EmbeddedChannel(new GrpcClientResponseHandler(shared, "large"),
+ new RpcStreamResponseHandler(shared, "large"));
+ ByteBuf wire = framed(response.toByteArray());
+ try {
+ channel.writeInbound(new DefaultHttp2HeadersFrame(new DefaultHttp2Headers().status("200"), false));
+ channel.writeInbound(new DefaultHttp2DataFrame(wire.readRetainedSlice(3), false));
+ while (wire.isReadable()) {
+ channel.writeInbound(new DefaultHttp2DataFrame(
+ wire.readRetainedSlice(Math.min(8192, wire.readableBytes())), false));
+ }
+ assertFalse(future.isDone(), "A body alone is not gRPC success");
+ channel.writeInbound(new DefaultHttp2HeadersFrame(new DefaultHttp2Headers().set("grpc-status", "0"), true));
+ assertEquals(response, future.join());
+ channel.close();
+ assertFalse(future.isCompletedExceptionally());
+ } finally {
+ wire.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void errorTrailersDoNotBecomeSuccessfulResponse() {
+ NettyRpcClientHandler shared = new NettyRpcClientHandler();
+ CompletableFuture future = new CompletableFuture<>();
+ shared.addFuture("error", future);
+ EmbeddedChannel channel = new EmbeddedChannel(new GrpcClientResponseHandler(shared, "error"),
+ new RpcStreamResponseHandler(shared, "error"));
+ try {
+ channel.writeInbound(new DefaultHttp2DataFrame(framed(RpcResponse.newBuilder().setRequestId("error").build().toByteArray()), false));
+ channel.writeInbound(new DefaultHttp2HeadersFrame(new DefaultHttp2Headers().set("grpc-status", "13"), true));
+ assertTrue(future.isCompletedExceptionally());
+ } finally {
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void boundsAndTruncatedMessagesAreRejected() {
+ try (GrpcMessageAccumulator accumulator = new GrpcMessageAccumulator(32)) {
+ ByteBuf header = Unpooled.buffer().writeByte(0).writeInt(33);
+ try {
+ assertThrows(TooLongFrameException.class,
+ () -> accumulator.append(UnpooledByteBufAllocator.DEFAULT, header));
+ } finally {
+ header.release();
+ }
+ }
+ try (GrpcMessageAccumulator accumulator = new GrpcMessageAccumulator(32)) {
+ ByteBuf truncated = Unpooled.buffer().writeByte(0).writeInt(8).writeByte(1);
+ try {
+ assertNull(accumulator.append(UnpooledByteBufAllocator.DEFAULT, truncated));
+ assertThrows(CorruptedFrameException.class, accumulator::requireComplete);
+ } finally {
+ truncated.release();
+ }
+ }
+ }
+
+ private static ByteBuf framed(byte[] payload) {
+ return Unpooled.buffer(payload.length + 5).writeByte(0).writeInt(payload.length).writeBytes(payload);
+ }
+}