adam-p

限制字符串长度的最佳(但并非完美)方式

2025年4月28日

目录

获取字符串的长度似乎很简单,并且是我们每天在代码中做的事情。限制字符串的长度在前端和后端代码中也非常常见。但是这两个操作——尤其是长度限制——隐藏了大量的复杂性、bug风险,甚至漏洞危险。在这篇文章中,我们将深入研究字符串长度限制,以帮助我们充分理解它的含义以及如何最好地做到这一点……并发现最好的仍然不是很好。

如果只看 TL;DR 会错过“充分理解”的部分,但并非每个人都有时间阅读所有内容,所以这里有一些关键要点:

  1. 注意到测量字符串长度有不同的方式。
  2. 真正理解你的编程语言如何在内存中存储字符串,如何将它们暴露给你,以及如何确定字符串的长度。
  3. 在限制字符串长度时,有意识地决定如何计算字符。
  4. 仔细查看你的语言(框架等)提供的“最大长度”功能是如何实际工作的。它们很有可能与你选择的限制方法不匹配。
  5. 确保你在架构的所有层中使用相同的计数方法。
  6. 最好通过计算规范化的 Unicode 代码点来限制。(就像 Google 推荐的.)

说完这些,让我们从查看一些我们熟悉的字符串长度函数开始我们的调查:

“a”| “字”| “🔤”| “👨‍👩‍👧‍👦”| “र्स्प”| “x̴͙̹̬̑̓͝͝” ---|---|---|---|---|--- Go| len(string)| 1| 3| 4| 25| 15| 17 JavaScript| String.length| 1| 1| 2| 11| 5| 9 Python 3| len(str)| 1| 1| 1| 7| 5| 9 Swift| String.count| 1| 1| 1| 1| 1| 1

这四种字符串长度的测量方法是大多数编程语言中常见方法的示例:UTF-8 字节、UTF-16 代码单元、Unicode 代码点和字素簇。

## 字符编码和术语

在其他地方有一些很好的解释,但让我们尝试快速掌握我们需要进一步研究的概念。(如果你已经熟悉某些内容,请随意跳过。)

首先,一个“字符”的定义:这是人类对大多数书面语言的最小构建块的理想概念1:一个字母、一个表情符号、一个表意文字、一个标点符号、一个符号、一个字素。稍后我们还将把字符看作是“如果用户输入了这个,他们希望‘字符计数’增加 1”。上表中所有的例子可能在你看来都是“一个字符”。

(我们将避免轻易使用这个术语,而是为了技术上的正确性,以避免混淆。所以我永远不会说“Unicode 字符”。)

### Unicode

Unicode是人类尝试列出所有可能的字符的尝试,以及更多的东西:控制字符、非打印字符、可以组合形成字符的片段等等。

Unicode 空间中的每个条目都称为“代码点”,并用 32 位无符号整数表示,尽管实际可用空间只有 2²¹(110 万个可能的值),并且只有大约 15 万个值已被分配。你可能还会看到术语“Unicode 标量单位”——这些基本上与代码点相同,但不包括保留的“代理对”范围。

示例(字符:代码,十进制)

严格来说,“Unicode 代码点”是一个抽象概念,每个字符都分配了一个数值。有 3 个常见的具体方案用于编码这些代码点:UTF-8、UTF-16 和 UTF-32。“UTF-32”是一个直接表示:32 位用于编码 32 位,具有 endian 变体。为了清楚起见,我更喜欢只说“Unicode 代码点”。我们将在下面详细讨论 UTF-8 和 UTF-16。

请注意,在 Go 中,Unicode 代码点通常称为“rune”。(Go 似乎已经 为了简洁而引入了这个术语。我当然很欣赏这一点,但我将坚持使用通用术语。)

### 字素簇 (Grapheme cluster)

一些 Unicode 代码点可以组合并呈现为单个视觉字符;我们称之为字素簇(或扩展字素簇)。

我们将通过一些示例,以帮助阐明复杂性。

家庭表情符号是“零宽度连接符 (ZWJ) 序列”的一个例子。

并非所有可能的表情符号(或一般代码点)的组合都可以以这种方式组合以创建一个字素簇字符。Unicode 联盟 发布 所有已定义的表情符号,包括多代码点复合表情符号。

表情符号的呈现方式取决于你正在查看它的平台。例如,这是 Windows 上的 Brave 浏览器中的家庭表情符号的样子 - - 相比之下,Android 上 -

