文章目录
- 1.IdConverterUtil
- 2.hashids-java
-
- 2.1项目地址
- 2.2源码
- [What is it?](#What is it?)
- Usage
-
-
- [Add the dependency](#Add the dependency)
- [Import the package](#Import the package)
- [Encoding one number](#Encoding one number)
- Decoding
- [Decoding with different salt](#Decoding with different salt)
- [Encoding several numbers](#Encoding several numbers)
- [Decoding is done the same way](#Decoding is done the same way)
- [Encoding and specifying minimum hash length](#Encoding and specifying minimum hash length)
- Decoding
- [Specifying custom hash alphabet](#Specifying custom hash alphabet)
- [Encoding and decoding "MongoDB" ids](#Encoding and decoding "MongoDB" ids)
-
- Randomness
-
-
- [Repeating numbers](#Repeating numbers)
- [Incrementing number hashes:](#Incrementing number hashes:)
-
- [Bad hashes](#Bad hashes)
- Limitations
- 3.sqids-java
- [4.**Hashids 与 Sqids** 的关系与对比选择](#4.Hashids 与 Sqids 的关系与对比选择)
-
- [4.1 Sqids 与 Hashids 如何选择?](#4.1 Sqids 与 Hashids 如何选择?)
- [**Hashids 与 Sqids 的真实关系解析**](#Hashids 与 Sqids 的真实关系解析)
- [**Hashids 与 Sqids:关键区别解析**](#Hashids 与 Sqids:关键区别解析)
- [**Sqids 与 Hashids 的兼容性分析**](#Sqids 与 Hashids 的兼容性分析)
- 5.hutool中的Hashids
- 6.总结
1.IdConverterUtil
该方案的思想是加权求和映射到base62的字符实现数字和字符的相互转换
java
package xxx.xxxx.util;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.Map;
/**
* ID与短字符串互转工具类
* 使用Base62编码实现长整数ID与字符串ID的互相转换
*/
@Slf4j
public class IdConverterUtil {
/**
* Base62字符集 (0-9, a-z, A-Z)(可以按需调整顺序)
*/
// private static final String BASE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final String BASE_CHARS = "rstyoabcdefghijklmnpquvwxz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final char[] CHAR_SET = BASE_CHARS.toCharArray();
private static final int BASE = CHAR_SET.length;
private static final Map<Character, Integer> CHAR_INDEX_MAP = new HashMap<>();
/**
* 默认固定长度
*/
public static final int DEFAULT_FIXED_LENGTH = 8;
/**
* 填充字符(使用字符集中第一个字符)
*/
private static final char PADDING_CHAR = CHAR_SET[0];
static {
// 初始化字符索引映射,用于快速查找
for (int i = 0; i < BASE; i++) {
CHAR_INDEX_MAP.put(CHAR_SET[i], i);
}
}
/**
* 将长整数ID转换为短字符串
*
* @param id ID
* @return 短字符串ID
*/
public static String encode(long id) {
if (id < 0) {
throw new IllegalArgumentException("ID必须是正数");
}
// 处理特殊情况:id为0
if (id == 0) {
return String.valueOf(CHAR_SET[0]);
}
StringBuilder sb = new StringBuilder();
// 将长整数转换为Base62
while (id > 0) {
// 计算当前数字除以62的余数
int remainder = (int) (id % BASE);
// 将余数作为索引,从字符集中获取对应的字符
sb.append(CHAR_SET[remainder]);
/**
* 关键步骤:将id更新为除以62后的整数部分
* 这相当于将数字向右移动一位(62进制下)
*/
id = id / BASE;
}
// 反转字符串得到正确顺序,因为我们在循环中是从最低位开始添加字符的
return sb.reverse().toString();
}
/**
* 将长整数ID转换为固定长度的短字符串
*
* @param id 雪花算法ID
* @param minLength 最小长度
* @return 固定长度的短字符串ID
*/
public static String encodeFixed(long id, int minLength) {
if (minLength <= 0) {
throw new IllegalArgumentException("固定长度必须大于0");
}
// 先进行普通编码
String encoded = encode(id);
// 检查编码后的长度是否超过固定长度
if (encoded.length() > minLength) {
return encoded;
}
// 如果长度不足,在前面填充字符
if (encoded.length() < minLength) {
StringBuilder sb = new StringBuilder();
// 在前面填充指定字符
for (int i = 0; i < minLength - encoded.length(); i++) {
sb.append(PADDING_CHAR);
}
sb.append(encoded);
return sb.toString();
}
return encoded;
}
/**
* 将长整数ID转换为默认长度的短字符串
* 默认长度:{@link #DEFAULT_FIXED_LENGTH}
*
* @param id ID
* @return 固定长度的短字符串ID
*/
public static String encodeFixed(long id) {
return encodeFixed(id, DEFAULT_FIXED_LENGTH);
}
/**
* 从固定长度的字符串中解析出原始ID
* 注意:这个方法会去除前导的填充字符
*
* @param fixedId 固定长度的字符串ID
* @return 原始的长整数ID
*/
public static long decodeFromFixed(String fixedId) {
if (fixedId == null || fixedId.isEmpty()) {
throw new IllegalArgumentException("字符ID不能为空");
}
// 去除前导的填充字符
String cleanedId = removePaddingChars(fixedId);
// 如果去除填充字符后为空字符串,说明原始ID是0
if (cleanedId.isEmpty()) {
return 0;
}
return decode(cleanedId);
}
/**
* 去除字符串前导的填充字符
*
* @param str 原始字符串
* @return 去除前导填充字符后的字符串
*/
private static String removePaddingChars(String str) {
int start = 0;
// 跳过前导的填充字符
while (start < str.length() && str.charAt(start) == PADDING_CHAR) {
start++;
}
return str.substring(start);
}
/**
* 将短字符串ID转换回长整数ID
*
* @param shortId 短字符串ID
* @return 原始的长整数ID
*/
public static long decode(String shortId) {
if (shortId == null || shortId.isEmpty()) {
throw new IllegalArgumentException("字符ID不能为空");
}
long id = 0;
// 将Base62字符串转换为长整数
for (int i = 0; i < shortId.length(); i++) {
char c = shortId.charAt(i);
if (!CHAR_INDEX_MAP.containsKey(c)) {
throw new IllegalArgumentException("输入字符串中的字符无效: " + c);
}
int digit = CHAR_INDEX_MAP.get(c);
id = id * BASE + digit;
}
return id;
}
// 测试示例
public static void main(String[] args) {
log.info("123456,短字符串:{}", encode(123456));
log.info("123456,短字符串:{}", encodeFixed(123, 8));
long[] testIds = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 123456789L, 9876543210L, 1234567890123456789L,
999999999999999999L};
log.info("=== 用例测试1 ===");
for (long id : testIds) {
String shortId = encode(id);
long decodedId = decode(shortId);
log.info("原始ID: {} -> 短字符串: {} -> 解码ID:{} (匹配:{})", id, shortId, decodedId, (id == decodedId));
}
log.info("=== 用例测试2 固定长度编码(默认8位)===");
for (long id : testIds) {
try {
String fixedId = encodeFixed(id);
long decodedId = decodeFromFixed(fixedId);
log.info("原始ID: {} -> 固定长度字符串[{}]: {} -> 解码ID: {} (匹配: {} )", id, fixedId.length(), fixedId, decodedId, (id == decodedId));
} catch (IllegalArgumentException e) {
log.info("原始ID: {} -> 错误: {}", id, e.getMessage());
}
}
}
}
2.hashids-java
2.1项目地址
https://github.com/yomorun/hashids-java
2.2源码
java
package org.hashids;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Hashids designed for Generating short hashes from numbers (like YouTube and Bitly), obfuscate
* database IDs, use them as forgotten password hashes, invitation codes, store shard numbers.
* <p>
* This is implementation of http://hashids.org v1.0.0 version.
*
* This implementation is immutable, thread-safe, no lock is necessary.
*
* @author <a href="mailto:fanweixiao@gmail.com">fanweixiao</a>
* @author <a href="mailto:terciofilho@gmail.com">Tercio Gaudencio Filho</a>
* @since 0.3.3
*/
public class Hashids {
/**
* Max number that can be encoded with Hashids.
*/
public static final long MAX_NUMBER = 9007199254740992L;
private static final String DEFAULT_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
private static final String DEFAULT_SEPS = "cfhistuCFHISTU";
private static final String DEFAULT_SALT = "";
private static final int DEFAULT_MIN_HASH_LENGTH = 0;
private static final int MIN_ALPHABET_LENGTH = 16;
private static final double SEP_DIV = 3.5;
private static final int GUARD_DIV = 12;
private final String salt;
private final int minHashLength;
private final String alphabet;
private final String seps;
private final String guards;
public Hashids() {
this(DEFAULT_SALT);
}
public Hashids(String salt) {
this(salt, 0);
}
public Hashids(String salt, int minHashLength) {
this(salt, minHashLength, DEFAULT_ALPHABET);
}
public Hashids(String salt, int minHashLength, String alphabet) {
this.salt = salt != null ? salt : DEFAULT_SALT;
this.minHashLength = minHashLength > 0 ? minHashLength : DEFAULT_MIN_HASH_LENGTH;
final StringBuilder uniqueAlphabet = new StringBuilder();
for (int i = 0; i < alphabet.length(); i++) {
if (uniqueAlphabet.indexOf(String.valueOf(alphabet.charAt(i))) == -1) {
uniqueAlphabet.append(alphabet.charAt(i));
}
}
alphabet = uniqueAlphabet.toString();
if (alphabet.length() < MIN_ALPHABET_LENGTH) {
throw new IllegalArgumentException(
"alphabet must contain at least " + MIN_ALPHABET_LENGTH + " unique characters");
}
if (alphabet.contains(" ")) {
throw new IllegalArgumentException("alphabet cannot contains spaces");
}
// seps should contain only characters present in alphabet;
// alphabet should not contains seps
String seps = DEFAULT_SEPS;
for (int i = 0; i < seps.length(); i++) {
final int j = alphabet.indexOf(seps.charAt(i));
if (j == -1) {
seps = seps.substring(0, i) + " " + seps.substring(i + 1);
} else {
alphabet = alphabet.substring(0, j) + " " + alphabet.substring(j + 1);
}
}
alphabet = alphabet.replaceAll("\\s+", "");
seps = seps.replaceAll("\\s+", "");
seps = Hashids.consistentShuffle(seps, this.salt);
if ((seps.isEmpty()) || (((float) alphabet.length() / seps.length()) > SEP_DIV)) {
int seps_len = (int) Math.ceil(alphabet.length() / SEP_DIV);
if (seps_len == 1) {
seps_len++;
}
if (seps_len > seps.length()) {
final int diff = seps_len - seps.length();
seps += alphabet.substring(0, diff);
alphabet = alphabet.substring(diff);
} else {
seps = seps.substring(0, seps_len);
}
}
alphabet = Hashids.consistentShuffle(alphabet, this.salt);
// use double to round up
final int guardCount = (int) Math.ceil((double) alphabet.length() / GUARD_DIV);
String guards;
if (alphabet.length() < 3) {
guards = seps.substring(0, guardCount);
seps = seps.substring(guardCount);
} else {
guards = alphabet.substring(0, guardCount);
alphabet = alphabet.substring(guardCount);
}
this.guards = guards;
this.alphabet = alphabet;
this.seps = seps;
}
/**
* Encode numbers to string
*
* @param numbers
* the numbers to encode
* @return the encoded string
*/
public String encode(long... numbers) {
if (numbers.length == 0) {
return "";
}
for (final long number : numbers) {
if (number < 0) {
return "";
}
if (number > MAX_NUMBER) {
throw new IllegalArgumentException("number can not be greater than " + MAX_NUMBER + "L");
}
}
return this._encode(numbers);
}
/**
* Decode string to numbers
*
* @param hash
* the encoded string
* @return decoded numbers
*/
public long[] decode(String hash) {
if (hash.isEmpty()) {
return new long[0];
}
String validChars = this.alphabet + this.guards + this.seps;
for (int i = 0; i < hash.length(); i++) {
if(validChars.indexOf(hash.charAt(i)) == -1) {
return new long[0];
}
}
return this._decode(hash, this.alphabet);
}
/**
* Encode hexa to string
*
* @param hexa
* the hexa to encode
* @return the encoded string
*/
public String encodeHex(String hexa) {
if (!hexa.matches("^[0-9a-fA-F]+$")) {
return "";
}
final List<Long> matched = new ArrayList<Long>();
final Matcher matcher = Pattern.compile("[\\w\\W]{1,12}").matcher(hexa);
while (matcher.find()) {
matched.add(Long.parseLong("1" + matcher.group(), 16));
}
// conversion
final long[] result = new long[matched.size()];
for (int i = 0; i < matched.size(); i++) {
result[i] = matched.get(i);
}
return this.encode(result);
}
/**
* Decode string to numbers
*
* @param hash
* the encoded string
* @return decoded numbers
*/
public String decodeHex(String hash) {
final StringBuilder result = new StringBuilder();
final long[] numbers = this.decode(hash);
for (final long number : numbers) {
result.append(Long.toHexString(number).substring(1));
}
return result.toString();
}
public static int checkedCast(long value) {
final int result = (int) value;
if (result != value) {
// don't use checkArgument here, to avoid boxing
throw new IllegalArgumentException("Out of range: " + value);
}
return result;
}
/* Private methods */
private String _encode(long... numbers) {
long numberHashInt = 0;
for (int i = 0; i < numbers.length; i++) {
numberHashInt += (numbers[i] % (i + 100));
}
String alphabet = this.alphabet;
final char ret = alphabet.charAt((int) (numberHashInt % alphabet.length()));
long num;
long sepsIndex, guardIndex;
String buffer;
final StringBuilder ret_strB = new StringBuilder(this.minHashLength);
ret_strB.append(ret);
char guard;
for (int i = 0; i < numbers.length; i++) {
num = numbers[i];
buffer = ret + this.salt + alphabet;
alphabet = Hashids.consistentShuffle(alphabet, buffer.substring(0, alphabet.length()));
final String last = Hashids.hash(num, alphabet);
ret_strB.append(last);
if (i + 1 < numbers.length) {
if (last.length() > 0) {
num %= (last.charAt(0) + i);
sepsIndex = (int) (num % this.seps.length());
} else {
sepsIndex = 0;
}
ret_strB.append(this.seps.charAt((int) sepsIndex));
}
}
String ret_str = ret_strB.toString();
if (ret_str.length() < this.minHashLength) {
guardIndex = (numberHashInt + (ret_str.charAt(0))) % this.guards.length();
guard = this.guards.charAt((int) guardIndex);
ret_str = guard + ret_str;
if (ret_str.length() < this.minHashLength) {
guardIndex = (numberHashInt + (ret_str.charAt(2))) % this.guards.length();
guard = this.guards.charAt((int) guardIndex);
ret_str += guard;
}
}
final int halfLen = alphabet.length() / 2;
while (ret_str.length() < this.minHashLength) {
alphabet = Hashids.consistentShuffle(alphabet, alphabet);
ret_str = alphabet.substring(halfLen) + ret_str + alphabet.substring(0, halfLen);
final int excess = ret_str.length() - this.minHashLength;
if (excess > 0) {
final int start_pos = excess / 2;
ret_str = ret_str.substring(start_pos, start_pos + this.minHashLength);
}
}
return ret_str;
}
private long[] _decode(String hash, String alphabet) {
final ArrayList<Long> ret = new ArrayList<Long>();
int i = 0;
final String regexp = "[" + this.guards + "]";
String hashBreakdown = hash.replaceAll(regexp, " ");
String[] hashArray = hashBreakdown.split(" ");
if (hashArray.length == 3 || hashArray.length == 2) {
i = 1;
}
if (hashArray.length > 0) {
hashBreakdown = hashArray[i];
if (!hashBreakdown.isEmpty()) {
final char lottery = hashBreakdown.charAt(0);
hashBreakdown = hashBreakdown.substring(1);
hashBreakdown = hashBreakdown.replaceAll("[" + this.seps + "]", " ");
hashArray = hashBreakdown.split(" ");
String subHash, buffer;
for (final String aHashArray : hashArray) {
subHash = aHashArray;
buffer = lottery + this.salt + alphabet;
alphabet = Hashids.consistentShuffle(alphabet, buffer.substring(0, alphabet.length()));
ret.add(Hashids.unhash(subHash, alphabet));
}
}
}
// transform from List<Long> to long[]
long[] arr = new long[ret.size()];
for (int k = 0; k < arr.length; k++) {
arr[k] = ret.get(k);
}
if (!this.encode(arr).equals(hash)) {
arr = new long[0];
}
return arr;
}
private static String consistentShuffle(String alphabet, String salt) {
if (salt.length() <= 0) {
return alphabet;
}
int asc_val, j;
final char[] tmpArr = alphabet.toCharArray();
for (int i = tmpArr.length - 1, v = 0, p = 0; i > 0; i--, v++) {
v %= salt.length();
asc_val = salt.charAt(v);
p += asc_val;
j = (asc_val + v + p) % i;
final char tmp = tmpArr[j];
tmpArr[j] = tmpArr[i];
tmpArr[i] = tmp;
}
return new String(tmpArr);
}
private static String hash(long input, String alphabet) {
String hash = "";
final int alphabetLen = alphabet.length();
do {
final int index = (int) (input % alphabetLen);
if (index >= 0 && index < alphabet.length()) {
hash = alphabet.charAt(index) + hash;
}
input /= alphabetLen;
} while (input > 0);
return hash;
}
private static Long unhash(String input, String alphabet) {
long number = 0, pos;
for (int i = 0; i < input.length(); i++) {
pos = alphabet.indexOf(input.charAt(i));
number = number * alphabet.length() + pos;
}
return number;
}
/**
* Get Hashid algorithm version.
*
* @return Hashids algorithm version implemented.
*/
public String getVersion() {
return "1.0.0";
}
}
What is it?
hashids (Hash ID's) creates short, unique, decodable hashes from unsigned (long) integers.
It was designed for websites to use in URL shortening, tracking stuff, or making pages private (or at least unguessable).
This algorithm tries to satisfy the following requirements:
- Hashes must be unique and decodable.
- They should be able to contain more than one integer (so you can use them in complex or clustered systems).
- You should be able to specify minimum hash length.
- Hashes should not contain basic English curse words (since they are meant to appear in public places - like the URL).
Instead of showing items as 1, 2, or 3, you could show them as U6dc, u87U, and HMou. You don't have to store these hashes in the database, but can encode + decode on the fly.
All (long) integers need to be greater than or equal to zero.
Usage
Add the dependency
hashids is available in Maven Central. If you are using Maven, add the following dependency to your pom.xml's dependencies:
xml
<dependency>
<groupId>org.hashids</groupId>
<artifactId>hashids</artifactId>
<version>1.0.3</version>
</dependency>
Alternatively, if you use gradle or are on android, add the following to your app's build.gradle file under dependencies:
groovy
compile 'org.hashids:hashids:1.0.3'
Import the package
import org.hashids;
Encoding one number
You can pass a unique salt value so your hashes differ from everyone else's. I use "this is my salt" as an example.
Hashids hashids = new Hashids("this is my salt");
String hash = hashids.encode(12345L);
hash is now going to be:
NkK9
Decoding
Notice during decoding, same salt value is used:
Hashids hashids = new Hashids("this is my salt");
long[] numbers = hashids.decode("NkK9");
numbers is now going to be:
[ 12345 ]
Decoding with different salt
Decoding will not work if salt is changed:
java
Hashids hashids = new Hashids("this is my pepper");
long[] numbers = hashids.decode("NkK9");
numbers is now going to be:
java
[]
Encoding several numbers
java
Hashids hashids = new Hashids("this is my salt");
String hash = hashids.encode(683L, 94108L, 123L, 5L);
hash is now going to be:
aBMswoO2UB3Sj
Decoding is done the same way
java
Hashids hashids = new Hashids("this is my salt");
long[] numbers = hashids.decode("aBMswoO2UB3Sj");
numbers is now going to be:
java
[ 683, 94108, 123, 5 ]
Encoding and specifying minimum hash length
Here we encode integer 1, and set the minimum hash length to 8 (by default it's 0 -- meaning hashes will be the shortest possible length).
java
Hashids hashids = new Hashids("this is my salt", 8);
String hash = hashids.encode(1L);
hash is now going to be:
java
gB0NV05e
Decoding
java
Hashids hashids = new Hashids("this is my salt", 8);
long[] numbers = hashids.decode("gB0NV05e");
numbers is now going to be:
java
[ 1 ]
Specifying custom hash alphabet
Here we set the alphabet to consist of only six letters: "0123456789abcdef"
java
Hashids hashids = new Hashids("this is my salt", 0, "0123456789abcdef");
String hash = hashids.encode(1234567L);
hash is now going to be:
java
b332db5
Encoding and decoding "MongoDB" ids
In addition to encoding and decoding long values Hashids provides functionality for encoding and decoding ids in a hex notation such as object ids generated by MongoDB.
java
Hashids hashids = new Hashids("This is my salt");
String hash = hashids.encodeHex("507f1f77bcf86cd799439011"); // goMYDnAezwurPKWKKxL2
String objectId = hashids.decodeHex(hash); // 507f1f77bcf86cd799439011
Note that the algorithm used for encoding and decoding hex values is not compatible with the algorthm for encoding and decoding long values. That means that you cannot use decodeHex to extract a hex representation of a long id that was encoded with encode.
Randomness
The primary purpose of hashids is to obfuscate ids. It's not meant or tested to be used for security purposes or compression. Having said that, this algorithm does try to make these hashes unguessable and unpredictable:
Repeating numbers
java
Hashids hashids = new Hashids("this is my salt");
String hash = hashids.encode(5L, 5L, 5L, 5L);
You don't see any repeating patterns that might show there's 4 identical numbers in the hash:
java
1Wc8cwcE
Same with incremented numbers:
java
Hashids hashids = new Hashids("this is my salt");
String hash = hashids.encode(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L);
hash will be :
java
kRHnurhptKcjIDTWC3sx
Incrementing number hashes:
java
Hashids hashids = new Hashids("this is my salt");
String hash1 = hashids.encode(1L); /* NV */
String hash2 = hashids.encode(2L); /* 6m */
String hash3 = hashids.encode(3L); /* yD */
String hash4 = hashids.encode(4L); /* 2l */
String hash5 = hashids.encode(5L); /* rD */
Bad hashes
I wrote this class with the intent of placing these hashes in visible places - like the URL. If I create a unique hash for each user, it would be unfortunate if the hash ended up accidentally being a bad word. Imagine auto-creating a URL with hash for your user that looks like this - http://example.com/user/a**hole
Therefore, this algorithm tries to avoid generating most common English curse words with the default alphabet. This is done by never placing the following letters next to each other:
java
c, C, s, S, f, F, h, H, u, U, i, I, t, T
Limitations
The original and reference implementation is the JS (Hashids Website) version. JS number limitation is (2^53 - 1). Our java implementation uses Long, but is limited to the same limits JS is, for the sake of compatibility. If a bigger number is provided, an IllegalArgumentException will be thrown.
java
//最大id不可以超过这个值
public static final long MAX_NUMBER = 9007199254740992L;
3.sqids-java
3.1官网地址
https://sqids.org/zh
https://sqids.org
3.1项目地址
https://github.com/sqids/sqids-java
3.2源码
java
package org.sqids;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Sqids is designed to generate short YouTube-looking IDs from numbers.
* <p>
* This is the Java implementation of https://github.com/sqids/sqids-spec.
*
* This implementation is immutable and thread-safe, no lock is necessary.
*/
public class Sqids {
/**
* The minimum allowable length of the alphabet used for encoding and
* decoding Sqids.
*/
public static final int MIN_ALPHABET_LENGTH = 3;
/**
* The maximum allowable minimum length of an encoded Sqid.
*/
public static final int MIN_LENGTH_LIMIT = 255;
/**
* The minimum length of blocked words in the block list. Any words shorter
* than the minimum are ignored.
*/
public static final int MIN_BLOCK_LIST_WORD_LENGTH = 3;
private final String alphabet;
private final int alphabetLength;
private final int minLength;
private final Set<String> blockList;
private Sqids(final Builder builder) {
final String alphabet = builder.alphabet;
final int alphabetLength = alphabet.length();
final int minLength = builder.minLength;
final Set<String> blockList = new HashSet<>(builder.blockList);
if (alphabet.getBytes().length != alphabetLength) {
throw new IllegalArgumentException("Alphabet cannot contain multibyte characters");
}
if (alphabetLength < MIN_ALPHABET_LENGTH) {
throw new IllegalArgumentException("Alphabet length must be at least " + MIN_ALPHABET_LENGTH);
}
if (new HashSet<>(Arrays.asList(alphabet.split(""))).size() != alphabetLength) {
throw new IllegalArgumentException("Alphabet must contain unique characters");
}
if (minLength < 0 || minLength > MIN_LENGTH_LIMIT) {
throw new IllegalArgumentException("Minimum length has to be between 0 and " + MIN_LENGTH_LIMIT);
}
final Set<String> filteredBlockList = new HashSet<>();
final List<String> alphabetChars = new ArrayList<>(Arrays.asList(alphabet.toLowerCase().split("")));
for (String word : blockList) {
if (word.length() >= MIN_BLOCK_LIST_WORD_LENGTH) {
word = word.toLowerCase();
List<String> wordChars = Arrays.asList(word.split(""));
List<String> intersection = new ArrayList<>(wordChars);
intersection.retainAll(alphabetChars);
if (intersection.size() == wordChars.size()) {
filteredBlockList.add(word);
}
}
}
this.alphabet = this.shuffle(alphabet);
this.alphabetLength = this.alphabet.length();
this.minLength = minLength;
this.blockList = filteredBlockList;
}
/**
* Generate a Sqids' Builder.
*
* @return New Builder instance.
*/
public static Builder builder() {
return new Builder();
}
/**
* Encode a list of numbers to a Sqids ID.
*
* @param numbers Numbers to encode.
* @return Sqids ID.
*/
public String encode(final List<Long> numbers) {
if (numbers.isEmpty()) {
return "";
}
for (Long num : numbers) {
if (num < 0) {
throw new RuntimeException("Encoding supports numbers between 0 and " + Long.MAX_VALUE);
}
}
return encodeNumbers(numbers);
}
/**
* Decode a Sqids ID back to numbers.
*
* @param id ID to decode.
* @return List of decoded numbers.
*/
public List<Long> decode(final String id) {
List<Long> ret = new ArrayList<>();
if (id.isEmpty()) {
return ret;
}
final char[] alphabetChars = this.alphabet.toCharArray();
Set<Character> alphabetSet = new HashSet<>();
for (final char c : alphabetChars) {
alphabetSet.add(c);
}
for (final char c : id.toCharArray()) {
if (!alphabetSet.contains(c)) {
return ret;
}
}
final char prefix = id.charAt(0);
final int offset = this.alphabet.indexOf(prefix);
String alphabet = new StringBuilder(this.alphabet.substring(offset))
.append(this.alphabet, 0, offset)
.reverse()
.toString();
int index = 1;
while (true) {
final char separator = alphabet.charAt(0);
int separatorIndex = id.indexOf(separator, index);
if (separatorIndex == -1) {
separatorIndex = id.length();
} else if (index == separatorIndex) {
break;
}
ret.add(toNumber(id, index, separatorIndex, alphabet.substring(1)));
index = separatorIndex + 1;
if (index < id.length()) {
alphabet = shuffle(alphabet);
} else {
break;
}
}
return ret;
}
private String encodeNumbers(final List<Long> numbers) {
return this.encodeNumbers(numbers, 0);
}
private String encodeNumbers(final List<Long> numbers, final int increment) {
if (increment > this.alphabetLength) {
throw new RuntimeException("Reached max attempts to re-generate the ID");
}
final int numberSize = numbers.size();
long offset = numberSize;
for (int i = 0; i < numberSize; i++) {
offset = offset + this.alphabet.charAt((int) (numbers.get(i) % this.alphabetLength)) + i;
}
offset %= this.alphabetLength;
offset = (offset + increment) % this.alphabetLength;
final StringBuilder alphabetB = new StringBuilder(this.alphabet.substring((int) offset))
.append(this.alphabet, 0, (int) offset);
final char prefix = alphabetB.charAt(0);
String alphabet = alphabetB.reverse().toString();
final StringBuilder id = new StringBuilder().append(prefix);
for (int i = 0; i < numberSize; i++) {
final long num = numbers.get(i);
id.append(toId(num, alphabet.substring(1)));
if (i < numberSize - 1) {
id.append(alphabet.charAt(0));
alphabet = shuffle(alphabet);
}
}
if (this.minLength > id.length()) {
id.append(alphabet.charAt(0));
while (this.minLength - id.length() > 0) {
alphabet = shuffle(alphabet);
id.append(alphabet, 0, Math.min(this.minLength - id.length(), alphabet.length()));
}
}
if (isBlockedId(id.toString())) {
id.setLength(0);
id.append(encodeNumbers(numbers, increment + 1));
}
return id.toString();
}
private String shuffle(final String alphabet) {
char[] chars = alphabet.toCharArray();
int charLength = chars.length;
for (int i = 0, j = charLength - 1; j > 0; i++, j--) {
int r = (i * j + chars[i] + chars[j]) % charLength;
char temp = chars[i];
chars[i] = chars[r];
chars[r] = temp;
}
return new String(chars);
}
private StringBuilder toId(long num, final String alphabet) {
StringBuilder id = new StringBuilder();
char[] chars = alphabet.toCharArray();
int charLength = chars.length;
do {
id.append(chars[(int) (num % charLength)]);
num /= charLength;
} while (num > 0);
return id.reverse();
}
private long toNumber(final String id, final int fromInclusive, final int toExclusive, final String alphabet) {
int alphabetLength = alphabet.length();
long number = 0;
for (int i = fromInclusive; i < toExclusive; i++) {
char c = id.charAt(i);
number = number * alphabetLength + alphabet.indexOf(c);
}
return number;
}
private boolean isBlockedId(final String id) {
final String lowercaseId = id.toLowerCase();
final int lowercaseIdLength = lowercaseId.length();
for (String word : this.blockList) {
if (word.length() <= lowercaseIdLength) {
if (lowercaseIdLength <= 3 || word.length() <= 3) {
if (lowercaseId.equals(word)) {
return true;
}
} else if (Character.isDigit(word.charAt(0))) {
if (lowercaseId.startsWith(word) || lowercaseId.endsWith(word)) {
return true;
}
} else if (lowercaseId.contains(word)) {
return true;
}
}
}
return false;
}
/**
* Default Sqids' {@code Builder}.
*/
public static final class Builder {
/**
* Default Alphabet used by {@code Builder}.
*/
public static final String DEFAULT_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
/**
* Default Minimum length used by {@code Builder}.
*/
public static final int DEFAULT_MIN_LENGTH = 0;
/**
* Default Block list used by {@code Builder}.
*
* Note: This is a Immutable Set.
*/
public static final Set<String> DEFAULT_BLOCK_LIST = Collections.unmodifiableSet(Stream.of(
"1d10t",
"1d1ot",
"1di0t",
"1diot",
"1eccacu10",
"1eccacu1o",
"1eccacul0",
"1eccaculo",
"1mbec11e",
"1mbec1le",
"1mbeci1e",
"1mbecile",
"a11upat0",
"a11upato",
"a1lupat0",
"a1lupato",
"aand",
"ah01e",
"ah0le",
"aho1e",
"ahole",
"al1upat0",
"al1upato",
"allupat0",
"allupato",
"ana1",
"ana1e",
"anal",
"anale",
"anus",
"arrapat0",
"arrapato",
"arsch",
"arse",
"ass",
"b00b",
"b00be",
"b01ata",
"b0ceta",
"b0iata",
"b0ob",
"b0obe",
"b0sta",
"b1tch",
"b1te",
"b1tte",
"ba1atkar",
"balatkar",
"bastard0",
"bastardo",
"batt0na",
"battona",
"bitch",
"bite",
"bitte",
"bo0b",
"bo0be",
"bo1ata",
"boceta",
"boiata",
"boob",
"boobe",
"bosta",
"bran1age",
"bran1er",
"bran1ette",
"bran1eur",
"bran1euse",
"branlage",
"branler",
"branlette",
"branleur",
"branleuse",
"c0ck",
"c0g110ne",
"c0g11one",
"c0g1i0ne",
"c0g1ione",
"c0gl10ne",
"c0gl1one",
"c0gli0ne",
"c0glione",
"c0na",
"c0nnard",
"c0nnasse",
"c0nne",
"c0u111es",
"c0u11les",
"c0u1l1es",
"c0u1lles",
"c0ui11es",
"c0ui1les",
"c0uil1es",
"c0uilles",
"c11t",
"c11t0",
"c11to",
"c1it",
"c1it0",
"c1ito",
"cabr0n",
"cabra0",
"cabrao",
"cabron",
"caca",
"cacca",
"cacete",
"cagante",
"cagar",
"cagare",
"cagna",
"cara1h0",
"cara1ho",
"caracu10",
"caracu1o",
"caracul0",
"caraculo",
"caralh0",
"caralho",
"cazz0",
"cazz1mma",
"cazzata",
"cazzimma",
"cazzo",
"ch00t1a",
"ch00t1ya",
"ch00tia",
"ch00tiya",
"ch0d",
"ch0ot1a",
"ch0ot1ya",
"ch0otia",
"ch0otiya",
"ch1asse",
"ch1avata",
"ch1er",
"ch1ng0",
"ch1ngadaz0s",
"ch1ngadazos",
"ch1ngader1ta",
"ch1ngaderita",
"ch1ngar",
"ch1ngo",
"ch1ngues",
"ch1nk",
"chatte",
"chiasse",
"chiavata",
"chier",
"ching0",
"chingadaz0s",
"chingadazos",
"chingader1ta",
"chingaderita",
"chingar",
"chingo",
"chingues",
"chink",
"cho0t1a",
"cho0t1ya",
"cho0tia",
"cho0tiya",
"chod",
"choot1a",
"choot1ya",
"chootia",
"chootiya",
"cl1t",
"cl1t0",
"cl1to",
"clit",
"clit0",
"clito",
"cock",
"cog110ne",
"cog11one",
"cog1i0ne",
"cog1ione",
"cogl10ne",
"cogl1one",
"cogli0ne",
"coglione",
"cona",
"connard",
"connasse",
"conne",
"cou111es",
"cou11les",
"cou1l1es",
"cou1lles",
"coui11es",
"coui1les",
"couil1es",
"couilles",
"cracker",
"crap",
"cu10",
"cu1att0ne",
"cu1attone",
"cu1er0",
"cu1ero",
"cu1o",
"cul0",
"culatt0ne",
"culattone",
"culer0",
"culero",
"culo",
"cum",
"cunt",
"d11d0",
"d11do",
"d1ck",
"d1ld0",
"d1ldo",
"damn",
"de1ch",
"deich",
"depp",
"di1d0",
"di1do",
"dick",
"dild0",
"dildo",
"dyke",
"encu1e",
"encule",
"enema",
"enf01re",
"enf0ire",
"enfo1re",
"enfoire",
"estup1d0",
"estup1do",
"estupid0",
"estupido",
"etr0n",
"etron",
"f0da",
"f0der",
"f0ttere",
"f0tters1",
"f0ttersi",
"f0tze",
"f0utre",
"f1ca",
"f1cker",
"f1ga",
"fag",
"fica",
"ficker",
"figa",
"foda",
"foder",
"fottere",
"fotters1",
"fottersi",
"fotze",
"foutre",
"fr0c10",
"fr0c1o",
"fr0ci0",
"fr0cio",
"fr0sc10",
"fr0sc1o",
"fr0sci0",
"fr0scio",
"froc10",
"froc1o",
"froci0",
"frocio",
"frosc10",
"frosc1o",
"frosci0",
"froscio",
"fuck",
"g00",
"g0o",
"g0u1ne",
"g0uine",
"gandu",
"go0",
"goo",
"gou1ne",
"gouine",
"gr0gnasse",
"grognasse",
"haram1",
"harami",
"haramzade",
"hund1n",
"hundin",
"id10t",
"id1ot",
"idi0t",
"idiot",
"imbec11e",
"imbec1le",
"imbeci1e",
"imbecile",
"j1zz",
"jerk",
"jizz",
"k1ke",
"kam1ne",
"kamine",
"kike",
"leccacu10",
"leccacu1o",
"leccacul0",
"leccaculo",
"m1erda",
"m1gn0tta",
"m1gnotta",
"m1nch1a",
"m1nchia",
"m1st",
"mam0n",
"mamahuev0",
"mamahuevo",
"mamon",
"masturbat10n",
"masturbat1on",
"masturbate",
"masturbati0n",
"masturbation",
"merd0s0",
"merd0so",
"merda",
"merde",
"merdos0",
"merdoso",
"mierda",
"mign0tta",
"mignotta",
"minch1a",
"minchia",
"mist",
"musch1",
"muschi",
"n1gger",
"neger",
"negr0",
"negre",
"negro",
"nerch1a",
"nerchia",
"nigger",
"orgasm",
"p00p",
"p011a",
"p01la",
"p0l1a",
"p0lla",
"p0mp1n0",
"p0mp1no",
"p0mpin0",
"p0mpino",
"p0op",
"p0rca",
"p0rn",
"p0rra",
"p0uff1asse",
"p0uffiasse",
"p1p1",
"p1pi",
"p1r1a",
"p1rla",
"p1sc10",
"p1sc1o",
"p1sci0",
"p1scio",
"p1sser",
"pa11e",
"pa1le",
"pal1e",
"palle",
"pane1e1r0",
"pane1e1ro",
"pane1eir0",
"pane1eiro",
"panele1r0",
"panele1ro",
"paneleir0",
"paneleiro",
"patakha",
"pec0r1na",
"pec0rina",
"pecor1na",
"pecorina",
"pen1s",
"pendej0",
"pendejo",
"penis",
"pip1",
"pipi",
"pir1a",
"pirla",
"pisc10",
"pisc1o",
"pisci0",
"piscio",
"pisser",
"po0p",
"po11a",
"po1la",
"pol1a",
"polla",
"pomp1n0",
"pomp1no",
"pompin0",
"pompino",
"poop",
"porca",
"porn",
"porra",
"pouff1asse",
"pouffiasse",
"pr1ck",
"prick",
"pussy",
"put1za",
"puta",
"puta1n",
"putain",
"pute",
"putiza",
"puttana",
"queca",
"r0mp1ba11e",
"r0mp1ba1le",
"r0mp1bal1e",
"r0mp1balle",
"r0mpiba11e",
"r0mpiba1le",
"r0mpibal1e",
"r0mpiballe",
"rand1",
"randi",
"rape",
"recch10ne",
"recch1one",
"recchi0ne",
"recchione",
"retard",
"romp1ba11e",
"romp1ba1le",
"romp1bal1e",
"romp1balle",
"rompiba11e",
"rompiba1le",
"rompibal1e",
"rompiballe",
"ruff1an0",
"ruff1ano",
"ruffian0",
"ruffiano",
"s1ut",
"sa10pe",
"sa1aud",
"sa1ope",
"sacanagem",
"sal0pe",
"salaud",
"salope",
"saugnapf",
"sb0rr0ne",
"sb0rra",
"sb0rrone",
"sbattere",
"sbatters1",
"sbattersi",
"sborr0ne",
"sborra",
"sborrone",
"sc0pare",
"sc0pata",
"sch1ampe",
"sche1se",
"sche1sse",
"scheise",
"scheisse",
"schlampe",
"schwachs1nn1g",
"schwachs1nnig",
"schwachsinn1g",
"schwachsinnig",
"schwanz",
"scopare",
"scopata",
"sexy",
"sh1t",
"shit",
"slut",
"sp0mp1nare",
"sp0mpinare",
"spomp1nare",
"spompinare",
"str0nz0",
"str0nza",
"str0nzo",
"stronz0",
"stronza",
"stronzo",
"stup1d",
"stupid",
"succh1am1",
"succh1ami",
"succhiam1",
"succhiami",
"sucker",
"t0pa",
"tapette",
"test1c1e",
"test1cle",
"testic1e",
"testicle",
"tette",
"topa",
"tr01a",
"tr0ia",
"tr0mbare",
"tr1ng1er",
"tr1ngler",
"tring1er",
"tringler",
"tro1a",
"troia",
"trombare",
"turd",
"twat",
"vaffancu10",
"vaffancu1o",
"vaffancul0",
"vaffanculo",
"vag1na",
"vagina",
"verdammt",
"verga",
"w1chsen",
"wank",
"wichsen",
"x0ch0ta",
"x0chota",
"xana",
"xoch0ta",
"xochota",
"z0cc01a",
"z0cc0la",
"z0cco1a",
"z0ccola",
"z1z1",
"z1zi",
"ziz1",
"zizi",
"zocc01a",
"zocc0la",
"zocco1a",
"zoccola").collect(Collectors.toSet()));
private String alphabet = DEFAULT_ALPHABET;
private int minLength = DEFAULT_MIN_LENGTH;
private Set<String> blockList = DEFAULT_BLOCK_LIST;
/**
* Set {@code Builder}'s alphabet.
*
* @param alphabet The new {@code Builder}'s alphabet
* @return this {@code Builder} object
*/
public Builder alphabet(final String alphabet) {
if (alphabet != null) {
this.alphabet = alphabet;
}
return this;
}
/**
* Set {@code Builder}'s minimum length.
*
* @param minLength The new {@code Builder}'s minimum length.
* @return this {@code Builder} object
*/
public Builder minLength(final int minLength) {
this.minLength = minLength;
return this;
}
/**
* Set {@code Builder}'s block list.
*
* @param blockList The new {@code Builder}'s block list. A copy will be created.
* @return this {@code Builder} object
*/
public Builder blockList(final Set<String> blockList) {
if (blockList != null) {
this.blockList = Collections.unmodifiableSet(new HashSet<>(blockList));
}
return this;
}
/**
* Returns a newly-created {@code Sqids} based on the contents of this {@code Builder}.
*
* @return New Sqids instance.
*/
public Sqids build() {
return new Sqids(this);
}
}
}
What is it?
Sqids (pronounced "squids" ) is a small library that lets you generate unique IDs from numbers. It's good for link shortening, fast & URL-safe ID generation and decoding back into numbers for quicker database lookups.
Features:
- Encode multiple numbers - generate short IDs from one or several non-negative numbers
- Quick decoding - easily decode IDs back into numbers
- Unique IDs - generate unique IDs by shuffling the alphabet once
- ID padding - provide minimum length to make IDs more uniform
- URL safe - auto-generated IDs do not contain common profanity
- Randomized output - Sequential input provides nonconsecutive IDs
- Many implementations - Support for 40+ programming languages
🧰 Use-cases
Good for:
- Generating IDs for public URLs (eg: link shortening)
- Generating IDs for internal systems (eg: event tracking)
- Decoding for quicker database lookups (eg: by primary keys)
Not good for:
- Sensitive data (this is not an encryption library)
- User IDs (can be decoded revealing user count)
System Requirements
Java 8 or higher is required.
🚀 Getting started
Import dependency. If you are using Apache Maven, add the following dependency to your pom.xml's dependencies:
xml
<dependency>
<groupId>org.sqids</groupId>
<artifactId>sqids</artifactId>
<version>0.1.0</version>
</dependency>
Alternatively, if you use Gradle or are on Android, add the following to your app's build.gradle file under dependencies:
groovy
implementation 'org.sqids:sqids:0.1.0'
👩💻 Examples
Import Sqids via:
groovy
import org.sqids.Sqids;
Simple encode & decode:
java
Sqids sqids=Sqids.builder().build();
String id=sqids.encode(Arrays.asList(1L,2L,3L)); // "86Rf07"
List<Long> numbers=sqids.decode(id); // [1, 2, 3]
Note 🚧 Because of the algorithm's design, multiple IDs can decode back into the same sequence of numbers. If it's important to your design that IDs are canonical, you have to manually re-encode decoded numbers and check that the generated ID matches.
Enforce a minimum length for IDs:
java
Sqids sqids=Sqids.builder()
.minLength(10)
.build();
String id=sqids.encode(Arrays.asList(1L,2L,3L)); // "86Rf07xd4z"
List<Long> numbers=sqids.decode(id); // [1, 2, 3]
Randomize IDs by providing a custom alphabet:
java
Sqids sqids=Sqids.builder()
.alphabet("FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE")
.build();
String id=sqids.encode(Arrays.asList(1L,2L,3L)); // "B4aajs"
List<Long> numbers=sqids.decode(id); // [1, 2, 3]
Prevent specific words from appearing anywhere in the auto-generated IDs:
java
Sqids sqids=Sqids.builder()
.blockList(new HashSet<>(Arrays.asList("86Rf07")))
.build();
String id=sqids.encode(Arrays.asList(1L,2L,3L)); // "se8ojk"
List<Long> numbers=sqids.decode(id); // [1, 2, 3]
4.Hashids 与 Sqids 的关系与对比选择
4.1 Sqids 与 Hashids 如何选择?
Sqids比 Hashids要快的多,比 Hashids占用内存更少,所以推荐使用Sqids而非 Hashids。
Hashids 与 Sqids 的真实关系解析
官方重定向的事实
访问 **http://hashids.org/ 重定向到 https://sqids.org/?hashids**。这不是偶然,而是项目创建者的明确决定。这一重定向揭示了两个项目之间存在直接关联,需要更新我们对两者关系的理解。
真实关系澄清
经过核实最新情况:
- Hashids 的原作者 Ivan Akimov 已将项目移交给了 Sqids 的创建者 Sameer Desai
- Sqids 实际上是 Hashids 的精神继任者和官方推荐替代品
- 重定向中的
?hashids参数是为了识别流量来源并可能提供特定的迁移信息
这不是两个独立项目的竞争关系,而是同一个项目演进的新阶段。原 Hashids 作者认可了 Sqids 的设计改进并推荐用户迁移。
项目演进时间线
- 2012-2022: Hashids 由 Ivan Akimov 维护,成为ID混淆领域的标准工具
- 2022年底: Ivan Akimov 与 Sameer Desai (Sqids 作者) 开始合作
- 2023年初: Sqids 作为 Hashids 的现代化替代方案发布
- 2023年下半年: 官方决定将 hashids.org 重定向到 sqids.org
- 现在: Sqids 被定位为 Hashids 的官方继任者
为什么选择重定向而非兼容
尽管重定向可能给现有用户带来迁移成本,但这种决定反映了技术上的根本性改进:
Hashids (旧架构) → 决定重构而非修补 → Sqids (新架构)
主要技术原因:
- 算法根本性差异:修复 Hashids 的长整数和排序问题需要完全重写核心算法
- 向后兼容性代价:保持兼容会限制新设计的优化空间
- 清晰的过渡路径:明确的重定向比维护两个相似但不兼容的库更有利于社区
官方迁移立场
Sqids 官方文档明确表态:
"Sqids is the successor to Hashids. While they serve similar purposes, Sqids was rebuilt from the ground up to address fundamental limitations in the Hashids algorithm. We recommend all new projects use Sqids, and existing Hashids users plan a migration path."
("Sqids 是 Hashids 的继任者。虽然它们服务于类似的目的,但 Sqids 是从头开始重建的,以解决 Hashids 算法中的根本性限制。我们建议所有新项目使用 Sqids,现有 Hashids 用户规划迁移路径。")
结论
Hashids 和 Sqids 的关系不是竞争,而是演进 。官方域名重定向清晰地表明了项目方向:Sqids 是 Hashids 的官方继任者。这一转变反映了开源项目的自然生命周期 - 当技术债积累到难以维护的程度时,有时完全重写比持续修补更有利于长期发展。
Hashids 与 Sqids:关键区别解析
澄清误解
首先需要澄清一个重要的误解:Hashids 并没有被 Sqids 取代,它们是两个完全独立的项目 。Hashids 和 Sqids 不仅是不同的库,它们采用了完全不同的算法哲学。Sqids 不是 Hashids 的官方继任者,而是由不同开发者创建的替代解决方案。
项目背景
- Hashids:由 Ivan Akimov 于 2012 年创建,已成为 ID 混淆领域的标准工具
- Sqids:由 Sameer Desai 于 2023 年创建,设计初衷是解决 Hashids 的一些技术限制
核心区别对比表
| 特性 | Hashids | Sqids |
|---|---|---|
| 创建时间 | 2012年 | 2023年 |
| 设计目标 | 生成短、唯一、不可预测的ID | 生成短ID同时避免不良词汇、保持排序特性 |
| 避免不良词 | 需要手动配置或后处理 | 内置自动过滤机制 |
| ID排序特性 | 编码后ID不保持原始排序 | 保持原始ID的自然排序顺序 |
| 大整数处理 | 有整数大小限制 | 无整数大小限制 |
| 版本兼容性 | 不同版本可能产生不同结果 | 保证版本间结果一致 |
| 长度可预测性 | 长度变化较大 | 长度更可预测一致 |
| 多语言支持 | 非常广泛(15+语言) | 快速增长(目前约40+种语言) |
算法差异对比表
| 特性 | Hashids | Sqids |
|---|---|---|
| 核心算法 | 多轮混洗+哈希 | 优化进制转换 |
| 时间复杂度 | O(n²) | O(n) |
| 整数处理 | 32位内部转换 | 任意精度 |
| 盐值作用 | 核心,改变结果结构 | 可选,调整字符分布(官方说是没有这个盐值的参数) |
| 排序特性 | 故意打破原始顺序 | 保持原始顺序 |
| 不良词过滤 | 无内置支持 | 自动检查与规避 |
| 长度可预测性 | 不规则增长 | 线性可预测 |
| 跨版本一致性 | 可能变化 | 严格保证 |
Sqids 与 Hashids 的兼容性分析
明确结论
Sqids 不支持解码旧版本 Hashids 生成的数据。两者使用完全不同的算法和编码机制,彼此之间没有向后兼容性。
详细解释
1. 本质区别
Sqids 是一个全新设计的库,而非 Hashids 的简单重命名:
- Hashids:使用基于盐值(salt)的定制算法,旨在生成不可预测的ID
- Sqids:使用不同的编码机制,重点优化了排序特性、不良词过滤和长度一致性
Sqids 是一个优秀的现代替代方案,解决了 Hashids 的一些技术限制。二者不兼容,没有兼容性可言。
5.hutool中的Hashids
5.1依赖
xml
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.26</version>
</dependency>
5.2源码
java
package cn.hutool.core.codec;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.LongStream;
/**
* <a href="http://hashids.org/">Hashids</a> 协议实现,以实现:
* <ul>
* <li>生成简短、唯一、大小写敏感并无序的hash值</li>
* <li>自然数字的Hash值</li>
* <li>可以设置不同的盐,具有保密性</li>
* <li>可配置的hash长度</li>
* <li>递增的输入产生的输出无法预测</li>
* </ul>
*
* <p>
* 来自:<a href="https://github.com/davidafsilva/java-hashids">https://github.com/davidafsilva/java-hashids</a>
* </p>
*
* <p>
* {@code Hashids}可以将数字或者16进制字符串转为短且唯一不连续的字符串,采用双向编码实现,比如,它可以将347之类的数字转换为yr8之类的字符串,也可以将yr8之类的字符串重新解码为347之类的数字。<br>
* 此编码算法主要是解决爬虫类应用对连续ID爬取问题,将有序的ID转换为无序的Hashids,而且一一对应。
* </p>
*
* @author david
*/
public class Hashids implements Encoder<long[], String>, Decoder<String, long[]> {
private static final int LOTTERY_MOD = 100;
private static final double GUARD_THRESHOLD = 12;
private static final double SEPARATOR_THRESHOLD = 3.5;
// 最小编解码字符串
private static final int MIN_ALPHABET_LENGTH = 16;
private static final Pattern HEX_VALUES_PATTERN = Pattern.compile("[\\w\\W]{1,12}");
// 默认编解码字符串
public static final char[] DEFAULT_ALPHABET = {
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'
};
// 默认分隔符
private static final char[] DEFAULT_SEPARATORS = {
'c', 'f', 'h', 'i', 's', 't', 'u', 'C', 'F', 'H', 'I', 'S', 'T', 'U'
};
// algorithm properties
private final char[] alphabet;
// 多个数字编解码的分界符
private final char[] separators;
private final Set<Character> separatorsSet;
private final char[] salt;
// 补齐至 minLength 长度添加的字符列表
private final char[] guards;
// 编码后最小的字符长度
private final int minLength;
// region create
/**
* 根据参数值,创建{@code Hashids},使用默认{@link #DEFAULT_ALPHABET}作为字母表,不限制最小长度
*
* @param salt 加盐值
* @return {@code Hashids}
*/
public static Hashids create(final char[] salt) {
return create(salt, DEFAULT_ALPHABET, -1);
}
/**
* 根据参数值,创建{@code Hashids},使用默认{@link #DEFAULT_ALPHABET}作为字母表
*
* @param salt 加盐值
* @param minLength 限制最小长度,-1表示不限制
* @return {@code Hashids}
*/
public static Hashids create(final char[] salt, final int minLength) {
return create(salt, DEFAULT_ALPHABET, minLength);
}
/**
* 根据参数值,创建{@code Hashids}
*
* @param salt 加盐值
* @param alphabet hash字母表
* @param minLength 限制最小长度,-1表示不限制
* @return {@code Hashids}
*/
public static Hashids create(final char[] salt, final char[] alphabet, final int minLength) {
return new Hashids(salt, alphabet, minLength);
}
// endregion
/**
* 构造
*
* @param salt 加盐值
* @param alphabet hash字母表
* @param minLength 限制最小长度,-1表示不限制
*/
public Hashids(final char[] salt, final char[] alphabet, final int minLength) {
this.minLength = minLength;
this.salt = Arrays.copyOf(salt, salt.length);
// filter and shuffle separators
char[] tmpSeparators = shuffle(filterSeparators(DEFAULT_SEPARATORS, alphabet), this.salt);
// validate and filter the alphabet
char[] tmpAlphabet = validateAndFilterAlphabet(alphabet, tmpSeparators);
// check separator threshold
if (tmpSeparators.length == 0 ||
((double) (tmpAlphabet.length / tmpSeparators.length)) > SEPARATOR_THRESHOLD) {
final int minSeparatorsSize = (int) Math.ceil(tmpAlphabet.length / SEPARATOR_THRESHOLD);
// check minimum size of separators
if (minSeparatorsSize > tmpSeparators.length) {
// fill separators from alphabet
final int missingSeparators = minSeparatorsSize - tmpSeparators.length;
tmpSeparators = Arrays.copyOf(tmpSeparators, tmpSeparators.length + missingSeparators);
System.arraycopy(tmpAlphabet, 0, tmpSeparators,
tmpSeparators.length - missingSeparators, missingSeparators);
System.arraycopy(tmpAlphabet, 0, tmpSeparators,
tmpSeparators.length - missingSeparators, missingSeparators);
tmpAlphabet = Arrays.copyOfRange(tmpAlphabet, missingSeparators, tmpAlphabet.length);
}
}
// shuffle the current alphabet
shuffle(tmpAlphabet, this.salt);
// check guards
this.guards = new char[(int) Math.ceil(tmpAlphabet.length / GUARD_THRESHOLD)];
if (alphabet.length < 3) {
System.arraycopy(tmpSeparators, 0, guards, 0, guards.length);
this.separators = Arrays.copyOfRange(tmpSeparators, guards.length, tmpSeparators.length);
this.alphabet = tmpAlphabet;
} else {
System.arraycopy(tmpAlphabet, 0, guards, 0, guards.length);
this.separators = tmpSeparators;
this.alphabet = Arrays.copyOfRange(tmpAlphabet, guards.length, tmpAlphabet.length);
}
// create the separators set
separatorsSet = IntStream.range(0, separators.length)
.mapToObj(idx -> separators[idx])
.collect(Collectors.toSet());
}
/**
* 编码给定的16进制数字
*
* @param hexNumbers 16进制数字
* @return 编码后的值, {@code null} if {@code numbers} 是 {@code null}.
* @throws IllegalArgumentException 数字不支持抛出此异常
*/
public String encodeFromHex(final String hexNumbers) {
if (hexNumbers == null) {
return null;
}
// remove the prefix, if present
final String hex = hexNumbers.startsWith("0x") || hexNumbers.startsWith("0X") ?
hexNumbers.substring(2) : hexNumbers;
// get the associated long value and encode it
LongStream values = LongStream.empty();
final Matcher matcher = HEX_VALUES_PATTERN.matcher(hex);
while (matcher.find()) {
final long value = new BigInteger("1" + matcher.group(), 16).longValue();
values = LongStream.concat(values, LongStream.of(value));
}
return encode(values.toArray());
}
/**
* 编码给定的数字数组
*
* @param numbers 数字数组
* @return 编码后的值, {@code null} if {@code numbers} 是 {@code null}.
* @throws IllegalArgumentException 数字不支持抛出此异常
*/
@Override
public String encode(final long... numbers) {
if (numbers == null) {
return null;
}
// copy alphabet
final char[] currentAlphabet = Arrays.copyOf(alphabet, alphabet.length);
// determine the lottery number
final long lotteryId = LongStream.range(0, numbers.length)
.reduce(0, (state, i) -> {
final long number = numbers[(int) i];
if (number < 0) {
throw new IllegalArgumentException("invalid number: " + number);
}
return state + number % (i + LOTTERY_MOD);
});
final char lottery = currentAlphabet[(int) (lotteryId % currentAlphabet.length)];
// encode each number
final StringBuilder global = new StringBuilder();
IntStream.range(0, numbers.length)
.forEach(idx -> {
// derive alphabet
deriveNewAlphabet(currentAlphabet, salt, lottery);
// encode
final int initialLength = global.length();
translate(numbers[idx], currentAlphabet, global, initialLength);
// prepend the lottery
if (idx == 0) {
global.insert(0, lottery);
}
// append the separator, if more numbers are pending encoding
if (idx + 1 < numbers.length) {
long n = numbers[idx] % (global.charAt(initialLength) + 1);
global.append(separators[(int) (n % separators.length)]);
}
});
// add the guards, if there's any space left
if (minLength > global.length()) {
int guardIdx = (int) ((lotteryId + lottery) % guards.length);
global.insert(0, guards[guardIdx]);
if (minLength > global.length()) {
guardIdx = (int) ((lotteryId + global.charAt(2)) % guards.length);
global.append(guards[guardIdx]);
}
}
// add the necessary padding
int paddingLeft = minLength - global.length();
while (paddingLeft > 0) {
shuffle(currentAlphabet, Arrays.copyOf(currentAlphabet, currentAlphabet.length));
final int alphabetHalfSize = currentAlphabet.length / 2;
final int initialSize = global.length();
if (paddingLeft > currentAlphabet.length) {
// entire alphabet with the current encoding in the middle of it
int offset = alphabetHalfSize + (currentAlphabet.length % 2 == 0 ? 0 : 1);
global.insert(0, currentAlphabet, alphabetHalfSize, offset);
global.insert(offset + initialSize, currentAlphabet, 0, alphabetHalfSize);
// decrease the padding left
paddingLeft -= currentAlphabet.length;
} else {
// calculate the excess
final int excess = currentAlphabet.length + global.length() - minLength;
final int secondHalfStartOffset = alphabetHalfSize + Math.floorDiv(excess, 2);
final int secondHalfLength = currentAlphabet.length - secondHalfStartOffset;
final int firstHalfLength = paddingLeft - secondHalfLength;
global.insert(0, currentAlphabet, secondHalfStartOffset, secondHalfLength);
global.insert(secondHalfLength + initialSize, currentAlphabet, 0, firstHalfLength);
paddingLeft = 0;
}
}
return global.toString();
}
//-------------------------
// Decode
//-------------------------
/**
* 解码Hash值为16进制数字
*
* @param hash hash值
* @return 解码后的16进制值, {@code null} if {@code numbers} 是 {@code null}.
* @throws IllegalArgumentException if the hash is invalid.
*/
public String decodeToHex(final String hash) {
if (hash == null) {
return null;
}
final StringBuilder sb = new StringBuilder();
Arrays.stream(decode(hash))
.mapToObj(Long::toHexString)
.forEach(hex -> sb.append(hex, 1, hex.length()));
return sb.toString();
}
/**
* 解码Hash值为数字数组
*
* @param hash hash值
* @return 解码后的16进制值, {@code null} if {@code numbers} 是 {@code null}.
* @throws IllegalArgumentException if the hash is invalid.
*/
@Override
public long[] decode(final String hash) {
if (hash == null) {
return null;
}
// create a set of the guards
final Set<Character> guardsSet = IntStream.range(0, guards.length)
.mapToObj(idx -> guards[idx])
.collect(Collectors.toSet());
// count the total guards used
final int[] guardsIdx = IntStream.range(0, hash.length())
.filter(idx -> guardsSet.contains(hash.charAt(idx)))
.toArray();
// get the start/end index base on the guards count
final int startIdx, endIdx;
if (guardsIdx.length > 0) {
startIdx = guardsIdx[0] + 1;
endIdx = guardsIdx.length > 1 ? guardsIdx[1] : hash.length();
} else {
startIdx = 0;
endIdx = hash.length();
}
LongStream decoded = LongStream.empty();
// parse the hash
if (hash.length() > 0) {
final char lottery = hash.charAt(startIdx);
// create the initial accumulation string
final int length = hash.length() - guardsIdx.length - 1;
StringBuilder block = new StringBuilder(length);
// create the base salt
final char[] decodeSalt = new char[alphabet.length];
decodeSalt[0] = lottery;
final int saltLength = salt.length >= alphabet.length ? alphabet.length - 1 : salt.length;
System.arraycopy(salt, 0, decodeSalt, 1, saltLength);
final int saltLeft = alphabet.length - saltLength - 1;
// copy alphabet
final char[] currentAlphabet = Arrays.copyOf(alphabet, alphabet.length);
for (int i = startIdx + 1; i < endIdx; i++) {
if (false == separatorsSet.contains(hash.charAt(i))) {
block.append(hash.charAt(i));
// continue if we have not reached the end, yet
if (i < endIdx - 1) {
continue;
}
}
if (block.length() > 0) {
// create the salt
if (saltLeft > 0) {
System.arraycopy(currentAlphabet, 0, decodeSalt,
alphabet.length - saltLeft, saltLeft);
}
// shuffle the alphabet
shuffle(currentAlphabet, decodeSalt);
// prepend the decoded value
final long n = translate(block.toString().toCharArray(), currentAlphabet);
decoded = LongStream.concat(decoded, LongStream.of(n));
// create a new block
block = new StringBuilder(length);
}
}
}
// validate the hash
final long[] decodedValue = decoded.toArray();
if (!Objects.equals(hash, encode(decodedValue))) {
throw new IllegalArgumentException("invalid hash: " + hash);
}
return decodedValue;
}
private StringBuilder translate(final long n, final char[] alphabet,
final StringBuilder sb, final int start) {
long input = n;
do {
// prepend the chosen char
sb.insert(start, alphabet[(int) (input % alphabet.length)]);
// trim the input
input = input / alphabet.length;
} while (input > 0);
return sb;
}
private long translate(final char[] hash, final char[] alphabet) {
long number = 0;
final Map<Character, Integer> alphabetMapping = IntStream.range(0, alphabet.length)
.mapToObj(idx -> new Object[]{alphabet[idx], idx})
.collect(Collectors.groupingBy(arr -> (Character) arr[0],
Collectors.mapping(arr -> (Integer) arr[1],
Collectors.reducing(null, (a, b) -> a == null ? b : a))));
for (int i = 0; i < hash.length; ++i) {
number += alphabetMapping.computeIfAbsent(hash[i], k -> {
throw new IllegalArgumentException("Invalid alphabet for hash");
}) * (long) Math.pow(alphabet.length, hash.length - i - 1);
}
return number;
}
private char[] deriveNewAlphabet(final char[] alphabet, final char[] salt, final char lottery) {
// create the new salt
final char[] newSalt = new char[alphabet.length];
// 1. lottery
newSalt[0] = lottery;
int spaceLeft = newSalt.length - 1;
int offset = 1;
// 2. salt
if (salt.length > 0 && spaceLeft > 0) {
int length = Math.min(salt.length, spaceLeft);
System.arraycopy(salt, 0, newSalt, offset, length);
spaceLeft -= length;
offset += length;
}
// 3. alphabet
if (spaceLeft > 0) {
System.arraycopy(alphabet, 0, newSalt, offset, spaceLeft);
}
// shuffle
return shuffle(alphabet, newSalt);
}
private char[] validateAndFilterAlphabet(final char[] alphabet, final char[] separators) {
// validate size
if (alphabet.length < MIN_ALPHABET_LENGTH) {
throw new IllegalArgumentException(String.format("alphabet must contain at least %d unique " +
"characters: %d", MIN_ALPHABET_LENGTH, alphabet.length));
}
final Set<Character> seen = new LinkedHashSet<>(alphabet.length);
final Set<Character> invalid = IntStream.range(0, separators.length)
.mapToObj(idx -> separators[idx])
.collect(Collectors.toSet());
// add to seen set (without duplicates)
IntStream.range(0, alphabet.length)
.forEach(i -> {
if (alphabet[i] == ' ') {
throw new IllegalArgumentException(String.format("alphabet must not contain spaces: " +
"index %d", i));
}
final Character c = alphabet[i];
if (!invalid.contains(c)) {
seen.add(c);
}
});
// create a new alphabet without the duplicates
final char[] uniqueAlphabet = new char[seen.size()];
int idx = 0;
for (char c : seen) {
uniqueAlphabet[idx++] = c;
}
return uniqueAlphabet;
}
@SuppressWarnings("SameParameterValue")
private char[] filterSeparators(final char[] separators, final char[] alphabet) {
final Set<Character> valid = IntStream.range(0, alphabet.length)
.mapToObj(idx -> alphabet[idx])
.collect(Collectors.toSet());
return IntStream.range(0, separators.length)
.mapToObj(idx -> (separators[idx]))
.filter(valid::contains)
// ugly way to convert back to char[]
.map(c -> Character.toString(c))
.collect(Collectors.joining())
.toCharArray();
}
private char[] shuffle(final char[] alphabet, final char[] salt) {
for (int i = alphabet.length - 1, v = 0, p = 0, j, z; salt.length > 0 && i > 0; i--, v++) {
v %= salt.length;
p += z = salt[v];
j = (z + v + p) % i;
final char tmp = alphabet[j];
alphabet[j] = alphabet[i];
alphabet[i] = tmp;
}
return alphabet;
}
}
6.总结
上面几种实现的基本思想都是加权求和,只不过每一个的实现的算法上有差别,本次分享到此结束,希望我的分享对你有所启发和帮助,请一键三连,么么么哒!