资源标识符生成:如何实现带校验和的唯一ID

简介

唯一标识符(UIDs)或简称标识符,可以是字符串值或整数。API开发者经常使用它们来寻址API中的唯一资源。随后,API消费者利用这些标识符从资源集合中获取单个资源。如果没有唯一标识符,几乎不可能将资源区分开来并根据需要进行调用。标识符可以指代数据库结构元素,例如表的名称、表中的字段(列)或约束,并且可以进一步指定为数据库中的唯一项目。例如,在与酒店预订门户网站相关的数据库中,Hotel(id) 可能指向一个引用唯一酒店的标识符。通过 Hotel(id = 1234, name = "Hyatt"),您将能够通过ID 1234或名称“Hyatt”识别这家特定酒店。

在API设计模式中,John J. Geewax确定了一个良好标识符所具备的七个基本特征。在生成唯一ID时,考虑到这些特征非常重要。

  • 易于使用:标识符应避免使用保留字符,如正斜杠(/),因为这些字符在URL中具有特定含义。
  • 唯一性:标识符应能够指向API中的单个资源。
  • 生成速度快:ID生成过程应以可预测的方式进行,以确保在扩展时保持一致性。
  • 不可预测性:当标识符不可预测时,它能为漏洞管理提供安全益处。
  • 可读性:标识符应具有人类可读性,这可以通过避免使用数字1、小写字母l、大写字母I或竖线字符(|)来实现,因为这些字符在手动检查ID时可能会造成混淆。
  • 可验证性:可以使用校验字符来在完整性检查期间验证ID。
  • 永久性:一旦分配,标识符不应更改。

提示:更改标识符可能会引起意想不到的混淆。如果您有一个指定了酒店标识符的条目(id=1234, name="Hyatt"),然后这个标识符后来变成了(id=5678, name="Hyatt"),原来的ID可能会被重复使用。如果原先的标识符可用,并且创建一个名为“Grand Villa”的新酒店(Hotel(id=1234, name="Grand Villa")),这个新酒店会重用原始的标识符(1234)。然后,当您查询酒店1234时,可能会收到与预期不符的结果。

在本教程中,您将使用Node.js生成一个独特的自定义资源标识符,满足这些特征,并生成一个相关的校验和。校验和是通过对数字对象应用哈希函数获得的文件或数字数据的数字指纹的哈希值。本教程的校验和将是通过对与您的资源对应的字节大小进行编码(或哈希)的算法过程得出的单个字母数字字符。

先决条件

开始这个教程之前,您需要准备以下物品:

  • 您的机器上已安装Node.js,您可以按照《如何安装Node.js》进行设置。本教程已使用Node.js版本16.16.0进行测试。
  • 熟悉Node.js。您可以在《如何在Node.js中编写代码》系列中了解更多。
  • 熟悉API。有关使用API的全面教程,您可以查阅《如何在Python3中使用Web API》。尽管该文章是为Python编写的,但它将帮助您理解使用API的核心概念。
  • 一个支持JavaScript语法高亮的文本编辑器,例如Atom、Visual Studio Code或Sublime Text。本教程使用命令行编辑器nano。

步骤1 — 生成一个编码ID

在这个步骤中,您将编写一个函数,将随机字节生成的标识符转换为唯一的字母数字字符串。您的标识符将使用Base32编码进行编码,但在本教程的后期将不会附带校验和。编码过程将根据您选择的字节数创建一个指定长度的唯一标识符,构建一个融合了一些良好标识符特征的ID。

开始时,为这个项目创建一个新文件夹,然后进入该文件夹。

  1. mkdir checksum
  2. cd checksum

本教程中,项目文件夹将被称为“checksum”。

使用您喜爱的编辑器在项目文件夹中创建并打开一个package.json文件。

  1. nano package.json

然后添加以下代码行:

package.json -> 包描述文件
{
  "name": "checksum",
  "version": "1.0.0",
  "main": "index.js",
  "type": "module"
}

