diff --git a/CHANGELOG.md b/CHANGELOG.md index 619887e0d..0debc4469 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ------------------------------------------------------------------------------------------------------------- -# 5.8.0.M4 (2022-04-24) +# 5.8.0.M4 (2022-04-25) ### ❌不兼容特性 * 【json 】 【可能兼容问题】JSONArray删除部分构造 @@ -19,6 +19,7 @@ * 【core 】 CHINESE_NAME正则条件放宽(pr#599@Gitee) * 【extra 】 增加JakartaServletUtil(issue#2271@Github) * 【poi 】 ExcelWriter支持重复别名的数据写出(issue#I53APY@Gitee) +* 【core 】 增加Hashids(issue#I53APY@Gitee) ### 🐞Bug修复 * 【core 】 修复StrUtil.firstNonX非static问题(issue#2257@Github) diff --git a/hutool-core/src/main/java/cn/hutool/core/codec/Hashids.java b/hutool-core/src/main/java/cn/hutool/core/codec/Hashids.java new file mode 100755 index 000000000..95c97def7 --- /dev/null +++ b/hutool-core/src/main/java/cn/hutool/core/codec/Hashids.java @@ -0,0 +1,506 @@ +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; + +/** + * Hashids 协议实现,以实现: + * + * + *

+ * 来自:https://github.com/davidafsilva/java-hashids + *

+ * + *

+ * {@code Hashids}可以将数字或者16进制字符串转为短且唯一不连续的字符串,采用双向编码实现,比如,它可以将347之类的数字转换为yr8之类的字符串,也可以将yr8之类的字符串重新解码为347之类的数字。
+ * 此编码算法主要是解决爬虫类应用对连续ID爬取问题,将有序的ID转换为无序的Hashids,而且一一对应。 + *

+ * + * @author david + */ +public class Hashids implements Encoder, Decoder { + + 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 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 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 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 seen = new LinkedHashSet<>(alphabet.length); + final Set 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 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; + } +} diff --git a/hutool-core/src/main/java/cn/hutool/core/lang/reflect/LookupFactory.java b/hutool-core/src/main/java/cn/hutool/core/lang/reflect/LookupFactory.java index 48aa5ff88..5ae8392fa 100755 --- a/hutool-core/src/main/java/cn/hutool/core/lang/reflect/LookupFactory.java +++ b/hutool-core/src/main/java/cn/hutool/core/lang/reflect/LookupFactory.java @@ -13,9 +13,7 @@ import java.lang.reflect.Method; * 时会出现权限不够问题,抛出"no private access for invokespecial"异常,因此针对JDK8及JDK9+分别封装lookup方法。 * * 参考: - *
    - *
  • https://blog.csdn.net/u013202238/article/details/108687086
  • - *
+ *

https://blog.csdn.net/u013202238/article/details/108687086

* * @author looly * @since 5.7.7 diff --git a/hutool-core/src/test/java/cn/hutool/core/codec/HashidsTest.java b/hutool-core/src/test/java/cn/hutool/core/codec/HashidsTest.java new file mode 100755 index 000000000..458cde5b1 --- /dev/null +++ b/hutool-core/src/test/java/cn/hutool/core/codec/HashidsTest.java @@ -0,0 +1,20 @@ +package cn.hutool.core.codec; + +import org.junit.Assert; +import org.junit.Test; + +public class HashidsTest { + @Test + public void hexEncodeDecode() { + final Hashids hashids = Hashids.create("my awesome salt".toCharArray()); + final String encoded1 = hashids.encodeFromHex("507f1f77bcf86cd799439011"); + final String encoded2 = hashids.encodeFromHex("0x507f1f77bcf86cd799439011"); + final String encoded3 = hashids.encodeFromHex("0X507f1f77bcf86cd799439011"); + + Assert.assertEquals("R2qnd2vkOJTXm7XV7yq4", encoded1); + Assert.assertEquals(encoded1, encoded2); + Assert.assertEquals(encoded1, encoded3); + final String decoded = hashids.decodeToHex(encoded1); + Assert.assertEquals("507f1f77bcf86cd799439011", decoded); + } +}