并且渲染可能会随着时间的推移而改变。2014 年,使用 Windows 10,Microsoft 引入了 “ninjacat” ZWJ 序列表情符号,结合了“cat”和“ninja”表情符号。它不受任何其他平台的支持。2021 年,Microsoft 删除了对它的支持,现在它呈现为两个单独的表情符号。

家庭表情符号本身经历了一些重大变化。(也许它甚至在某个时候从 iOS 中删除了,但现在看起来渲染得很好。)

还有“组合标记”,它们是不需要代码点之间有 ZWJ 的重音或其他片段。

这是字素簇的一个例子,它可以用单个代码点表示:U+00E9 是“Latin Small Letter E With Acute”,在视觉上与上面的分解簇相同。(参见下面的“Unicode 规范化”。)

由字素簇形成的字符(无法被规范化掉)在欧洲和东亚文字中非常不常见,但在南亚文字中相当常见,例如印地语,其中约 25% 的字符涉及组合标记。

然后是 Zalgo 文本,它以 c̴͚͉͔̓̑͂͜r̷̙̎̎̿͊a̵̜͍̱̋̕z̷̭̰͉͊̎́͒y̵̺̿̔ 方式滥用组合标记:

据我所知,可以构成单个字素簇“字符”的代码点的数量没有限制。当我们在下面考虑如何限制字符串长度时,我们肯定会记住这一点。

(在讨论从字符串中提取字素簇时,有时会看到“分段”这个词。它通常指的是将字符串分成定义好的部分;例如,JavaScript 的 Intl.Segmenter API 可以将字符串分成字素、单词或句子。)

### Unicode 规范化 (Unicode normalization)

Unicode 规范化 有两个功能轴:组合/分解和兼容性简化,从而产生四种具有标准名称的模式。“NF”是“规范化形式”;“C”是“规范组合”,“D”是“规范分解”;“K”是“兼容性简化”。这给了我们:

无简化| 简化 ---|--- 组合| NFC| NFKC 分解| NFD| NFKD

组合规范化 将“é”(U+0065 + U+0301)的双代码点字素簇形式组合成单代码点形式“é”(U+00E9);对于存在等效单代码点的其他字素簇也是如此(并且在没有单代码点时什么也不做;例如,主要不更改 Zalgo 文本)。分解规范化 将执行相反的操作:单代码点字符将被分解为多代码点字素簇(这包括将韩语拼音分解为字母语音组件)。

兼容性简化 将一些花哨的字符转换为更普通的字符。例如,“ℍ”(U+210D)和“ℌ”(U+210C)变为纯拉丁字母“H”;上标“²”变为纯数字“2”;“ffi”(连字)变为“ffi”。非简化形式不会替换此类字符。(请注意,普通的变音符号不会被删除——“é”保留其重音。)与规范化不同,简化是不可逆的。

NFC 适用于保持字符串尽可能紧凑,同时确保更高的一致性。

NFKC 适用于如果你希望字符串可以使用等效字符进行搜索或以其他方式进行比较(例如,在搜索“HELLO”时匹配“ℍ𝔼𝕃𝕃𝕆”)。

分解形式(通过其他处理)可用于剥离所有重音;例如,如果你想要纯 ASCII 用于搜索或用于文件名。

请注意,规范化的输出可能会在 Unicode 版本之间发生变化。有关此类事情如何出错的讨论,请参见下面的 Unicode 版本部分。

### UTF-8

UTF-8 将代码点编码为 1、2、3 或 4 个单字节代码单元的序列。它具有为大多数字符串数据提供紧凑编码的非常好的特性;特别是,所有 ASCII 可打印字符都适合一个字节,具有相同的 ASCII 数值(例如,如果你打开 UTF-8 编码的源文件的 ASCII 视图,你可能会很好地读取它)。它是迄今为止最常见的编码,可以找到序列化到磁盘或线路上的编码(近年来)。

请注意,在设计中存在开销2,因此你无法获得完整的 8*bytes 位数来表示代码点。以下是它的细分方式

### UTF-16

UTF-16 通常不在线路或磁盘上使用,但许多编程语言和操作系统在内存中使用它。原因是某些平台和语言最初支持 UCS-2,它是旧的 2 字节 Unicode 标准。当 Unicode 增加到 4 字节时,创建了 UTF-16。它在一个代码点序列中使用 1 或 2 个双字节代码单元。单个代码单元的序列与 UCS-2 相同并且向后兼容,使得 UCS-2 平台的转换相当容易。

(构成代码点的两个 UTF-16 代码单元称为 代理对。在 UCS-2 规范中有一个“代理对”保留区域,用于指示何时在代码点序列中使用第二个 UTF-16 代码单元。)

