整数ID与短字符串互转思路及开源实现分享

文章目录

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:

  1. Hashes must be unique and decodable.
  2. They should be able to contain more than one integer (so you can use them in complex or clustered systems).
  3. You should be able to specify minimum hash length.
  4. 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 的设计改进并推荐用户迁移。

项目演进时间线

  1. 2012-2022: Hashids 由 Ivan Akimov 维护,成为ID混淆领域的标准工具
  2. 2022年底: Ivan Akimov 与 Sameer Desai (Sqids 作者) 开始合作
  3. 2023年初: Sqids 作为 Hashids 的现代化替代方案发布
  4. 2023年下半年: 官方决定将 hashids.org 重定向到 sqids.org
  5. 现在: Sqids 被定位为 Hashids 的官方继任者

为什么选择重定向而非兼容

尽管重定向可能给现有用户带来迁移成本,但这种决定反映了技术上的根本性改进:

复制代码
Hashids (旧架构) → 决定重构而非修补 → Sqids (新架构)

主要技术原因:

  1. 算法根本性差异:修复 Hashids 的长整数和排序问题需要完全重写核心算法
  2. 向后兼容性代价:保持兼容会限制新设计的优化空间
  3. 清晰的过渡路径:明确的重定向比维护两个相似但不兼容的库更有利于社区

官方迁移立场

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.总结

上面几种实现的基本思想都是加权求和,只不过每一个的实现的算法上有差别,本次分享到此结束,希望我的分享对你有所启发和帮助,请一键三连,么么么哒!

相关推荐
李少兄2 小时前
解决 org.springframework.context.annotation.ConflictingBeanDefinitionException 报错
java·spring boot·mybatis
benjiangliu2 小时前
LINUX系统-09-程序地址空间
android·java·linux
历程里程碑2 小时前
子串-----和为 K 的子数组
java·数据结构·c++·python·算法·leetcode·tornado
独自破碎E2 小时前
字符串相乘
android·java·jvm
东东5162 小时前
OA自动化居家办公管理系统 ssm+vue
java·前端·vue.js·后端·毕业设计·毕设
没有bug.的程序员2 小时前
Spring Cloud Alibaba:Nacos 配置中心与服务发现的工业级深度实战
java·spring boot·nacos·服务发现·springcloud·配置中心·alibaba
韦东东2 小时前
本地问答系统升级:本地RAG+网络搜索(已开源)
开源·问答系统·本地rag
rainbow68892 小时前
Java并发三要素:原子性、可见性、有序性
java
小罗和阿泽2 小时前
复习 Java(2)
java·开发语言