软件架构:使用脚本来增强系统的灵活性

2022-01-04 15:09:40 浏览数 (1)

上一篇文章《做个简单的 reverse proxy》中我提到了最近做的一个小工具 wormhole。基本的功能已经跑通,后续的增强功能可以按照之前的设计慢慢迭代:

但一来我遇到有意思的问题实在是按捺不住想要攻克它的冲动,二来我正好这段时间在给国内的团队上一个架构系列的课程,我也想通过 wormhole 作为范例,更好地辅助我对架构的讲解。所以过去的一周时间,基本上只要有时间我就会扑在这个项目上,都不知 2022 悄然降临。年底同事们大多休假去了,上班时间出奇的安静,一天都没几个会议,所以我难得有大片的无打扰的闲暇。

开发 Rule Engine

在软件开发中,延迟绑定能给系统带来最大的灵活性:比如函数是对代码块的延迟绑定,泛型是对类型的延迟绑定等。而延迟绑定的最高境界就是把处理逻辑交给用户:比如通过配置让用户决定使用什么样的功能,或者通过 DSL/Script 让用户来撰写处理逻辑。

使用 DSL 还是通用脚本?

最初,我对 Rule Engine 的定位是 proxy server 可以根据配置中的规则,以及用户实时发送的规则来更灵活地处理 proxy 的逻辑。比如对请求拦截,完全提供一个 mock 响应返回,或者对响应拦截,返回一个改写过的响应等等。

这个需求如果仔细想下去,就会发现规则如果只是使用普通的配置去描述,很难穷尽,也很难满足各种各样奇葩的需求。比如,如果想要把响应的某个嵌套字段里的某个数组里添加一项,这用配置描述起来几乎不可能,只能引入 DSL。我们可以规定出一些简单的语法来允许用户做这样的操作,比如:

代码语言:javascript复制
res.movies[0].actors = res.movies[0].actors   "Tyr Chen"

然后撰写一个 parser 来解析并执行这个 DSL。在 Rust 下,我们可以很容易用 nom/pest 做对于上述语法的解析器,但是很快你会发现如果一开始没有很好地思考和设计这个 DSL,很容易陷入语法越来越复杂,功能越来越乱的境地。虽然自己设计 DSL 很有成就感,但如果要解决的问题用 DSL 很难简单表述,则最好考虑现成的通用的脚本语言。

Rust 生态下可用的脚本语言很多,比如大家比较熟悉的 lua(mlua / rlua),Python3(pyo3),以及 rhai / rune 这样用 Rust 构建的脚本语言。对我而言,这样一个简单的功能用 pyo3 嵌入 Python 代码过于笨重,有点高射炮打蚊子的赶脚。我在 mlua / rhai 之间权衡再三,最终决定使用 rhai。原因有几个:

  1. rhai 使用非常简单,它的语法也不会给使用者带来太大负担;
  2. rhai 引擎和 Rust 集成度很高,它的 Dynamic 类型和 serde_json 的 Value 类型类似,都可以很方便地转换成 Rust 数据结构。

确定下来用 rhai 后,上述表达式用 rhai 是这样子的:

代码语言:javascript复制
res.movies[0].actors.append("Tyr Chen")

重新定义配置

如果你看上篇文章中的配置定义,会发现这个定义不够灵活,只能通过配置的路由来决定 proxy 的动作,比如下面的配置,当 8081 端口收到了来自 /movie 路径的请求,将其转发到 api1.server.com:

代码语言:javascript复制
proxies:
  - addr: "0.0.0.0:8081"
    cache: true
    actions:
      - action_type: Forward
        route: /movie
        dst: "https://api1.server.com"

既然我们打算使用 rhai 脚本来做 API mock 或者 rewrite 的动作,那么如何做 proxy 的决策是不是也可以完全交给用户来处理呢(而不是把逻辑限制在路径的匹配上)?如果这样的话,就不仅仅是通过路径来决定到底匹配哪个 action,而是一个 rhai 表达式的结果来决定。

下面是一个修改后的配置的例子:

代码语言:javascript复制
proxies:
  api1:
    addr: "0.0.0.0:8081"
    rules:
      - name: mock new/api
        test: req.path == "/new/api2" && req.headers.contains("secret")
        action:
          store: true
          publish: true
          delay: 0
          withhold:
            mock_handler: |
              let map = #{
                  "status": 500,
                  "headers": #{
                      "secret": req.headers.secret,
                      "content-type": "text/plain"
                  },
                  "body": "The server is too lazy to do anything useful"
              };
              map

在这个配置中, test 是要测试的条件,action 是 proxy 执行的动作,可以是 forward 和 withhold(以后还可以添加)。这里,withhold 是拦截请求并用 mock_handler 来返回结果。

我们再看一个例子:

代码语言:javascript复制
- name: rewrite content api
  test: req.path == "/movies" && res.headers["content-type"] == "application/json"
  action:
    store: true
    publish: true
    delay: 0
    forward:
      rewrite_handler: |
        res.status = 201;
        res.headers.server = "Hacked Server";
        res.headers.hello = "World";
        res.body.actors.push("Tyr Chen");
        res.body.canonical_id = "Modified!!!";

这里当请求 “/movies” 并且响应是 “application/json” 时,把 response 按照 rewrite_handler 重写。

预编译

