From a49763dd739c3c68c4a8322896d594e926ac8e6b Mon Sep 17 00:00:00 2001 From: Zhineng Li Date: Mon, 5 Jan 2026 16:26:11 +0800 Subject: first commit --- .editorconfig | 10 +++++ .gitattributes | 7 ++++ .gitignore | 5 +++ .php-cs-fixer.dist.php | 19 +++++++++ LICENSE | 21 ++++++++++ README.md | 109 ++++++++++++++++++++++++++++++++++++++++++++++++ composer.json | 44 +++++++++++++++++++ phpstan.neon.dist | 7 ++++ phpunit.xml.dist | 27 ++++++++++++ src/Component.php | 33 +++++++++++++++ src/Constant.php | 36 ++++++++++++++++ src/Field.php | 58 ++++++++++++++++++++++++++ src/Makable.php | 13 ++++++ src/Manager.php | 69 ++++++++++++++++++++++++++++++ src/Sequence.php | 42 +++++++++++++++++++ src/Structure.php | 62 +++++++++++++++++++++++++++ src/Timestamp.php | 38 +++++++++++++++++ tests/ConstantTest.php | 70 +++++++++++++++++++++++++++++++ tests/ManagerTest.php | 70 +++++++++++++++++++++++++++++++ tests/SequenceTest.php | 53 +++++++++++++++++++++++ tests/StructureTest.php | 70 +++++++++++++++++++++++++++++++ tests/TimestampTest.php | 67 +++++++++++++++++++++++++++++ 22 files changed, 930 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Component.php create mode 100644 src/Constant.php create mode 100644 src/Field.php create mode 100644 src/Makable.php create mode 100644 src/Manager.php create mode 100644 src/Sequence.php create mode 100644 src/Structure.php create mode 100644 src/Timestamp.php create mode 100644 tests/ConstantTest.php create mode 100644 tests/ManagerTest.php create mode 100644 tests/SequenceTest.php create mode 100644 tests/StructureTest.php create mode 100644 tests/TimestampTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7183dc9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.php] +charset = utf-8 +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..805f87c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto +/.editorconfig export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a3bb9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.php-cs-fixer.cache +.phpunit.cache/ +/vendor/ +composer.lock +phpunit.xml diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..931a884 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,19 @@ +setParallelConfig(ParallelConfigFactory::detect()) + ->setRiskyAllowed(false) + ->setRules([ + '@auto' => true, + '@PSR12' => true, + 'new_with_parentheses' => [ + 'named_class' => false, + ], + ]) + ->setFinder((new Finder())->in(__DIR__)); diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b91c005 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zhineng Li + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d74b4a --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# Snowflake ID Generator + +A flexible, customizable distributed ID generator inspired by Twitter's +Snowflake, built for PHP 8.2+. + +Unlike traditional implementations with hard-coded structures, this package +lets you **build your own ID format** to perfectly suit your application's +needs. + +## Installation + +The package can be installed through Composer: + +```bash +composer require lizhineng/snowflake +``` + +**Requirements:** PHP 8.2 or higher + +## Quick Start + +```php +use Zhineng\Snowflake\Constant; +use Zhineng\Snowflake\Manager; +use Zhineng\Snowflake\Sequence; +use Zhineng\Snowflake\Structure; +use Zhineng\Snowflake\Timestamp; + +$structure = new Structure; +$structure->add(Sequence::make('sequence', 12)); +$structure->add(Constant::make('instance', 10)); +$structure->add(Timestamp::make('timestamp', 41)); + +$manager = new Manager; +$manager->structureUsing($structure); + +$id1 = $manager->nextId(); // e.g., 123456789012345678 +$id2 = $manager->nextId(); // e.g., 123456789012345679 +``` + +## Usage + +### Field Types + +#### Timestamp + +Timestamp field tracks milliseconds since epoch, defaulting to 41 bits +and the name `timestamp`: + +```php +Timestamp::make(); +``` + +Optionally set a custom epoch via the third parameter—accepts a `DateTime` +object or an integer (milliseconds since Unix epoch): + +```php +$epoch = new \DateTime('2026-01-01 00:00:00'); +Timestamp::make('timestamp', 41, $epoch); +``` + +#### Constant + +Fixed value field for static identifiers (e.g., datacenter ID, machine ID). +Value defaults to 0 if not specified. + +```php +// Machine ID with value 5 (10 bits) +Constant::make('machine_id', 10, 5); + +// Datacenter ID with value 2 (5 bits) +Constant::make('datacenter_id', 5, 2); +``` + +#### Sequence + +Auto-incrementing sequence that resets to 0 whenever any other field changes. +An `OverflowException` is thrown if the value exceeds its maximum. + +```php +// 12-bit sequence (range 0-4095) +Sequence::make('sequence', 12); +``` + +### Building Custom Structures + +Customize the ID format to suit your needs—the structure is built from +**right to left** (LSB to MSB): + +```php +$structure = new Structure; + +// Bit layout: [timestamp:41][instance:10][sequence:12] = 63 bits +$structure->add(Sequence::make('sequence', 12)); +$structure->add(Constant::make('instance', 10)); +$structure->add(Timestamp::make('timestamp', 41)); + +echo $structure->size(); // 63 +``` + +## Testing + +```bash +composer run test +``` + +## License + +MIT License. See [LICENSE](LICENSE) for details. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1776b5f --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "lizhineng/snowflake", + "description": "Build your own Snowflake.", + "type": "library", + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5", + "friendsofphp/php-cs-fixer": "^3.92", + "phpstan/phpstan": "^2.1" + }, + "license": "MIT", + "autoload": { + "psr-4": { + "Zhineng\\Snowflake\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Zhineng\\Snowflake\\Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "Zhineng Li", + "email": "im@zhineng.li" + } + ], + "minimum-stability": "stable", + "scripts": { + "test": "phpunit --testdox", + "format": "php-cs-fixer fix", + "lint": "phpstan analyse", + "all": [ + "@format", + "@lint", + "@test" + ] + }, + "config": { + "sort-packages": true + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..0c805cf --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + - tests + ignoreErrors: + - '#^Parameter \#1 \$name of class Zhineng\\Snowflake\\(Constant|Sequence|Timestamp) constructor expects string, mixed given\.$#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..377fe27 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Component.php b/src/Component.php new file mode 100644 index 0000000..67cf96a --- /dev/null +++ b/src/Component.php @@ -0,0 +1,33 @@ +value < 0) { + throw new \InvalidArgumentException('Field value must be non-negative.'); + } + + if ($this->value > $this->maxValue()) { + throw new \InvalidArgumentException(sprintf( + 'Field value %d exceeds maximum %d for %d bits.', + $this->value, + $this->maxValue(), + $this->bits + )); + } + } + + public function value(): int + { + return $this->value; + } +} diff --git a/src/Field.php b/src/Field.php new file mode 100644 index 0000000..9e11bad --- /dev/null +++ b/src/Field.php @@ -0,0 +1,58 @@ + 63) { + throw new \InvalidArgumentException('Bits must be between 1 and 63.'); + } + } + + /** + * The bit size. + */ + public function bits(): int + { + return $this->bits; + } + + /** + * The maximum value based on the number of bits. + */ + public function maxValue(): int + { + return (1 << $this->bits) - 1; + } + + /** + * Set the bit offset. + */ + public function setOffset(int $offset): static + { + $this->offset = $offset; + + return $this; + } + + /** + * The bit offset. + */ + public function offset(): int + { + return $this->offset; + } + + abstract public function value(): int; +} diff --git a/src/Makable.php b/src/Makable.php new file mode 100644 index 0000000..15352dc --- /dev/null +++ b/src/Makable.php @@ -0,0 +1,13 @@ +structure = $structure; + $this->lastCombination = 0; + + return $this; + } + + /** + * Generate the next Snowflake ID. + */ + public function nextId(): int + { + if (! isset($this->structure)) { + throw new \RuntimeException('ID structure is not defined.'); + } + + $id = 0; + $sequence = null; + + foreach ($this->structure->components() as $field) { + // Defer sequence until after detecting other-field changes + // so we can reset it if needed. + if ($field instanceof Sequence) { + $sequence = $field; + + continue; + } + + $current = $field->maxValue() & $field->value(); + $id |= $current << $field->offset(); + } + + // The ID variable now holds the combination of all fields except + // sequence. Reset the sequence field if any other field has changed. + if ($this->lastCombination !== $id) { + $sequence?->reset(); + } + + $this->lastCombination = $id; + + if ($sequence instanceof Sequence) { + $id |= ($sequence->maxValue() & $sequence->value()) << $sequence->offset(); + } + + return $id; + } +} diff --git a/src/Sequence.php b/src/Sequence.php new file mode 100644 index 0000000..d799e29 --- /dev/null +++ b/src/Sequence.php @@ -0,0 +1,42 @@ +next(); + } + + public function next(): int + { + $current = $this->value++; + + if ($current > $this->maxValue()) { + throw new \OverflowException(sprintf( + 'Sequence "%s" exceeded its maximum value of %d.', + $this->name, + $this->maxValue() + )); + } + + return $current; + } + + public function reset(): self + { + $this->value = 0; + + return $this; + } +} diff --git a/src/Structure.php b/src/Structure.php new file mode 100644 index 0000000..307397d --- /dev/null +++ b/src/Structure.php @@ -0,0 +1,62 @@ +sequenceCount > 1) { + throw new \LogicException('Only one sequence field is allowed in a structure.'); + } + + if ($this->currentOffset + $field->bits() > 63) { + throw new \OverflowException('Total structure size cannot exceed 63 bits.'); + } + + $this->fields[] = $field->setOffset($this->currentOffset); + $this->currentOffset += $field->bits(); + + return $this; + } + + /** + * The ID components. + * + * @return \Zhineng\Snowflake\Component[] + */ + public function components(): array + { + return $this->fields; + } + + /** + * The total size in bits. + */ + public function size(): int + { + return $this->currentOffset; + } +} diff --git a/src/Timestamp.php b/src/Timestamp.php new file mode 100644 index 0000000..a23ba13 --- /dev/null +++ b/src/Timestamp.php @@ -0,0 +1,38 @@ +epoch = $epoch instanceof \DateTime + ? $epoch->getTimestamp() * 1000 + : ($epoch ?? 0); + + if ($this->epoch < 0) { + throw new \InvalidArgumentException('Epoch must be non-negative.'); + } + } + + public function value(): int + { + $now = (int) floor(microtime(as_float: true) * 1000); + + return $now - $this->epoch; + } +} diff --git a/tests/ConstantTest.php b/tests/ConstantTest.php new file mode 100644 index 0000000..78d1be3 --- /dev/null +++ b/tests/ConstantTest.php @@ -0,0 +1,70 @@ +assertSame('machine_id', $field->name); + $this->assertSame(10, $field->bits); + $this->assertSame(0, $field->value); + } + + public function testMaxValueShouldBeCalculatedCorrectly(): void + { + $field = new Constant('machine_id', 10); + $maxValue = (1 << 10) - 1; + $this->assertSame($maxValue, $field->maxValue()); + } + + public function testInitialValueCanBeSet(): void + { + $field = new Constant('machine_id', 10, 2); + $this->assertSame(2, $field->value); + } + + public function testInitialValueMustBePositive(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Field value must be non-negative.'); + new Constant('machine_id', 10, -1); + } + + public function testInitialValueMustNotExceedMaxValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Field value 1024 exceeds maximum 1023 for 10 bits.'); + new Constant('machine_id', 10, 1024); + } + + public function testConstantHasMakeFactoryMethod(): void + { + $field = Constant::make('machine_id', 10, 5); + $this->assertSame('machine_id', $field->name); + $this->assertSame(10, $field->bits); + $this->assertSame(5, $field->value); + } + + public function testBitsMustBeAtLeastOne(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Bits must be between 1 and 63.'); + new Constant('machine_id', 0); + } + + public function testBitsMustNotExceed63(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Bits must be between 1 and 63.'); + new Constant('machine_id', 64); + } +} diff --git a/tests/ManagerTest.php b/tests/ManagerTest.php new file mode 100644 index 0000000..dc81a06 --- /dev/null +++ b/tests/ManagerTest.php @@ -0,0 +1,70 @@ +add(Sequence::make('sequence', 12)); + $struct->add(Constant::make('instance_id', 10)); + $struct->add(Timestamp::make()); + + $manager = new Manager; + $manager->structureUsing($struct); + $this->assertNotSame($manager->nextId(), $manager->nextId()); + } + + public function testSequenceShouldBeResetWhenAnyOtherFieldChanges(): void + { + $struct = new Structure; + $struct->add(Sequence::make('sequence', 12)); + $struct->add($field = new class ('test', 10) extends Field { + public int $value = 0; + + public function value(): int + { + return $this->value; + } + + public function setValue(int $value): void + { + $this->value = $value; + } + }); + + $manager = new Manager; + $manager->structureUsing($struct); + + $id1 = $manager->nextId(); + $id2 = $manager->nextId(); + $field->setValue(1); + $id3 = $manager->nextId(); + + $sequenceMask = (1 << 12) - 1; + $this->assertSame(0, $id1 & $sequenceMask); + $this->assertSame(1, $id2 & $sequenceMask); + $this->assertSame(0, $id3 & $sequenceMask); + } + + public function testExceptionShouldBeThrownWhenMissingStructure(): void + { + $manager = new Manager; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('ID structure is not defined.'); + $manager->nextId(); + } +} diff --git a/tests/SequenceTest.php b/tests/SequenceTest.php new file mode 100644 index 0000000..fc02d87 --- /dev/null +++ b/tests/SequenceTest.php @@ -0,0 +1,53 @@ +assertSame('sequence', $seq->name); + $this->assertSame(12, $seq->bits); + } + + public function testSequenceHasMakeFactoryMethod(): void + { + $seq = Sequence::make('sequence', 12); + $this->assertSame('sequence', $seq->name); + $this->assertSame(12, $seq->bits); + } + + public function testNextValueResolution(): void + { + $seq = new Sequence('sequence', 12); + $this->assertSame(0, $seq->next()); + $this->assertSame(1, $seq->next()); + $this->assertSame(2, $seq->next()); + } + + public function testExceptionShouldBeThrownWhenMaxValueExceeded(): void + { + $seq = new Sequence('sequence', 1); // Max value is 1 + $this->assertSame(0, $seq->next()); + $this->assertSame(1, $seq->next()); + $this->expectException(\OverflowException::class); + $this->expectExceptionMessage('Sequence "sequence" exceeded its maximum value of 1.'); + $seq->next(); + } + + public function testSequenceCanBeReset(): void + { + $seq = new Sequence('sequence', 12); + $this->assertSame(0, $seq->next()); + $this->assertSame(1, $seq->next()); + $this->assertSame(0, $seq->reset()->next()); + } +} diff --git a/tests/StructureTest.php b/tests/StructureTest.php new file mode 100644 index 0000000..80d94fe --- /dev/null +++ b/tests/StructureTest.php @@ -0,0 +1,70 @@ +add(Constant::make('machine_id', 10)); + $this->assertCount(1, $struct->components()); + } + + public function testAddMultipleFields(): void + { + $struct = new Structure; + $struct->add(Constant::make('machine_id', 5)); + $struct->add(Constant::make('datacenter_id', 5)); + $this->assertCount(2, $struct->components()); + } + + public function testOffsetsAreCalculatedCorrectly(): void + { + $struct = new Structure; + $struct->add($machineId = Constant::make('machine_id', 5)); + $struct->add($dataCenterId = Constant::make('datacenter_id', 5)); + $this->assertSame(0, $machineId->offset()); + $this->assertSame(5, $dataCenterId->offset()); + } + + public function testSizeResolution(): void + { + $struct = new Structure; + $struct->add(Constant::make('machine_id', 5)); + $struct->add(Constant::make('datacenter_id', 5)); + $this->assertSame(10, $struct->size()); + } + + public function testAtMostOneSequenceIsAllowed(): void + { + $struct = new Structure; + $struct->add(Sequence::make('seq1', 12)); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Only one sequence field is allowed in a structure.'); + + $struct->add(Sequence::make('seq2', 12)); + } + + public function testTotalSizeCannotExceed63Bits(): void + { + $struct = new Structure; + $struct->add(Constant::make('field1', 32)); + $struct->add(Constant::make('field2', 31)); + + $this->expectException(\OverflowException::class); + $this->expectExceptionMessage('Total structure size cannot exceed 63 bits.'); + + $struct->add(Constant::make('field3', 1)); + } +} diff --git a/tests/TimestampTest.php b/tests/TimestampTest.php new file mode 100644 index 0000000..d157adf --- /dev/null +++ b/tests/TimestampTest.php @@ -0,0 +1,67 @@ +assertSame('timestamp', $field->name); + $this->assertSame(41, $field->bits); + } + + public function testTimestampHasDefaultParameterValues(): void + { + $field = new Timestamp; + $this->assertSame('timestamp', $field->name); + $this->assertSame(41, $field->bits); + } + + public function testTimestampHasMakeFactoryMethod(): void + { + $field = Timestamp::make('timestamp', 41); + $this->assertSame('timestamp', $field->name); + $this->assertSame(41, $field->bits); + } + + public function testTimestampHasDynamicValue(): void + { + $field = new Timestamp; + $value1 = $field->value(); + usleep(1000); // Sleep for 1 millisecond + $value2 = $field->value(); + $this->assertGreaterThan($value1, $value2); + } + + public function testEpochCanBeCustomized(): void + { + $epoch = new \DateTime('2026-01-01 00:00:00'); + $field = new Timestamp('timestamp', 41, $epoch); + $now = (int) floor(microtime(as_float: true) * 1000); + $this->assertSame($now - $epoch->getTimestamp() * 1000, $field->value()); + } + + public function testEpochCanBeSetAsInteger(): void + { + $epoch = new \DateTime('2026-01-01 00:00:00'); + $timestampInMillis = $epoch->getTimestamp() * 1000; + $field = new Timestamp('timestamp', 41, $timestampInMillis); + $now = (int) floor(microtime(as_float: true) * 1000); + $this->assertSame($now - $timestampInMillis, $field->value()); + } + + public function testEpochMustBeNonNegative(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Epoch must be non-negative.'); + new Timestamp('timestamp', 41, -1); + } +} -- cgit v1.2.3