# solon-httpbin **Repository Path**: dyrnq/solon-httpbin ## Basic Information - **Project Name**: solon-httpbin - **Description**: 参考 httpbin.org 实现的 HTTP 测试服务,基于 Solon 框架 同时跑在 8 种 Solon 内置 HTTP 服务器适配器上,统一接口、按模块出包。 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: https://gitee.com/opensolon/solon - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-27 - **Last Updated**: 2026-10-02 ## Categories & Tags **Categories**: Uncategorized **Tags**: httpbin, solon ## README # solon-httpbin > 参考 [httpbin.org](https://httpbin.org/) 实现的 HTTP 测试服务,基于 [Solon](https://solon.noear.org/) 框架 > 同时跑在 **9 种** Solon 内置 HTTP 服务器适配器上,统一接口、按模块出包。 ## 概览 | 模块 | 端口 | 适配器 | |------|------|--------| | server-smarthttp | 18080 | `solon-server-smarthttp` | | server-jetty | 18081 | `solon-server-jetty` | | server-undertow | 18082 | `solon-server-undertow` | | server-tomcat | 18083 | `solon-server-tomcat-embed` | | server-jdkhttp | 18084 | `solon-server-jdkhttp` | | server-feathttp | 18085 | `solon-server-feat-http` | | server-grizzly | 18086 | `solon-server-grizzly` | | server-netahttp | 18087 | `solon-server-netahttp` | | server-vertx | 18088 | `solon-server-vertx` | > **server-netahttp (18087)** 4.1.1 通过 PR `!424`(commit `ec7a88549e...`)`commitResponse()` 漏调 `markWriter()` 致 body 字节 0 的 bug 已修 —— 4.1.1 实测 body 正常发送、50 并发全 200。`NetaHttpContext.close()` 仍是空方法,keep-alive 时 server 不主动关,由 curl `--max-time` 兜底退出;verify.sh 用 `--max-time 8` 兼容。 ## 构建 要求:JDK 21+、Maven 3.9+。 ```bash mvn -B package -DskipTests ``` 产物:每个 `server-*/target/server-*.jar` 是 BOOT-INF 布局的可执行胖包([solon-maven-plugin repackage](https://solon.noear.org/article/307)),直接: ```bash java -jar server-jetty/target/server-jetty.jar ``` ## 跑起来 + 验证 ```bash ./verify.sh # 默认 solon 4.1.0 ./verify.sh 3.7.5 # 换 solon 版本 MVN_OFFLINE=-o ./verify.sh # 全离线(仅当所需构件本地仓库都有时) ``` `verify.sh` 会: 1. 一次性 `mvn package` 全部胖包; 2. **并发**启动 8 个模块,各自日志写 `target/.log`; 3. 并发跑 87 条 smoke(含 1 条 `/post` multipart + 7 条 `/auxiliary/*` + 6 条 `/auxiliary/payload` + 3 条应用层手动压缩 `/gzip|/deflate|/brotli`)→ `target/.smoke.tsv`; 4. 汇总成 `target/report.tsv`(module | path | expected | actual | status | ms); 5. 打印每模块通过率、失败明细。 > 类型边界 case 由独立脚本 `./verify-edge.sh` 覆盖(4 serialization × 84 × 9 适配器 = **3024** 条断言,每 plugin 一份 `target/.edge.report.tsv`),两条脚本并行维护、互不干扰。两条脚本都跑全绿时合计 783 + 3024 = **3807 条断言**。 最近一次(~24s): ``` 模块数 : 9 用例数 : 783 通过 / 失败: 783 / 0 ─── 各模块 ─── server-feathttp 87/87 server-grizzly 87/87 server-jdkhttp 87/87 server-jetty 87/87 server-netahttp 87/87 server-smarthttp 87/87 server-tomcat 87/87 server-undertow 87/87 server-vertx 87/87 ``` ## 响应压缩独立测试(静态文件) `./verify-static-gzip.sh` —— 单独跑静态资源 gzip 路径下的 `Content-Encoding` 头 + body 字节数断言。覆盖 **4 场景 × 8 模块 × 2 规则 = 64** 条,结果写 `target/verify-static-gzip.tsv`。 2 条规则: - `probe_raw`: `/probe.txt` 无 `Accept-Encoding` → body 字节数 = 6160 - `probe_gzip`: `/probe.txt` + `AE: gzip` → 应 `Content-Encoding: gzip` + body < 6160 4 个场景(按 GzipProps 配置维度拆,验证各 server 适配器在 StaticResourceHandler 路径下行为一致): - `A_default_off` —— 不传 `-D`(enable 走默认 false,minSize 走默认 4096)→ probe_gzip 应**不**触发 gzip - `B_default_on` —— `-Dserver.http.gzip.enable=true`(minSize 走默认 4096 > 6160)→ probe_gzip 应触发 - `C_min_below` —— `-D...enable=true -D...minSize=1024`(阈值 < 6160)→ probe_gzip 应触发 - `D_min_above` —— `-D...enable=true -D...minSize=8192`(阈值 > 6160)→ probe_gzip 应**不**触发 Solon 的 `Props.getProperty` 读 `System.getProperty` 优先于 `app.properties`,所以这些 `-D` 在启服时注入即可生效,让 `StaticResourceHandler` / `OutputUtils` 真触发或拒绝 gzip。 `/gzip /deflate /brotli` 应用层手动压缩端点仍走 `verify.sh`(不依赖 GzipProps 配置)。 ### `verify.sh` smoke flag 语法 每行 `PATH|EXPECT_CODE|FLAGS`,同一行用 `&` 串多个子 flag: | 写法 | 效果 | |------|------| | `M=METHOD` | `-X METHOD`(非 GET 时必填) | | `u=user:pass` | `-u user:pass`(Basic / Digest) | | `h=Name: Value` | `-H "Name: Value"`(Bearer 等) | | `F=name=value` | `-F name=value`(multipart 文本 part) | | `F=name=@path` | `-F name=@path`(multipart 文件 part,`@` 开头) | | `Ct=` | 再发一次 GET(`-H 'Connection: close'` 强制新连接避免 feathttp/smarthttp keep-alive 残留),断言 `Content-Type: ...` 前缀匹配 —— 覆盖 Solon StaticResourceHandler 的 mime 设头、框架默认 `text/html`、显式 `contentType()` | 例:`/post|200|M=POST&F=token=abc&F=file=@/etc/hostname` ## 端点 完整接口与 httpbin.org 对齐:[HttpbinController](common/src/main/java/httpbin/HttpbinController.java) 集中实现([HttpbinAuxiliaryController](common/src/main/java/httpbin/HttpbinAuxiliaryController.java) 走 Solon 上层 multipart 路径做对照),主要分组: | 分组 | 端点举例 | |------|----------| | HTTP 方法 | `/get` `/post` `/put` `/delete` `/patch` `/anything/{anything}` | | 辅助对照 | `/auxiliary/{get,post,put,delete,patch,anything,anything/{anything}}`(UploadFile 路径) + `/auxiliary/payload`(raw body 字节透传) | | 请求检视 | `/headers` `/ip` `/user-agent` `/cookies` | | 状态码 | `/status/{codes}` × 5 方法 | | 重定向 | `/redirect/{n}` `/absolute-redirect/{n}` `/relative-redirect/{n}` `/redirect-to?url=…` | | Cookie | `/cookies` `/cookies/set` `/cookies/set/{n}/{v}` `/cookies/delete` | | 动态数据 | `/uuid` `/base64/{v}` `/bytes/{n}` `/range/{n}` `/delay/{s}` + 5 方法 `/stream/{n}` `/drip` `/stream-bytes/{n}` `/links/{n}/{offset}` | | 响应格式 | `/json` `/gzip` `/deflate` `/html` `/xml` `/robots.txt` `/deny` `/brotli` `/encoding/utf8` | | 响应检视 | `/cache` `/cache/{value}` `/etag/{etag}` `/response-headers` | | 图片 | `/image` `/image/{type}` `/image/png` `/image/jpeg` `/image/webp` `/image/svg` | | 鉴权 | `/basic-auth/{u}/{p}` `/hidden-basic-auth/{u}/{p}` `/bearer` `/digest-auth/{realm}/{u}/{p}` | | 类型/编码边界 | `/edge/*` — UTF-8 字符串、long 极值、BigDecimal 精度、`@Body` POJO 绑定、`ctx.render` 序列化(见 `./verify-edge.sh`)| `/brotli` 走 `brotli4j` 压缩,`/encoding/utf8` 走 `UTF-8-demo.txt` 资源 —— 所有 classpath 资源(moby.html / sample.xml / images/…)来自与原项目对应的开源样本。 ## `/edge/*` 类型边界 case(独立 smoke 脚本) 主 `HttpbinController` + `HttpbinAuxiliaryController` 的 smoke 只验「接口通 + 状态码」,**没有覆盖 Solon 框架自带能力在 8 适配器下的一致性**。`HttpbinEdgeController.java`(路径前缀 `/edge/*`)专门跑: - **字符串**:`/edge/string/echo` `/edge/string/echo/path/{s}` `/edge/string/escape` —— UTF-8 中文 / emoji / 控制字符 / JSON 转义字符在 URL 解码后的完整性 - **数字**:`/edge/num/long/{v}` `/edge/num/int/{v}` `/edge/num/double/{v}` `/edge/num/bigint/{v}` `/edge/num/bigdecimal/{v}` `/edge/num/int-array` —— 走 Solon `ConvertUtil.tryTo()` 的路径/query 绑定分支;测试 `Long.MAX_VALUE` / `Long.MIN_VALUE` / `Integer.MAX_VALUE` / `0.1` 精度保留 - **数字溢出**:`/edge/num/overflow/{v}` —— 超 `long` 范围的字符串 → 期望 400(Solon 把 `NumberFormatException` 包成 `StatusException(400)`) - **JSON body**:`/edge/body/map` `/edge/body/pojo` `/edge/body/empty` —— Solon `@Body` 参数绑定(注册到 `ChainManager.addEntityConverter` 的 JSON plugin 把 JSON 反序列化成 `Map` / POJO) - **JSON 渲染**:`/edge/render/map` `/edge/render/list` `/edge/render/pojo` —— `ctx.render(Object)` 走 Solon `@json` Render(plugin 实现,4 个候选见下) - **Raw JSON 通道**:`/edge/output-as-json/raw` —— `ctx.outputAsJson(String)` 跳过反射直接写 body - **`!426` percent-decode 契约矩阵**(8 行)—— `+` 字面量 / `%20` 解码 / 4-byte UTF-8 / 小写 hex / 中文+空格+# 混合 / 双重编码防御,回归锁 PR `!426`(`debc483cf6f...`)在 4.1.1 rawpath 切法后的修复 `./verify-edge.sh` 跑 **4 个 serialization plugin × 84 smoke × 9 适配器 = 3024 条断言**,每 plugin 一份 `target/.edge.report.tsv`,**与 `./verify.sh` 的 783 条完全隔离**(两条线独立构建、独立启服、独立聚合报告)。两条脚本都跑全绿时合计 783 + 3024 = **3807 条断言**。 `/edge/*` 依赖一个 JSON serialization 插件 —— Solon core 不内置 JSON 转换器,`ctx.render(Object)` 走 `Solon.app().serializers()` 找 `@json` Render;没注册就 fallback 到 `Object.toString()` 输出垃圾。`common/pom.xml` 用 4 个互斥 Maven profile 切换 serialization 实现(Plugin SPI 完全等价): | profile | plugin | 底层 json 库 | |---|---|---| | `-Pserialization-snack4` (default) | `solon-serialization-snack4` | org.noear:snack4 | | `-Pserialization-fastjson2` | `solon-serialization-fastjson2` | com.alibaba.fastjson2 | | `-Pserialization-jackson3` | `solon-serialization-jackson3` | tools.jackson.core:jackson-core/databind | | `-Pserialization-gson` | `solon-serialization-gson` | com.google.code.gson:gson | `verify-edge.sh` 一次会话里把 4 个 profile 全部跑一遍(`mvn -Pserialization-X package` × 4 + 启 9 + smoke × 84 + 收尾),对比每个 serialization 在 9 适配器下的一致性。脚本退出时强制重打回 snack4 默认状态,保证仓库 fat jar 是 `solon-serialization-snack4` jar。 ### controller 方法返回值(非 void)—— Solon 一个反直觉的设计 `HttpbinEdgeController` 第 5 组 9 个端点专门测 controller 方法返回非 void 时的行为(见 `ActionDefault.invokeMethodDo()` 第 347 行 + `RenderManager.render()` 第 132 行): | 方法签名 | 行为 | |---|---| | `String returnString()` → `"hello"` | Solon 把 String 视为 **raw 文本**,直接 `ctx.output((String) data)`,绕过 `@json` Render,**`Content-Type: text/plain;charset=utf-8`** —— 返回 `hello` 而非 JSON 编码的 `"hello"`。**与 serialization plugin 无关**(这是 `RenderManager.render()` 第 212 行的框架层逻辑) | | `Map` return | String 在 Map **内**会被 JSON serialization plugin 编码(加引号转义),证明 String 在容器内才走序列化路径 | | `Map` return | 渲染为 JSON object,`Content-Type: application/json` | | `EdgePojo` return | 反射 getter → JSON object | | `List` return | 渲染为 JSON array(顶层是 array 不是 object) | | `long` / `boolean` / `Integer` 等基本类型 return | 渲染为 JSON primitive,`Content-Type: application/json` | | `null` return | `renderDo` 看 `c.result == null` 直接 return,body 空(`Content-Length: 0`) | **坑**:`return "hello"` 不会 JSON-encode(与 serialization plugin 无关)。要 JSON-encoded String 就用 `return Map.of("k", "hello")` 包一层,或显式 `ctx.outputAsJson("hello")`(见 `/edge/output-as-json/raw` 对照)。 ## 模块化设计 ``` solon-httpbin ├── pom.xml ← 反应堆父 POM,pluginManagement 里管 solon-maven-plugin ├── common/ ← 业务代码 + 通用依赖(slf4j-simple、brotli4j) │ └── src/main/java/httpbin/ │ ├── App.java ← @SolonMain 入口 │ ├── HttpbinController.java ← 主端点(HttpMultipartCollection 路径) │ ├── HttpbinAuxiliaryController.java← UploadFile/fileMap 对照端点(/auxiliary/*) │ ├── HttpbinEdgeController.java ← 类型/编码边界 case(/edge/*,4 serialization profile 切换) │ ├── Json.java ← 自带极简 JSON 序列化 │ └── JsonStringParser.java └── server-*/ ← 8 个 server 适配器模块 └── pom.xml ← 各自引入一个 solon-server-* 适配器 ``` 要点: * **业务代码全在 `common/`**,server 模块只差 classpath 上的 HTTP 适配器; * **`App.java` 用三参 `Solon.start(Class, String[], Consumer)`** —— 两参形式在本版本会 NPE(`Solon.app` 还没赋值就被读); * **`App.java` 起一条非 daemon 的 keepalive 线程** —— 部分适配器的工作线程是 daemon,main 一返回 JVM 就退; * **`App.java` 全局 handler 链前挂一个 `prev()`** 设 `Connection: close` —— 兜底未来适配器的不关 socket 行为; * **`writeBytes()` 写完 `ctx.close()`** —— 同上兜底,对其他适配器是 no-op; * **路由匹配按注册顺序**:字面量路由必须先于参数化路由(`/image/png` 必须在 `/image/{type}` 前注册,否则参数化会先吞)—— Solon 的 `RoutingTableDefault.matchOne()` 沿 LinkedList 顺序找首个匹配; * **`brotli4j` native 库**:fat jar 里访问 `BOOT-INF/lib/*.jar` 内的 native 不走 `System.loadLibrary`,由 `Brotli4jLoader.ensureAvailability()` 在启动时把 native 解压到临时目录;启动期做一次 probe,失败则 `/brotli` 返回 501。 * **multipart 解析两条路径**: - **主 `HttpbinController`**(`/post` 等):入口先 `ctx.autoMultipart(false)` 关掉 Solon 上层自动 multipart 解析,再走 `HttpMultipartCollection(contentType, ctx.bodyAsStream())` 直接吃 raw body 解 part。**必关**的原因:8 适配器里 5 个(undertow / tomcat / feathttp / grizzly / vertx)的 `paramMap()` 懒调用 `loadMultipartFormData()`,直接读 `getInputStream()` 把 body 流消化掉;后续 handler 里 `bodyAsStream()` 拿到的是空流。Smarthttp / jdkhttp / jetty 的 `MultipartUtil` 走 `request.getParts()` 缓存,不碰 bodyAsStream() 那条流,所以这 3 个适配器不关也能跑,但为了一致性、8 适配器统一行为,统一关掉。 - **对照 `HttpbinAuxiliaryController`**(`/auxiliary/*`):完全不碰 `bodyAsStream()`,让各适配器的 `loadMultipartFormData()` 自然触发,自己只读 `ctx.paramMap()` 拿文本 part、`ctx.fileMap()` 拿文件 part。**两条路径 8 适配器实测都给出一致 form/files 字段**,差别是 `/post` 的 `form` 只装 multipart text part,`/auxiliary/post` 的 `form` 同时装 url query(Solon 上层 `paramMap()` 把两者同源)。 * **响应压缩 `GzipProps`(`server.http.gzip.enable` 等)只对静态文件 / 文件下载路径生效**,不影响 controller 直接 `ctx.output(byte[])`。源码层面 `GzipProps.requiredGzip()` 全 repo 只在 `OutputUtils.outputStream()` 一处被调,而 `outputStream()` 又只在 `outputFile()` 内部被调 —— 也就是说,**框架给 `ctx.outputAsFile(File/DownloadedFile)` 这一路通了 gzip 自动 hook**(输出 `image/*` / classpath 静态资源 / 文件下载时会按 `enable / minSize / mimeTypes` 自动 gzip),但 controller 写 `ctx.output(byte[])` / `ctx.output(String)` / `ctx.outputAsJson(...)` **不走该 hook**。8 个 server 适配器的 `Context.output(byte[])` 实现里没有任何一个调用过 `OutputUtils.outputStream` 或读 `GzipProps`。要 controller 输出自动 gzip,要么自己手动 `if (GzipProps.requiredGzip(ctx, mime, size)) ctx.outputStreamAsGzip()`(见 `HttpbinController.gzip()` / `deflate()` / `brotli()` 的写法),要么改走 `ctx.outputAsFile(...)`。`GzipProps` 在静态文件路径下的 4 场景(enable × minSize 阈值)对照行为详见 `./verify-static-gzip.sh` —— 与 Spring Boot 默认 servlet 容器 `server.compression.min-response-size` 自动行为不一样。 ## License MIT(继承自上游 httpbin 项目)。