diff --git a/README.md b/README.md index 7f72ef0..ef7fa3c 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,15 @@ To speed up process, you can ignore unit tests by using: `-DskipTests=true -Dmav ``` ```java -final CUID cuid = CUID.randomCUID(); +final CUID cuid = CUID.randomCUID1(); System.out.println("CUID: " + cuid); ``` +```java +final CUID cuid = CUID.randomCUID2(); +System.out.println("CUID (Version 2): " + cuid); +``` + ```java final CUID cuid = CUID.fromString("cl9gts1kw00393647w1z4v2tc"); System.out.println("CUID: " + cuid); diff --git a/src/main/java/io/github/thibaultmeyer/cuid/CUID.java b/src/main/java/io/github/thibaultmeyer/cuid/CUID.java index 455fd32..a0901ee 100644 --- a/src/main/java/io/github/thibaultmeyer/cuid/CUID.java +++ b/src/main/java/io/github/thibaultmeyer/cuid/CUID.java @@ -1,6 +1,13 @@ package io.github.thibaultmeyer.cuid; +import io.github.thibaultmeyer.cuid.exception.CUIDGenerationException; + +import java.io.Serializable; import java.lang.management.ManagementFactory; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Objects; @@ -8,30 +15,24 @@ * Collision-resistant ID optimized for horizontal scaling and performance. * * @see CUID official website + * @since 1.0.0 */ -public final class CUID implements java.io.Serializable, Comparable { +public final class CUID implements Serializable, Comparable { // Explicit serialVersionUID for interoperability. private static final long serialVersionUID = -2441709761088574861L; - // CUID configuration. + // Base to use private static final int NUMBER_BASE = 36; - private static final int BLOCK_SIZE = 4; - private static final int CUID_VALUE_LENGTH = 25; - private static final String START_CHARACTER = "c"; - private static final int RANDOM_BUFFER_SIZE = 4096; - - // Counter - private static int counter = 0; - private static int randomBufferIndex = RANDOM_BUFFER_SIZE; // CUID value private final String value; /** - * Build a new instance. + * Creates a new instance. * * @param value A valid CUID value + * @since 1.0.0 */ private CUID(final String value) { @@ -39,103 +40,100 @@ private CUID(final String value) { } /** - * Generates a new random CUID. + * Generates a new random CUID (Version 2). * - * @return Newly generated CUID + * @return Newly generated CUID (Version 2) + * @since 2.0.0 */ - public static CUID randomCUID() { - - final String timestamp = Long.toString(System.currentTimeMillis(), NUMBER_BASE); - final String counter = padWithZero(Integer.toString(nextCounterValue(), NUMBER_BASE), BLOCK_SIZE); - final String random = getRandomBlock() + getRandomBlock(); + public static CUID randomCUID2() { - return new CUID(START_CHARACTER + timestamp + counter + Holder.MACHINE_FINGERPRINT + random); + return randomCUID2(CUIDv2.LENGTH_STANDARD); } /** - * Creates a {@code CUID} from the string standard representation. + * Generates a new random CUID (Version 2). * - * @param cuidAsString A string that specifies a {@code CUID} - * @return A {@code CUID} with the specified value - * @throws IllegalArgumentException If the string is not conform + * @param withBigLength {@code true} to generate a long CUID (Version 2), otherwise, {@code false} + * @return Newly generated CUID (Version 2) + * @since 2.0.0 */ - public static CUID fromString(final String cuidAsString) { - - if (isValid(cuidAsString)) { - return new CUID(cuidAsString); - } + public static CUID randomCUID2(final boolean withBigLength) { - throw new IllegalArgumentException("CUID string is invalid: '" + cuidAsString + "'"); + return randomCUID2(withBigLength ? CUIDv2.LENGTH_BIG : CUIDv2.LENGTH_STANDARD); } /** - * Retrieves the counter next value. + * Generates a new random CUID (Version 2). * - * @return The counter next value + * @param length requested CUID length + * @return Newly generated CUID (Version 2) + * @since 2.0.0 */ - private static synchronized int nextCounterValue() { + private static CUID randomCUID2(final int length) { - counter = counter < Holder.DISCRETE_VALUES ? counter : 0; - return counter++; - } + final String time = Long.toString(System.currentTimeMillis(), NUMBER_BASE); + final char firstLetter = CUIDv2.ALPHABET_ARRAY[Math.abs(Common.nextIntValue()) % CUIDv2.ALPHABET_ARRAY.length]; + final String hash = CUIDv2.computeHash( + time + CUIDv2.createEntropy(length) + CUIDv2.nextCounterValue() + Common.MACHINE_FINGERPRINT, + length); - /** - * Generates a random block of data. - * - * @return Newly generated block of data - */ - private static String getRandomBlock() { - - return padWithZero(Integer.toString(nextIntValue() * Holder.DISCRETE_VALUES, NUMBER_BASE), BLOCK_SIZE); + return new CUID(firstLetter + hash.substring(1, length)); } /** - * Retrieves next random integer value. + * Generates a new random CUID (Version 1). * - * @return A random integer + * @return Newly generated CUID (Version 1) + * @since 2.0.0 */ - private static synchronized int nextIntValue() { + public static CUID randomCUID1() { - if (randomBufferIndex == RANDOM_BUFFER_SIZE) { - Holder.NUMBER_GENERATOR.nextBytes(Holder.RANDOM_BUFFER); - randomBufferIndex = 0; - } + final String timestamp = Long.toString(System.currentTimeMillis(), NUMBER_BASE); + final String counter = Common.padWithZero(Integer.toString(CUIDv1.nextCounterValue(), NUMBER_BASE), CUIDv1.BLOCK_SIZE); + final String random = CUIDv1.getRandomBlock() + CUIDv1.getRandomBlock(); - return Holder.RANDOM_BUFFER[randomBufferIndex++] << 24 - | (Holder.RANDOM_BUFFER[randomBufferIndex++] & 0xff) << 16 - | (Holder.RANDOM_BUFFER[randomBufferIndex++] & 0xff) << 8 - | (Holder.RANDOM_BUFFER[randomBufferIndex++] & 0xff); + return new CUID(CUIDv1.START_CHARACTER + timestamp + counter + Common.MACHINE_FINGERPRINT + random); } /** - * Pads string with leading zero. + * Creates a {@code CUID} from the string standard representation. * - * @param str The string to pad - * @param size The size to keep - * @return The padded string + * @param cuidAsString A string that specifies a {@code CUID} (Version 1 or 2) + * @return A {@code CUID} with the specified value + * @throws IllegalArgumentException If the string is not conform + * @since 1.0.0 */ - private static String padWithZero(final String str, final int size) { + public static CUID fromString(final String cuidAsString) { - final String paddedString = "000000000" + str; - return paddedString.substring(paddedString.length() - size); + if (isValid(cuidAsString)) { + return new CUID(cuidAsString); + } + + throw new IllegalArgumentException("CUID string is invalid: '" + cuidAsString + "'"); } /** * Checks the {@code CUID} from the string standard representation. * - * @param cuidAsString A string that specifies a {@code CUID} + * @param cuidAsString A string that specifies a {@code CUID} (Version 1 or 2) * @return {@code true} If the string is not conform, otherwise, {@code false} + * @since 1.0.0 */ public static boolean isValid(final String cuidAsString) { return cuidAsString != null - && cuidAsString.length() == CUID_VALUE_LENGTH - && cuidAsString.startsWith(START_CHARACTER) + && (cuidAsString.length() == CUIDv1.LENGTH_STANDARD && cuidAsString.startsWith(CUIDv1.START_CHARACTER) // Version 1 + || (cuidAsString.length() == CUIDv2.LENGTH_STANDARD || cuidAsString.length() == CUIDv2.LENGTH_BIG)) // Version 2 && cuidAsString.chars() .filter(c -> !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) .count() == 0; } + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ @Override public int compareTo(final CUID cuid) { @@ -146,12 +144,24 @@ public int compareTo(final CUID cuid) { return this.value.compareTo(cuid.value); } + /** + * Returns the string representation. + * + * @return String containing the {@code CUID} + * @since 1.0.0 + */ @Override public String toString() { return this.value; } + /** + * Returns {@code true} if the argument is equal to current object, {@code false} otherwise. + * + * @param o An object + * @since 1.0.0 + */ @Override public boolean equals(final Object o) { @@ -161,26 +171,186 @@ public boolean equals(final Object o) { return Objects.equals(value, cuid.value); } + /** + * Generates a hash code. + * + * @return Generated hashcode + * @since 1.0.0 + */ @Override public int hashCode() { return Objects.hash(value); } + /** + * CUID Version 1. + * + * @since 1.0.0 + */ + private static final class CUIDv1 { + + // CUID configuration + private static final int BLOCK_SIZE = 4; + private static final int LENGTH_STANDARD = 25; + private static final String START_CHARACTER = "c"; + private static final int DISCRETE_VALUE = (int) Math.pow(NUMBER_BASE, BLOCK_SIZE); + + // Counter + private static int counter = 0; + + /** + * Retrieves the counter next value. + * + * @return The counter next value + * @since 1.0.0 + */ + private static synchronized int nextCounterValue() { + + counter = counter < DISCRETE_VALUE ? counter : 0; + return counter++; + } + + /** + * Generates a random block of data. + * + * @return Newly generated block of data + * @since 1.0.0 + */ + private static String getRandomBlock() { + + return Common.padWithZero(Integer.toString(Common.nextIntValue() * DISCRETE_VALUE, NUMBER_BASE), BLOCK_SIZE); + } + } + + /** + * CUID Version 2. + * + * @since 2.0.0 + */ + private static final class CUIDv2 { + + private static final char[] ALPHABET_ARRAY = new char[]{ + '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'}; + private static final int[] PRIME_NUMBER_ARRAY = new int[]{ + 109717, + 109721, + 109741, + 109751, + 109789, + 109793, + 109807, + 109819, + 109829, + 109831}; + + // CUID configuration + private static final int LENGTH_STANDARD = 24; + private static final int LENGTH_BIG = 32; + + // Counter + private static int counter = Integer.MAX_VALUE; + + /** + * Retrieves the counter next value. + * + * @return The counter next value + */ + private static synchronized int nextCounterValue() { + + counter = counter < Integer.MAX_VALUE ? counter : Math.abs(Common.nextIntValue()); + return counter++; + } + + /** + * Creates an entropy string. + * + * @param length Length of the entropy string + * @return String containing entropy in base {@link CUID#NUMBER_BASE} + */ + private static String createEntropy(final int length) { + + int primeNumber; + final StringBuilder stringBuilder = new StringBuilder(length); + + while (stringBuilder.length() < length) { + primeNumber = PRIME_NUMBER_ARRAY[Math.abs(Common.nextIntValue()) % PRIME_NUMBER_ARRAY.length]; + stringBuilder.append(Integer.toString(primeNumber * Common.nextIntValue(), NUMBER_BASE)); + } + + return stringBuilder.toString(); + } + + /** + * Computes hash. + * + * @return String containing hash + */ + private static String computeHash(final String content, final int saltLength) { + + final String salt = createEntropy(saltLength); + try { + return new BigInteger(MessageDigest.getInstance("SHA3-256").digest((content + salt).getBytes(StandardCharsets.UTF_8))) + .toString(NUMBER_BASE); + } catch (final NoSuchAlgorithmException exception) { + throw new CUIDGenerationException(exception); + } + } + } + /* * Holder class to defer initialization until needed. + * + * @since 1.0.0 */ - private static final class Holder { + private static final class Common { - static final byte[] RANDOM_BUFFER = new byte[RANDOM_BUFFER_SIZE]; - static final SecureRandom NUMBER_GENERATOR = new SecureRandom(); - static final String MACHINE_FINGERPRINT = getMachineFingerprint(); - private static final int DISCRETE_VALUES = (int) Math.pow(NUMBER_BASE, BLOCK_SIZE); + private static final int RANDOM_BUFFER_SIZE = 4096; + private static final byte[] RANDOM_BUFFER = new byte[RANDOM_BUFFER_SIZE]; + private static final SecureRandom NUMBER_GENERATOR = new SecureRandom(); + private static final String MACHINE_FINGERPRINT = getMachineFingerprint(); + + private static int randomBufferIndex = RANDOM_BUFFER_SIZE; + + /** + * Retrieves next random integer value. + * + * @return A random integer + * @since 1.0.0 + */ + private static synchronized int nextIntValue() { + + if (randomBufferIndex == RANDOM_BUFFER_SIZE) { + Common.NUMBER_GENERATOR.nextBytes(Common.RANDOM_BUFFER); + randomBufferIndex = 0; + } + + return Common.RANDOM_BUFFER[randomBufferIndex++] << 24 + | (Common.RANDOM_BUFFER[randomBufferIndex++] & 0xff) << 16 + | (Common.RANDOM_BUFFER[randomBufferIndex++] & 0xff) << 8 + | (Common.RANDOM_BUFFER[randomBufferIndex++] & 0xff); + } + + /** + * Pads string with leading zero. + * + * @param str The string to pad + * @param size The size to keep + * @return The padded string + * @since 1.0.0 + */ + private static String padWithZero(final String str, final int size) { + + final String paddedString = "000000000" + str; + return paddedString.substring(paddedString.length() - size); + } /** * retrieves the machine fingerprint. * * @return The machine fingerprint + * @since 1.0.0 */ private static String getMachineFingerprint() { diff --git a/src/main/java/io/github/thibaultmeyer/cuid/exception/CUIDGenerationException.java b/src/main/java/io/github/thibaultmeyer/cuid/exception/CUIDGenerationException.java new file mode 100644 index 0000000..2cddf3a --- /dev/null +++ b/src/main/java/io/github/thibaultmeyer/cuid/exception/CUIDGenerationException.java @@ -0,0 +1,20 @@ +package io.github.thibaultmeyer.cuid.exception; + +/** + * Exception indicates that the generation of a new CUID has failed. + * + * @since 2.0.0 + */ +public class CUIDGenerationException extends RuntimeException { + + /** + * Creates a new instance. + * + * @param cause Cause of the exception + * @since 2.0.0 + */ + public CUIDGenerationException(final Throwable cause) { + + super("CUID generation failure", cause); + } +} diff --git a/src/test/java/io/github/thibaultmeyer/cuid/CUIDTest.java b/src/test/java/io/github/thibaultmeyer/cuid/CUIDv1Test.java similarity index 68% rename from src/test/java/io/github/thibaultmeyer/cuid/CUIDTest.java rename to src/test/java/io/github/thibaultmeyer/cuid/CUIDv1Test.java index 8490d4a..d6b3208 100644 --- a/src/test/java/io/github/thibaultmeyer/cuid/CUIDTest.java +++ b/src/test/java/io/github/thibaultmeyer/cuid/CUIDv1Test.java @@ -5,14 +5,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; -import java.util.ArrayList; import java.util.HashSet; -import java.util.List; import java.util.Set; -import java.util.UUID; @TestMethodOrder(MethodOrderer.MethodName.class) -final class CUIDTest { +final class CUIDv1Test { @Test void fromString() { @@ -49,7 +46,7 @@ void fromStringInvalid() { void randomCUID() { // Act - final CUID cuid = CUID.randomCUID(); + final CUID cuid = CUID.randomCUID1(); // Assert Assertions.assertNotNull(cuid); @@ -103,42 +100,10 @@ void unicityOver500000() { // Act final Set cuidSet = new HashSet<>(); for (int i = 0; i < 500000; i += 1) { - cuidSet.add(CUID.randomCUID()); + cuidSet.add(CUID.randomCUID1()); } // Assert Assertions.assertEquals(500000, cuidSet.size()); } - - @Test - void speedVersusUUID() { - - System.gc(); - for (int i = 0; i < 10; i += 1) { - UUID.randomUUID(); - } - - final List uuidList = new ArrayList<>(); - final long start2 = System.nanoTime(); - for (int i = 0; i < 1_000_000; i += 1) { - uuidList.add(UUID.randomUUID()); - } - final long end2 = System.nanoTime(); - System.err.println("1,000,000 UUID have been generated in " + (end2 - start2) / 1000000 + " ms"); - uuidList.clear(); - - System.gc(); - for (int i = 0; i < 10; i += 1) { - CUID.randomCUID(); - } - - final List cuidList = new ArrayList<>(); - final long start = System.nanoTime(); - for (int i = 0; i < 1_000_000; i += 1) { - cuidList.add(CUID.randomCUID()); - } - final long end = System.nanoTime(); - System.err.println("1,000,000 CUID have been generated in " + (end - start) / 1000000 + " ms"); - cuidList.clear(); - } } diff --git a/src/test/java/io/github/thibaultmeyer/cuid/CUIDv2Test.java b/src/test/java/io/github/thibaultmeyer/cuid/CUIDv2Test.java new file mode 100644 index 0000000..67ec9f7 --- /dev/null +++ b/src/test/java/io/github/thibaultmeyer/cuid/CUIDv2Test.java @@ -0,0 +1,120 @@ +package io.github.thibaultmeyer.cuid; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.HashSet; +import java.util.Set; + +@TestMethodOrder(MethodOrderer.MethodName.class) +final class CUIDv2Test { + + @Test + void fromString() { + + // Arrange + final String cuidAsString = "n1ht3jch1r23dy9ramd6ts16"; + + // Act + final CUID cuid = CUID.fromString(cuidAsString); + + // Assert + Assertions.assertNotNull(cuid); + Assertions.assertEquals(24, cuid.toString().length()); + Assertions.assertEquals("n1ht3jch1r23dy9ramd6ts16", cuid.toString()); + } + + @Test + void fromStringInvalid() { + + // Arrange + final String cuidAsString = "invalid-cuid"; + + // Act + final IllegalArgumentException exception = Assertions.assertThrows( + IllegalArgumentException.class, + () -> CUID.fromString(cuidAsString)); + + // Assert + Assertions.assertNotNull(exception); + Assertions.assertEquals("CUID string is invalid: 'invalid-cuid'", exception.getMessage()); + } + + @Test + void randomCUIDv2() { + + // Act + final CUID cuid = CUID.randomCUID2(); + + // Assert + Assertions.assertNotNull(cuid); + Assertions.assertEquals(24, cuid.toString().length()); + } + + @Test + void randomCUIDv2BigLength() { + + // Act + final CUID cuid = CUID.randomCUID2(true); + + // Assert + Assertions.assertNotNull(cuid); + Assertions.assertEquals(32, cuid.toString().length()); + } + + @Test + void compareToNotSame() { + + // Arrange + final CUID cuidOne = CUID.fromString("g346rykdwn4m117cupchv9m6"); + final CUID cuidTwo = CUID.fromString("o7ti2h84195cdvxmbx9vb2gg"); + + // Act + final int result = cuidOne.compareTo(cuidTwo); + + // Assert + Assertions.assertEquals(-8, result); + } + + @Test + void compareToNotSameNull() { + + // Arrange + final CUID cuidOne = CUID.fromString("f23nsqjlsmd1oo0kooedsg07"); + + // Act + final int result = cuidOne.compareTo(null); + + // Assert + Assertions.assertEquals(-1, result); + } + + @Test + void compareToSame() { + + // Arrange + final CUID cuidOne = CUID.fromString("z976prixkgxs0u13x7g67fo3"); + final CUID cuidTwo = CUID.fromString("z976prixkgxs0u13x7g67fo3"); + + // Act + final int result = cuidOne.compareTo(cuidTwo); + + // Assert + Assertions.assertEquals(0, result); + } + + @Test + void unicityOver500000() { + + // Act + final Set cuidSet = new HashSet<>(); + for (int i = 0; i < 500000; i += 1) { + cuidSet.add(CUID.randomCUID2()); + } + + // Assert + Assertions.assertEquals(500000, cuidSet.size()); + } +} diff --git a/src/test/java/io/github/thibaultmeyer/cuid/PerformanceTest.java b/src/test/java/io/github/thibaultmeyer/cuid/PerformanceTest.java new file mode 100644 index 0000000..bbbae79 --- /dev/null +++ b/src/test/java/io/github/thibaultmeyer/cuid/PerformanceTest.java @@ -0,0 +1,90 @@ +package io.github.thibaultmeyer.cuid; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@TestMethodOrder(MethodOrderer.MethodName.class) +final class PerformanceTest { + + @BeforeAll + public static void beforeAll() { + + System.gc(); + } + + @Test + void speedUUID() { + + for (int i = 0; i < 10; i += 1) { + UUID.randomUUID(); + } + + final List uuidList = new ArrayList<>(); + final long start = System.nanoTime(); + for (int i = 0; i < 1_000_000; i += 1) { + uuidList.add(UUID.randomUUID()); + } + final long end = System.nanoTime(); + + System.err.println("1,000,000 UUID have been generated in " + (end - start) / 1_000_000 + " ms"); + Assertions.assertEquals(1_000_000, uuidList.size()); + } + + @Test + void speedCUIDv1() { + for (int i = 0; i < 10; i += 1) { + CUID.randomCUID1(); + } + + final List cuidList = new ArrayList<>(); + final long start = System.nanoTime(); + for (int i = 0; i < 1_000_000; i += 1) { + cuidList.add(CUID.randomCUID1()); + } + final long end = System.nanoTime(); + + System.err.println("1,000,000 CUIDv1 have been generated in " + (end - start) / 1_000_000 + " ms"); + Assertions.assertEquals(1_000_000, cuidList.size()); + } + + @Test + void speedCUIDv2Standard() { + for (int i = 0; i < 10; i += 1) { + CUID.randomCUID2(); + } + + final List cuidList = new ArrayList<>(); + final long start = System.nanoTime(); + for (int i = 0; i < 1_000_000; i += 1) { + cuidList.add(CUID.randomCUID2()); + } + final long end = System.nanoTime(); + + System.err.println("1,000,000 CUIDv2 have been generated in " + (end - start) / 1_000_000 + " ms"); + Assertions.assertEquals(1_000_000, cuidList.size()); + } + + @Test + void speedCUIDv2Big() { + for (int i = 0; i < 10; i += 1) { + CUID.randomCUID2(true); + } + + final List cuidList = new ArrayList<>(); + final long start = System.nanoTime(); + for (int i = 0; i < 1_000_000; i += 1) { + cuidList.add(CUID.randomCUID2(true)); + } + final long end = System.nanoTime(); + + System.err.println("1,000,000 CUIDv2 have been generated in " + (end - start) / 1_000_000 + " ms"); + Assertions.assertEquals(1_000_000, cuidList.size()); + } +}