用 OCaml 写一个 URL Router:一个有趣的过度工程
这篇文章的价值:这是一个"过度工程"的实验——用 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"}] }
好的地方
- 类型安全的参数:在路由定义阶段就区分
int和string,/users/42匹配:id(int),/users/john匹配:name(string)。不需要运行时手动转换和校验。 - 编译器前端的完整体验:Lexer → Parser → AST → 语义处理,麻雀虽小五脏俱全。如果你想学编译器前端,写一个 URL router 比写一个计算器有趣多了。
- 错误信息友好:因为有完整的 lexer/parser,语法错误能精确定位到行号和列号,而不是一个笼统的 "invalid route"。
- OCaml 的 pattern matching 天然适合这个场景:匹配器的核心逻辑用 match 表达式写出来非常清晰,几乎就是规则的直接翻译。
- 跨语言复用:写一次 OCaml,通过 js_of_ocaml 编译成 JS,服务端和客户端用同一套路由逻辑。
不好的地方
- 过度工程的典范:用编译器技术栈来解析 URL,就像用火箭送外卖。
path-to-regexp用正则表达式做同样的事,代码量少一个数量级,且久经实战。 - 构建链路复杂:需要 OCaml 工具链(opam、dune)+ js_of_ocaml,光是让 CI 跑起来就很痛苦。团队里没人写 OCaml 的话,维护成本极高。
- 匹配算法太朴素:用
List.find_opt线性扫描所有路由。真正的路由器(如 Go 的 httprouter)用 radix tree,O(路径长度) 就能匹配。这里路由一多就会变慢。 - 不支持通配符和嵌套路由:没有
/api/*、/files/**这样的 glob 匹配,也没有路由嵌套(layout routes)。作为框架级路由器,功能太有限。 - 类型系统太简单:只有 int 和 string。实际项目还需要 UUID、日期、枚举等类型的参数。
- js_of_ocaml 的 JS 交互很痛苦:看
jsmain.ml里的代码——手动构造 JS 对象、手动类型转换,没有任何类型安全可言。这部分代码比纯 OCaml 部分丑陋得多。
值得分享的经验
- 编译器技术不只是用来写编译器的:Lexer/Parser 这套东西适用于任何有结构的输入——配置文件、DSL、查询语言、路由规则。一旦你掌握了,会发现很多地方都能用上。
- OCaml 的代数数据类型 + pattern matching 是处理树状结构的最佳工具:AST 的定义和匹配代码几乎就是规范的直接翻译,编译器会帮你检查是否处理了所有情况(exhaustiveness check)。
- "正确"的方案未必是"合适"的方案:从计算机科学的角度,用 formal grammar 定义路由语法是"正确"的。但从工程角度,一个正则表达式就够了。知道什么时候该用什么,比什么都会用更重要。
- js_of_ocaml 的定位:它适合把已有的 OCaml 库编译到浏览器使用(比如 OCaml 的各种形式化验证工具),但不适合从头写一个需要大量 JS 互操作的项目。如果你的代码一半在构造
Js.Unsafe.obj,那还不如直接写 TypeScript。 - 小项目是最好的学习方式:这个路由器虽然"没用",但写完之后我对 lexer generator、parser generator、AST 设计、跨语言编译都有了直觉性的理解。这些知识在后来读其他编译器代码时反复用到。
总结
ssrrouter 是一个典型的"为了学习而过度工程"的项目。它用了最重的方案来解决一个简单的问题,但在过程中覆盖了编译器前端的核心概念:词法分析、语法分析、AST 设计、类型匹配、跨语言编译。
如果你也在学编译器或者 OCaml,我推荐你也找一个类似的小问题来练手。不要从"写一个编程语言"开始——那太大了。从一个 URL router、一个 SQL 子集解析器、一个 Markdown 渲染器开始。问题足够小,你才能把每个环节都搞清楚。