微型 Web 框架 Kemal 的路由设计

前言


当我们要实现一个 Web 应用后端的时候,通常会选择一些知名的 Web 框架。一般来讲,Web 框架可以分为两大类:功能全面、抽象层次高的一站式框架和仅提供 Web 本质功能的微型框架。Kemal 属于后者,出自 Crystal 官方团队之手。

这种微型 Web 框架有很高的自定义能力,更多方案可以选择。而不是像一站式框架那样,虽然大而全,但必须按照它的设计走,使用它集成的东西。但是大而全的框架帮你规范了架构和模式,傻瓜式的填空便能做到最佳实践的效果。而微型框架通常没有规范限制,业务功能耦合在一起,导致代码的可维护性降低。

另外,需要澄清的是,本文并不是对 Kemal 的解读或剖析,但我暂时没有想到怎样为本文取名称而不冲突。实际上本文要介绍的是如何拆分使用 Kemal 框架的项目代码,而不是 Kemal 自身。

如果我后续有兴趣,也许会发文。想想最近我也确实读了一些 Kemal 代码,貌似可以水一篇……

Kemal


Kemal 是一个号称「快速闪电,超级简单」的框架,这不是我个人所用的形容词,这就是官方的宣传语。在我看来,它的确做到了:

  • 快速:微妙级的响应速度,简单的架构设计。Crystal 带来的轻量级用户线程 Fiber 处理请求,仅单线程就可以承受海量并发,多线程环境成倍上升。
  • 简单:全部文档仅仅一个页面(跟 Ruby 的 Sinatra 一样),概念极少。几行代码便可启动……

也许有人能列举出 Kemal 除快速和简单以外的更多优点,但这并不是本文的目的。那么 Kemal 所谓的超级简单会不会带来功能不全的缺点呢?

的确 Kemal 很简单,也符合“微型框架”这个定义,毕竟它的概念真的很少…… 硬要类比的话,和 Ruby 的 Sinatra 是一个路子的。但是 Kemal 并不残缺,这是官方所列举的:

  1. 支持所有 REST 动词
  2. WebSocket 支持
  3. 请求/响应上下文,方便的参数处理
  4. 中间件支持
  5. 内置 JSON 支持
  6. 内置静态文件服务
  7. 内置视图模板

从基本功能上来看,它是比较全的,它和一站式框架的差别主要在于架构设计的风格,或者说它并没有限定任何架构模式。对比比较典型的大而全框架 Amber,它没有例如控制器、管道、通道、Flash 这些东西,也不需要按照固定的组织方式编写代码。

它的“简单”在于没有按照固定的模式规范而过度抽象,自然很小巧而简单。如果愿意的话,基于 Kemal 设计出 Amber 是能做到的。总而言之,缺乏更多抽象概念和模式规范的 Kemal 简单,但是过分自由。下文会阐述原因。

膨胀的代码


在 Kemal 中,不存在路由器、控制器和视图的“具体抽象组件”,而这些是 Web 开发最普遍的 MVC 模式必备的三样东西。当然,这不表示没有相关概念,我强调了“具体的组件”,是指你无法继承一个 Kemal 基类实现一些方法就变成了「组件」。例如 Amber 中,需要这样定义一个控制器:

class ApiController < ApplicationController
  def index
    @client = Client.new
    render("index.slang")
  end
end

咱 Kemal 是没有 ApplicationController 给你继承的哈……

类似的,Kemal 也不需要让全部路由在一个地方定义和实现函数分离。对于 Kemal 而言,那些是多余的。毕竟你直接撸起袖子就可以开干了:

require "kemal"

get "/" do
  "Hello World!"
end

导入 kemal 模块以后,调用 get(或其它 REST 动词)函数传递一个代码块,这就是全部。如果你要添加新的路由,同样的方式即可。

你未必需要自行给 Kemal 项目实现路由/函数分离或控制器,但你真的有必要拆分代码。因为默认情况下所有路由函数全部混合在一起,代码持续膨胀会难以维护。

注意:从这里开始我会统一的将使用 Kemal 的 REST 动词函数定义的代码块称之为「路由函数」

对于 Amber 这种框架而言是没有相关问题的,因为它强制你拆分出了一个个 Controller,对应着不同类型的资源或功能。

拆分路由


为了统一管理 Kemal 的设置和路由函数,我们创建一个 web.cr 文件:

require "kemal"
require "./web/*"

module Blog::Web
  def self.start(port : Int)
    serve_static({"gzip" => false})
    public_folder "static"

    # 这里注册路由

    Kemal.run(args: nil, port: port)
  end
end

上面只是定义了一个基本配置,并启动了 Kemal。正常情况下路由函数会定义在”这里注册路由“的中文注释那里,一个个排列下来。因为我们要拆分路由资源,所以不会这么做。

