jsernews 1.0.1

<~>

ts 189 days ago.

原文:https://blog.safia.rocks/post/167198565657/node-module-deep-dive-querystring

有一段时间了,我想要在 Node 生态系统中执行标准库和常用软件包的代码演练。我想现在是时候把这个意愿改变为行动,并且实际写出一篇文章。所以在这里,我的第一个带注释的代码演练。

我想先看一下Node标准库中最基本的模块之一:querystringquerystring 是一个允许用户提取 URL 的查询部分的值和从键值关联的对象构建查询的模块。这是一个快速的代码片段,显示了由 querystring 暴露的四种不同的API函数,escapeparsestringifyunescape

> const querystring = require("querystring");
> querystring.escape("key=It's the final countdown");
'key%3DIt\'s%20the%20final%20countdown'
> querystring.parse("foo=bar&abc=xyz&abc=123");
{ foo: 'bar', abc: [ 'xyz', '123' ] }
> querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: 'i' });
'foo=bar&baz=qux&baz=quux&corge=i'
> querystring.unescape("key%3DIt\'s%20the%20final%20countdown");
'key=It\'s the final countdown'

好的!让我们深入了解有趣的部分。我将查看 querystring 模块的代码作为我写这篇文章的标准。你可以在这里找到这个版本的副本。

引起我注意的第一件事是47-64行的这段代码。

const unhexTable = [
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 0 - 15
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 16 - 31
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 32 - 47
  +0, +1, +2, +3, +4, +5, +6, +7, +8, +9, -1, -1, -1, -1, -1, -1, // 48 - 63
  -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 64 - 79
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 80 - 95
  -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 96 - 111
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 112 - 127
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 128 ...
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
  -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1  // ... 255
];

这是什么胡言乱语?我在整个代码库中搜索了 unhexTable 这个术语,以找出它在哪里使用。除了定义语句之外,搜索还返回了另外两个结果。它们出现在代码库的第86和91行。这里是包含这些引用的代码块。

    if (currentChar === 37 /*'%'*/ && index < maxLength) {
      currentChar = s.charCodeAt(++index);
      hexHigh = unhexTable[currentChar];
      if (!(hexHigh >= 0)) {
        out[outIndex++] = 37; // '%'
      } else {
        nextChar = s.charCodeAt(++index);
        hexLow = unhexTable[nextChar];
        if (!(hexLow >= 0)) {
          out[outIndex++] = 37; // '%'
          out[outIndex++] = currentChar;
          currentChar = nextChar;
        } else {
          hasHex = true;
          currentChar = hexHigh * 16 + hexLow;
        }
      }
    }

所有这些都发生在 unescapeBuffer 函数中。快速搜索后,我发现 unescapeBuffer 函数由模块公开的 unescape 函数调用(请参见第113行)。所以这里是发生在 querystring 中的 unescape 动作。

好的!那么,unhexTable 的所有逻辑是什么?我开始通读 unescapeBuffer 函数来弄清楚它在做什么。我从第67行开始。

var out = Buffer.allocUnsafe(s.length);

所以函数首先初始化一个传递给函数的字符串长度的 Buffer。此时,我可以深入了解 Buffer 类中的 allocUnsafe 正在做什么,但是我将预留它为另一篇博客文章。之后,有几个语句会初始化为稍后将在函数中使用的不同变量。

  var index = 0;
  var outIndex = 0;
  var currentChar;
  var nextChar;
  var hexHigh;
  var hexLow;
  var maxLength = s.length - 2;
  // Flag to know if some hex chars have been decoded
  var hasHex = false;