UTF-16 具有整个“基本多文种平面 (Basic Multilingual Plane, BMP)”适合单个 UTF-16 代码单元的良好特性。这是“世界主要语言中使用的大部分常用字符”(但不包括表情符号,值得注意的是)。缺点是它需要两倍的空间来表示 ASCII 字符。

(请注意,需要 三个 UTF-8 字节才能容纳整个 BMP。)

### 其他编码

我们不会深入研究代码页、WTF-8、CESU 等。它们与手头的任务无关(并且我了解得不够,无法说出任何有用的东西)。

## 字符串长度

现在我们更好地理解了字符编码,让我们重新审视上面的表格。

编码计数| “a”| “字”| “🔤”| “👨‍👩‍👧‍👦”| “र्स्प”| “x̴͙̹̬̑̓͝͝” ---|---|---|---|---|---|--- UTF-8 代码单元| 1| 3| 4| 25| 15| 17 UTF-16 代码单元| 1| 1| 2| 11| 5| 9 Unicode 代码点| 1| 1| 1| 7| 5| 9 字素簇 (Grapheme clusters)| 1| 1| 1| 1| 1| 1

因此,不同的编程语言(以及语言中的函数)为我们提供了不同的计数方法。一些例子3

请注意,许多(可能所有)语言都提供了在编码之间进行转换并在这些其他编码中计算“长度”的方法;以上只是默认值。内存中使用的编码与作为访问这些字符串的主要编程接口呈现的编码之间也可能存在差异。

要了解我们选择的编程语言如何处理字符串长度,值得退后一步思考字符串 是什么。“字符串”的定义,我们许多人会给出的定义类似于“一堆字符”。但是我们现在已经看到“字符”只有抽象的含义,所以当我们使用 string 类型时,这不足以帮助我们。我们需要知道并记住两件事:

  1. 字符串的底层内存表示,以及
  2. 呈现给我们的对该表示的视图。

一些例子:

Gostring 类型实际上是一个字节数组。目的是这些字节保存 UTF-8 代码单元,但不保证 UTF-8 序列的有效性。len(string) 给出字符串的字节长度。如果要迭代字节/代码单元,必须首先将字符串强制转换为 []byte,因为如果你只是迭代字符串,则每一步都会给你一个 Unicode 代码点/rune。Go 提供 unicode/utf8.RuneCountInString 来获取代码点计数。它还具有 unicode/utf16 包,用于在 rune(代码点)和 UTF-16 代码单元之间进行转换。它没有内置支持 字素簇分割。

JavaScript 的 string 类型是一组 UTF-16 代码单元,string.length 给出这些代码单元的计数。[...string] 给出一个 Unicode 代码点数组。TextEncoder 转换为 UTF-8。Intl.Segmenter 提供对字素簇的访问。

Swift 的底层表示过去是 UTF-16,但自 2019 年以来一直是 UTF-8。它的 Character 类型保存单个字素簇,String.count 返回字素簇计数。为了访问编码,它提供 String.UTF8View, String.UTF16View, 和 String.UnicodeScalarView.4

更深入地了解字符串在底层如何工作将有助于防止发现单个表情符号的长度为 7 的混淆,以及可能由此产生的 bug。

### 限制和一致性

我们终于到了这篇文章的真正重点!

因为有 4 种不同的字符编码方式,所以有 4 种不同的方式来计算字符串长度。因为有 4 种不同的方式来计算字符串长度,所以有(至少)4 种不同的方式来限制字符串的长度

这使得你的架构各层之间非常容易不一致,从而导致 bug 和糟糕的用户体验。它们在测试中很容易被遗漏,因为可能需要某些字符和字符组合才能显示它们。

以下是我在决定需要撰写这篇文章时所研究的一些长度限制器:

抱怨:计数长度的方法通常在文档中并不立即显而易见,这真的很烦人。我不应该不得不深入研究 React Native 源代码才能得到答案。(这不仅仅是 RN 的问题。)

前端客户端和后端 API 服务器之间、API 和数据库之间、不同的客户端实现之间、访问相同数据库的不同服务器之间等等,可能会出现不一致。让我们看一下不一致的长度限制可能导致的一些问题。

如果前端允许比后端_更长_的输入(也许前端允许 100 个 Unicode 代码点,而后端允许 100 个 UTF-8 或 UTF-16 代码单元),则前端可能会指示用户的输入有效,但会被后端拒绝。

如果前端的输入限制比后端_更短_,则用户将受到不必要的限制。对于像用户名这样的东西,如果在具有一致计数的前端创建了帐户