上面的代码还导入了 web 目录下的模块,所以我们接着创建 web/router.cr 文件:

module Blog::Web::Router
  macro registry(module_name, *args)
    {{ module_name = module_name.camelcase }}
    {% if args.size > 0 %}
      {{@type}}::{{module_name.id}}.init({{*args}})
    {% else %}
      {{@type}}::{{module_name.id}}.init
    {% end %}
  end

  macro def(name, *args)
    module Blog::Web::Router::{{name.camelcase.id}}
      def self.init({{*args}})
        {{yield}}
      end
    end
  end
end

require "./router/*"

这个模块提供了两个宏,registry 用于在 web.cr 中注册路由资源,也就是中文注释那里。def 用来定义路由资源,在最后导入了 router 目录的模块,而这个目录下的就是“路由资源”。

然后就是创建路由资源了。例如全局页面相关的路由 web/router/page.cr 它会是这个样子:

module Blog::Web
  Router.def :page do
    get "/" do
      render "src/views/index.html.ecr"
    end

    error 404 do
      "Not Found"
    end
  end
end

我们定义了两个路由函数,其中一个是处理 404 错误,另一个就是首页。这两个函数调用原本应该放在 web.cr 中和 Kemal 其它的相关配置放在一起,但现在被我们拆分出来了。

最后,就是注册这个名为 :page 的路由资源了,我们修改 web.cr

require "kemal"
require "./web/*"

module Blog::Web
  def self.start(port : Int)
    serve_static({"gzip" => false})
    public_folder "static"

    # 注册路由资源
    Router.registry :page

    Kemal.run(args: nil, port: port)
  end
end

现在我们启动程序便可访问 :page 路由资源中定义的路由。类似的,其余类型的路由资源用同样的方式创建和注册,这样就将整体定义的路由函数拆分成一个个模块。没有“约定”,而是强制使用宏来规范和简单化。

高级使用


我们仅仅编写两个宏就拆分出了路由资源,但它们的用途不仅仅于此。常见的,某个路由函数需要参数,例如说从 CLI 或文件拿到的配置。

我们在启动 Kemal 的时候,统一传递这些参数:

Web.start port.to_i, safe_level

如上,我们给 Web.start 函数新增了一个 safe_level 参数,假定它表示的是“安全级别”,是从配置文件或命令行参数中读取的。

我们有一个名为 "error" 的路由资源,专门处理错误,它需要这个安全级别参数根据不同的级别响应不同的内容。

创建 web/router/error.cr

module Blog::Web
  module Router::Error
    extend self
    # 可以定义其它函数

    HIGHER = 1
    LOWER = 0
  end

  Router.def :error, safe_level : Int do
    error 500 do
      if safe_level == HIGIER
        # 发生了一个错误,请上报问题或刷新页面
      elsif safe_vevel == LOWER
        # 发生了一个错误,输出错误堆栈
      end
    end
  end
end

我们给 Router.def 宏调用传递第二个参数,也就是本路由资源需要的数据,它从注册方提供。这样整个资源的所有路由函数都能用这些从外部传递进来数据,上述代码根据不同的安全级别,响应不同的错误内容。

如果你想定义第二个、第三个参数,只需要在后面继续增加即可。毕竟 Router.def 宏的第二个参数是 *args,并不限定参数个数。

不仅如此,和定义路由的代码块同级的上方还定义了一个名为 "Router::Error" 的模块,它实际上就是宏展开后的真实路由资源模块名称。因为模块名是相同的,所以在它里边定义的函数或常量在路由函数中能直接调用。

我们在同名模块中编写需要使用的函数,在路由函数中直接调用。这样做的好处显而易见:负责请求处理的路由和需要用到的 Helper 函数是分离的。

最后,我们在注册路由资源的地方传递参数即可:

Router.registry :error, safe_level

这样不同的路由资源可以有不同的参数,而不是利用一个全局访问的巨大配置中心(包含所有配置项)。实际上你应该将配置中心中的数据在注册前拆分给不同的路由资源。

结束语


本文没有给 Kemal 项目去实现控制器、路由器之类的概念,是因为我觉得既然使用 Kemal 就不必要。我个人选择 Kemal 通常是因为 Web 并非应用的核心部分,而是附加功能,我需要一个不影响代码组织结构并且精小自由的框架。

如果您的项目以 Web 业务为主,我建议还是选择 Amber 之类的。如果你把 Kemal 项目改造成了 Amber 的样子,同样的我也建议换框架。

拆分路由设计是使用 Kemal 必要的前提,除非真的只存在几个路由函数,无关紧要。本文的核心其实就在两个宏上,使用 Crystal 这种语言,学会利用宏抽象代码是非常重要的:)