下一块代码是一个 while 循环,遍历字符串中的每个字符。如果字符是 +,并且函数设置为将 + 更改为空格,则会将转义的字符串中该字符的值设置为空格。

  while (index < s.length) {
    currentChar = s.charCodeAt(index);
    if (currentChar === 43 /*'+'*/ && decodeSpaces) {
      out[outIndex++] = 32; // ' '
      index++;
      continue;
    }

第二组 if 语句检查迭代器是否处于以 % 开始的字符序列,这表示接下来的字符将代表十六进制代码。然后程序获取字符代码。接着程序使用该字符代码作为查找 unhexTable 列表中的索引。如果查找返回的值为 -1,则该函数将输出字符串中的字符值设置为百分号。如果从 unhexTable 中的查找返回的值大于 -1,则函数会将分隔字符解析为十六进制字符代码。

    if (currentChar === 37 /*'%'*/ && index < maxLength) {
      currentChar = s.charCodeAt(++index);
      hexHigh = unhexTable[currentChar];
      if (!(hexHigh >= 0)) {
        out[outIndex++] = 37; // '%'
      } else {
        nextChar = s.charCodeAt(++index);
        hexLow = unhexTable[nextChar];
        if (!(hexLow >= 0)) {
          out[outIndex++] = 37; // '%'
          out[outIndex++] = currentChar;
          currentChar = nextChar;
        } else {
          hasHex = true;
          currentChar = hexHigh * 16 + hexLow;
        }
      }
    }
    out[outIndex++] = currentChar;
    index++;
  }

让我们再深入一点这段代码。所以,如果第一个字符是有效的十六进制代码,它将使用下一个字符的字符代码作为 unhexTable 的查找索引。这个值是在 hexLow 变量中。如果该变量等于 -1,则该值不会被解析为十六进制字符序列。如果不等于 -1,则该字符被解析为十六进制字符代码。该函数取十六进制代码的最高位(第二位)(hexHigh)的值,将其乘以16并将其加到十六进制代码的值的第一位中。

      } else {
        nextChar = s.charCodeAt(++index);
        hexLow = unhexTable[nextChar];
        if (!(hexLow >= 0)) {
          out[outIndex++] = 37; // '%'
          out[outIndex++] = currentChar;
          currentChar = nextChar;
        } else {
          hasHex = true;
          currentChar = hexHigh * 16 + hexLow;
        }
      }

函数的最后一行让我困惑了一会儿。

return hasHex ? out.slice(0, outIndex) : out;

如果我们在查询中检测到一个十六进制序列,则将输出字符串从 0outIndex 的切片,否则保持原样。这使我感到困惑,因为我认为 outIndex 的值将等于程序结束时输出字符串的长度。 我本可以花时间弄清楚这个假设是否属实,但说实话,现在已经快到午夜了,而且我已经没有精力在深夜做这种荒唐举动了。所以我在代码库上运行 git blame,并试图找出哪些提交与这个特别的改动相关联。事实证明,这并没有太大的帮助。我期待着那里有一个孤立的提交,它描述了为什么那个特别的一行代码是这样的,但最近的变化是属于 escape 函数的一个更大的重构的一部分。 我越看越确定这里不需要三元运算符,但我还没有找到一些可重现的证据。

我研究的下一个函数是 parse 函数。函数的第一部分进行一些基本的设置。函数默认分析查询字符串中的 1000 个键值对,但用户可以在 options 对象中传递 maxKeys 值以更改此值。 该函数还使用我们前面介绍的 unescape 函数,除非用户在选项对象中提供了不同的东西。

