OCaml webmachine 框架基准测试实现详解:Webmachine + Cohttp + Caqti 的多端点服务实战

  • 性能测试
  • 测试
  • 后端
  • 开发者工具

【免费下载链接】FrameworkBenchmarks

Source for the TechEmpower Framework Benchmarks project

项目地址: https://gitcode.com/gh_mirrors/fr/FrameworkBenchmarks
点击查看 免费下载

本文以 webmachine 基准测试说明文档 为主体,结合仓库内的完整源码,系统讲解如何在 OCaml 中基于 Webmachine(RESTful 资源路由库)、Cohttp(HTTP 服务器)、Caqti(数据库访问库)与 Lwt(异步运行时)实现 TechEmpower 基准测试的 JSON、PLAINTEXT、DB、QUERY 四类端点。读完本文,你将掌握 Webmachine 资源对象模型的写法、Caqti 连接池的接入方式、Cohttp 多进程服务器的启动流程,以及该项目从构建到本地验证的完整链路。

一、测试概况与仓库结构

frameworks/OCaml/webmachine/README.md 是本篇的核心文档,它明确了该框架在 TechEmpower 基准套件中的定位:使用 OCaml 生态中的 Webmachine 库实现一个 HTTP 服务,覆盖四类标准测试:

测试类型实现位置(相对仓库根目录)功能说明
JSONframeworks/OCaml/webmachine/src/src/bin/tfb.ml(hello 类)返回 {"message":"Hello, World!"}
PLAINTEXT同上(hello 类)返回纯文本 Hello, World!
DB同上(db 类)从 PostgreSQL 随机取一行 World 记录并返回 JSON
QUERY同上(queries 类)批量随机查询并返回 JSON 数组

整个框架目录结构如下:

frameworks/OCaml/webmachine/
├── README.md                 # 测试说明文档(本文主体)
├── benchmark_config.json     # 基准测试元数据(端口、分类、数据库等)
├── config.toml               # TFB 新版配置(与 JSON 等价的信息)
├── webmachine.dockerfile     # 容器镜像定义
└── src/
    ├── Makefile              # install / build / run 构建入口
    ├── dune-project          # dune 工程定义 + opam 依赖声明
    ├── tfb.opam / lib.opam   # opam 包描述
    └── src/
        ├── bin/tfb.ml        # 全部端点实现与服务器启动逻辑
        └── lib/
            ├── db.atd        # World 记录的 atd 类型定义
            ├── message.atd   # JSON 响应的 atd 类型定义
            ├── time.ml       # HTTP Date 头的缓存刷新模块
            └── dune          # 库构建规则(含 atdgen 代码生成)

值得留意的是:README 中提到的 src/tfb.ml 在仓库中的实际路径为 src/src/bin/tfb.ml,阅读源码时请以此为准。

二、技术栈与关键依赖库

README 的 "Important Libraries" 一节列出了六个核心依赖,它们共同构成了这套实现的技术底座:

  • Webmachine:RESTful 资源路由库,采用“资源对象 + 决策流程”模型,通过继承 resource 类并覆写方法来完成路由与内容协商。
  • Cohttp:OCaml 的 HTTP 库,此处使用 Cohttp_lwt_unix(与 Lwt 结合的 Unix 后端)承担底层 HTTP 协议与服务器职责。
  • Caqti:数据库访问抽象库,本实现使用 Caqti_lwt 与 caqti-driver-postgresql 驱动连接 PostgreSQL。
  • Lwt:轻量级协作式并发运行时,为服务器与数据库访问提供 Lwt/let%lwt 异步语法。
  • Ezjsonm:JSON 工具库(README 列为依赖,实际序列化由 atdgen 生成代码完成,见第六节)。
  • Ptime:POSIX 时间库(README 列为依赖,实际日期头由自实现的 Lib.Time 模块生成,见第七节)。

依赖的版本约束在 dune-project 中声明,并被生成到 tfb.opam:

dune >= 2.8.5
webmachine >= 0.7.0
lwt >= 5.4.1
conf-libev >= 4-12
caqti >= 1.6.0
caqti-lwt >= 1.6.0
caqti-driver-postgresql >= 1.6.0
cohttp-lwt-unix >= 4.0.0
lwt_ppx >= 2.0.2

