Yesod/Rust:路径解析和生成问题介绍及分析

304 阅读14分钟

我花了大量的编码时间来研究网络应用中的路径解析和生成问题。首先是在Haskell中的Yesod,最近是在Rust中的一个路由类型的辅助项目。(题外话:我可能会在未来做一些关于这个项目的博客和/或视频,敬请关注。)我最近的工作让我想起了这里的一些痛点。就像经常发生的那样,我向我妻子抱怨这些痛点,并决定写一篇博文。

首先,有很多痛点我不打算讨论。例如,百分比编码的疯狂世界,以及你在URL的哪一部分的不同规则,是痛苦和错误的持续来源。一些小事,比如需要的前导斜杠,或者查询字符串参数是否应该区分 "没有提供价值"(例如:?foo )和 "提供空价值"(例如:?foo= )。但我只限于一个方面:往返的路径段和渲染的路径

什么是路径?

让我们来看看这篇博客文章的URL。https://www.fpcomplete.com/blog/pains-path-parsing/.我们可以把它分解成四个逻辑部分:

  • https方案
  • :// 是URL语法的一个必要部分
  • www.fpcomplete.com授权。你可能想知道:这不就是域名吗?嗯,是的。但权限也可能包含其他信息,如端口号、用户名、密码等。
  • /blog/pains-path-parsing/ 是路径,包括前面和后面的正斜杠

这个URL不包括它们,但URL也可能包括查询字符串,如?source=rss ,以及片段,如#what-s-a-path 。但我们只关心那个path 的成分。

思考路径的第一种方式是把它看作一个字符串。我所说的字符串是指一连串的字符。我所说的字符序列,实际上是指Unicode代码点。(看到我变得多么可笑的迂腐了吗?是的,这很重要。)但这根本不是真的。为了证明这一点,这里有一些Rust代码,在路径中使用希伯来字母。

fn main() {
    let uri = http::Uri::builder().path_and_query("/hello/מיכאל/").build();
    println!("{:?}", uri);
}

虽然这看起来很简单,但它的错误信息却非常失败。

Err(http::Error(InvalidUri(InvalidUriChar)))

实际上,根据RFC,路径是由一组有限的ASCII字符组成的,以八位数(原始字节)表示。而我们不得不以某种方式使用百分比编码来表示其他字符。

但在我们真正谈论编码和表示之前,我们必须问另一个正交的问题。

路径代表什么?

虽然从技术上讲,路径是一个保留数量的ASCII八位字节的序列,但我们的应用程序并不这样对待它们。相反,我们希望能够谈论全部的Unicode代码点。但它不仅仅是这样。我们希望能够谈论序列分组。我们把这些称为典型的。原始路径/hello/world ,可以被认为是分段["hello", "world"] 。我把这称为路径的解析。而且,反过来说,我们可以这些片段渲染成原始的路径。

对于这类解析/渲染对,能有完整的往返能力总是好的。换句话说,parse(render(x)) == xrender(parse(x)) == x 。一般来说,这些规则失败的原因有很多,比如:

  1. 多种有效的表示法。例如,用我们下面要提到的百分比编码,%2a%2A 意味着同样的事情。
  2. 通常不重要的空白细节在解析过程中会丢失。这适用于像JSON这样的格式,[true, false][ true, false ] 具有相同的含义。
  3. 解析可能会失败,因此,在parse(x) 上调用render 是无效的。

正因为如此,我们常常把我们的目标简化为:对于所有的xparse(render(x)) 是成功的,并产生与x 相同的输出。

在路径解析中,我们肯定会遇到上面的问题(1)(多种有效表示)。但是通过使用这个简化的目标,我们不再担心这个问题。URL中的路径也没有不重要的空白细节(每个八位数都有意义),所以(2)并不是一个需要关注的问题。即使它是,我们的parse(render(x)) ,最终也会 "解决 "它。

最后一点很有意思,对我们的完整解决方案至关重要。路径解析失败到底是什么意思?我可以想到基本路径解析中的两个想法:

  • 它包括一个超出允许范围的八位数
  • 它包括一个无效的百分比编码,比如说%@@

然而,在这篇文章的其余部分,让我们假设这些问题已经在前一个步骤中得到处理,而且我们知道这些错误情况不会发生。还有其他的解析失败的方法吗?在基本意义上:没有。在更复杂的解析中:绝对有。

基本渲染

基本的渲染步骤是相当直接的:

  • 对每个片段进行百分比编码
  • 用斜线分隔符对各段进行插值
  • 在整个字符串中预留斜线

为了允许绕行,我们需要确保对render 函数的每个输入都产生一个唯一的输出。不幸的是,通过这些基本的渲染步骤,我们立即遇到了一个错误。

render segs = "/" ++ interpolate '/' (map percentEncode segs)

render []
    = "/" ++ interpolate '/' (map percentEncode [])
    = "/" ++ interpolate '/' []
    = "/" ++ ""
    = "/"