在这个文件中,您将项目名称定义为“checksum”,并将代码版本定义为“1.0.0”。您将主要的JavaScript文件定义为index.js。当您在package.json文件中有"type": "module"时,您的源代码应该使用import语法。在这个文件中,您使用JSON数据格式,您可以在《如何在JavaScript中使用JSON》中了解更多相关内容。

保存并关闭文件。

您将使用一些Node.js模块来生成ID:cryptobase32-encode,以及它对应的解码器base32-decodecrypto模块已经打包在Node.js中,但是您需要在本教程后面使用时安装base32-encodebase32-decode。编码是将一系列字符(字母、数字、标点符号和特定符号)转换为一种专门的格式,以实现高效的传输或存储。解码是相反的过程:将编码格式转换回原始字符序列。Base32编码使用32个字符集,使它成为用于表示数字的文本32符号符号化方法。

在终端会话中,使用以下命令将这些模块包安装到项目文件夹中。

  1. npm i base32-encode base32-decode

您将收到一个输出,该输出表明这些模块已经被添加。

输出
added 3 packages, and audited 5 packages in 2s

found 0 vulnerabilities

如果安装中遇到问题,您可以参考《如何使用npm和package.json来支持Node.js模块》获取支持。

在您的项目文件夹中,还需要创建一个名为index.js的新文件。

  1. nano index.js

将下列行的JavaScript代码添加到index.js文件中:

import crypto from 'crypto';  
import base32Encode from 'base32-encode';
import base32Decode from 'base32-decode';
 
function generate_Id(byte_size) {
    const bytes = crypto.randomBytes(byte_size);
    return base32Encode(bytes, 'Crockford');
}

console.log('ID for byte size = 1:',generate_Id(1), '\n');
console.log('ID for byte size = 12:',generate_Id(12), '\n');
console.log('ID for byte size = 123:',generate_Id(123), '\n');

导入命令会加载所需的模块。为了从数字生成字节,您可以定义一个名为 generate_Id 的函数,该函数接收字节大小作为参数,并使用加密模块的 randomBytes 函数生成指定大小的随机字节。然后,generate_Id 函数将使用 Crockford 的 Base32 编码方式对这些字节进行编码。

为了教学目的,这里生成了一些 ID 并将其输出到控制台。下一步将使用 base32-decode 模块来解码这些资源 ID。

保存您的 index.js 文件,然后在终端会话中使用以下命令运行代码:

node index.js

您将收到类似于以下的输出响应:

输出
ID for byte size = 1: Y8 ID for byte size = 12: JTGSEMQH2YZFD3H35HJ0 ID for byte size = 123: QW2E2KJKM8QZ7174DDB1Q3JMEKV7328EE8T79V1KG0TEAE67DEGG1XS4AR57FPCYTS24J0ZRR3E6TKM28AM8FYZ2AZTZ55C9VVQTABE0R7QRH7QBY7V3GBYBNN5D9JK0QMD9NXSWZN95S0772DHN43Q003G0QNTPA2J3AFA3P7Q167C1VNR92Z85PCDXCMEY0M7WA

由于生成的字节是随机的,您的 ID 值可能会有所不同。生成的 ID 长度会因所选择的字节大小而变化。