这样的配置虽然灵活,但有个问题,只有当请求到达时,rhai 才开始解析脚本执行。有没有办法在加载配置的时候就把脚本编译成 AST 呢?嗯,可以的,rhai 支持预编译。使用预编译,把脚本转化成 AST,不仅可以在很早期的时候就检测出脚本的错误,而且还能节省运行时的编译代价,不至于每一个请求都要编译一次。

如果你问我最喜欢 Rust 生态的哪一点,我会毫不犹豫地说 serde。serde 构建了一个强大,通用又灵活的序列化反序列化生态,让很多需求都能很优雅且非常高效地完成。对于上面的配置,可以用如下数据结构表述:

通过 serde,无论配置是什么格式,只要语法正确,配置都可以一句话就反序列化成对应的数据结构使用。

不仅如此,我们可以为自己的数据结构实现 serde,使得配置反序列化后,rhai 代码片段被直接解析成 AST,这样,这个结构在运行时就可以不加修改地直接使用。

也就是说,我们仅仅是做了一个 let rule: ProxyRule = serde_yaml::from_str(&config)? 的操作,就可以得到一个预编译好的规则。

执行引擎

尽管我们确定了用 rhai 来做脚本支持,但在代码中直接使用 rhai 的功能并不是一个好习惯,应该先设计一套针对我们自己的系统使用脚本的 trait。这样,其它代码使用的是 trait 提供的行为, 而不是不节制地使用 rhai 的任意功能。所以,我定义了 ScriptHost 这个 trait:

为了让处理 rhai 脚本的代码都集中在一处,我创建了一个新的 crate,把 rhai 的功能封装起来。上述 trait 目前只有一个实现,就是用 RhaiEngine 和 ExecutionScope 的实现:

看代码你会发现,我简单地把 rhai::Engine 和 rhai::Scope 封装起来,这有利于有利于减少依赖的泄露,这样,别的 crates 只需要和这个 crate 发生关系,而不需要引入 rhai。

用 ExecutionScope 封装 Scope 还有一个好处是,我们可以控制并且封装哪些变量和函数需要提供给用户脚本使用,比如请求和响应的 headers / body。

重构 proxy server

在 Rule Engine 的基本功能 ok 后,接下来就是如何把这个 engine 和 proxy server 集成起来。一开始 proxy server 的功能很简单,就几十行,主要的功能都在 proxy_handler 中。但当我们重新设计 rule/action 后,proxy_handler 中会添加很多额外的步骤,并且要处理或者不处理哪些步骤,还有各种复杂的判断逻辑。因此,我决定使用 pipeline 模式对 proxy_handler 重构。在我的《Rust 第一课》中我介绍过如何使用 Rust 构建一个通用的 pipeline,这里的代码基本就是课程中代码的简单修改:

有了基本的 pipeline 执行引擎,之后就是把 proxy_handler 里的逻辑整理出若干个彼此独立的 plug,当请求进来后可以根据需要走不同的路径:

这里面,一开始的 pipeline 是 [ExtractRequestInfo, EvalRule],然后 EvalRule 会根据匹配的 action,生成一个新的 pipeline。

这样处理之后,proxy server 的流程更加清晰灵活。未来有新的功能,只需要添加新的 plug,然后在 EvalRule 时,看看什么条件下需要把它添加到 pipeline 中。

动态控制 proxy server 行为

在实现了 Rule Engine 和 proxy server 的 pipeline 化之后,用户动态控制 proxy server 行为就变得比较简单了。我们可以复用配置文件中的 ProxyRule 结构,在 control plane 提供一个新的 API,让用户可以把一个序列化成 json 的规则发给 web server。web server 处理后,再把这个规则下发到 data plane 的 proxy server 中。proxy server 在 EvalRule plug 中,先检查动态的规则,如果没匹配,再检查静态配置中的规则。

这样的动态添加规则的能力虽然强大,但如果没有一个与之匹配的 UI,并提供各种开箱即用(或者简单修改就可以使用)的规则和 rhai 代码,那么功能会大打折扣,因为用户很难用得起来。

贤者时刻

下图是更新后的架构:

和一开始的架构相比,变化主要在配置文件,以及 proxy server 的 pipeline。

那么,这样一个远超出一开始 E2ET 需求的系统,有些过分灵活的系统,有什么实际的使用场景呢?

我脑海里有很多很多。其中,最重要的两个:

  1. 客户端开发时,我们可以刻意创建出一些错误场景。比如 proxy 拿到 API 的返回结果后,把里面电影的 CDN URL 转成 proxy server 地址,这样客户端播放器就走 proxy 来获取内容的片段。proxy server 可以设置让播放片段几秒钟之后就出 5xx / 4xx 问题,或者把每个响应的延迟增加若干毫秒,或者随机丢弃若干个响应,测试播放器的行为。
  2. 因为 proxy server 可以潜在记录一个客户端使用某个场景的完整网络访问(需要把所有 API 响应中的 url 都 rewrite 并 proxy),因此我们可以绘制出各种场景下,客户端行为的时序图,这样一来可以梳理整个流程,看看有没有什么问题或者可以优化的地方;二来作为新人培训的资料,可以让新人更快上手。

目前的产品离这两个场景的最终实现不远。革命尚未成功,同志仍需努力。

0 人点赞