render [""]
    = "/" ++ interpolate '/' (map percentEncode [""])
    = "/" ++ interpolate '/' [""]
    = "/" ++ ""
    = "/"

换句话说,[][""] 都编码为同一个原始路径,/ 。这似乎是一个微不足道的角落案例,不值得解决。事实上,更普遍的情况是,空的路径段似乎也是一个角落案例。一种可能性是说 "段的长度必须为非零"。那么就没有潜在的[""] 输入来担心了。

当这个问题出现在Yesod中时,我们决定以不同的方式来处理这个问题。实际上,我们确实有一些人对空的路径段有使用需求。我们将在规范化的渲染中再讨论这个问题。

百分比编码

我最初提到了百分比编码字符集的烦人之处。我还是不打算深入讨论它的细节。但我们确实需要在表面上讨论它。在上面的步骤中,我们来问两个相关的问题。

  • 为什么我们要插值之前进行百分比编码?
  • 我们要对正斜线进行百分比编码吗?

让我们尝试插值进行百分比编码。假设我们决定不对正斜线进行百分比编码。那么render(["foo/bar"]) 就会变成/foo/bar ,这和render(["foo", "bar"]) 是一样的。这不是我们想要的。如果我们决定插值进行百分比编码,并对正斜杠进行百分比编码,那么这两个输入的输出结果都是/foo%2Fbar 。这两种情况都不太妙。

好吧,回到插值前的百分比编码,让我们假设我们不对正斜杠进行百分比编码。那么,["foo/bar"]["foo", "bar"] 都会变成/foo/bar ,也是不好的。因此,通过排除法,我们只能在插值前进行百分比编码,并在分段中转义正斜杠。有了这个配置,我们就剩下render(["foo/bar"]) == "/foo%2Fbar"render(["foo", "bar"]) == "/foo/bar" 。这不仅是独特的输出(我们在这里的目标),而且在直觉上也是正确的,至少对我来说是这样。

Unicode编码点处理

我们在这里忽略了一个细节,那就是Unicode,以及编码点和八位数之间的区别。现在是时候纠正了。百分比编码是一个在字节上工作的过程,而不是字符。我可以将/ 编码为%2F ,但这只是因为我假设了该字符的 ASCII 表示。相比之下,让我们回到我最喜欢的非拉丁字母的例子,希伯来语。你如何用百分比编码表示希伯来语字母Alefא ?答案是,你不能,至少不能直接。相反,我们需要将Unicode编码点(U+05D0)表示为字节。而最普遍接受的方法是使用UTF-8。所以我们的过程是这样的。

let segment: &[char] = "א";
let segment_bytes: &[u8] = encode_utf8(segment); // b"\xD7\x90"
let encoded: &[u8] = percent_encode(segment_bytes); // b"%D7%90"

好了,太棒了,我们现在有了一种方法,可以把一串非空的Unicode字符串,生成一个唯一的路径表示。下一步是什么?

基本解析

我们如何往回走?很简单:我们把上面的每个步骤倒过来。让我们再看看渲染步骤。

  • 百分比对每个片段进行编码,包括:。
    • UTF-8将编码点编码为字节
    • Percent编码所有相关的八位数,包括正斜线
  • 把所有的段插在一起,用正斜杠隔开
    • 从技术上讲,这里的 "正斜杠 "是正斜杠八位数 \x2F 。但是因为大家基本上都假定是ASCII/UTF-8编码,所以我们通常可以在术语上稍微放宽一些。
  • 预加一个正斜杠(八位数)。

基本解析是完全相同的反向步骤:

  • 剥去正斜线。
    • 可以说,如果缺少一个正斜杠,你可以认为这是一个解析错误。大多数解析器只是简单地忽略了它。
  • 在每次出现正斜杠的时候都要分割原始路径。我们接下来会讨论这个问题的一些微妙之处。
  • 百分比解码每个片段,由以下部分组成。
    • 寻找任何% 符号,并抓住下两个十六进制数字。理论上,你可以把一个不正确或缺失的数字当作一个解析错误。在实践中,许多人最终会使用某种回退。
    • 取出解码后的百分比八位数,对其进行UTF-8解码。同样,在理论上,你可以把无效的UTF-8数据作为一个解析错误,但许多人只是使用Unicode替换字符

如果实施得当,这应该会导致我们上面提到的目标:对一个特定的输入进行编码和解码,将总是返回原始值(忽略空段的情况,我们仍然没有解决这个问题)。一个真正棘手的事情是确保我们的分割插值操作能正确地相互映照。实际上有许多不同的方法来分割列表和字符串。幸运的是,对于我的Rust插值, str 上的标准split 方法恰好实现了我们想要的行为。你可以查看该方法的文档以了解细节(甚至对非Rustaceans也有帮助!)。请特别注意关于连续分隔符的注释,并想想["foo", "", "", "bar"] 最终将如何被插值,然后被解析。

好了,我们都完成了,对吗?错了!

正常化

