diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..f3ba4db --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1,4 @@ +src_dir: src +coverage_clover: build/logs/clover.xml +json_path: build/logs/coveralls-upload.json +service_name: travis-ci \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ce09ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/vendor +composer.phar +composer.lock +*.iml +.idea +.env.*.php +.env.php +.DS_Store +.vagrant +Thumbs.db diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..af0b37b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +language: php + +php: + - 5.4 + - 5.5 + - 5.6 + +matrix: + allow_failures: + - php: 5.6 + +before_script: + - composer self-update + - composer install + +script: + - mkdir -p build/logs + - phpunit --coverage-clover build/logs/clover.xml -c test/ + +after_script: + - php vendor/bin/coveralls -v \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9888268 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014 ada-u + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e05486 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# PHP IdGen + +### 64bit ID Generator for PHP + +`PHP IdGen` is an implementation of twitter Snowflake concept. This provides generating IDs based on time in a distributed environment. + +### ID Specification + +The IDs consist of four elements: + + - timestamp + - region id + - server id + - sequence + +You can specify any bit length to each element. + +## Usage + +### Prerequisites + + - PHP 5.4 or later + +### Installation + +### Sample + +```php + +// example: +// 41 bit for timestamp +// 5 bit for region id +// 5 bit for server id +// 12 bit for sequence per milliseconds +// 1414334507356 - service start epoch (unix timestamp) +$config = new IdValueConfig(41, 5, 5, 12, 1414334507356); + +$service = new IdGenService($config); + +$worker = $service->createIdWorker(new RegionId(1), new ServerId(1)); + +$id = $worker->generate(); +// string(10) "4194439168" + +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b3dc477 --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "ada-u/php-idgen", + "type": "library", + "description": "Id Generator for PHP", + "keywords": [ "php-idgen", "id", "generator" ], + "homepage": "https://github.com/ada-u/php-idgen", + "authors": [ + { "name": "ada-u", "email": "ada-u@ada-u.com" } + ], + "license": "MIT", + "require-dev": { + "phpunit/phpunit": "4.4.*", + "mockery/mockery": "0.9.*", + "satooshi/php-coveralls": "dev-master" + }, + "require": { + "php": ">=5.4.0" + }, + "autoload": { + "psr-4": { + "Adachi\\IdGen\\": "src/IdGen/" + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2344527 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + ./tests/ + + + + + + \ No newline at end of file diff --git a/src/IdGen/Foundation/IdValue/Element/RegionId.php b/src/IdGen/Foundation/IdValue/Element/RegionId.php new file mode 100644 index 0000000..ecc077a --- /dev/null +++ b/src/IdGen/Foundation/IdValue/Element/RegionId.php @@ -0,0 +1,41 @@ +value = $value; + } + + /** + * @param RegionId $target + * @return bool + */ + public function equals(RegionId $target) + { + return $this->value === $target->value; + } + + /** + * @return string + */ + public function __toString() + { + return (String) $this->value; + } +} diff --git a/src/IdGen/Foundation/IdValue/Element/ServerId.php b/src/IdGen/Foundation/IdValue/Element/ServerId.php new file mode 100644 index 0000000..f4328a8 --- /dev/null +++ b/src/IdGen/Foundation/IdValue/Element/ServerId.php @@ -0,0 +1,41 @@ +value = $value; + } + + /** + * @param ServerId $target + * @return bool + */ + public function equals(ServerId $target) + { + return $this->value === $target->value; + } + + /** + * @return string + */ + public function __toString() + { + return (String) $this->value; + } +} diff --git a/src/IdGen/Foundation/IdValue/Element/Timestamp.php b/src/IdGen/Foundation/IdValue/Element/Timestamp.php new file mode 100644 index 0000000..54a563b --- /dev/null +++ b/src/IdGen/Foundation/IdValue/Element/Timestamp.php @@ -0,0 +1,41 @@ +value = $value; + } + + /** + * @param Timestamp $target + * @return bool + */ + public function equals(Timestamp $target) + { + return $this->value === $target->value; + } + + /** + * @return string + */ + public function __toString() + { + return (String) $this->value; + } +} \ No newline at end of file diff --git a/src/IdGen/Foundation/IdValue/IdValue.php b/src/IdGen/Foundation/IdValue/IdValue.php new file mode 100644 index 0000000..d1c0e7d --- /dev/null +++ b/src/IdGen/Foundation/IdValue/IdValue.php @@ -0,0 +1,81 @@ +timestamp = $timestamp; + $this->regionId = $regionId; + $this->serverId = $serverId; + $this->sequence = $sequence; + $this->value = $value; + } + + /** + * @return int + */ + public function toInt() + { + return $this->value; + } + + /** + * @return string + */ + public function asString() + { + return (string) $this->value; + } + + /** + * @return string + */ + public function __toString() + { + return $this->asString(); + } +} \ No newline at end of file diff --git a/src/IdGen/Foundation/IdValue/IdValueConfig.php b/src/IdGen/Foundation/IdValue/IdValueConfig.php new file mode 100644 index 0000000..d8af637 --- /dev/null +++ b/src/IdGen/Foundation/IdValue/IdValueConfig.php @@ -0,0 +1,122 @@ +timestampBitLength = $timestampBitLength; + $this->regionIdBitLength = $regionIdBitLength; + $this->serverIdBitLength = $serverIdBitLength; + $this->sequenceBitLength = $sequenceBitLength; + $this->epoch = $epochOffset; + + $this->maxTimestamp = -1 ^ (-1 << $this->timestampBitLength); + $this->maxRegionId = -1 ^ (-1 << $this->regionIdBitLength); + $this->maxServerId = -1 ^ (-1 << $this->serverIdBitLength); + $this->maxSequence = -1 ^ (-1 << $this->sequenceBitLength); + + $this->serverIdBitShift = $this->sequenceBitLength; + $this->regionIdBitShift = $this->serverIdBitShift + $this->serverIdBitLength; + $this->timestampBitShift = $this->regionIdBitShift + $this->regionIdBitLength; + + $this->timestampMask = -1 ^ (-1 << ($this->timestampBitLength + $this->timestampBitShift)); + $this->regionIdMask = -1 ^ (-1 << ($this->regionIdBitLength + $this->regionIdBitShift)); + $this->serverIdMask = -1 ^ (-1 << ($this->serverIdBitLength + $this->serverIdBitShift)); + $this->sequenceMask = -1 ^ (-1 << $this->sequenceBitLength); + } + +} \ No newline at end of file diff --git a/src/IdGen/Foundation/IdWorker/IdWorker.php b/src/IdGen/Foundation/IdWorker/IdWorker.php new file mode 100644 index 0000000..7deecec --- /dev/null +++ b/src/IdGen/Foundation/IdWorker/IdWorker.php @@ -0,0 +1,189 @@ +config = $config; + $this->regionId = $regionId; + $this->serverId = $serverId; + $this->semaphoreId = $semaphoreId; + } + + + /** + * @param IdValue $value + * @return int + * @throws \RuntimeException + */ + public function write(IdValue $value) + { + if ($value->timestamp->value <= $this->config->maxTimestamp && + $value->regionId->value <= $this->config->maxRegionId && + $value->serverId->value <= $this->config->maxServerId && + $value->sequence <= $this->config->maxSequence) + { + return $this->calculateValue($value->timestamp, $value->regionId, $value->serverId, $value->sequence); + } + else + { + throw new \RuntimeException("IdValue Specification is not satisfied"); + } + } + + /** + * @param $value + * @return IdValue + * @throws \RuntimeException + */ + public function read($value) + { + $timestamp = new Timestamp(($value & $this->config->timestampMask) >> $this->config->timestampBitShift); + $regionId = new RegionId(($value & $this->config->regionIdMask) >> $this->config->regionIdBitShift); + $serverId = new ServerId(($value & $this->config->serverIdMask) >> $this->config->serverIdBitShift); + $sequence = ($value & $this->config->sequenceMask); + + if ($timestamp->value <= $this->config->maxTimestamp && + $regionId->value <= $this->config->maxRegionId && + $serverId->value <= $this->config->maxServerId && + $sequence <= $this->config->maxSequence) + { + return new IdValue($timestamp, $regionId, $serverId, $sequence, $this->calculateValue($timestamp, $regionId, $serverId, $sequence)); + } + else + { + throw new \RuntimeException("IdValue Specification is not satisfied"); + } + } + + /** + * @return IdValue + */ + public function generate() + { + $timestamp = $this->generateTimestamp(); + + // Acquire semaphore + $semaphore = sem_get($this->semaphoreId); + sem_acquire($semaphore); + + // Attach shared memory + $memory = shm_attach(self::SHM_KEY); + + $sequence = 0; + + if ( ! is_null($this->lastTimestamp) && $timestamp->equals($this->lastTimestamp)) + { + // Increment sequence + $sequence = (shm_get_var($memory, self::SHM_SEQUENCE) + 1) & $this->config->sequenceMask; + shm_put_var($memory, self::SHM_SEQUENCE, $sequence); + + if ($sequence === 0) + { + usleep(1000); + $timestamp = $this->generateTimestamp(); + } + } + else + { + // Reset sequence if timestamp is different from last one. + $sequence = 0; + shm_put_var($memory, self::SHM_SEQUENCE, $sequence); + } + + // Detach shared memory + shm_detach($memory); + + // Release semaphore + sem_release($semaphore); + + // Update lastTimestamp + $this->lastTimestamp = $timestamp; + + return new IdValue($timestamp, $this->regionId, $this->serverId, $sequence, $this->calculateValue($timestamp, $this->regionId, $this->serverId, $sequence)); + } + + /** + * @todo make this protected, but it'll be difficult to test. any ideas? + * @return Timestamp + */ + public function generateTimestamp() + { + $stamp = (int) round(microtime(true) * 1000); + return new Timestamp($stamp - $this->config->epoch); + } + + /** + * @param Timestamp $timestamp + * @param RegionId $regionId + * @param ServerId $serverId + * @param $sequence + * @return int + */ + protected function calculateValue(Timestamp $timestamp, RegionId $regionId, ServerId $serverId, $sequence) + { + return ($timestamp->value << $this->config->timestampBitShift) | + ($regionId->value << $this->config->regionIdBitShift) | + ($serverId->value << $this->config->serverIdBitShift) | + ($sequence); + } +} \ No newline at end of file diff --git a/src/IdGen/Service/IdGenService.php b/src/IdGen/Service/IdGenService.php new file mode 100644 index 0000000..28e7fbe --- /dev/null +++ b/src/IdGen/Service/IdGenService.php @@ -0,0 +1,39 @@ +config = $config; + } + + /** + * @param RegionId $regionId + * @param ServerId $serverId + * @return IdWorker + */ + public function createIdWorker(RegionId $regionId, ServerId $serverId) + { + return new IdWorker($this->config, $regionId, $serverId); + } +} \ No newline at end of file diff --git a/tests/Tests/IdGen/Foundation/IdWorker/IdWorkerTest.php b/tests/Tests/IdGen/Foundation/IdWorker/IdWorkerTest.php new file mode 100644 index 0000000..b1bbb9b --- /dev/null +++ b/tests/Tests/IdGen/Foundation/IdWorker/IdWorkerTest.php @@ -0,0 +1,47 @@ +idWorker = Mockery::mock('\Adachi\IdGen\Foundation\IdWorker\IdWorker[generateTimestamp]', [$config, new RegionId(1), new ServerId(1)]); + $this->idWorker->shouldReceive('generateTimestamp') + ->andReturn(new Timestamp(1000)); + } + + /** + * @test + */ + public function createIdValue() + { + $id = $this->idWorker->generate(); + // Timestamp(1000) | RegionId(1) | ServerId(1) | Sequence(0) + // 1111101000 00001 00001 000000000000 + $this->assertSame(sprintf('%b', $this->idWorker->write($id)), '11111010000000100001000000000000'); + } + + /** + * @test + */ + public function convertIdValueToIntValue() + { + $id = $this->idWorker->generate(); + $intValue = $this->idWorker->write($id); + $this->assertEquals($id, $this->idWorker->read($intValue)); + } +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..c0d757a --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,3 @@ +set('Tests', __DIR__); \ No newline at end of file