前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >软件架构:使用脚本来增强系统的灵活性

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

作者头像
tyrchen
发布2022-01-04 15:09:40
7790
发布2022-01-04 15:09:40
举报
文章被收录于专栏:程序人生程序人生

上一篇文章《做个简单的 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),因此我们可以绘制出各种场景下,客户端行为的时序图,这样一来可以梳理整个流程,看看有没有什么问题或者可以优化的地方;二来作为新人培训的资料,可以让新人更快上手。

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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-01-03,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 程序人生 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 使用 DSL 还是通用脚本?
  • 重新定义配置
  • 预编译
  • 执行引擎
  • 重构 proxy server
  • 动态控制 proxy server 行为
  • 贤者时刻
相关产品与服务
文件存储
文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档