我打赌你以为我忘了空段的问题。(实际上,考虑到我已经叫了它们多少次,我打赌你不会这么想)。之前,我们只看到了空段的一个问题:[""] 。我想首先确定,空段是一个比这大得多的问题。

我在上面给出了一个GitHub仓库的链接:https://github.com/snoyberg/routetype-rs 。让我们稍微改变一下这个URL,在snoybergroutetype-rs 之间添加一个额外的正斜杠:https://github.com/snoyberg//routetype-rs 。令人惊讶的是,两个URL都得到了相同的页面。这不是很奇怪吗?

不,并非如此。额外的正斜杠经常被网络服务器忽略。"我知道你的意思,你不是指一个空的路径段。" 这不仅仅是网络服务器的一个 "特点"。同样的概念也适用于我的Linux命令行上。

$ cat /etc/debian_version
bullseye/sid
$ cat /etc///debian_version
bullseye/sid

对于GitHub在上面展示的行为,我有两个问题:

  • 如果我在写一些网络应用,而我真的很想在路径中嵌入一个有意义的空段怎么办?
  • 如果有两个不同的URL解析到相同的内容,是不是感觉不对,甚至会损害SEO?

在Yesod中,我们用一个名为cleanPath 的类方法解决了第二个问题,该方法分析了传入路径的段,并查看是否有更规范的表示。在上面的例子中,https://github.com/snoyberg//routetype-rs 会产生片段["snoyberg", "", "routetype-rs"] ,而cleanPath 会决定一个更经典的表述是["snoyberg", "routetype-rs"] 。然后,Yesod会采用规范的表示法,并生成一个重定向。换句话说,如果GitHub是用Yesod编写的,那么我对https://github.com/snoyberg//routetype-rs 的请求就会被重定向到https://github.com/snoyberg/routetype-rs

然而,早在2012年,这导致了一个问题。有人实际上有空的路径段,而Yesod则自动重定向到了生成的URL。我们当时想出了一个解决方案,我现在仍然很喜欢:破折号前缀。详细情况请看链接的问题,但它的工作方式是。

  • 编码时,如果一个段完全由破折号组成,则在其上再加一个破折号。
    • 根据我们对 "完全由破折号组成 "的定义,空字符串也算在内。所以dashPrefix "" == "-" ,和dashPrefix "---" == "----"
  • 解码时:
    • 执行上面的分割操作。
    • 接下来,执行清洁路径检查,如果有任何空的路径段,就生成一个重定向。
    • 一旦我们知道没有空的路径段,那么撤销破折号的前缀。如果一个路径段只由破折号组成,则删除其中一个破折号。

如果你仔细研究一下,你就会发现,通过这样的补充,每一个可能的段的序列--甚至是空段--在渲染后都会产生一个独特的原始路径。每一个传入的原始路径都可以被解析为一个必要的重定向(如果有空段)或一个段的序列。最后,在解析和渲染时,每个段的序列都会成功地往返于原始序列。

我把这称为规范化的解析和渲染,因为它是将每个传入的路径规范化为一个单一的、规范的表述,至少就空的路径段而言。我想如果有人想真正地迂腐,他们也可以尝试解决百分比编码行为或无效的UTF-8序列的变化。但是,我认为前者是一个无意义的区别,而后者则是垃圾中的垃圾。

尾部斜线

还有最后一点要提出来。究竟是什么原因导致解析时出现空路径段呢?一个例子是连续的斜线,就像我们上面的例子snoyberg//routetype-rs 。但还有一个更有趣、更普遍的情况:尾部斜杠。许多网络服务器使用尾部斜杠,可能是源于有index.html 文件和根据包含的目录名称访问页面的常见模式。事实上,这篇博文托管在一个静态生成的网站上,该网站使用这种技术,这就是为什么URL有一个尾部斜杠。而如果你在这里对我们的路径进行基本解析,你会得到。

basic_parse("/blog/pains-path-parsing/") == ["blog", "pains-path-parsing", ""]

是否在URL中包含尾部斜杠,在互联网上一直是一个古老的争论。就我个人而言,因为我认为 "分段解析 "的概念是路径解析的核心,我倾向于排除尾部斜杠。而事实上,Yesod的默认值(至少现在,routetype-rs'的默认值)是将这样的URL视为非经典的,并重定向离开它。当我意识到许多框架对 "带有文件名扩展名的最后一段 "有特殊处理时,我对此的感觉更加强烈。例如,/blog/bananas/ 有尾部斜线就很好,但/images/bananas.png 应该有尾部斜线。

然而,由于许多人喜欢有尾部斜杠,Yesod在这一点上是可以配置的,这就是为什么cleanPath 是一个可以被重写的类型方法。我想每个人都有自己的想法。

总结

我希望这篇博文能让大家对网络的狂野世界有更多的了解,以及像路径这样看似无害的东西实际上隐藏着一些深度。如果你有兴趣了解更多关于routetype-rs 项目的信息,请告诉我,我会尝试优先考虑一些关于它的后续工作。