从路径匹配到 HTTP/2:wastnet 路由开发的工程化方法

2026-08-27 37 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

wastnet 把 Java Web 服务的关键链路掌握在自己手中:网络层基于 JDK 原生 NIO 和 Reactor 多路复用模型,不依赖 Netty、Tomcat 等第三方网络库;HTTP/2 的 HPACK、Huffman、ALPN 等组件也由框架自行实现。对业务开发者来说,这些底层能力最终都会汇聚到一个高频入口:HTTP 路由分发。

路由代码看起来只是“路径对应处理函数”,但真正上线后,还要处理静态路径、参数提取、匹配优先级、方法约束、异常响应,以及 h2、h2c 与 HTTP/1.1 的一致性。下面从工程角度拆解这些问题。

路由层应该负责什么

一条请求进入服务器后,路由层通常需要根据 HTTP 方法和规范化后的路径选择处理器。例如:

GET    /users             -> 查询用户列表
GET    /users/:id         -> 查询单个用户
POST   /users             -> 创建用户
DELETE /users/:id         -> 删除用户

路由层适合承担以下职责:

  • 按 HTTP 方法和路径定位处理器。
  • 提取路径参数,例如 /users/42 中的 id=42
  • 在找不到路径时返回 404 Not Found
  • 在路径存在但方法不匹配时返回 405 Method Not Allowed
  • 将处理器异常转换为稳定的 HTTP 错误响应。

身份认证、数据库事务和复杂业务校验则应放在中间件或业务服务中。否则路由表会迅速变成难以测试的大型条件分支。

还要特别注意匹配顺序。对于下面两条规则:

GET /users/me
GET /users/:id

静态路径 /users/me 应当比参数路径优先。如果路由器只按注册顺序或简单通配规则匹配,me 可能被错误解释为用户 ID。一个稳妥策略是按照“静态段数量更多、参数段数量更少、路径更长”的顺序计算优先级,并在应用启动时检查完全重复的路由。

用一个零依赖示例理解路径参数

来源摘要没有给出 wastnet 的完整路由 API,因此下面不是对其真实接口的复刻,而是一个可以直接运行的 JDK 示例,用来验证路由匹配、参数提取和静态路径优先级。创建 RouteDemo.java

import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class RouteDemo {
    record Route(String method, String pattern, String handler) {
        int staticSegments() {
            int count = 0;
            for (String segment : segments(pattern)) {
                if (!segment.startsWith(":")) count++;
            }
            return count;
        }
    }

    record Match(String handler, Map<String, String> params) {}

    static final List<Route> routes = new ArrayList<>();

    static void add(String method, String pattern, String handler) {
        routes.add(new Route(method, normalize(pattern), handler));
        routes.sort(Comparator
                .comparingInt(Route::staticSegments).reversed()
                .thenComparingInt(r -> segments(r.pattern()).size()).reversed());
    }

    static Match match(String method, String path) {
        List<String> actual = segments(normalize(path));

        for (Route route : routes) {
            if (!route.method().equalsIgnoreCase(method)) continue;

            List<String> expected = segments(route.pattern());
            if (expected.size() != actual.size()) continue;

            Map<String, String> params = new LinkedHashMap<>();
            boolean matched = true;
            for (int i = 0; i < expected.size(); i++) {
                String rule = expected.get(i);
                String value = actual.get(i);
                if (rule.startsWith(":")) {
                    params.put(rule.substring(1), value);
                } else if (!rule.equals(value)) {
                    matched = false;
                    break;
                }
            }
            if (matched) return new Match(route.handler(), params);
        }
        return null;
    }

    static String normalize(String path) {
        String value = path.split("\\?", 2)[0];
        if (value.length() > 1 && value.endsWith("/")) {
            value = value.substring(0, value.length() - 1);
        }
        return value.startsWith("/") ? value : "/" + value;
    }

    static List<String> segments(String path) {
        if (path.equals("/")) return List.of();
        return List.of(path.substring(1).split("/"));
    }

    public static void main(String[] args) {
        add("GET", "/users/:id", "getUser");
        add("GET", "/users/me", "getCurrentUser");
        add("DELETE", "/users/:id", "deleteUser");

        for (String[] request : List.of(
                new String[]{"GET", "/users/me"},
                new String[]{"GET", "/users/42?verbose=true"},
                new String[]{"DELETE", "/users/42"})) {
            System.out.printf("%s %s -> %s%n",
                    request[0], request[1], match(request[0], request[1]));
        }
    }
}

