用 OCaml 写一个 URL Router:一个有趣的过度工程

Le Carceri d'Invenzione by Giovanni Battista Piranesi
Giovanni Battista Piranesi,《想象的监狱》(Le Carceri d'Invenzione), c. 1761 — 层层叠叠的拱门和阶梯,像极了过度工程的路由系统
这篇文章的价值:这是一个"过度工程"的实验——用 OCaml 的 ocamllex + menhir(编译器工具链)来实现 URL 路由匹配,然后通过 js_of_ocaml 编译成 JavaScript 跑在 Node.js 里。实用价值有限,但如果你对编译器前端技术(词法分析、语法分析、AST)如何应用在日常问题上感兴趣,这是一个有趣的案例。

起因

事情的起因很简单:我在做一个类 Next.js 的 SSR 框架实验,需要一个路由匹配器。正常人会用正则表达式或者 path-to-regexp 这样的库,十行代码搞定。但我当时正好在学 OCaml,就想:能不能用 ocamllex + menhir 写一个"正经的"路由解析器?

结果就是 ssrrouter——一个用编译器前端技术栈来解析 URL 的路由库。它有自己的词法分析器、语法分析器、AST、类型系统(虽然只有 int 和 string),最后通过 js_of_ocaml 编译成 JavaScript,跑在 Node.js 的 SSR 服务器里。

它是怎么工作的

整个系统的处理流程:

路由定义字符串
    ↓ Lexer (ocamllex)
Token 流
    ↓ Parser (menhir)
AST (route_pattern)
    ↓ Matcher
匹配结果 + 参数绑定

第一步:定义 AST

首先定义路由的类型系统。一个路由模式由多个 segment 组成,每个 segment 可以是静态路径、斜杠、或者带类型的参数:

(* ast.ml *)
type param = IntParam of string | StringParam of string
type value = StringValue of string | IntValue of int

type segment = Static of string | Slash | Param of param

type route_pattern = {
  path : string;
  segments : segment list;
  entrypoint : string;
}

同时定义路由实例(实际的 URL),它的 segment 是具体的值而不是模式:

type segment_instance = IStatic of string | ISlash | IValue of value

type route_instance = {
  path : string;
  segments : segment_instance list;
  query_params : query_param list;
}

这里的关键设计是:路由定义和路由实例用不同的类型。定义里有 Param (IntParam "id"),实例里有 IValue (IntValue 42)。这让类型系统帮你检查匹配逻辑的正确性。

第二步:词法分析

用 ocamllex 把字符串拆成 token:

(* lexer.mll *)
rule token = parse
    | '/'       { SLASH }
    | ':'       { COLON }
    | '('       { LPAREN }
    | ')'       { RPAREN }
    | '?'       { QUESTION_MARK }
    | '='       { EQUALS }
    | '&'       { AMPERSAND }
    | "route"   { ROUTE }
    | "int"     { INT_TYPE }
    | "string"  { STRING_TYPE }
    | ['0'-'9']+ as n { NUMBER (int_of_string n) }
    | ['A'-'Z']['a'-'z''A'-'Z''0'-'9']*"Entrypoint" as e { ENTRYPOINT e }
    | ['a'-'z']['a'-'z''A'-'Z''0'-'9''-']* as id { ID id }
    | [' ' '\t' '\n']     { token lexbuf }
    | eof       { EOF }
    | _         { raise (LexError ("Unexpected char: " ^ Lexing.lexeme lexbuf)) }

注意 ENTRYPOINT 的匹配规则:必须大写开头且以 "Entrypoint" 结尾。这是一个 convention——路由定义长这样:

route /admin/:id(int) AdminDetailEntrypoint

第三步:语法分析

用 menhir(OCaml 的 parser generator)定义语法:

(* parser.mly *)
route:
  | ROUTE segments = route_path entrypoint = ENTRYPOINT EOF  {
    {
      path = build_path_from_segments segments;
      segments = segments;
      entrypoint = entrypoint;
    }
  }

route_segments:
  | p = ID rest = route_segments_tail     { Static p :: rest }
  | param = param_segment rest = route_segments_tail  { param :: rest }
  |                                    { [] }

param_segment:
  | COLON id = ID LPAREN INT_TYPE RPAREN  { Param (IntParam id) }
  | COLON id = ID LPAREN STRING_TYPE RPAREN { Param (StringParam id) }

Parser 同时处理路由定义和路由实例(带 query string),两套产生式共享同一个 lexer。

第四步:匹配

匹配器的核心逻辑出奇地简单——逐段比较,类型必须对上:

(* matcher.ml *)
let match_segment (pattern : segment) (instance : segment_instance) =
  match (pattern, instance) with
  | Static s1, IStatic s2 -> s1 = s2
  | Static s1, IValue (StringValue s2) -> s1 = s2
  | Slash, ISlash -> true
  | Param (IntParam _), IValue (IntValue _) -> true
  | Param (StringParam _), IValue (StringValue _) -> true
  | _ -> false

let match_route (pattern : route_pattern) instance =
  List.length pattern.segments = List.length instance.segments
  && List.for_all2 match_segment pattern.segments instance.segments

匹配成功后,Router 会提取参数绑定和 query params:

(* router.ml *)
let extract_bindings pattern_segments instance_segments =
  List.fold_left2
    (fun acc pattern_seg instance_seg ->
      match (pattern_seg, instance_seg) with
      | Param (StringParam name), IValue value -> { name; value } :: acc
      | Param (IntParam name), IValue value -> { name; value } :: acc
      | _, _ -> acc)
    [] pattern_segments instance_segments

第五步:编译到 JavaScript

最后一步是通过 js_of_ocaml 暴露 JS 接口:

(* jsmain.ml *)
Js.export "ssrrouter"
  (object%js
     method createRouter routes =
       let routes_list =
         routes |> Js.to_array |> Array.to_list |> List.map Js.to_string
       in
       let r = Router.create routes_list in
       current_router := Some r

     method lookup path =
       match !current_router with
       | None -> Js.null
       | Some router ->
           match Router.lookup router (Js.to_string path) with
           | None -> Js.null
           | Some result -> (* 构造 JS 对象返回 *)
  end)

在 Node.js 里用起来就是:

const ssrrouter = require("./ssrrouter");

ssrrouter.createRouter([
  "route /      AppEntrypoint",
  "route /admin AdminEntrypoint",
  "route /admin/:id(int) AppEntrypoint",
]);

const result = ssrrouter.lookup("/admin/42?sort=name");
// { entrypoint: "AppEntrypoint", bindings: [{name:"id", value:42}], queryParams: [{key:"sort", value:"name"}] }

好的地方

不好的地方

值得分享的经验

总结

ssrrouter 是一个典型的"为了学习而过度工程"的项目。它用了最重的方案来解决一个简单的问题,但在过程中覆盖了编译器前端的核心概念:词法分析、语法分析、AST 设计、类型匹配、跨语言编译。

如果你也在学编译器或者 OCaml,我推荐你也找一个类似的小问题来练手。不要从"写一个编程语言"开始——那太大了。从一个 URL router、一个 SQL 子集解析器、一个 Markdown 渲染器开始。问题足够小,你才能把每个环节都搞清楚。