其中 conf-libev 表明 Lwt 使用 libev 事件循环后端;lwt_ppx 为 let%lwt 异步语法提供预处理支持。

三、核心路由实现:Webmachine 资源对象模型

tfb.ml 的入口先定义一个 Wm 模块,将 Webmachine 库实例化到 Cohttp_lwt_unix__Io 上:

module Wm = struct
  module Rd = Webmachine.Rd

  module UnixClock = struct
    let now () = 0
  end

  include Webmachine.Make (Cohttp_lwt_unix__Io) (UnixClock)
end

接着定义路由表(tfb.ml#L185-L192):

let routes =
  [
    ("/plaintext", fun () -> new hello);
    ("/json", fun () -> new hello);
    ("/db", fun () -> new db);
    ("/queries", fun () -> new queries);
    ("/queries/:id", fun () -> new queries);
  ]

每一条路由都对应一个 Webmachine 资源类实例。/json 与 /plaintext 共用 hello 类,/db 对应 db 类,/queries 与带参路由 /queries/:id 共用 queries 类。请求到达后,通过 Wm.dispatch' 分发(tfb.ml#L196),未命中时返回 404:

(Wm.dispatch' routes ~body ~request >|= function
 | None -> (`Not_found, Header.init (), `String "Not found", [])
 | Some result -> result)

3.1 资源类的标准覆写方法

每个资源类继承 [Cohttp_lwt.Body.t] Wm.resource,并覆写三个方法(hello 类):

class hello =
  object (self)
    inherit [Cohttp_lwt.Body.t] Wm.resource
    method! allowed_methods rd = Wm.continue [ `GET ] rd

    method content_types_provided rd =
      Wm.continue
        [ ("text/plain", self#to_text); ("application/json", self#to_json) ]
        rd

    method content_types_accepted rd = Wm.continue [] rd

    method private to_text rd =
      let text = "Hello, World!" in
      Wm.continue (`String text) rd

    method private to_json rd =
      let json = Lib.Message_t.{ message = "Hello, World!" } in
      Wm.continue (`String (Lib.Message_j.string_of_message json)) rd
  end
  • allowed_methods:声明该资源仅接受 GET。
  • content_types_provided:声明可提供的内容类型及其对应的处理器;content_types_accepted 返回空表,表示不接受请求体内容。
  • Wm.continue:Webmachine 决策流程的标准返回构造,将控制权交还给路由框架。
  • to_text / to_json:分别用普通字符串与 atdgen 生成的序列化函数构造响应体。

3.2 JSON / PLAINTEXT 端点

  • JSON 端点 http://localhost:8080/json:构造 Lib.Message_t.{ message = "Hello, World!" } 记录,经 Lib.Message_j.string_of_message 序列化为 JSON 输出。类型定义见 message.atd。
  • PLAINTEXT 端点 http://localhost:8080/plaintext:直接返回固定字符串 Hello, World!。

3.3 DB 端点

db 类(tfb.ml#L67-L91)只提供一个 JSON 处理器,流程为:生成随机 id → 从连接池取出连接执行查询 → 序列化结果:

method private read_db rd =
  let read_db' (module C : Caqti_lwt.CONNECTION) =
    C.find select_random (Random.int 10000 + 1)
  in
  let%lwt id, randomNumber =
    Caqti_lwt.Pool.use read_db' pool |> or_error >|= function
    | Ok (x, y) -> (x, y)
    | Error _ -> failwith "whoops"
  in
  let json =
    Lib.Db_t.
      { id = float_of_int id; randomNumber = float_of_int randomNumber }
  in
  Wm.continue (`String (Lib.Db_j.string_of_query json)) rd

3.4 QUERY 端点

queries 类(tfb.ml#L93-L138)是四个端点中最复杂的:

  • id 方法从路径参数 :id 解析查询数量,并做边界钳制:小于 1 取 1,大于 500 取 500,解析失败(Failure / Not_found)时默认取 1:
method private id rd =
  try
    let _id = int_of_string (Wm.Rd.lookup_path_info_exn "id" rd) in
    match _id with
    | x when x < 1 -> 1
    | x when x > 500 -> 500
    | x -> x
  with
  | Failure _ -> 1
  | Not_found -> 1
  • read_query 先生成 n 个随机 id,为每个 id 发起一次独立的连接池查询,然后用 Lwt.all 并发等待全部完成,最后映射为 JSON 数组:
let query_ids = List.init (self#id rd) (fun _ -> Random.int 10000 + 1) in
let read_query' x (module C : Caqti_lwt.CONNECTION) = C.find select_random x in
let response =
  List.map
    (fun id -> Caqti_lwt.Pool.use (read_query' id) pool |> or_error >|= ...)
    query_ids
in
let%lwt resolved_response = Lwt.all response in
...
Wm.continue (`String (Lib.Db_j.string_of_queries json)) rd

这套并发批查模式对应 TechEmpower 的 query 测试要求:返回长度等于查询数量的 JSON 数组,每个元素包含 id 与 randomNumber 两个整数字段(验证逻辑见 query.py)。

四、数据库访问:Caqti 连接池与预处理查询

4.1 连接池的建立

连接池在进程启动时创建一次,使用标准基准数据库凭据(与 create-postgres.sql 中 benchmarkdbuser 的授权一致):

let pool =
  let connection_url =
    "postgresql://benchmarkdbuser:benchmarkdbpass@tfb-database:5432/hello_world?connect_timeout=15"
  in
  match Caqti_lwt.connect_pool ~max_size:4 (Uri.of_string connection_url) with
  | Ok pool -> pool
  | Error err ->
      Printf.eprintf "%s" (Caqti_error.show err);
      flush stderr;
      failwith (Caqti_error.show err)

要点:

  • max_size:4:连接池最大连接数为 4,所有查询都通过 Caqti_lwt.Pool.use f pool 借还连接;
  • 连接 URL 使用主机名 tfb-database,这是基准运行环境中 PostgreSQL 容器的默认服务名;
  • 建池失败会打印错误并直接 failwith 退出,便于快速定位配置问题。

4.2 预处理查询语句

查询使用 Caqti_request.Infix 定义类型安全的预处理语句(tfb.ml#L41-L44):

let select_random =
  let open Caqti_request.Infix in
  Caqti_type.(int ->! tup2 int int)
    "SELECT id, randomNumber FROM World WHERE id = $1"

int ->! tup2 int int 表示:接收一个 int 参数,期望返回 (int, int) 二元组(对应 World 表的 id 与 randomNumber 两列)。

4.3 统一错误处理

or_error 包装了查询结果的错误路径(tfb.ml#L33-L39),将 Caqti 错误打印到 stderr 后转为 Database_error:

let or_error m =
  match%lwt m with
  | Ok a -> Ok a |> Lwt.return
  | Error err ->
      Printf.eprintf "%s" (Caqti_error.show err);
      flush stderr;
      Error (Database_error (Caqti_error.show err)) |> Lwt.return

查询失败时上层会 failwith "whoops" 快速终止,保证基准运行期不会返回错误的响应体。

五、HTTP 服务器:Cohttp + 多进程 fork 模型

5.1 启动前的环境检测

main 函数(tfb.ml#L163-L233)在启动前做了几项准备:

  1. 将 Lwt 缓冲默认值设为 0x10000(64 KB)——该改动来自 Cohttp 的 issue #328 讨论,用于提升吞吐;
  2. 读取环境变量 APP_INSTANCES 确定 worker 进程数,未设置则回退到 getconf _NPROCESSORS_ONLN(CPU 核数);
  3. 读取 PORT 环境变量确定监听端口,默认 8080;
  4. 通过 dump_lwt 打印 Lwt 各系统调用的检测结果(libev、epoll 相关能力等),便于排查运行时后端问题。

5.2 请求回调与响应头

回调统一为每个响应附加 Server: webmachine 与缓存后的 Date 头(tfb.ml#L194-L203):

let callback (_ch, _conn) request body =
  let open Cohttp in
  (Wm.dispatch' routes ~body ~request >|= function
   | None -> (`Not_found, Header.init (), `String "Not found", [])
   | Some result -> result)
  >>= fun (status, headers, body, _) ->
  let headers = Header.add headers "Server" "webmachine" in
  let headers = Header.add headers "Date" Lib.Time.(!memo_date) in
  Server.respond ~headers ~body ~status ()

5.3 多进程监听模型

服务器采用经典的“父进程建 socket、子进程共享监听”的 fork 模型:

let socket =
  Lwt_unix.socket (Unix.domain_of_sockaddr sockaddr) Unix.SOCK_STREAM 0
in
Lwt_unix.setsockopt socket Unix.SO_REUSEADDR true;
Lwt_main.run @@ (Lwt_unix.bind socket sockaddr >|= fun () ->
    Lwt_unix.listen socket (Lwt_unix.somaxconn () [@ocaml.warning "-3"]));

for i = 1 to nproc do
  Lwt.async (fun () -> Lwt_io.flush_all ());
  if Lwt_unix.fork () = 0 then (
    (* child *)
    Lib.Time.refresh_date ();
    Lwt.async (fun () -> Lwt_io.eprintf "Listening on %s:%d (child %d)\n" ...);
    let config = Server.make ~callback () in
    Lwt.async (fun () -> Server.create ~mode:(`TCP (`Socket socket)) config);
    let forever, _ = Lwt.wait () in
    Lwt_main.run forever;
    exit 0)
done;
while true do
  Unix.pause ()
done

关键点:

  • 先创建 socket、bind、listen,再逐 fork 出 nproc 个子进程共享同一监听 socket;
  • 每个子进程用 Cohttp_lwt_unix.Server.create ~mode:(TCP (Socket socket)) 启动事件循环并永久挂起(Lwt.wait);
  • 父进程随后进入 Unix.pause 空闲循环,仅承担进程管理职责;
  • SO_REUSEADDR 保证端口可被快速重启复用。

六、JSON 序列化:atdgen 代码生成

虽然 README 将 Ezjsonm 列为依赖库,本项目实际采用 atd + atdgen 自动生成类型与序列化代码,这是 lib 库 的核心职责。

类型定义文件:

type message = {
  message : string;
}
type queries = query list

type query = {
    id : float;
    randomNumber : float;
}

构建时由 dune 中的规则调用 atdgen 生成四组文件(message_t/ml/mli、message_j/ml/mli、db_t/ml/mli、db_j/ml/mli):

(rule
 (targets message_j.ml message_j.mli)
 (deps    message.atd)
 (action  (run atdgen -j %{deps})))

(rule
 (targets db_j.ml db_j.mli)
 (deps    db.atd)
 (action  (run atdgen -j %{deps})))

生成的 Db_j.string_of_query、Db_j.string_of_queries、Message_j.string_of_message 即被 tfb.ml 直接调用。由于 db.atd 将字段定义为 float,源码在构造记录前会先用 float_of_int 做转换(见 DB 端点实现)。

七、Date 头的缓存优化:Lib.Time 模块

HTTP 响应的 Date 头需要 RFC 1123 格式(如 Wed, 17 Apr 2013 12:00:00 GMT)。为避免每个请求都调用 gmtime 格式化,time.ml 实现了缓存方案:

let date () =
  let d = get_date () in
  Format.sprintf "%s, %02d %s %4d %02d:%02d:%02d GMT" (dow d.tm_wday) d.tm_mday
    (month d.tm_mon) (1900 + d.tm_year) d.tm_hour d.tm_min d.tm_sec

let memo_date = ref @@ date ()

let refresh_date () =
  let f _ =
    memo_date := date ();
    ignore @@ Unix.alarm 1
  in
  (ignore @@ Sys.(signal sigalrm (Signal_handle f)));
  f ()

机制说明:

  • 启动时用 memo_date 记录当前格式化结果;
  • 子进程 fork 后调用 refresh_date:注册 SIGALRM 处理器,处理器每次触发就刷新 memo_date 并重新设置 Unix.alarm 1(即每秒刷新一次);
  • 请求回调只读取 !memo_date,把日期格式化的开销从每次请求摊薄为每秒一次,避免成为性能瓶颈。

八、构建与运行

8.1 本地构建

Makefile 提供四个目标:

BINARY = tfb
PROFILE ?= release

install:
	opam install --yes --deps-only .

gen-opam:
	dune build @install

build:
	opam exec -- dune build --profile $(PROFILE)

run:
	./_build/default/src/bin/$(BINARY).exe

本地构建步骤为:make install(按 opam 安装依赖)→ make build(dune 构建,默认 release profile)→ make run(执行生成的二进制)。

8.2 Docker 镜像

webmachine.dockerfile 基于预编译依赖镜像 rbjorklin/techempower-ocaml-image:5.3.0-e8949511,通过 OCAMLRUNPARAM 环境变量调优 OCaml 运行时:

FROM rbjorklin/techempower-ocaml-image:5.3.0-e8949511
ENV DIR project
ENV OCAMLRUNPARAM a=2,o=240

WORKDIR /${DIR}
COPY ./src /${DIR}
RUN make build

EXPOSE 8080
ENTRYPOINT ["_build/default/src/bin/tfb.exe"]
  • OCAMLRUNPARAM a=2:控制运行时参数中的分配策略(a 参数);
  • OCAMLRUNPARAM o=240:设置最大栈大小相关的运行时参数(o 参数),此类调优目的均为减少 GC 停顿、提升热路径吞吐;
  • 容器入口直接运行构建产物,监听 8080 端口。

8.3 测试端点

按 README 提供的测试 URL,服务启动后可验证四个端点:

http://localhost:8080/json        # 返回 {"message":"Hello, World!"}
http://localhost:8080/plaintext   # 返回 Hello, World!
http://localhost:8080/db          # 返回 {"id":N,"randomNumber":M}
http://localhost:8080/queries/    # 返回 [{"id":..,"randomNumber":..}, ...]

/queries/ 还可携带数量参数,例如 /queries/5,数量超出 1~500 范围时会被钳制到边界值(见 3.4 节)。

九、基准配置元数据与验证机制

9.1 benchmark_config.json

benchmark_config.json 声明了该框架在基准套件中的注册信息:

{
  "framework": "webmachine",
  "tests": [{
    "default": {
      "json_url": "/json",
      "plaintext_url": "/plaintext",
      "db_url": "/db",
      "query_url": "/queries/",
      "port": 8080,
      "approach": "Realistic",
      "classification": "Micro",
      "database": "postgres",
      "framework": "webmachine",
      "language": "OCaml",
      "flavor": "None",
      "orm": "Raw",
      "platform": "cohttp",
      "webserver": "None",
      "os": "Linux",
      "database_os": "Linux",
      "display_name": "webmachine",
      "notes": "",
      "versus": "None"
    }
  }]
}

关键字段含义:

  • port: 8080:服务监听端口;
  • approach: "Realistic" / classification: "Micro":框架的基准分类;
  • database: "postgres" + orm: "Raw":使用原始 SQL 访问 PostgreSQL;
  • platform: "cohttp":底层 HTTP 平台为 Cohttp;
  • webserver: "None":应用自身即为 HTTP 服务器,未前置独立 Web 服务器。

config.toml 是 TFB 新版配置格式,内容与上述 JSON 等价(urls.*、approach、database、orm、platform 等字段一一对应)。

9.2 对应测试类型的验证逻辑

仓库的 toolset/test_types 目录定义了每个测试的自动校验规则,可据此理解各端点的行为要求:

  • json(json.py):校验响应为包含 message 字段的 JSON 对象、HTTP 状态码与 Content-Type 头;
  • plaintext(plaintext.py):校验响应体包含 hello, world!(大小写不敏感),且多余字节数越少越好;
  • db(db.py):校验响应为包含 id、randomNumber 整数字段的 JSON 对象,并通过 verify_queries_count 检查实际 SQL 查询次数是否符合并发模型;
  • query(query.py):依次请求 2、0、foo、501、空串等用例,验证数组长度与字段类型,并校验 /queries/ 路由至少 9 个字符。

十、小结

从 README 的四行测试清单出发,结合 tfb.ml 的完整源码可以看到:这套 OCaml webmachine 基准实现,用 Webmachine 的资源对象模型把路由、内容协商与响应生成解耦成清晰的类结构,用 Caqti 连接池 + 预处理查询保证数据库访问的类型安全,用 Cohttp 的 TCP socket fork 模型摊满多核 CPU,再用 atdgen 自动生成 JSON 序列化代码、以 Lib.Time 缓存 Date 头规避格式化开销——每一处细节都直接服务于基准测试的高吞吐场景。若要在本地复现,按第八节的 make install && make build && make run 即可启动,并通过第九节的端点逐一验证。

  • 性能测试
  • 测试
  • 后端
  • 开发者工具

【免费下载链接】FrameworkBenchmarks

Source for the TechEmpower Framework Benchmarks project

项目地址: https://gitcode.com/gh_mirrors/fr/FrameworkBenchmarks
点击查看 免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值