使用 JDK 17 或更高版本编译运行:

javac RouteDemo.java
java RouteDemo

预期可以看到 /users/me 命中静态处理器,而 /users/42 提取出 id=42。接入 wastnet 时,可以保留这里的测试思路,将 addmatchhandler 替换为项目实际提供的路由注册、请求上下文与响应接口。

这个示例有意保持简单,没有实现 URL 解码、通配符、矩阵参数和冲突检测。生产路由器必须明确这些行为,尤其不能在路径规范化时把 %2F 随意解码成 /,否则可能改变路径边界,甚至造成鉴权绕过。

同一套路由要跨协议验证

wastnet 的特色之一是自研 HTTP/2 协议栈。路由处理器不应该依赖请求来自 HTTP/1.1、TLS 上的 h2,还是明文 h2c;协议解析层应把它们转换成一致的请求语义,再交给路由层。

假设服务监听 8080 提供 h2c,并在 8443 提供启用 ALPN 的 TLS,可以这样实践协议回归测试。请按实际端口、证书和路径修改命令:

# HTTP/1.1
curl --http1.1 -i http://127.0.0.1:8080/users/42

# h2c prior knowledge:客户端直接发送 HTTP/2 连接前言
curl --http2-prior-knowledge -i http://127.0.0.1:8080/users/42

# TLS + ALPN 协商 h2;开发环境使用自签名证书时才加 -k
curl --http2 -k -i https://127.0.0.1:8443/users/42

三次请求应得到一致的状态码、业务响应体和关键响应头。排查问题时,可以增加 -v,观察 ALPN 是否协商出 h2,以及 h2c 客户端是否真的发送了 HTTP/2 请求。

协议一致性测试还应覆盖:

  • 查询参数和重复请求头的解析结果。
  • 大请求体、空请求体和分块到达的数据。
  • 不存在的路由以及方法不匹配的路由。
  • 处理器抛出异常后的状态码和连接行为。
  • HTTP/2 多路复用下并发请求是否发生上下文串扰。

性能不只取决于底层 NIO

来源摘要提到 wastnet 的基准吞吐可对标 Undertow,但业务项目不能直接把框架基准当成自身容量结论。路由层仍可能引入明显开销,例如每次请求编译正则表达式、遍历整张路由表、创建大量临时字符串,或者在 Reactor 线程中执行阻塞数据库查询。

可以重点检查以下边界:

  • 路由规则在启动阶段编译并建立索引,不在请求阶段重复构造。
  • 参数容器和请求上下文避免无意义复制,但不能跨 HTTP/2 stream 共享可变状态。
  • 阻塞数据库、文件和远程 RPC 调用离开 I/O 事件循环,进入受限工作线程池。
  • 压测同时记录延迟分位数、错误率、CPU、内存和 GC,而不是只看吞吐量。
  • HTTP/1.1 与 HTTP/2 分别压测,避免协议差异掩盖路由实现的问题。

上线前的路由检查表

采用 wastnet 路由组件时,先用少量接口验证真实 API 和运行模型,再逐步迁移业务。上线前至少确认:

  • 静态、参数和通配路由的优先级有确定规则。
  • 重复路由在启动时失败,而不是运行时随机覆盖。
  • 404405、参数错误和内部异常具有统一响应格式。
  • 路径参数经过 URL 解码、长度限制和业务校验。
  • 路由处理器不会阻塞 Reactor I/O 线程。
  • HTTP/1.1、h2c 和 TLS h2 使用同一组契约测试。
  • 性能结论来自接近真实业务的数据、连接数和处理器逻辑。

自研网络栈让 wastnet 能够精确控制从 NIO 事件循环到 HTTP/2 编解码的完整链路,也意味着使用者需要认真验证协议边界与线程模型。把路由当作可测试的请求分发层,而不是一组散落的回调,才能真正发挥这类零依赖服务器的价值。


相关推荐