回到 index.js 文件,使用 JavaScript 的注释功能注释掉控制台输出(在代码行前面添加双斜杠 //)。

index.js 文件
...
//console.log('ID for byte size = 1:',generate_Id(1), '\n'); 
//console.log('ID for byte size = 12:',generate_Id(12), '\n');
//console.log('ID for byte size = 123:',generate_Id(123), '\n');

这些行展示了编码将根据相关的字节输出不同的标识符。由于这些行在后面的部分不会被使用,您可以根据此代码块示例将其注释掉或完全删除。

在这一步中,您通过对随机字节进行编码来创建了一个编码的 ID。在下一步中,您将合并编码字节和校验和,创建一个唯一的标识符。

第二步 — 生成资源标识符

现在您将创建一个带有校验字符的 ID。生成校验字符是一个两步骤的过程。为了教学目的,组合函数的每个子函数将分别在以下小节中构建。首先,您将编写一个执行模数运算的函数。然后,您将编写另一个将结果映射到校验字符的函数,这是生成资源 ID 校验和的方式。最后,您将验证标识符和校验和,以确保资源标识符准确无误。

进行模运算

在本节中,您将把与编号 ID 相对应的字节转换为介于 0 到 36 之间的数字(含上下限,即 0 到 36 之间的任何数字,包括 0 和 36)。将与编号 ID 相对应的字节通过求模运算转换为整数。求模运算将返回将字节转换为 BigInteger(大整数)值后获得的被除数的余数。

要实施此过程,请将以下代码行添加到 index.js 文件的底部。

index.js – 索引.js

这是文章《如何生成带有校验和的资源标识符》的第3部分(共7部分)。

function calculate_checksum(bytes) {
    const intValue = BigInt(`0x${bytes.toString('hex')}`);
    return Number(intValue % BigInt(37));
}

calculate_checksum 函数与文件中先前定义的字节一起工作。该函数将字节转换为十六进制值,并进一步将其转换为 BigInt 类型。BigInt 数据类型表示比 JavaScript 中原始 number 类型所能表示的数值更大的数字。例如,尽管整数 37 相对较小,但它在模运算中被转换为 BigInt 类型。

为了实现这种转换,首先您需要使用 BigInt 的转换方法将 intValue 变量设置为一个数值,然后使用 toString 方法将字节转换为十六进制。接着,您可以使用 Number 构造函数返回一个数值,其中您可以运用 % 符号执行模运算,以找出 intValueBigInt(37) 之间的余数。这个整数值(在本例中为 37)被用作下标,从自定义的字符字符串中选择一个字母数字字符。

如果 intValue 的值是 123(根据字节而定),模运算将是 123 % 37。这个运算的结果,以 37 为整数值,将会得到余数 12 和商 3。如果资源 ID 的值为 154,运算 154 % 37 将会得到余数 6。

这个函数将输入的字节映射到模数结果。接下来,您将编写一个函数将模数结果映射到校验和字符。

获取校验字符

在前一节中获得模数结果后,您可以将其映射为校验字符。

将以下代码行添加到 index.js 文件中先前代码之下:

index.js -> 主程序文件
function get_checksum_character(checksumValue) {
    const alphabet = '0123456789ABCDEFG' +
        'HJKMNPQRSTVWXYZ*~$=U';  
    return alphabet[Math.abs(checksumValue)];
}

get_checksum_character 函数中,您以 checksumValue 作为参数调用该函数。在这个函数中,您将字符串常量 alphabet 定义为一个自定义的字母数字字符串。根据 checksumValue 的值,该函数将返回一个与 alphabet 常量中定义的字符串和 checksumValue 的绝对值相对应的字符。

接下来,您将编写一个函数,该函数利用这两个已编写的函数来生成一个由字节编码和校验字符组合而成的 ID。

将以下代码添加到 index.js 文件中。

index.js -> 主程序文件
...

function generate_Id_with_checksum(bytes_size) {
    const bytes = crypto.randomBytes(bytes_size);
    const checksum = calculate_checksum(bytes);
    const checksumChar = get_checksum_character(checksum);
    console.log("校验字符: ", checksumChar);
    const encoded = base32Encode(bytes, 'Crockford');
    return encoded + checksumChar;
}

const Hotel_resource_id =generate_Id_with_checksum(132)
console.log("酒店资源ID: ",Hotel_resource_id)

这段代码将之前定义的 calculate_checksumget_checksum_character 函数(用于生成校验字符)与编码函数结合,创建了一个名为 generate_Id_with_checksum 的新函数,用于生成带有校验字符的ID。

保存文件,然后在单独的终端会话中运行代码:

  1. node index.js

你将会收到一个类似于这样的输出:

输出
校验字符: B 酒店资源ID: 9V99B9P55K7M4DN5XYP4VTJYJGENZKJ0F9Q6EEEZ07X49G0V14AXJS3RYXBT3J1WJZXWGM76C6H7G895TJT27AW77BHBX2D16QNQ2ZNBY9MQHWG9NJ1WWVTNRCKRBX6HC3M7BB3JG0V413VJ767JN6FT0GFS5VQJ9X7KSP1KM29B02NAGXN3FP30WA8Y63N1XJAMGDPEE1RNHRTWH6P0B

相同的校验字符出现在ID的末尾,表示校验和匹配。

这个示意图提供了一个结构性的表示,展示了这个复合函数是如何工作的。

一个顶部显示产品ID的图表。它指向加密方法,加密方法指向字节。字节有两个分支:base32解码和取模处理。base32解码分支指向编码ID,而取模处理分支指向校验和。当编码ID和校验和配对时,它们就变成了资源ID。

这个流程图展示了一个产品ID是如何通过编码和取模的过程转换成唯一的资源ID的。产品ID是由资源计数器手动创建的标识符。图中的加密方法指的是 crypto.randomBytes() 函数。

你根据字节大小创建了一个包含校验字符的ID。在下一个部分,你将实现一个验证函数,通过Base32解码来验证ID的完整性。

检查标识符的完整性

为了保证完整性,您将使用一个名为 verify_Id 的新函数,将校验和字符(标识符的最后一个字符)与生成的校验和进行比较。比较校验和字符是检查原始ID的完整性并确定其是否被篡改的重要步骤。

将这些行添加到你的 index.js 文件中。

index.js 内容如下
...
function verify_Id(identifier) {
    const value = identifier.substring( 0, identifier.length-1);
    const checksum_char = identifier[identifier.length-1];     
    const buffer = Buffer.from( base32Decode(value, 'Crockford'));
    const calculated_checksum_char = get_checksum_character(calculate_checksum(buffer));
    console.log(calculated_checksum_char);
    const flag =calculated_checksum_char== checksum_char;
    return (flag);    
     }
console.log('\n');
console.log("正在计算校验和")
const flag = verify_Id(Hotel_resource_id);
if (flag) console.log("校验和匹配成功。");
else console.log("校验和不匹配。");

verify_Id 函数通过检查校验和来验证 ID 的完整性。它将标识符的剩余字符解码到缓冲区中,然后依次运行 calculate_checksumget_checksum_character 函数来提取校验和字符,并与计算得出的校验和字符进行比较(即 calculated_checksum_char == checksum_char)。

这个示意图展示了复合函数的工作原理:

一张图表,顶部是资源ID。它指向切片方法,该方法有两个分支:值和校验和。值分支指向base32解码,然后变为解码后的校验和。校验和分支指向校验和。如果解码后的校验和与校验和匹配,则结果为验证成功。

在此图表中,切片操作指的是将ID值(value)与校验和字符(checksum)分离开来。在您之前的代码块中,函数 identifier.substring(0, identifier.length - 1) 用于选择ID值,而 identifier[identifier.length - 1] 则获取了资源ID中的最后一个字符,即校验和字符。

您的 index.js 文件现在应该与以下代码相匹配:

您的 index.js 文件现在需要与以下代码一致:

import crypto from 'crypto';  // 用于从数字生成字节
import base32Encode from 'base32-encode'; // 用于将字节编码为字符串类型的唯一ID
import base32Decode from 'base32-decode';// 用于将ID解码为字节

function generate_Id(byte_size) {
    const bytes = crypto.randomBytes(byte_size);
    return base32Encode(bytes, 'Crockford');
}

//console.log('ID for byte size = 1:',generate_Id(1), '\n');
//console.log('ID for byte size = 12:',generate_Id(12), '\n');
//console.log('ID for byte size = 123:',generate_Id(123), '\n');

function calculate_checksum(bytes) {
    const intValue = BigInt(`0x${bytes.toString('hex')}`);
    return Number(intValue % BigInt(37));
}

function get_checksum_character(checksumValue) {
    const alphabet = '0123456789ABCDEFG' +
        'HJKMNPQRSTVWXYZ*~$=U'; // 自定义字符串,包含字母数字字符
    return alphabet[Math.abs(checksumValue)]; // 选取一个字母数字字符
}

function generate_Id_with_checksum(bytes_size) {
    const bytes = crypto.randomBytes(bytes_size);
    const checksum = calculate_checksum(bytes);
    const checksumChar = get_checksum_character(checksum);
    console.log("校验和字符: ", checksumChar); 
    const encoded = base32Encode(bytes, 'Crockford');
    return encoded + checksumChar;
}

const Hotel_resource_id =generate_Id_with_checksum(132)
console.log("酒店资源ID: ",Hotel_resource_id)

function verify_Id(identifier) {
    const value = identifier.substring( 0, identifier.length-1);
    const checksum_char = identifier[identifier.length-1]; 
    //console.log(value,checksum_char);
    const buffer = Buffer.from( base32Decode(value, 'Crockford'));
    const calculated_checksum_char = get_checksum_character(calculate_checksum(buffer));
    console.log(calculated_checksum_char);
    const flag =calculated_checksum_char== checksum_char;

    return (flag);
    
     }

console.log('\n');
console.log("正在计算校验和")
const flag = verify_Id(Hotel_resource_id);
if (flag) console.log("校验和匹配成功。");
else console.log("校验和不匹配。");

现在您可以运行这段代码了。

node index.js

您将收到以下输出结果:

输出...
computing checksum AW75SY7FVC7TKT7VP5ZF0M8C67CN36YZK27BXHVFHSDXJFKH54HK2AXQFMPN89Q5YQRPGNHGAYQ5JFKVD40EKTXCET97Q0FEPX6MX1ZTNWGCA08SBRSHP8B0037ACJG6F6472FEVARCAWM6P5MRJ2F6WTRPXHYS9N1JEDZVH41D33RA5365VNFC5G5VYEFPFJJD8151B28XXDBRHAF80 H H Checksums matched.

您现在拥有一个名为 verify_Id 的函数,它使用校验字符来检查标识符的完整性。接下来,出于教学目的,您可以修改资源ID,使函数返回不匹配的结果,以评估校验失败时会发生什么。

(可选)第三步——更改标识符以产生不匹配结果

您现在将更改标识符的值,以检查校验和是否匹配。此步骤中的更改总是会导致校验和不匹配,因为如果ID中的任何字符被篡改,完整性就无法得到保持。这种更改可能是由于传输错误或恶意行为引起的。此更改仅用于教学目的,不建议在生产环境中使用,但它将使您能够评估不匹配的校验和结果。

在您的 index.js 文件中,通过添加高亮行来修改 Hotel_resource_id

index.js 重点.js
...
const altered_Hotel_resource_id= Hotel_resource_id.replace('P','H');   
console.log("computing checksum")
const flag = verify_Id(altered_Hotel_resource_id);
if (flag) console.log("Checksum matched.");
else console.log("Checksums did not match.");

在上述代码中,您需要将所有的 ‘P’ 替换为 ‘H’,并将变量名从 Hotel_resource_ID 改为 altered_Hotel_resource_id。同样,这些变动仅用于信息目的,在此步骤结束后可以还原,以确保匹配的完整性。

保存文件,然后使用更改后的资源ID重新运行代码。

  1. node index.js

您将会收到一个输出,显示校验和不匹配。

输出
Checksums did not match.

在此步骤中,您创建了一个函数,用于验证校验和是否通过完整性测试,并且您遇到了两种情况。不匹配的校验和表示资源ID已被篡改。此通知使开发人员可以根据应用需求采取措施对抗恶意行为,例如阻止用户请求或报告与资源ID相关的请求。

为了使函数恢复与匹配校验和结果相符,请删除在此步骤开头添加的额外代码,以便代码与第2步结束时的文件相匹配。

当您需要一个带有校验和的自定义唯一标识符时,可以使用本教程来帮助您生成数据模型、版本化您的API等。

结论

在本教程中,您开发了与良好标识符特征相一致的资源ID。您还在Node.js环境中使用Base32编码创建了一个带有校验和的唯一资源ID。最后,您通过Base32解码来验证该ID的完整性。

为了交叉验证,您可以将最终文件与Silicon Cloud社区仓库中的文件进行对比。如果您熟悉Git版本控制系统,或者按照《GitHub和开源项目入门系列》的介绍进行操作,也可以进行Git克隆仓库。

现在您了解了校验和的基础知识,您可以尝试其他的编码算法,例如MD5。

bannerAds