function parse(qs, sep, eq, options) {
  const obj = Object.create(null);

  if (typeof qs !== 'string' || qs.length === 0) {
    return obj;
  }

  var sepCodes = (!sep ? defSepCodes : charCodes(sep + ''));
  var eqCodes = (!eq ? defEqCodes : charCodes(eq + ''));
  const sepLen = sepCodes.length;
  const eqLen = eqCodes.length;

  var pairs = 1000;
  if (options && typeof options.maxKeys === 'number') {
    // -1 is used in place of a value like Infinity for meaning
    // "unlimited pairs" because of additional checks V8 (at least as of v5.4)
    // has to do when using variables that contain values like Infinity. Since
    // `pairs` is always decremented and checked explicitly for 0, -1 works
    // effectively the same as Infinity, while providing a significant
    // performance boost.
    pairs = (options.maxKeys > 0 ? options.maxKeys : -1);
  }

  var decode = QueryString.unescape;
  if (options && typeof options.decodeURIComponent === 'function') {
    decode = options.decodeURIComponent;
  }
  const customDecode = (decode !== qsUnescape);

然后函数遍历查询字符串中的每个字符并获取该字符的字符代码。

  var lastPos = 0;
  var sepIdx = 0;
  var eqIdx = 0;
  var key = '';
  var value = '';
  var keyEncoded = customDecode;
  var valEncoded = customDecode;
  const plusChar = (customDecode ? '%20' : ' ');
  var encodeCheck = 0;
  for (var i = 0; i < qs.length; ++i) {
    const code = qs.charCodeAt(i);

函数然后检查被检查的字符是否对应于键值分隔符(例如查询字符串中的”&“字符)并执行一些特殊的逻辑。它会检查“&”后面是否有“key=value”段,并尝试从中提取相应的键和值对(第304-347行)。

如果字符代码不与分隔符相对应,函数会检查它是否与“=”符号或其它的用于提取键值的分隔符相对应。

接下来,该函数检查字符是否为“+”符号。如果是这种情况,那么函数会生成一个空格分隔的字符串。如果字符是“%”,则该函数会正确解码后面的十六进制字符。

      if (code === 43/*+*/) {
        if (lastPos < i)
          value += qs.slice(lastPos, i);
        value += plusChar;
        lastPos = i + 1;
      } else if (!valEncoded) {
        // Try to match an (valid) encoded byte (once) to minimize unnecessary
        // calls to string decoding functions
        if (code === 37/*%*/) {
          encodeCheck = 1;
        } else if (encodeCheck > 0) {
          // eslint-disable-next-line no-extra-boolean-cast
          if (!!isHexTable[code]) {
            if (++encodeCheck === 3)
              valEncoded = true;
          } else {
            encodeCheck = 0;
          }
        }
      }

还需要在未处理的数据上完成剩余的检查。换句话说,函数要检查是否还有一个需要添加的键值对,或者该函数是否可以返回空数据。我认为这里包含了处理解析时可能出现的边界情况。

  // Deal with any leftover key or value data
  if (lastPos < qs.length) {
    if (eqIdx < eqLen)
      key += qs.slice(lastPos);
    else if (sepIdx < sepLen)
      value += qs.slice(lastPos);
  } else if (eqIdx === 0 && key.length === 0) {
    // We ended on an empty substring
    return obj;
  }

最后一组检查,它会检查是否需要对键或值进行解码(使用 unescape 函数),或者是否需要将特定键的值构造为数组。

  if (key.length > 0 && keyEncoded)
    key = decodeStr(key, decode);
  if (value.length > 0 && valEncoded)
    value = decodeStr(value, decode);
  if (obj[key] === undefined) {
    obj[key] = value;
  } else {
    const curValue = obj[key];
    // A simple Array-specific property check is enough here to
    // distinguish from a string value and is faster and still safe since
    // we are generating all of the values being assigned.
    if (curValue.pop)
      curValue[curValue.length] = value;
    else
      obj[key] = [curValue, value];
  }

这就是 parse 函数!

好的!我继续看看 querystring 模块暴露的另一个函数 stringifystringify 函数首先初始化一些必需的变量。它默认使用 escape 函数来编码值,用户可以在选项中提供自定义的编码函数。

function stringify(obj, sep, eq, options) {
  sep = sep || '&';
  eq = eq || '=';

  var encode = QueryString.escape;
  if (options && typeof options.encodeURIComponent === 'function') {
    encode = options.encodeURIComponent;
  }

之后,该函数会迭代对象中的每个键值对。当它遍历每个键值对时,它会对键进行编码和串化。

if (obj !== null && typeof obj === 'object') {
    var keys = Object.keys(obj);
    var len = keys.length;
    var flast = len - 1;
    var fields = '';
    for (var i = 0; i < len; ++i) {
      var k = keys[i];
      var v = obj[k];
      var ks = encode(stringifyPrimitive(k)) + eq;

接下来,它检查键值对中的值是否为数组。如果是,则它遍历数组中的每个元素,并向该字符串添加 ks=element 关联。如果不是,则该函数根据键值对构建 ks=v 关联。

      if (Array.isArray(v)) {
        var vlen = v.length;
        var vlast = vlen - 1;
        for (var j = 0; j < vlen; ++j) {
          fields += ks + encode(stringifyPrimitive(v[j]));
          if (j < vlast)
            fields += sep;
        }
        if (vlen && i < flast)
          fields += sep;
      } else {
        fields += ks + encode(stringifyPrimitive(v));
        if (i < flast)
          fields += sep;
      }

这个函数对我来说很简单。在由API公开的最后一个函数中,escape。该函数遍历字符串中的每个字符并获取与该字符相对应的字符代码。

function qsEscape(str) {
  if (typeof str !== 'string') {
    if (typeof str === 'object')
      str = String(str);
    else
      str += '';
  }
  var out = '';
  var lastPos = 0;

  for (var i = 0; i < str.length; ++i) {
    var c = str.charCodeAt(i);

如果字符代码小于 0x80,表示所代表的字符是有效的ASCII字符(ASCII字符的十六进制代码范围为 00x7F)。该函数然后通过在 noEscape 表中查找来检查字符是否应该被转义。该表允许标点符号,数字或字符的字符不会被转义,并要求其他所有内容都被转义。然后它检查字符的位置是否大于 lastPos 发现的位置(意味着已经超过了字符串的长度)并适当地对字符串进行分片。最后,如果字符确实需要转义,它将查找 hexTable 中的字符代码并将其附加到输出字符串。

    if (c < 0x80) {
      if (noEscape[c] === 1)
        continue;
      if (lastPos < i)
        out += str.slice(lastPos, i);
      lastPos = i + 1;
      out += hexTable[c];
      continue;
    }

下一个if语句会检查字符是否是多字节字符代码。多字节字符通常代表重音和非英文字母的字符。

    if (c < 0x800) {
      lastPos = i + 1;
      out += hexTable[0xC0 | (c >> 6)] + hexTable[0x80 | (c & 0x3F)];
      continue;
    }

在这种情况下,使用 hexTable 中的以下查找来计算输出字符串。

out += hexTable[0xC0 | (c >> 6)] + hexTable[0x80 | (c & 0x3F)];

好的!这里有很多事情要做,所以我开始研究它。 hexTableinternal/querystring 支持模块中定义,并且像这样生成。

const hexTable = new Array(256);
for (var i = 0; i < 256; ++i)
  hexTable[i] = '%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase();

所以输出结果是一个字符串数组,代表256个字符的十六进制字符代码。它看起来有点像 ['%00', '%01', '%02',..., '%FD', '%FE', '%FF']。所以,上面的查询语句。

out += hexTable[0xC0 | (c >> 6)] + hexTable[0x80 | (c & 0x3F)];

语句 c >> 6 将字符代码右移6位,并与192的二进制表示执行按位或。语句 c & 0x3F 将字符串和63的二进制表示执行按位与,然后和 0x80 执行按位或。两条语句执行的结果各自进行一次 hexTable 查询,并将结果相连接。所以我知道多字节序列从 0x80 开始,但我无法弄清楚到底发生了什么。

下一个被检查的情况是这样的。

    if (c < 0xD800 || c >= 0xE000) {
      lastPos = i + 1;
      out += hexTable[0xE0 | (c >> 12)] +
             hexTable[0x80 | ((c >> 6) & 0x3F)] +
             hexTable[0x80 | (c & 0x3F)];
      continue;
    }

在所有其他情况下,该函数使用以下策略来生成输出字符串。

    var c2 = str.charCodeAt(i) & 0x3FF;
    lastPos = i + 1;
    c = 0x10000 + (((c & 0x3FF) << 10) | c2);
    out += hexTable[0xF0 | (c >> 18)] +
           hexTable[0x80 | ((c >> 12) & 0x3F)] +
           hexTable[0x80 | ((c >> 6) & 0x3F)] +
           hexTable[0x80 | (c & 0x3F)];

我真的被所有这些困惑了。当我去做一些调查时,我发现所有这些与十六进制相关的代码都来自这个单独的提交。它似乎是因性能相关的一部分而出现。关于为什么使用这种特定的方法,并没有大量的信息,我怀疑这个逻辑是从另一个编码函数中复制的。在某些时候我会进一步深入研究。

最后,有一些逻辑处理输出字符串返回的方式。如果 lastPos 的值为0,表示没有处理字符,则返回原始字符串。否则,返回生成的输出字符串。

  if (lastPos === 0)
    return str;
  if (lastPos < str.length)
    return out + str.slice(lastPos);
  return out;

就是这样!我介绍了由Node querystring 模块公开的四个函数。